fix(10): close UX-06/UX-10/UX-13 verification gaps — Escape modal close, rename/revoke toasts, StorageBrowser picker tests
This commit is contained in:
@@ -49,6 +49,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import AppIcon from '../ui/AppIcon.vue'
|
import AppIcon from '../ui/AppIcon.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -68,6 +69,12 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['confirm', 'cancel'])
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Escape') handleCancel()
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
if (props.onConfirm) props.onConfirm()
|
if (props.onConfirm) props.onConfirm()
|
||||||
|
|||||||
@@ -113,9 +113,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import AppIcon from '../ui/AppIcon.vue'
|
import AppIcon from '../ui/AppIcon.vue'
|
||||||
import { useDocumentsStore } from '../../stores/documents.js'
|
import { useDocumentsStore } from '../../stores/documents.js'
|
||||||
|
import { useToastStore } from '../../stores/toast.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doc: {
|
doc: {
|
||||||
@@ -127,6 +128,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['close', 'unshared'])
|
const emit = defineEmits(['close', 'unshared'])
|
||||||
|
|
||||||
const docsStore = useDocumentsStore()
|
const docsStore = useDocumentsStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
const handle = ref('')
|
const handle = ref('')
|
||||||
const permission = ref('view')
|
const permission = ref('view')
|
||||||
@@ -137,7 +139,12 @@ const shares = ref([])
|
|||||||
const loadingShares = ref(false)
|
const loadingShares = ref(false)
|
||||||
const updatingPermission = ref(new Set())
|
const updatingPermission = ref(new Set())
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
loadingShares.value = true
|
loadingShares.value = true
|
||||||
try {
|
try {
|
||||||
const data = await docsStore.listShares(props.doc.id)
|
const data = await docsStore.listShares(props.doc.id)
|
||||||
@@ -149,6 +156,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
async function submitShare() {
|
async function submitShare() {
|
||||||
const trimmed = handle.value.trim()
|
const trimmed = handle.value.trim()
|
||||||
if (!trimmed || submitting.value) return
|
if (!trimmed || submitting.value) return
|
||||||
@@ -201,6 +212,7 @@ async function handleRevoke(shareId) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await docsStore.revokeShare(shareId)
|
await docsStore.revokeShare(shareId)
|
||||||
|
toast.show('Share revoked', 'success')
|
||||||
if (shares.value.length === 0) emit('unshared', props.doc.id)
|
if (shares.value.length === 0) emit('unshared', props.doc.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Re-add on failure
|
// Re-add on failure
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import StorageBrowser from '../StorageBrowser.vue'
|
import StorageBrowser from '../StorageBrowser.vue'
|
||||||
|
|
||||||
const globalStubs = {
|
const globalStubs = {
|
||||||
@@ -55,8 +56,82 @@ describe('UX-02: StorageBrowser shows skeleton rows during loading', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const SAMPLE_FILE = {
|
||||||
|
id: 'file-1',
|
||||||
|
original_name: 'doc.pdf',
|
||||||
|
size_bytes: 2048,
|
||||||
|
created_at: '2025-01-01T00:00:00Z',
|
||||||
|
status: 'ready',
|
||||||
|
topics: [],
|
||||||
|
is_shared: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_FOLDER = { id: 'folder-1', name: 'Archive', doc_count: 0 }
|
||||||
|
|
||||||
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {
|
describe('UX-13: StorageBrowser folder picker uses Teleport + getBoundingClientRect', () => {
|
||||||
it.todo('folder picker dropdown is teleported to body (escapes overflow container)')
|
beforeEach(() => {
|
||||||
it.todo('dropdown position reflects getBoundingClientRect of the trigger button')
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
it.todo('window scroll while open recalculates position')
|
top: 100, bottom: 140, left: 50, right: 250,
|
||||||
|
width: 200, height: 40, x: 50, y: 100,
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: 800, writable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('folder picker dropdown is teleported to body (escapes overflow container)', async () => {
|
||||||
|
const wrapper = mount(StorageBrowser, {
|
||||||
|
props: { mode: 'local', files: [SAMPLE_FILE], folders: [], rootFolders: [SAMPLE_FOLDER] },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
const moveBtn = wrapper.find('button[title="Move to folder"]')
|
||||||
|
expect(moveBtn.exists()).toBe(true)
|
||||||
|
await moveBtn.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
const picker = document.body.querySelector('[data-test="folder-picker"]')
|
||||||
|
expect(picker).not.toBeNull()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dropdown position reflects getBoundingClientRect of the trigger button', async () => {
|
||||||
|
const wrapper = mount(StorageBrowser, {
|
||||||
|
props: { mode: 'local', files: [SAMPLE_FILE], folders: [], rootFolders: [SAMPLE_FOLDER] },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
const moveBtn = wrapper.find('button[title="Move to folder"]')
|
||||||
|
await moveBtn.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
const picker = document.body.querySelector('[data-test="folder-picker"]')
|
||||||
|
expect(picker).not.toBeNull()
|
||||||
|
const style = picker.style
|
||||||
|
expect(style.top || style.bottom).toBeTruthy()
|
||||||
|
expect(style.left).toBeTruthy()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('window scroll while open recalculates position', async () => {
|
||||||
|
const wrapper = mount(StorageBrowser, {
|
||||||
|
props: { mode: 'local', files: [SAMPLE_FILE], folders: [], rootFolders: [SAMPLE_FOLDER] },
|
||||||
|
global: { stubs: globalStubs },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
const moveBtn = wrapper.find('button[title="Move to folder"]')
|
||||||
|
await moveBtn.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
const picker = document.body.querySelector('[data-test="folder-picker"]')
|
||||||
|
const initialTop = picker.style.top || picker.style.bottom
|
||||||
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
top: 300, bottom: 340, left: 50, right: 250,
|
||||||
|
width: 200, height: 40, x: 50, y: 300,
|
||||||
|
})
|
||||||
|
window.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
const updatedTop = picker.style.top || picker.style.bottom
|
||||||
|
expect(updatedTop).not.toBe(initialTop)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -140,7 +140,13 @@ async function handleFolderCreate({ name, onError, onSuccess }) {
|
|||||||
|
|
||||||
async function handleFolderRename({ id, name }) {
|
async function handleFolderRename({ id, name }) {
|
||||||
if (!name) return
|
if (!name) return
|
||||||
try { await foldersStore.renameFolder(id, name) } catch {}
|
const toast = useToastStore()
|
||||||
|
try {
|
||||||
|
await foldersStore.renameFolder(id, name)
|
||||||
|
toast.show('Folder renamed', 'success')
|
||||||
|
} catch (e) {
|
||||||
|
toast.show('Rename failed: ' + (e.message || 'unknown error'), 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderToDelete = ref(null)
|
const folderToDelete = ref(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user