From c850af74097e48834c579eb03c172a5c423ae3b8 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Mon, 4 Nov 2024 04:47:10 -0500 Subject: [PATCH] feat: allow users to generate csv tables for org members (#17458) * add downloadAsCsv util * add sanitizeValue util * write logic * refactor * add tests --- apps/web/test/lib/generateCsv.test.ts | 202 ++++++++++++++++++ .../components/UserTable/UserListTable.tsx | 21 ++ packages/lib/csvUtils.ts | 88 ++++++++ 3 files changed, 311 insertions(+) create mode 100644 apps/web/test/lib/generateCsv.test.ts create mode 100644 packages/lib/csvUtils.ts diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts new file mode 100644 index 0000000000..4da30d33c7 --- /dev/null +++ b/apps/web/test/lib/generateCsv.test.ts @@ -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 { + 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; +} + +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; + + 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,,," + `); + }); +}); diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 971f6568a4..ae3fd9a2fa 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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 ( <>
setDebouncedSearchTerm(value)} /> + handleDownload()} + data-testid="export-members-button"> + {t("download")} + {/* 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 */} diff --git a/packages/lib/csvUtils.ts b/packages/lib/csvUtils.ts new file mode 100644 index 0000000000..c413fe2d4b --- /dev/null +++ b/packages/lib/csvUtils.ts @@ -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, + 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); + + 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"); +};