chore: merge executor worktree (10-10 OS drag overlay)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
---
|
||||
phase: 10-ux-interaction
|
||||
plan: "10"
|
||||
subsystem: frontend/ux
|
||||
tags: [os-drag, overlay, depth-counter, teleport, vitest, tdd, ux-09, wave-3]
|
||||
dependency_graph:
|
||||
requires: [10-04, 10-05, 10-06, 10-09]
|
||||
provides: [UX-09-os-drag-overlay, OsDragOverlay-component, App.vue-osDrop-wiring, FileManagerView-handleOsDrop]
|
||||
affects: [App.vue, FileManagerView.vue, OsDragOverlay.vue, OsDragOverlay.test.js]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [depth-counter-drag-detection, teleport-to-body, window-event-listeners, options-api-component, tdd-red-green]
|
||||
key_files:
|
||||
created:
|
||||
- frontend/src/components/layout/OsDragOverlay.vue
|
||||
modified:
|
||||
- frontend/src/components/layout/__tests__/OsDragOverlay.test.js
|
||||
- frontend/src/App.vue
|
||||
- frontend/src/views/FileManagerView.vue
|
||||
decisions:
|
||||
- "Depth-counter pattern (dragDepth++) used instead of boolean flag to prevent flicker when cursor crosses child element boundaries"
|
||||
- "dataTransfer.types.includes('Files') guard ensures in-app drags (file rows, folder rows) do not trigger the overlay"
|
||||
- "OsDragOverlay placed after ToastContainer in App.vue template, maintaining z-[9998] < z-[9999] z-order invariant"
|
||||
- "handleOsDrop delegates to existing onFilesSelected rather than duplicating upload logic — quota, progress, toast wiring all reused"
|
||||
- "node_modules symlinked/installed in worktree frontend to make vitest available without polluting main checkout"
|
||||
metrics:
|
||||
duration_minutes: 6
|
||||
completed_date: "2026-06-15T18:46:33Z"
|
||||
tasks_completed: 3
|
||||
tasks_total: 3
|
||||
files_created: 1
|
||||
files_modified: 3
|
||||
---
|
||||
|
||||
# Phase 10 Plan 10: OS File Drag Overlay Summary
|
||||
|
||||
**One-liner:** Full-screen OS drag overlay (OsDragOverlay.vue) wired end-to-end via depth-counter pattern: window dragenter/drop events in App.vue route through routeViewRef to FileManagerView.handleOsDrop which calls the existing upload flow.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Promote OsDragOverlay stubs to 8 RED failing tests | 71f55b8 | `layout/__tests__/OsDragOverlay.test.js` |
|
||||
| 2 | Implement OsDragOverlay.vue (GREEN) | 20eceb8 | `layout/OsDragOverlay.vue` |
|
||||
| 3 | Mount in App.vue; expose handleOsDrop in FileManagerView | 69bf40a | `App.vue`, `FileManagerView.vue` |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: RED Tests
|
||||
|
||||
Replaced 7 `it.todo` stubs in `OsDragOverlay.test.js` with 8 real tests. The plan specified 8 tests while the stub file had 7 entries; the extra test covers "nested dragenter+dragleave" (depth-2 scenario, critical for the depth-counter correctness). Tests use synthetic DragEvent / Event dispatched on `window` with `Object.defineProperty` to attach fake `dataTransfer`. All 8 were RED before Task 2.
|
||||
|
||||
### Task 2: OsDragOverlay.vue
|
||||
|
||||
Options API component implementing UX-09:
|
||||
|
||||
- `data`: `dragDepth: 0`, `showOverlay: false`
|
||||
- `onDragEnter`: ignores events where `e.dataTransfer?.types.includes('Files')` is false (in-app drags); increments `dragDepth` and sets `showOverlay = true`
|
||||
- `onDragLeave`: decrements via `Math.max(0, dragDepth - 1)`; hides when depth reaches 0
|
||||
- `onDragOver`: calls `e.preventDefault()` (required for drop to fire)
|
||||
- `onDrop`: resets depth=0, hides overlay, emits `files-dropped` with `Array.from(e.dataTransfer.files)`
|
||||
- `mounted()`/`beforeUnmount()`: register/remove 4 window listeners
|
||||
- Template: `<Teleport to="body">` + `<Transition name="fade">` + overlay div with `z-[9998]` and `data-test="os-drag-overlay"`
|
||||
- Scoped CSS: `.fade-enter-active/.fade-leave-active` with `transition: opacity 0.15s ease`
|
||||
|
||||
### Task 3: Wiring
|
||||
|
||||
**App.vue:**
|
||||
- Added `import OsDragOverlay from './components/layout/OsDragOverlay.vue'`
|
||||
- Added `<OsDragOverlay @files-dropped="onOsFilesDropped" />` immediately after `<ToastContainer />` (line 10 vs line 9)
|
||||
- Added `function onOsFilesDropped(files) { routeViewRef.value?.handleOsDrop?.(files) }` — double optional chain: no-ops on non-file-manager routes where `handleOsDrop` is not exposed
|
||||
|
||||
**FileManagerView.vue:**
|
||||
- Extended `defineExpose` block with `handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true })`
|
||||
- No duplicate upload logic — existing `onFilesSelected` function handles per-file UploadProgress items, quota error detection (e.status 413), and the summary toast
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `vitest run OsDragOverlay` — 8 tests | PASS (all GREEN) |
|
||||
| `vitest run keyboard` — 9 tests | PASS (no regression) |
|
||||
| `vitest run FileManagerView` — 20 tests | PASS (no regression) |
|
||||
| Full suite — 198 tests, 13 todo | PASS (0 failures) |
|
||||
| OsDragOverlay placed AFTER ToastContainer | PASS (line 10 vs line 9) |
|
||||
| OsDragOverlay z-[9998] < ToastContainer z-[9999] | PASS |
|
||||
| FileManagerView.handleOsDrop reuses onFilesSelected | PASS |
|
||||
| `routeViewRef.value?.handleOsDrop?.(files)` in App.vue | PASS |
|
||||
| No duplicate upload logic | PASS |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] node_modules not available in git worktree**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The worktree has no `node_modules` directory; `npm run test` (using the global vitest) and the main repo's vitest binary both fail to resolve imports when the test root points at the worktree
|
||||
- **Fix:** Ran `npm install` in the worktree's `frontend/` directory to create a local `node_modules` matching the `package.json`. Tests then run correctly with `worktree/frontend/node_modules/.bin/vitest`
|
||||
- **Files modified:** none (runtime setup only)
|
||||
- **Commit:** none (npm install not committed — `node_modules` is gitignored)
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. All files created and modified in this plan have complete implementations with no placeholder values, TODO markers, or hardcoded empty data flowing to the UI.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. OsDragOverlay handles OS file drop at the browser level and delegates to the existing `onFilesSelected` → `docsStore.upload` path. No new network endpoints, auth paths, or schema changes are introduced. The existing upload path has quota enforcement, JWT auth headers, and error handling already in place.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
- RED gate: commit `71f55b8` — 8 failing tests (`test(10-10): promote OsDragOverlay stubs...`)
|
||||
- GREEN gate: commit `20eceb8` — implementation passes 8 tests (`feat(10-10): implement OsDragOverlay.vue...`)
|
||||
- REFACTOR gate: not required (no cleanup needed after GREEN)
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `frontend/src/components/layout/OsDragOverlay.vue` — exists, contains `name: 'OsDragOverlay'`, `emits: ['files-dropped']`, `dragDepth: 0`, `dataTransfer?.types.includes('Files')`, 4 window listeners, `<Teleport to="body">`, `z-[9998]`, no `<script setup>`
|
||||
- `frontend/src/App.vue` — contains `import OsDragOverlay`, `<OsDragOverlay @files-dropped="onOsFilesDropped" />` after `<ToastContainer />`, `onOsFilesDropped` handler, `routeViewRef.value?.handleOsDrop?.(files)`
|
||||
- `frontend/src/views/FileManagerView.vue` — `handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true })` in defineExpose
|
||||
- Commit 71f55b8 — confirmed in git log
|
||||
- Commit 20eceb8 — confirmed in git log
|
||||
- Commit 69bf40a — confirmed in git log
|
||||
- No unexpected file deletions in any commit
|
||||
@@ -7,6 +7,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
<OsDragOverlay @files-dropped="onOsFilesDropped" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -15,12 +16,17 @@ import { useRoute } from 'vue-router'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AuthLayout from './layouts/AuthLayout.vue'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
import OsDragOverlay from './components/layout/OsDragOverlay.vue'
|
||||
import { useTopicsStore } from './stores/topics.js'
|
||||
|
||||
const route = useRoute()
|
||||
const topicsStore = useTopicsStore()
|
||||
const routeViewRef = ref(null)
|
||||
|
||||
function onOsFilesDropped(files) {
|
||||
routeViewRef.value?.handleOsDrop?.(files)
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
const tag = document.activeElement?.tagName
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag) || document.activeElement?.isContentEditable) return
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showOverlay"
|
||||
class="fixed inset-0 z-[9998] bg-indigo-900/40 flex items-center justify-center pointer-events-none"
|
||||
data-test="os-drag-overlay"
|
||||
>
|
||||
<div class="bg-white rounded-2xl px-10 py-8 text-center shadow-xl pointer-events-none">
|
||||
<AppIcon name="upload" class="w-10 h-10 text-indigo-400 mx-auto mb-3" />
|
||||
<p class="text-base font-semibold text-gray-800">Drop files to upload</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppIcon from '../ui/AppIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'OsDragOverlay',
|
||||
components: { AppIcon },
|
||||
emits: ['files-dropped'],
|
||||
data() {
|
||||
return {
|
||||
dragDepth: 0,
|
||||
showOverlay: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDragEnter(e) {
|
||||
if (!e.dataTransfer?.types.includes('Files')) return
|
||||
this.dragDepth++
|
||||
this.showOverlay = true
|
||||
},
|
||||
onDragLeave() {
|
||||
this.dragDepth = Math.max(0, this.dragDepth - 1)
|
||||
if (this.dragDepth === 0) this.showOverlay = false
|
||||
},
|
||||
onDragOver(e) {
|
||||
e.preventDefault()
|
||||
},
|
||||
onDrop(e) {
|
||||
e.preventDefault()
|
||||
this.dragDepth = 0
|
||||
this.showOverlay = false
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length) this.$emit('files-dropped', files)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('dragenter', this.onDragEnter)
|
||||
window.addEventListener('dragleave', this.onDragLeave)
|
||||
window.addEventListener('dragover', this.onDragOver)
|
||||
window.addEventListener('drop', this.onDrop)
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('dragenter', this.onDragEnter)
|
||||
window.removeEventListener('dragleave', this.onDragLeave)
|
||||
window.removeEventListener('dragover', this.onDragOver)
|
||||
window.removeEventListener('drop', this.onDrop)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,158 @@
|
||||
import { describe, it } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OsDragOverlay from '../OsDragOverlay.vue'
|
||||
|
||||
function makeDragEvent(type, types = ['Files'], files = []) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
value: { types, files, preventDefault: vi.fn() },
|
||||
writable: false,
|
||||
})
|
||||
event.preventDefault = vi.fn()
|
||||
return event
|
||||
}
|
||||
|
||||
describe('UX-09: OsDragOverlay shows when OS files are dragged over the window', () => {
|
||||
it.todo('overlay hidden by default (dragDepth=0)')
|
||||
it.todo('dragenter with dataTransfer.types including "Files" shows overlay')
|
||||
it.todo('dragenter without "Files" type is ignored (in-app element drag)')
|
||||
it.todo('dragleave decrements depth; overlay hides when depth reaches 0')
|
||||
it.todo('drop event emits files-dropped with the file list')
|
||||
it.todo('drop resets dragDepth to 0 and hides overlay')
|
||||
it.todo('overlay is teleported to body with z-[9998] (below toast z-[9999])')
|
||||
it('overlay hidden by default (dragDepth=0)', () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
expect(w.vm.showOverlay).toBe(false)
|
||||
expect(w.vm.dragDepth).toBe(0)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('dragenter with dataTransfer.types including "Files" shows overlay (dragDepth=1)', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
const event = makeDragEvent('dragenter', ['Files'])
|
||||
window.dispatchEvent(event)
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.showOverlay).toBe(true)
|
||||
expect(w.vm.dragDepth).toBe(1)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('dragenter without "Files" type is ignored (in-app element drag)', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
const event = makeDragEvent('dragenter', ['text/plain'])
|
||||
window.dispatchEvent(event)
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.showOverlay).toBe(false)
|
||||
expect(w.vm.dragDepth).toBe(0)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('dragleave decrements depth; overlay hides when depth reaches 0', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(1)
|
||||
expect(w.vm.showOverlay).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('dragleave'))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(0)
|
||||
expect(w.vm.showOverlay).toBe(false)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('nested dragenter+dragleave maintains overlay until depth=0', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(2)
|
||||
expect(w.vm.showOverlay).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('dragleave'))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(1)
|
||||
expect(w.vm.showOverlay).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('dragleave'))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(0)
|
||||
expect(w.vm.showOverlay).toBe(false)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('drop event emits files-dropped with the file list', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
await w.vm.$nextTick()
|
||||
|
||||
const file = new File(['x'], 'a.txt', { type: 'text/plain' })
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: [file] },
|
||||
writable: false,
|
||||
})
|
||||
dropEvent.preventDefault = vi.fn()
|
||||
window.dispatchEvent(dropEvent)
|
||||
await w.vm.$nextTick()
|
||||
|
||||
const emitted = w.emitted('files-dropped')
|
||||
expect(emitted).toBeTruthy()
|
||||
expect(emitted[0][0]).toHaveLength(1)
|
||||
expect(emitted[0][0][0]).toBeInstanceOf(File)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('drop resets dragDepth to 0 and hides overlay', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
global: { stubs: { Teleport: true, AppIcon: true } },
|
||||
})
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(3)
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: [] },
|
||||
writable: false,
|
||||
})
|
||||
dropEvent.preventDefault = vi.fn()
|
||||
window.dispatchEvent(dropEvent)
|
||||
await w.vm.$nextTick()
|
||||
|
||||
expect(w.vm.dragDepth).toBe(0)
|
||||
expect(w.vm.showOverlay).toBe(false)
|
||||
|
||||
window.dispatchEvent(new Event('dragleave'))
|
||||
await w.vm.$nextTick()
|
||||
expect(w.vm.dragDepth).toBe(0)
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('overlay is teleported to body with z-[9998] (below toast z-[9999])', async () => {
|
||||
const w = mount(OsDragOverlay, {
|
||||
attachTo: document.body,
|
||||
})
|
||||
window.dispatchEvent(makeDragEvent('dragenter', ['Files']))
|
||||
await w.vm.$nextTick()
|
||||
|
||||
const overlay = document.querySelector('[data-test="os-drag-overlay"]')
|
||||
expect(overlay).toBeTruthy()
|
||||
expect(overlay.classList.contains('z-[9998]')).toBe(true)
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,5 +186,6 @@ defineExpose({
|
||||
triggerUpload: () => browserRef.value?.triggerUpload?.(),
|
||||
startNewFolder: () => browserRef.value?.startNewFolder?.(),
|
||||
clearSearch: () => browserRef.value?.clearSearch?.(),
|
||||
handleOsDrop: (files) => onFilesSelected({ files, autoClassify: true }),
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user