Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…o fix/status
  • Loading branch information
sanjeevs9 committed Nov 17, 2024
2 parents 33a670f + d0a84b7 commit 61c3df3
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 62 deletions.
85 changes: 47 additions & 38 deletions apps/web/test/lib/generateCsv.test.ts
Original file line number Diff line number Diff line change
@@ -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<UserTableUser> {
Expand Down Expand Up @@ -53,8 +53,19 @@ function createMockTable(data: UserTableUser[]): Table<UserTableUser> {
}

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",
Expand All @@ -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<UserTableUser>;
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", () => {
Expand All @@ -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
[email protected],MEMBER,Team1,value1,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
[email protected],https://acme.cal.com/testuser,MEMBER,Team1,value1,"
`);
});

Expand All @@ -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
[email protected],MEMBER,Team1,"value1,value2","
"Members,Link,Role,Teams,Attribute 1,Attribute 2
[email protected],https://acme.cal.com/testuser,MEMBER,Team1,"value1,value2","
`);
});

Expand All @@ -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
[email protected],MEMBER,"Team1,Team2",,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
[email protected],https://acme.cal.com/testuser,MEMBER,"Team1,Team2",,"
`);
});

Expand All @@ -169,60 +177,61 @@ 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
[email protected],MEMBER,"Team,1","value,1","
"Members,Link,Role,Teams,Attribute 1,Attribute 2
[email protected],https://acme.cal.com/testuser,MEMBER,"Team,1","value,1","
`);
});

it("should handle all membership roles", () => {
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
[email protected],OWNER,,,
[email protected],ADMIN,,,
[email protected],MEMBER,,,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
[email protected],https://acme.cal.com/owner,OWNER,,,
[email protected],https://acme.cal.com/admin,ADMIN,,,
[email protected],https://acme.cal.com/member,MEMBER,,,"
`);
});

it("should handle users without teams and attributes", () => {
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
[email protected],MEMBER,,,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
[email protected],https://acme.cal.com/testuser,MEMBER,,,"
`);
});
});
18 changes: 12 additions & 6 deletions packages/features/users/components/UserTable/UserListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.");
}
Expand All @@ -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");
Expand All @@ -475,7 +481,7 @@ export function UserListTable() {
className="sm:max-w-64 max-w-full"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<DataTableToolbar.CTA
type="button"
color="secondary"
Expand Down
55 changes: 37 additions & 18 deletions packages/lib/csvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,15 @@ export const sanitizeValue = (value: string) => {
return value;
};

export const generateHeaderFromReactTable = (
table: Table<UserTableUser>,
HEADER_IDS_TO_EXCLUDE?: string[]
): string[] | null => {
export const generateHeaderFromReactTable = (table: Table<UserTableUser>): 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") {
Expand All @@ -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) => {
Expand All @@ -110,14 +124,19 @@ export const generateCsvRaw = (
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(",")) : "";
}),
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");
Expand Down

0 comments on commit 61c3df3

Please sign in to comment.