mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Add Documents library UI (PAP-10524 slice 1)
Adds a first-class company Documents surface so board users can find documents without knowing their source issue: - documentsApi client (list/get/backlinks/metadata/links + review index) - Documents library page at /:co/documents with URL-state search, status/ type/owner filters, open-feedback and trusted-source toggles, sort, and active filter chips - DocumentRow (reuses EntityRow): status dot + type icon, owner chip, source-trust warning, backlink/comment/suggestion counts, updated time - Sidebar "Documents" entry + route wiring - draft status badge color; documents queryKeys - Page tests covering rows, empty states, filter chips, query wiring Remaining slices (child issues): detail/review view, issue Documents tab, done-reviewing handoff dialog. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
93acc1d1a2
commit
14691bd51f
8 files changed
+976
No files matched your search
@@ -23,6 +23,7 @@ import { UserProfile } from "./pages/UserProfile";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { Artifacts } from "./pages/Artifacts";
|
||||
import { Documents } from "./pages/Documents";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
import { Approvals } from "./pages/Approvals";
|
||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
@@ -128,6 +129,7 @@ function boardRoutes() {
|
||||
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
<Route path="documents" element={<Documents />} />
|
||||
<Route path="artifacts" element={<Artifacts />} />
|
||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||
<Route path="approvals/pending" element={<Approvals />} />
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
CompanyDocument,
|
||||
CompanyDocumentSummary,
|
||||
DocumentBacklink,
|
||||
DocumentLink,
|
||||
DocumentLinkTargetType,
|
||||
DocumentReviewIndex,
|
||||
DocumentStatus,
|
||||
DocumentType,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export interface CompanyDocumentListFilters {
|
||||
q?: string;
|
||||
status?: DocumentStatus[];
|
||||
type?: DocumentType[];
|
||||
ownerAgentId?: string;
|
||||
ownerUserId?: string;
|
||||
targetType?: DocumentLinkTargetType;
|
||||
targetId?: string;
|
||||
projectId?: string;
|
||||
hasOpenFeedback?: boolean;
|
||||
trustedOnly?: boolean;
|
||||
includeArchived?: boolean;
|
||||
updatedAfter?: string;
|
||||
updatedBefore?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface UpdateDocumentMetadataInput {
|
||||
title?: string | null;
|
||||
status?: DocumentStatus;
|
||||
documentType?: DocumentType;
|
||||
summary?: string | null;
|
||||
ownerAgentId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateDocumentLinkInput {
|
||||
targetType: DocumentLinkTargetType;
|
||||
targetId: string;
|
||||
relationship?: string;
|
||||
}
|
||||
|
||||
function buildDocumentListQuery(filters: CompanyDocumentListFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.q) params.set("q", filters.q);
|
||||
for (const status of filters.status ?? []) params.append("status", status);
|
||||
for (const type of filters.type ?? []) params.append("type", type);
|
||||
if (filters.ownerAgentId) params.set("ownerAgentId", filters.ownerAgentId);
|
||||
if (filters.ownerUserId) params.set("ownerUserId", filters.ownerUserId);
|
||||
if (filters.targetType) params.set("targetType", filters.targetType);
|
||||
if (filters.targetId) params.set("targetId", filters.targetId);
|
||||
if (filters.projectId) params.set("projectId", filters.projectId);
|
||||
if (filters.hasOpenFeedback) params.set("hasOpenFeedback", "true");
|
||||
if (filters.trustedOnly) params.set("trustedOnly", "true");
|
||||
if (filters.includeArchived) params.set("includeArchived", "true");
|
||||
if (filters.updatedAfter) params.set("updatedAfter", filters.updatedAfter);
|
||||
if (filters.updatedBefore) params.set("updatedBefore", filters.updatedBefore);
|
||||
if (filters.limit !== undefined) params.set("limit", String(filters.limit));
|
||||
if (filters.offset !== undefined) params.set("offset", String(filters.offset));
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export const documentsApi = {
|
||||
list: (companyId: string, filters: CompanyDocumentListFilters = {}) =>
|
||||
api.get<CompanyDocumentSummary[]>(
|
||||
`/companies/${companyId}/documents${buildDocumentListQuery(filters)}`,
|
||||
),
|
||||
listForProject: (projectId: string, filters: CompanyDocumentListFilters = {}) =>
|
||||
api.get<CompanyDocumentSummary[]>(
|
||||
`/projects/${projectId}/documents${buildDocumentListQuery(filters)}`,
|
||||
),
|
||||
get: (companyId: string, documentId: string) =>
|
||||
api.get<CompanyDocument>(`/companies/${companyId}/documents/${documentId}`),
|
||||
backlinks: (companyId: string, documentId: string) =>
|
||||
api.get<DocumentBacklink[]>(`/companies/${companyId}/documents/${documentId}/backlinks`),
|
||||
updateMetadata: (companyId: string, documentId: string, data: UpdateDocumentMetadataInput) =>
|
||||
api.patch<CompanyDocument>(`/companies/${companyId}/documents/${documentId}`, data),
|
||||
createLink: (companyId: string, documentId: string, data: CreateDocumentLinkInput) =>
|
||||
api.post<DocumentLink>(`/companies/${companyId}/documents/${documentId}/links`, data),
|
||||
deleteLink: (companyId: string, documentId: string, linkId: string) =>
|
||||
api.delete<void>(`/companies/${companyId}/documents/${documentId}/links/${linkId}`),
|
||||
/**
|
||||
* The review index is keyed by the owning issue + document key (the same shape
|
||||
* the issue annotation rail consumes). The library/detail surface looks it up
|
||||
* by following a document's `issue` backlink.
|
||||
*/
|
||||
reviewIndex: (issueId: string, key: string, options: { rev?: number } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options.rev !== undefined) params.set("rev", String(options.rev));
|
||||
const qs = params.toString();
|
||||
return api.get<DocumentReviewIndex>(
|
||||
`/issues/${issueId}/documents/${encodeURIComponent(key)}/review-index${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
@@ -104,6 +105,7 @@ export function Sidebar() {
|
||||
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
|
||||
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
<SidebarNavItem to="/documents" label="Documents" icon={FileText} />
|
||||
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
PencilLine,
|
||||
ShieldAlert,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { CompanyDocumentSummary, DocumentType } from "@paperclipai/shared";
|
||||
import { EntityRow } from "../EntityRow";
|
||||
import { StatusBadge } from "../StatusBadge";
|
||||
import { Identity, deriveInitials } from "../Identity";
|
||||
import { relativeTime } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DOC_TYPE_ICON: Record<DocumentType, LucideIcon> = {
|
||||
plan: ListTodo,
|
||||
spec: BookOpen,
|
||||
brief: ClipboardList,
|
||||
report: BarChart3,
|
||||
other: FileText,
|
||||
};
|
||||
|
||||
/** Status dot color matching the StatusBadge palette (see status-colors.ts). */
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
draft: "bg-sky-500",
|
||||
in_review: "bg-violet-500",
|
||||
approved: "bg-green-500",
|
||||
archived: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
export interface DocumentOwner {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
interface DocumentRowProps {
|
||||
document: CompanyDocumentSummary;
|
||||
/** Company prefix used to build the detail link (e.g. "PAP"). */
|
||||
to: string;
|
||||
owner?: DocumentOwner | null;
|
||||
/** Short identifier shown in monospace — typically the issue document key. */
|
||||
identifier?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Library row for a company document. Reuses `EntityRow` so it matches the
|
||||
* issues/projects list rhythm: leading status dot + type icon, monospace
|
||||
* identifier, title, then owner / source-trust / feedback-count / updated meta.
|
||||
*/
|
||||
export function DocumentRow({ document, to, owner, identifier, selected }: DocumentRowProps) {
|
||||
const TypeIcon = DOC_TYPE_ICON[document.documentType] ?? FileText;
|
||||
const counts = document.feedbackCounts;
|
||||
const openComments = counts.openComments + counts.openReviewThreads;
|
||||
const pendingSuggestions = counts.pendingSuggestions;
|
||||
// Source trust: documents are trusted unless their source was quarantined by
|
||||
// the trust policy (matches the server-side `trustedOnly` filter).
|
||||
const quarantined = document.sourceTrust?.disposition === "quarantined";
|
||||
const backlinkCount = document.backlinks.length;
|
||||
|
||||
const primaryBacklink = useMemo(() => {
|
||||
const issueLink = document.backlinks.find((link) => link.identifier);
|
||||
return issueLink ?? document.backlinks[0] ?? null;
|
||||
}, [document.backlinks]);
|
||||
|
||||
const resolvedIdentifier = identifier ?? primaryBacklink?.issueDocumentKey ?? undefined;
|
||||
|
||||
return (
|
||||
<EntityRow
|
||||
to={to}
|
||||
selected={selected}
|
||||
leading={
|
||||
<>
|
||||
<span
|
||||
className={cn("h-2 w-2 shrink-0 rounded-full", STATUS_DOT[document.status] ?? "bg-muted-foreground")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</>
|
||||
}
|
||||
identifier={resolvedIdentifier}
|
||||
title={document.title ?? "Untitled document"}
|
||||
subtitle={document.summary ?? undefined}
|
||||
reserveSubtitleSpace
|
||||
meta={
|
||||
<>
|
||||
<StatusBadge status={document.status} />
|
||||
{owner ? (
|
||||
<Identity
|
||||
name={owner.name}
|
||||
avatarUrl={owner.avatarUrl}
|
||||
initials={deriveInitials(owner.name)}
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : null}
|
||||
{quarantined ? (
|
||||
<span
|
||||
className="inline-flex items-center text-amber-600 dark:text-amber-400"
|
||||
title="Untrusted source — quarantined by trust policy"
|
||||
>
|
||||
<ShieldAlert className="h-3.5 w-3.5" aria-label="Untrusted source" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-2.5 text-xs text-muted-foreground">
|
||||
{backlinkCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={`${backlinkCount} linked item(s)`}>
|
||||
<Link2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{backlinkCount}
|
||||
</span>
|
||||
) : null}
|
||||
{openComments > 0 ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={`${openComments} open comment(s)`}>
|
||||
<MessageSquare className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{openComments}
|
||||
</span>
|
||||
) : null}
|
||||
{pendingSuggestions > 0 ? (
|
||||
<span className="inline-flex items-center gap-0.5" title={`${pendingSuggestions} pending suggestion(s)`}>
|
||||
<PencilLine className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{pendingSuggestions}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="tabular-nums whitespace-nowrap" title={new Date(document.updatedAt).toLocaleString()}>
|
||||
{relativeTime(document.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -116,6 +116,16 @@ export const queryKeys = {
|
||||
list: (companyId: string) => ["goals", companyId] as const,
|
||||
detail: (id: string) => ["goals", "detail", id] as const,
|
||||
},
|
||||
documents: {
|
||||
list: (companyId: string, filters: Record<string, unknown> = {}) =>
|
||||
["documents", companyId, filters] as const,
|
||||
detail: (companyId: string, documentId: string) =>
|
||||
["documents", "detail", companyId, documentId] as const,
|
||||
backlinks: (companyId: string, documentId: string) =>
|
||||
["documents", "backlinks", companyId, documentId] as const,
|
||||
reviewIndex: (issueId: string, key: string) =>
|
||||
["documents", "review-index", issueId, key] as const,
|
||||
},
|
||||
artifacts: {
|
||||
list: (
|
||||
companyId: string,
|
||||
|
||||
@@ -78,6 +78,11 @@ export const statusBadge: Record<string, string> = {
|
||||
blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
|
||||
done: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
|
||||
cancelled: "bg-muted text-muted-foreground",
|
||||
|
||||
// Document statuses — draft (blue) → in_review (violet) → approved (green)
|
||||
// → archived (neutral). `in_review`, `approved`, and `archived` reuse hues
|
||||
// defined above; only `draft` is document-specific.
|
||||
draft: "bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300",
|
||||
};
|
||||
|
||||
export const statusBadgeDefault = "bg-muted text-muted-foreground";
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent, CompanyDocumentSummary } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Documents } from "./Documents";
|
||||
|
||||
const mockDocumentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
||||
// A mutable search-params store so tests can seed the URL state and observe writes.
|
||||
const searchStore = vi.hoisted(() => ({ value: "" }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useSearchParams: () => {
|
||||
const params = new URLSearchParams(searchStore.value);
|
||||
const setParams = (
|
||||
next: URLSearchParams | Record<string, string> | ((prev: URLSearchParams) => URLSearchParams),
|
||||
) => {
|
||||
const resolved =
|
||||
typeof next === "function"
|
||||
? next(new URLSearchParams(searchStore.value))
|
||||
: new URLSearchParams(next as Record<string, string>);
|
||||
searchStore.value = resolved.toString();
|
||||
};
|
||||
return [params, setParams];
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/documents", () => ({
|
||||
documentsApi: mockDocumentsApi,
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: mockAgentsApi,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
function makeDocument(overrides: Partial<CompanyDocumentSummary>): CompanyDocumentSummary {
|
||||
return {
|
||||
id: "doc-1",
|
||||
companyId: "company-1",
|
||||
title: "Paperclip Documents review flow",
|
||||
format: "markdown",
|
||||
status: "in_review",
|
||||
documentType: "spec",
|
||||
summary: "How review works",
|
||||
ownerAgentId: "agent-1",
|
||||
ownerUserId: null,
|
||||
latestRevisionId: "rev-1",
|
||||
latestRevisionNumber: 12,
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: "agent-1",
|
||||
updatedByUserId: null,
|
||||
lockedAt: null,
|
||||
lockedByAgentId: null,
|
||||
lockedByUserId: null,
|
||||
sourceTrust: null,
|
||||
archivedAt: null,
|
||||
archivedByAgentId: null,
|
||||
archivedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-06T00:00:00Z"),
|
||||
backlinks: [],
|
||||
feedbackCounts: {
|
||||
openComments: 3,
|
||||
resolvedComments: 0,
|
||||
openReviewThreads: 0,
|
||||
resolvedReviewThreads: 0,
|
||||
pendingSuggestions: 2,
|
||||
acceptedSuggestions: 0,
|
||||
rejectedSuggestions: 0,
|
||||
staleAnchors: 0,
|
||||
orphanedAnchors: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgent(overrides: Partial<Agent>): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "ClaudeCoder",
|
||||
urlKey: "claudecoder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Documents library", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
searchStore.value = "";
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
mockAgentsApi.list.mockResolvedValue([makeAgent({})]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root!.unmount());
|
||||
root = null;
|
||||
}
|
||||
container.remove();
|
||||
queryClient.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
root = createRoot(container);
|
||||
await act(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Documents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
// Allow react-query microtasks to flush.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
it("renders a row per document with title and feedback counts", async () => {
|
||||
mockDocumentsApi.list.mockResolvedValue([
|
||||
makeDocument({ id: "doc-1", title: "Review flow spec" }),
|
||||
makeDocument({ id: "doc-2", title: "Onboarding plan", documentType: "plan", status: "draft" }),
|
||||
]);
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Review flow spec");
|
||||
expect(container.textContent).toContain("Onboarding plan");
|
||||
// Feedback counts (3 open comments, 2 pending suggestions) are surfaced.
|
||||
expect(container.textContent).toContain("3");
|
||||
expect(container.textContent).toContain("2");
|
||||
expect(mockDocumentsApi.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ limit: 100 }));
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no documents and no filters", async () => {
|
||||
mockDocumentsApi.list.mockResolvedValue([]);
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("No documents yet");
|
||||
});
|
||||
|
||||
it("renders an active filter chip and the filtered empty state when a status filter is set", async () => {
|
||||
searchStore.value = "status=approved";
|
||||
mockDocumentsApi.list.mockResolvedValue([]);
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Status: Approved");
|
||||
expect(container.textContent).toContain("No documents match these filters");
|
||||
});
|
||||
|
||||
it("passes the status filter through to the API query", async () => {
|
||||
searchStore.value = "status=in_review";
|
||||
mockDocumentsApi.list.mockResolvedValue([makeDocument({})]);
|
||||
|
||||
await render();
|
||||
|
||||
expect(mockDocumentsApi.list).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({ status: ["in_review"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, FileText, Search, SlidersHorizontal, X } from "lucide-react";
|
||||
import type { DocumentStatus, DocumentType } from "@paperclipai/shared";
|
||||
import { DOCUMENT_STATUSES, DOCUMENT_TYPES } from "@paperclipai/shared";
|
||||
import { documentsApi, type CompanyDocumentListFilters } from "../api/documents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { DocumentRow, type DocumentOwner } from "../components/documents/DocumentRow";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 250;
|
||||
|
||||
const STATUS_LABELS: Record<DocumentStatus, string> = {
|
||||
draft: "Draft",
|
||||
in_review: "In review",
|
||||
approved: "Approved",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<DocumentType, string> = {
|
||||
plan: "Plan",
|
||||
spec: "Spec",
|
||||
brief: "Brief",
|
||||
report: "Report",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
type SortOption = "updated_desc" | "updated_asc" | "title_asc" | "feedback_desc" | "status";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
{ value: "updated_desc", label: "Updated (newest)" },
|
||||
{ value: "updated_asc", label: "Updated (oldest)" },
|
||||
{ value: "title_asc", label: "Title (A–Z)" },
|
||||
{ value: "feedback_desc", label: "Open feedback" },
|
||||
{ value: "status", label: "Status" },
|
||||
];
|
||||
|
||||
function parseMulti<T extends string>(values: string[], allowed: readonly T[]): T[] {
|
||||
const set = new Set<string>(allowed);
|
||||
return values.filter((value): value is T => set.has(value));
|
||||
}
|
||||
|
||||
function parseSort(value: string | null): SortOption {
|
||||
return SORT_OPTIONS.some((option) => option.value === value)
|
||||
? (value as SortOption)
|
||||
: "updated_desc";
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<DocumentStatus, number> = {
|
||||
draft: 0,
|
||||
in_review: 1,
|
||||
approved: 2,
|
||||
archived: 3,
|
||||
};
|
||||
|
||||
export function Documents() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const query = searchParams.get("q") ?? "";
|
||||
const statusFilter = useMemo(
|
||||
() => parseMulti(searchParams.getAll("status"), DOCUMENT_STATUSES),
|
||||
[searchParams],
|
||||
);
|
||||
const typeFilter = useMemo(
|
||||
() => parseMulti(searchParams.getAll("type"), DOCUMENT_TYPES),
|
||||
[searchParams],
|
||||
);
|
||||
const ownerFilter = searchParams.get("owner") ?? undefined;
|
||||
const hasFeedback = searchParams.get("feedback") === "1";
|
||||
const trustedOnly = searchParams.get("trusted") === "1";
|
||||
const includeArchived = statusFilter.includes("archived");
|
||||
const sort = parseSort(searchParams.get("sort"));
|
||||
|
||||
const [draftQuery, setDraftQuery] = useState(query);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Documents" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
// Keep the search box synced when `q` changes from outside (back/forward).
|
||||
useEffect(() => {
|
||||
setDraftQuery((prev) => (prev.trim() === query ? prev : query));
|
||||
}, [query]);
|
||||
|
||||
// Debounce the search box into the `q` URL param.
|
||||
useEffect(() => {
|
||||
const trimmed = draftQuery.trim();
|
||||
if (trimmed === query) return;
|
||||
const timer = setTimeout(() => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (trimmed) next.set("q", trimmed);
|
||||
else next.delete("q");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [draftQuery, query, setSearchParams]);
|
||||
|
||||
const updateParams = useCallback(
|
||||
(mutate: (params: URLSearchParams) => void) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
mutate(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const toggleMulti = useCallback(
|
||||
(key: "status" | "type", value: string) => {
|
||||
updateParams((params) => {
|
||||
const current = params.getAll(key);
|
||||
params.delete(key);
|
||||
const next = current.includes(value)
|
||||
? current.filter((entry) => entry !== value)
|
||||
: [...current, value];
|
||||
for (const entry of next) params.append(key, entry);
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const toggleFlag = useCallback(
|
||||
(key: "feedback" | "trusted") => {
|
||||
updateParams((params) => {
|
||||
if (params.get(key) === "1") params.delete(key);
|
||||
else params.set(key, "1");
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const setSort = useCallback(
|
||||
(value: SortOption) => {
|
||||
updateParams((params) => {
|
||||
if (value === "updated_desc") params.delete("sort");
|
||||
else params.set("sort", value);
|
||||
});
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setDraftQuery("");
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const filters: CompanyDocumentListFilters = useMemo(
|
||||
() => ({
|
||||
q: query || undefined,
|
||||
status: statusFilter.length > 0 ? statusFilter : undefined,
|
||||
type: typeFilter.length > 0 ? typeFilter : undefined,
|
||||
ownerAgentId: ownerFilter,
|
||||
hasOpenFeedback: hasFeedback || undefined,
|
||||
trustedOnly: trustedOnly || undefined,
|
||||
includeArchived: includeArchived || undefined,
|
||||
limit: 100,
|
||||
}),
|
||||
[query, statusFilter, typeFilter, ownerFilter, hasFeedback, trustedOnly, includeArchived],
|
||||
);
|
||||
|
||||
const {
|
||||
data: documents,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.documents.list(selectedCompanyId!, filters as Record<string, unknown>),
|
||||
queryFn: () => documentsApi.list(selectedCompanyId!, filters),
|
||||
enabled: !!selectedCompanyId,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const ownerById = useMemo(() => {
|
||||
const map = new Map<string, DocumentOwner>();
|
||||
for (const agent of agents ?? []) {
|
||||
map.set(agent.id, { name: agent.name });
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
const list = [...(documents ?? [])];
|
||||
list.sort((a, b) => {
|
||||
switch (sort) {
|
||||
case "updated_asc":
|
||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
||||
case "title_asc":
|
||||
return (a.title ?? "").localeCompare(b.title ?? "");
|
||||
case "feedback_desc": {
|
||||
const open = (doc: typeof a) =>
|
||||
doc.feedbackCounts.openComments +
|
||||
doc.feedbackCounts.openReviewThreads +
|
||||
doc.feedbackCounts.pendingSuggestions;
|
||||
return open(b) - open(a);
|
||||
}
|
||||
case "status":
|
||||
return STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||
case "updated_desc":
|
||||
default:
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}, [documents, sort]);
|
||||
|
||||
const activeFilterCount =
|
||||
statusFilter.length + typeFilter.length + (ownerFilter ? 1 : 0) + (hasFeedback ? 1 : 0) + (trustedOnly ? 1 : 0);
|
||||
const hasActiveFilters = activeFilterCount > 0 || query.length > 0;
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={FileText} message="Select a company to view documents." />;
|
||||
}
|
||||
|
||||
const ownerAgent = ownerFilter ? ownerById.get(ownerFilter) : undefined;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl space-y-4">
|
||||
{/* Search + filter controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={draftQuery}
|
||||
onChange={(event) => setDraftQuery(event.currentTarget.value)}
|
||||
placeholder="Search documents..."
|
||||
aria-label="Search documents"
|
||||
className="h-9 pl-9 pr-9 text-sm"
|
||||
/>
|
||||
{draftQuery.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftQuery("")}
|
||||
aria-label="Clear document search"
|
||||
className="absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Status filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8", statusFilter.length > 0 && "bg-accent")}
|
||||
data-testid="documents-status-filter"
|
||||
>
|
||||
Status{statusFilter.length > 0 ? ` (${statusFilter.length})` : ""}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{DOCUMENT_STATUSES.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={statusFilter.includes(status)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
toggleMulti("status", status);
|
||||
}}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Type filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8", typeFilter.length > 0 && "bg-accent")}
|
||||
data-testid="documents-type-filter"
|
||||
>
|
||||
Type{typeFilter.length > 0 ? ` (${typeFilter.length})` : ""}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuLabel>Type</DropdownMenuLabel>
|
||||
{DOCUMENT_TYPES.map((type) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={type}
|
||||
checked={typeFilter.includes(type)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
toggleMulti("type", type);
|
||||
}}
|
||||
>
|
||||
{TYPE_LABELS[type]}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Owner filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("h-8", ownerFilter && "bg-accent")}
|
||||
data-testid="documents-owner-filter"
|
||||
>
|
||||
{ownerAgent ? ownerAgent.name : "Owner"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-72 w-52 overflow-y-auto">
|
||||
<DropdownMenuLabel>Owner</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => updateParams((params) => params.delete("owner"))}
|
||||
className="justify-between"
|
||||
>
|
||||
Any owner
|
||||
{!ownerFilter ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(agents ?? []).map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onSelect={() => updateParams((params) => params.set("owner", agent.id))}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="truncate">{agent.name}</span>
|
||||
{ownerFilter === agent.id ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Toggles + sort */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", (hasFeedback || trustedOnly) && "bg-accent")}
|
||||
aria-label="More filters and sort"
|
||||
title="More filters and sort"
|
||||
data-testid="documents-more-filters"
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Filters</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={hasFeedback}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
toggleFlag("feedback");
|
||||
}}
|
||||
>
|
||||
With open feedback
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={trustedOnly}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
toggleFlag("trusted");
|
||||
}}
|
||||
>
|
||||
Trusted sources only
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onSelect={() => setSort(option.value)}
|
||||
className="justify-between"
|
||||
>
|
||||
{option.label}
|
||||
{sort === option.value ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filter chips */}
|
||||
{hasActiveFilters ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
{statusFilter.map((status) => (
|
||||
<FilterChip key={`status-${status}`} label={`Status: ${STATUS_LABELS[status]}`} onRemove={() => toggleMulti("status", status)} />
|
||||
))}
|
||||
{typeFilter.map((type) => (
|
||||
<FilterChip key={`type-${type}`} label={`Type: ${TYPE_LABELS[type]}`} onRemove={() => toggleMulti("type", type)} />
|
||||
))}
|
||||
{ownerFilter ? (
|
||||
<FilterChip
|
||||
label={`Owner: ${ownerAgent?.name ?? "Unknown"}`}
|
||||
onRemove={() => updateParams((params) => params.delete("owner"))}
|
||||
/>
|
||||
) : null}
|
||||
{hasFeedback ? <FilterChip label="Open feedback" onRemove={() => toggleFlag("feedback")} /> : null}
|
||||
{trustedOnly ? <FilterChip label="Trusted sources" onRemove={() => toggleFlag("trusted")} /> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="rounded-md px-2 py-1 font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{(error as Error).message}</p> : null}
|
||||
|
||||
{isLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : sortedDocuments.length === 0 ? (
|
||||
hasActiveFilters ? (
|
||||
<EmptyState icon={FileText} message="No documents match these filters." action="Clear filters" onAction={clearFilters} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
message="No documents yet. Plans, specs, and briefs from across the company will appear here for review."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
{sortedDocuments.map((document) => (
|
||||
<DocumentRow
|
||||
key={document.id}
|
||||
document={document}
|
||||
to={`/documents/${document.id}`}
|
||||
owner={
|
||||
document.ownerAgentId ? ownerById.get(document.ownerAgentId) ?? null : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-accent/60 px-2 py-1 font-medium text-foreground">
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove filter ${label}`}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user