diff --git a/frontend/taskdeck-web/src/locales/en/inbox.ts b/frontend/taskdeck-web/src/locales/en/inbox.ts index d80d0940e..10601a69f 100644 --- a/frontend/taskdeck-web/src/locales/en/inbox.ts +++ b/frontend/taskdeck-web/src/locales/en/inbox.ts @@ -246,6 +246,31 @@ export default { state: 'State: {label}. Where this capture stands right now.', source: 'Source: {label}. How this capture arrived — not a state.', }, + // Where an unsaved correction stands once its capture left the list (#1999 + // item 3) — a board-filter change, a refresh that no longer returns the + // row, or the switch into archived history. `{capture}` is the row's own + // excerpt, so the sentence names the same thing the list did. + // + // `kept` and `discarded` are receipts about a moment. `held`, `blocked` and + // `heldUneditable` are standing statements, true for as long as they are on + // screen, so each ends by saying what the reader can do about it. + // + // `kept` says "this Inbox list" on purpose. The correction lives in the + // table for as long as the table does; promising it back after a reload + // would be a promise this mechanism cannot keep. + // + // `discarded` is the only sentence about a loss, and it is reached only + // from a status the SERVER itself would refuse the edit in. It states that + // status rather than leaving the drop unexplained. + draft: { + kept: 'The unsaved correction to “{capture}” was not lost. It is held while you stay on this Inbox list, and comes back with that capture when it returns. Nothing was saved.', + held: 'The unsaved correction to “{capture}” is still held. Choose Edit capture on that row to bring it back.', + blocked: 'The unsaved correction to “{capture}” is still held. Another capture is open for editing — finish that one, then choose Edit capture on this row to bring the correction back.', + heldUneditable: 'The unsaved correction to “{capture}” is still held. This list does not edit a capture that is {status}, so the correction waits here until that capture can be edited again.', + restored: 'The unsaved correction to “{capture}” is back in the editor, over the capture as it stands now. Save it or cancel as usual.', + discarded: 'The unsaved correction to “{capture}” was dropped: the capture is now {status}, and its text can no longer be edited. Nothing was saved.', + dismiss: 'Dismiss these notes', + }, // Pre-triage text correction (GH-1951) — the Legacy detail panel's // "Edit Text" affordance, ported to the Paper row. // diff --git a/frontend/taskdeck-web/src/locales/es/inbox.ts b/frontend/taskdeck-web/src/locales/es/inbox.ts index 1b8e45216..5d2f97d31 100644 --- a/frontend/taskdeck-web/src/locales/es/inbox.ts +++ b/frontend/taskdeck-web/src/locales/es/inbox.ts @@ -174,6 +174,27 @@ export default { state: 'Estado: {label}. Dónde está ahora mismo esta captura.', source: 'Origen: {label}. Cómo llegó esta captura — no es un estado.', }, + // Dónde queda una corrección sin guardar cuya captura sale de la lista + // (#1999, punto 3): un cambio del filtro de tablero, una recarga que ya no + // devuelve la fila, o el paso al historial de solo lectura. `{capture}` es + // el extracto de la propia fila. + // + // `kept` y `discarded` son recibos de un momento. `held`, `blocked` y + // `heldUneditable` son frases vigentes mientras se ven, así que cada una + // termina diciendo qué puede hacer quien lee. + // + // `kept` dice "esta lista" a propósito: la corrección vive en la tabla + // mientras la tabla exista, y prometerla tras recargar la página sería una + // promesa que este mecanismo no puede cumplir. + draft: { + kept: 'La corrección sin guardar de “{capture}” no se ha perdido. Se conserva mientras sigas en esta lista de Inbox y vuelve con esa captura cuando reaparezca. No se guardó nada.', + held: 'La corrección sin guardar de “{capture}” sigue conservada. Pulsa Editar captura en esa fila para recuperarla.', + blocked: 'La corrección sin guardar de “{capture}” sigue conservada. Otra captura está abierta para editar: termina esa y luego pulsa Editar captura en esta fila para recuperarla.', + heldUneditable: 'La corrección sin guardar de “{capture}” sigue conservada. Esta lista no edita una captura que está {status}, así que la corrección espera aquí hasta que esa captura vuelva a poder editarse.', + restored: 'La corrección sin guardar de “{capture}” vuelve a estar en el editor, sobre la captura tal como está ahora. Guárdala o cancela como siempre.', + discarded: 'La corrección sin guardar de “{capture}” se descartó: la captura ahora está {status} y su texto ya no se puede editar. No se guardó nada.', + dismiss: 'Descartar estos avisos', + }, // Corrección del texto antes de clasificar (GH-1951). // // `blocked.notEditable` enuncia el HECHO, no la causa: el servidor rechaza diff --git a/frontend/taskdeck-web/src/locales/it/inbox.ts b/frontend/taskdeck-web/src/locales/it/inbox.ts index b93ee9e9a..b9b819821 100644 --- a/frontend/taskdeck-web/src/locales/it/inbox.ts +++ b/frontend/taskdeck-web/src/locales/it/inbox.ts @@ -171,6 +171,27 @@ export default { state: 'Stato: {label}. Il punto in cui si trova ora questa cattura.', source: 'Origine: {label}. Come è arrivata questa cattura — non è uno stato.', }, + // Dove resta una correzione non salvata la cui cattura esce dall'elenco + // (#1999, punto 3): un cambio del filtro bacheca, un aggiornamento che non + // restituisce più la riga, oppure il passaggio allo storico in sola + // lettura. `{capture}` è l'estratto della riga stessa. + // + // `kept` e `discarded` sono ricevute di un momento. `held`, `blocked` e + // `heldUneditable` sono frasi valide finché restano a schermo, quindi + // ognuna finisce dicendo che cosa può fare chi legge. + // + // `kept` dice "questo elenco" di proposito: la correzione vive nella + // tabella finché la tabella esiste, e prometterla dopo un ricaricamento + // sarebbe una promessa che questo meccanismo non può mantenere. + draft: { + kept: 'La correzione non salvata di “{capture}” non è andata persa. Resta conservata finché rimani su questo elenco Inbox e torna con quella cattura quando ricompare. Non è stato salvato nulla.', + held: 'La correzione non salvata di “{capture}” è ancora conservata. Premi Modifica cattura su quella riga per riprenderla.', + blocked: 'La correzione non salvata di “{capture}” è ancora conservata. Un\'altra cattura è aperta in modifica: concludi quella, poi premi Modifica cattura su questa riga per riprenderla.', + heldUneditable: 'La correzione non salvata di “{capture}” è ancora conservata. Questo elenco non modifica una cattura che è {status}, quindi la correzione resta qui finché quella cattura non torna modificabile.', + restored: 'La correzione non salvata di “{capture}” è di nuovo nell\'editor, sopra la cattura così com\'è ora. Salvala o annulla come sempre.', + discarded: 'La correzione non salvata di “{capture}” è stata scartata: la cattura ora è {status} e il suo testo non è più modificabile. Non è stato salvato nulla.', + dismiss: 'Chiudi queste note', + }, // Correzione del testo prima dello smistamento (GH-1951). // // `blocked.notEditable` dichiara il FATTO, non la causa: il server rifiuta 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..5b26af607 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,135 @@ 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('reports only the fields the user changed, and an empty draft when none are', 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()).toEqual({ state: 'ready', draft: null }) + + await wrapper.get('[data-testid="capture-edit-textarea"]').setValue('Ship the release notes') + await wrapper.get('[data-testid="capture-edit-label-input"]').setValue('release') + + // Per field: the untouched due date is absent rather than carried as the + // value this load happened to see, so it can never be laid back over a + // newer one. + expect(wrapper.vm.readDraft()).toEqual({ + state: 'ready', + draft: { + text: 'Ship the release notes', + // The uncommitted label box travels too: Save would have flushed it. + labelInput: 'release', + }, + }) + }) + + /** + * The distinction the whole hand-off rests on: "there is nothing unsaved" and + * "I cannot tell" are different answers. Collapsing them into one lets the + * table release a held correction while the editor is still loading, has + * failed to load, or was refused — none of which is evidence about the draft. + */ + it('reports that it cannot answer 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() + + expect(wrapper.vm.readDraft()).toEqual({ state: 'unavailable' }) + resolveDetail(makeDetail()) + await flushPromises() + expect(wrapper.vm.readDraft()).toEqual({ state: 'ready', draft: null }) + }) + + it('reports that it cannot answer when the load failed', async () => { + mockCaptureStore.fetchDetail.mockRejectedValue(new Error('offline')) + const wrapper = await mountEditor() + + expect(wrapper.find('[data-testid="capture-edit-load-error"]').exists()).toBe(true) + expect(wrapper.vm.readDraft()).toEqual({ state: 'unavailable' }) + }) + + it('reports that it cannot answer when the server refuses the edit', async () => { + mockCaptureStore.fetchDetail.mockResolvedValue(makeDetail({ canEditSuggestion: false })) + const wrapper = await mountEditor() + + expect(wrapper.find('[data-testid="capture-edit-blocked"]').exists()).toBe(true) + expect(wrapper.vm.readDraft()).toEqual({ state: 'unavailable' }) + }) + + 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' }, + }, + }) + await flushPromises() + + // The fetch still happens: the values the draft is laid over have to be the + // capture's current ones, not the ones 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('lays only the changed fields over the values it re-reads, so a Save cannot revert the rest', async () => { + // The capture's metadata moved on the server while the correction was held. + // The user only ever touched the text. + mockCaptureStore.fetchDetail.mockResolvedValue( + makeDetail({ metadata: { dueDate: '2026-06-01', labels: ['ops'] } }), + ) + const wrapper = mount(PaperTriageRowEdit, { + props: { + itemId: 'capture-1', + restoredDraft: { text: 'Ship the release notes before Friday' }, + }, + }) + await flushPromises() + + expect(wrapper.get('[data-testid="capture-edit-due-date"]').element.value) + .toBe('2026-06-01') + expect(wrapper.get('[data-testid="capture-edit-label-chip"]').text()).toContain('ops') + + await wrapper.get('button[data-action="edit-save"]').trigger('click') + await flushPromises() + + // No metadata block at all: nothing the user touched was metadata, so the + // server's own due date and labels are left exactly where they are. + expect(mockCaptureStore.updateSuggestion).toHaveBeenCalledWith('capture-1', { + text: 'Ship the release notes before Friday', + }) + }) + + 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' }, + }, + }) + 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/tests/views/paper/inbox/PaperTriageTable.spec.ts b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts index 238bfa11f..7c993c6ba 100644 --- a/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/paper/inbox/PaperTriageTable.spec.ts @@ -1104,15 +1104,451 @@ describe('PaperTriageTable', () => { expect(wrapper.get('button[data-action="edit-save"]').attributes('disabled')).toBeDefined() }) - it('closes a stale editor when its row leaves the list', async () => { + + // --- draft fate when the edited row leaves the list (#1999 item 3) -------- + + /** + * Until now this closed the editor and destroyed the unsaved correction + * WITHOUT a word: switching the Inbox board filter replaced the list, the + * edited row was not in the replacement, and the `items` watcher cleared + * `editItemId`. The draft went with the unmounted child. That was pinned as + * intended; the pin is replaced here by preserve-and-announce. + * + * The list replacement below IS the board-filter change as this component + * sees it: `useInboxOrchestrator`'s `activeBoardId` watcher reloads the + * inbox for the new scope and a new `items` array arrives. A same-scope + * refresh that drops the row reaches the table through exactly the same + * prop, which is why one case covers both. + * + * Restoring is the user's move, never the table's: a held correction comes + * back only through Edit capture on its row. The notices region says the + * correction is there to come back to. + */ + function noticeKinds(wrapper: ReturnType): string[] { + return wrapper + .findAll('[data-testid="capture-draft-notices"] p') + .map((line) => line.attributes('data-notice') ?? '') + } + + function noticeFor(wrapper: ReturnType, kind: string) { + return wrapper.get(`[data-testid="capture-draft-notices"] p[data-notice="${kind}"]`) + } + + async function openEditorAndType( + wrapper: ReturnType, + rowIndex: number, + text: string, + ) { + await wrapper.findAll('button[data-action="edit"]')[rowIndex].trigger('click') + await flushPromises() + await wrapper.get('[data-testid="capture-edit-textarea"]').setValue(text) + return text + } + + it('mounts the notices region before it has anything to say', () => { + // A live region inserted at the same moment its text appears is announced + // unreliably (#2593/#2630). It is mounted and silent instead, so a notice + // is a mutation of a region that was already there. + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const region = wrapper.get('[data-testid="capture-draft-notices"]') + + expect(region.attributes('role')).toBe('status') + expect(region.attributes('aria-live')).toBe('polite') + expect(region.text()).toBe('') + expect(noticeKinds(wrapper)).toEqual([]) + }) + + it('keeps an unsaved correction and names the capture when its row leaves the list', async () => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + await openEditorAndType(wrapper, 0, 'a correction the filter change must not eat') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + + // The editor still closes — the row it belongs to is gone. + expect(wrapper.find('[data-testid="capture-edit"]').exists()).toBe(false) + + const kept = noticeFor(wrapper, 'kept') + // Named by the same excerpt the row shows, so the user knows WHICH capture. + expect(kept.text()).toContain('First excerpt') + expect(kept.text()).toContain('this Inbox list') + + // Nothing was written anywhere: this is component state, not a save. + expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled() + expect(wrapper.emitted('accept')).toBeUndefined() + expect(wrapper.emitted('keep')).toBeUndefined() + expect(wrapper.emitted('reject')).toBeUndefined() + }) + + it('says nothing and keeps nothing when the row leaves with an untouched editor', async () => { + // The receipt must describe a real loss. An editor that was opened and not + // typed into has no correction to keep, and announcing one would be noise. + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + + expect(noticeKinds(wrapper)).toEqual([]) + }) + + it('offers a held correction back on the returning row, and restores it on Edit', async () => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'the correction that has to survive the round trip') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + + // No editor opens by itself: restoring is an explicit action, like every + // other write-adjacent move on this surface. + expect(wrapper.find('[data-testid="capture-edit"]').exists()).toBe(false) + const held = noticeFor(wrapper, 'held') + expect(held.text()).toContain('First excerpt') + expect(held.text()).toContain('Edit capture') + + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + expect(noticeFor(wrapper, 'restored').text()).toContain('First excerpt') + // The server's current text is re-read on the way back in, so the draft is + // measured against what the capture says NOW, not what it said before. + expect(mockCaptureStore.fetchDetail).toHaveBeenCalledTimes(2) + expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled() + }) + + it('holds the correction while another editor is open, and says that is why', async () => { + const items = makeItems() + items[1] = { ...items[1], status: 'New' } + const wrapper = mount(PaperTriageTable, { props: { items } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction waiting for its row') + + // capture-1 leaves; its correction is kept. + await wrapper.setProps({ items: items.slice(1) }) + await flushPromises() + + // The user starts editing the row that stayed, then capture-1 comes back. + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + await wrapper.setProps({ items }) + await flushPromises() + + // One editor, on capture-2 — the open-editor gate is not broken by a return. + const rows = wrapper.findAll('.paper-triage__row') + expect(wrapper.findAll('[data-testid="capture-edit"]')).toHaveLength(1) + expect(rows[1].find('[data-testid="capture-edit"]').exists()).toBe(true) + expect(rows[0].find('[data-testid="capture-edit"]').exists()).toBe(false) + const blocked = noticeFor(wrapper, 'blocked') + expect(blocked.text()).toContain('First excerpt') + + // Still held: closing the open editor and asking for capture-1 brings it back. + await wrapper.get('button[data-action="edit-cancel"]').trigger('click') + await flushPromises() + expect(noticeKinds(wrapper)).toContain('held') + + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + /** + * The editor can be open without being able to say anything about a draft: + * loading, failed, or refused by the server. Treating that silence as + * "nothing unsaved" released the held correction, so the second filter change + * in this test used to destroy it — the same silent loss, one layer in. + */ + it('keeps a correction whose editor could not load before the list changed again', async () => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction behind a failing read') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + + // Restoring it hits a detail read that fails, so the editor can say nothing. + mockCaptureStore.fetchDetail.mockRejectedValueOnce(new Error('offline')) + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.find('[data-testid="capture-edit-load-error"]').exists()).toBe(true) + + // The filter changes again while that error panel is open. + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + expect(noticeFor(wrapper, 'kept').text()).toContain('First excerpt') + + // The correction survived: it is offered back, and it comes back. + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + it('keeps a correction when the reader closes an editor that failed to load', async () => { + // Cancel on the error panel answers the failed READ. The user never saw the + // correction, so it cannot be their answer to it. + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction the reader never got to see') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + + mockCaptureStore.fetchDetail.mockRejectedValueOnce(new Error('offline')) + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + await wrapper.get('[data-testid="capture-edit-load-error"] button[data-action="edit-cancel"]') + .trigger('click') + await flushPromises() + + expect(noticeKinds(wrapper)).toContain('held') + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + it('keeps a correction when the reader closes an editor the server refused', async () => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction behind a refused edit') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + + // The server answers "not editable" this time — a transcript-linked capture + // reads that way whatever its status. + 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: false, + }) + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + await wrapper.get('[data-testid="capture-edit-blocked"] button[data-action="edit-cancel"]') + .trigger('click') + await flushPromises() + + expect(noticeKinds(wrapper)).toContain('held') + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + /** + * Editability for the DISCARD decision is the server's rule + * (`CaptureService.IsSuggestionEditableStatus`: New, Failed, Triaged), never + * this list's action gate. Dropping a correction is irreversible, so it may + * not be keyed on a client-side product policy that a ruling could change, + * and never on a status the capture is only passing through. + */ + // `Triaged` is a capture this list will not edit under the current gate; + // `Triaging` is one it is only passing through. Neither is the server saying + // no, so neither may cost the reader their correction. + it.each(['Triaged', 'Triaging'] as const)('keeps a correction whose capture comes back %s, and says why', async (status) => { const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction for a capture that moved on') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + + const returned = makeItems() + returned[0] = { ...returned[0], status: status as CaptureStatusValue } + await wrapper.setProps({ items: returned }) + await flushPromises() + + expect(noticeKinds(wrapper)).not.toContain('discarded') + const held = noticeFor(wrapper, 'heldUneditable') + expect(held.text()).toContain('First excerpt') + expect(held.text()).toContain(status) + + // And it really is still there once the capture is editable again. + await wrapper.setProps({ items: makeItems() }) + await flushPromises() await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') await flushPromises() - expect(wrapper.find('[data-testid="capture-edit"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + it.each([ + ['ProposalCreated', 'Ready for review'], + ['Converted', 'Applied to board'], + ['Ignored', 'Ignored'], + ] as const)('drops a correction with a stated reason once the capture is %s', async (status, label) => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + await openEditorAndType(wrapper, 0, 'a correction with nowhere left to land') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + + // These are the settled states the server itself would refuse a text edit + // in, so the correction can never be applied and holding it would be a + // promise this surface cannot keep. + const returned = makeItems() + returned[0] = { ...returned[0], status: status as CaptureStatusValue } + await wrapper.setProps({ items: returned }) + await flushPromises() + + const discarded = noticeFor(wrapper, 'discarded') + expect(discarded.text()).toContain('First excerpt') + expect(discarded.text()).toContain(label) + + // And it is really gone: an editable capture-1 later opens on server text. + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe('First excerpt in full') + expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled() + }) + + it('never announces a discard for a capture the reader has open', async () => { + // The correction is on screen in the textarea. Announcing that it was + // dropped would be false, and dropping it would take what the user is + // looking at. + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction being read right now') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + + const converted = makeItems() + converted[0] = { ...converted[0], status: 'Converted' } + await wrapper.setProps({ items: converted }) + await flushPromises() + + expect(noticeKinds(wrapper)).not.toContain('discarded') + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + it('carries one line per capture, so one correction never speaks over another', async () => { + const items = makeItems() + items[1] = { ...items[1], status: 'New' } + const wrapper = mount(PaperTriageTable, { props: { items } }) + + await openEditorAndType(wrapper, 0, 'the first correction') + await wrapper.setProps({ items: items.slice(1) }) + await flushPromises() + await openEditorAndType(wrapper, 0, 'the second correction') + await wrapper.setProps({ items: [] }) + await flushPromises() + + // One list change settles both: capture-1 is past editing, capture-2 is not. + const returned = [{ ...items[0], status: 'Converted' as CaptureStatusValue }, items[1]] + await wrapper.setProps({ items: returned }) + await flushPromises() + expect(noticeKinds(wrapper).sort()).toEqual(['discarded', 'held']) + + // Restoring capture-2 must not take capture-1's receipt off the surface. + await wrapper.findAll('button[data-action="edit"]')[1].trigger('click') + await flushPromises() + expect(noticeKinds(wrapper).sort()).toEqual(['discarded', 'restored']) + expect(noticeFor(wrapper, 'discarded').text()).toContain('First excerpt') + expect(noticeFor(wrapper, 'restored').text()).toContain('Second excerpt') + }) + + it('does not drop a kept correction merely because the list changed again', async () => { + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + const typed = await openEditorAndType(wrapper, 0, 'a correction that outlives two filter changes') await wrapper.setProps({ items: makeItems().slice(1) }) await flushPromises() + await wrapper.setProps({ items: [] }) + await flushPromises() + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe(typed) + }) + + it('keeps the correction when the list switches to archived history', async () => { + // Entering read-only history clears the editor through its own watcher, so + // it is a second door onto the same silent loss. + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + await openEditorAndType(wrapper, 0, 'a correction interrupted by the archive') + + await wrapper.setProps({ readOnly: true }) + await flushPromises() expect(wrapper.find('[data-testid="capture-edit"]').exists()).toBe(false) + expect(noticeFor(wrapper, 'kept').text()).toContain('First excerpt') + }) + + it('lets the reader dismiss the receipts and leaves focus on the region', async () => { + const wrapper = mount(PaperTriageTable, { + props: { items: makeItems() }, + attachTo: document.body, + }) + try { + await openEditorAndType(wrapper, 0, 'a correction whose receipt has been read') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + expect(noticeKinds(wrapper)).toEqual(['kept']) + + await wrapper.get('button[data-action="dismiss-draft-notice"]').trigger('click') + await flushPromises() + + const region = wrapper.get('[data-testid="capture-draft-notices"]') + // The region stays mounted; only what it was saying goes away. + expect(noticeKinds(wrapper)).toEqual([]) + expect(document.activeElement).toBe(region.element) + } finally { + wrapper.unmount() + } + }) + + it('forgets the correction when the user cancels a loaded edit', async () => { + // Cancel still means cancel: the held copy is not a second life for text + // the user has explicitly abandoned in an editor they could see. + const wrapper = mount(PaperTriageTable, { props: { items: makeItems() } }) + await openEditorAndType(wrapper, 0, 'a correction the user changed their mind about') + + await wrapper.setProps({ items: makeItems().slice(1) }) + await flushPromises() + await wrapper.setProps({ items: makeItems() }) + await flushPromises() + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + + await wrapper.get('button[data-action="edit-cancel"]').trigger('click') + await flushPromises() + + expect(noticeKinds(wrapper)).toEqual([]) + await wrapper.findAll('button[data-action="edit"]')[0].trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="capture-edit-textarea"]').element.value) + .toBe('First excerpt in full') }) }) diff --git a/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue b/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue index f6629ca14..dad78f4a3 100644 --- a/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue +++ b/frontend/taskdeck-web/src/views/paper/inbox/PaperTriageRowEdit.vue @@ -1,3 +1,45 @@ + +