chore: merge executor worktree (10-03 BreadcrumbBar)
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
phase: 10-ux-interaction
|
||||||
|
plan: "03"
|
||||||
|
subsystem: frontend/ui
|
||||||
|
tags: [breadcrumb, navigation, vue, tdd, options-api]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides: [BreadcrumbBar.vue, AppIcon.vue]
|
||||||
|
affects: [StorageBrowser.vue, FileManagerView.vue, CloudFolderView.vue, admin views, settings views]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [options-api, computed-visibleSegments, AppIcon-separator, tdd-red-green]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- frontend/src/components/ui/BreadcrumbBar.vue
|
||||||
|
- frontend/src/components/ui/AppIcon.vue
|
||||||
|
- frontend/src/components/ui/__tests__/BreadcrumbBar.test.js
|
||||||
|
modified: []
|
||||||
|
decisions:
|
||||||
|
- "Options API chosen for BreadcrumbBar per CLAUDE.md convention (despite FolderBreadcrumb using script setup)"
|
||||||
|
- "AppIcon.vue created in worktree as Rule-3 dependency fix (plan 10-01 creates it in another wave-0 agent)"
|
||||||
|
- "BreadcrumbBar separator uses AppIcon not inline SVG — icon centralization paradigm enforced"
|
||||||
|
metrics:
|
||||||
|
duration: "~5 minutes"
|
||||||
|
completed: "2026-06-15T18:13:50Z"
|
||||||
|
tasks_completed: 2
|
||||||
|
tasks_total: 2
|
||||||
|
files_created: 3
|
||||||
|
files_modified: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 10 Plan 03: BreadcrumbBar Component Summary
|
||||||
|
|
||||||
|
**One-liner:** Shared breadcrumb component with showRoot/rootLabel props, ellipsis collapse for >4 segments, and static no-id segment support for admin/settings views.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| 1 | Create BreadcrumbBar.test.js (RED) | b6ea858 | frontend/src/components/ui/__tests__/BreadcrumbBar.test.js |
|
||||||
|
| 2 | Implement BreadcrumbBar.vue (GREEN) | 7e584e0 | frontend/src/components/ui/BreadcrumbBar.vue, frontend/src/components/ui/AppIcon.vue |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
`BreadcrumbBar.vue` is a generalized breadcrumb component that replaces `FolderBreadcrumb.vue` across all views. Key capabilities over the original:
|
||||||
|
|
||||||
|
- **`showRoot` prop** (Boolean, default true): When false, omits the root button entirely — needed for admin/settings views (`Admin > Users`, `Settings > Account`) that have no "Home" concept.
|
||||||
|
- **`rootLabel` prop** (String, default 'Home'): Configurable root text — file manager uses 'Home', cloud view uses 'Cloud'.
|
||||||
|
- **Static segments** (no `id`): Segments without an `id` render as non-clickable `<span>` elements. Admin views pass static breadcrumb labels that should not navigate anywhere.
|
||||||
|
- **`{ id?, label }` shape**: Changed from FolderBreadcrumb's `{ id, name }` to `{ id?, label }`. Wave 1 (plan 10-06) maps existing `{ id, name }` to `{ id, label: name }` at call sites.
|
||||||
|
- **AppIcon separator**: Uses `<AppIcon name="chevronRight">` instead of inline SVG — enforces the new icon centralization paradigm from plan 10-01.
|
||||||
|
- **Ellipsis collapse**: `>4 segments` collapses to `[first, ellipsis, ...last_two]` — carried from FolderBreadcrumb.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
10 Vitest unit tests, all green:
|
||||||
|
1. Renders rootLabel button when showRoot=true
|
||||||
|
2. No button rendered when showRoot=false
|
||||||
|
3. Root click emits navigate(null)
|
||||||
|
4. Last segment is non-clickable span
|
||||||
|
5. Intermediate segment click emits navigate(segment.id)
|
||||||
|
6. No-id segments render as plain text (no buttons)
|
||||||
|
7. >4 segments collapse to first + ellipsis + last two
|
||||||
|
8. rootLabel defaults to 'Home'
|
||||||
|
9. Custom rootLabel 'Cloud' renders correctly
|
||||||
|
10. chevronRight AppIcon separator present
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] AppIcon.vue missing in worktree**
|
||||||
|
- **Found during:** Task 2 (implementing BreadcrumbBar.vue which imports AppIcon)
|
||||||
|
- **Issue:** `frontend/src/components/ui/AppIcon.vue` does not exist in this worktree. Plan 10-01 creates AppIcon in a parallel wave-0 agent. Without AppIcon, the BreadcrumbBar import would fail at test time.
|
||||||
|
- **Fix:** Created `AppIcon.vue` in the worktree using the full implementation from PATTERNS.md. Identical to what plan 10-01 will produce. When the wave merges, git will show no conflict (same content).
|
||||||
|
- **Files modified:** `frontend/src/components/ui/AppIcon.vue` (created)
|
||||||
|
- **Commit:** 7e584e0
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None. BreadcrumbBar is complete and self-contained. It emits `navigate` events; the caller handles routing.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None. BreadcrumbBar is a pure presentational component — no network requests, no auth, no user data stored. Segment labels come from trusted store data (folder names, view titles) and are rendered via Vue template interpolation (auto-escaped, no XSS risk).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- [x] `frontend/src/components/ui/__tests__/BreadcrumbBar.test.js` exists
|
||||||
|
- [x] `frontend/src/components/ui/BreadcrumbBar.vue` exists
|
||||||
|
- [x] `frontend/src/components/ui/AppIcon.vue` exists
|
||||||
|
- [x] Commit b6ea858 exists (RED phase)
|
||||||
|
- [x] Commit 7e584e0 exists (GREEN phase)
|
||||||
|
- [x] All 10 tests pass (`./node_modules/.bin/vitest run BreadcrumbBar` — 10/10)
|
||||||
|
- [x] `name: 'BreadcrumbBar'` present in BreadcrumbBar.vue
|
||||||
|
- [x] `rootLabel` appears >= 2 times in BreadcrumbBar.vue
|
||||||
|
- [x] `showRoot` appears >= 3 times in BreadcrumbBar.vue
|
||||||
|
- [x] `<AppIcon name="chevronRight"` present in BreadcrumbBar.vue
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<nav aria-label="Navigation">
|
||||||
|
<ol class="flex items-center gap-1 text-sm flex-wrap">
|
||||||
|
<li v-if="showRoot" class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="$emit('navigate', null)"
|
||||||
|
class="text-indigo-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{{ rootLabel }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<template v-for="(segment, idx) in visibleSegments" :key="segment.id ?? 'static-' + idx">
|
||||||
|
<li
|
||||||
|
v-if="showRoot || idx > 0"
|
||||||
|
class="shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<AppIcon name="chevronRight" class="w-3 h-3 text-gray-400" />
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li v-if="segment.id === 'ellipsis'" class="flex items-center">
|
||||||
|
<span class="px-2 py-1 text-gray-400">…</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li v-else-if="idx === visibleSegments.length - 1" class="flex items-center">
|
||||||
|
<span class="text-gray-900 font-medium">{{ segment.label }}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li v-else-if="segment.id" class="flex items-center">
|
||||||
|
<button
|
||||||
|
@click="$emit('navigate', segment.id)"
|
||||||
|
class="text-indigo-600 hover:underline font-medium"
|
||||||
|
>
|
||||||
|
{{ segment.label }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li v-else class="flex items-center">
|
||||||
|
<span class="text-gray-500">{{ segment.label }}</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AppIcon from './AppIcon.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'BreadcrumbBar',
|
||||||
|
components: { AppIcon },
|
||||||
|
props: {
|
||||||
|
segments: { type: Array, default: () => [] },
|
||||||
|
rootLabel: { type: String, default: 'Home' },
|
||||||
|
showRoot: { type: Boolean, default: true },
|
||||||
|
},
|
||||||
|
emits: ['navigate'],
|
||||||
|
computed: {
|
||||||
|
visibleSegments() {
|
||||||
|
if (this.segments.length > 4) {
|
||||||
|
return [
|
||||||
|
this.segments[0],
|
||||||
|
{ id: 'ellipsis', label: '…' },
|
||||||
|
...this.segments.slice(-2),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return this.segments
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
||||||
|
|
||||||
|
const STUBS = { global: { stubs: { AppIcon: true } } }
|
||||||
|
|
||||||
|
function seg(id, label) { return { id, label } }
|
||||||
|
function staticSeg(label) { return { label } }
|
||||||
|
|
||||||
|
describe('BreadcrumbBar', () => {
|
||||||
|
it('renders rootLabel button when showRoot=true', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { rootLabel: 'Home', segments: [] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
expect(w.find('button').text()).toBe('Home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT render root button when showRoot=false', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { showRoot: false, segments: [staticSeg('Users')] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
expect(w.findAll('button').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking root button emits navigate(null)', async () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { segments: [] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
await w.find('button').trigger('click')
|
||||||
|
expect(w.emitted('navigate')).toBeTruthy()
|
||||||
|
expect(w.emitted('navigate')[0]).toEqual([null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last segment renders as a non-clickable <span>', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { segments: [seg('a', 'A'), seg('b', 'B')] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
const text = w.text()
|
||||||
|
expect(text).toContain('B')
|
||||||
|
const buttonWithB = w.findAll('button').filter(b => b.text() === 'B')
|
||||||
|
expect(buttonWithB.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking intermediate segment emits navigate(segment.id)', async () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { segments: [seg('r1', 'Root'), seg('f1', 'Test')] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
const buttons = w.findAll('button')
|
||||||
|
const rootBtn = buttons.find(b => b.text() === 'Root')
|
||||||
|
await rootBtn.trigger('click')
|
||||||
|
expect(w.emitted('navigate')).toBeTruthy()
|
||||||
|
expect(w.emitted('navigate')[0]).toEqual(['r1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('segments without id render as plain text even when intermediate', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: {
|
||||||
|
showRoot: false,
|
||||||
|
segments: [staticSeg('Admin'), staticSeg('Users')],
|
||||||
|
},
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
expect(w.findAll('button').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('>4 segments collapse to first + ellipsis + last two', () => {
|
||||||
|
const segments = [
|
||||||
|
seg('a', 'Alpha'),
|
||||||
|
seg('b', 'Beta'),
|
||||||
|
seg('c', 'Gamma'),
|
||||||
|
seg('d', 'Delta'),
|
||||||
|
seg('e', 'Epsilon'),
|
||||||
|
]
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { segments },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
const text = w.text()
|
||||||
|
expect(text).toContain('Alpha')
|
||||||
|
expect(text).toContain('…')
|
||||||
|
expect(text).toContain('Delta')
|
||||||
|
expect(text).toContain('Epsilon')
|
||||||
|
expect(text).not.toContain('Beta')
|
||||||
|
expect(text).not.toContain('Gamma')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rootLabel defaults to "Home"', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { segments: [] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
expect(w.find('button').text()).toBe('Home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('custom rootLabel "Cloud" renders correctly', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { rootLabel: 'Cloud', segments: [] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
expect(w.find('button').text()).toBe('Cloud')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders chevronRight separator between segments', () => {
|
||||||
|
const w = mount(BreadcrumbBar, {
|
||||||
|
props: { segments: [seg('a', 'A'), seg('b', 'B')] },
|
||||||
|
...STUBS,
|
||||||
|
})
|
||||||
|
const appIcons = w.findAll('app-icon-stub')
|
||||||
|
expect(appIcons.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user