From e88b81a14cbe999b869dcb587d26add423a62a87 Mon Sep 17 00:00:00 2001 From: "Claude product-architect (Opus 4.6)" Date: Mon, 2 Mar 2026 16:10:07 +0000 Subject: [PATCH 1/4] feat(documents): add document linking section to work item detail page Adds a Documents section to the work item detail page that allows users to link, view, and unlink Paperless-ngx documents. Includes document picker modal, inline detail panel, loading/error/empty states, and full accessibility support. Fixes #357 Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) Co-Authored-By: Claude backend-developer (Haiku 4.5) Co-Authored-By: Claude frontend-developer (Haiku 4.5) Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) --- .claude/agent-memory/ux-designer/MEMORY.md | 14 + .../documents/LinkedDocumentCard.module.css | 181 ++++++ .../documents/LinkedDocumentCard.test.tsx | 334 +++++++++++ .../documents/LinkedDocumentCard.tsx | 108 ++++ .../LinkedDocumentsSection.module.css | 357 ++++++++++++ .../documents/LinkedDocumentsSection.test.tsx | 532 ++++++++++++++++++ .../documents/LinkedDocumentsSection.tsx | 335 +++++++++++ client/src/hooks/useDocumentLinks.test.ts | 333 +++++++++++ client/src/hooks/useDocumentLinks.ts | 96 ++++ client/src/lib/documentLinksApi.test.ts | 208 +++++++ client/src/lib/documentLinksApi.ts | 32 ++ .../WorkItemDetailPage/WorkItemDetailPage.tsx | 6 + wiki | 2 +- 13 files changed, 2537 insertions(+), 1 deletion(-) create mode 100644 client/src/components/documents/LinkedDocumentCard.module.css create mode 100644 client/src/components/documents/LinkedDocumentCard.test.tsx create mode 100644 client/src/components/documents/LinkedDocumentCard.tsx create mode 100644 client/src/components/documents/LinkedDocumentsSection.module.css create mode 100644 client/src/components/documents/LinkedDocumentsSection.test.tsx create mode 100644 client/src/components/documents/LinkedDocumentsSection.tsx create mode 100644 client/src/hooks/useDocumentLinks.test.ts create mode 100644 client/src/hooks/useDocumentLinks.ts create mode 100644 client/src/lib/documentLinksApi.test.ts create mode 100644 client/src/lib/documentLinksApi.ts diff --git a/.claude/agent-memory/ux-designer/MEMORY.md b/.claude/agent-memory/ux-designer/MEMORY.md index 275c47fb7..d36d7c57d 100644 --- a/.claude/agent-memory/ux-designer/MEMORY.md +++ b/.claude/agent-memory/ux-designer/MEMORY.md @@ -83,6 +83,20 @@ Common token mistakes in this PR to watch for in future reviews: WorkItemPicker (`client/src/components/WorkItemPicker/`) is the existing reference for search-as-you-type inline pickers. DocumentBrowser is a richer version of that pattern — a full grid browser rather than a dropdown list. +## Story 8.4 — Document Linking Spec (Issue #357) + +- Documents section: full-width panel BELOW the two-column contentGrid, ABOVE the footer +- Linked doc display: mini-card strip using CSS Grid `auto-fill minmax(180px, 1fr)` — NOT the full DocumentCard (different semantics) +- Card action tray: View / Open in Paperless / Unlink — tray uses `--color-bg-secondary` background with `border-top: 1px solid var(--color-border)` +- Unlink confirmation: uses `.btnDanger` (outline red, not `.btnConfirmDelete` solid red) — unlinking is reversible +- Picker modal: wide (860px max), not the default 28rem `.modalContent` size — `min(860px, calc(100vw - 2rem))` +- Single-click document selection in modal (no separate confirm step) — linking is reversible via Unlink +- Not-configured banner: neutral tokens (`--color-bg-secondary`, `--color-border`) NOT `--color-primary-bg` which is blue-tinted +- Count badge in heading uses `--color-bg-tertiary` + `--color-text-muted` (neutral pill, not status-colored) +- Skeleton: show 2 cards (not spinner text); uses same shimmer as DocumentSkeleton from 8.3 +- `srAnnouncement` visually-hidden live region announces "Document linked: title" / "Document unlinked: title" +- Mobile modal: full-viewport sheet (width:100vw; height:100vh; border-radius:0) at < 768px + ## PR #364 Review Findings — Document Browser (for future reference) Common misses in this PR to watch for in card/grid components: diff --git a/client/src/components/documents/LinkedDocumentCard.module.css b/client/src/components/documents/LinkedDocumentCard.module.css new file mode 100644 index 000000000..b4607a317 --- /dev/null +++ b/client/src/components/documents/LinkedDocumentCard.module.css @@ -0,0 +1,181 @@ +.card { + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + display: flex; + flex-direction: column; + transition: + box-shadow var(--transition-normal), + border-color var(--transition-normal); +} + +.card:hover { + box-shadow: var(--shadow-md); + border-color: var(--color-border-strong); +} + +.thumbContainer { + position: relative; + aspect-ratio: 4 / 3; + overflow: hidden; + background: var(--color-bg-tertiary); +} + +.thumb { + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumbFallback { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + font-size: 2rem; + opacity: 0.35; +} + +.body { + padding: var(--spacing-3); + display: flex; + flex-direction: column; + gap: var(--spacing-1); + flex: 1; +} + +.title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + margin: 0; +} + +.meta { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + margin: 0; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-1); + margin-top: var(--spacing-0-5); +} + +.tagChip { + padding: var(--spacing-0-5) var(--spacing-1-5); + font-size: 0.625rem; + background: var(--color-primary-bg); + color: var(--color-primary-badge-text); + border-radius: var(--radius-full); + white-space: nowrap; +} + +.actions { + display: flex; + align-items: center; + gap: var(--spacing-1); + padding: var(--spacing-2) var(--spacing-3); + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); +} + +.viewButton { + flex: 1; + padding: var(--spacing-1-5) 0; + background: transparent; + border: none; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-primary); + cursor: pointer; + text-align: left; + transition: color var(--transition-fast); + border-radius: var(--radius-sm); +} + +.viewButton:hover { + color: var(--color-primary-hover); +} + +.viewButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-subtle); +} + +.openLink { + flex-shrink: 0; + padding: var(--spacing-1); + background: transparent; + border: none; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + cursor: pointer; + border-radius: var(--radius-sm); + transition: color var(--transition-fast); + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.openLink:hover { + color: var(--color-primary); +} + +.openLink:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-subtle); +} + +.unlinkButton { + flex-shrink: 0; + padding: var(--spacing-1); + background: transparent; + border: none; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + cursor: pointer; + border-radius: var(--radius-sm); + transition: + color var(--transition-fast), + background var(--transition-fast); +} + +.unlinkButton:hover { + color: var(--color-danger); + background: var(--color-overlay-delete); +} + +.unlinkButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-danger); +} + +/* Touch targets on mobile */ +@media (max-width: 1023px) { + .viewButton, + .openLink, + .unlinkButton { + min-height: 44px; + min-width: 44px; + display: flex; + align-items: center; + justify-content: center; + } + + .viewButton { + text-align: center; + flex: 1; + } +} diff --git a/client/src/components/documents/LinkedDocumentCard.test.tsx b/client/src/components/documents/LinkedDocumentCard.test.tsx new file mode 100644 index 000000000..210de60bc --- /dev/null +++ b/client/src/components/documents/LinkedDocumentCard.test.tsx @@ -0,0 +1,334 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { jest } from '@jest/globals'; +import type * as LinkedDocumentCardModule from './LinkedDocumentCard.js'; +import type { DocumentLinkWithMetadata } from '@cornerstone/shared'; + +const mockGetDocumentThumbnailUrl = jest.fn<(id: number) => string>(); + +jest.unstable_mockModule('../../lib/paperlessApi.js', () => ({ + getPaperlessStatus: jest.fn(), + listPaperlessDocuments: jest.fn(), + listPaperlessTags: jest.fn(), + getPaperlessDocument: jest.fn(), + getDocumentThumbnailUrl: mockGetDocumentThumbnailUrl, + getDocumentPreviewUrl: jest.fn(), +})); + +let LinkedDocumentCard: (typeof LinkedDocumentCardModule)['LinkedDocumentCard']; + +beforeEach(async () => { + ({ LinkedDocumentCard } = + (await import('./LinkedDocumentCard.js')) as typeof LinkedDocumentCardModule); + mockGetDocumentThumbnailUrl.mockReset(); + mockGetDocumentThumbnailUrl.mockImplementation((id) => `/api/paperless/documents/${id}/thumb`); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +const makeLink = (overrides: Partial = {}): DocumentLinkWithMetadata => ({ + id: 'link-1', + entityType: 'work_item', + entityId: 'wi-1', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + document: { + id: 42, + title: 'Invoice March', + content: null, + tags: [{ id: 1, name: 'Invoice', color: null, documentCount: 5 }], + created: '2026-01-15', + added: null, + modified: null, + correspondent: 'ACME Corp', + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, + ...overrides, +}); + +describe('LinkedDocumentCard', () => { + it('renders document title from link.document.title', () => { + render( + , + ); + expect(screen.getByRole('heading', { name: 'Invoice March' })).toBeInTheDocument(); + }); + + it('renders formatted date from link.document.created', () => { + render( + , + ); + // 2026-01-15 => "Jan 15, 2026" + expect(screen.getByText(/Jan 15, 2026/)).toBeInTheDocument(); + }); + + it('renders up to 2 tag chips', () => { + const link = makeLink({ + document: { + id: 42, + title: 'Invoice March', + content: null, + tags: [ + { id: 1, name: 'Invoice', color: null, documentCount: 5 }, + { id: 2, name: 'Work', color: null, documentCount: 3 }, + ], + created: '2026-01-15', + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, + }); + render( + , + ); + expect(screen.getByText('Invoice')).toBeInTheDocument(); + expect(screen.getByText('Work')).toBeInTheDocument(); + expect(screen.queryByText(/\+/)).not.toBeInTheDocument(); + }); + + it('shows "+N" overflow indicator when more than 2 tags exist', () => { + const link = makeLink({ + document: { + id: 42, + title: 'Invoice March', + content: null, + tags: [ + { id: 1, name: 'Invoice', color: null, documentCount: 5 }, + { id: 2, name: 'Work', color: null, documentCount: 3 }, + { id: 3, name: 'Archive', color: null, documentCount: 2 }, + { id: 4, name: 'Extra', color: null, documentCount: 1 }, + ], + created: '2026-01-15', + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, + }); + render( + , + ); + expect(screen.getByText('Invoice')).toBeInTheDocument(); + expect(screen.getByText('Work')).toBeInTheDocument(); + // 3rd and 4th tags are not shown individually + expect(screen.queryByText('Archive')).not.toBeInTheDocument(); + expect(screen.queryByText('Extra')).not.toBeInTheDocument(); + // Overflow indicator shows +2 (4 tags - 2 shown = 2 extra) + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('"View" button calls onView prop with the link', () => { + const onView = jest.fn(); + const link = makeLink(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /View document: Invoice March/i })); + expect(onView).toHaveBeenCalledWith(link); + }); + + it('"Open in Paperless" link is rendered when paperlessBaseUrl is set', () => { + const link = makeLink(); + render( + , + ); + const openLink = screen.getByRole('link', { + name: /Open document in Paperless: Invoice March/i, + }) as HTMLAnchorElement; + expect(openLink).toBeInTheDocument(); + expect(openLink.href).toBe('https://paperless.example.com/documents/42/details'); + }); + + it('"Open in Paperless" link has correct href with document ID', () => { + const link = makeLink({ paperlessDocumentId: 77 }); + render( + , + ); + const openLink = screen.getByRole('link', { + name: /Open document in Paperless/i, + }) as HTMLAnchorElement; + expect(openLink.href).toContain('/documents/77/details'); + }); + + it('"Open in Paperless" link is NOT rendered when paperlessBaseUrl is null', () => { + render( + , + ); + expect( + screen.queryByRole('link', { name: /Open document in Paperless/i }), + ).not.toBeInTheDocument(); + }); + + it('"Unlink" button calls onUnlink prop with the link', () => { + const onUnlink = jest.fn(); + const link = makeLink(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /Unlink document: Invoice March/i })); + expect(onUnlink).toHaveBeenCalledWith(link); + }); + + it('when link.document is null: only "Unlink" action visible, no View or Open buttons', () => { + const link = makeLink({ document: null }); + render( + , + ); + // View button should not appear + expect(screen.queryByRole('button', { name: /View document/i })).not.toBeInTheDocument(); + // Open in Paperless link should not appear (hasDocument is false) + expect( + screen.queryByRole('link', { name: /Open document in Paperless/i }), + ).not.toBeInTheDocument(); + // Unlink button should still be present + expect(screen.getByRole('button', { name: /Unlink document/i })).toBeInTheDocument(); + }); + + it('shows fallback title Document # when link.document is null', () => { + const link = makeLink({ paperlessDocumentId: 99, document: null }); + render( + , + ); + expect(screen.getByRole('heading', { name: 'Document #99' })).toBeInTheDocument(); + }); + + it('does not render date section when document.created is null', () => { + const link = makeLink({ + document: { + id: 42, + title: 'Invoice March', + content: null, + tags: [], + created: null, + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, + }); + render( + , + ); + // No formatted date text visible + expect(screen.queryByText(/^[A-Z][a-z]+ \d+, \d{4}$/)).not.toBeInTheDocument(); + }); + + it('does not render tag section when document has no tags', () => { + const link = makeLink({ + document: { + id: 42, + title: 'Invoice March', + content: null, + tags: [], + created: '2026-01-15', + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, + }); + render( + , + ); + expect(screen.queryByText(/\+/)).not.toBeInTheDocument(); + }); + + it('"Open in Paperless" link opens in new tab with noopener noreferrer', () => { + render( + , + ); + const openLink = screen.getByRole('link', { + name: /Open document in Paperless/i, + }) as HTMLAnchorElement; + expect(openLink).toHaveAttribute('target', '_blank'); + expect(openLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); +}); diff --git a/client/src/components/documents/LinkedDocumentCard.tsx b/client/src/components/documents/LinkedDocumentCard.tsx new file mode 100644 index 000000000..bf4dc2f19 --- /dev/null +++ b/client/src/components/documents/LinkedDocumentCard.tsx @@ -0,0 +1,108 @@ +import { useState } from 'react'; +import type { DocumentLinkWithMetadata } from '@cornerstone/shared'; +import { getDocumentThumbnailUrl } from '../../lib/paperlessApi.js'; +import styles from './LinkedDocumentCard.module.css'; + +interface LinkedDocumentCardProps { + link: DocumentLinkWithMetadata; + paperlessBaseUrl: string | null; + onView: (link: DocumentLinkWithMetadata) => void; + onUnlink: (link: DocumentLinkWithMetadata) => void; +} + +export function LinkedDocumentCard({ + link, + paperlessBaseUrl, + onView, + onUnlink, +}: LinkedDocumentCardProps) { + const [thumbError, setThumbError] = useState(false); + + const thumbUrl = getDocumentThumbnailUrl(link.paperlessDocumentId); + const hasDocument = link.document !== null; + const title = link.document?.title ?? `Document #${link.paperlessDocumentId}`; + const created = link.document?.created ?? null; + const tags = link.document?.tags ?? []; + + return ( +
+
+ {!thumbError && hasDocument && ( + {title} setThumbError(true)} + /> + )} + {(thumbError || !hasDocument) && ( + + )} +
+ +
+

{title}

+ + {created && ( +

+ {new Date(created).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +

+ )} + + {tags.length > 0 && ( +
+ {tags.slice(0, 2).map((tag) => ( + + {tag.name} + + ))} + {tags.length > 2 && +{tags.length - 2}} +
+ )} +
+ +
+ {hasDocument && ( + + )} + + {hasDocument && paperlessBaseUrl && ( + + ↗ + + )} + + +
+
+ ); +} diff --git a/client/src/components/documents/LinkedDocumentsSection.module.css b/client/src/components/documents/LinkedDocumentsSection.module.css new file mode 100644 index 000000000..6e34c3da2 --- /dev/null +++ b/client/src/components/documents/LinkedDocumentsSection.module.css @@ -0,0 +1,357 @@ +.section { + background: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-6); + margin-top: var(--spacing-8); +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-6); + gap: var(--spacing-4); +} + +.sectionTitle { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; + display: flex; + align-items: center; + gap: var(--spacing-3); +} + +.countBadge { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + background: var(--color-primary-bg); + color: var(--color-primary-badge-text); + padding: var(--spacing-1) var(--spacing-2-5); + border-radius: var(--radius-full); + min-width: 2rem; + text-align: center; +} + +.addButton { + padding: var(--spacing-2) var(--spacing-4); + background: var(--color-primary); + color: var(--color-primary-text); + border: none; + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color var(--transition-normal); + white-space: nowrap; +} + +.addButton:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.addButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.addButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cardStrip { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--spacing-4); + margin-bottom: var(--spacing-6); +} + +.skeletonStrip { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--spacing-4); + margin-bottom: var(--spacing-6); +} + +.emptyState { + text-align: center; + padding: var(--spacing-12) var(--spacing-8); + color: var(--color-text-muted); +} + +.emptyIcon { + font-size: 3rem; + display: block; + margin-bottom: var(--spacing-4); + opacity: 0.5; +} + +.emptyTitle { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.emptyBody { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin: 0; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +.notConfiguredBanner { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--spacing-5); + display: flex; + gap: var(--spacing-4); + margin-bottom: var(--spacing-6); +} + +.notConfiguredIcon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.notConfiguredTitle { + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-2) 0; + font-size: 0.9375rem; +} + +.notConfiguredBody { + margin: 0; + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + line-height: 1.5; +} + +.errorBanner { + background: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); + border-radius: var(--radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-4); + color: var(--color-danger-active); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-4); +} + +.retryButton { + background: transparent; + border: none; + color: var(--color-danger-active); + font-weight: var(--font-weight-medium); + font-size: var(--font-size-sm); + cursor: pointer; + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.retryButton:hover { + background: var(--color-overlay-danger); +} + +.detailPanel { + margin-top: var(--spacing-8); + margin-bottom: var(--spacing-4); +} + +/* Modal styles */ +.modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modalBackdrop { + position: absolute; + inset: 0; + background: var(--color-overlay); + cursor: pointer; +} + +.modalContent { + position: relative; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; + width: 90%; + max-width: 500px; +} + +.modalContentLarge { + max-width: 800px; +} + +.modalHeader { + padding: var(--spacing-6); + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-4); + flex-shrink: 0; +} + +.modalTitle { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; +} + +.modalSubtitle { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: var(--spacing-2) 0 0 0; +} + +.modalClose { + background: transparent; + border: none; + font-size: 1.75rem; + color: var(--color-text-muted); + cursor: pointer; + padding: 0; + line-height: 1; + transition: color var(--transition-fast); +} + +.modalClose:hover { + color: var(--color-text-primary); +} + +.pickerBody { + padding: var(--spacing-6); + flex: 1; + overflow-y: auto; +} + +.modalText { + font-size: 0.9375rem; + color: var(--color-text-secondary); + line-height: 1.6; + margin: 0 0 var(--spacing-6) 0; + padding: var(--spacing-6); +} + +.modalActions { + display: flex; + gap: var(--spacing-4); + padding: var(--spacing-6); + border-top: 1px solid var(--color-border); + background: var(--color-bg-secondary); + justify-content: flex-end; + flex-shrink: 0; +} + +.modalCancelButton, +.modalDeleteButton { + padding: var(--spacing-2-5) var(--spacing-5); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--transition-normal); +} + +.modalCancelButton { + background: var(--color-bg-primary); + color: var(--color-text-primary); +} + +.modalCancelButton:hover:not(:disabled) { + background: var(--color-bg-tertiary); + border-color: var(--color-text-placeholder); +} + +.modalDeleteButton { + background: var(--color-danger); + color: var(--color-danger-text-on-light); + border-color: var(--color-danger); +} + +.modalDeleteButton:hover:not(:disabled) { + background: var(--color-danger-active); + border-color: var(--color-danger-active); +} + +.modalCancelButton:disabled, +.modalDeleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +/* Responsive design */ +@media (max-width: 767px) { + .sectionHeader { + flex-direction: column; + align-items: flex-start; + } + + .addButton { + width: 100%; + } + + .cardStrip, + .skeletonStrip { + grid-template-columns: repeat(1, 1fr); + } + + .modalContent, + .modalContentLarge { + width: 95%; + max-width: none; + border-radius: var(--radius-sm); + } + + .section { + padding: var(--spacing-4); + } +} + +@media (min-width: 768px) and (max-width: 1023px) { + .cardStrip, + .skeletonStrip { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } +} + +@media (min-width: 1024px) { + .cardStrip, + .skeletonStrip { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } +} diff --git a/client/src/components/documents/LinkedDocumentsSection.test.tsx b/client/src/components/documents/LinkedDocumentsSection.test.tsx new file mode 100644 index 000000000..51b0f6e75 --- /dev/null +++ b/client/src/components/documents/LinkedDocumentsSection.test.tsx @@ -0,0 +1,532 @@ +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { jest } from '@jest/globals'; +import type { UseDocumentLinksResult } from '../../hooks/useDocumentLinks.js'; +import type { DocumentLinkWithMetadata, PaperlessDocumentSearchResult } from '@cornerstone/shared'; + +// ─── Mock: useDocumentLinks hook ───────────────────────────────────────────── + +const mockUseDocumentLinks = jest.fn<() => UseDocumentLinksResult>(); + +jest.unstable_mockModule('../../hooks/useDocumentLinks.js', () => ({ + useDocumentLinks: mockUseDocumentLinks, +})); + +// ─── Mock: paperlessApi (for getPaperlessStatus) ────────────────────────────── + +const mockGetPaperlessStatus = jest.fn<() => Promise>(); + +jest.unstable_mockModule('../../lib/paperlessApi.js', () => ({ + getPaperlessStatus: mockGetPaperlessStatus, + listPaperlessDocuments: jest.fn(), + listPaperlessTags: jest.fn(), + getPaperlessDocument: jest.fn(), + getDocumentThumbnailUrl: (id: number) => `/api/paperless/documents/${id}/thumb`, + getDocumentPreviewUrl: (id: number) => `/api/paperless/documents/${id}/preview`, +})); + +// ─── Mock: apiClient (needed by LinkedDocumentsSection) ────────────────────── + +class MockApiClientError extends Error { + statusCode: number; + error: { code: string; message?: string }; + constructor(statusCode: number, error: { code: string; message?: string }) { + super(error.message ?? 'API Error'); + this.statusCode = statusCode; + this.error = error; + } +} + +jest.unstable_mockModule('../../lib/apiClient.js', () => ({ + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + del: jest.fn(), + put: jest.fn(), + setBaseUrl: jest.fn(), + getBaseUrl: jest.fn().mockReturnValue('/api'), + ApiClientError: MockApiClientError, + NetworkError: class MockNetworkError extends Error {}, +})); + +// ─── Mock: child components (to avoid transitive dependency issues) ─────────── + +jest.unstable_mockModule('./DocumentBrowser.js', () => ({ + DocumentBrowser: function MockDocumentBrowser(props: { + onSelect?: (doc: PaperlessDocumentSearchResult) => void; + mode?: string; + }) { + const mockDoc: PaperlessDocumentSearchResult = { + id: 99, + title: 'Test Doc', + content: null, + tags: [], + created: '2026-01-15', + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + searchHit: null, + }; + return
props.onSelect?.(mockDoc)} />; + }, +})); + +jest.unstable_mockModule('./DocumentDetailPanel.js', () => ({ + DocumentDetailPanel: function MockDocumentDetailPanel(props: { onClose?: () => void }) { + return
; + }, +})); + +jest.unstable_mockModule('./DocumentSkeleton.js', () => ({ + DocumentSkeleton: function MockDocumentSkeleton() { + return
; + }, +})); + +jest.unstable_mockModule('./LinkedDocumentCard.js', () => ({ + LinkedDocumentCard: function MockLinkedDocumentCard(props: { + link: DocumentLinkWithMetadata; + onView?: (link: DocumentLinkWithMetadata) => void; + onUnlink?: (link: DocumentLinkWithMetadata) => void; + }) { + return ( +
+ + +
+ ); + }, +})); + +// ─── Type imports ───────────────────────────────────────────────────────────── + +import type * as LinkedDocumentsSectionModule from './LinkedDocumentsSection.js'; + +let LinkedDocumentsSection: (typeof LinkedDocumentsSectionModule)['LinkedDocumentsSection']; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const makeHook = (overrides: Partial = {}): UseDocumentLinksResult => ({ + links: [], + isLoading: false, + error: null, + addLink: jest.fn<() => Promise>().mockResolvedValue(undefined), + removeLink: jest.fn<() => Promise>().mockResolvedValue(undefined), + refresh: jest.fn(), + ...overrides, +}); + +const makeLink = (id: string): DocumentLinkWithMetadata => ({ + id, + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + document: { + id: 42, + title: `Document ${id}`, + content: null, + tags: [], + created: '2026-01-15', + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, +}); + +const makeConfiguredStatus = (overrides = {}) => ({ + configured: true, + reachable: true, + error: null, + paperlessUrl: null, + ...overrides, +}); + +// ─── Setup ─────────────────────────────────────────────────────────────────── + +beforeEach(async () => { + ({ LinkedDocumentsSection } = + (await import('./LinkedDocumentsSection.js')) as typeof LinkedDocumentsSectionModule); + + mockUseDocumentLinks.mockReset(); + mockGetPaperlessStatus.mockReset(); + + // Default: configured paperless, no links + mockUseDocumentLinks.mockReturnValue(makeHook()); + mockGetPaperlessStatus.mockResolvedValue(makeConfiguredStatus()); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('LinkedDocumentsSection', () => { + describe('loading state', () => { + it('renders DocumentSkeleton when hook.isLoading=true', async () => { + mockUseDocumentLinks.mockReturnValue(makeHook({ isLoading: true })); + render(); + await waitFor(() => expect(screen.getByTestId('document-skeleton')).toBeInTheDocument()); + }); + }); + + describe('error state', () => { + it('renders error banner when hook.error is set', async () => { + mockUseDocumentLinks.mockReturnValue( + makeHook({ error: 'Failed to load documents', isLoading: false }), + ); + render(); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(screen.getByText('Failed to load documents')).toBeInTheDocument(); + }); + + it('renders Retry button in error state that calls hook.refresh', async () => { + const refresh = jest.fn(); + mockUseDocumentLinks.mockReturnValue( + makeHook({ error: 'Failed to load', isLoading: false, refresh }), + ); + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(), + ); + fireEvent.click(screen.getByRole('button', { name: /retry/i })); + expect(refresh).toHaveBeenCalledTimes(1); + }); + }); + + describe('not configured state', () => { + it('renders not-configured banner when paperlessStatus.configured=false', async () => { + mockGetPaperlessStatus.mockResolvedValue({ + configured: false, + reachable: false, + error: null, + paperlessUrl: null, + }); + render(); + await waitFor(() => + expect(screen.getByText(/Paperless-ngx is not configured/i)).toBeInTheDocument(), + ); + }); + + it('"Add Document" button is disabled when Paperless is not configured', async () => { + mockGetPaperlessStatus.mockResolvedValue({ + configured: false, + reachable: false, + error: null, + paperlessUrl: null, + }); + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).toBeDisabled(), + ); + }); + }); + + describe('empty state', () => { + it('renders empty state text when links=[] and paperless is configured', async () => { + mockUseDocumentLinks.mockReturnValue(makeHook({ links: [], isLoading: false })); + render(); + await waitFor(() => expect(screen.getByText(/No documents linked yet/i)).toBeInTheDocument()); + }); + }); + + describe('list with cards', () => { + it('renders one LinkedDocumentCard per link', async () => { + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1'), makeLink('link-2')], isLoading: false }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('linked-card-link-1')).toBeInTheDocument()); + expect(screen.getByTestId('linked-card-link-2')).toBeInTheDocument(); + }); + + it('renders the linked documents list role with aria-label', async () => { + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1')], isLoading: false }), + ); + render(); + await waitFor(() => + expect(screen.getByRole('list', { name: /Linked documents/i })).toBeInTheDocument(), + ); + }); + }); + + describe('Add Document opens picker', () => { + it('shows DocumentBrowser when "Add Document" is clicked', async () => { + render(); + // Wait for paperless status to load + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + expect(screen.getByTestId('document-browser')).toBeInTheDocument(); + }); + + it('shows the picker dialog with aria-modal', async () => { + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + + it('closes picker when close button is clicked', async () => { + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + expect(screen.getByTestId('document-browser')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Close document picker/i })); + expect(screen.queryByTestId('document-browser')).not.toBeInTheDocument(); + }); + }); + + describe('selection triggers addLink', () => { + it('calls hook.addLink when DocumentBrowser triggers onSelect', async () => { + const addLink = jest.fn<() => Promise>().mockResolvedValue(undefined); + mockUseDocumentLinks.mockReturnValue(makeHook({ addLink })); + + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + + // Trigger onSelect on the mocked DocumentBrowser (click fires onSelect with doc id 99) + await act(async () => { + fireEvent.click(screen.getByTestId('document-browser')); + }); + + expect(addLink).toHaveBeenCalledWith(99); + }); + + it('closes picker after successful selection', async () => { + const addLink = jest.fn<() => Promise>().mockResolvedValue(undefined); + mockUseDocumentLinks.mockReturnValue(makeHook({ addLink })); + + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + + await act(async () => { + fireEvent.click(screen.getByTestId('document-browser')); + }); + + await waitFor(() => expect(screen.queryByTestId('document-browser')).not.toBeInTheDocument()); + }); + }); + + describe('duplicate error shown inline', () => { + it('shows duplicate error message when addLink rejects with DUPLICATE_DOCUMENT_LINK', async () => { + const addLink = jest + .fn<() => Promise>() + .mockRejectedValue( + new MockApiClientError(409, { + code: 'DUPLICATE_DOCUMENT_LINK', + message: 'Already linked', + }), + ); + mockUseDocumentLinks.mockReturnValue(makeHook({ addLink })); + + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + + await act(async () => { + fireEvent.click(screen.getByTestId('document-browser')); + }); + + await waitFor(() => + expect( + screen.getByText(/This document is already linked to this work item/i), + ).toBeInTheDocument(), + ); + }); + + it('shows generic error message when addLink rejects with non-duplicate error', async () => { + const addLink = jest.fn<() => Promise>().mockRejectedValue(new Error('Unknown error')); + mockUseDocumentLinks.mockReturnValue(makeHook({ addLink })); + + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + + await act(async () => { + fireEvent.click(screen.getByTestId('document-browser')); + }); + + await waitFor(() => + expect(screen.getByText(/Failed to link document. Please try again/i)).toBeInTheDocument(), + ); + }); + + it('can dismiss the error banner', async () => { + const addLink = jest + .fn<() => Promise>() + .mockRejectedValue( + new MockApiClientError(409, { + code: 'DUPLICATE_DOCUMENT_LINK', + message: 'Already linked', + }), + ); + mockUseDocumentLinks.mockReturnValue(makeHook({ addLink })); + + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + + await act(async () => { + fireEvent.click(screen.getByTestId('document-browser')); + }); + + await waitFor(() => + expect( + screen.getByText(/This document is already linked to this work item/i), + ).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByRole('button', { name: /Dismiss error/i })); + expect( + screen.queryByText(/This document is already linked to this work item/i), + ).not.toBeInTheDocument(); + }); + }); + + describe('unlink confirmation', () => { + it('shows confirmation dialog when onUnlink is triggered on a card', async () => { + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1')], isLoading: false }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('linked-card-link-1')).toBeInTheDocument()); + + // Click the Unlink button on the card + fireEvent.click(screen.getByRole('button', { name: /Unlink link-1/i })); + + expect(screen.getByRole('dialog', { name: /Unlink Document/i })).toBeInTheDocument(); + expect(screen.getByText(/Unlink Document\?/i)).toBeInTheDocument(); + }); + + it('Cancel button dismisses the confirmation dialog', async () => { + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1')], isLoading: false }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('linked-card-link-1')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /Unlink link-1/i })); + expect(screen.getByRole('dialog', { name: /Unlink Document/i })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(screen.queryByRole('dialog', { name: /Unlink Document/i })).not.toBeInTheDocument(); + }); + }); + + describe('unlink confirmed', () => { + it('calls hook.removeLink with correct ID when confirmed', async () => { + const removeLink = jest.fn<() => Promise>().mockResolvedValue(undefined); + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1')], isLoading: false, removeLink }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('linked-card-link-1')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /Unlink link-1/i })); + expect(screen.getByRole('dialog', { name: /Unlink Document/i })).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^Unlink$/i })); + }); + + expect(removeLink).toHaveBeenCalledWith('link-1'); + }); + + it('dismisses the confirmation dialog after confirmed unlink', async () => { + const removeLink = jest.fn<() => Promise>().mockResolvedValue(undefined); + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1')], isLoading: false, removeLink }), + ); + render(); + await waitFor(() => expect(screen.getByTestId('linked-card-link-1')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /Unlink link-1/i })); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^Unlink$/i })); + }); + + await waitFor(() => + expect(screen.queryByRole('dialog', { name: /Unlink Document/i })).not.toBeInTheDocument(), + ); + }); + }); + + describe('live region announces after link', () => { + it('announces success message in aria-live region after addLink succeeds', async () => { + const addLink = jest.fn<() => Promise>().mockResolvedValue(undefined); + mockUseDocumentLinks.mockReturnValue(makeHook({ addLink })); + + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).not.toBeDisabled(), + ); + fireEvent.click(screen.getByRole('button', { name: /\+ Add Document/i })); + + await act(async () => { + fireEvent.click(screen.getByTestId('document-browser')); + }); + + // The aria-live region should contain the success message + await waitFor(() => { + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion).not.toBeNull(); + expect(liveRegion!.textContent).toContain('Document linked: Test Doc'); + }); + }); + }); + + describe('section structure', () => { + it('renders the "Documents" section heading', async () => { + render(); + expect(screen.getByRole('heading', { name: /^Documents/i, level: 2 })).toBeInTheDocument(); + }); + + it('renders count badge when links are present', async () => { + mockUseDocumentLinks.mockReturnValue( + makeHook({ links: [makeLink('link-1'), makeLink('link-2')], isLoading: false }), + ); + render(); + await waitFor(() => expect(screen.getByLabelText(/2 documents linked/i)).toBeInTheDocument()); + }); + + it('does not render count badge when there are no links', async () => { + mockUseDocumentLinks.mockReturnValue(makeHook({ links: [], isLoading: false })); + render(); + await waitFor(() => + expect(screen.getByRole('button', { name: /\+ Add Document/i })).toBeInTheDocument(), + ); + expect(screen.queryByLabelText(/documents linked/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/documents/LinkedDocumentsSection.tsx b/client/src/components/documents/LinkedDocumentsSection.tsx new file mode 100644 index 000000000..3d98a26fb --- /dev/null +++ b/client/src/components/documents/LinkedDocumentsSection.tsx @@ -0,0 +1,335 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import type { DocumentLinkWithMetadata, PaperlessDocumentSearchResult } from '@cornerstone/shared'; +import { getPaperlessStatus } from '../../lib/paperlessApi.js'; +import { useDocumentLinks } from '../../hooks/useDocumentLinks.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import { LinkedDocumentCard } from './LinkedDocumentCard.js'; +import { DocumentBrowser } from './DocumentBrowser.js'; +import { DocumentDetailPanel } from './DocumentDetailPanel.js'; +import { DocumentSkeleton } from './DocumentSkeleton.js'; +import styles from './LinkedDocumentsSection.module.css'; + +interface LinkedDocumentsSectionProps { + workItemId: string; +} + +export function LinkedDocumentsSection({ workItemId }: LinkedDocumentsSectionProps) { + const hook = useDocumentLinks(workItemId); + + // Paperless status state + const [paperlessStatus, setPaperlessStatus] = useState + > | null>(null); + + // Modal and interaction states + const [showPicker, setShowPicker] = useState(false); + const [viewingLink, setViewingLink] = useState(null); + const [unlinkTarget, setUnlinkTarget] = useState(null); + const [isUnlinking, setIsUnlinking] = useState(false); + const [linkError, setLinkError] = useState(null); + const [announceMessage, setAnnounceMessage] = useState(''); + const addButtonRef = useRef(null); + + // Load Paperless status on mount + useEffect(() => { + let cancelled = false; + + async function loadStatus() { + try { + const status = await getPaperlessStatus(); + if (!cancelled) { + setPaperlessStatus(status); + } + } catch { + if (!cancelled) { + setPaperlessStatus({ + configured: false, + reachable: false, + error: 'Failed to check status', + paperlessUrl: null, + }); + } + } finally { + // Status loaded, nothing more to do + } + } + + void loadStatus(); + return () => { + cancelled = true; + }; + }, []); + + // Close modals on Escape key + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key !== 'Escape') return; + if (showPicker) { + closePicker(); + return; + } + if (unlinkTarget && !isUnlinking) { + setUnlinkTarget(null); + } + } + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [showPicker, unlinkTarget, isUnlinking, closePicker]); + + const closePicker = useCallback(() => { + setShowPicker(false); + // Restore focus to add button + setTimeout(() => { + addButtonRef.current?.focus(); + }, 0); + }, []); + + const handleDocumentSelect = useCallback( + async (doc: PaperlessDocumentSearchResult) => { + // Close picker immediately on selection + setShowPicker(false); + + try { + await hook.addLink(doc.id); + // Announce success to screen readers + setAnnounceMessage(`Document linked: ${doc.title}`); + setTimeout(() => setAnnounceMessage(''), 3000); + } catch (err) { + if (err instanceof ApiClientError && err.error.code === 'DUPLICATE_DOCUMENT_LINK') { + setLinkError('This document is already linked to this work item.'); + } else { + setLinkError('Failed to link document. Please try again.'); + } + } + + // Restore focus to add button + setTimeout(() => { + addButtonRef.current?.focus(); + }, 0); + }, + [hook], + ); + + const handleUnlink = useCallback(async () => { + if (!unlinkTarget) return; + + setIsUnlinking(true); + try { + await hook.removeLink(unlinkTarget.id); + // Announce removal to screen readers + setAnnounceMessage(`Document unlinked: ${unlinkTarget.document?.title ?? 'document'}`); + setTimeout(() => setAnnounceMessage(''), 3000); + } catch { + setLinkError('Failed to unlink document. Please try again.'); + } finally { + setUnlinkTarget(null); + setIsUnlinking(false); + // Restore focus to add button + setTimeout(() => { + addButtonRef.current?.focus(); + }, 0); + } + }, [unlinkTarget, hook]); + + return ( +
+
+

+ Documents + {!hook.isLoading && hook.links.length > 0 && ( + + {hook.links.length} + + )} +

+ +
+ + {/* Live region for screen reader announcements */} +
+ {announceMessage} +
+ + {/* Link error banner (e.g., duplicate) */} + {linkError && ( +
+ {linkError} + +
+ )} + + {/* Loading state */} + {hook.isLoading && ( +
+ +
+ )} + + {/* Error state */} + {hook.error && !hook.isLoading && ( +
+ {hook.error} + +
+ )} + + {/* Not configured state */} + {paperlessStatus && !paperlessStatus.configured && !hook.isLoading && !hook.error && ( +
+ ℹ️ +
+

Paperless-ngx is not configured

+

+ Set PAPERLESS_URL and PAPERLESS_API_TOKEN environment variables and restart + Cornerstone to enable document linking. +

+
+
+ )} + + {/* Empty state */} + {!hook.isLoading && !hook.error && paperlessStatus?.configured && hook.links.length === 0 && ( +
+ 📄 +

No documents linked yet

+

+ Link receipts, contracts, and plans from Paperless-ngx to keep everything in one place. +

+
+ )} + + {/* Linked document cards */} + {hook.links.length > 0 && ( +
+ {hook.links.map((link) => ( +
+ setViewingLink(l)} + onUnlink={(l) => setUnlinkTarget(l)} + /> +
+ ))} +
+ )} + + {/* Inline detail panel */} + {viewingLink?.document && ( +
+ setViewingLink(null)} + paperlessBaseUrl={paperlessStatus?.paperlessUrl ?? undefined} + /> +
+ )} + + {/* Add Document picker modal */} + {showPicker && ( +
+
+
+
+
+

+ Add Document +

+

+ Select a document from Paperless-ngx to link to this work item. +

+
+ +
+
+ +
+
+
+ )} + + {/* Unlink confirmation modal */} + {unlinkTarget && ( +
+
!isUnlinking && setUnlinkTarget(null)} + /> +
+ +

+ “{unlinkTarget.document?.title ?? 'This document'}” will be removed from + this work item. The document will remain in Paperless-ngx. +

+
+ + +
+
+
+ )} +
+ ); +} diff --git a/client/src/hooks/useDocumentLinks.test.ts b/client/src/hooks/useDocumentLinks.test.ts new file mode 100644 index 000000000..df519f97d --- /dev/null +++ b/client/src/hooks/useDocumentLinks.test.ts @@ -0,0 +1,333 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { jest } from '@jest/globals'; + +const mockListDocumentLinks = jest.fn<() => Promise>(); +const mockCreateDocumentLink = jest.fn<() => Promise>(); +const mockDeleteDocumentLink = jest.fn<() => Promise>(); + +jest.unstable_mockModule('../lib/documentLinksApi.js', () => ({ + listDocumentLinks: mockListDocumentLinks, + createDocumentLink: mockCreateDocumentLink, + deleteDocumentLink: mockDeleteDocumentLink, +})); + +class MockApiClientError extends Error { + statusCode: number; + error: { code: string; message?: string }; + constructor(statusCode: number, error: { code: string; message?: string }) { + super(error.message ?? 'API Error'); + this.statusCode = statusCode; + this.error = error; + } +} + +class MockNetworkError extends Error { + constructor(message: string) { + super(message); + } +} + +jest.unstable_mockModule('../lib/apiClient.js', () => ({ + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + del: jest.fn(), + put: jest.fn(), + setBaseUrl: jest.fn(), + getBaseUrl: jest.fn().mockReturnValue('/api'), + ApiClientError: MockApiClientError, + NetworkError: MockNetworkError, +})); + +import type * as UseDocumentLinksModule from './useDocumentLinks.js'; + +let useDocumentLinks: (typeof UseDocumentLinksModule)['useDocumentLinks']; + +const makeLink = (id: string, paperlessDocumentId = 42) => ({ + id, + entityType: 'work_item' as const, + entityId: 'wi-abc', + paperlessDocumentId, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + document: { + id: paperlessDocumentId, + title: `Document #${paperlessDocumentId}`, + content: null, + tags: [], + created: '2026-01-15', + added: null, + modified: null, + correspondent: null, + documentType: null, + archiveSerialNumber: null, + originalFileName: null, + pageCount: null, + }, +}); + +beforeEach(async () => { + ({ useDocumentLinks } = (await import('./useDocumentLinks.js')) as typeof UseDocumentLinksModule); + mockListDocumentLinks.mockReset(); + mockCreateDocumentLink.mockReset(); + mockDeleteDocumentLink.mockReset(); + + // Default: returns empty list + mockListDocumentLinks.mockResolvedValue([]); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('useDocumentLinks', () => { + it('starts with isLoading=true before fetch completes', () => { + mockListDocumentLinks.mockImplementationOnce(() => new Promise(() => {})); + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + expect(result.current.isLoading).toBe(true); + expect(result.current.links).toEqual([]); + expect(result.current.error).toBeNull(); + }); + + it('fetches document links on mount and stores results in links', async () => { + const links = [makeLink('link-1', 42), makeLink('link-2', 99)]; + mockListDocumentLinks.mockResolvedValueOnce(links); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(mockListDocumentLinks).toHaveBeenCalledWith('work_item', 'wi-abc'); + expect(result.current.links).toEqual(links); + expect(result.current.error).toBeNull(); + }); + + it('sets isLoading=false after fetch completes', async () => { + mockListDocumentLinks.mockResolvedValueOnce([makeLink('link-1')]); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.links).toHaveLength(1); + }); + + it('sets error string and isLoading=false on ApiClientError; links stays empty', async () => { + mockListDocumentLinks.mockRejectedValueOnce( + new MockApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }), + ); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBe('Server error'); + expect(result.current.links).toEqual([]); + }); + + it('uses fallback error message when ApiClientError has no message', async () => { + mockListDocumentLinks.mockRejectedValueOnce( + new MockApiClientError(500, { code: 'INTERNAL_ERROR' }), + ); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBe('Failed to load documents.'); + }); + + it('sets error string on NetworkError', async () => { + mockListDocumentLinks.mockRejectedValueOnce(new MockNetworkError('Network request failed')); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toContain('Network error'); + }); + + it('sets generic error message on unknown error', async () => { + mockListDocumentLinks.mockRejectedValueOnce(new Error('Something unexpected')); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error).toBe('An unexpected error occurred.'); + }); + + describe('refresh()', () => { + it('increments fetch count to trigger a new fetch', async () => { + mockListDocumentLinks.mockResolvedValue([]); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const callsBefore = mockListDocumentLinks.mock.calls.length; + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => + expect(mockListDocumentLinks.mock.calls.length).toBeGreaterThan(callsBefore), + ); + }); + + it('fetches fresh data after refresh', async () => { + mockListDocumentLinks.mockResolvedValueOnce([]); + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const newLinks = [makeLink('link-new', 77)]; + mockListDocumentLinks.mockResolvedValueOnce(newLinks); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => expect(result.current.links).toEqual(newLinks)); + }); + }); + + describe('addLink()', () => { + it('calls createDocumentLink with correct args and workItemId', async () => { + mockListDocumentLinks.mockResolvedValue([]); + mockCreateDocumentLink.mockResolvedValueOnce({ + id: 'link-new', + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.addLink(42); + }); + + expect(mockCreateDocumentLink).toHaveBeenCalledWith({ + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + }); + }); + + it('refreshes the list after successful addLink', async () => { + const newLink = makeLink('link-added', 42); + mockListDocumentLinks + .mockResolvedValueOnce([]) // initial load + .mockResolvedValueOnce([newLink]); // after add + mockCreateDocumentLink.mockResolvedValueOnce({ + id: 'link-added', + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.addLink(42); + }); + + await waitFor(() => expect(result.current.links).toEqual([newLink])); + }); + + it('re-throws when createDocumentLink rejects with DUPLICATE_DOCUMENT_LINK', async () => { + mockListDocumentLinks.mockResolvedValue([]); + mockCreateDocumentLink.mockRejectedValueOnce( + new MockApiClientError(409, { code: 'DUPLICATE_DOCUMENT_LINK', message: 'Already linked' }), + ); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let thrownError: unknown; + await act(async () => { + try { + await result.current.addLink(42); + } catch (err) { + thrownError = err; + } + }); + + expect(thrownError).toBeInstanceOf(MockApiClientError); + expect((thrownError as MockApiClientError).error.code).toBe('DUPLICATE_DOCUMENT_LINK'); + }); + + it('re-throws when createDocumentLink rejects with any error', async () => { + mockListDocumentLinks.mockResolvedValue([]); + mockCreateDocumentLink.mockRejectedValueOnce(new Error('Network failure')); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let thrownError: unknown; + await act(async () => { + try { + await result.current.addLink(99); + } catch (err) { + thrownError = err; + } + }); + + expect(thrownError).toBeInstanceOf(Error); + }); + }); + + describe('removeLink()', () => { + it('calls deleteDocumentLink with the correct linkId', async () => { + const existingLink = makeLink('link-1', 42); + mockListDocumentLinks.mockResolvedValue([existingLink]); + mockDeleteDocumentLink.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.links).toHaveLength(1)); + + await act(async () => { + await result.current.removeLink('link-1'); + }); + + expect(mockDeleteDocumentLink).toHaveBeenCalledWith('link-1'); + }); + + it('optimistically removes the link from local state immediately after delete', async () => { + const link1 = makeLink('link-1', 42); + const link2 = makeLink('link-2', 99); + mockListDocumentLinks.mockResolvedValue([link1, link2]); + mockDeleteDocumentLink.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.links).toHaveLength(2)); + + await act(async () => { + await result.current.removeLink('link-1'); + }); + + expect(result.current.links).toHaveLength(1); + expect(result.current.links[0].id).toBe('link-2'); + }); + + it('removes only the targeted link when multiple links are present', async () => { + const link1 = makeLink('link-1', 42); + const link2 = makeLink('link-2', 99); + const link3 = makeLink('link-3', 7); + mockListDocumentLinks.mockResolvedValue([link1, link2, link3]); + mockDeleteDocumentLink.mockResolvedValueOnce(undefined); + + const { result } = renderHook(() => useDocumentLinks('wi-abc')); + await waitFor(() => expect(result.current.links).toHaveLength(3)); + + await act(async () => { + await result.current.removeLink('link-2'); + }); + + expect(result.current.links).toHaveLength(2); + expect(result.current.links.map((l) => l.id)).toEqual(['link-1', 'link-3']); + }); + }); +}); diff --git a/client/src/hooks/useDocumentLinks.ts b/client/src/hooks/useDocumentLinks.ts new file mode 100644 index 000000000..5ba01f005 --- /dev/null +++ b/client/src/hooks/useDocumentLinks.ts @@ -0,0 +1,96 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { DocumentLinkWithMetadata } from '@cornerstone/shared'; +import { + listDocumentLinks, + createDocumentLink, + deleteDocumentLink, +} from '../lib/documentLinksApi.js'; +import { ApiClientError, NetworkError } from '../lib/apiClient.js'; + +export interface UseDocumentLinksResult { + links: DocumentLinkWithMetadata[]; + isLoading: boolean; + error: string | null; + addLink: (paperlessDocumentId: number) => Promise; + removeLink: (linkId: string) => Promise; + refresh: () => void; +} + +/** + * Manages document links for a work item. + * Handles fetching the list, adding new links, and removing existing links. + */ +export function useDocumentLinks(workItemId: string): UseDocumentLinksResult { + const [links, setLinks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [fetchCount, setFetchCount] = useState(0); + + // Fetch document links on mount and when refresh is called + useEffect(() => { + let cancelled = false; + + async function loadLinks() { + setIsLoading(true); + setError(null); + + try { + const fetchedLinks = await listDocumentLinks('work_item', workItemId); + if (!cancelled) { + setLinks(fetchedLinks); + } + } catch (err) { + if (!cancelled) { + if (err instanceof ApiClientError) { + setError(err.error.message ?? 'Failed to load documents.'); + } else if (err instanceof NetworkError) { + setError('Network error: Unable to connect to the server.'); + } else { + setError('An unexpected error occurred.'); + } + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + void loadLinks(); + return () => { + cancelled = true; + }; + }, [workItemId, fetchCount]); + + const addLink = useCallback( + async (paperlessDocumentId: number) => { + await createDocumentLink({ + entityType: 'work_item', + entityId: workItemId, + paperlessDocumentId, + }); + // Refresh the list after successful creation + setFetchCount((c) => c + 1); + }, + [workItemId], + ); + + const removeLink = useCallback(async (linkId: string) => { + await deleteDocumentLink(linkId); + // Optimistically remove from local state immediately for better UX + setLinks((prev) => prev.filter((link) => link.id !== linkId)); + }, []); + + const refresh = useCallback(() => { + setFetchCount((c) => c + 1); + }, []); + + return { + links, + isLoading, + error, + addLink, + removeLink, + refresh, + }; +} diff --git a/client/src/lib/documentLinksApi.test.ts b/client/src/lib/documentLinksApi.test.ts new file mode 100644 index 000000000..3a03d188f --- /dev/null +++ b/client/src/lib/documentLinksApi.test.ts @@ -0,0 +1,208 @@ +import { jest } from '@jest/globals'; +import type * as DocumentLinksApiModule from './documentLinksApi.js'; + +// Mock apiClient before imports +const mockGet = jest.fn<() => Promise>(); +const mockPost = jest.fn<() => Promise>(); +const mockDel = jest.fn<() => Promise>(); + +jest.unstable_mockModule('./apiClient.js', () => ({ + get: mockGet, + post: mockPost, + del: mockDel, + patch: jest.fn(), + put: jest.fn(), + setBaseUrl: jest.fn(), + getBaseUrl: jest.fn().mockReturnValue('/api'), + ApiClientError: class ApiClientError extends Error { + statusCode: number; + error: unknown; + constructor(statusCode: number, error: unknown) { + super('error'); + this.statusCode = statusCode; + this.error = error; + } + }, + NetworkError: class NetworkError extends Error {}, +})); + +// Deferred import after mock +let documentLinksApi: typeof DocumentLinksApiModule; + +beforeEach(async () => { + documentLinksApi = (await import('./documentLinksApi.js')) as typeof DocumentLinksApiModule; + mockGet.mockReset(); + mockPost.mockReset(); + mockDel.mockReset(); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('documentLinksApi', () => { + describe('listDocumentLinks', () => { + it('calls GET /document-links with entityType and entityId query params and returns documentLinks array', async () => { + const mockLinks = [ + { + id: 'link-1', + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + document: null, + }, + ]; + mockGet.mockResolvedValueOnce({ documentLinks: mockLinks }); + + const result = await documentLinksApi.listDocumentLinks('work_item', 'wi-abc'); + + expect(mockGet).toHaveBeenCalledWith('/document-links?entityType=work_item&entityId=wi-abc'); + expect(result).toEqual(mockLinks); + }); + + it('returns empty array when documentLinks is empty', async () => { + mockGet.mockResolvedValueOnce({ documentLinks: [] }); + + const result = await documentLinksApi.listDocumentLinks('work_item', 'wi-abc'); + + expect(result).toEqual([]); + }); + + it('passes entityType and entityId exactly as provided', async () => { + mockGet.mockResolvedValueOnce({ documentLinks: [] }); + + await documentLinksApi.listDocumentLinks('invoice', 'inv-999'); + + expect(mockGet).toHaveBeenCalledWith('/document-links?entityType=invoice&entityId=inv-999'); + }); + + it('returns multiple links from the response', async () => { + const mockLinks = [ + { + id: 'link-1', + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + document: null, + }, + { + id: 'link-2', + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 99, + createdBy: { id: 'user-1', displayName: 'Frank' }, + createdAt: '2026-01-02T00:00:00Z', + document: null, + }, + ]; + mockGet.mockResolvedValueOnce({ documentLinks: mockLinks }); + + const result = await documentLinksApi.listDocumentLinks('work_item', 'wi-abc'); + + expect(result).toHaveLength(2); + expect(result).toEqual(mockLinks); + }); + }); + + describe('createDocumentLink', () => { + it('calls POST /document-links with the correct body and returns documentLink', async () => { + const mockLink = { + id: 'link-123', + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + createdBy: null, + createdAt: '2026-01-01T00:00:00Z', + }; + mockPost.mockResolvedValueOnce({ documentLink: mockLink }); + + const result = await documentLinksApi.createDocumentLink({ + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + }); + + expect(mockPost).toHaveBeenCalledWith('/document-links', { + entityType: 'work_item', + entityId: 'wi-abc', + paperlessDocumentId: 42, + }); + expect(result).toEqual(mockLink); + }); + + it('passes all fields in the request body correctly', async () => { + const mockLink = { + id: 'link-456', + entityType: 'invoice', + entityId: 'inv-888', + paperlessDocumentId: 7, + createdBy: null, + createdAt: '2026-02-15T12:00:00Z', + }; + mockPost.mockResolvedValueOnce({ documentLink: mockLink }); + + await documentLinksApi.createDocumentLink({ + entityType: 'invoice', + entityId: 'inv-888', + paperlessDocumentId: 7, + }); + + expect(mockPost).toHaveBeenCalledWith('/document-links', { + entityType: 'invoice', + entityId: 'inv-888', + paperlessDocumentId: 7, + }); + }); + + it('returns the created link from the response', async () => { + const mockLink = { + id: 'link-789', + entityType: 'household_item', + entityId: 'hi-001', + paperlessDocumentId: 55, + createdBy: { id: 'user-1', displayName: 'Alice' }, + createdAt: '2026-03-01T08:00:00Z', + }; + mockPost.mockResolvedValueOnce({ documentLink: mockLink }); + + const result = await documentLinksApi.createDocumentLink({ + entityType: 'household_item', + entityId: 'hi-001', + paperlessDocumentId: 55, + }); + + expect(result).toEqual(mockLink); + }); + }); + + describe('deleteDocumentLink', () => { + it('calls DELETE /document-links/:id and returns void', async () => { + mockDel.mockResolvedValueOnce(undefined); + + const result = await documentLinksApi.deleteDocumentLink('link-123'); + + expect(mockDel).toHaveBeenCalledWith('/document-links/link-123'); + expect(result).toBeUndefined(); + }); + + it('passes the link id in the URL path', async () => { + mockDel.mockResolvedValueOnce(undefined); + + await documentLinksApi.deleteDocumentLink('link-abc-xyz-999'); + + expect(mockDel).toHaveBeenCalledWith('/document-links/link-abc-xyz-999'); + }); + + it('propagates errors from the API client', async () => { + mockDel.mockRejectedValueOnce(new Error('Not Found')); + + await expect(documentLinksApi.deleteDocumentLink('link-does-not-exist')).rejects.toThrow( + 'Not Found', + ); + }); + }); +}); diff --git a/client/src/lib/documentLinksApi.ts b/client/src/lib/documentLinksApi.ts new file mode 100644 index 000000000..3ce8de95a --- /dev/null +++ b/client/src/lib/documentLinksApi.ts @@ -0,0 +1,32 @@ +import { get, post, del } from './apiClient.js'; +import type { + DocumentLink, + DocumentLinkWithMetadata, + CreateDocumentLinkRequest, +} from '@cornerstone/shared'; + +/** + * Lists all document links for a given entity. + */ +export function listDocumentLinks( + entityType: string, + entityId: string, +): Promise { + return get<{ documentLinks: DocumentLinkWithMetadata[] }>( + `/document-links?entityType=${entityType}&entityId=${entityId}`, + ).then((r) => r.documentLinks); +} + +/** + * Creates a new document link between a Cornerstone entity and a Paperless-ngx document. + */ +export function createDocumentLink(data: CreateDocumentLinkRequest): Promise { + return post<{ documentLink: DocumentLink }>('/document-links', data).then((r) => r.documentLink); +} + +/** + * Deletes a document link by its ID. + */ +export function deleteDocumentLink(id: string): Promise { + return del(`/document-links/${id}`); +} diff --git a/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx b/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx index 33d662f4b..2d0eba0a5 100644 --- a/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx +++ b/client/src/pages/WorkItemDetailPage/WorkItemDetailPage.tsx @@ -71,6 +71,7 @@ import type { DependencyType } from '@cornerstone/shared'; import { formatDate } from '../../lib/formatters.js'; import { AutosaveIndicator } from '../../components/AutosaveIndicator/AutosaveIndicator.js'; import type { AutosaveState } from '../../components/AutosaveIndicator/AutosaveIndicator.js'; +import { LinkedDocumentsSection } from '../../components/documents/LinkedDocumentsSection.js'; import styles from './WorkItemDetailPage.module.css'; interface DeletingDependency { @@ -2316,6 +2317,11 @@ export default function WorkItemDetailPage() {
+ {/* Documents — full-width section, loads independently */} +
+ +
+ {/* Footer */}