diff --git a/.planning/phases/10-ux-interaction/10-09-SUMMARY.md b/.planning/phases/10-ux-interaction/10-09-SUMMARY.md new file mode 100644 index 0000000..17df273 --- /dev/null +++ b/.planning/phases/10-ux-interaction/10-09-SUMMARY.md @@ -0,0 +1,131 @@ +--- +phase: 10-ux-interaction +plan: "09" +subsystem: frontend/ux +tags: [keyboard-shortcuts, ref-chain, defineExpose, vue3, ux, vitest, tdd] +dependency_graph: + requires: [10-04, 10-05, 10-06] + provides: [keyboard-shortcuts-UX-05-06-07-08, App.vue-routeViewRef, FileManagerView-defineExpose, StorageBrowser-defineExpose-extended] + affects: [App.vue, FileManagerView.vue, StorageBrowser.vue, DropZone.vue, SearchBar.vue] +tech_stack: + added: [] + patterns: [defineExpose-ref-chain, optional-chaining-delegation, global-keydown-handler, TDD-red-green] +key_files: + created: [] + modified: + - frontend/src/__tests__/keyboard.test.js + - 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 +decisions: + - "Double optional chaining (?.) used in StorageBrowser.triggerUpload and focusSearch to guard against stub components lacking the method in tests" + - "Testing strategy: mount FileManagerView directly and assert defineExpose methods exist/do not throw — avoids complexity of mounting App.vue with router+stores" + - "onMounted callback in App.vue chains both topicsStore.fetchTopics() and document.addEventListener('keydown', onKeydown) — single mount lifecycle call" +metrics: + duration: "6m" + completed: "2026-06-15T20:37:00Z" + tasks_completed: 3 + files_changed: 6 +--- + +# Phase 10 Plan 09: Global Keyboard Shortcuts Summary + +**One-liner:** Four global keyboard shortcuts (/, Escape, U, N) wired via App.vue routeViewRef through a defineExpose ref chain: FileManagerView → StorageBrowser → DropZone/SearchBar. + +## Tasks Completed + +| Task | Name | Commit | Files | +|------|------|--------|-------| +| 1 | Promote keyboard.test.js stubs to 9 RED failing tests | 089af90 | `src/__tests__/keyboard.test.js` | +| 2 | Plumb ref chain — DropZone + SearchBar + StorageBrowser + FileManagerView | aaa0532 | `DropZone.vue`, `SearchBar.vue`, `StorageBrowser.vue`, `FileManagerView.vue` | +| 3 | Add global keydown handler to App.vue + routeViewRef | d7bda3c | `App.vue`, `StorageBrowser.vue` | + +## What Was Built + +**keyboard.test.js:** Replaced 11 `it.todo` stubs with 9 real `it()` tests grouped across 4 describe blocks (UX-05..UX-08). Tests mount `FileManagerView` with mocked child components and assert the exposed methods exist and do not throw. All 9 were RED before Task 2 and GREEN after Task 3. + +**DropZone.vue:** Added `defineExpose({ triggerInput })` after the existing `triggerInput` function definition. No other changes. + +**SearchBar.vue:** Added `import { ref } from 'vue'`, declared `const inputEl = ref(null)`, bound `ref="inputEl"` to the `` element, and added `defineExpose({ focus() { inputEl.value?.focus() } })`. + +**StorageBrowser.vue:** +- Added `const dropZoneRef = ref(null)` and `const searchBarRef = ref(null)` +- Added `ref="dropZoneRef"` on `` and `ref="searchBarRef"` on `` +- Replaced `defineExpose({ startNewFolder })` with: + ```js + defineExpose({ + startNewFolder, + triggerUpload: () => dropZoneRef.value?.triggerInput?.(), + focusSearch: () => searchBarRef.value?.focus?.(), + clearSearch: () => emit('search-change', ''), + }) + ``` + +**FileManagerView.vue:** Added `defineExpose` block delegating all four methods to `browserRef` via optional chaining: +```js +defineExpose({ + focusSearch: () => browserRef.value?.focusSearch?.(), + triggerUpload: () => browserRef.value?.triggerUpload?.(), + startNewFolder: () => browserRef.value?.startNewFolder?.(), + clearSearch: () => browserRef.value?.clearSearch?.(), +}) +``` + +**App.vue:** +- Added `ref` and `onUnmounted` to the imports +- Added `const routeViewRef = ref(null)` +- Added `ref="routeViewRef"` on `` +- Added `onKeydown` handler with activeElement guard and four shortcut branches +- Chained `document.addEventListener('keydown', onKeydown)` into the existing `onMounted` +- Added `onUnmounted(() => document.removeEventListener('keydown', onKeydown))` + +## Verification Results + +| Check | Result | +|-------|--------| +| `vitest run keyboard` — 9 tests | PASS (all GREEN) | +| `vitest run FileManagerView` — 20 tests | PASS (no regression) | +| `vitest run StorageBrowser` — 4 tests (9 todo) | PASS (no regression) | +| Full suite: 190 tests, 0 failures, 20 todo | PASS | +| `ref="routeViewRef"` in App.vue template | PASS | +| `const routeViewRef = ref(null)` in App.vue | PASS | +| `function onKeydown` in App.vue | PASS | +| `document.activeElement?.tagName` guard | PASS | +| `isContentEditable` guard | PASS | +| `e.preventDefault()` in / branch | PASS | +| `addEventListener('keydown')` + `removeEventListener` | PASS | +| All 14 defineExpose acceptance criteria greps | PASS | + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Added double optional chaining on StorageBrowser.triggerUpload and focusSearch** +- **Found during:** Task 3 +- **Issue:** `dropZoneRef.value?.triggerInput()` throws `TypeError: triggerInput is not a function` in tests when the DropZone stub doesn't expose `triggerInput`. The `?.` guard only prevents calling on null/undefined ref, but `triggerInput` being `undefined` on the stub still causes a throw when invoked with `()`. +- **Fix:** Changed to `dropZoneRef.value?.triggerInput?.()` and `searchBarRef.value?.focus?.()` — double optional chaining silently no-ops when the method is absent. +- **Files modified:** `StorageBrowser.vue` +- **Commit:** d7bda3c + +## Known Stubs + +None. + +## Threat Flags + +None. This plan adds only client-side keyboard event handling and component `defineExpose` plumbing. No new network endpoints, auth paths, file access, or schema changes. + +## Self-Check: PASSED + +- `frontend/src/__tests__/keyboard.test.js` — exists, 9 real tests +- `frontend/src/App.vue` — contains routeViewRef + onKeydown + addEventListener/removeEventListener +- `frontend/src/views/FileManagerView.vue` — contains defineExpose with all 4 methods +- `frontend/src/components/storage/StorageBrowser.vue` — contains dropZoneRef, searchBarRef, expanded defineExpose +- `frontend/src/components/upload/DropZone.vue` — contains defineExpose({ triggerInput }) +- `frontend/src/components/documents/SearchBar.vue` — contains inputEl ref + defineExpose({ focus }) +- Commit 089af90 — confirmed in git log (RED tests) +- Commit aaa0532 — confirmed in git log (ref chain) +- Commit d7bda3c — confirmed in git log (App.vue handler) +- No unexpected file deletions diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6309859..9f9658f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,14 +3,14 @@
- +
diff --git a/frontend/src/__tests__/keyboard.test.js b/frontend/src/__tests__/keyboard.test.js index 9dbde81..a67df28 100644 --- a/frontend/src/__tests__/keyboard.test.js +++ b/frontend/src/__tests__/keyboard.test.js @@ -1,24 +1,138 @@ -import { describe, it } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import { createRouter, createMemoryHistory } from 'vue-router' +import FileManagerView from '../views/FileManagerView.vue' + +vi.mock('../api/client.js', () => ({ + listFolders: vi.fn().mockResolvedValue({ items: [] }), + listDocuments: vi.fn().mockResolvedValue({ items: [], total: 0 }), + getFolder: vi.fn().mockResolvedValue({ id: 'f1', name: 'Test', breadcrumb: [] }), + createFolder: vi.fn(), + renameFolder: vi.fn(), + deleteFolder: vi.fn(), + moveDocument: vi.fn(), + deleteDocument: vi.fn().mockResolvedValue(null), + getSharedWithMe: vi.fn().mockResolvedValue([]), + listTopics: vi.fn().mockResolvedValue([]), + getMyQuota: vi.fn().mockResolvedValue({ used_bytes: 0, limit_bytes: 104857600 }), +})) + +vi.mock('../stores/auth.js', () => ({ + useAuthStore: () => ({ + user: { email: 'test@example.com', role: 'user' }, + accessToken: 'fake-token', + fetchQuota: vi.fn().mockResolvedValue(null), + }), +})) + +vi.mock('../stores/topics.js', () => ({ + useTopicsStore: () => ({ + topics: [], + loading: false, + fetchTopics: vi.fn().mockResolvedValue(null), + }), +})) + +vi.mock('../components/ui/BreadcrumbBar.vue', () => ({ + default: { template: '