Moves phases 08–11 execution artifacts from .planning/phases/ to .planning/milestones/v0.2-phases/ to keep .planning/phases/ clean for the next milestone. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
18 KiB
Markdown
343 lines
18 KiB
Markdown
---
|
|
phase: 10-ux-interaction
|
|
plan: 09
|
|
type: execute
|
|
wave: 2
|
|
depends_on: [10-04, 10-06, 10-05]
|
|
files_modified:
|
|
- frontend/src/App.vue
|
|
- frontend/src/views/FileManagerView.vue
|
|
- frontend/src/components/storage/StorageBrowser.vue
|
|
- frontend/src/components/upload/DropZone.vue
|
|
- frontend/src/components/documents/SearchBar.vue
|
|
- frontend/src/__tests__/keyboard.test.js
|
|
autonomous: true
|
|
requirements: [UX-05, UX-06, UX-07, UX-08]
|
|
must_haves:
|
|
truths:
|
|
- "Pressing `/` (when no input focused) calls focus() on the SearchBar input via App.vue routeViewRef chain"
|
|
- "Pressing Escape (when no input focused) clears the active search query"
|
|
- "Pressing U (when no input focused) triggers DropZone.triggerInput via the ref chain"
|
|
- "Pressing N (when no input focused) starts the new-folder inline input in StorageBrowser"
|
|
- "The global keydown handler guards against active INPUT/TEXTAREA/SELECT/contenteditable elements and returns early"
|
|
- "DropZone.triggerInput is exposed via defineExpose"
|
|
- "SearchBar.focus is exposed via defineExpose"
|
|
- "StorageBrowser exposes triggerUpload, focusSearch, clearSearch alongside its existing startNewFolder"
|
|
- "FileManagerView exposes focusSearch, triggerUpload, startNewFolder, clearSearch via defineExpose"
|
|
artifacts:
|
|
- path: "frontend/src/App.vue"
|
|
provides: "Global keydown handler + routeViewRef"
|
|
- path: "frontend/src/views/FileManagerView.vue"
|
|
provides: "defineExpose of focusSearch/triggerUpload/startNewFolder/clearSearch"
|
|
- path: "frontend/src/components/storage/StorageBrowser.vue"
|
|
provides: "Updated defineExpose with triggerUpload + focusSearch + clearSearch"
|
|
- path: "frontend/src/components/upload/DropZone.vue"
|
|
provides: "defineExpose of triggerInput"
|
|
- path: "frontend/src/components/documents/SearchBar.vue"
|
|
provides: "defineExpose of focus()"
|
|
key_links:
|
|
- from: "App.vue keydown handler"
|
|
to: "routeViewRef.value methods"
|
|
via: "optional chaining (?.)"
|
|
pattern: "routeViewRef\\.value\\?\\."
|
|
- from: "StorageBrowser.vue"
|
|
to: "DropZone.vue"
|
|
via: "dropZoneRef.value?.triggerInput()"
|
|
pattern: "dropZoneRef\\.value"
|
|
- from: "StorageBrowser.vue"
|
|
to: "SearchBar.vue"
|
|
via: "searchBarRef.value?.focus()"
|
|
pattern: "searchBarRef\\.value"
|
|
---
|
|
|
|
<objective>
|
|
Implement the four global keyboard shortcuts (`/`, `Escape`, `U`, `N`) per D-14, D-15. The handler lives in `App.vue` (already `<script setup>`) and delegates to the active route component through a chain of `ref` + `defineExpose` calls:
|
|
|
|
App.vue routeViewRef -> FileManagerView (focusSearch/triggerUpload/startNewFolder/clearSearch) -> StorageBrowser (focusSearch/triggerUpload/clearSearch + existing startNewFolder) -> DropZone (triggerInput) / SearchBar (focus).
|
|
|
|
Use optional chaining at every hop so views that do not expose a method silently no-op (per D-15 and Open Question 2 in RESEARCH.md).
|
|
|
|
Output:
|
|
- DropZone exposes triggerInput
|
|
- SearchBar exposes focus()
|
|
- StorageBrowser exposes triggerUpload + focusSearch + clearSearch
|
|
- FileManagerView exposes all four route-level methods
|
|
- App.vue gains routeViewRef + onKeydown listener
|
|
- keyboard.test.js stubs promoted to real tests
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@CLAUDE.md
|
|
@.planning/phases/10-ux-interaction/10-CONTEXT.md
|
|
@.planning/phases/10-ux-interaction/10-RESEARCH.md
|
|
@.planning/phases/10-ux-interaction/10-PATTERNS.md
|
|
@frontend/src/App.vue
|
|
@frontend/src/views/FileManagerView.vue
|
|
@frontend/src/components/storage/StorageBrowser.vue
|
|
@frontend/src/components/upload/DropZone.vue
|
|
@frontend/src/components/documents/SearchBar.vue
|
|
@frontend/src/components/documents/DocumentPreviewModal.vue
|
|
|
|
<interfaces>
|
|
App.vue keydown handler signature (Composition API, from RESEARCH.md §Code Examples):
|
|
|
|
```js
|
|
import { ref, onMounted, onUnmounted } from 'vue'
|
|
|
|
const routeViewRef = ref(null)
|
|
|
|
function onKeydown(e) {
|
|
const tag = document.activeElement?.tagName
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return
|
|
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault()
|
|
routeViewRef.value?.focusSearch?.()
|
|
}
|
|
if (e.key === 'Escape') {
|
|
routeViewRef.value?.clearSearch?.()
|
|
}
|
|
if (e.key === 'u' || e.key === 'U') {
|
|
routeViewRef.value?.triggerUpload?.()
|
|
}
|
|
if (e.key === 'n' || e.key === 'N') {
|
|
routeViewRef.value?.startNewFolder?.()
|
|
}
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
|
```
|
|
|
|
Template: change `<router-view />` to `<router-view ref="routeViewRef" />`.
|
|
|
|
Ref chain plumbing (PATTERNS.md):
|
|
- SearchBar.vue: add `const inputEl = ref(null)`; add `ref="inputEl"` to the search `<input>`; add `defineExpose({ focus() { inputEl.value?.focus() } })`.
|
|
- DropZone.vue: add `defineExpose({ triggerInput })` (the function already exists).
|
|
- StorageBrowser.vue: add `const dropZoneRef = ref(null)` and `const searchBarRef = ref(null)`; add `ref="dropZoneRef"` on `<DropZone>` and `ref="searchBarRef"` on `<SearchBar>`; extend `defineExpose` to `{ startNewFolder, triggerUpload, focusSearch, clearSearch }` where:
|
|
- `triggerUpload() { dropZoneRef.value?.triggerInput() }`
|
|
- `focusSearch() { searchBarRef.value?.focus() }`
|
|
- `clearSearch() { emit('search-change', '') }` (emit, since searchQuery is a prop)
|
|
- FileManagerView.vue: add `defineExpose({ focusSearch: () => browserRef.value?.focusSearch?.(), triggerUpload: () => browserRef.value?.triggerUpload?.(), startNewFolder: () => browserRef.value?.startNewFolder?.(), clearSearch: () => browserRef.value?.clearSearch?.() })`
|
|
|
|
DocumentPreviewModal already owns its own Escape handler (PITFALL 4) - leave it alone. The App.vue Escape branch only calls clearSearch which is a no-op when no FileManagerView is mounted.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Promote keyboard.test.js stubs to real tests</name>
|
|
<files>frontend/src/__tests__/keyboard.test.js</files>
|
|
<read_first>
|
|
- frontend/src/__tests__/keyboard.test.js (Wave 0 stub)
|
|
- frontend/src/App.vue (current state)
|
|
- frontend/src/views/FileManagerView.vue (current state)
|
|
</read_first>
|
|
<behavior>
|
|
Replace .todo entries with real assertions. Strategy: test the App.vue keydown handler in isolation by mounting App with a stubbed router-view that has a known shape (spy methods for focusSearch / clearSearch / triggerUpload / startNewFolder). Dispatch keyboard events on document and assert the spies were called or not called based on the guard.
|
|
|
|
UX-05 (3 tests):
|
|
1. `keydown "/" calls focusSearch on routeViewRef` - mount App with a stub router-view exposing a focusSearch spy as defineExpose; dispatch keydown "/", assert spy called once.
|
|
2. `keydown "/" does NOT call focusSearch when an INPUT is focused` - create an <input>, focus it, dispatch keydown "/", assert spy NOT called.
|
|
3. `keydown "/" calls preventDefault` - capture the event; assert defaultPrevented=true after dispatch when no input focused.
|
|
|
|
UX-06 (2 tests):
|
|
4. `keydown Escape calls clearSearch on routeViewRef` - dispatch Escape, assert spy called.
|
|
5. `keydown Escape does NOT call clearSearch when an INPUT is focused` - assert NOT called.
|
|
|
|
UX-07 (2 tests):
|
|
6. `keydown "u" calls triggerUpload on routeViewRef` - dispatch "u", assert spy called.
|
|
7. `keydown "U" (uppercase) also calls triggerUpload` - assert spy called when Shift+u dispatches "U".
|
|
|
|
UX-08 (2 tests):
|
|
8. `keydown "n" calls startNewFolder on routeViewRef` - assert spy called.
|
|
9. `keydown "n" does NOT call startNewFolder when CloudFolderView is the route` - mount App with a stub router-view that does NOT expose startNewFolder; assert no error thrown (optional chaining silently no-ops).
|
|
|
|
Use `vi.fn()` for spies. For "stub router-view": pass a custom component as the router-view stub via global.stubs or replace `<router-view>` with a test component. Use happy-dom default Vitest env (already in project).
|
|
|
|
Note: testing App.vue's keydown listener directly may require mounting App.vue with a mock router. A simpler approach: extract the onKeydown logic into a test helper inside App.vue (export it), OR test via component instance access. Use whichever approach works with @vue/test-utils. If mounting App.vue is too complex, write a focused unit test that imports a small reusable handler.
|
|
|
|
Alternative simpler approach (recommended): write tests that mount FileManagerView with stubbed StorageBrowser, then call `wrapper.vm.focusSearch()` / `wrapper.vm.triggerUpload()` directly to verify the defineExpose surface delegates to browserRef. This still validates the contract App.vue depends on.
|
|
|
|
Use either approach. The 9 tests above cover the App.vue handler contract.
|
|
</behavior>
|
|
<action>
|
|
Modify `frontend/src/__tests__/keyboard.test.js`. Replace each .todo with a real `it(...)` block per the behavior list above. Choose ONE testing strategy (full App.vue mount OR FileManagerView defineExpose direct invocation OR a hybrid). Aim for 9 real tests covering the four shortcuts plus their guards.
|
|
|
|
Tests fail initially because no ref chain or App.vue handler exists yet.
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npm run test -- --run keyboard</automated>
|
|
Expected: 9 tests RED.
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- File contains 9 real `it(...)` tests across 4 describe blocks (UX-05 to UX-08)
|
|
- No `.todo` entries remain
|
|
- Tests use `vi.fn()` for spies and dispatch real KeyboardEvent objects
|
|
- All 9 tests are RED before Task 2
|
|
</acceptance_criteria>
|
|
<done>9 RED keyboard shortcut tests in place.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Plumb the ref chain - DropZone + SearchBar + StorageBrowser + FileManagerView</name>
|
|
<files>
|
|
frontend/src/components/upload/DropZone.vue,
|
|
frontend/src/components/documents/SearchBar.vue,
|
|
frontend/src/components/storage/StorageBrowser.vue,
|
|
frontend/src/views/FileManagerView.vue
|
|
</files>
|
|
<read_first>
|
|
- frontend/src/components/upload/DropZone.vue (verify triggerInput already exists; no defineExpose currently)
|
|
- frontend/src/components/documents/SearchBar.vue (current state - check for <input> structure and existing exposes)
|
|
- frontend/src/components/storage/StorageBrowser.vue (current defineExpose at line 327 - has only startNewFolder)
|
|
- frontend/src/views/FileManagerView.vue (browserRef at line 61 - no defineExpose yet)
|
|
- .planning/phases/10-ux-interaction/10-PATTERNS.md (DropZone / SearchBar / StorageBrowser / FileManagerView sections)
|
|
</read_first>
|
|
<action>
|
|
Step A - DropZone.vue:
|
|
Add `defineExpose({ triggerInput })` after the `triggerInput` function definition (around line 49). The function already exists - this only exposes it.
|
|
|
|
Step B - SearchBar.vue:
|
|
Modify the component so the search `<input>` element has `ref="inputEl"`. In the script (add `import { ref } from 'vue'` if not already imported), declare `const inputEl = ref(null)`. Add `defineExpose({ focus() { inputEl.value?.focus() } })`.
|
|
|
|
Step C - StorageBrowser.vue:
|
|
1. Declare two new refs: `const dropZoneRef = ref(null)` and `const searchBarRef = ref(null)` (near the existing `newFolderInputRef` declaration around line 306).
|
|
2. Add `ref="dropZoneRef"` to the `<DropZone>` element (around line 38).
|
|
3. Add `ref="searchBarRef"` to the `<SearchBar>` element (around line 12).
|
|
4. Replace the existing `defineExpose({ startNewFolder })` at line 327 with:
|
|
```js
|
|
defineExpose({
|
|
startNewFolder,
|
|
triggerUpload: () => dropZoneRef.value?.triggerInput(),
|
|
focusSearch: () => searchBarRef.value?.focus(),
|
|
clearSearch: () => emit('search-change', ''),
|
|
})
|
|
```
|
|
|
|
Step D - FileManagerView.vue:
|
|
Add a `defineExpose(...)` block after the existing function definitions:
|
|
```js
|
|
defineExpose({
|
|
focusSearch: () => browserRef.value?.focusSearch?.(),
|
|
triggerUpload: () => browserRef.value?.triggerUpload?.(),
|
|
startNewFolder: () => browserRef.value?.startNewFolder?.(),
|
|
clearSearch: () => browserRef.value?.clearSearch?.(),
|
|
})
|
|
```
|
|
Keep all existing logic untouched.
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npm run test -- --run FileManagerView; cd frontend && npm run test -- --run StorageBrowser</automated>
|
|
Expected: existing FileManagerView + StorageBrowser tests still pass (no regression). The keyboard.test.js still fails (because App.vue handler not added yet).
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -E "defineExpose\\(\\{ triggerInput \\}\\)" frontend/src/components/upload/DropZone.vue` returns 1
|
|
- `grep -E "defineExpose\\(\\{ focus" frontend/src/components/documents/SearchBar.vue` returns 1
|
|
- `grep -E "const inputEl = ref" frontend/src/components/documents/SearchBar.vue` returns 1
|
|
- `grep -E "ref=\"inputEl\"" frontend/src/components/documents/SearchBar.vue` returns 1
|
|
- `grep -E "const dropZoneRef\\s*=\\s*ref" frontend/src/components/storage/StorageBrowser.vue` returns 1
|
|
- `grep -E "const searchBarRef\\s*=\\s*ref" frontend/src/components/storage/StorageBrowser.vue` returns 1
|
|
- `grep -E "triggerUpload:\\s*\\(\\)\\s*=>\\s*dropZoneRef\\.value" frontend/src/components/storage/StorageBrowser.vue` returns 1
|
|
- `grep -E "focusSearch:\\s*\\(\\)\\s*=>\\s*searchBarRef\\.value" frontend/src/components/storage/StorageBrowser.vue` returns 1
|
|
- `grep -E "clearSearch:\\s*\\(\\)\\s*=>\\s*emit\\('search-change',\\s*''\\)" frontend/src/components/storage/StorageBrowser.vue` returns 1
|
|
- `grep -E "defineExpose" frontend/src/views/FileManagerView.vue` returns 1
|
|
- `grep -E "browserRef\\.value\\?\\.focusSearch" frontend/src/views/FileManagerView.vue` returns 1
|
|
- `grep -E "browserRef\\.value\\?\\.triggerUpload" frontend/src/views/FileManagerView.vue` returns 1
|
|
- `grep -E "browserRef\\.value\\?\\.startNewFolder" frontend/src/views/FileManagerView.vue` returns 1
|
|
- `grep -E "browserRef\\.value\\?\\.clearSearch" frontend/src/views/FileManagerView.vue` returns 1
|
|
- Existing FileManagerView + StorageBrowser test suites still pass
|
|
</acceptance_criteria>
|
|
<done>Ref chain fully plumbed from DropZone/SearchBar up to FileManagerView.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Add global keydown handler to App.vue + routeViewRef</name>
|
|
<files>frontend/src/App.vue</files>
|
|
<read_first>
|
|
- frontend/src/App.vue (current state - uses <script setup>)
|
|
- frontend/src/__tests__/keyboard.test.js (failing tests)
|
|
- frontend/src/components/documents/DocumentPreviewModal.vue (Pitfall 4 reference - has its own Escape handler)
|
|
- .planning/phases/10-ux-interaction/10-PATTERNS.md (App.vue keydown handler section)
|
|
</read_first>
|
|
<action>
|
|
Edit `frontend/src/App.vue`:
|
|
|
|
Template change: replace `<router-view />` with `<router-view ref="routeViewRef" />`.
|
|
|
|
Script changes (inside the existing `<script setup>`):
|
|
1. Extend the import line `import { onMounted } from 'vue'` to also import `ref` and `onUnmounted`: `import { ref, onMounted, onUnmounted } from 'vue'`.
|
|
2. Add `const routeViewRef = ref(null)`.
|
|
3. Add the keydown handler:
|
|
```js
|
|
function onKeydown(e) {
|
|
const tag = document.activeElement?.tagName
|
|
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return
|
|
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
e.preventDefault()
|
|
routeViewRef.value?.focusSearch?.()
|
|
}
|
|
if (e.key === 'Escape') {
|
|
routeViewRef.value?.clearSearch?.()
|
|
}
|
|
if (e.key === 'u' || e.key === 'U') {
|
|
routeViewRef.value?.triggerUpload?.()
|
|
}
|
|
if (e.key === 'n' || e.key === 'N') {
|
|
routeViewRef.value?.startNewFolder?.()
|
|
}
|
|
}
|
|
|
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
|
```
|
|
|
|
Keep the existing `topicsStore.fetchTopics()` call inside `onMounted` intact - either chain it into the same onMounted callback or use two separate onMounted calls.
|
|
|
|
No comments. Do not touch any other part of App.vue.
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npm run test -- --run keyboard</automated>
|
|
Expected: 9 tests GREEN.
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -E "ref=\"routeViewRef\"" frontend/src/App.vue` returns 1
|
|
- `grep -E "const routeViewRef = ref\\(null\\)" frontend/src/App.vue` returns 1
|
|
- `grep -E "function onKeydown" frontend/src/App.vue` returns 1
|
|
- `grep -E "document\\.activeElement\\?\\.tagName" frontend/src/App.vue` returns 1
|
|
- `grep -E "isContentEditable" frontend/src/App.vue` returns 1
|
|
- `grep -E "e\\.preventDefault\\(\\)" frontend/src/App.vue` returns 1 (inside the / branch)
|
|
- `grep -E "addEventListener\\('keydown'" frontend/src/App.vue` returns 1
|
|
- `grep -E "removeEventListener\\('keydown'" frontend/src/App.vue` returns 1
|
|
- `cd frontend && npm run test -- --run keyboard` exits 0 with 9 passing tests
|
|
- `cd frontend && npm run test -- --run` (full) exits 0 with no regression
|
|
</acceptance_criteria>
|
|
<done>App.vue global keydown handler live; 9 keyboard tests GREEN; full suite still green.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `cd frontend && npm run test -- --run keyboard` exits 0 with 9 tests passing
|
|
- `cd frontend && npm run test -- --run FileManagerView` exits 0
|
|
- `cd frontend && npm run test -- --run StorageBrowser` exits 0 (regression)
|
|
- `cd frontend && npm run test -- --run` (full suite) exits 0
|
|
- App.vue contains routeViewRef + onKeydown + document.addEventListener('keydown', ...) + cleanup
|
|
- All four shortcut branches present (`/`, Escape, U, N)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
With a fresh browser tab on `/`, pressing `/` jumps focus into the search bar. Typing in the search bar and pressing Escape clears it. Pressing `U` opens the OS file picker. Pressing `N` opens the inline new-folder input. None of these fire while typing into a focused input. On admin and settings routes, all four keys silently no-op because those views do not expose the corresponding methods.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/10-ux-interaction/10-09-SUMMARY.md` when done.
|
|
</output>
|