feat: allow users to generate csv tables for org members (#17458)
* add downloadAsCsv util * add sanitizeValue util * write logic * refactor * add tests
This commit is contained in:
1 parent
06dccd7a73
commit
c850af7409
3 files changed
+311
No files matched your search
@@ -0,0 +1,202 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { UserTableUser } from "@calcom/features/users/components/UserTable/types";
|
||||
import { generateCsvRawForUsersTable } from "@calcom/lib/csvUtils";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
function createMockTable(data: UserTableUser[]): Table<UserTableUser> {
|
||||
return {
|
||||
getHeaderGroups: vi.fn().mockReturnValue([
|
||||
{
|
||||
headers: [
|
||||
{
|
||||
id: "select",
|
||||
column: { columnDef: { header: "" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "member",
|
||||
column: { columnDef: { header: "Members" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
column: { columnDef: { header: "Role" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
column: { columnDef: { header: "Teams" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "attr1",
|
||||
column: { columnDef: { header: () => "Attribute 1" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "attr2",
|
||||
column: { columnDef: { header: () => "Attribute 2" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
column: { columnDef: { header: "" } },
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
getRowModel: vi.fn().mockReturnValue({ rows: data.map((item) => ({ original: item })) }),
|
||||
} as unknown as Table<UserTableUser>;
|
||||
}
|
||||
|
||||
describe("generate Csv for Org Users Table", () => {
|
||||
const mockAttributeIds = ["attr1", "attr2"];
|
||||
const HEADER_IDS_TO_EXCLUDE = ["select", "actions"];
|
||||
const mockUser: UserTableUser = {
|
||||
id: 1,
|
||||
username: "testuser",
|
||||
email: "test@example.com",
|
||||
timeZone: "UTC",
|
||||
role: "MEMBER" as MembershipRole,
|
||||
avatarUrl: null,
|
||||
accepted: true,
|
||||
disableImpersonation: false,
|
||||
completedOnboarding: true,
|
||||
teams: [],
|
||||
attributes: [],
|
||||
};
|
||||
|
||||
it("should return null if no headers", () => {
|
||||
const mockTableNoHeaders = {
|
||||
getHeaderGroups: vi.fn().mockReturnValue([]),
|
||||
getRowModel: vi.fn().mockReturnValue({ rows: [] }),
|
||||
} as unknown as Table<UserTableUser>;
|
||||
|
||||
expect(generateCsvRawForUsersTable(mockTableNoHeaders, mockAttributeIds, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("should generate correct CSV headers", () => {
|
||||
const mockTable = createMockTable([]);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
const headers = csv?.split("\n")[0];
|
||||
expect(headers).toBe("Members,Role,Teams,Attribute 1,Attribute 2");
|
||||
});
|
||||
|
||||
it("should handle user with single attribute value", () => {
|
||||
const mockData: UserTableUser[] = [
|
||||
{
|
||||
...mockUser,
|
||||
teams: [{ id: 1, name: "Team1", slug: "team1" }],
|
||||
attributes: [{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1" }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockTable = createMockTable(mockData);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
|
||||
expect(csv).toMatchInlineSnapshot(`
|
||||
"Members,Role,Teams,Attribute 1,Attribute 2
|
||||
test@example.com,MEMBER,Team1,value1,"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle user with multiple attribute values for same attribute", () => {
|
||||
const mockData: UserTableUser[] = [
|
||||
{
|
||||
...mockUser,
|
||||
teams: [{ id: 1, name: "Team1", slug: "team1" }],
|
||||
attributes: [
|
||||
{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1" },
|
||||
{ id: "2", attributeId: "attr1", value: "value2", slug: "slug1" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockTable = createMockTable(mockData);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
|
||||
expect(csv).toMatchInlineSnapshot(`
|
||||
"Members,Role,Teams,Attribute 1,Attribute 2
|
||||
test@example.com,MEMBER,Team1,"value1,value2","
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle user with multiple teams", () => {
|
||||
const mockData: UserTableUser[] = [
|
||||
{
|
||||
...mockUser,
|
||||
teams: [
|
||||
{ id: 1, name: "Team1", slug: "team1" },
|
||||
{ id: 2, name: "Team2", slug: "team2" },
|
||||
],
|
||||
attributes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockTable = createMockTable(mockData);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
|
||||
expect(csv).toMatchInlineSnapshot(`
|
||||
"Members,Role,Teams,Attribute 1,Attribute 2
|
||||
test@example.com,MEMBER,"Team1,Team2",,"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle values that need sanitization", () => {
|
||||
const mockData: UserTableUser[] = [
|
||||
{
|
||||
...mockUser,
|
||||
teams: [{ id: 1, name: "Team,1", slug: "team1" }],
|
||||
attributes: [{ id: "1", attributeId: "attr1", value: "value,1", slug: "slug1" }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockTable = createMockTable(mockData);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
|
||||
expect(csv).toMatchInlineSnapshot(`
|
||||
"Members,Role,Teams,Attribute 1,Attribute 2
|
||||
test@example.com,MEMBER,"Team,1","value,1","
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle all membership roles", () => {
|
||||
const roles: MembershipRole[] = ["OWNER", "ADMIN", "MEMBER"];
|
||||
const mockData: UserTableUser[] = roles.map((role) => ({
|
||||
...mockUser,
|
||||
role,
|
||||
email: `${role.toLowerCase()}@example.com`,
|
||||
}));
|
||||
|
||||
const mockTable = createMockTable(mockData);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
|
||||
expect(csv).toMatchInlineSnapshot(`
|
||||
"Members,Role,Teams,Attribute 1,Attribute 2
|
||||
owner@example.com,OWNER,,,
|
||||
admin@example.com,ADMIN,,,
|
||||
member@example.com,MEMBER,,,"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should handle users without teams and attributes", () => {
|
||||
const mockData: UserTableUser[] = [
|
||||
{
|
||||
...mockUser,
|
||||
username: null,
|
||||
avatarUrl: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockTable = createMockTable(mockData);
|
||||
const csv = generateCsvRawForUsersTable(mockTable, mockAttributeIds, HEADER_IDS_TO_EXCLUDE);
|
||||
|
||||
expect(csv).toMatchInlineSnapshot(`
|
||||
"Members,Role,Teams,Attribute 1,Attribute 2
|
||||
test@example.com,MEMBER,,,"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { useMemo, useReducer, useRef, useState } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { downloadAsCsv, generateCsvRawForUsersTable } from "@calcom/lib/csvUtils";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
@@ -400,6 +401,18 @@ export function UserListTable() {
|
||||
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
|
||||
const handleDownload = () => {
|
||||
const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? [];
|
||||
const HEADER_IDS_TO_EXCLUDE = ["select", "actions"];
|
||||
const csvRaw = generateCsvRawForUsersTable(table, ATTRIBUTE_IDS, HEADER_IDS_TO_EXCLUDE);
|
||||
if (!csvRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = `${org?.name ?? "Org"}_${new Date().toISOString().split("T")[0]}.csv`; // e.g., ${OrgName}_2024-11-04.csv
|
||||
downloadAsCsv(csvRaw, filename);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
@@ -412,6 +425,14 @@ export function UserListTable() {
|
||||
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
|
||||
<div className="flex w-full gap-2">
|
||||
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="secondary"
|
||||
StartIcon="file-down"
|
||||
onClick={() => handleDownload()}
|
||||
data-testid="export-members-button">
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
{/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */}
|
||||
<DataTableFilters.FilterButton table={table} omit={["member"]} />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
|
||||
import type { UserTableUser } from "@calcom/features/users/components/UserTable/types";
|
||||
|
||||
export const downloadAsCsv = (csvRaw: string, filename: string) => {
|
||||
// Create a Blob from the text data
|
||||
const blob = new Blob([csvRaw], { type: "text/plain" });
|
||||
|
||||
// Create an Object URL for the Blob
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create a download link
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename; // Specify the filename
|
||||
|
||||
// Simulate a click event to trigger the download
|
||||
a.click();
|
||||
|
||||
// Release the Object URL to free up memory
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const sanitizeValue = (value: string) => {
|
||||
// handling three cases:
|
||||
// 1. quotes - we need to double quotes for CSV
|
||||
// 2. commas
|
||||
// 3. newlines
|
||||
if (value.includes('"')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
if (value.includes(",") || value.includes("\n")) {
|
||||
return `"${value}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const generateCsvRawForUsersTable = (
|
||||
table: Table<UserTableUser>,
|
||||
ATTRIBUTE_IDS: string[],
|
||||
HEADER_IDS_TO_EXCLUDE: string[]
|
||||
): string | null => {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
if (!headerGroups.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Header formation
|
||||
const { headers } = headerGroups[0];
|
||||
const filteredHeaders = headers.filter((header) => !HEADER_IDS_TO_EXCLUDE.includes(header.id));
|
||||
const headerNames = filteredHeaders.map((header) => {
|
||||
const h = header.column.columnDef.header;
|
||||
if (typeof h === "string") {
|
||||
return sanitizeValue(h);
|
||||
}
|
||||
if (typeof h === "function") {
|
||||
return sanitizeValue(h(header.getContext()));
|
||||
}
|
||||
return "Unknown";
|
||||
});
|
||||
|
||||
// Body formation
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const csvRows = rows.map((row) => {
|
||||
const { email, role, teams, attributes } = row.original;
|
||||
|
||||
// Create a map of attributeId to array of values
|
||||
const attributeMap = attributes.reduce((acc, attr) => {
|
||||
if (!acc[attr.attributeId]) {
|
||||
acc[attr.attributeId] = [];
|
||||
}
|
||||
acc[attr.attributeId].push(attr.value);
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
return [
|
||||
email,
|
||||
role,
|
||||
sanitizeValue(teams.map((team) => team.name).join(",")),
|
||||
...ATTRIBUTE_IDS.map((attrId) => {
|
||||
return attributeMap[attrId] ? sanitizeValue(attributeMap[attrId].join(",")) : "";
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
return [headerNames.join(","), ...csvRows.map((row) => row.join(","))].join("\n");
|
||||
};
|
||||
Reference in new issue
Block a user