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

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("\n"));
});
});
29 changes: 29 additions & 0 deletions ui/src/lib/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type CsvColumn<T> = {
header: string;
value: (row: T) => unknown;
};

const FORMULA_PREFIX = /^[=+\-@\t\r\n]/;

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(","),
...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(",")),
].join("\n");
}

export function downloadCsv<T>(filename: string, columns: CsvColumn<T>[], rows: T[]) {
const blob = new Blob([toCsv(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.click();
URL.revokeObjectURL(url);
}
27 changes: 27 additions & 0 deletions 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 @@ -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,26 @@ 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("opens the upload dialog for a partition upload link", async () => {
renderDocuments(["/documents?partition=docs&upload=1"]);

Expand Down
77 changes: 70 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,20 @@ export default function DocumentListPage() {
refetchInterval: 5000,
});
const fileRows = useMemo(() => filesQuery.data?.files ?? [], [filesQuery.data?.files]);
const filteredFileRows = useMemo(() => {
const q = fileSearch.trim().toLowerCase();
return fileRows.filter((file) => {
const filename = fileLabel(file);
const fileDate = str(file.indexed_at ?? file.created_at).slice(0, 10);
const matchesSearch =
!q ||
[filename, file.file_id, file.mimetype].some((value) =>
str(value).toLowerCase().includes(q),
);
const matchesDate = !indexedSince || (fileDate && fileDate >= indexedSince);
return matchesSearch && matchesDate;
});
}, [fileRows, fileSearch, indexedSince]);
const fileRowSelection = useMemo(
() => (fileSelection.partition === selected ? fileSelection.rows : {}),
[fileSelection.partition, fileSelection.rows, selected],
Expand All @@ -158,10 +176,25 @@ 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 = () => {
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,
);
};

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 +391,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 +405,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 +450,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 +511,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
68 changes: 67 additions & 1 deletion ui/src/pages/admin/jobs/list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { toast } from "sonner";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { cancelTask, getQueueInfo, listTasks, type TaskState } from "@/lib/api/jobs";
import { downloadCsv } from "@/lib/csv";
import JobListPage from "./list";

vi.mock("sonner", () => ({
Expand Down Expand Up @@ -32,10 +33,30 @@ vi.mock("@/lib/api/jobs", async () => {
};
});

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

const cancelTaskMock = vi.mocked(cancelTask);
const getQueueInfoMock = vi.mocked(getQueueInfo);
const listTasksMock = vi.mocked(listTasks);
const toastErrorMock = vi.mocked(toast.error);
const downloadCsvMock = vi.mocked(downloadCsv);

beforeAll(() => {
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false;
}
if (!Element.prototype.setPointerCapture) {
Element.prototype.setPointerCapture = () => {};
}
if (!Element.prototype.releasePointerCapture) {
Element.prototype.releasePointerCapture = () => {};
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});

const task = (
task_id: string,
Expand Down Expand Up @@ -177,6 +198,51 @@ describe("JobListPage filters", () => {
await waitFor(() => expect(screen.queryByText("1 selected")).toBeNull());
});

it("filters jobs by partition and exports the filtered rows", async () => {
listTasksMock.mockResolvedValue({
tasks: [
task("docs-task", "COMPLETED", "docs.pdf", "docs"),
task("archive-task", "FAILED", "archive.pdf", "archive"),
task("all-named-task", "COMPLETED", "all-named.pdf", "__all__"),
],
});

renderJobs();

expect(await screen.findByText("docs.pdf")).not.toBeNull();
await userEvent.click(screen.getByRole("combobox", { name: /filter jobs by partition/i }));
await userEvent.click(await screen.findByRole("option", { name: "archive" }));

expect(screen.queryByText("docs.pdf")).toBeNull();
expect(screen.getByText("archive.pdf")).not.toBeNull();

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

expect(downloadCsvMock).toHaveBeenCalledWith(
"openrag-jobs.csv",
expect.any(Array),
[expect.objectContaining({ task_id: "archive-task" })],
);

await userEvent.click(screen.getByRole("combobox", { name: /filter jobs by partition/i }));
await userEvent.click(await screen.findByRole("option", { name: "__all__" }));

expect(screen.queryByText("docs.pdf")).toBeNull();
expect(screen.queryByText("archive.pdf")).toBeNull();
expect(screen.getByText("all-named.pdf")).not.toBeNull();

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

expect(downloadCsvMock).toHaveBeenLastCalledWith(
"openrag-jobs.csv",
expect.any(Array),
[expect.objectContaining({ task_id: "all-named-task" })],
);

await userEvent.click(screen.getByRole("tab", { name: "FAILED" }));
await waitFor(() => expect(screen.getByText("docs.pdf")).not.toBeNull());
});

it("keeps long job values constrained while exposing full names", async () => {
const longTaskId = "task-" + "1234567890".repeat(5);
const longFilename = "benchmark-run-with-a-very-long-document-name-that-should-not-stretch-the-table.pdf";
Expand Down
Loading
Loading