From f10cd01594d6378bb9503d8286197fdae5377814 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Wed, 29 Apr 2026 17:38:19 +0200 Subject: [PATCH] feat(#207): document-centric routing skeleton Adds the routing scaffold for the doc-centric pivot. Each new route renders a placeholder page until E3/E4/E5 implement them; legacy routes (/studio, /documents, /history, /search, /reasoning) keep working in parallel. New routes (Vue Router, history mode): /docs library (placeholder, #211) /docs/new import (placeholder, #214) /docs/:id?mode= workspace (placeholder, #216) /index stores list (placeholder, 0.7.0) /index/:store store detail (placeholder) /index/:store/query RAG playground (placeholder) /runs run history (placeholder) /runs/:id run detail (placeholder) Mode parsing - shared/routing/modes.ts: DocMode union ('ask'|'inspect'|'chunks'), parseMode() returns the default ('ask') for missing or unknown values. #210 will layer feature-flag-aware redirection on top. Route names - shared/routing/names.ts: ROUTES typed const so callers do router.push({ name: ROUTES.DOC_WORKSPACE, ... }) instead of stringly-typed names. Pages - ComingSoonShell shared component: card + back-home link, themed with existing CSS tokens. - 8 thin placeholder pages (one per new route) that compose the shell and forward route params. - i18n keys under comingSoon.* added in fr + en. Tests - shared/routing/modes.test.ts (10 cases): isDocMode + parseMode + ALL_MODES invariants. - app/router/router.test.ts (5 cases): every doc-centric route resolves to a component, legacy routes still work, doc workspace receives id and parsed mode as props, unknown mode falls back to ask, unknown path redirects to home. Routes table extracted to routes.ts so tests build a router with createMemoryHistory() (no window required) instead of needing the production createWebHistory() router. Refs #207 --- frontend/src/app/router/index.ts | 57 +--------- frontend/src/app/router/router.test.ts | 81 +++++++++++++ frontend/src/app/router/routes.ts | 125 +++++++++++++++++++++ frontend/src/pages/DocWorkspacePage.vue | 27 +++++ frontend/src/pages/DocsLibraryPage.vue | 14 +++ frontend/src/pages/DocsNewPage.vue | 11 ++ frontend/src/pages/RunDetailPage.vue | 20 ++++ frontend/src/pages/RunsPage.vue | 11 ++ frontend/src/pages/StoreDetailPage.vue | 20 ++++ frontend/src/pages/StoreQueryPage.vue | 20 ++++ frontend/src/pages/StoresListPage.vue | 11 ++ frontend/src/shared/i18n.ts | 40 +++++++ frontend/src/shared/routing/modes.test.ts | 35 ++++++ frontend/src/shared/routing/modes.ts | 24 ++++ frontend/src/shared/routing/names.ts | 35 ++++++ frontend/src/shared/ui/ComingSoonShell.vue | 101 +++++++++++++++++ 16 files changed, 578 insertions(+), 54 deletions(-) create mode 100644 frontend/src/app/router/router.test.ts create mode 100644 frontend/src/app/router/routes.ts create mode 100644 frontend/src/pages/DocWorkspacePage.vue create mode 100644 frontend/src/pages/DocsLibraryPage.vue create mode 100644 frontend/src/pages/DocsNewPage.vue create mode 100644 frontend/src/pages/RunDetailPage.vue create mode 100644 frontend/src/pages/RunsPage.vue create mode 100644 frontend/src/pages/StoreDetailPage.vue create mode 100644 frontend/src/pages/StoreQueryPage.vue create mode 100644 frontend/src/pages/StoresListPage.vue create mode 100644 frontend/src/shared/routing/modes.test.ts create mode 100644 frontend/src/shared/routing/modes.ts create mode 100644 frontend/src/shared/routing/names.ts create mode 100644 frontend/src/shared/ui/ComingSoonShell.vue diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts index adb9397..d8745be 100644 --- a/frontend/src/app/router/index.ts +++ b/frontend/src/app/router/index.ts @@ -1,59 +1,8 @@ import { createRouter, createWebHistory } from 'vue-router' -import type { RouteRecordRaw } from 'vue-router' -const routes: RouteRecordRaw[] = [ - { - path: '/', - name: 'home', - component: () => import('../../pages/HomePage.vue'), - }, - { - path: '/studio', - name: 'studio', - component: () => import('../../pages/StudioPage.vue'), - }, - { - path: '/history', - name: 'history', - component: () => import('../../pages/HistoryPage.vue'), - }, - { - path: '/documents', - name: 'documents', - component: () => import('../../pages/DocumentsPage.vue'), - }, - { - path: '/search', - name: 'search', - component: () => import('../../pages/SearchPage.vue'), - }, - { - // Reasoning-trace tunnel. Route is always registered; the page shows - // an empty state when the `reasoning` feature flag is off (same pattern - // as /search does for ingestion). - path: '/reasoning', - name: 'reasoning', - component: () => import('../../pages/ReasoningPage.vue'), - }, - { - // Deep-link into a specific document's reasoning workspace, e.g. shared - // by Peter to a teammate. - path: '/reasoning/:docId', - name: 'reasoning-doc', - component: () => import('../../pages/ReasoningPage.vue'), - props: true, - }, - { - path: '/settings', - name: 'settings', - component: () => import('../../pages/SettingsPage.vue'), - }, - { - path: '/:pathMatch(.*)*', - name: 'not-found', - redirect: '/', - }, -] +import { routes } from './routes' + +export { routes } export const router = createRouter({ history: createWebHistory(), diff --git a/frontend/src/app/router/router.test.ts b/frontend/src/app/router/router.test.ts new file mode 100644 index 0000000..b43713b --- /dev/null +++ b/frontend/src/app/router/router.test.ts @@ -0,0 +1,81 @@ +import { createMemoryHistory, createRouter } from 'vue-router' +import { describe, expect, it } from 'vitest' + +import { routes } from './routes' +import { ROUTES } from '../../shared/routing/names' + +/** + * Router test — uses `createMemoryHistory` so we don't need `window` + * (vitest defaults to the node environment for performance). The + * production router builds the same `routes` table on top of + * `createWebHistory` in `index.ts`. + */ +const buildRouter = () => createRouter({ history: createMemoryHistory(), routes }) + +describe('router', () => { + it('resolves every 0.6.0 doc-centric route to a component', () => { + const router = buildRouter() + const cases: Array<{ path: string; name: string }> = [ + { path: '/docs', name: ROUTES.DOCS_LIBRARY }, + { path: '/docs/new', name: ROUTES.DOCS_NEW }, + { path: '/docs/abc', name: ROUTES.DOC_WORKSPACE }, + { path: '/index', name: ROUTES.STORES_LIST }, + { path: '/index/foo', name: ROUTES.STORE_DETAIL }, + { path: '/index/foo/query', name: ROUTES.STORE_QUERY }, + { path: '/runs', name: ROUTES.RUNS }, + { path: '/runs/run-42', name: ROUTES.RUN_DETAIL }, + ] + for (const c of cases) { + const resolved = router.resolve(c.path) + expect(resolved.name, `route ${c.path}`).toBe(c.name) + expect(resolved.matched.length, `route ${c.path} has a component`).toBeGreaterThan(0) + } + }) + + it('keeps legacy routes functional', () => { + const router = buildRouter() + expect(router.resolve('/').name).toBe(ROUTES.HOME) + expect(router.resolve('/studio').name).toBe(ROUTES.STUDIO) + expect(router.resolve('/documents').name).toBe(ROUTES.DOCUMENTS) + expect(router.resolve('/history').name).toBe(ROUTES.HISTORY) + expect(router.resolve('/search').name).toBe(ROUTES.SEARCH) + expect(router.resolve('/reasoning').name).toBe(ROUTES.REASONING) + expect(router.resolve('/reasoning/abc').name).toBe(ROUTES.REASONING_DOC) + expect(router.resolve('/settings').name).toBe(ROUTES.SETTINGS) + }) + + it('passes id and parsed mode to the doc workspace as props', () => { + const router = buildRouter() + const route = router.resolve({ + name: ROUTES.DOC_WORKSPACE, + params: { id: 'abc' }, + query: { mode: 'chunks' }, + }) + const propsFn = route.matched[0]?.props as + | { default?: (r: typeof route) => unknown } + | undefined + const computed = (propsFn?.default ?? (() => null))(route) as { id: string; mode: string } + expect(computed.id).toBe('abc') + expect(computed.mode).toBe('chunks') + }) + + it('falls back to ask when mode is unknown', () => { + const router = buildRouter() + const route = router.resolve({ + name: ROUTES.DOC_WORKSPACE, + params: { id: 'abc' }, + query: { mode: 'garbage' }, + }) + const propsFn = route.matched[0]?.props as + | { default?: (r: typeof route) => unknown } + | undefined + const computed = (propsFn?.default ?? (() => null))(route) as { mode: string } + expect(computed.mode).toBe('ask') + }) + + it('redirects unknown paths to /', () => { + const router = buildRouter() + const resolved = router.resolve('/nope/this/does/not/exist') + expect(resolved.matched[0]?.redirect).toBeDefined() + }) +}) diff --git a/frontend/src/app/router/routes.ts b/frontend/src/app/router/routes.ts new file mode 100644 index 0000000..dfe2da2 --- /dev/null +++ b/frontend/src/app/router/routes.ts @@ -0,0 +1,125 @@ +import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' + +import { parseMode } from '../../shared/routing/modes' +import { ROUTES } from '../../shared/routing/names' + +/** + * Route table used by the production router and by tests. + * + * Lives in its own file so importing the router definition doesn't + * trigger `createWebHistory()` (which needs `window` and breaks the + * default node-environment vitest tests). Tests build a router with + * `createMemoryHistory()` over this same table. + */ +export const routes: RouteRecordRaw[] = [ + // --------------------------------------------------------------------------- + // Legacy routes — kept functional during the 0.6.0 transition. + // --------------------------------------------------------------------------- + { + path: '/', + name: ROUTES.HOME, + component: () => import('../../pages/HomePage.vue'), + }, + { + path: '/studio', + name: ROUTES.STUDIO, + component: () => import('../../pages/StudioPage.vue'), + }, + { + path: '/history', + name: ROUTES.HISTORY, + component: () => import('../../pages/HistoryPage.vue'), + }, + { + path: '/documents', + name: ROUTES.DOCUMENTS, + component: () => import('../../pages/DocumentsPage.vue'), + }, + { + path: '/search', + name: ROUTES.SEARCH, + component: () => import('../../pages/SearchPage.vue'), + }, + { + // Reasoning-trace tunnel. Route is always registered; the page shows + // an empty state when the `reasoning` feature flag is off (same pattern + // as /search does for ingestion). + path: '/reasoning', + name: ROUTES.REASONING, + component: () => import('../../pages/ReasoningPage.vue'), + }, + { + // Deep-link into a specific document's reasoning workspace, e.g. shared + // by Peter to a teammate. + path: '/reasoning/:docId', + name: ROUTES.REASONING_DOC, + component: () => import('../../pages/ReasoningPage.vue'), + props: true, + }, + { + path: '/settings', + name: ROUTES.SETTINGS, + component: () => import('../../pages/SettingsPage.vue'), + }, + + // --------------------------------------------------------------------------- + // 0.6.0 — Document-centric routes (#207). Placeholder pages until E3/E4/E5 + // implement them; the legacy routes above keep working in parallel. + // --------------------------------------------------------------------------- + { + path: '/docs', + name: ROUTES.DOCS_LIBRARY, + component: () => import('../../pages/DocsLibraryPage.vue'), + }, + { + path: '/docs/new', + name: ROUTES.DOCS_NEW, + component: () => import('../../pages/DocsNewPage.vue'), + }, + { + path: '/docs/:id', + name: ROUTES.DOC_WORKSPACE, + component: () => import('../../pages/DocWorkspacePage.vue'), + props: (route: RouteLocationNormalized) => ({ + id: String(route.params.id), + mode: parseMode(route.query.mode), + }), + }, + { + path: '/index', + name: ROUTES.STORES_LIST, + component: () => import('../../pages/StoresListPage.vue'), + }, + { + path: '/index/:store', + name: ROUTES.STORE_DETAIL, + component: () => import('../../pages/StoreDetailPage.vue'), + props: true, + }, + { + path: '/index/:store/query', + name: ROUTES.STORE_QUERY, + component: () => import('../../pages/StoreQueryPage.vue'), + props: true, + }, + { + path: '/runs', + name: ROUTES.RUNS, + component: () => import('../../pages/RunsPage.vue'), + }, + { + path: '/runs/:id', + name: ROUTES.RUN_DETAIL, + component: () => import('../../pages/RunDetailPage.vue'), + props: true, + }, + + // --------------------------------------------------------------------------- + // 404 — must come last. + // --------------------------------------------------------------------------- + { + path: '/:pathMatch(.*)*', + name: ROUTES.NOT_FOUND, + redirect: '/', + }, +] diff --git a/frontend/src/pages/DocWorkspacePage.vue b/frontend/src/pages/DocWorkspacePage.vue new file mode 100644 index 0000000..37ea58f --- /dev/null +++ b/frontend/src/pages/DocWorkspacePage.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/pages/DocsLibraryPage.vue b/frontend/src/pages/DocsLibraryPage.vue new file mode 100644 index 0000000..9449be2 --- /dev/null +++ b/frontend/src/pages/DocsLibraryPage.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/src/pages/DocsNewPage.vue b/frontend/src/pages/DocsNewPage.vue new file mode 100644 index 0000000..46df169 --- /dev/null +++ b/frontend/src/pages/DocsNewPage.vue @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/pages/RunDetailPage.vue b/frontend/src/pages/RunDetailPage.vue new file mode 100644 index 0000000..8243632 --- /dev/null +++ b/frontend/src/pages/RunDetailPage.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/pages/RunsPage.vue b/frontend/src/pages/RunsPage.vue new file mode 100644 index 0000000..6a65c9f --- /dev/null +++ b/frontend/src/pages/RunsPage.vue @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/pages/StoreDetailPage.vue b/frontend/src/pages/StoreDetailPage.vue new file mode 100644 index 0000000..c8cdae2 --- /dev/null +++ b/frontend/src/pages/StoreDetailPage.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/pages/StoreQueryPage.vue b/frontend/src/pages/StoreQueryPage.vue new file mode 100644 index 0000000..dcfa6ad --- /dev/null +++ b/frontend/src/pages/StoreQueryPage.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/pages/StoresListPage.vue b/frontend/src/pages/StoresListPage.vue new file mode 100644 index 0000000..f846106 --- /dev/null +++ b/frontend/src/pages/StoresListPage.vue @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index a7d6e6e..3b74247 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -19,6 +19,26 @@ const messages: Messages = { // Top bar 'topbar.newAnalysis': 'Nouvelle analyse', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) + 'comingSoon.title': 'Bientôt disponible', + 'comingSoon.subtitle.docsLibrary': + "La bibliothèque de documents arrive avec la 0.6.0. Vous y verrez l'état du cycle de vie de chaque document, ses stores et ses dernières mises à jour.", + 'comingSoon.subtitle.docsNew': + "L'import multi-fichiers (drop d'un dossier ou sélection multiple) arrive avec la 0.6.0.", + 'comingSoon.subtitle.docWorkspace': + "L'espace de travail document (Inspect / Chunks / Ask) arrive avec la 0.6.0.", + 'comingSoon.subtitle.stores': 'La liste des stores arrive avec la 0.6.0.', + 'comingSoon.subtitle.storeDetail': + 'La vue détaillée du store (documents présents, état par store) arrive avec la 0.6.0.', + 'comingSoon.subtitle.storeQuery': 'Le playground de requête RAG arrive avec la 0.6.0.', + 'comingSoon.subtitle.runs': "L'historique des runs (audit / debug) arrive avec la 0.6.0.", + 'comingSoon.subtitle.runDetail': "Le détail d'un run arrive avec la 0.6.0.", + 'comingSoon.hint.docWorkspace': 'doc {id} · mode {mode}', + 'comingSoon.hint.storeDetail': 'store {store}', + 'comingSoon.hint.storeQuery': 'store {store}', + 'comingSoon.hint.runDetail': 'run {id}', + 'comingSoon.backHome': "Retour à l'accueil", + // Home 'home.title': 'Docling Studio', 'home.subtitle': @@ -287,6 +307,26 @@ const messages: Messages = { 'topbar.newAnalysis': 'New analysis', + // Coming-soon placeholders (0.6.0 doc-centric routes — #207) + 'comingSoon.title': 'Coming soon', + 'comingSoon.subtitle.docsLibrary': + 'The document library lands with 0.6.0. It will show every document with its lifecycle state, the stores it lives in, and when it was last updated.', + 'comingSoon.subtitle.docsNew': + 'Multi-file import (drop a folder or pick multiple files) lands with 0.6.0.', + 'comingSoon.subtitle.docWorkspace': + 'The doc workspace (Inspect / Chunks / Ask) lands with 0.6.0.', + 'comingSoon.subtitle.stores': 'The stores list lands with 0.6.0.', + 'comingSoon.subtitle.storeDetail': + 'The store detail view (docs present, per-store state) lands with 0.6.0.', + 'comingSoon.subtitle.storeQuery': 'The RAG query playground lands with 0.6.0.', + 'comingSoon.subtitle.runs': 'The runs history (audit / debug) lands with 0.6.0.', + 'comingSoon.subtitle.runDetail': 'Run detail lands with 0.6.0.', + 'comingSoon.hint.docWorkspace': 'doc {id} · mode {mode}', + 'comingSoon.hint.storeDetail': 'store {store}', + 'comingSoon.hint.storeQuery': 'store {store}', + 'comingSoon.hint.runDetail': 'run {id}', + 'comingSoon.backHome': 'Back to home', + 'home.title': 'Docling Studio', 'home.subtitle': 'Analyze, explore and validate the structure of your PDF documents with Docling.', diff --git a/frontend/src/shared/routing/modes.test.ts b/frontend/src/shared/routing/modes.test.ts new file mode 100644 index 0000000..7de79fa --- /dev/null +++ b/frontend/src/shared/routing/modes.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { ALL_MODES, DEFAULT_MODE, isDocMode, parseMode } from './modes' + +describe('isDocMode', () => { + it.each(['ask', 'inspect', 'chunks'])('accepts %s', (value) => { + expect(isDocMode(value)).toBe(true) + }) + + it.each([undefined, null, '', 'foo', 42, {}, []])('rejects %s', (value) => { + expect(isDocMode(value)).toBe(false) + }) +}) + +describe('parseMode', () => { + it('returns the default for missing or unknown values', () => { + expect(parseMode(undefined)).toBe(DEFAULT_MODE) + expect(parseMode(null)).toBe(DEFAULT_MODE) + expect(parseMode('garbage')).toBe(DEFAULT_MODE) + expect(parseMode(['chunks'])).toBe(DEFAULT_MODE) // arrays not accepted + }) + + it.each(['ask', 'inspect', 'chunks'] as const)('respects %s', (mode) => { + expect(parseMode(mode)).toBe(mode) + }) +}) + +describe('ALL_MODES', () => { + it('lists every mode exactly once', () => { + expect(new Set(ALL_MODES).size).toBe(ALL_MODES.length) + expect(ALL_MODES).toContain('ask') + expect(ALL_MODES).toContain('inspect') + expect(ALL_MODES).toContain('chunks') + }) +}) diff --git a/frontend/src/shared/routing/modes.ts b/frontend/src/shared/routing/modes.ts new file mode 100644 index 0000000..3cccc8e --- /dev/null +++ b/frontend/src/shared/routing/modes.ts @@ -0,0 +1,24 @@ +/** + * Doc workspace mode parsing. + * + * The doc workspace at `/docs/:id` exposes three modes via the `?mode=` + * query param. Anything missing or unknown resolves to the default, + * `ask`, so a malformed URL never produces a broken page. + * + * #210 layers feature-flag-aware redirection on top: if the requested + * mode is disabled for the current tenant, the router replaces it with + * the first enabled mode (priority `ask` > `chunks` > `inspect`). + */ + +export type DocMode = 'ask' | 'inspect' | 'chunks' + +export const DEFAULT_MODE: DocMode = 'ask' +export const ALL_MODES: readonly DocMode[] = ['ask', 'inspect', 'chunks'] as const + +export function isDocMode(value: unknown): value is DocMode { + return value === 'ask' || value === 'inspect' || value === 'chunks' +} + +export function parseMode(raw: unknown): DocMode { + return isDocMode(raw) ? raw : DEFAULT_MODE +} diff --git a/frontend/src/shared/routing/names.ts b/frontend/src/shared/routing/names.ts new file mode 100644 index 0000000..4c2e89b --- /dev/null +++ b/frontend/src/shared/routing/names.ts @@ -0,0 +1,35 @@ +/** + * Canonical route name constants — typed to keep callers honest. + * + * Use: + * + * router.push({ name: ROUTES.DOC_WORKSPACE, params: { id } }) + * + * instead of stringly-typed names. Adding a route requires touching + * exactly two places: the router definition and this file. + */ + +export const ROUTES = { + // Existing legacy routes — kept until E3/E4/E5 replace them. + HOME: 'home', + STUDIO: 'studio', + HISTORY: 'history', + DOCUMENTS: 'documents', + SEARCH: 'search', + REASONING: 'reasoning', + REASONING_DOC: 'reasoning-doc', + SETTINGS: 'settings', + NOT_FOUND: 'not-found', + + // 0.6.0 — Document-centric routes (#207). + DOCS_LIBRARY: 'docs-library', + DOCS_NEW: 'docs-new', + DOC_WORKSPACE: 'doc-workspace', + STORES_LIST: 'stores-list', + STORE_DETAIL: 'store-detail', + STORE_QUERY: 'store-query', + RUNS: 'runs', + RUN_DETAIL: 'run-detail', +} as const + +export type RouteName = (typeof ROUTES)[keyof typeof ROUTES] diff --git a/frontend/src/shared/ui/ComingSoonShell.vue b/frontend/src/shared/ui/ComingSoonShell.vue new file mode 100644 index 0000000..89ed35b --- /dev/null +++ b/frontend/src/shared/ui/ComingSoonShell.vue @@ -0,0 +1,101 @@ + + + + +