Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,52 @@ describe('PaperTriageRowEdit', () => {

// ── the editable path ──────────────────────────────────────────────────────

it('offers Cancel while the detail is loading and ignores a late response', async () => {
let resolveDetail!: (detail: CaptureItem) => void
mockCaptureStore.fetchDetail.mockImplementation(
() => new Promise<CaptureItem>((resolve) => { resolveDetail = resolve }),
)

const wrapper = mount(PaperTriageRowEdit, { props: { itemId: 'capture-1' } })
await flushPromises()

expect(wrapper.find('[data-testid="capture-edit-loading"]').exists()).toBe(true)
expect(wrapper.get('button[data-action="edit-cancel"]').text()).toContain('Cancel')

const options = mockCaptureStore.fetchDetail.mock.calls[0][1] as {
requestOptions?: { signal?: AbortSignal }
shouldCache?: () => boolean
}
await wrapper.get('button[data-action="edit-cancel"]').trigger('click')

expect(wrapper.emitted('close')).toHaveLength(1)
expect(options.requestOptions?.signal?.aborted).toBe(true)
expect(options.shouldCache?.()).toBe(false)

resolveDetail(makeDetail())
await flushPromises()

expect(wrapper.attributes('data-edit-state')).toBe('loading')
expect(wrapper.find('[data-testid="capture-edit-textarea"]').exists()).toBe(false)
})

it('does not reopen the load error after loading Cancel', async () => {
let rejectDetail!: (reason?: unknown) => void
mockCaptureStore.fetchDetail.mockImplementation(
() => new Promise<CaptureItem>((_resolve, reject) => { rejectDetail = reject }),
)

const wrapper = mount(PaperTriageRowEdit, { props: { itemId: 'capture-1' } })
await flushPromises()
await wrapper.get('button[data-action="edit-cancel"]').trigger('click')

rejectDetail(new Error('late network failure'))
await flushPromises()

expect(wrapper.attributes('data-edit-state')).toBe('loading')
expect(wrapper.find('[data-testid="capture-edit-load-error"]').exists()).toBe(false)
})

it('loads the untruncated text rather than offering the row excerpt', async () => {
const wrapper = await mountEditor()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick, reactive } from 'vue'
import PaperTriageTable from '../../../../views/paper/inbox/PaperTriageTable.vue'
import PaperTriageRowEdit from '../../../../views/paper/inbox/PaperTriageRowEdit.vue'
import type { CaptureItemSummary, CaptureStatusValue } from '../../../../types/capture'
import { i18n, type SupportedLocale } from '../../../../i18n'

Expand Down Expand Up @@ -1043,6 +1044,53 @@ describe('PaperTriageTable', () => {
expect(row.find('button[data-action="accept"]').attributes('disabled')).toBeUndefined()
})

it('cancels a loading editor, keeps row controls usable, and returns focus to Edit', async () => {
let resolveDetail!: (detail: unknown) => void
mockCaptureStore.fetchDetail.mockImplementationOnce(
() => new Promise<unknown>((resolve) => { resolveDetail = resolve }),
)
const wrapper = mount(PaperTriageTable, {
props: { items: makeItems() },
attachTo: document.body,
})

const editButton = wrapper.findAll('button[data-action="edit"]')[0]
await editButton.trigger('click')
await flushPromises()

const cancelButton = wrapper.get('button[data-action="edit-cancel"]')
expect(wrapper.find('[data-testid="capture-edit-loading"]').exists()).toBe(true)
;(cancelButton.element as HTMLButtonElement).focus()
await cancelButton.trigger('click')
await flushPromises()

expect(wrapper.find('[data-testid="capture-edit"]').exists()).toBe(false)
expect(editButton.attributes('disabled')).toBeUndefined()
expect(document.activeElement).toBe(editButton.element)

resolveDetail({})
await flushPromises()
expect(wrapper.find('[data-testid="capture-edit"]').exists()).toBe(false)
wrapper.unmount()
})

it('does not steal focus from a persistent row control when the editor closes', async () => {
const wrapper = mount(PaperTriageTable, {
props: { items: makeItems() },
attachTo: document.body,
})
await wrapper.findAll('button[data-action="edit"]')[0].trigger('click')
await flushPromises()

const persistentControl = wrapper.findAll('.paper-triage__open')[0]
;(persistentControl.element as HTMLButtonElement).focus()
wrapper.findComponent(PaperTriageRowEdit).vm.$emit('close')
await nextTick()

expect(document.activeElement).toBe(persistentControl.element)
wrapper.unmount()
})

it('does not narrate an open editor as a decision', async () => {
// The row is still undecided while its text is being corrected — claiming
// "Sending to Review…" here is the GH-1944 lie in a new place.
Expand Down Expand Up @@ -1282,6 +1330,49 @@ describe('PaperTriageTable', () => {
expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled()
})

it('keeps a held correction when loading Cancel closes the returning editor', async () => {
const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } })
const typed = await openEditorAndType(wrapper, 0, 'a correction behind a deferred read')

await wrapper.setProps({ items: makeItems().slice(1) })
await flushPromises()
await wrapper.setProps({ items: makeItems() })
await flushPromises()

let resolveDetail!: (detail: unknown) => void
mockCaptureStore.fetchDetail.mockImplementationOnce(
() => new Promise<unknown>((resolve) => { resolveDetail = resolve }),
)
await wrapper.findAll('button[data-action="edit"]')[0].trigger('click')
await flushPromises()
await wrapper.get('[data-testid="capture-edit-loading"] button[data-action="edit-cancel"]')
.trigger('click')
await flushPromises()

expect(noticeKinds(wrapper)).toContain('held')
resolveDetail({})
await flushPromises()

mockCaptureStore.fetchDetail.mockResolvedValueOnce({
id: 'capture-1',
userId: 'user-1',
boardId: 'board-alpha',
status: 'New',
source: 'Typed',
textExcerpt: 'First excerpt',
rawText: 'First excerpt in full',
createdAt: new Date('2026-04-25T09:42:00Z').toISOString(),
processedAt: null,
retryCount: 0,
provenance: null,
canEditSuggestion: true,
})
await wrapper.findAll('button[data-action="edit"]')[0].trigger('click')
await flushPromises()
expect(wrapper.get<HTMLTextAreaElement>('[data-testid="capture-edit-textarea"]').element.value)
.toBe(typed)
})

it('holds the correction while another editor is open, and says that is why', async () => {
const items = makeItems()
items[1] = { ...items[1], status: 'New' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type PaperTriageDraftReport =
</script>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import PaperHLBtn from '../../../components/paper/PaperHLBtn.vue'
import { getErrorDisplay } from '../../../composables/useErrorMapper'
Expand Down Expand Up @@ -118,6 +118,8 @@ const { t } = useI18n()
type LoadState = 'loading' | 'ready' | 'blocked' | 'error'

const loadState = ref<LoadState>('loading')
let loadGeneration = 0
let loadAbortController: AbortController | null = null
const loadErrorMessage = ref<string | null>(null)
const saveErrorMessage = ref<string | null>(null)
const saving = ref(false)
Expand Down Expand Up @@ -308,6 +310,10 @@ function readDraft(): PaperTriageDraftReport {
defineExpose({ readDraft })

async function load() {
const generation = ++loadGeneration
loadAbortController?.abort()
const abortController = new AbortController()
loadAbortController = abortController
loadState.value = 'loading'
loadErrorMessage.value = null
saveErrorMessage.value = null
Expand All @@ -329,7 +335,10 @@ async function load() {
forceRefresh: true,
recordError: false,
showToast: false,
requestOptions: { signal: abortController.signal },
shouldCache: () => generation === loadGeneration && !abortController.signal.aborted,
})
if (generation !== loadGeneration || abortController.signal.aborted) return
if (detail.canEditSuggestion !== true) {
loadState.value = 'blocked'
return
Expand All @@ -345,11 +354,21 @@ async function load() {
applyRestoredDraft()
loadState.value = 'ready'
} catch (e: unknown) {
if (generation !== loadGeneration || abortController.signal.aborted) return
loadErrorMessage.value = getErrorDisplay(e, t('inbox.triage.edit.unknownReason')).message
loadState.value = 'error'
} finally {
if (generation === loadGeneration) loadAbortController = null
}
}

function cancelLoading() {
loadGeneration += 1
loadAbortController?.abort()
loadAbortController = null
emit('close')
}

async function save() {
// Belt and braces behind the disabled button — every branch that stops the
// write also renders its reason above the button (`saveBlock`). `saveBlock`
Expand Down Expand Up @@ -393,6 +412,12 @@ async function save() {
onMounted(() => {
void load()
})

onBeforeUnmount(() => {
loadGeneration += 1
loadAbortController?.abort()
loadAbortController = null
})
</script>

<template>
Expand All @@ -404,6 +429,14 @@ onMounted(() => {
data-testid="capture-edit-loading"
>
<span class="tk-meta">{{ t('inbox.triage.edit.loading') }}</span>
<div class="paper-triage-edit__actions">
<PaperHLBtn
:label="t('inbox.triage.edit.cancel')"
variant="ghost"
data-action="edit-cancel"
@click="cancelLoading"
/>
</div>
</div>

<div
Expand Down
15 changes: 15 additions & 0 deletions frontend/taskdeck-web/src/views/paper/inbox/PaperTriageTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,9 @@ function onEdit(item: CaptureItemSummary) {
*/
function closeEdit() {
const itemId = editItemId.value
const activeElement = typeof document === 'undefined' ? null : document.activeElement
const editorOwnedFocus = activeElement instanceof HTMLElement &&
activeElement.closest('[data-testid="capture-edit"]') !== null
if (itemId !== null) {
const report = readOpenEditor()
if (report.state === 'ready') {
Expand All @@ -593,6 +596,18 @@ function closeEdit() {
}
editItemId.value = null
editItemLabel.value = null

// A Cancel click leaves focus on the editor that is about to disappear.
// Return it to this row's Edit control only in that case; a persistent row
// control or dialog may have taken focus while the editor was closing.
if (itemId !== null && editorOwnedFocus) {
void nextTick(() => {
const row = Array.from(document.querySelectorAll<HTMLElement>('.paper-triage__row'))
.find(candidate => candidate.dataset.itemId === itemId)
const editButton = row?.querySelector<HTMLButtonElement>('button[data-action="edit"]')
if (editButton && !editButton.disabled) editButton.focus()
})
}
}

/** The editor has put a held correction back; say which one, once it is true. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from '@playwright/test'
import { API_BASE_URL, registerAndAttachSession } from './support/authSession'
import { createCaptureItem } from './support/captureFlow'

test('Paper triage edit can cancel a held detail read without reopening', async ({ page, request }) => {
test.setTimeout(60_000)
const auth = await registerAndAttachSession(page, request, 'edit-load')
const captureText = `loading-cancel-${Date.now()}`
const capture = await createCaptureItem(request, auth, null, captureText)

let detailSeen!: () => void
const detailRequestSeen = new Promise<void>((resolve) => { detailSeen = resolve })
let releaseDetail!: () => void
const detailResponseHeld = new Promise<void>((resolve) => { releaseDetail = resolve })
const detailUrl = `${API_BASE_URL}/capture/items/${encodeURIComponent(capture.id)}`

await page.route(detailUrl, async (route) => {
detailSeen()
await detailResponseHeld
try {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
...capture,
rawText: captureText,
textExcerpt: captureText,
canEditSuggestion: true,
}),
})
} catch {
// Cancel aborts the browser request. The late response is intentionally
// allowed to lose the race without turning the test into a route error.
}
})

await page.goto('/workspace/inbox')
const row = page.locator('.paper-triage__row').filter({ hasText: captureText }).first()
await expect(row).toBeVisible()
await row.locator('button[data-action="edit"]').click()
await expect(row.locator('[data-testid="capture-edit-loading"]')).toBeVisible()
await detailRequestSeen

const cancel = row.locator('button[data-action="edit-cancel"]')
await expect(cancel).toHaveText(/Cancel/)
await cancel.click()
await expect(row.locator('[data-testid="capture-edit"]')).toHaveCount(0)
await expect(row.locator('button[data-action="edit"]')).toBeEnabled()
await expect(row.locator('button[data-action="edit"]')).toBeFocused()

releaseDetail()
await page.waitForTimeout(100)
await expect(row.locator('[data-testid="capture-edit"]')).toHaveCount(0)
})
Loading