Merge pull request #232 from scub-france/feature/207-document-centric-routing

feat(#207): document-centric routing skeleton
This commit is contained in:
Pier-Jean Malandrino 2026-04-29 17:52:48 +02:00 committed by GitHub
commit b74e3b169c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 578 additions and 54 deletions

View file

@ -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(),

View file

@ -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()
})
})

View file

@ -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: '/',
},
]

View file

@ -0,0 +1,27 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.docWorkspace')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import { type DocMode } from '../shared/routing/modes'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
/**
* Doc workspace placeholder. Receives the doc id (from `:id` path param)
* and the resolved mode (from `?mode=` query param, parsed by the
* router via `parseMode`). The full workspace is built by #216 (E4)
* on top of #218-#224 (E5 chunks editor).
*/
const props = defineProps<{ id: string; mode: DocMode }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
</script>

View file

@ -0,0 +1,14 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.docsLibrary')"
/>
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -0,0 +1,11 @@
<template>
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.docsNew')" />
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -0,0 +1,20 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.runDetail')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const props = defineProps<{ id: string }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.runDetail', { id: props.id }))
</script>

View file

@ -0,0 +1,11 @@
<template>
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.runs')" />
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -0,0 +1,20 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.storeDetail')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const props = defineProps<{ store: string }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.storeDetail', { store: props.store }))
</script>

View file

@ -0,0 +1,20 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.storeQuery')"
:hint="hint"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const props = defineProps<{ store: string }>()
const { t } = useI18n()
const hint = computed(() => t('comingSoon.hint.storeQuery', { store: props.store }))
</script>

View file

@ -0,0 +1,11 @@
<template>
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.stores')" />
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
const { t } = useI18n()
</script>

View file

@ -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.',

View file

@ -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')
})
})

View file

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

View file

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

View file

@ -0,0 +1,101 @@
<template>
<div class="coming-soon">
<div class="coming-soon__card">
<div class="coming-soon__badge">0.6.0</div>
<h1 class="coming-soon__title">{{ title }}</h1>
<p class="coming-soon__subtitle">{{ subtitle }}</p>
<p v-if="hint" class="coming-soon__hint">{{ hint }}</p>
<RouterLink :to="{ name: ROUTES.HOME }" class="coming-soon__back">
{{ t('comingSoon.backHome') }}
</RouterLink>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../i18n'
import { ROUTES } from '../routing/names'
defineProps<{
/** Page title shown as `<h1>`. Typically the i18n-resolved page name. */
title: string
/** One-sentence description of what the page will do once shipped. */
subtitle: string
/** Optional secondary hint (e.g. issue link, ETA). */
hint?: string
}>()
const { t } = useI18n()
</script>
<style scoped>
.coming-soon {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 48px);
padding: 2rem;
}
.coming-soon__card {
max-width: 28rem;
text-align: center;
padding: 2.5rem 2rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.coming-soon__badge {
display: inline-block;
margin-bottom: 1rem;
padding: 0.25rem 0.6rem;
background: var(--color-accent-soft, #eff6ff);
color: var(--color-accent, #1d4ed8);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.coming-soon__title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #111);
}
.coming-soon__subtitle {
margin: 0 0 1rem;
font-size: 1rem;
color: var(--color-text-muted, #6b7280);
line-height: 1.5;
}
.coming-soon__hint {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--color-text-muted, #6b7280);
}
.coming-soon__back {
display: inline-block;
padding: 0.5rem 1rem;
background: transparent;
color: var(--color-accent, #1d4ed8);
border: 1px solid var(--color-accent, #1d4ed8);
border-radius: 6px;
font-size: 0.875rem;
text-decoration: none;
transition:
background-color 0.15s,
color 0.15s;
}
.coming-soon__back:hover {
background: var(--color-accent, #1d4ed8);
color: #fff;
}
</style>