From f9afca2939a72922bfb39c1b00d242c6a37c4136 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Sun, 17 Nov 2024 05:53:43 -0500 Subject: [PATCH] feat: Add link column to CSV file for org members (#17695) * improve spacing * feat: Add link column to CSV file for org members * update tests --- apps/web/test/lib/generateCsv.test.ts | 85 ++++++++++--------- .../components/UserTable/UserListTable.tsx | 18 ++-- packages/lib/csvUtils.ts | 55 ++++++++---- 3 files changed, 96 insertions(+), 62 deletions(-) diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts index 742d32d403..f19aacd04b 100644 --- a/apps/web/test/lib/generateCsv.test.ts +++ b/apps/web/test/lib/generateCsv.test.ts @@ -1,8 +1,8 @@ import type { Table } from "@tanstack/react-table"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import type { UserTableUser } from "@calcom/features/users/components/UserTable/types"; -import { generateCsvRaw, generateHeaderFromReactTable } from "@calcom/lib/csvUtils"; +import { generateCsvRawForMembersTable, generateHeaderFromReactTable } from "@calcom/lib/csvUtils"; import type { MembershipRole } from "@calcom/prisma/enums"; function createMockTable(data: UserTableUser[]): Table { @@ -53,8 +53,19 @@ function createMockTable(data: UserTableUser[]): Table { } describe("generate Csv for Org Users Table", () => { + beforeAll(() => { + vi.stubGlobal("window", { + location: { + origin: "https://acme.cal.com", + }, + }); + }); + + afterAll(() => { + vi.unstubAllGlobals(); + }); + const mockAttributeIds = ["attr1", "attr2"]; - const HEADER_IDS_TO_EXCLUDE = ["select", "actions"]; const mockUser: UserTableUser = { id: 1, username: "testuser", @@ -69,22 +80,19 @@ describe("generate Csv for Org Users Table", () => { attributes: [], }; - it("should return null if no headers", () => { - const mockTableNoHeaders = { - getHeaderGroups: vi.fn().mockReturnValue([]), - } as unknown as Table; - expect(generateCsvRaw([], [], mockAttributeIds)).toBeNull(); + it("should throw if no headers", () => { + expect(() => generateCsvRawForMembersTable([], [], mockAttributeIds)).toThrow(); }); it("should generate correct CSV headers", () => { const mockTable = createMockTable([]); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], [], mockAttributeIds ); const headers = csv?.split("\n")[0]; - expect(headers).toBe("Members,Role,Teams,Attribute 1,Attribute 2"); + expect(headers).toBe("Members,Link,Role,Teams,Attribute 1,Attribute 2"); }); it("should handle user with single attribute value", () => { @@ -97,15 +105,15 @@ describe("generate Csv for Org Users Table", () => { ]; const mockTable = createMockTable(mockData); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], mockData, mockAttributeIds ); expect(csv).toMatchInlineSnapshot(` - "Members,Role,Teams,Attribute 1,Attribute 2 - test@example.com,MEMBER,Team1,value1," + "Members,Link,Role,Teams,Attribute 1,Attribute 2 + test@example.com,https://acme.cal.com/testuser,MEMBER,Team1,value1," `); }); @@ -122,15 +130,15 @@ describe("generate Csv for Org Users Table", () => { ]; const mockTable = createMockTable(mockData); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], mockData, mockAttributeIds ); expect(csv).toMatchInlineSnapshot(` - "Members,Role,Teams,Attribute 1,Attribute 2 - test@example.com,MEMBER,Team1,"value1,value2"," + "Members,Link,Role,Teams,Attribute 1,Attribute 2 + test@example.com,https://acme.cal.com/testuser,MEMBER,Team1,"value1,value2"," `); }); @@ -147,15 +155,15 @@ describe("generate Csv for Org Users Table", () => { ]; const mockTable = createMockTable(mockData); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], mockData, mockAttributeIds ); expect(csv).toMatchInlineSnapshot(` - "Members,Role,Teams,Attribute 1,Attribute 2 - test@example.com,MEMBER,"Team1,Team2",," + "Members,Link,Role,Teams,Attribute 1,Attribute 2 + test@example.com,https://acme.cal.com/testuser,MEMBER,"Team1,Team2",," `); }); @@ -169,15 +177,15 @@ describe("generate Csv for Org Users Table", () => { ]; const mockTable = createMockTable(mockData); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], mockData, mockAttributeIds ); expect(csv).toMatchInlineSnapshot(` - "Members,Role,Teams,Attribute 1,Attribute 2 - test@example.com,MEMBER,"Team,1","value,1"," + "Members,Link,Role,Teams,Attribute 1,Attribute 2 + test@example.com,https://acme.cal.com/testuser,MEMBER,"Team,1","value,1"," `); }); @@ -185,22 +193,23 @@ describe("generate Csv for Org Users Table", () => { const roles: MembershipRole[] = ["OWNER", "ADMIN", "MEMBER"]; const mockData: UserTableUser[] = roles.map((role) => ({ ...mockUser, + username: role.toLowerCase(), role, email: `${role.toLowerCase()}@example.com`, })); const mockTable = createMockTable(mockData); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], mockData, mockAttributeIds ); expect(csv).toMatchInlineSnapshot(` - "Members,Role,Teams,Attribute 1,Attribute 2 - owner@example.com,OWNER,,, - admin@example.com,ADMIN,,, - member@example.com,MEMBER,,," + "Members,Link,Role,Teams,Attribute 1,Attribute 2 + owner@example.com,https://acme.cal.com/owner,OWNER,,, + admin@example.com,https://acme.cal.com/admin,ADMIN,,, + member@example.com,https://acme.cal.com/member,MEMBER,,," `); }); @@ -208,21 +217,21 @@ describe("generate Csv for Org Users Table", () => { const mockData: UserTableUser[] = [ { ...mockUser, - username: null, + username: "testuser", avatarUrl: null, }, ]; const mockTable = createMockTable(mockData); - const csv = generateCsvRaw( - generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [], + const csv = generateCsvRawForMembersTable( + generateHeaderFromReactTable(mockTable) ?? [], mockData, mockAttributeIds ); expect(csv).toMatchInlineSnapshot(` - "Members,Role,Teams,Attribute 1,Attribute 2 - test@example.com,MEMBER,,," + "Members,Link,Role,Teams,Attribute 1,Attribute 2 + test@example.com,https://acme.cal.com/testuser,MEMBER,,," `); }); }); diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index d418c6f848..3f485bb145 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -15,7 +15,11 @@ 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, generateCsvRaw, generateHeaderFromReactTable } from "@calcom/lib/csvUtils"; +import { + downloadAsCsv, + generateCsvRawForMembersTable, + generateHeaderFromReactTable, +} from "@calcom/lib/csvUtils"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; @@ -418,9 +422,11 @@ export function UserListTable() { const handleDownload = async () => { try { + if (!org?.slug || !org?.name) { + throw new Error("Org slug or name is missing."); + } setIsDownloading(true); - const HEADER_IDS_TO_EXCLUDE = ["select", "actions"]; - const headers = generateHeaderFromReactTable(table, HEADER_IDS_TO_EXCLUDE); + const headers = generateHeaderFromReactTable(table); if (!headers || !headers.length) { throw new Error("Header is missing."); } @@ -443,12 +449,12 @@ export function UserListTable() { } const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? []; - const csvRaw = generateCsvRaw(headers, allMembers as UserTableUser[], ATTRIBUTE_IDS); + const csvRaw = generateCsvRawForMembersTable(headers, allMembers as UserTableUser[], ATTRIBUTE_IDS); if (!csvRaw) { throw new Error("Generating CSV file failed."); } - const filename = `${org?.name ?? "Org"}_${new Date().toISOString().split("T")[0]}.csv`; + const filename = `${org.name}_${new Date().toISOString().split("T")[0]}.csv`; downloadAsCsv(csvRaw, filename); } catch (error) { showToast(`Error: ${error}`, "error"); @@ -475,7 +481,7 @@ export function UserListTable() { className="sm:max-w-64 max-w-full" /> -
+
{ return value; }; -export const generateHeaderFromReactTable = ( - table: Table, - HEADER_IDS_TO_EXCLUDE?: string[] -): string[] | null => { +export const generateHeaderFromReactTable = (table: Table): string[] | null => { const headerGroups = table.getHeaderGroups(); if (!headerGroups.length) { return null; } const { headers } = headerGroups[0]; - const filteredHeaders = HEADER_IDS_TO_EXCLUDE - ? headers.filter((header) => !HEADER_IDS_TO_EXCLUDE.includes(header.id)) - : headers; + const HEADER_IDS_TO_EXCLUDE = ["select", "actions"]; // these columns only make sense in web page + 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") { @@ -86,20 +82,38 @@ export const generateHeaderFromReactTable = ( return "Unknown"; }); + // Add "Link" column (member's public page) + const MEMBERS_COLUMN = "Members"; + const LINK_COLUMN = "Link"; + const memberIndex = headerNames.findIndex((name) => name === MEMBERS_COLUMN); + if (memberIndex > -1) { + headerNames.splice(memberIndex + 1, 0, LINK_COLUMN); + } + return headerNames; }; -export const generateCsvRaw = ( +export const generateCsvRawForMembersTable = ( headers: string[], rows: UserTableUser[], ATTRIBUTE_IDS: string[] -): string | null => { +): string => { if (!headers.length) { - return null; + throw new Error("The header is empty."); } + + const REQUIRED_HEADERS = ["Members", "Link", "Role", "Teams"] as const; + // Validate required headers are present and in correct order + const firstFourHeaders = headers.slice(0, REQUIRED_HEADERS.length); + if (!REQUIRED_HEADERS.every((header, index) => header === firstFourHeaders[index])) { + throw new Error( + `Invalid headers structure. Expected headers to start with: ${JSON.stringify(REQUIRED_HEADERS)}` + ); + } + // Body formation const csvRows = rows.map((row) => { - const { email, role, teams, attributes } = row; + const { email, role, teams, username, attributes } = row; // Create a map of attributeId to array of values const attributeMap = attributes.reduce((acc, attr) => { @@ -110,14 +124,19 @@ export const generateCsvRaw = ( 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(",")) : ""; - }), + const requiredColumns = [ + email, // Members column + `${window.location.origin}/${username}`, // Link column + role, // Role column + sanitizeValue(teams.map((team) => team.name).join(",")), // Teams column ]; + + // Add attribute columns + const attributeColumns = ATTRIBUTE_IDS.map((attrId) => + attributeMap[attrId] ? sanitizeValue(attributeMap[attrId].join(",")) : "" + ); + + return [...requiredColumns, ...attributeColumns]; }); return [headers.join(","), ...csvRows.map((row) => row.join(","))].join("\n");