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.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, diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx index 75dd6ed..3ce415d 100644 --- a/src/app/services/page.tsx +++ b/src/app/services/page.tsx @@ -11,6 +11,8 @@ import { Spinner } from "@/components/Spinner"; 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 = { @@ -184,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 } = @@ -266,6 +273,22 @@ export default function ServicesPage() { + {!loading && !error && services && services.length > 0 && ( + + )} + {!loading && !error && services && ( + + )} {loading && (
@@ -285,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) => (
  • { + it("exports only the active filtered view and preserves its display order", () => { + const filtered = filterServicesForView(rows, "SVC-A"); + + expect(filtered).toEqual([rows[1]]); + expect(JSON.parse(serializeServicesJson(filtered))).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)", + " =2+3", + "\t+2+3", + "\r-2+3", + "\n@SUM(A1:A2)", + ])( + "neutralizes spreadsheet formula trigger %j", + (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..6a9f50b --- /dev/null +++ b/src/app/services/serviceExport.ts @@ -0,0 +1,101 @@ +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; + } +} + +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 ]*[=+\-@]/; + +export function escapeCsvCell(value: string): string { + // 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, '""')}"`; +} + +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.", + ); + } +}