Add CSV export for member list (#10803)

* Initial plan

* Add CSV export functionality to members page

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Align Export CSV button to the right

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Address code review feedback: improve type safety, error handling, and date formatting

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Improve CSV utility and date handling consistency

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Improve error messages and fix useCallback dependencies

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add comprehensive tests for CSV utility

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Refactor: reduce limit to 100, replace lastActiveIp with role, extract ExportCSV component

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Improve type safety and extract pagination constant

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* refactor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Copilot
2025-12-05 09:42:36 -05:00
committed by GitHub
parent 305b81fbf4
commit 133ec073be
5 changed files with 302 additions and 18 deletions
+20 -15
View File
@@ -10,6 +10,7 @@ import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -21,6 +22,7 @@ import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { ExportCSV } from "./components/ExportCSV";
import { MembersTable } from "./components/MembersTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
@@ -144,21 +146,24 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
<StickyFilters gap={8} justify="space-between">
<Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<ExportCSV reqParams={reqParams} />
</StickyFilters>
<ConditionalFade animate={!data}>
<MembersTable
@@ -0,0 +1,86 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import { CSVHelper } from "@shared/utils/csv";
import download from "~/utils/download";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import useCurrentTeam from "~/hooks/useCurrentTeam";
type Props = {
/** Request parameters for filtering users */
reqParams: {
query?: string;
filter?: string;
role?: string;
sort?: string;
direction?: "ASC" | "DESC";
};
};
/**
* A button that exports all members to a CSV file.
*/
export function ExportCSV({ reqParams }: Props) {
const { t } = useTranslation();
const { users } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const [isExporting, setIsExporting] = useState(false);
const handleExportCSV = useCallback(async () => {
setIsExporting(true);
try {
const allUsers = await users.fetchAll({
...reqParams,
limit: 100,
});
// Convert to CSV format with formatted dates
const csvData = allUsers.map((user) => ({
id: user.id,
name: user.name,
email: user.email || "",
role: user.role,
lastActiveAt: user.lastActiveAt
? new Date(user.lastActiveAt).toISOString()
: "",
createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : "",
}));
const headers: (keyof (typeof csvData)[0])[] = [
"id",
"name",
"email",
"role",
"lastActiveAt",
"createdAt",
];
const csv = CSVHelper.convertToCSV(csvData, headers);
// Trigger download
download(csv, "members.csv", "text/csv");
toast.success(t("Members exported successfully"));
} catch {
toast.error(t("Failed to export members"));
} finally {
setIsExporting(false);
}
}, [users, reqParams, t]);
if (!can.createExport) {
return null;
}
return (
<Button
type="button"
onClick={handleExportCSV}
disabled={isExporting}
neutral
>
{isExporting ? t("Exporting") + "…" : t("Download CSV")}
</Button>
);
}
@@ -1018,6 +1018,9 @@
"Start import": "Start import",
"Added by": "Added by",
"Date added": "Date added",
"Members exported successfully": "Members exported successfully",
"Failed to export members": "Failed to export members",
"Download CSV": "Download CSV",
"Processing": "Processing",
"Expired": "Expired",
"Completed": "Completed",
+125 -1
View File
@@ -18,7 +18,7 @@ describe("CSVHelper", () => {
});
it("should remove control characters", () => {
expect(CSVHelper.sanitizeValue("\t1x2")).toBe(`1x2`);
expect(CSVHelper.sanitizeValue("\u00011x2")).toBe(`1x2`);
});
it("should remove zero-width characters", () => {
@@ -29,4 +29,128 @@ describe("CSVHelper", () => {
expect(CSVHelper.sanitizeValue("\u200B1x2")).toBe(`1x2`);
});
});
describe("escapeCSVField", () => {
it("should escape fields with commas", () => {
expect(CSVHelper.escapeCSVField("Doe, John")).toBe('"Doe, John"');
});
it("should escape fields with quotes", () => {
expect(CSVHelper.escapeCSVField('John "Johnny" Doe')).toBe(
'"John ""Johnny"" Doe"'
);
});
it("should escape fields with newlines", () => {
expect(CSVHelper.escapeCSVField("John\nDoe")).toBe('"John\nDoe"');
});
it("should handle null values", () => {
expect(CSVHelper.escapeCSVField(null)).toBe("");
});
it("should handle undefined values", () => {
expect(CSVHelper.escapeCSVField(undefined)).toBe("");
});
it("should handle empty strings", () => {
expect(CSVHelper.escapeCSVField("")).toBe("");
});
it("should leave simple values unchanged", () => {
expect(CSVHelper.escapeCSVField("John")).toBe("John");
});
});
describe("convertToCSV", () => {
it("should convert simple data to CSV", () => {
const data = [
{ id: "1", name: "John Doe", email: "john@example.com" },
{ id: "2", name: "Jane Smith", email: "jane@example.com" },
];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe(
"id,name,email\n1,John Doe,john@example.com\n2,Jane Smith,jane@example.com"
);
});
it("should escape fields with commas", () => {
const data = [{ id: "1", name: "Doe, John", email: "john@example.com" }];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe('id,name,email\n1,"Doe, John",john@example.com');
});
it("should escape fields with quotes", () => {
const data = [
{ id: "1", name: 'John "Johnny" Doe', email: "john@example.com" },
];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe(
'id,name,email\n1,"John ""Johnny"" Doe",john@example.com'
);
});
it("should escape fields with newlines", () => {
const data = [{ id: "1", name: "John\nDoe", email: "john@example.com" }];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe('id,name,email\n1,"John\nDoe",john@example.com');
});
it("should handle empty values", () => {
const data = [{ id: "1", name: "", email: null as unknown as string }];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe("id,name,email\n1,,");
});
it("should handle undefined values", () => {
const data = [
{
id: "1",
name: undefined as unknown as string,
email: "john@example.com",
},
];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe("id,name,email\n1,,john@example.com");
});
it("should handle empty data array", () => {
const data: { id: string; name: string }[] = [];
const headers: (keyof (typeof data)[0])[] = ["id", "name"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe("id,name\n");
});
it("should handle different data types", () => {
const data = [{ id: 1, active: true, date: new Date("2024-01-01") }];
const headers: (keyof (typeof data)[0])[] = ["id", "active", "date"];
const result = CSVHelper.convertToCSV(data, headers);
// Should convert all types to strings
expect(result).toContain("1");
expect(result).toContain("true");
expect(result).toContain("2024");
});
it("should sanitize formula trigger characters", () => {
const data = [{ id: "1", name: "=John", email: "+john@example.com" }];
const headers: (keyof (typeof data)[0])[] = ["id", "name", "email"];
const result = CSVHelper.convertToCSV(data, headers);
expect(result).toBe("id,name,email\n1,'=John,'+john@example.com");
});
});
});
+68 -2
View File
@@ -20,12 +20,78 @@ export class CSVHelper {
.toString()
// Formula triggers
.replace(/^([+\-=@<>±÷×])/u, "'$1")
// Control characters
.replace(/[\u0000-\u001F\u007F-\u009F]/gu, "")
// Control characters (excluding tab, newline, and carriage return)
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/gu, "")
// Zero-width spaces
.replace(/[\u200B-\u200D\uFEFF]/g, "")
// Bidirectional control
.replace(/[\u202A-\u202E\u2066-\u2069]/g, "")
);
}
/**
* Escapes a CSV field value by wrapping it in quotes if necessary.
*
* @param value The value to escape.
* @returns The escaped value.
*/
public static escapeCSVField(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
const stringValue = String(value);
// If the value contains comma, quote, or newline, wrap it in quotes and escape internal quotes
if (
stringValue.includes(",") ||
stringValue.includes('"') ||
stringValue.includes("\n")
) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
/**
* Converts an array of objects to CSV format.
*
* @param data Array of objects to convert.
* @param headers Array of header names in the desired order.
* @returns CSV string.
*/
public static convertToCSV<T extends Record<string, unknown>>(
data: T[],
headers: (keyof T)[]
): string {
if (data.length === 0) {
return (
headers
.map((h) => String(h))
.map((h) => this.escapeCSVField(this.sanitizeValue(h)))
.join(",") + "\n"
);
}
// Create header row
const headerRow = headers
.map((h) => String(h))
.map((h) => this.escapeCSVField(this.sanitizeValue(h)))
.join(",");
// Create data rows
const dataRows = data.map((row) =>
headers
.map((header) => {
const value = row[header];
const stringValue =
value === null || value === undefined ? "" : String(value);
return this.escapeCSVField(this.sanitizeValue(stringValue));
})
.join(",")
);
return [headerRow, ...dataRows].join("\n");
}
}