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
This commit is contained in:
parent
621a9e3fce
commit
f10cd01594
16 changed files with 578 additions and 54 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
81
frontend/src/app/router/router.test.ts
Normal file
81
frontend/src/app/router/router.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
125
frontend/src/app/router/routes.ts
Normal file
125
frontend/src/app/router/routes.ts
Normal 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: '/',
|
||||
},
|
||||
]
|
||||
27
frontend/src/pages/DocWorkspacePage.vue
Normal file
27
frontend/src/pages/DocWorkspacePage.vue
Normal 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>
|
||||
14
frontend/src/pages/DocsLibraryPage.vue
Normal file
14
frontend/src/pages/DocsLibraryPage.vue
Normal 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>
|
||||
11
frontend/src/pages/DocsNewPage.vue
Normal file
11
frontend/src/pages/DocsNewPage.vue
Normal 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>
|
||||
20
frontend/src/pages/RunDetailPage.vue
Normal file
20
frontend/src/pages/RunDetailPage.vue
Normal 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>
|
||||
11
frontend/src/pages/RunsPage.vue
Normal file
11
frontend/src/pages/RunsPage.vue
Normal 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>
|
||||
20
frontend/src/pages/StoreDetailPage.vue
Normal file
20
frontend/src/pages/StoreDetailPage.vue
Normal 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>
|
||||
20
frontend/src/pages/StoreQueryPage.vue
Normal file
20
frontend/src/pages/StoreQueryPage.vue
Normal 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>
|
||||
11
frontend/src/pages/StoresListPage.vue
Normal file
11
frontend/src/pages/StoresListPage.vue
Normal 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>
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
35
frontend/src/shared/routing/modes.test.ts
Normal file
35
frontend/src/shared/routing/modes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
24
frontend/src/shared/routing/modes.ts
Normal file
24
frontend/src/shared/routing/modes.ts
Normal 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
|
||||
}
|
||||
35
frontend/src/shared/routing/names.ts
Normal file
35
frontend/src/shared/routing/names.ts
Normal 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]
|
||||
101
frontend/src/shared/ui/ComingSoonShell.vue
Normal file
101
frontend/src/shared/ui/ComingSoonShell.vue
Normal 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>
|
||||
Loading…
Reference in a new issue