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
14 changes: 14 additions & 0 deletions .changeset/11082-detail-undo-capture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/core': minor
'@object-ui/app-shell': patch
---

fix(app-shell,core): the record page's Undo captures only what the record carries, through core's one capture rule (objectui#11082)

**Clause-②: yes** — `captureUpdateUndoData` becomes a named export of `@object-ui/core`, a new published symbol. Its answers do not change. No declared type or accepted key moves.

- **`@object-ui/core` exports `captureUpdateUndoData(writtenFields, rowRecord)`.** It is the rule `ActionRunner` already used for an `undoable` update's Undo snapshot (objectui#10404): it answers each written field's stored value when the row carries every written field, and `undefined` when any one is missing, so the caller offers no Undo at all. A `null` the row carries is captured as `null`. "Carries" means an own key whose value is not `undefined`.
- **The record page's own `api` handler calls it.** `RecordDetailView` read each written field's prior value off the loaded page record as `pageRecord[k] ?? null`. The page record is read with no column list, but the server removes every field the reader may not read, so an undoable action that writes such a field found it absent and captured `null`. Pressing Undo then wrote `null` over the stored value. Now that action offers no Undo button, logs a warning naming the missing fields, and still performs the write. A carried value, `null` included, is restored as before.
- **The console runtime's `api` handler calls it too**, in place of its inline copy of the same rule. Its behaviour does not change.

Pinned in `packages/app-shell/src/views/RecordDetailView.undoCapture-11082.test.tsx` and `packages/core/src/actions/__tests__/captureUpdateUndoData.export-11082.test.ts`.
28 changes: 14 additions & 14 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type {
ResultDialogHandler,
ToastHandler,
} from '@object-ui/core';
import { actionErrorDetail, isRecordScopedAction, resolveRecordIdParamSeed } from '@object-ui/core';
import { actionErrorDetail, captureUpdateUndoData, isRecordScopedAction, resolveRecordIdParamSeed } from '@object-ui/core';
import { useActionModal } from './useActionModal.js';
import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog.js';
import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog.js';
Expand Down Expand Up @@ -527,33 +527,33 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
// fields from the row record so the success toast can offer "Undo".
//
// ⛔ A field the row does not CARRY is never captured as `null`
// (objectui#10404) — the rule `captureUpdateUndoData` in
// `@object-ui/core`'s `ActionRunner` states, applied to this handler's
// written set (`params` plus `bodyExtra`). A list row is projected by
// `$select`, so a written field no column shows is absent while the
// server holds a real value; recording `null` made Undo overwrite it. A
// `null` the row carries is a real empty value and is captured as one.
// When any written field is not carried there is no Undo at all: the
// success toast then has no Undo button, and the warning names the cause.
// (objectui#10404). The snapshot is `@object-ui/core`'s
// `captureUpdateUndoData`, called on this handler's written set
// (`params` plus `bodyExtra`), so the rule lives in one place
// (objectui#11082). A list row is projected by `$select`, so a written
// field no column shows is absent while the server holds a real value;
// recording `null` made Undo overwrite it. A `null` the row carries is a
// real empty value and is captured as one. When any written field is not
// carried there is no Undo at all: the success toast then has no Undo
// button, and the warning names the cause.
let undo: ActionResult['undo'];
if (action.undoable && obj && recId && rowRecord && Object.keys(fields).length > 0
&& typeof dataSource?.update === 'function') {
const written = Object.keys(fields);
const missing = written.filter(
(k) => !Object.prototype.hasOwnProperty.call(rowRecord, k) || rowRecord[k] === undefined,
);
if (missing.length === 0) {
const undoData = captureUpdateUndoData(written, rowRecord);
if (undoData) {
undo = {
id: `undo-${obj}-${recId}-${Date.now()}`,
type: 'update',
objectName: obj,
recordId: String(recId),
timestamp: Date.now(),
description: action.label || `Undo ${obj}`,
undoData: Object.fromEntries(written.map((k) => [k, rowRecord[k]])),
undoData,
redoData: { ...fields },
};
} else {
const missing = written.filter((k) => captureUpdateUndoData([k], rowRecord) === undefined);
console.warn(
'[useConsoleActionRuntime] `undoable` action succeeded but offers no Undo: the row it ran on '
+ 'does not carry every field it wrote, so their prior values are unknown and an Undo would '
Expand Down
51 changes: 38 additions & 13 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useAuth, createAuthenticatedFetch } from '@object-ui/auth';
import { usePermissions } from '@object-ui/permissions';
import { useDisplayLocale } from '@object-ui/i18n';
import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged, useRowPredicate } from '@object-ui/react';
import { buildExpandFields, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields } from '@object-ui/core';
import { buildExpandFields, captureUpdateUndoData, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields } from '@object-ui/core';
import { toast } from 'sonner';
import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration';
import { Database, ChevronLeft } from 'lucide-react';
Expand Down Expand Up @@ -971,21 +971,46 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
// values from the loaded record so the success toast can offer Undo.
// Only this page's record has its prior values loaded, so child-row
// updates skip undo capture.
//
// ⛔ A field the record does not CARRY is never captured as `null`
// (objectui#11082, the objectui#10404 rule). The snapshot is
// `@object-ui/core`'s `captureUpdateUndoData`, the one capture rule
// the runner and the console runtime also call. The page record is
// read with no column list, but the server deletes every field the
// reader may not read, so an action that writes such a field finds
// it absent here; `?? null` then made Undo write `null` over its
// stored value. A `null` the record carries is a real empty value
// and is captured as one. When any written field is not carried
// there is no Undo at all: the success toast then has no Undo button.
let undoMissing: string[] | undefined;
if (action.undoable && isThisRecord && pageRecord) {
const undoData: Record<string, unknown> = {};
for (const k of Object.keys(params)) undoData[k] = (pageRecord as any)[k] ?? null;
undo = {
id: `undo-${targetObject}-${targetId}-${Date.now()}`,
type: 'update',
objectName: targetObject,
recordId: String(targetId),
timestamp: Date.now(),
description: action.label || `Undo ${targetObject}`,
undoData,
redoData: { ...params },
};
const record = pageRecord as Record<string, unknown>;
const written = Object.keys(params);
const undoData = captureUpdateUndoData(written, record);
if (undoData) {
undo = {
id: `undo-${targetObject}-${targetId}-${Date.now()}`,
type: 'update',
objectName: targetObject,
recordId: String(targetId),
timestamp: Date.now(),
description: action.label || `Undo ${targetObject}`,
undoData,
redoData: { ...params },
};
} else {
undoMissing = written.filter((k) => captureUpdateUndoData([k], record) === undefined);
}
}
await dataSource.update(targetObject, String(targetId), params);
if (undoMissing) {
console.warn(
'[RecordDetailView] `undoable` action succeeded but offers no Undo: the record it ran on '
+ 'does not carry every field it wrote, so their prior values are unknown and an Undo would '
+ 'overwrite stored data. The record page carries a written field when the principal may read it.',
{ action: action.name, missing: undoMissing },
);
}
}
break;
}
Expand Down
Loading
Loading