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
This commit is contained in:
1 parent
fc1d553b74
commit
f9afca2939
3 files changed
+96
-62
No files matched your search
@@ -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> {
|
||||
@@ -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",
|
||||
@@ -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", () => {
|
||||
@@ -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,,,"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
</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"
|
||||
|
||||
+37
-18
@@ -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") {
|
||||
@@ -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<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");
|
||||
|
||||
Reference in new issue
Block a user