Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/app/services/ServiceExportActions.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ServiceExportActions services={displayedServices} />);

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(<ServiceExportActions services={[]} />);

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(<ServiceExportActions services={displayedServices} />);

fireEvent.click(screen.getByRole("button", { name: "Export JSON" }));

expect(await screen.findByRole("alert")).toHaveTextContent(
"EXPORT_DOWNLOAD_UNAVAILABLE",
);
});
});
127 changes: 127 additions & 0 deletions src/app/services/ServiceExportActions.tsx
Original file line number Diff line number Diff line change
@@ -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<ServiceExportFormat | null>(null);
const [failure, setFailure] = useState<ExportFailure | null>(null);
const [status, setStatus] = useState<string | null>(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<void>((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 (
<section
aria-labelledby="services-export-heading"
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 dark:border-zinc-800 dark:bg-zinc-900"
>
<div>
<h2 id="services-export-heading" className="text-sm font-medium">
Export current view
</h2>
<p className="text-xs text-zinc-600 dark:text-zinc-400">
Downloads the services shown on this page in their current display
order.
</p>
</div>

<div className="flex gap-2">
<button
type="button"
disabled={exporting !== null}
aria-busy={exporting === "csv" || undefined}
onClick={() => void startExport("csv")}
className="rounded-full border border-zinc-300 px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700"
>
{exporting === "csv" ? "Exporting CSV..." : "Export CSV"}
</button>
<button
type="button"
disabled={exporting !== null}
aria-busy={exporting === "json" || undefined}
onClick={() => void startExport("json")}
className="rounded-full bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:bg-white dark:text-black"
>
{exporting === "json" ? "Exporting JSON..." : "Export JSON"}
</button>
</div>

{status && (
<p role="status" aria-live="polite" className="w-full text-xs text-zinc-600 dark:text-zinc-400">
{status}
</p>
)}
{failure && (
<p role="alert" className="w-full text-xs text-rose-600">
{failure.code}: {failure.message}
</p>
)}
</section>
);
}
24 changes: 24 additions & 0 deletions src/app/services/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 30 additions & 2 deletions src/app/services/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -266,6 +273,22 @@ export default function ServicesPage() {
</Link>
</header>
<ErrorMessage title="Failed to load services" detail={error} />
{!loading && !error && services && services.length > 0 && (
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">Filter services</span>
<input
type="search"
value={filterQuery}
onChange={(event) => setFilterQuery(event.target.value)}
placeholder="Filter by service ID"
aria-label="Filter services by service ID"
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950"
/>
</label>
)}
{!loading && !error && services && (
<ServiceExportActions services={sortedServices ?? []} />
)}
{loading && (
<div className="flex justify-center py-10">
<Spinner label="Loading services" />
Expand All @@ -285,7 +308,12 @@ export default function ServicesPage() {
}
/>
)}
{!loading && services && services.length > 0 && (
{!loading && !error && services && services.length > 0 && sortedServices?.length === 0 && (
<p role="status" className="py-6 text-sm text-zinc-600 dark:text-zinc-400">
No services match this filter.
</p>
)}
{!loading && services && sortedServices && sortedServices.length > 0 && (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{sortedServices?.map((s) => (
<li
Expand Down
71 changes: 71 additions & 0 deletions src/app/services/serviceExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
buildServiceExport,
escapeCsvCell,
filterServicesForView,
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 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",
});
});
});
Loading
Loading