mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 05:56:19 +01:00
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:
1 parent
02012b84d7
commit
b3cf40aa29
3 files changed
+185
-25
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user