From 37f38204793dc28057f1ed9269b0e76eab486ec6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 14:33:09 -0400 Subject: [PATCH 1/7] feat(services): export current view safely --- .../services/ServiceExportActions.test.tsx | 71 ++++++++++ src/app/services/ServiceExportActions.tsx | 127 ++++++++++++++++++ src/app/services/page.tsx | 4 + src/app/services/serviceExport.test.ts | 62 +++++++++ src/app/services/serviceExport.ts | 83 ++++++++++++ 5 files changed, 347 insertions(+) create mode 100644 src/app/services/ServiceExportActions.test.tsx create mode 100644 src/app/services/ServiceExportActions.tsx create mode 100644 src/app/services/serviceExport.test.ts create mode 100644 src/app/services/serviceExport.ts diff --git a/src/app/services/ServiceExportActions.test.tsx b/src/app/services/ServiceExportActions.test.tsx new file mode 100644 index 0000000..ca1d650 --- /dev/null +++ b/src/app/services/ServiceExportActions.test.tsx @@ -0,0 +1,71 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ServiceExportActions } from "./ServiceExportActions"; + +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +let clickSpy: jest.SpyInstance; + +beforeEach(() => { + URL.createObjectURL = jest.fn(() => "blob:services-export"); + URL.revokeObjectURL = jest.fn(); + clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(); +}); + +afterEach(() => { + clickSpy.mockRestore(); + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; +}); + +describe("ServiceExportActions", () => { + const displayedServices = [ + { serviceId: "svc-filtered", priceStroops: 7, createdAt: 123 }, + ]; + + it("downloads the displayed service rows as CSV", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Export CSV" })); + + await waitFor(() => { + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob; + expect(blob.type).toBe("text/csv;charset=utf-8"); + + const link = clickSpy.mock.instances[0] as HTMLAnchorElement; + expect(link.download).toBe("services-current-view.csv"); + expect(await screen.findByRole("status")).toHaveTextContent( + "Exported 1 service as CSV.", + ); + }); + + it("allows a valid empty-view export", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Export JSON" })); + + expect(await screen.findByRole("status")).toHaveTextContent( + "Exported 0 services as JSON.", + ); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it("surfaces a stable typed download error", async () => { + URL.createObjectURL = jest.fn(() => { + throw new Error("blocked"); + }); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Export JSON" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "EXPORT_DOWNLOAD_UNAVAILABLE", + ); + }); +}); diff --git a/src/app/services/ServiceExportActions.tsx b/src/app/services/ServiceExportActions.tsx new file mode 100644 index 0000000..a60b9aa --- /dev/null +++ b/src/app/services/ServiceExportActions.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useState } from "react"; +import { + buildServiceExport, + ServiceExportError, + type ServiceExportFormat, + type ServiceExportRow, +} from "./serviceExport"; + +type Props = { + services: readonly ServiceExportRow[]; +}; + +type ExportFailure = Readonly<{ + code: string; + message: string; +}>; + +function downloadPayload(content: string, mimeType: string, filename: string) { + if ( + typeof document === "undefined" || + typeof URL.createObjectURL !== "function" + ) { + throw new ServiceExportError( + "EXPORT_DOWNLOAD_UNAVAILABLE", + "Downloads are unavailable in this browser.", + ); + } + + const blobUrl = URL.createObjectURL(new Blob([content], { type: mimeType })); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = filename; + link.rel = "noopener"; + + document.body.appendChild(link); + link.click(); + link.remove(); + + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0); +} + +export function ServiceExportActions({ services }: Props) { + const [exporting, setExporting] = useState(null); + const [failure, setFailure] = useState(null); + const [status, setStatus] = useState(null); + + const startExport = async (format: ServiceExportFormat) => { + setExporting(format); + setFailure(null); + setStatus(null); + + // Yield once before serializing. The current view is page-bounded, so this + // keeps interaction responsive without inventing a server-side export path. + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + try { + const payload = buildServiceExport(services, format); + downloadPayload(payload.content, payload.mimeType, payload.filename); + setStatus( + `Exported ${services.length} service${services.length === 1 ? "" : "s"} as ${format.toUpperCase()}.`, + ); + } catch (error) { + const typed = + error instanceof ServiceExportError + ? error + : new ServiceExportError( + "EXPORT_DOWNLOAD_UNAVAILABLE", + "Could not download the current services view.", + ); + + setFailure({ code: typed.code, message: typed.message }); + } finally { + setExporting(null); + } + }; + + return ( +
+
+

+ Export current view +

+

+ Downloads the services shown on this page in their current display + order. +

+
+ +
+ + +
+ + {status && ( +

+ {status} +

+ )} + {failure && ( +

+ {failure.code}: {failure.message} +

+ )} +
+ ); +} diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..f998881 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -11,6 +11,7 @@ import { Spinner } from "@/components/Spinner"; import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; +import { ServiceExportActions } from "./ServiceExportActions"; type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; type ServicesResponse = { @@ -266,6 +267,9 @@ export default function ServicesPage() { + {!loading && !error && services && ( + + )} {loading && (
diff --git a/src/app/services/serviceExport.test.ts b/src/app/services/serviceExport.test.ts new file mode 100644 index 0000000..7b862de --- /dev/null +++ b/src/app/services/serviceExport.test.ts @@ -0,0 +1,62 @@ +import { + buildServiceExport, + escapeCsvCell, + serializeServicesCsv, + serializeServicesJson, + type ServiceExportRow, +} from "./serviceExport"; + +const rows: ServiceExportRow[] = [ + { serviceId: "svc-b", priceStroops: 20, createdAt: 200 }, + { serviceId: "svc-a", priceStroops: 10, createdAt: "100" }, +]; + +describe("serviceExport", () => { + it("exports only the supplied current view and preserves its display order", () => { + const filteredAndSorted = [rows[1]]; + + expect(JSON.parse(serializeServicesJson(filteredAndSorted))).toEqual([ + rows[1], + ]); + }); + + it("quotes commas, quotes and newlines in CSV cells", () => { + const csv = serializeServicesCsv([ + { + serviceId: 'svc,"quoted"\nline', + priceStroops: 10, + createdAt: null, + }, + ]); + + expect(csv).toContain('"svc,""quoted""\nline"'); + }); + + it.each(["=2+3", "+2+3", "-2+3", "@SUM(A1:A2)"])( + "neutralizes spreadsheet formula trigger %s", + (value) => { + expect(escapeCsvCell(value)).toBe(`"'${value}"`); + }, + ); + + it("produces a valid empty CSV export", () => { + expect(serializeServicesCsv([])).toBe( + "serviceId,priceStroops,createdAt\r\n", + ); + }); + + it("round-trips JSON data", () => { + expect(JSON.parse(serializeServicesJson(rows))).toEqual(rows); + }); + + it("builds predictable client download metadata", () => { + expect(buildServiceExport(rows, "csv")).toMatchObject({ + filename: "services-current-view.csv", + mimeType: "text/csv;charset=utf-8", + }); + expect(buildServiceExport(rows, "json")).toMatchObject({ + filename: "services-current-view.json", + mimeType: "application/json;charset=utf-8", + }); + }); +}); diff --git a/src/app/services/serviceExport.ts b/src/app/services/serviceExport.ts new file mode 100644 index 0000000..ecb4a7e --- /dev/null +++ b/src/app/services/serviceExport.ts @@ -0,0 +1,83 @@ +export type ServiceExportRow = Readonly<{ + serviceId: string; + priceStroops: number; + createdAt?: number | string | null; +}>; + +export type ServiceExportFormat = "csv" | "json"; + +export type ServiceExportErrorCode = + | "EXPORT_SERIALIZATION_FAILED" + | "EXPORT_DOWNLOAD_UNAVAILABLE"; + +export class ServiceExportError extends Error { + readonly code: ServiceExportErrorCode; + + constructor(code: ServiceExportErrorCode, message: string) { + super(message); + this.name = "ServiceExportError"; + this.code = code; + } +} + +const CSV_HEADER = "serviceId,priceStroops,createdAt"; + +export function escapeCsvCell(value: string): string { + // Prefix spreadsheet formula triggers before CSV quoting so exported data is inert. + const neutralized = /^[=+\-@]/.test(value) ? `'${value}` : value; + return `"${neutralized.replace(/"/g, '""')}"`; +} + +export function serializeServicesCsv( + services: readonly ServiceExportRow[], +): string { + const rows = services.map((service) => + [ + service.serviceId, + String(service.priceStroops), + service.createdAt == null ? "" : String(service.createdAt), + ] + .map(escapeCsvCell) + .join(","), + ); + + return [CSV_HEADER, ...rows].join("\r\n") + "\r\n"; +} + +export function serializeServicesJson( + services: readonly ServiceExportRow[], +): string { + return `${JSON.stringify(services, null, 2)}\n`; +} + +export type ServiceExportPayload = Readonly<{ + content: string; + mimeType: string; + filename: string; +}>; + +export function buildServiceExport( + services: readonly ServiceExportRow[], + format: ServiceExportFormat, +): ServiceExportPayload { + try { + if (format === "csv") { + return { + content: serializeServicesCsv(services), + mimeType: "text/csv;charset=utf-8", + filename: "services-current-view.csv", + }; + } + + return { + content: serializeServicesJson(services), + mimeType: "application/json;charset=utf-8", + filename: "services-current-view.json", + }; + } catch { + throw new ServiceExportError( + "EXPORT_SERIALIZATION_FAILED", + "Could not prepare the current services view for export.", + ); + } +} From 9561babe3e14d0b83e29a5f21946ed5a87fa6ec5 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 14:39:08 -0400 Subject: [PATCH 2/7] fix(exports): neutralize prefixed CSV formulas --- src/app/services/serviceExport.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/services/serviceExport.ts b/src/app/services/serviceExport.ts index ecb4a7e..bd65c2b 100644 --- a/src/app/services/serviceExport.ts +++ b/src/app/services/serviceExport.ts @@ -22,9 +22,15 @@ export class ServiceExportError extends Error { const CSV_HEADER = "serviceId,priceStroops,createdAt"; +const SPREADSHEET_FORMULA_PREFIX = /^[\t\r\n ]*[=+\-@]/; + export function escapeCsvCell(value: string): string { - // Prefix spreadsheet formula triggers before CSV quoting so exported data is inert. - const neutralized = /^[=+\-@]/.test(value) ? `'${value}` : value; + // Spreadsheet importers may ignore leading whitespace/control characters + // before deciding that a cell is a formula. Prefix the original value so it + // stays inert without trimming or otherwise changing user data. + const neutralized = SPREADSHEET_FORMULA_PREFIX.test(value) + ? `'${value}` + : value; return `"${neutralized.replace(/"/g, '""')}"`; } From 6c6958ad04ba36591b065d91fb8bef6b0e16c69e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 14:39:10 -0400 Subject: [PATCH 3/7] test(exports): cover prefixed formula injection --- src/app/services/serviceExport.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/services/serviceExport.test.ts b/src/app/services/serviceExport.test.ts index 7b862de..37cb380 100644 --- a/src/app/services/serviceExport.test.ts +++ b/src/app/services/serviceExport.test.ts @@ -32,8 +32,17 @@ describe("serviceExport", () => { expect(csv).toContain('"svc,""quoted""\nline"'); }); - it.each(["=2+3", "+2+3", "-2+3", "@SUM(A1:A2)"])( - "neutralizes spreadsheet formula trigger %s", + it.each([ + "=2+3", + "+2+3", + "-2+3", + "@SUM(A1:A2)", + " =2+3", + "\t+2+3", + "\r-2+3", + "\n@SUM(A1:A2)", + ])( + "neutralizes spreadsheet formula trigger %j", (value) => { expect(escapeCsvCell(value)).toBe(`"'${value}"`); }, From 2730b229744d6f4a4aa470d6dc485fcde2297772 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 22:41:13 -0400 Subject: [PATCH 4/7] fix(services): filter the active export view --- src/app/services/serviceExport.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/services/serviceExport.ts b/src/app/services/serviceExport.ts index bd65c2b..6a9f50b 100644 --- a/src/app/services/serviceExport.ts +++ b/src/app/services/serviceExport.ts @@ -20,6 +20,18 @@ export class ServiceExportError extends Error { } } +export function filterServicesForView( + services: readonly T[], + query: string, +): T[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return services.slice(); + + return services.filter((service) => + service.serviceId.toLowerCase().includes(normalizedQuery), + ); +} + const CSV_HEADER = "serviceId,priceStroops,createdAt"; const SPREADSHEET_FORMULA_PREFIX = /^[\t\r\n ]*[=+\-@]/; From 7e9c4b3c25a5c31231ca4f93e3575d1c3b5e1680 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 22:41:24 -0400 Subject: [PATCH 5/7] test(services): cover active filter export --- src/app/services/serviceExport.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/services/serviceExport.test.ts b/src/app/services/serviceExport.test.ts index 37cb380..a21a1df 100644 --- a/src/app/services/serviceExport.test.ts +++ b/src/app/services/serviceExport.test.ts @@ -1,6 +1,7 @@ import { buildServiceExport, escapeCsvCell, + filterServicesForView, serializeServicesCsv, serializeServicesJson, type ServiceExportRow, @@ -12,12 +13,11 @@ const rows: ServiceExportRow[] = [ ]; describe("serviceExport", () => { - it("exports only the supplied current view and preserves its display order", () => { - const filteredAndSorted = [rows[1]]; + it("exports only the active filtered view and preserves its display order", () => { + const filtered = filterServicesForView(rows, "SVC-A"); - expect(JSON.parse(serializeServicesJson(filteredAndSorted))).toEqual([ - rows[1], - ]); + expect(filtered).toEqual([rows[1]]); + expect(JSON.parse(serializeServicesJson(filtered))).toEqual([rows[1]]); }); it("quotes commas, quotes and newlines in CSV cells", () => { From 10e9dffeb75ec17e3fa50f17b296b7b3d0edc9df Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Tue, 6 Oct 2026 22:41:36 -0400 Subject: [PATCH 6/7] fix(services): render and export the filtered view --- src/app/services/page.tsx | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index f998881..3ce415d 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -12,6 +12,7 @@ import { truncateMiddle } from "@/lib/format"; import { useToast } from "@/components/ToastProvider"; import { useClipboard } from "@/lib/useClipboard"; import { ServiceExportActions } from "./ServiceExportActions"; +import { filterServicesForView } from "./serviceExport"; type Service = { serviceId: string; priceStroops: number; createdAt?: number | string | null }; type ServicesResponse = { @@ -185,11 +186,16 @@ export default function ServicesPage() { const [page, setPage] = useState(1); const [requestedPage, setRequestedPage] = useState(1); const [pageCount, setPageCount] = useState(1); + const [filterQuery, setFilterQuery] = useState(""); const [{ key: sortKey, dir: sortDir }, setSort] = useState<{ key: SortKey; dir: SortDir }>( getInitialSort ); - const sortedServices = useSorted(services, sortKey, sortDir); + const filteredServices = useMemo( + () => (services ? filterServicesForView(services, filterQuery) : services), + [services, filterQuery] + ); + const sortedServices = useSorted(filteredServices, sortKey, sortDir); const handleSort = (key: SortKey) => { const next: { key: SortKey; dir: SortDir } = @@ -267,6 +273,19 @@ export default function ServicesPage() { + {!loading && !error && services && services.length > 0 && ( + + )} {!loading && !error && services && ( )} @@ -289,7 +308,12 @@ export default function ServicesPage() { } /> )} - {!loading && services && services.length > 0 && ( + {!loading && !error && services && services.length > 0 && sortedServices?.length === 0 && ( +

+ No services match this filter. +

+ )} + {!loading && services && sortedServices && sortedServices.length > 0 && (
    {sortedServices?.map((s) => (
  • Date: Tue, 6 Oct 2026 22:47:46 -0400 Subject: [PATCH 7/7] test(services): cover active filter integration --- src/app/services/page.test.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/services/page.test.tsx b/src/app/services/page.test.tsx index 3e91d64..8831d00 100644 --- a/src/app/services/page.test.tsx +++ b/src/app/services/page.test.tsx @@ -311,6 +311,30 @@ describe("ServicesPage", () => { expect(screen.queryByRole("navigation", { name: /pagination/i })).not.toBeInTheDocument(); }); + it("filters the rendered current view from the service-ID control", async () => { + apiGetMock.mockResolvedValueOnce({ + services: [ + service("svc-alpha", 10), + service("svc-beta", 20), + ], + page: 1, + pageCount: 1, + } as never); + + renderServicesPage(); + + expect(await screen.findByRole("link", { name: /svc-alpha/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /svc-beta/i })).toBeInTheDocument(); + + fireEvent.change( + screen.getByRole("searchbox", { name: /filter services by service id/i }), + { target: { value: "ALPHA" } }, + ); + + expect(screen.getByRole("link", { name: /svc-alpha/i })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /svc-beta/i })).not.toBeInTheDocument(); + }); + it("renders services using the order selected in the URL", async () => { window.history.replaceState( null,