Merge pull request #233 from scub-france/feature/208-doc-workspace-breadcrumb
feat(#208): doc workspace breadcrumb (Studio > <doc> > <mode>)
This commit is contained in:
commit
a645d26e6b
9 changed files with 308 additions and 1 deletions
|
|
@ -40,6 +40,7 @@
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
<AppSidebar :open="sidebarOpen" />
|
<AppSidebar :open="sidebarOpen" />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
<AppBreadcrumb :crumbs="breadcrumbStore.crumbs" />
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,6 +51,8 @@
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { RouterView, useRouter } from 'vue-router'
|
import { RouterView, useRouter } from 'vue-router'
|
||||||
import { AppSidebar } from '../shared/ui/index'
|
import { AppSidebar } from '../shared/ui/index'
|
||||||
|
import AppBreadcrumb from '../shared/breadcrumb/AppBreadcrumb.vue'
|
||||||
|
import { useBreadcrumbStore } from '../shared/breadcrumb/store'
|
||||||
import { useSettingsStore } from '../features/settings/store'
|
import { useSettingsStore } from '../features/settings/store'
|
||||||
import { useDocumentStore } from '../features/document/store'
|
import { useDocumentStore } from '../features/document/store'
|
||||||
import { useFeatureFlag } from '../features/feature-flags'
|
import { useFeatureFlag } from '../features/feature-flags'
|
||||||
|
|
@ -58,6 +61,7 @@ import { useI18n } from '../shared/i18n'
|
||||||
|
|
||||||
useSettingsStore()
|
useSettingsStore()
|
||||||
const flagStore = useFeatureFlagStore()
|
const flagStore = useFeatureFlagStore()
|
||||||
|
const breadcrumbStore = useBreadcrumbStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const documentStore = useDocumentStore()
|
const documentStore = useDocumentStore()
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,13 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useI18n } from '../shared/i18n'
|
|
||||||
|
|
||||||
|
import { useCrumbs } from '../shared/breadcrumb/store'
|
||||||
|
import { truncate } from '../shared/breadcrumb/text'
|
||||||
|
import type { Crumb } from '../shared/breadcrumb/types'
|
||||||
|
import { useI18n } from '../shared/i18n'
|
||||||
import { type DocMode } from '../shared/routing/modes'
|
import { type DocMode } from '../shared/routing/modes'
|
||||||
|
import { ROUTES } from '../shared/routing/names'
|
||||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -24,4 +28,18 @@ const props = defineProps<{ id: string; mode: DocMode }>()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
|
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
|
||||||
|
|
||||||
|
// Provide the breadcrumb segments. Once the doc is fetched (E4 / #216),
|
||||||
|
// `truncate(doc.filename, 40)` will replace the id; for now we render
|
||||||
|
// the id itself so the user can still see what they are looking at.
|
||||||
|
const crumbs = computed<Crumb[]>(() => [
|
||||||
|
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
|
||||||
|
{
|
||||||
|
kind: 'link',
|
||||||
|
label: truncate(props.id, 40),
|
||||||
|
to: { name: ROUTES.DOC_WORKSPACE, params: { id: props.id } },
|
||||||
|
},
|
||||||
|
{ kind: 'leaf', label: t(`breadcrumb.mode.${props.mode}`) },
|
||||||
|
])
|
||||||
|
useCrumbs(crumbs)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
93
frontend/src/shared/breadcrumb/AppBreadcrumb.vue
Normal file
93
frontend/src/shared/breadcrumb/AppBreadcrumb.vue
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<template>
|
||||||
|
<nav v-if="crumbs.length > 0" class="breadcrumb" :aria-label="t('breadcrumb.aria')">
|
||||||
|
<ol class="breadcrumb__list">
|
||||||
|
<li
|
||||||
|
v-for="(crumb, index) in crumbs"
|
||||||
|
:key="`${index}-${crumb.label}`"
|
||||||
|
class="breadcrumb__item"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
v-if="crumb.kind === 'link'"
|
||||||
|
:to="crumb.to"
|
||||||
|
class="breadcrumb__link"
|
||||||
|
:title="crumb.label"
|
||||||
|
>
|
||||||
|
{{ crumb.label }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else class="breadcrumb__leaf" aria-current="page" :title="crumb.label">
|
||||||
|
{{ crumb.label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="index < crumbs.length - 1" class="breadcrumb__sep" aria-hidden="true"> › </span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '../i18n'
|
||||||
|
|
||||||
|
import type { Crumb } from './types'
|
||||||
|
|
||||||
|
defineProps<{ crumbs: Crumb[] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
border-bottom: 1px solid var(--color-border, #1a1a1d);
|
||||||
|
background: var(--color-surface, #111113);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb__list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb__link {
|
||||||
|
color: var(--color-text-muted, #9ca3af);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 16rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb__link:hover {
|
||||||
|
color: var(--color-text, #f3f4f6);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb__leaf {
|
||||||
|
color: var(--color-text, #f3f4f6);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 16rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb__sep {
|
||||||
|
color: var(--color-text-muted, #6b7280);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
frontend/src/shared/breadcrumb/store.test.ts
Normal file
71
frontend/src/shared/breadcrumb/store.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { useBreadcrumbStore, useCrumbs } from './store'
|
||||||
|
import type { Crumb } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composable side of `useCrumbs` registers `onBeforeUnmount` which
|
||||||
|
* requires a component lifecycle context. The project does not have
|
||||||
|
* `@vue/test-utils`, so we test the store API directly. The composable
|
||||||
|
* lifecycle is exercised end-to-end by the integration tests landing
|
||||||
|
* in #211 / #216.
|
||||||
|
*
|
||||||
|
* We DO test the reactive-input behaviour by calling `useCrumbs` with
|
||||||
|
* a ref inside a setup-style scope; the watch fires synchronously
|
||||||
|
* thanks to `immediate: true`.
|
||||||
|
*/
|
||||||
|
describe('useBreadcrumbStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts empty', () => {
|
||||||
|
const store = useBreadcrumbStore()
|
||||||
|
expect(store.crumbs).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setCrumbs replaces the segments', () => {
|
||||||
|
const store = useBreadcrumbStore()
|
||||||
|
const next: Crumb[] = [{ kind: 'leaf', label: 'a' }]
|
||||||
|
store.setCrumbs(next)
|
||||||
|
expect(store.crumbs).toEqual(next)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear empties the segments', () => {
|
||||||
|
const store = useBreadcrumbStore()
|
||||||
|
store.setCrumbs([{ kind: 'leaf', label: 'a' }])
|
||||||
|
store.clear()
|
||||||
|
expect(store.crumbs).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useCrumbs (reactive source path)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds the store from a ref synchronously', () => {
|
||||||
|
const store = useBreadcrumbStore()
|
||||||
|
const dynamic: Ref<Crumb[]> = ref([{ kind: 'leaf', label: 'first' }])
|
||||||
|
// Note: `useCrumbs` calls `onBeforeUnmount` which is a no-op when
|
||||||
|
// there is no current component — Vue logs a warning but the watch
|
||||||
|
// path still installs.
|
||||||
|
useCrumbs(dynamic)
|
||||||
|
expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'first' }])
|
||||||
|
|
||||||
|
dynamic.value = [{ kind: 'leaf', label: 'second' }]
|
||||||
|
// The watch is `immediate: true`; for subsequent updates it runs
|
||||||
|
// on the next microtask.
|
||||||
|
return Promise.resolve().then(() => {
|
||||||
|
expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'second' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds the store from a static array', () => {
|
||||||
|
const store = useBreadcrumbStore()
|
||||||
|
useCrumbs([{ kind: 'leaf', label: 'static' }])
|
||||||
|
expect(store.crumbs).toEqual([{ kind: 'leaf', label: 'static' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
53
frontend/src/shared/breadcrumb/store.ts
Normal file
53
frontend/src/shared/breadcrumb/store.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { onBeforeUnmount, watch, type Ref, type ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
import type { Crumb } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the current breadcrumb segments. Pages set them on mount via
|
||||||
|
* `useCrumbs(crumbs)` and clear them on unmount automatically.
|
||||||
|
*
|
||||||
|
* The shell (`App.vue`) reads `crumbs` and renders `<AppBreadcrumb>` —
|
||||||
|
* empty array → component renders nothing.
|
||||||
|
*/
|
||||||
|
export const useBreadcrumbStore = defineStore('breadcrumb', {
|
||||||
|
state: () => ({
|
||||||
|
crumbs: [] as Crumb[],
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
setCrumbs(crumbs: Crumb[]) {
|
||||||
|
this.crumbs = crumbs
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.crumbs = []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for pages to declare their breadcrumb. Auto-clears on
|
||||||
|
* unmount so a stale breadcrumb never leaks to the next route.
|
||||||
|
*
|
||||||
|
* Accepts a static array OR a reactive ref / computed so pages with
|
||||||
|
* async data (e.g. doc workspace fetching the doc title) can rebuild
|
||||||
|
* crumbs as the data lands.
|
||||||
|
*/
|
||||||
|
export function useCrumbs(source: Crumb[] | Ref<Crumb[]> | ComputedRef<Crumb[]>) {
|
||||||
|
const store = useBreadcrumbStore()
|
||||||
|
|
||||||
|
if (Array.isArray(source)) {
|
||||||
|
store.setCrumbs(source)
|
||||||
|
} else {
|
||||||
|
watch(
|
||||||
|
source,
|
||||||
|
(next) => {
|
||||||
|
store.setCrumbs(next)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
store.clear()
|
||||||
|
})
|
||||||
|
}
|
||||||
27
frontend/src/shared/breadcrumb/text.test.ts
Normal file
27
frontend/src/shared/breadcrumb/text.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { truncate } from './text'
|
||||||
|
|
||||||
|
describe('truncate', () => {
|
||||||
|
it('returns the input unchanged when shorter than the limit', () => {
|
||||||
|
expect(truncate('short', 10)).toBe('short')
|
||||||
|
expect(truncate('exactly10!', 10)).toBe('exactly10!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('truncates and adds an ellipsis when longer than the limit', () => {
|
||||||
|
expect(truncate('this is a long title', 10)).toBe('this is a…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims trailing whitespace before the ellipsis', () => {
|
||||||
|
expect(truncate('foo bar baz qux', 8)).toBe('foo bar…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the input untouched for non-positive limits', () => {
|
||||||
|
expect(truncate('hello', 0)).toBe('hello')
|
||||||
|
expect(truncate('hello', -5)).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty strings', () => {
|
||||||
|
expect(truncate('', 10)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
14
frontend/src/shared/breadcrumb/text.ts
Normal file
14
frontend/src/shared/breadcrumb/text.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/**
|
||||||
|
* Truncate `text` to at most `max` characters, replacing the tail with
|
||||||
|
* an ellipsis when shortened. Returns the original string if it
|
||||||
|
* already fits.
|
||||||
|
*
|
||||||
|
* Used by the breadcrumb to keep the topbar tidy when document
|
||||||
|
* filenames are long. The full title stays available via the `title`
|
||||||
|
* attribute on the segment so the user can hover to read it whole.
|
||||||
|
*/
|
||||||
|
export function truncate(text: string, max: number): string {
|
||||||
|
if (max <= 0 || text.length <= max) return text
|
||||||
|
// Reserve one character for the ellipsis so the visible length is `max`.
|
||||||
|
return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…'
|
||||||
|
}
|
||||||
13
frontend/src/shared/breadcrumb/types.ts
Normal file
13
frontend/src/shared/breadcrumb/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One segment in a breadcrumb.
|
||||||
|
*
|
||||||
|
* - `LinkCrumb` renders as a `<RouterLink>` to `to`.
|
||||||
|
* - `LeafCrumb` renders as a non-clickable label with `aria-current="page"`.
|
||||||
|
* The leaf is always the last segment; the rendering component refuses
|
||||||
|
* to render a leaf in a non-final position.
|
||||||
|
*/
|
||||||
|
export type LinkCrumb = { kind: 'link'; label: string; to: RouteLocationRaw }
|
||||||
|
export type LeafCrumb = { kind: 'leaf'; label: string }
|
||||||
|
export type Crumb = LinkCrumb | LeafCrumb
|
||||||
|
|
@ -19,6 +19,13 @@ const messages: Messages = {
|
||||||
// Top bar
|
// Top bar
|
||||||
'topbar.newAnalysis': 'Nouvelle analyse',
|
'topbar.newAnalysis': 'Nouvelle analyse',
|
||||||
|
|
||||||
|
// Breadcrumb (0.6.0 doc workspace — #208)
|
||||||
|
'breadcrumb.aria': "Fil d'Ariane",
|
||||||
|
'breadcrumb.studio': 'Studio',
|
||||||
|
'breadcrumb.mode.ask': 'Ask',
|
||||||
|
'breadcrumb.mode.inspect': 'Inspect',
|
||||||
|
'breadcrumb.mode.chunks': 'Chunks',
|
||||||
|
|
||||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||||
'comingSoon.title': 'Bientôt disponible',
|
'comingSoon.title': 'Bientôt disponible',
|
||||||
'comingSoon.subtitle.docsLibrary':
|
'comingSoon.subtitle.docsLibrary':
|
||||||
|
|
@ -307,6 +314,13 @@ const messages: Messages = {
|
||||||
|
|
||||||
'topbar.newAnalysis': 'New analysis',
|
'topbar.newAnalysis': 'New analysis',
|
||||||
|
|
||||||
|
// Breadcrumb (0.6.0 doc workspace — #208)
|
||||||
|
'breadcrumb.aria': 'Breadcrumb',
|
||||||
|
'breadcrumb.studio': 'Studio',
|
||||||
|
'breadcrumb.mode.ask': 'Ask',
|
||||||
|
'breadcrumb.mode.inspect': 'Inspect',
|
||||||
|
'breadcrumb.mode.chunks': 'Chunks',
|
||||||
|
|
||||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||||
'comingSoon.title': 'Coming soon',
|
'comingSoon.title': 'Coming soon',
|
||||||
'comingSoon.subtitle.docsLibrary':
|
'comingSoon.subtitle.docsLibrary':
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue