Skip to content

Commit 8da498e

Browse files
authored
Merge pull request #96 from ghoul2017/codex/add-published-gifts-csv-export
Add CSV export for published gifts table
2 parents 4a6c922 + 5c93620 commit 8da498e

3 files changed

Lines changed: 138 additions & 2 deletions

File tree

app/src/components/tables/PublishedGiftsTable/PublishedGiftsTable.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DataTable } from "../DataTable";
55
import { columns } from "./columns";
66
import { Input } from "@/components/ui/input";
77
import { Button } from "@/components/ui/button";
8+
import { downloadCsv, serializeCsv } from "@/lib/csv";
89
import { cn } from "@/lib/utils";
910
import { StatusSummaryHeader } from "./StatusSummaryHeader";
1011
import {
@@ -58,11 +59,44 @@ export function PublishedGiftsTable({
5859
return result;
5960
}, [activeFilter, giftStatusFilter, data]);
6061

62+
const exportData = useMemo(() => {
63+
const normalizedSearch = globalSearch.trim().toLowerCase();
64+
if (!normalizedSearch) return filteredData;
65+
66+
return filteredData.filter((row) =>
67+
[
68+
row.giftName,
69+
row.giftStatus,
70+
row.sponsorType,
71+
row.sponsorName,
72+
row.sponsorEmail,
73+
row.dateOfFulfillment,
74+
row.productUrl,
75+
].some((value) =>
76+
String(value ?? "")
77+
.toLowerCase()
78+
.includes(normalizedSearch),
79+
),
80+
);
81+
}, [filteredData, globalSearch]);
82+
6183
const tableKey = `${activeFilter ?? "all"}-${giftStatusFilter ?? "all"}`;
6284

6385
const handleExport = () => {
64-
// TODO: Implement export functionality
65-
console.log("Export clicked");
86+
const csv = serializeCsv(exportData, [
87+
{ header: "Gift Name", value: (row) => row.giftName },
88+
{ header: "Gift Status", value: (row) => row.giftStatus },
89+
{ header: "Sponsor Type", value: (row) => row.sponsorType },
90+
{ header: "Sponsor Name", value: (row) => row.sponsorName },
91+
{ header: "Sponsor Email", value: (row) => row.sponsorEmail },
92+
{
93+
header: "Date of Fulfillment",
94+
value: (row) => row.dateOfFulfillment,
95+
},
96+
{ header: "Product URL", value: (row) => row.productUrl },
97+
]);
98+
99+
downloadCsv("published-gifts.csv", csv);
66100
};
67101

68102
return (

app/src/lib/csv.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { serializeCsv } from "./csv";
3+
4+
describe("serializeCsv", () => {
5+
it("serializes headers and row values", () => {
6+
const csv = serializeCsv(
7+
[
8+
{ name: "Blanket", status: "claimed" },
9+
{ name: "Puzzle", status: "unclaimed" },
10+
],
11+
[
12+
{ header: "Gift Name", value: (row) => row.name },
13+
{ header: "Status", value: (row) => row.status },
14+
],
15+
);
16+
17+
expect(csv).toBe(
18+
["Gift Name,Status", "Blanket,claimed", "Puzzle,unclaimed"].join("\r\n"),
19+
);
20+
});
21+
22+
it("escapes commas, quotes, and newlines", () => {
23+
const csv = serializeCsv(
24+
[{ note: 'Needs "large", blue\nbox' }],
25+
[{ header: "Note", value: (row) => row.note }],
26+
);
27+
28+
expect(csv).toBe('Note\r\n"Needs ""large"", blue\nbox"');
29+
});
30+
31+
it("serializes nullish values as empty cells", () => {
32+
const csv = serializeCsv(
33+
[{ name: undefined, email: null }],
34+
[
35+
{ header: "Name", value: (row) => row.name },
36+
{ header: "Email", value: (row) => row.email },
37+
],
38+
);
39+
40+
expect(csv).toBe("Name,Email\r\n,");
41+
});
42+
43+
it("neutralizes formula-like leading characters", () => {
44+
const csv = serializeCsv(
45+
[
46+
{ note: "=2+2" },
47+
{ note: "+SUM(A1:A2)" },
48+
{ note: "-10" },
49+
{ note: "@cmd" },
50+
],
51+
[{ header: "Note", value: (row) => row.note }],
52+
);
53+
54+
expect(csv).toBe("Note\r\n'=2+2\r\n'+SUM(A1:A2)\r\n'-10\r\n'@cmd");
55+
});
56+
});

app/src/lib/csv.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export type CsvColumn<T> = {
2+
header: string;
3+
value: (row: T) => unknown;
4+
};
5+
6+
export function serializeCsv<T>(
7+
rows: Array<T>,
8+
columns: Array<CsvColumn<T>>,
9+
): string {
10+
const headerRow = columns.map((column) => serializeCsvCell(column.header));
11+
const dataRows = rows.map((row) =>
12+
columns.map((column) => serializeCsvCell(column.value(row))),
13+
);
14+
15+
return [headerRow, ...dataRows].map((row) => row.join(",")).join("\r\n");
16+
}
17+
18+
export function downloadCsv(filename: string, csv: string) {
19+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
20+
const url = URL.createObjectURL(blob);
21+
const link = document.createElement("a");
22+
23+
link.href = url;
24+
link.download = filename;
25+
link.style.display = "none";
26+
document.body.appendChild(link);
27+
link.click();
28+
link.remove();
29+
URL.revokeObjectURL(url);
30+
}
31+
32+
function serializeCsvCell(value: unknown): string {
33+
if (value === null || value === undefined) return "";
34+
35+
let stringValue = value instanceof Date ? value.toISOString() : String(value);
36+
37+
if (/^[=+\-@]/.test(stringValue)) {
38+
stringValue = `'${stringValue}`;
39+
}
40+
41+
if (/[",\r\n]/.test(stringValue)) {
42+
return `"${stringValue.replaceAll('"', '""')}"`;
43+
}
44+
45+
return stringValue;
46+
}

0 commit comments

Comments
 (0)