Skip to content
Merged
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
69 changes: 69 additions & 0 deletions ui/src/lib/csv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadCsv, toCsv, toExcelCsv } from "./csv";

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.replaceChildren();
});

describe("toCsv", () => {
it("sanitizes formula-leading cells", () => {
const csv = toCsv(
[{ header: "filename", value: (row: { filename: string }) => row.filename }],
[
{ filename: "=cmd|' /C calc'!A0" },
{ filename: "+sum(1,2)" },
{ filename: "-10" },
{ filename: "@admin" },
{ filename: "\t=cmd" },
{ filename: "\r=cmd" },
{ filename: "\n=cmd" },
{ filename: "safe.pdf" },
],
);

expect(csv).toBe([
"filename",
"'=cmd|' /C calc'!A0",
"\"'+sum(1,2)\"",
"'-10",
"'@admin",
"'\t=cmd",
"\"'\r=cmd\"",
"\"'\n=cmd\"",
"safe.pdf",
].join("\r\n"));
});

it("adds Excel encoding and delimiter hints to downloads", () => {
const csv = toExcelCsv(
[{ header: "filename", value: (row: { filename: string }) => row.filename }],
[{ filename: "Rapport_priv\u00e9.pdf" }],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(csv).toBe("\uFEFFsep=,\r\nfilename\r\nRapport_priv\u00e9.pdf");
});

it("keeps the object URL alive until the browser handles the click", () => {
vi.useFakeTimers();
const createObjectURL = vi.fn(() => "blob:csv");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});

downloadCsv("report.csv", [{ header: "name", value: (row: { name: string }) => row.name }], [
{ name: "report" },
]);

expect(document.querySelector('a[download="report.csv"]')).not.toBeNull();
expect(click).toHaveBeenCalledOnce();
expect(revokeObjectURL).not.toHaveBeenCalled();

vi.runAllTimers();

expect(document.querySelector('a[download="report.csv"]')).toBeNull();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv");
});
});
45 changes: 45 additions & 0 deletions ui/src/lib/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
type CsvColumn<T> = {
header: string;
value: (row: T) => unknown;
};

const FORMULA_PREFIX = /^[=+\-@\t\r\n]/;
const CSV_DELIMITER = ",";
const CSV_LINE_BREAK = "\r\n";
const UTF8_BOM = "\uFEFF";

function csvCell(value: unknown): string {
const raw = value == null ? "" : String(value);
const text = FORMULA_PREFIX.test(raw) ? `'${raw}` : raw;
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}

export function toCsv<T>(columns: CsvColumn<T>[], rows: T[]): string {
return [
columns.map((column) => csvCell(column.header)).join(CSV_DELIMITER),
...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(CSV_DELIMITER)),
].join(CSV_LINE_BREAK);
}

export function toExcelCsv<T>(columns: CsvColumn<T>[], rows: T[]): string {
return `${UTF8_BOM}sep=${CSV_DELIMITER}${CSV_LINE_BREAK}${toCsv(columns, rows)}`;
}

export function downloadCsv<T>(filename: string, columns: CsvColumn<T>[], rows: T[]) {
const blob = new Blob([toExcelCsv(columns, rows)], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.style.display = "none";

try {
document.body.appendChild(link);
link.click();
} finally {
window.setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 0);
}
}
41 changes: 40 additions & 1 deletion ui/src/pages/admin/documents/list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "sonner";
import type { Action } from "sonner";
import { deleteFile, uploadFile } from "@/lib/api/indexing";
import { downloadCsv } from "@/lib/csv";
import DocumentListPage from "./list";

vi.mock("sonner", () => ({
Expand Down Expand Up @@ -54,7 +55,7 @@ vi.mock("@/lib/api/documents", () => ({
partition: "docs",
filename: "b.pdf",
mimetype: "application/pdf",
indexed_at: "2026-01-02T00:00:00Z",
indexed_at: new Date(2026, 0, 2, 0, 30).toISOString(),
},
],
}),
Expand All @@ -66,8 +67,13 @@ vi.mock("@/lib/api/indexing", () => ({
newFileId: vi.fn(() => "new-file-id"),
}));

vi.mock("@/lib/csv", () => ({
downloadCsv: vi.fn(),
}));

const deleteFileMock = vi.mocked(deleteFile);
const uploadFileMock = vi.mocked(uploadFile);
const downloadCsvMock = vi.mocked(downloadCsv);
const toastSuccessMock = vi.mocked(toast.success);

function LocationProbe() {
Expand Down Expand Up @@ -104,6 +110,7 @@ describe("DocumentListPage", () => {
permissions.superAdminModeResolved = true;
deleteFileMock.mockClear();
uploadFileMock.mockReset();
downloadCsvMock.mockClear();
toastSuccessMock.mockClear();
});

Expand All @@ -122,6 +129,38 @@ describe("DocumentListPage", () => {
expect(fileLink.className).toContain("truncate");
});

it("filters documents by file name and indexed date before exporting", async () => {
renderDocuments();

expect(await screen.findByText("a.pdf")).not.toBeNull();
await userEvent.type(screen.getByLabelText("Search files"), "b");
await userEvent.type(screen.getByLabelText("Indexed since"), "2026-01-02");

expect(screen.queryByText("a.pdf")).toBeNull();
expect(screen.getByText("b.pdf")).not.toBeNull();
expect(screen.getByText("1 of 2 file(s)")).not.toBeNull();

await userEvent.click(screen.getByRole("button", { name: /export csv/i }));

expect(downloadCsvMock).toHaveBeenCalledWith(
"openrag-documents-docs.csv",
expect.any(Array),
[expect.objectContaining({ file_id: "file-b" })],
);
});

it("reports CSV download failures", async () => {
downloadCsvMock.mockImplementationOnce(() => {
throw new Error("downloads unavailable");
});
renderDocuments();

expect(await screen.findByText("a.pdf")).not.toBeNull();
await userEvent.click(screen.getByRole("button", { name: /export csv/i }));

expect(toast.error).toHaveBeenCalledWith("CSV export failed: downloads unavailable");
});

it("opens the upload dialog for a partition upload link", async () => {
renderDocuments(["/documents?partition=docs&upload=1"]);

Expand Down
82 changes: 75 additions & 7 deletions ui/src/pages/admin/documents/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef, OnChangeFn, RowSelectionState } from "@tanstack/react-table";
import { Plus, Eye, Trash2, RefreshCw } from "lucide-react";
import { Download, Plus, Eye, Trash2, RefreshCw, Search } from "lucide-react";
import { toast } from "sonner";

import { PageHeader } from "@/components/shared/page-header";
Expand Down Expand Up @@ -32,11 +32,13 @@ import { listPartitionFiles, type PartitionFile } from "@/lib/api/documents";
import { uploadFile, deleteFile, newFileId } from "@/lib/api/indexing";
import { listPartitions } from "@/lib/api/partitions";
import { usePermissions } from "@/lib/permissions";
import { downloadCsv } from "@/lib/csv";
import { resolveDocumentsPartition } from "./partition-selection";

const fileHref = (partition: string, fileId: string) =>
`/documents/${encodeURIComponent(partition)}/${encodeURIComponent(fileId)}`;
const fileLabel = (f: PartitionFile) => (f.filename as string) || f.file_id;
const str = (v: unknown) => (v == null ? "" : String(v));

export default function DocumentListPage() {
const queryClient = useQueryClient();
Expand All @@ -54,6 +56,8 @@ export default function DocumentListPage() {
const [uploadOpen, setUploadOpen] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [fileSearch, setFileSearch] = useState("");
const [indexedSince, setIndexedSince] = useState("");
const [fileSelection, setFileSelection] = useState<{
partition: string;
rows: RowSelectionState;
Expand Down Expand Up @@ -143,6 +147,21 @@ export default function DocumentListPage() {
refetchInterval: 5000,
});
const fileRows = useMemo(() => filesQuery.data?.files ?? [], [filesQuery.data?.files]);
const filteredFileRows = useMemo(() => {
const q = fileSearch.trim().toLowerCase();
const indexedSinceTime = indexedSince ? new Date(`${indexedSince}T00:00:00`).getTime() : null;
return fileRows.filter((file) => {
const filename = fileLabel(file);
const fileTime = Date.parse(str(file.indexed_at ?? file.created_at));
const matchesSearch =
!q ||
[filename, file.file_id, file.mimetype].some((value) =>
str(value).toLowerCase().includes(q),
);
const matchesDate = indexedSinceTime === null || (Number.isFinite(fileTime) && fileTime >= indexedSinceTime);
return matchesSearch && matchesDate;
});
}, [fileRows, fileSearch, indexedSince]);
const fileRowSelection = useMemo(
() => (fileSelection.partition === selected ? fileSelection.rows : {}),
[fileSelection.partition, fileSelection.rows, selected],
Expand All @@ -158,10 +177,29 @@ export default function DocumentListPage() {
[selected],
);
const selectedFiles = useMemo(
() => fileRows.filter((file) => fileRowSelection[file.file_id]),
[fileRows, fileRowSelection],
() => filteredFileRows.filter((file) => fileRowSelection[file.file_id]),
[filteredFileRows, fileRowSelection],
);

const exportDocuments = () => {
try {
downloadCsv(
`openrag-documents-${selected || "partition"}.csv`,
[
{ header: "partition", value: () => selected },
{ header: "file_id", value: (file) => file.file_id },
{ header: "filename", value: (file) => fileLabel(file) },
{ header: "mimetype", value: (file) => file.mimetype },
{ header: "indexed_at", value: (file) => file.indexed_at },
{ header: "created_at", value: (file) => file.created_at },
],
filteredFileRows,
);
} catch (error) {
toast.error(`CSV export failed: ${error instanceof Error ? error.message : "Unknown error"}`);
}
};

useEffect(() => {
if (!writable && Object.keys(fileRowSelection).length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Clear stale controlled selection when write access is lost.
Expand Down Expand Up @@ -358,7 +396,7 @@ export default function DocumentListPage() {
}
/>

<div className="flex items-center gap-2 mb-4">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Label className="text-sm font-medium">Partition</Label>
<Select value={selected} onValueChange={selectPartition}>
<SelectTrigger className="w-[220px]">
Expand All @@ -372,6 +410,23 @@ export default function DocumentListPage() {
))}
</SelectContent>
</Select>
<div className="relative max-w-xs">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search files..."
value={fileSearch}
onChange={(e) => setFileSearch(e.target.value)}
className="pl-9"
aria-label="Search files"
/>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Input
type="date"
value={indexedSince}
onChange={(e) => setIndexedSince(e.target.value)}
className="w-[150px]"
aria-label="Indexed since"
/>
{writable && selectedFiles.length > 0 && (
<>
<ConfirmDialog
Expand Down Expand Up @@ -400,10 +455,23 @@ export default function DocumentListPage() {
</p>
</>
)}
<div className="ml-auto flex items-center gap-2">
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{filesQuery.data && (
<p className="text-sm text-muted-foreground">{fileRows.length} file(s)</p>
<p className="text-sm text-muted-foreground">
{filteredFileRows.length}
{(fileSearch || indexedSince) && ` of ${fileRows.length}`} file(s)
</p>
)}
<Button
variant="outline"
size="sm"
onClick={exportDocuments}
disabled={!filteredFileRows.length}
title="Export filtered documents"
>
<Download className="h-4 w-4" />
Export CSV
</Button>
<Button
variant="outline"
size="icon-sm"
Expand Down Expand Up @@ -448,7 +516,7 @@ export default function DocumentListPage() {
) : (
<DataTable
columns={columns}
data={fileRows}
data={filteredFileRows}
initialSorting={[{ id: "indexed_at", desc: true }]}
enableSelection={writable}
getRowId={(f) => f.file_id}
Expand Down
Loading
Loading