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 @@ + + + + +