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
25 changes: 25 additions & 0 deletions frontend/taskdeck-web/src/locales/en/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
21 changes: 21 additions & 0 deletions frontend/taskdeck-web/src/locales/es/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions frontend/taskdeck-web/src/locales/it/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CaptureItem>((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<HTMLTextAreaElement>('[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<HTMLInputElement>('[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()
})
})
Loading
Loading