diff --git a/.changeset/13015-undo-partial-failures.md b/.changeset/13015-undo-partial-failures.md new file mode 100644 index 0000000000..ee9eb6909f --- /dev/null +++ b/.changeset/13015-undo-partial-failures.md @@ -0,0 +1,18 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/types': minor +'@object-ui/i18n': minor +--- + +An import undo that leaves rows unreverted now says so in the Import Wizard (objectui#13015). + +The undo route runs its reversal writes under the object's declared controls, so a hook or rule can refuse a row. The server then answers with `success: false` and one `failures` entry per row it did not revert. Each entry carries the app's own refusal sentence. The job stays undoable for exactly those rows. The wizard used to ignore the answer and only reported a thrown error, so a partial undo showed nothing. + +- **Both lists that run Undo show it.** In the History list and on the cancelled-result screen, a partial answer reports how many rows were not undone and that undoing again retries only those rows. It lists each refusal sentence the answer carries. Rows refused with the same sentence share one line, and after ten distinct sentences the rest are counted. Undo stays offered, because the re-read job is still undoable. The object's open lists still refetch, because the undo reverted the other rows. A full undo is unchanged. +- **A thrown undo in the History list shows its message again.** The list's reload cleared the error slot the undo had just written to, in the same tick, so that message never appeared. The undo outcome now has a slot of its own. +- **`@object-ui/types`: `ImportJobUndoResult` is the spec's `UndoImportJobResponse`, bound by reference.** It was a hand-written copy that lacked `failures`. It now carries the required `failures` array of `{ id, action: 'delete' | 'restore', code, error }`. `@object-ui/types` now requires `@objectstack/spec` `^18.0.0-next.5`, the first published release that declares `failures`. +- **`@object-ui/i18n`:** the ten packs gain the `grid.import.undoPartial` count family. + +BREAKING (`@object-ui/types`), for a custom `DataSource` whose `undoImportJob` resolves to the old shape: it must now return `failures` (an empty array for a full undo). `@object-ui/data-objectstack` already returns the server's answer unchanged. The bump is `minor` by this repo's release model: objectui's major follows the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here. + +Clause-②: yes (narrowing): `ImportJobUndoResult`, exported from `@object-ui/types`, gains `failures`, and the new pack keys widen `@object-ui/i18n`'s `TranslationKeys` (the widening). `failures` is now required of a custom `DataSource`'s `undoImportJob` answer (the narrowing; see BREAKING above). diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c448ea3e17..1762e45a61 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -662,6 +662,13 @@ const ar = { undoing: "جارٍ التراجع…", undoConfirm: "هل تريد التراجع عن هذا الاستيراد؟ ستُحذف السجلات التي أنشأها وتُستعاد السجلات التي حدّثها إلى قيمها السابقة.", reverted: "تم التراجع", + undoPartial: "لم يتم التراجع عن {{count}} صف(صفوف). التراجع مرة أخرى يعيد محاولة هذه الصفوف فقط.", + undoPartial_zero: "لم يتم التراجع عن {{count}} صف. التراجع مرة أخرى يعيد محاولة هذه الصفوف فقط.", + undoPartial_one: "لم يتم التراجع عن {{count}} صف. التراجع مرة أخرى يعيد محاولة هذا الصف فقط.", + undoPartial_two: "لم يتم التراجع عن صفّين ({{count}}). التراجع مرة أخرى يعيد محاولة هذين الصفّين فقط.", + undoPartial_few: "لم يتم التراجع عن {{count}} صفوف. التراجع مرة أخرى يعيد محاولة هذه الصفوف فقط.", + undoPartial_many: "لم يتم التراجع عن {{count}} صفًا. التراجع مرة أخرى يعيد محاولة هذه الصفوف فقط.", + undoPartial_other: "لم يتم التراجع عن {{count}} صف. التراجع مرة أخرى يعيد محاولة هذه الصفوف فقط.", jobStatus: { pending: "قيد الانتظار", running: "قيد التنفيذ", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7c56f7fc3a..fdf3c29beb 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -596,6 +596,9 @@ const de = { undoing: "Wird rückgängig gemacht…", undoConfirm: "Diesen Import rückgängig machen? Dabei erstellte Datensätze werden gelöscht und aktualisierte Datensätze auf ihre vorherigen Werte zurückgesetzt.", reverted: "Rückgängig gemacht", + undoPartial: "{{count}} Zeilen wurden nicht rückgängig gemacht. Erneutes Rückgängigmachen wiederholt nur diese Zeilen.", + undoPartial_one: "{{count}} Zeile wurde nicht rückgängig gemacht. Erneutes Rückgängigmachen wiederholt nur diese Zeile.", + undoPartial_other: "{{count}} Zeilen wurden nicht rückgängig gemacht. Erneutes Rückgängigmachen wiederholt nur diese Zeilen.", jobStatus: { pending: "Ausstehend", running: "Läuft", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bdc4ee6e77..12080f8481 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -868,6 +868,9 @@ const en = { undoing: 'Undoing…', undoConfirm: 'Undo this import? Records it created will be deleted and records it updated will be restored to their previous values.', reverted: 'Undone', + undoPartial: '{{count}} rows were not undone. Undo again to retry only those rows.', + undoPartial_one: '{{count}} row was not undone. Undo again to retry only that row.', + undoPartial_other: '{{count}} rows were not undone. Undo again to retry only those rows.', jobStatus: { pending: 'Pending', running: 'Running', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e9f9e1e527..2b53bb2a53 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -616,6 +616,10 @@ const es = { undoing: "Deshaciendo…", undoConfirm: "¿Deshacer esta importación? Los registros que creó se eliminarán y los que actualizó se restaurarán a sus valores anteriores.", reverted: "Deshecha", + undoPartial: "{{count}} filas no se deshicieron. Al deshacer de nuevo, solo se reintentan esas filas.", + undoPartial_one: "{{count}} fila no se deshizo. Al deshacer de nuevo, solo se reintenta esa fila.", + undoPartial_many: "{{count}} de filas no se deshicieron. Al deshacer de nuevo, solo se reintentan esas filas.", + undoPartial_other: "{{count}} filas no se deshicieron. Al deshacer de nuevo, solo se reintentan esas filas.", jobStatus: { pending: "Pendiente", running: "En ejecución", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index e58e368edd..f6ab33a146 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -611,6 +611,10 @@ const fr = { undoing: "Annulation…", undoConfirm: "Annuler cet import ? Les enregistrements créés seront supprimés et les enregistrements mis à jour seront rétablis à leurs valeurs précédentes.", reverted: "Annulé", + undoPartial: "{{count}} lignes n'ont pas été annulées. Annuler à nouveau ne réessaie que ces lignes.", + undoPartial_one: "{{count}} ligne n'a pas été annulée. Annuler à nouveau ne réessaie que cette ligne.", + undoPartial_many: "{{count}} de lignes n'ont pas été annulées. Annuler à nouveau ne réessaie que ces lignes.", + undoPartial_other: "{{count}} lignes n'ont pas été annulées. Annuler à nouveau ne réessaie que ces lignes.", jobStatus: { pending: "En attente", running: "En cours", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index b6e5b98b1b..725b7a830c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -596,6 +596,9 @@ const ja = { undoing: "取り消し中…", undoConfirm: "このインポートを取り消しますか?作成されたレコードは削除され、更新されたレコードは以前の値に戻されます。", reverted: "取り消し済み", + undoPartial: "{{count}} 行を取り消せませんでした。もう一度取り消すと、これらの行だけを再試行します。", + undoPartial_one: "{{count}} 行を取り消せませんでした。もう一度取り消すと、この行だけを再試行します。", + undoPartial_other: "{{count}} 行を取り消せませんでした。もう一度取り消すと、これらの行だけを再試行します。", jobStatus: { pending: "待機中", running: "実行中", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index b624b42d75..5c417832df 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -596,6 +596,9 @@ const ko = { undoing: "되돌리는 중…", undoConfirm: "이 가져오기를 되돌릴까요? 생성된 레코드는 삭제되고 업데이트된 레코드는 이전 값으로 복원됩니다.", reverted: "되돌림", + undoPartial: "{{count}}개 행을 되돌리지 못했습니다. 다시 되돌리면 이 행만 다시 시도합니다.", + undoPartial_one: "{{count}}개 행을 되돌리지 못했습니다. 다시 되돌리면 이 행만 다시 시도합니다.", + undoPartial_other: "{{count}}개 행을 되돌리지 못했습니다. 다시 되돌리면 이 행만 다시 시도합니다.", jobStatus: { pending: "대기 중", running: "실행 중", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 22c9f8f75b..5038b8661f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -611,6 +611,10 @@ const pt = { undoing: "Desfazendo…", undoConfirm: "Desfazer esta importação? Os registros criados serão excluídos e os registros atualizados voltarão aos valores anteriores.", reverted: "Desfeita", + undoPartial: "{{count}} linhas não foram desfeitas. Ao desfazer novamente, só essas linhas serão tentadas de novo.", + undoPartial_one: "{{count}} linha não foi desfeita. Ao desfazer novamente, só essa linha será tentada de novo.", + undoPartial_many: "{{count}} de linhas não foram desfeitas. Ao desfazer novamente, só essas linhas serão tentadas de novo.", + undoPartial_other: "{{count}} linhas não foram desfeitas. Ao desfazer novamente, só essas linhas serão tentadas de novo.", jobStatus: { pending: "Pendente", running: "Em execução", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index be0b3ba6f7..d2255226b6 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -629,6 +629,11 @@ const ru = { undoing: "Отмена…", undoConfirm: "Отменить этот импорт? Созданные им записи будут удалены, а обновлённые — восстановлены к прежним значениям.", reverted: "Отменён", + undoPartial: "Не удалось отменить {{count}} строк. Повторная отмена затронет только эти строки.", + undoPartial_one: "Не удалось отменить {{count}} строку. Повторная отмена затронет только эту строку.", + undoPartial_few: "Не удалось отменить {{count}} строки. Повторная отмена затронет только эти строки.", + undoPartial_many: "Не удалось отменить {{count}} строк. Повторная отмена затронет только эти строки.", + undoPartial_other: "Не удалось отменить {{count}} строки. Повторная отмена затронет только эти строки.", jobStatus: { pending: "Ожидает", running: "Выполняется", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 4a6b6af120..8e040f3fec 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -666,6 +666,9 @@ const zh = { undoImport: '撤销导入', undoing: '撤销中…', reverted: '已撤销', + undoPartial: '{{count}} 行未能撤销。再次撤销只会重试这些行。', + undoPartial_one: '{{count}} 行未能撤销。再次撤销只会重试这一行。', + undoPartial_other: '{{count}} 行未能撤销。再次撤销只会重试这些行。', undoConfirm: '撤销此次导入?它新建的记录将被删除,它更新的记录将恢复为之前的值。', }, bulk: { diff --git a/packages/plugin-grid/src/ImportWizard.tsx b/packages/plugin-grid/src/ImportWizard.tsx index 3cdd0e998b..42f326c539 100644 --- a/packages/plugin-grid/src/ImportWizard.tsx +++ b/packages/plugin-grid/src/ImportWizard.tsx @@ -33,6 +33,7 @@ import type { ImportJobResultsInfo, ImportJobStatus, ImportJobSummaryInfo, + ImportJobUndoResult, } from '@object-ui/types'; import { parseSpreadsheetFile, parseClipboardTable, inferColumnType, isTypeCompatible, @@ -196,6 +197,11 @@ export const IMPORT_DEFAULT_TRANSLATIONS: Record = { 'grid.import.undoing': 'Undoing…', 'grid.import.undoConfirm': 'Undo this import? Records it created will be deleted and records it updated will be restored to their previous values.', 'grid.import.reverted': 'Undone', + // A partial undo (the answer reads `success: false`): how many rows stayed, + // and that undoing again retries only them. Each row's refusal is listed below. + 'grid.import.undoPartial': '{{count}} rows were not undone. Undo again to retry only those rows.', + 'grid.import.undoPartial_one': '{{count}} row was not undone. Undo again to retry only that row.', + 'grid.import.undoPartial_other': '{{count}} rows were not undone. Undo again to retry only those rows.', 'grid.import.jobStatus.pending': 'Pending', 'grid.import.jobStatus.running': 'Running', 'grid.import.jobStatus.succeeded': 'Succeeded', @@ -1079,17 +1085,29 @@ async function readSettledImportJob( return null; } +/** What an Undo left for the list that ran it to show: the message of an undo + * that threw, or the answer of one that left rows unreverted. */ +type ImportUndoOutcome = + | { kind: 'error'; message: string } + | { kind: 'partial'; answer: ImportJobUndoResult }; + /** The Undo action, shared by the History list and the cancelled-result * screen: confirm (destructive + irreversible), roll the job back, tell the * object's mounted readers their data changed (the undo deleted and restored - * records, so an open list refetches in place), hand a failure to `onError` - * (cleared to `null` when an undo starts), then call `onSettled` so the - * caller re-reads the job and the Undo button gives way to "Undone". */ + * records, so an open list refetches in place), hand anything the user must + * read to `onOutcome` (cleared to `null` when an undo starts), then call + * `onSettled` so the caller re-reads the job and the Undo button gives way to + * "Undone". + * + * An answer of `success: false` is not a throw: the server reverted what it + * could, listed every row it did not revert in `failures`, and kept the job + * undoable for those rows only. So the readers still refetch, the outcome + * carries the answer, and the re-read job still offers Undo (objectui#13015). */ function useImportJobUndo( dataSource: unknown, objectName: string, t: (key: string, vars?: Record) => string, - onError: (message: string | null) => void, + onOutcome: (outcome: ImportUndoOutcome | null) => void, onSettled: () => void, ): { undoingId: string | null; undo: (jobId: string) => Promise } { // Job id currently being undone (disables its Undo button + confirm). @@ -1098,20 +1116,54 @@ function useImportJobUndo( const ds = dataSource as Partial | undefined; if (typeof ds?.undoImportJob !== 'function') return; if (typeof window !== 'undefined' && !window.confirm(t('grid.import.undoConfirm'))) return; - setUndoingId(jobId); onError(null); + setUndoingId(jobId); onOutcome(null); try { - await ds.undoImportJob(jobId); + const answer = await ds.undoImportJob(jobId); notifyDataChanged({ objectName }); + if (!answer.success) onOutcome({ kind: 'partial', answer }); } catch (err) { - onError(err instanceof Error ? err.message : String(err)); + onOutcome({ kind: 'error', message: err instanceof Error ? err.message : String(err) }); } finally { setUndoingId(null); onSettled(); } - }, [dataSource, objectName, t, onError, onSettled]); + }, [dataSource, objectName, t, onOutcome, onSettled]); return { undoingId, undo }; } +/** How many distinct refusal sentences a partial-undo notice lists before it + * counts the rest, as the result screen does for row errors. */ +const UNDO_REFUSAL_LIMIT = 10; + +/** What an Undo left, drawn where the list that ran it shows it. A partial + * undo says how many rows stayed and that undoing again retries only them, + * then lists each refusal sentence the answer carries — the app's own words, + * a hook's or a rule's. Rows refused with the same sentence share one line, + * since one hook typically refuses many rows alike; the count above covers + * them all. */ +const ImportUndoOutcomeNotice: React.FC<{ + outcome: ImportUndoOutcome; + t: (key: string, vars?: Record) => string; + errorTestId: string; + partialTestId: string; +}> = ({ outcome, t, errorTestId, partialTestId }) => { + if (outcome.kind === 'error') { + return

{outcome.message}

; + } + const refusals = [...new Set(outcome.answer.failures.map((failure) => failure.error))]; + return ( +
+

{t('grid.import.undoPartial', { count: outcome.answer.failed })}

+
    + {refusals.slice(0, UNDO_REFUSAL_LIMIT).map((refusal) =>
  • {refusal}
  • )} +
+ {refusals.length > UNDO_REFUSAL_LIMIT && ( +

{t('grid.import.moreErrors', { count: refusals.length - UNDO_REFUSAL_LIMIT })}

+ )} +
+ ); +}; + /** Build a CSV blob of failed rows for re-export: the original mapped columns * plus an `_error` column, so a user can fix and re-import just the failures. */ function buildFailedRowsCsv( @@ -1959,9 +2011,12 @@ const ImportHistoryPanel: React.FC<{ // Logical rollback: delete created records + restore updated ones. Confirms // first (destructive + irreversible), then reloads so the row flips to - // "reverted" and its Undo button disappears. + // "reverted" and its Undo button disappears — or, after a partial undo, keeps + // offering Undo for the rows that remain. What the undo left lives in its own + // slot: the reload clears `error` as it starts, and would wipe it there. + const [undoOutcome, setUndoOutcome] = useState(null); const reload = useCallback(() => { void load(); }, [load]); - const { undoingId, undo: handleUndo } = useImportJobUndo(dataSource, objectName, t, setError, reload); + const { undoingId, undo: handleUndo } = useImportJobUndo(dataSource, objectName, t, setUndoOutcome, reload); if (!supported) { return ( @@ -1980,6 +2035,14 @@ const ImportHistoryPanel: React.FC<{ {error &&

{error}

} + {undoOutcome && ( + + )} {jobs && jobs.length === 0 && !loading && (

{t('grid.import.historyEmpty')} @@ -2087,7 +2150,8 @@ export const ImportWizard: React.FC = ({ const jobRunRef = React.useRef(0); // The final read of the job the result screen shows, for its Undo button. const [finishedJob, setFinishedJob] = useState(null); - const [undoError, setUndoError] = useState(null); + // What the last Undo on the result screen left for the user to read. + const [undoOutcome, setUndoOutcome] = useState(null); // Small-file server dry-run pre-check — validates the exact payload without // writing, so the summary/error list reflect real coercion outcomes. const [validating, setValidating] = useState(false); @@ -2575,7 +2639,7 @@ export const ImportWizard: React.FC = ({ setImporting(true); setProgress(0); cancelPollRef.current = false; jobRunRef.current += 1; - setJobId(null); setAsyncCounts(null); setFinishedJob(null); setUndoError(null); + setJobId(null); setAsyncCounts(null); setFinishedJob(null); setUndoOutcome(null); const request = buildImportRequest(); @@ -2750,7 +2814,8 @@ export const ImportWizard: React.FC = ({ setResult(importResult); setImporting(false); }, [jobId, dataSource, asyncCounts, rows.length, publishJobOutcome]); - // Re-read the finished job after an Undo so its button gives way to "Undone". + // Re-read the finished job after an Undo so its button gives way to "Undone" + // (or, after a partial undo, keeps offering Undo for the rows that remain). // A read that lands after the wizard moved to another job is dropped. const rereadFinishedJob = useCallback(() => { const ds = dataSource as Partial | undefined; @@ -2761,12 +2826,12 @@ export const ImportWizard: React.FC = ({ () => { /* keep the last read */ }, ); }, [dataSource, finishedJob?.jobId]); - const { undoingId, undo: undoFinishedJob } = useImportJobUndo(dataSource, objectName, t, setUndoError, rereadFinishedJob); + const { undoingId, undo: undoFinishedJob } = useImportJobUndo(dataSource, objectName, t, setUndoOutcome, rereadFinishedJob); const canUndoJob = typeof (dataSource as Partial | undefined)?.undoImportJob === 'function'; const reset = useCallback(() => { cancelPollRef.current = false; - setFinishedJob(null); setUndoError(null); + setFinishedJob(null); setUndoOutcome(null); setStep('upload'); setHeaders([]); setRows([]); setMapping({}); setProgress(0); setResult(null); setCorrections({}); setSelectedTemplateId(null); setMappingName(null); setWriteMode(defaultWriteMode); setMatchFields([]); @@ -3036,8 +3101,13 @@ export const ImportWizard: React.FC = ({ {undoingId === finishedJob.jobId ? t('grid.import.undoing') : t('grid.import.undoImport')} )} - {result.cancelled && undoError && ( -

{undoError}

+ {result.cancelled && undoOutcome && ( + )} {result.degraded && !result.cancelled && (
{ vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +/** Open the History list over a data source whose list read answers `rows()`. */ +async function openHistory(undoImportJob: ReturnType, rows: () => ImportJobSummaryInfo[]) { + const ds = { listImportJobs: vi.fn(async () => rows()), undoImportJob }; + vi.stubGlobal('confirm', vi.fn(() => true)); + render( {}} />); + fireEvent.click(screen.getByTestId('import-history-toggle')); + const undo = await screen.findByTestId(`import-history-undo-${JOB_ID}`); + return { ds, undo }; +} + +describe('ImportWizard: an import undo that leaves rows unreverted says so (objectui#13015)', () => { + it('History list: a partial answer shows the count, each refusal sentence and the retry sentence, and Undo stays offered', async () => { + const undoImportJob = vi.fn(async () => PARTIAL); + // The server keeps the job undoable for the rows that remain. + const { ds, undo } = await openHistory(undoImportJob, () => [HISTORY_ROW]); + const changes: DataChange[] = []; + const unsubscribe = subscribeDataChanges((change) => { changes.push(change); }); + try { + fireEvent.click(undo); + + const notice = await screen.findByTestId('import-history-undo-partial'); + expect(notice).toHaveTextContent(PARTIAL_SENTENCE); + // Each refusal sentence the answer carries, once: two rows share one. + const items = within(notice).getAllByRole('listitem').map((li) => li.textContent); + expect(items).toEqual([CLOSED_CASE, APPROVAL_LOCK]); + // The reload ran and did not wipe the notice; the row still offers Undo. + await waitFor(() => expect(ds.listImportJobs).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId(`import-history-undo-${JOB_ID}`)).toBeEnabled()); + expect(screen.getByTestId('import-history-undo-partial')).toHaveTextContent(PARTIAL_SENTENCE); + expect(screen.queryByTestId(`import-history-reverted-${JOB_ID}`)).not.toBeInTheDocument(); + // A partial undo still reverted rows, so the object's readers refetch. + expect(changes).toEqual([{ objectName: 'contact' }]); + } finally { + unsubscribe(); + } + }); + + it('History list: undoing again clears the notice, and a full answer shows no refusal', async () => { + const undoImportJob = vi.fn() + .mockResolvedValueOnce(PARTIAL) + .mockResolvedValueOnce(FULL); + let reverted = false; + const { undo } = await openHistory(undoImportJob, () => [ + reverted ? { ...HISTORY_ROW, undoable: false, revertedAt: '2026-10-19T10:00:00.000Z' } : HISTORY_ROW, + ]); + fireEvent.click(undo); + await screen.findByTestId('import-history-undo-partial'); + await waitFor(() => expect(screen.getByTestId(`import-history-undo-${JOB_ID}`)).toBeEnabled()); + + reverted = true; + fireEvent.click(screen.getByTestId(`import-history-undo-${JOB_ID}`)); + + expect(await screen.findByTestId(`import-history-reverted-${JOB_ID}`)).toHaveTextContent('Undone'); + expect(undoImportJob).toHaveBeenCalledTimes(2); + expect(screen.queryByTestId('import-history-undo-partial')).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-history-undo-error')).not.toBeInTheDocument(); + expect(screen.queryByText(CLOSED_CASE)).not.toBeInTheDocument(); + }); + + it('History list: an undo that throws shows its message after the list reloads', async () => { + const undoImportJob = vi.fn(async () => { throw new Error('This import has already been undone'); }); + const { ds, undo } = await openHistory(undoImportJob, () => [HISTORY_ROW]); + + fireEvent.click(undo); + + await waitFor(() => expect(ds.listImportJobs).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId(`import-history-undo-${JOB_ID}`)).toBeEnabled()); + expect(screen.getByTestId('import-history-undo-error')).toHaveTextContent('This import has already been undone'); + expect(screen.queryByTestId('import-history-undo-partial')).not.toBeInTheDocument(); + }); + + it('cancelled-result screen: a partial answer shows the count, each refusal sentence and the retry sentence, and Undo stays offered', async () => { + const running: ImportJobProgressInfo = { + jobId: JOB_ID, object: 'contact', status: 'running', total: 12, processed: 6, created: 6, + updated: 0, skipped: 0, errors: 0, percentComplete: 50, undoable: false, + }; + const cancelled: ImportJobProgressInfo = { + ...running, status: 'cancelled', processed: 12, created: 10, updated: 2, percentComplete: 100, undoable: true, + }; + let cancelledYet = false; + const ds = { + createImportJob: vi.fn(async () => ({ jobId: JOB_ID, object: 'contact', status: 'pending', total: 12 })), + // After a partial undo the job reads exactly as before: undoable, not reverted. + getImportJobProgress: vi.fn(async () => (cancelledYet ? cancelled : running)), + // Read only by a job that completes; the wizard needs it to offer a background job. + getImportJobResults: vi.fn(async () => ({ ...cancelled, status: 'succeeded' as const, results: [], resultsTruncated: false })), + cancelImportJob: vi.fn(async () => { cancelledYet = true; }), + undoImportJob: vi.fn(async () => PARTIAL), + }; + vi.stubGlobal('confirm', vi.fn(() => true)); + render( {}} />); + const evt = new Event('paste', { bubbles: true, cancelable: true }) as Event & { + clipboardData: { getData: (type: string) => string }; + }; + evt.clipboardData = { getData: (type: string) => (type === 'text/plain' ? 'Name\nAda\nGrace' : '') }; + act(() => { window.dispatchEvent(evt); }); + const next = await screen.findByTestId('import-next-btn'); + await waitFor(() => expect(next).toBeEnabled()); + fireEvent.click(next); + const background = await screen.findByTestId('import-opt-background'); + fireEvent.click(within(background).getByRole('checkbox')); + fireEvent.click(screen.getByTestId('import-run-btn')); + fireEvent.click(await screen.findByTestId('import-cancel-async', undefined, WAIT)); + + fireEvent.click(await screen.findByTestId('import-cancelled-undo', undefined, WAIT)); + + const notice = await screen.findByTestId('import-cancelled-undo-partial', undefined, WAIT); + expect(notice).toHaveTextContent(PARTIAL_SENTENCE); + expect(within(notice).getAllByRole('listitem').map((li) => li.textContent)).toEqual([CLOSED_CASE, APPROVAL_LOCK]); + expect(ds.undoImportJob).toHaveBeenCalledWith(JOB_ID); + // The re-read job is still undoable: Undo stays, "Undone" does not appear. + await waitFor(() => expect(screen.getByTestId('import-cancelled-undo')).toBeEnabled(), WAIT); + expect(screen.queryByTestId('import-cancelled-reverted')).not.toBeInTheDocument(); + expect(screen.queryByTestId('import-cancelled-undo-error')).not.toBeInTheDocument(); + }, 20000); +}); diff --git a/packages/types/package.json b/packages/types/package.json index 9b708563e4..8f47dcd3f1 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -93,7 +93,7 @@ "directory": "packages/types" }, "dependencies": { - "@objectstack/spec": "^18.0.0-next.2", + "@objectstack/spec": "^18.0.0-next.5", "zod": "^4.6.1" }, "devDependencies": { diff --git a/packages/types/src/data.ts b/packages/types/src/data.ts index 3b39964030..3ef7934b52 100644 --- a/packages/types/src/data.ts +++ b/packages/types/src/data.ts @@ -24,6 +24,7 @@ import type { ImportJobStatus, ImportRowResult, ImportWriteMode, + UndoImportJobResponse, } from '@objectstack/spec/api'; import type { EngineAggregateOptions, FilterArray } from '@objectstack/spec/data'; import type { ValidationError } from '@objectstack/spec/kernel'; @@ -972,7 +973,8 @@ export interface DataSource { * is omitted or the job reports `undoable: false`. * * @param jobId - The job identifier to undo. - * @returns Counts of deleted / restored / failed reversal operations. + * @returns Counts of deleted / restored / failed reversal operations, and + * one `failures` entry per row the undo did not revert. */ undoImportJob?(jobId: string): Promise; } @@ -1174,21 +1176,13 @@ export interface ImportJobSummaryInfo { } /** - * Outcome of {@link DataSource.undoImportJob} — a logical rollback. Mirrors the - * server's `UndoImportJobResponse`. + * Outcome of {@link DataSource.undoImportJob} — a logical rollback. Bound to + * the server's `UndoImportJobResponse` by reference (objectui#13015): the + * hand-written copy it replaces lacked `failures`, so the wizard never read + * the rows a partial undo left unreverted. `success` is false when any row is + * listed in `failures`; the job then stays undoable for exactly those rows. */ -export interface ImportJobUndoResult { - /** Whether the undo completed. */ - success: boolean; - jobId: string; - object: string; - /** Created records deleted. */ - deleted: number; - /** Updated records restored to their pre-import values. */ - restored: number; - /** Reversal operations that failed. */ - failed: number; -} +export type ImportJobUndoResult = UndoImportJobResponse; /** * Filters + pagination for {@link DataSource.listImportJobs}.