mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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 haven’t 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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user