From 59af8acdc666862f50fa7353efd688a28ed8fe4a Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Thu, 30 Apr 2026 09:30:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(#211):=20E3=20=E2=80=94=20Document=20libra?= =?UTF-8?q?ry=20(/docs),=20filters,=20bulk=20actions,=20multi-file=20impor?= =?UTF-8?q?t,=20StatusBadge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusBadge component with 6 lifecycle states, compact/full variants, tooltip (#215) - DocsLibraryPage: table (Name/Status/Stores/Updated), empty state (#211) - Filter bar: status pills, store chips, debounced search, URL sync (#212) - Multi-select with sticky bulk bar: Re-chunk, Push to store modal, Delete (#213) - DocsNewPage: multi-file drop zone with per-file progress, sequential upload (#214) - Document.stores?: string[] field, formatRelativeTime(iso, locale) helper - rechunkDocument / pushDocumentToStore API + store actions (rechunk / pushToStore) - i18n keys status.*, docs.*, docsNew.* in FR + EN Closes #211, Closes #212, Closes #213, Closes #214, Closes #215 --- frontend/src/features/document/api.test.ts | 31 +- frontend/src/features/document/api.ts | 11 + frontend/src/features/document/store.test.ts | 49 + frontend/src/features/document/store.ts | 41 +- .../src/features/document/ui/StatusBadge.vue | 91 ++ frontend/src/pages/DocsLibraryPage.vue | 844 +++++++++++++++++- frontend/src/pages/DocsNewPage.vue | 386 +++++++- frontend/src/shared/format.test.ts | 66 ++ frontend/src/shared/format.ts | 14 + frontend/src/shared/i18n.ts | 106 +++ frontend/src/shared/types.ts | 2 + 11 files changed, 1620 insertions(+), 21 deletions(-) create mode 100644 frontend/src/features/document/ui/StatusBadge.vue create mode 100644 frontend/src/shared/format.test.ts diff --git a/frontend/src/features/document/api.test.ts b/frontend/src/features/document/api.test.ts index 736a969..b013ccf 100644 --- a/frontend/src/features/document/api.test.ts +++ b/frontend/src/features/document/api.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fetchDocuments, fetchDocument, uploadDocument, deleteDocument, getPreviewUrl } from './api' +import { + fetchDocuments, + fetchDocument, + uploadDocument, + deleteDocument, + getPreviewUrl, + rechunkDocument, + pushDocumentToStore, +} from './api' vi.mock('../../shared/api/http', () => ({ apiFetch: vi.fn(), @@ -62,4 +70,25 @@ describe('document API', () => { it('getPreviewUrl accepts custom page and dpi', () => { expect(getPreviewUrl('abc', 3, 300)).toBe('/api/documents/abc/preview?page=3&dpi=300') }) + + it('rechunkDocument calls POST /api/documents/:id/rechunk', async () => { + apiFetch.mockResolvedValue({ jobId: 'job-1' }) + + const result = await rechunkDocument('42') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/42/rechunk', { method: 'POST' }) + expect(result).toEqual({ jobId: 'job-1' }) + }) + + it('pushDocumentToStore calls POST /api/documents/:id/push with store', async () => { + apiFetch.mockResolvedValue({ jobId: 'job-2' }) + + const result = await pushDocumentToStore('42', 'my-store') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/42/push', { + method: 'POST', + body: JSON.stringify({ store: 'my-store' }), + }) + expect(result).toEqual({ jobId: 'job-2' }) + }) }) diff --git a/frontend/src/features/document/api.ts b/frontend/src/features/document/api.ts index 077073f..7b1e0c8 100644 --- a/frontend/src/features/document/api.ts +++ b/frontend/src/features/document/api.ts @@ -26,3 +26,14 @@ export function deleteDocument(id: string): Promise { export function getPreviewUrl(id: string, page = 1, dpi = 150): string { return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}` } + +export function rechunkDocument(id: string): Promise<{ jobId: string }> { + return apiFetch<{ jobId: string }>(`/api/documents/${id}/rechunk`, { method: 'POST' }) +} + +export function pushDocumentToStore(id: string, store: string): Promise<{ jobId: string }> { + return apiFetch<{ jobId: string }>(`/api/documents/${id}/push`, { + method: 'POST', + body: JSON.stringify({ store }), + }) +} diff --git a/frontend/src/features/document/store.test.ts b/frontend/src/features/document/store.test.ts index a596aed..efdf13f 100644 --- a/frontend/src/features/document/store.test.ts +++ b/frontend/src/features/document/store.test.ts @@ -6,6 +6,8 @@ vi.mock('./api', () => ({ fetchDocuments: vi.fn(), uploadDocument: vi.fn(), deleteDocument: vi.fn(), + rechunkDocument: vi.fn(), + pushDocumentToStore: vi.fn(), })) import * as api from './api' @@ -120,4 +122,51 @@ describe('useDocumentStore', () => { store.select('42') expect(store.selectedId).toBe('42') }) + + it('load() sets loading to false after success', async () => { + api.fetchDocuments.mockResolvedValue([]) + const store = useDocumentStore() + await store.load() + expect(store.loading).toBe(false) + }) + + it('load() sets loading to false after error', async () => { + api.fetchDocuments.mockRejectedValue(new Error('fail')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const store = useDocumentStore() + await store.load() + expect(store.loading).toBe(false) + }) + + it('rechunk() returns jobId on success', async () => { + api.rechunkDocument.mockResolvedValue({ jobId: 'j1' }) + const store = useDocumentStore() + const result = await store.rechunk('42') + expect(api.rechunkDocument).toHaveBeenCalledWith('42') + expect(result).toBe('j1') + }) + + it('rechunk() returns null on error', async () => { + api.rechunkDocument.mockRejectedValue(new Error('fail')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const store = useDocumentStore() + const result = await store.rechunk('42') + expect(result).toBeNull() + }) + + it('pushToStore() returns jobId on success', async () => { + api.pushDocumentToStore.mockResolvedValue({ jobId: 'j2' }) + const store = useDocumentStore() + const result = await store.pushToStore('42', 'my-store') + expect(api.pushDocumentToStore).toHaveBeenCalledWith('42', 'my-store') + expect(result).toBe('j2') + }) + + it('pushToStore() returns null on error', async () => { + api.pushDocumentToStore.mockRejectedValue(new Error('fail')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const store = useDocumentStore() + const result = await store.pushToStore('42', 'my-store') + expect(result).toBeNull() + }) }) diff --git a/frontend/src/features/document/store.ts b/frontend/src/features/document/store.ts index d43aaf1..3dfc68d 100644 --- a/frontend/src/features/document/store.ts +++ b/frontend/src/features/document/store.ts @@ -7,6 +7,7 @@ import * as api from './api' export const useDocumentStore = defineStore('document', () => { const documents = ref([]) const selectedId = ref(null) + const loading = ref(false) const uploading = ref(false) const error = ref(null) @@ -15,12 +16,15 @@ export const useDocumentStore = defineStore('document', () => { } async function load(): Promise { + loading.value = true try { error.value = null documents.value = await api.fetchDocuments() } catch (e) { error.value = (e as Error).message || 'Failed to load documents' console.error('Failed to load documents', e) + } finally { + loading.value = false } } @@ -61,5 +65,40 @@ export const useDocumentStore = defineStore('document', () => { selectedId.value = id } - return { documents, selectedId, uploading, error, clearError, load, upload, remove, select } + async function rechunk(id: string): Promise { + try { + const res = await api.rechunkDocument(id) + return res.jobId + } catch (e) { + error.value = (e as Error).message || 'Failed to rechunk' + console.error('Rechunk failed', e) + return null + } + } + + async function pushToStore(id: string, store: string): Promise { + try { + const res = await api.pushDocumentToStore(id, store) + return res.jobId + } catch (e) { + error.value = (e as Error).message || 'Failed to push to store' + console.error('Push to store failed', e) + return null + } + } + + return { + documents, + selectedId, + loading, + uploading, + error, + clearError, + load, + upload, + remove, + select, + rechunk, + pushToStore, + } }) diff --git a/frontend/src/features/document/ui/StatusBadge.vue b/frontend/src/features/document/ui/StatusBadge.vue new file mode 100644 index 0000000..b14d5d9 --- /dev/null +++ b/frontend/src/features/document/ui/StatusBadge.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/frontend/src/pages/DocsLibraryPage.vue b/frontend/src/pages/DocsLibraryPage.vue index d09144d..48c593c 100644 --- a/frontend/src/pages/DocsLibraryPage.vue +++ b/frontend/src/pages/DocsLibraryPage.vue @@ -1,39 +1,851 @@ diff --git a/frontend/src/pages/DocsNewPage.vue b/frontend/src/pages/DocsNewPage.vue index 46df169..9dd5abf 100644 --- a/frontend/src/pages/DocsNewPage.vue +++ b/frontend/src/pages/DocsNewPage.vue @@ -1,11 +1,391 @@ + + diff --git a/frontend/src/shared/format.test.ts b/frontend/src/shared/format.test.ts new file mode 100644 index 0000000..2024ddb --- /dev/null +++ b/frontend/src/shared/format.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatRelativeTime, formatSize } from './format' + +describe('formatSize', () => { + it('returns empty string for falsy value', () => { + expect(formatSize(null)).toBe('') + expect(formatSize(undefined)).toBe('') + expect(formatSize(0)).toBe('') + }) + + it('formats bytes below 1MB as KB', () => { + expect(formatSize(512 * 1024)).toBe('512 KB') + }) + + it('formats bytes above 1MB as MB', () => { + expect(formatSize(2.5 * 1024 * 1024)).toBe('2.5 MB') + }) +}) + +describe('formatRelativeTime', () => { + const now = new Date('2025-01-01T12:00:00Z').getTime() + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns em-dash for null', () => { + expect(formatRelativeTime(null)).toBe('—') + expect(formatRelativeTime(undefined)).toBe('—') + }) + + it('uses seconds for recent timestamps', () => { + const iso = new Date(now - 30_000).toISOString() + const result = formatRelativeTime(iso, 'en') + expect(result).toMatch(/30 seconds ago/) + }) + + it('uses minutes for timestamps 2–59 min ago', () => { + const iso = new Date(now - 5 * 60_000).toISOString() + const result = formatRelativeTime(iso, 'en') + expect(result).toMatch(/5 minutes ago/) + }) + + it('uses hours for timestamps 1–23h ago', () => { + const iso = new Date(now - 3 * 3_600_000).toISOString() + const result = formatRelativeTime(iso, 'en') + expect(result).toMatch(/3 hours ago/) + }) + + it('uses days for timestamps < 30 days ago', () => { + const iso = new Date(now - 7 * 86_400_000).toISOString() + const result = formatRelativeTime(iso, 'en') + expect(result).toMatch(/7 days ago/) + }) + + it('uses months for older timestamps', () => { + const iso = new Date(now - 60 * 86_400_000).toISOString() + const result = formatRelativeTime(iso, 'en') + expect(result).toMatch(/2 months ago/) + }) +}) diff --git a/frontend/src/shared/format.ts b/frontend/src/shared/format.ts index bd586d9..ec126a4 100644 --- a/frontend/src/shared/format.ts +++ b/frontend/src/shared/format.ts @@ -3,3 +3,17 @@ export function formatSize(bytes: number | null | undefined): string { const mb = bytes / (1024 * 1024) return mb >= 1 ? `${mb.toFixed(1)} MB` : `${(bytes / 1024).toFixed(0)} KB` } + +export function formatRelativeTime(iso: string | null | undefined, locale = 'fr'): string { + if (!iso) return '—' + const diffMs = Date.now() - new Date(iso).getTime() + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + const abs = Math.abs(diffMs) + const dir = diffMs >= 0 ? -1 : 1 + + if (abs < 60_000) return rtf.format(dir * Math.round(abs / 1_000), 'second') + if (abs < 3_600_000) return rtf.format(dir * Math.round(abs / 60_000), 'minute') + if (abs < 86_400_000) return rtf.format(dir * Math.round(abs / 3_600_000), 'hour') + if (abs < 30 * 86_400_000) return rtf.format(dir * Math.round(abs / 86_400_000), 'day') + return rtf.format(dir * Math.round(abs / (30 * 86_400_000)), 'month') +} diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 093fe65..36b5726 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -37,6 +37,59 @@ const messages: Messages = { 'flags.allModesDisabled': "Aucun mode (Ask / Inspect / Chunks) n'est activé pour ce déploiement. Contactez votre administrateur.", + // Lifecycle status badges (#215) + 'status.Uploaded': 'Uploadé', + 'status.Parsed': 'Parsé', + 'status.Chunked': 'Chunké', + 'status.Ingested': 'Ingéré', + 'status.Stale': 'Obsolète', + 'status.Failed': 'Échoué', + 'status.tooltip.Uploaded': 'Fichier reçu, en attente de parsing.', + 'status.tooltip.Parsed': 'Document parsé avec succès.', + 'status.tooltip.Chunked': 'Chunks générés, prêts à être poussés vers un store.', + 'status.tooltip.Ingested': 'Indexé dans au moins un store.', + 'status.tooltip.Stale': 'Chunks modifiés depuis le dernier push — re-ingestion requise.', + 'status.tooltip.Failed': 'Une étape du pipeline a échoué. Réessayez.', + + // Document library (#211, #212, #213) + 'docs.title': 'Documents', + 'docs.import': 'Importer', + 'docs.emptyTitle': 'Aucun document', + 'docs.emptySubtitle': 'Importez votre premier document pour commencer.', + 'docs.emptyAction': 'Importer un document', + 'docs.emptyFiltered': 'Aucun document ne correspond aux filtres.', + 'docs.colName': 'Nom', + 'docs.colStatus': 'État', + 'docs.colStores': 'Stores', + 'docs.colUpdated': 'Mis à jour', + 'docs.filterSearch': 'Rechercher…', + 'docs.filterClear': 'Effacer les filtres', + 'docs.selected': '{n} sélectionné(s)', + 'docs.bulkRechunk': 'Re-chunker', + 'docs.bulkPush': 'Pousser vers un store…', + 'docs.bulkDelete': 'Supprimer', + 'docs.bulkCancel': 'Annuler la sélection', + 'docs.deleteConfirm': 'Supprimer {n} document(s) ? Cette action est irréversible.', + 'docs.pushTitle': 'Pousser vers un store', + 'docs.pushLabel': 'Store cible', + 'docs.pushPlaceholder': 'Nom du store…', + 'docs.pushSubmit': 'Pousser', + 'docs.pushCancel': 'Annuler', + 'docs.jobDispatched': 'Job lancé ({jobId})', + + // Doc import (#214) + 'docsNew.title': 'Importer des documents', + 'docsNew.drop': 'Déposez des PDFs ici ou cliquez pour choisir', + 'docsNew.dropHint': 'Plusieurs fichiers acceptés · PDF uniquement', + 'docsNew.queued': 'En attente', + 'docsNew.uploading': 'Import…', + 'docsNew.done': 'Importé', + 'docsNew.failed': 'Échec', + 'docsNew.viewDoc': 'Voir le document', + 'docsNew.backToLibrary': 'Retour à la bibliothèque', + 'docsNew.viewLibrary': 'Voir la bibliothèque', + 'docsNew.allDone': 'Tous les fichiers ont été importés.', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) 'comingSoon.title': 'Bientôt disponible', 'comingSoon.subtitle.docsLibrary': @@ -341,6 +394,59 @@ const messages: Messages = { 'flags.allModesDisabled': 'No doc workspace mode (Ask / Inspect / Chunks) is enabled for this deployment. Contact your administrator.', + // Lifecycle status badges (#215) + 'status.Uploaded': 'Uploaded', + 'status.Parsed': 'Parsed', + 'status.Chunked': 'Chunked', + 'status.Ingested': 'Ingested', + 'status.Stale': 'Stale', + 'status.Failed': 'Failed', + 'status.tooltip.Uploaded': 'File received, awaiting parsing.', + 'status.tooltip.Parsed': 'Document parsed successfully.', + 'status.tooltip.Chunked': 'Chunks generated, ready to push to a store.', + 'status.tooltip.Ingested': 'Indexed in at least one store.', + 'status.tooltip.Stale': 'Chunks modified since last push — re-ingestion required.', + 'status.tooltip.Failed': 'A pipeline step failed. Retry to recover.', + + // Document library (#211, #212, #213) + 'docs.title': 'Documents', + 'docs.import': 'Import', + 'docs.emptyTitle': 'No documents yet', + 'docs.emptySubtitle': 'Import your first document to get started.', + 'docs.emptyAction': 'Import a document', + 'docs.emptyFiltered': 'No documents match the current filters.', + 'docs.colName': 'Name', + 'docs.colStatus': 'Status', + 'docs.colStores': 'Stores', + 'docs.colUpdated': 'Updated', + 'docs.filterSearch': 'Search…', + 'docs.filterClear': 'Clear filters', + 'docs.selected': '{n} selected', + 'docs.bulkRechunk': 'Re-chunk', + 'docs.bulkPush': 'Push to store…', + 'docs.bulkDelete': 'Delete', + 'docs.bulkCancel': 'Cancel selection', + 'docs.deleteConfirm': 'Delete {n} document(s)? This action cannot be undone.', + 'docs.pushTitle': 'Push to store', + 'docs.pushLabel': 'Target store', + 'docs.pushPlaceholder': 'Store name…', + 'docs.pushSubmit': 'Push', + 'docs.pushCancel': 'Cancel', + 'docs.jobDispatched': 'Job dispatched ({jobId})', + + // Doc import (#214) + 'docsNew.title': 'Import documents', + 'docsNew.drop': 'Drop PDFs here or click to choose', + 'docsNew.dropHint': 'Multiple files accepted · PDF only', + 'docsNew.queued': 'Queued', + 'docsNew.uploading': 'Uploading…', + 'docsNew.done': 'Imported', + 'docsNew.failed': 'Failed', + 'docsNew.viewDoc': 'View document', + 'docsNew.backToLibrary': 'Back to library', + 'docsNew.viewLibrary': 'View library', + 'docsNew.allDone': 'All files have been imported.', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) 'comingSoon.title': 'Coming soon', 'comingSoon.subtitle.docsLibrary': diff --git a/frontend/src/shared/types.ts b/frontend/src/shared/types.ts index 67d6618..1a24da2 100644 --- a/frontend/src/shared/types.ts +++ b/frontend/src/shared/types.ts @@ -28,6 +28,8 @@ export interface Document { lifecycleState: DocumentLifecycleState /** ISO timestamp of the last lifecycle transition (UTC). */ lifecycleStateAt: string | null + /** Stores this document has been pushed to (added in E1 #203). */ + stores?: string[] } export interface PipelineOptions {