From 804ca8669b1f37d58dfd1398b417499dc196a5c6 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sat, 5 Sep 2026 17:21:59 +0100 Subject: [PATCH 1/3] feat(inbox): let the row editor hand out and take back an unsaved draft The Paper triage row editor now exposes `readDraft()`, which answers with the unsaved correction only while the capture text has loaded and something actually differs from it, and accepts a `restoredDraft` prop that it applies once over the freshly re-read server values before announcing `restored`. A getter rather than an emit on unmount: `close` fires on Cancel and on a completed Save as well as on an involuntary close, so an unmount emit would force the table to infer which kind of close it was. The table already knows the one moment the close is involuntary and can read the draft there. Refs #1999 --- .../paper/inbox/PaperTriageRowEdit.spec.ts | 84 +++++++++++++++ .../views/paper/inbox/PaperTriageRowEdit.vue | 101 +++++++++++++++++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageRowEdit.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageRowEdit.spec.ts index 799dfab7c..4c86fdca9 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageRowEdit.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageRowEdit.spec.ts @@ -418,4 +418,88 @@ describe('PaperTriageRowEdit', () => { expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled() expect(wrapper.emitted('close')).toHaveLength(1) }) + + // ── the draft hand-off, so a row that leaves the list can keep it (#1999) ── + + it('hands out the unsaved draft, and nothing while there is nothing unsaved', async () => { + mockCaptureStore.fetchDetail.mockResolvedValue( + makeDetail({ metadata: { dueDate: '2026-05-01', labels: ['ops'] } }), + ) + const wrapper = await mountEditor() + + // An opened-but-untouched editor holds no correction; saying otherwise + // would have the table announce a loss that did not happen. + expect(wrapper.vm.readDraft()).toBeNull() + + await wrapper.get('[data-testid="capture-edit-textarea"]').setValue('Ship the release notes') + await wrapper.get('[data-testid="capture-edit-label-input"]').setValue('release') + + expect(wrapper.vm.readDraft()).toEqual({ + text: 'Ship the release notes', + dueDate: '2026-05-01', + labels: ['ops'], + // The uncommitted label box travels too: Save would have flushed it. + labelInput: 'release', + }) + }) + + it('hands out nothing while the capture text has not loaded', async () => { + let resolveDetail: (detail: CaptureItem) => void = () => {} + mockCaptureStore.fetchDetail.mockImplementation( + () => new Promise((resolve) => { resolveDetail = resolve }), + ) + const wrapper = mount(PaperTriageRowEdit, { props: { itemId: 'capture-1' } }) + await flushPromises() + + // There is no textarea yet, so an empty `draft` is the absence of data and + // not an edit — handing it out would let the table keep a blank correction. + expect(wrapper.vm.readDraft()).toBeNull() + resolveDetail(makeDetail()) + await flushPromises() + expect(wrapper.vm.readDraft()).toBeNull() + }) + + it('restores a kept draft over the server text it re-reads, and says it did', async () => { + const wrapper = mount(PaperTriageRowEdit, { + props: { + itemId: 'capture-1', + restoredDraft: { + text: 'Ship the release notes before Friday', + dueDate: '', + labels: [], + labelInput: '', + }, + }, + }) + await flushPromises() + + // The fetch still happens: the ORIGINAL the draft is compared against has + // to be the capture's current text, not the one it had when it left. + expect(mockCaptureStore.fetchDetail).toHaveBeenCalledWith( + 'capture-1', + expect.objectContaining({ forceRefresh: true }), + ) + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe('Ship the release notes before Friday') + expect(wrapper.emitted('restored')?.[0]).toEqual(['capture-1']) + // Restoring is a local rehydration, never a write. + expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled() + // And Save is live, because the restored text differs from the server's. + expect(wrapper.get('button[data-action="edit-save"]').attributes('disabled')).toBeUndefined() + }) + + it('does not restore a draft into a capture the server refuses to edit', async () => { + mockCaptureStore.fetchDetail.mockResolvedValue(makeDetail({ canEditSuggestion: false })) + const wrapper = mount(PaperTriageRowEdit, { + props: { + itemId: 'capture-1', + restoredDraft: { text: 'a correction with nowhere to land', dueDate: '', labels: [], labelInput: '' }, + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="capture-edit-blocked"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="capture-edit-textarea"]').exists()).toBe(false) + expect(wrapper.emitted('restored')).toBeUndefined() + }) }) diff --git a/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue b/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue index f6629ca14..c9ef660b5 100644 --- a/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue +++ b/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue @@ -1,3 +1,26 @@ + +