[verified] harden trash actions and mobile details
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
import { allowExpectedError, expect, test } from './fixtures'
|
||||
|
||||
async function csrf(request: APIRequestContext) {
|
||||
const state = await request.storageState()
|
||||
@@ -51,10 +51,33 @@ test('Trash uses compact deadline groups without exposing child-level actions',
|
||||
const row = page.locator('.task-row--trash').filter({ hasText: title })
|
||||
await expect(row).toHaveCount(1)
|
||||
await expect(row.getByRole('button', { name: '恢复' })).toBeVisible()
|
||||
await expect(row.getByRole('button', { name: '永久删除' })).toBeVisible()
|
||||
await expect(row.getByRole('button', { name: '打开任务操作' })).toBeVisible()
|
||||
expect(await row.locator('.task-check').count()).toBe(0)
|
||||
}
|
||||
await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除')
|
||||
|
||||
const overdueRow = page.locator('.task-row--trash').filter({ hasText: overdueTitle })
|
||||
await expect(overdueRow.locator('.task-tail')).toBeVisible()
|
||||
const actionTrigger = overdueRow.getByRole('button', { name: '打开任务操作' })
|
||||
await actionTrigger.focus()
|
||||
await actionTrigger.press('Enter')
|
||||
const menu = page.getByRole('menu', { name: '回收站任务操作' })
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused()
|
||||
await menu.getByRole('menuitem', { name: '永久删除' }).press('Tab')
|
||||
await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused()
|
||||
await menu.getByRole('menuitem', { name: '永久删除' }).press('Shift+Tab')
|
||||
await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused()
|
||||
await menu.getByRole('menuitem', { name: '永久删除' }).press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(actionTrigger).toBeFocused()
|
||||
await actionTrigger.click()
|
||||
await page.getByRole('menuitem', { name: '永久删除' }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toContainText(`输入任务名称“${overdueTitle}”确认`)
|
||||
await dialog.getByRole('button', { name: '永久删除' }).click()
|
||||
await expect(dialog.getByRole('alert')).toContainText('任务名称不匹配')
|
||||
await dialog.getByRole('button', { name: '取消' }).click()
|
||||
await expect(actionTrigger).toBeFocused()
|
||||
|
||||
const geometry = await page.evaluate(() => ({
|
||||
viewport: innerWidth,
|
||||
@@ -69,4 +92,12 @@ test('Trash uses compact deadline groups without exposing child-level actions',
|
||||
expect(control.width).toBeGreaterThanOrEqual(44)
|
||||
expect(control.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
|
||||
await actionTrigger.click()
|
||||
await page.getByRole('menuitem', { name: '永久删除' }).click()
|
||||
await dialog.getByLabel(`输入任务名称“${overdueTitle}”确认`).fill(overdueTitle)
|
||||
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
|
||||
await dialog.getByRole('button', { name: '永久删除' }).click()
|
||||
await expect(overdueRow).toHaveCount(0)
|
||||
await expect(page.getByRole('heading', { name: '回收站', exact: true })).toBeFocused()
|
||||
})
|
||||
|
||||
@@ -103,24 +103,25 @@ test('task rows use the body for detail and Trash keeps distinct actions', async
|
||||
await openSidebarView(page, '回收站')
|
||||
await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。')
|
||||
await expect(page.getByRole('heading', { name: '无截止日期', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除')
|
||||
const restoreRow = await taskRow(page, restoreTitle)
|
||||
const purgeRow = await taskRow(page, purgeTitle)
|
||||
for (const deletedRow of [restoreRow, purgeRow]) {
|
||||
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
|
||||
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
|
||||
await expect(deletedRow.getByRole('button', { name: '打开任务操作' })).toBeVisible()
|
||||
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role')
|
||||
await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex')
|
||||
expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0)
|
||||
}
|
||||
await restoreRow.getByRole('button', { name: '恢复' }).click()
|
||||
await expect(restoreRow).toHaveCount(0)
|
||||
await purgeRow.getByRole('button', { name: '永久删除' }).click()
|
||||
await purgeRow.getByRole('button', { name: '打开任务操作' }).click()
|
||||
await page.getByRole('menuitem', { name: '永久删除' }).click()
|
||||
const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` })
|
||||
await expect(purgeDialog).toBeVisible()
|
||||
// The UI can abort the completed 204 request while the confirmation overlay closes.
|
||||
allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`)
|
||||
await purgeDialog.getByRole('button', { name: '确认', exact: true }).click()
|
||||
await purgeDialog.getByLabel(`输入任务名称“${purgeTitle}”确认`).fill(purgeTitle)
|
||||
await purgeDialog.getByRole('button', { name: '永久删除', exact: true }).click()
|
||||
await expect(purgeRow).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0)
|
||||
|
||||
+41
-4
@@ -57,6 +57,10 @@ const tasks = ref<Task[]>([])
|
||||
const overdueTasks = ref<Task[]>([])
|
||||
const trash = ref<Task[]>([])
|
||||
const taskListElement = ref<HTMLElement | null>(null)
|
||||
const trashAction = ref<Task | null>(null)
|
||||
const trashMenu = ref<HTMLElement | null>(null)
|
||||
const trashPageTitle = ref<HTMLElement | null>(null)
|
||||
let trashActionTrigger: HTMLElement | null = null
|
||||
const NAVIGATION_STORAGE_KEY = 'dodo.navigation'
|
||||
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
|
||||
const activeList = ref(restoredNavigation.listId)
|
||||
@@ -1163,12 +1167,45 @@ async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, suc
|
||||
async function restoreTask(task: Task) {
|
||||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true)
|
||||
}
|
||||
function openTrashAction(task: Task, event?: Event) {
|
||||
trashActionTrigger = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||||
trashAction.value = task
|
||||
nextTick(() => trashMenu.value?.querySelector<HTMLElement>('[role=menuitem]')?.focus())
|
||||
}
|
||||
function focusTrashMenuItem() { trashMenu.value?.querySelector<HTMLElement>('[role=menuitem]')?.focus() }
|
||||
function focusTrashActionTrigger(trigger: HTMLElement | null) {
|
||||
nextTick(() => {
|
||||
if (trigger?.isConnected) trigger.focus()
|
||||
else trashPageTitle.value?.focus()
|
||||
})
|
||||
}
|
||||
function closeTrashAction(restoreFocus = true) {
|
||||
const trigger = trashActionTrigger
|
||||
trashAction.value = null
|
||||
trashActionTrigger = null
|
||||
if (restoreFocus) focusTrashActionTrigger(trigger)
|
||||
}
|
||||
async function requestPurgeTask() {
|
||||
const task = trashAction.value
|
||||
const trigger = trashActionTrigger
|
||||
closeTrashAction(false)
|
||||
if (task) await purgeTask(task)
|
||||
focusTrashActionTrigger(trigger)
|
||||
}
|
||||
async function purgeTask(task: Task) {
|
||||
const childCount = task.subtasks?.length ?? 0
|
||||
const impact = childCount
|
||||
? `此任务及其 ${childCount} 个子任务将被永久删除,不能撤销。`
|
||||
: '此任务将被永久删除,不能撤销。'
|
||||
if (!(await confirmAction(`永久删除“${task.title}”?`, impact, true))) return
|
||||
const entered = await appDialog.value?.show({
|
||||
title: `永久删除“${task.title}”?`,
|
||||
description: impact,
|
||||
label: `输入任务名称“${task.title}”确认`,
|
||||
confirmText: '永久删除',
|
||||
danger: true,
|
||||
validate: (value) => value.trim() === task.title ? null : '任务名称不匹配',
|
||||
})
|
||||
if (typeof entered !== 'string') return
|
||||
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false)
|
||||
}
|
||||
async function addSubtask() {
|
||||
@@ -1737,7 +1774,7 @@ onUnmounted(() => {
|
||||
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '›' : '⌄' }}</span></button>
|
||||
</template>
|
||||
<section v-if="activeView==='trash'" class="trash-page-context">
|
||||
<div><h1 class="trash-page-title">回收站</h1><p class="trash-page-summary">删除的任务保留在这里,可整组恢复或永久删除。</p></div>
|
||||
<div><h1 ref="trashPageTitle" class="trash-page-title" tabindex="-1">回收站</h1><p class="trash-page-summary">删除的任务保留在这里,可整组恢复或永久删除。</p></div>
|
||||
<span class="trash-page-count">共 {{totalTasks}} 项</span>
|
||||
</section>
|
||||
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||
@@ -1748,11 +1785,10 @@ onUnmounted(() => {
|
||||
<article v-for="node in group.nodes" :key="node.task.id" :data-task-id="node.task.id" class="task-row task-row--trash">
|
||||
<div class="task-main"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>含 {{node.subtasks.length}} 个子任务,整组处理</span></span></div>
|
||||
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span>
|
||||
<span class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost trash-more" aria-label="永久删除" :title="`永久删除${node.task.title}`" @click.stop="purgeTask(node.task)"><Ellipsis/></button></span>
|
||||
<span class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost trash-more" aria-label="打开任务操作" aria-haspopup="menu" :aria-expanded="trashAction?.id===node.task.id" :title="`${node.task.title}操作`" @click.stop="openTrashAction(node.task,$event)"><Ellipsis/></button></span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<p class="trash-safety-note">父任务与子任务始终作为一个整体处理,不支持子任务脱离父任务单独恢复或删除。</p>
|
||||
</section>
|
||||
<section v-else :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined" ref="taskListElement">
|
||||
<template v-for="node in taskTree" :key="node.task.id">
|
||||
@@ -1837,6 +1873,7 @@ onUnmounted(() => {
|
||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||
<Teleport to="body">
|
||||
<span v-if="trashAction" class="trash-action-mask" @click.self="closeTrashAction()"><span ref="trashMenu" class="trash-action-menu" role="menu" aria-label="回收站任务操作" @keydown.esc.stop="closeTrashAction()" @keydown.tab.prevent="focusTrashMenuItem"><span class="trash-action-title" :title="trashAction.title">{{trashAction.title}}</span><button role="menuitem" class="danger-text" @click="requestPurgeTask"><Trash2/>永久删除</button></span></span>
|
||||
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
|
||||
</Teleport>
|
||||
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
|
||||
|
||||
@@ -125,8 +125,8 @@ main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empt
|
||||
.trash-page-context{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;padding:7px 0 21px;border-bottom:1px solid #e8e0d5}
|
||||
.trash-page-title{margin:0;font-size:34px;line-height:1.1;font-weight:700;letter-spacing:-1.2px}.trash-page-summary{margin:8px 0 0;color:var(--muted);font-size:13px}.trash-page-count{min-height:44px;display:flex;align-items:center;color:var(--muted);font-size:12px}
|
||||
.trash-groups{display:grid}.trash-group{display:grid}.trash-group-heading{min-height:44px;padding-top:11px;display:flex;align-items:center;gap:7px;border-bottom:1px solid #e8e0d5}.trash-group-heading h2{margin:0;font-size:13px;font-weight:750}.trash-group-heading span{color:var(--muted);font-size:12px}
|
||||
.trash-list>.task-row,.trash-list>.task-row:has(>.task-tail .task-due--timed){height:62px;min-height:62px;max-height:62px}.trash-list .task-main{display:grid;align-content:center;gap:3px}.trash-list .task-main strong{font-size:14px;font-weight:620}.trash-list .task-main .meta{padding:0;font-size:11px}.trash-list .task-main .meta-item{display:inline-flex;align-items:center;gap:4px}.trash-list .task-main .meta-item svg{width:13px;height:13px}.trash-list .task-actions{display:flex;align-items:center;gap:0}.trash-list .restore{min-height:44px}.trash-list .trash-more{width:44px;height:44px;color:var(--danger)}.trash-list .trash-more svg{width:18px;height:18px}.trash-safety-note{margin:18px 0 0;padding:12px 14px;border:1px solid #ead7b4;border-radius:12px;background:#fff9ec;color:#755e3d;font-size:12px;line-height:1.55}
|
||||
@media(max-width:720px){main:has(>.trash-page-context){padding-left:29px;padding-right:29px;padding-bottom:calc(78px + var(--safe-area-bottom))}main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:100%}.trash-page-context{padding-top:2px;padding-bottom:17px}.trash-page-title{font-size:24px}.trash-page-summary{font-size:12px;line-height:1.5}.trash-group-heading{padding-top:9px}.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) auto}.trash-list .task-tail{display:none}.trash-list .restore{min-width:72px;padding-inline:12px}.trash-safety-note{margin-top:16px}}
|
||||
.trash-list>.task-row,.trash-list>.task-row:has(>.task-tail .task-due--timed){height:62px;min-height:62px;max-height:62px}.trash-list .task-main{display:grid;align-content:center;gap:3px}.trash-list .task-main strong{font-size:14px;font-weight:620}.trash-list .task-main .meta{padding:0;font-size:11px}.trash-list .task-main .meta-item{display:inline-flex;align-items:center;gap:4px}.trash-list .task-main .meta-item svg{width:13px;height:13px}.trash-list .task-actions{display:flex;align-items:center;gap:0}.trash-list .restore{min-height:44px}.trash-list .trash-more{width:44px;height:44px;color:var(--muted);opacity:1}.trash-list .trash-more svg{width:18px;height:18px}.trash-action-mask{position:fixed;z-index:90;inset:0;display:flex;align-items:flex-end;justify-content:center;background:rgba(45,38,31,.18)}.trash-action-menu{width:min(320px,calc(100vw - 24px));margin:12px;padding:8px;display:grid;gap:4px;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-panel);box-shadow:var(--highlight-inner),var(--shadow-raised)}.trash-action-title{min-width:0;padding:9px 10px 7px;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.trash-action-menu button{min-height:44px;width:100%;display:flex;align-items:center;gap:9px;padding:0 10px;border:0;border-radius:var(--radius-control);background:transparent;text-align:left}.trash-action-menu button:hover{background:#fff3ee}
|
||||
@media(max-width:720px){main:has(>.trash-page-context){padding-left:29px;padding-right:29px;padding-bottom:calc(78px + var(--safe-area-bottom))}main>.trash-page-context,main>.trash-groups,main:has(>.trash-page-context)>.empty,main:has(>.trash-page-context)>.pager{width:100%}.trash-page-context{padding-top:2px;padding-bottom:17px}.trash-page-title{font-size:24px}.trash-page-summary{font-size:12px;line-height:1.5}.trash-group-heading{padding-top:9px}.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) minmax(38px,auto) auto;gap:6px}.trash-list .task-tail{display:flex;max-width:64px;padding-left:0;font-size:11px}.trash-list .task-due--timed .task-due__absolute,.trash-list .task-due--timed .task-due__separator{display:none}.trash-list .restore{min-width:68px;padding-inline:10px}}
|
||||
@media(max-width:390px){main:has(>.trash-page-context){padding-left:17px;padding-right:17px}.trash-page-context{gap:10px}.trash-page-summary{max-width:230px}.trash-list .task-actions{margin-left:6px}.trash-list .restore{min-width:68px;padding-inline:10px}}
|
||||
|
||||
/* Approved task-list and full-Habits 01 parity. */
|
||||
|
||||
@@ -326,7 +326,7 @@ describe('approved UI detail direction', () => {
|
||||
const trashRows = app.slice(app.indexOf('class="trash-groups"'), ordinaryStart)
|
||||
expect(trashRows).toContain('class="task-actions"')
|
||||
expect(trashRows).toContain('restoreTask(node.task)')
|
||||
expect(trashRows).toContain('purgeTask(node.task)')
|
||||
expect(trashRows).toContain('openTrashAction(node.task,$event)')
|
||||
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
|
||||
expect(overdue).toContain('selectTaskUnlessSwiped(node.task)')
|
||||
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
||||
@@ -845,6 +845,31 @@ describe('task and habit row decoration', () => {
|
||||
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
|
||||
})
|
||||
|
||||
it('keeps Trash deadlines visible on mobile before the action area', () => {
|
||||
expect(css).not.toContain('.trash-list .task-tail{display:none}')
|
||||
expect(css).toContain('.trash-list>.task-row{height:68px;min-height:68px;max-height:68px;grid-template-columns:minmax(0,1fr) minmax(38px,auto) auto;gap:6px}')
|
||||
})
|
||||
|
||||
it('uses a real Trash action menu and typed permanent-delete confirmation', () => {
|
||||
expect(app).toContain('const trashAction = ref<Task | null>(null)')
|
||||
expect(app).toContain('const trashMenu = ref<HTMLElement | null>(null)')
|
||||
expect(app).toContain('const trashPageTitle = ref<HTMLElement | null>(null)')
|
||||
expect(app).toContain('aria-haspopup="menu"')
|
||||
expect(app).toContain('@click.stop="openTrashAction(node.task,$event)"')
|
||||
expect(app).toContain('ref="trashMenu" class="trash-action-menu" role="menu" aria-label="回收站任务操作"')
|
||||
expect(app).toContain('@keydown.esc.stop="closeTrashAction()"')
|
||||
expect(app).toContain('@keydown.tab.prevent="focusTrashMenuItem"')
|
||||
expect(app).toContain("nextTick(() => trashMenu.value?.querySelector<HTMLElement>('[role=menuitem]')?.focus())")
|
||||
expect(app).toContain('if (trigger?.isConnected) trigger.focus()')
|
||||
expect(app).toContain('await purgeTask(task)')
|
||||
expect(app).toContain('focusTrashActionTrigger(trigger)')
|
||||
expect(app).toContain('else trashPageTitle.value?.focus()')
|
||||
expect(app).toContain('@click="requestPurgeTask"')
|
||||
expect(app).toContain("label: `输入任务名称“${task.title}”确认`")
|
||||
expect(app).toContain("validate: (value) => value.trim() === task.title ? null : '任务名称不匹配'")
|
||||
expect(app).not.toContain('class="trash-safety-note"')
|
||||
})
|
||||
|
||||
it('reconciles only top-level Trash mutations and distinguishes refresh failure', () => {
|
||||
const loadBlock = app.slice(app.indexOf('async function loadTrash()'), app.indexOf('async function switchView'))
|
||||
const mutationBlock = app.slice(app.indexOf('async function mutateTrashTask'), app.indexOf('async function restoreTask'))
|
||||
|
||||
Reference in New Issue
Block a user