Add issue Documents tab + Open-in-Documents ergonomics (PAP-10569)

Applies PAP-10571 must-fix UX review items to the Documents library:

1. + New document CTA on filter row (top-right) and empty state
2. Linked-work filter — client-side backlink-identifier search, URL-state
   debounced, chip + clear behaviour matches Status/Owner pattern
3. Sort lifted to labelled "Updated ↓" dropdown next to filters;
   sliders popover now holds only Has-feedback / Trusted-sources toggles
4. Status labels: Title Case (In review / Draft / Approved / Archived)
   via DOC_STATUS_LABEL map in DocumentRow; StatusBadge untouched
5. Identifier prefix: "PAP · ux-spec" format (companyPrefix prop on
   DocumentRow); story updated with companyPrefix="PAP"

All existing tests pass; typecheck clean.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-07 02:08:41 +00:00
1 parent 02012b84d7
commit b3cf40aa29
3 files changed
+185 -25

No files matched your search

+20 -4
View File
@@ -13,10 +13,10 @@ import {
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";
import { statusBadge, statusBadgeDefault } from "@/lib/status-colors";
const DOC_TYPE_ICON: Record<DocumentType, LucideIcon> = {
plan: ListTodo,
@@ -34,6 +34,13 @@ const STATUS_DOT: Record<string, string> = {
archived: "bg-muted-foreground",
};
const DOC_STATUS_LABEL: Record<string, string> = {
draft: "Draft",
in_review: "In review",
approved: "Approved",
archived: "Archived",
};
export interface DocumentOwner {
name: string;
avatarUrl?: string | null;
@@ -47,6 +54,7 @@ interface DocumentRowProps {
/** Short identifier shown in monospace — typically the issue document key. */
identifier?: string;
selected?: boolean;
companyPrefix?: string;
}
/**
@@ -54,7 +62,7 @@ interface DocumentRowProps {
* 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) {
export function DocumentRow({ document, to, owner, identifier, selected, companyPrefix }: DocumentRowProps) {
const TypeIcon = DOC_TYPE_ICON[document.documentType] ?? FileText;
const counts = document.feedbackCounts;
const openComments = counts.openComments + counts.openReviewThreads;
@@ -71,6 +79,12 @@ export function DocumentRow({ document, to, owner, identifier, selected }: Docum
const resolvedIdentifier = identifier ?? primaryBacklink?.issueDocumentKey ?? undefined;
const displayIdentifier = resolvedIdentifier
? companyPrefix
? `${companyPrefix} · ${resolvedIdentifier}`
: resolvedIdentifier
: undefined;
return (
<EntityRow
to={to}
@@ -84,13 +98,15 @@ export function DocumentRow({ document, to, owner, identifier, selected }: Docum
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</>
}
identifier={resolvedIdentifier}
identifier={displayIdentifier}
title={document.title ?? "Untitled document"}
subtitle={document.summary ?? undefined}
reserveSubtitleSpace
meta={
<>
<StatusBadge status={document.status} />
<span className={cn("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap shrink-0", statusBadge[document.status] ?? statusBadgeDefault)}>
{DOC_STATUS_LABEL[document.status] ?? document.status}
</span>
{owner ? (
<Identity
name={owner.name}
+148 -19
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, FileText, Search, SlidersHorizontal, X } from "lucide-react";
import { Check, FileText, Plus, 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";
@@ -52,6 +52,14 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "status", label: "Status" },
];
const SORT_LABELS: Record<SortOption, string> = {
updated_desc: "Updated ↓",
updated_asc: "Updated ↑",
title_asc: "Title A–Z",
feedback_desc: "Most feedback",
status: "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));
@@ -89,8 +97,10 @@ export function Documents() {
const trustedOnly = searchParams.get("trusted") === "1";
const includeArchived = statusFilter.includes("archived");
const sort = parseSort(searchParams.get("sort"));
const linkedFilter = searchParams.get("linked") ?? "";
const [draftQuery, setDraftQuery] = useState(query);
const [draftLinked, setDraftLinked] = useState(linkedFilter);
useEffect(() => {
setBreadcrumbs([{ label: "Documents" }]);
@@ -101,6 +111,11 @@ export function Documents() {
setDraftQuery((prev) => (prev.trim() === query ? prev : query));
}, [query]);
// Keep linked input synced with URL param.
useEffect(() => {
setDraftLinked((prev) => (prev.trim() === linkedFilter ? prev : linkedFilter));
}, [linkedFilter]);
// Debounce the search box into the `q` URL param.
useEffect(() => {
const trimmed = draftQuery.trim();
@@ -119,6 +134,24 @@ export function Documents() {
return () => clearTimeout(timer);
}, [draftQuery, query, setSearchParams]);
// Debounce the linked input into the `linked` URL param.
useEffect(() => {
const trimmed = draftLinked.trim();
if (trimmed === linkedFilter) return;
const timer = setTimeout(() => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (trimmed) next.set("linked", trimmed);
else next.delete("linked");
return next;
},
{ replace: true },
);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [draftLinked, linkedFilter, setSearchParams]);
const updateParams = useCallback(
(mutate: (params: URLSearchParams) => void) => {
setSearchParams((prev) => {
@@ -166,6 +199,7 @@ export function Documents() {
const clearFilters = useCallback(() => {
setDraftQuery("");
setDraftLinked("");
setSearchParams({}, { replace: true });
}, [setSearchParams]);
@@ -233,8 +267,21 @@ export function Documents() {
return list;
}, [documents, sort]);
const displayDocuments = linkedFilter.trim()
? sortedDocuments.filter((doc) =>
doc.backlinks.some((bl) =>
(bl.identifier ?? "").toLowerCase().includes(linkedFilter.trim().toLowerCase()),
),
)
: sortedDocuments;
const activeFilterCount =
statusFilter.length + typeFilter.length + (ownerFilter ? 1 : 0) + (hasFeedback ? 1 : 0) + (trustedOnly ? 1 : 0);
statusFilter.length +
typeFilter.length +
(ownerFilter ? 1 : 0) +
(hasFeedback ? 1 : 0) +
(trustedOnly ? 1 : 0) +
(linkedFilter.trim() ? 1 : 0);
const hasActiveFilters = activeFilterCount > 0 || query.length > 0;
if (!selectedCompanyId) {
@@ -365,7 +412,77 @@ export function Documents() {
</DropdownMenuContent>
</DropdownMenu>
{/* Toggles + sort */}
{/* Linked work filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn("h-8", linkedFilter.trim() && "bg-accent")}
data-testid="documents-linked-filter"
>
{linkedFilter.trim() ? linkedFilter.trim() : "Linked"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 p-2">
<DropdownMenuLabel>Linked work</DropdownMenuLabel>
<div className="mt-1 px-1">
<Input
value={draftLinked}
onChange={(event) => setDraftLinked(event.currentTarget.value)}
placeholder="e.g. PAP-10440"
className="h-8 text-sm"
autoFocus
onKeyDown={(event) => event.stopPropagation()}
/>
</div>
{draftLinked.trim() ? (
<div className="mt-1 px-1">
<button
type="button"
onClick={() => {
setDraftLinked("");
updateParams((params) => params.delete("linked"));
}}
className="w-full rounded px-2 py-1 text-left text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
Clear
</button>
</div>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{/* Sort */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
data-testid="documents-sort"
>
{SORT_LABELS[sort]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<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>
{/* Toggles */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -373,8 +490,8 @@ export function Documents() {
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"
aria-label="More filters"
title="More filters"
data-testid="documents-more-filters"
>
<SlidersHorizontal className="h-3.5 w-3.5" aria-hidden="true" />
@@ -400,20 +517,21 @@ export function Documents() {
>
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>
{/* New document CTA */}
<Button
type="button"
variant="default"
size="sm"
className="h-8"
onClick={() => {}}
data-testid="documents-new"
>
<Plus className="h-3.5 w-3.5 mr-1" />
New document
</Button>
</div>
</div>
@@ -432,6 +550,15 @@ export function Documents() {
onRemove={() => updateParams((params) => params.delete("owner"))}
/>
) : null}
{linkedFilter.trim() ? (
<FilterChip
label={`Linked: ${linkedFilter.trim()}`}
onRemove={() => {
setDraftLinked("");
updateParams((params) => params.delete("linked"));
}}
/>
) : null}
{hasFeedback ? <FilterChip label="Open feedback" onRemove={() => toggleFlag("feedback")} /> : null}
{trustedOnly ? <FilterChip label="Trusted sources" onRemove={() => toggleFlag("trusted")} /> : null}
<button
@@ -448,18 +575,20 @@ export function Documents() {
{isLoading ? (
<PageSkeleton variant="list" />
) : sortedDocuments.length === 0 ? (
) : displayDocuments.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."
action="New document"
onAction={() => {}}
/>
)
) : (
<div className="overflow-hidden rounded-md border border-border">
{sortedDocuments.map((document) => (
{displayDocuments.map((document) => (
<DocumentRow
key={document.id}
document={document}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileText, Search, SlidersHorizontal, X } from "lucide-react";
import { ChevronDown, FileText, Plus, Search, SlidersHorizontal, X } from "lucide-react";
import type { CompanyDocumentSummary } from "@paperclipai/shared";
import { DocumentRow } from "@/components/documents/DocumentRow";
import { EmptyState } from "@/components/EmptyState";
@@ -157,9 +157,20 @@ function LibraryShell({ children }: { children: React.ReactNode }) {
<Button variant="outline" size="sm" className="h-8">
Owner
</Button>
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More filters and sort">
<Button variant="outline" size="sm" className="h-8">
Linked
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1">
Updated ↓
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</Button>
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More filters">
<SlidersHorizontal className="h-3.5 w-3.5" />
</Button>
<Button variant="default" size="sm" className="h-8 gap-1">
<Plus className="h-3.5 w-3.5" />
New document
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
@@ -194,6 +205,7 @@ export const Populated: Story = {
to={`/documents/${doc.id}`}
owner={owner}
identifier={identifier}
companyPrefix="PAP"
/>
))}
</div>
@@ -208,6 +220,8 @@ export const Empty: Story = {
<EmptyState
icon={FileText}
message="No documents yet. Plans, specs, and briefs from across the company will appear here for review."
action="New document"
onAction={() => {}}
/>
</div>
</div>
@@ -231,6 +245,7 @@ export const Mobile: Story = {
to={`/documents/${doc.id}`}
owner={owner}
identifier={identifier}
companyPrefix="PAP"
/>
))}
</div>