From 9188bd3bee7db93c253e5dddd56169c9fc2c0b22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:48:32 +0000 Subject: [PATCH 1/3] fix(core): a row action's templates read the row as `record`, like the header's The runner evaluated `confirmText` in its own context, which binds `record` on the record header and nothing on a list row, so the same `${record.display_name}` read filled from the header and raw from a row. The runner now binds a dispatch's `params._rowRecord` as `record` for that dispatch's templates, and hands the param-collection handler an `interpolate` that fills the dialog's `description` and `helpText` holes in the same scope, after localization. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../src/hooks/useConsoleActionRuntime.tsx | 12 ++++- .../app-shell/src/views/ActionParamDialog.tsx | 18 ++++++- .../app-shell/src/views/RecordDetailView.tsx | 8 ++- packages/core/src/actions/ActionRunner.ts | 53 ++++++++++++++++++- 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index b7c7d5f63a..422d7ba286 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -237,7 +237,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // whole function under the compiler — every other read below is a declared // `ActionDef` field, and the one that was not is the reason objectui#4282 // backed the narrowing out. - const paramCollectionHandler = useCallback((params: ActionParamDef[], action?: ConsoleActionDispatch) => { + const paramCollectionHandler = useCallback((params: ActionParamDef[], action?: ConsoleActionDispatch, interpolate?: (text: string) => string) => { return new Promise | null>((resolve) => { void (async () => { // ⭐ Ask the store for the object type BEFORE resolving (`6cc910b6d`). // `ensureType` is idempotent and answers from cache in a microtask once @@ -302,7 +302,14 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // // The notice arrives already localized (bar-authored chrome, resolved // through the normal locale bundle), so it is concatenated verbatim. - const declaredDescription = actionDescription(objForI18n, action?.name, action?.description); + // The declared description's `${...}` holes are filled here, in the + // runner's template scope (the row is `record` on a row dispatch — + // objectui#12967), and BEFORE the notice joins it: the notice carries + // approver names, which are data, never a template. + const localizedDescription = actionDescription(objForI18n, action?.name, action?.description); + const declaredDescription = interpolate && localizedDescription + ? interpolate(localizedDescription) + : localizedDescription; const overrideNotice = typeof action?.overrideNotice === 'string' && action.overrideNotice ? action.overrideNotice : undefined; @@ -320,6 +327,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons description: overrideNotice ? [overrideNotice, declaredDescription].filter(Boolean).join('\n\n') : declaredDescription, + interpolate, resolve, }); })(); }); diff --git a/packages/app-shell/src/views/ActionParamDialog.tsx b/packages/app-shell/src/views/ActionParamDialog.tsx index 80ce5490cd..89f103a08d 100644 --- a/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.tsx @@ -80,6 +80,15 @@ export interface ParamDialogState { * so the dialog reads as the task, not a generic param prompt. */ title?: string; description?: string; + /** + * Fills the `${...}` holes of each param's `helpText` — the runner's + * `ParamCollectionHandler` `interpolate` argument, passed through. It runs on + * the text the user reads (after an inline per-locale map is picked), in the + * template scope the runner reads `confirmText` in, so a `${record.*}` hole + * reads the same from a record header and from a list row (objectui#12967). + * Absent, `helpText` prints as authored. + */ + interpolate?: (text: string) => string; resolve?: (value: Record | null) => void; } @@ -96,6 +105,11 @@ interface ActionParamDialogProps { */ const NARROW_PARAM_MAX_COUNT = 8; +/** Apply the dialog's `interpolate` to a picked string; anything else as-is. */ +function fillHoles(text: T, interpolate: ParamDialogState['interpolate']): T | string { + return interpolate && typeof text === 'string' ? interpolate(text) : text; +} + /** * Filter action params by their optional `visible` predicate, evaluated on the * canonical entry (`evalRowPredicate`) against the host predicate scope @@ -532,7 +546,9 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp const param = { ...rawParam, label: pickLocalized(rawParam.label, language), - helpText: rawParam.helpText != null ? pickLocalized(rawParam.helpText, language) : rawParam.helpText, + helpText: rawParam.helpText != null + ? fillHoles(pickLocalized(rawParam.helpText, language), state.interpolate) + : rawParam.helpText, options: rawParam.options?.map((o) => ({ ...o, label: pickLocalized(o.label, language) })), }; // The refusal comes FIRST — ahead of `paramToField()`, which would diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index c6680e738e..0dc3b47be6 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -955,7 +955,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // not read `overrideNotice` itself — it takes the envelope so the two // handlers on this seam cannot drift apart from each other, and so a reader // added here later has the key declared rather than reaching for a cast. - const paramCollectionHandler = useCallback((params: ActionParamDef[], action?: ConsoleActionDispatch) => { + const paramCollectionHandler = useCallback((params: ActionParamDef[], action?: ConsoleActionDispatch, interpolate?: (text: string) => string) => { return new Promise | null>((resolve) => { // Related-list row actions retarget a CHILD object (e.g. sys_member rows // on an org record page) and stash the clicked row under @@ -990,6 +990,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ? p.options.map((o: any) => ({ ...o, label: actionParamOptionLabel(objForI18n, action?.name, p.name, o.value, o.label) })) : p.options, })); + const declaredDescription = actionDescription(objForI18n, action?.name, action?.description); setParamState({ open: true, params: localized, @@ -1007,7 +1008,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // handlers drift as a pair (objectui#5610). Reads exactly one key, like // `description` below. title: action?.label, - description: actionDescription(objForI18n, action?.name, action?.description), + // `${...}` holes in the description and each `helpText` are filled in + // the runner's template scope, as `confirmText` is (objectui#12967). + description: interpolate && declaredDescription ? interpolate(declaredDescription) : declaredDescription, + interpolate, resolve, }); }); diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index acafeae2d6..edf7176c00 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -632,10 +632,19 @@ export type NavigationHandler = (url: string, options?: { * Param collection handler — consumers provide to show a dialog * for collecting ActionParam values before action execution. * Returns collected values, or null if cancelled. + * + * `interpolate` fills the `${...}` holes of the dialog's authored copy (the + * action's `description`, each param's `helpText`) in the same template scope + * the runner reads `confirmText` in — for a row dispatch, the row is `record` + * (see `templateEvaluatorFor`). The runner cannot fill them itself before the + * call: the handler localizes that copy AFTER it receives it (a translation + * bundle entry or an inline per-locale map), so the hole has to be filled on + * the text the user will actually read (objectui#12967). */ export type ParamCollectionHandler = ( params: ActionParamDef[], action?: ActionDef, + interpolate?: (text: string) => string, ) => Promise | null>; /** @@ -1340,9 +1349,15 @@ export class ActionRunner { // structured `confirm.message` read that used to OUTRANK it is retired // (objectui#4314): it had zero producers and no bundle key, so a dialog // authored that way silently lost localization. + // + // Read through `templateEvaluatorFor`, not the runner's own evaluator: + // a row dispatch binds no `record` in the runner's context, so a + // `${record.*}` hole the record header fills was shown raw from a list + // row (objectui#12967). + const templates = this.templateEvaluatorFor(action); if (action.confirmText) { const confirmed = await this.confirmHandler( - this.evaluator.evaluate(action.confirmText) as string, + templates.evaluate(action.confirmText) as string, ); if (!confirmed) { return { success: false, error: 'Action cancelled by user' }; @@ -1360,7 +1375,11 @@ export class ActionRunner { : (Array.isArray(action.params) ? (action.params as unknown as ActionParamDef[]) : undefined); if (paramDefs && paramDefs.length > 0) { if (this.paramCollectionHandler) { - const collected = await this.paramCollectionHandler(paramDefs, action); + const collected = await this.paramCollectionHandler( + paramDefs, + action, + (text: string) => String(templates.evaluate(text) ?? ''), + ); if (collected === null) { return { success: false, error: 'Action cancelled by user (params)' }; } @@ -2540,6 +2559,36 @@ export class ActionRunner { return ctx; } + /** + * The evaluator a dispatch's authored copy is read through: `confirmText`, + * and the param dialog's `description` / `helpText` via the handler's + * `interpolate` argument. + * + * One rule for every door (objectui#12967): the record the action runs + * against is `record`. The record header binds it in the runner's context + * (`RecordDetailView`'s `ActionProvider`). A row surface does not — it + * stashes the clicked row under `params._rowRecord` (ObjectGrid's row + * actions, a related list's row actions, `DeclaredActionsBar`, a page + * header, `action:button`'s update write) and fires through the shared + * runner, whose context names the page, not the row. So when the dispatch + * carries a row, the row is bound as `record` for this dispatch's templates, + * and it wins over a page-level `record` (a related-list row on a record page + * is the child, not the page's record). Nothing else in the scope changes, + * and the runner's own context is not touched. + * + * A hole naming a field the record does not carry degrades the way the + * header's always has: the evaluator reads `undefined` and the hole prints + * empty. + */ + private templateEvaluatorFor(action: ActionDef): ExpressionEvaluator { + const params = action.params; + const row = params && typeof params === 'object' && !Array.isArray(params) + ? (params as Record)._rowRecord + : undefined; + if (!row || typeof row !== 'object' || Array.isArray(row)) return this.evaluator; + return new ExpressionEvaluator({ ...this.context, record: row }); + } + updateContext(newContext: Partial): void { this.context = withIdentityAlias({ ...this.context, ...newContext }); // Feed the merged (re-aliased) context so a refreshed `user` also From 4f3c2db86cabbbc6cd1a212a22ef9b07d2cc7d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:00:46 +0000 Subject: [PATCH 2/3] test: pin a row action's record holes on both doors, plus the changeset The same action's `confirmText` reads filled from a record header and from a real ObjectGrid row; the param dialog's `description` and `helpText` are filled through the runner's `interpolate`; a missing field degrades the same way on both doors; a toast's `${result.*}` is unchanged. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/12967-row-action-record-hole.md | 14 +++ ...nsoleActionRuntime.overrideNotice.test.tsx | 28 ++++- ...tionParamDialog.recordHoles-12967.test.tsx | 71 +++++++++++ ...RecordDetailView.paramDialogTitle.test.tsx | 21 +++- ...ionRunner.rowRecordTemplates-12967.test.ts | 116 ++++++++++++++++++ .../rowActionRecordHole-12967.test.tsx | 113 +++++++++++++++++ 6 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 .changeset/12967-row-action-record-hole.md create mode 100644 packages/app-shell/src/views/ActionParamDialog.recordHoles-12967.test.tsx create mode 100644 packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts create mode 100644 packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx diff --git a/.changeset/12967-row-action-record-hole.md b/.changeset/12967-row-action-record-hole.md new file mode 100644 index 0000000000..08572c660f --- /dev/null +++ b/.changeset/12967-row-action-record-hole.md @@ -0,0 +1,14 @@ +--- +'@object-ui/core': minor +'@object-ui/app-shell': patch +--- + +A row action's templates read the row as `record`, the way the record header's do (objectui#12967). + +The same action's `confirmText` with `${record.display_name}` read filled from a record header and as template source from a list row: the header binds `record` in the action runner's context, while a row surface (a grid row, a related-list row, `DeclaredActionsBar`, a page header, `action:button`'s update write) stashes the row under `params._rowRecord` and binds nothing. The runner now binds a dispatch's `_rowRecord` as `record` for that dispatch's templates, the row winning over a page-level `record`. A hole naming a field the record does not carry prints empty on both doors, as it always has on the header. + +The param dialog's `description` and each param's `helpText` are filled in the same scope, on both doors. Before, neither door filled them. The runner hands the param-collection handler an `interpolate` function; the console's two handlers apply it to the localized description (never to the admin-override notice, whose approver names are data) and pass it to `ActionParamDialog`, which applies it to the `helpText` the user reads, after an inline per-locale map is picked. + +A toast's `${result.*}` is unchanged. No action key is added. + +**Clause-②: yes (widening).** The exported `ParamCollectionHandler` type in `@object-ui/core` gains an optional third parameter, `interpolate?: (text: string) => string`. A handler that ignores it reads exactly as before. `ActionParamDialog`'s state type gains one optional member, `interpolate`; that type is not in `@object-ui/app-shell`'s barrel. diff --git a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx index 3e7181d26e..888e724eaf 100644 --- a/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx +++ b/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.overrideNotice.test.tsx @@ -128,12 +128,12 @@ function Harness({ onReady }: { onReady: (fn: any) => void }) { * state the dialog received. The promise stays pending (nothing resolves it) — * that is the real shape too: nothing is POSTed until the dialog's own Confirm. */ -async function collectParams(dispatch: Record) { +async function collectParams(dispatch: Record, interpolate?: (text: string) => string) { paramDialogState = null; let collect: any; render( { collect = fn; }} />); await act(async () => { - void collect(PARAMS, dispatch as any); + void collect(PARAMS, dispatch as any, interpolate); await Promise.resolve(); }); } @@ -225,3 +225,27 @@ describe('useConsoleActionRuntime — the override notice in the param dialog (o expect(paramDialogState.description).toBe('Declared copy.'); }); }); + +describe('useConsoleActionRuntime — the param dialog fills the runner\'s template holes (objectui#12967)', () => { + /** Stands in for the runner's evaluator over this dispatch's `record` (a list row). */ + const interpolate = (text: string) => text.replace(/\$\{record\.display_name\}/g, 'Acme'); + + it('fills a record hole in the localized description and hands `interpolate` to the dialog for `helpText`', async () => { + bundleDescription = 'Pausing ${record.display_name} (translated)'; + await collectParams({ name: 'pause', label: 'Pause', description: 'Pausing ${record.display_name}' }, interpolate); + // Filled on the text the user reads — the bundle's, not the literal. + expect(paramDialogState.description).toBe('Pausing Acme (translated)'); + expect(paramDialogState.interpolate).toBe(interpolate); + }); + + it('never reads the override notice as a template: approver names are data', async () => { + const notice = 'Bypasses ${record.display_name} — an approver name, not a hole.'; + await collectParams({ + name: 'approval_reject', + label: 'Override Reject', + description: 'Rejecting ${record.display_name}', + overrideNotice: notice, + }, interpolate); + expect(paramDialogState.description).toBe(`${notice}\n\nRejecting Acme`); + }); +}); diff --git a/packages/app-shell/src/views/ActionParamDialog.recordHoles-12967.test.tsx b/packages/app-shell/src/views/ActionParamDialog.recordHoles-12967.test.tsx new file mode 100644 index 0000000000..6cc73b6a67 --- /dev/null +++ b/packages/app-shell/src/views/ActionParamDialog.recordHoles-12967.test.tsx @@ -0,0 +1,71 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12967 — the param dialog fills a `${record.*}` hole in a param's + * `helpText` through the `interpolate` its state carries. + * + * The runner hands the param-collection handler an `interpolate` bound to the + * template scope it reads `confirmText` in (the row is `record` on a row + * dispatch); both handlers (`useConsoleActionRuntime`, `RecordDetailView`) put + * it on the dialog state. `helpText` may be an inline per-locale map that only + * this dialog resolves, so the hole is filled here, on the picked string the + * user reads. Without `interpolate` the text prints as authored. + * + * The real `ActionParamDialog` renders each case: what is asserted is the line + * under the control. + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog'; + +afterEach(() => cleanup()); + +/** Stands in for the runner's evaluator over `{ record: { display_name: 'Acme' } }`. */ +const interpolate = (text: string) => text.replace(/\$\{record\.display_name\}/g, 'Acme'); + +function open(state: Partial) { + render( + {}} + />, + ); +} + +describe('ActionParamDialog — a record hole in `helpText` is filled (objectui#12967)', () => { + it('fills a string `helpText` through `interpolate`', async () => { + open({ + interpolate, + params: [{ name: 'reason', label: 'Reason', type: 'text', helpText: 'Why pause ${record.display_name}?' }], + }); + expect(await screen.findByText('Why pause Acme?')).toBeTruthy(); + expect(screen.queryByText(/\$\{record\./)).toBeNull(); + }); + + it('fills the string picked from an inline per-locale `helpText` map', async () => { + open({ + interpolate, + params: [{ + name: 'reason', + label: 'Reason', + type: 'text', + helpText: { en: 'Why pause ${record.display_name}?' } as never, + }], + }); + expect(await screen.findByText('Why pause Acme?')).toBeTruthy(); + }); + + it('CONTROL: without `interpolate` the `helpText` prints as authored', async () => { + open({ + params: [{ name: 'reason', label: 'Reason', type: 'text', helpText: 'Why pause ${record.display_name}?' }], + }); + expect(await screen.findByText('Why pause ${record.display_name}?')).toBeTruthy(); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.paramDialogTitle.test.tsx b/packages/app-shell/src/views/RecordDetailView.paramDialogTitle.test.tsx index 0494b46237..34185250c2 100644 --- a/packages/app-shell/src/views/RecordDetailView.paramDialogTitle.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.paramDialogTitle.test.tsx @@ -188,7 +188,7 @@ function recordPageParamCollection() { * does and read back the state the dialog received. The promise stays pending — * that is the real shape too: nothing is POSTed until the dialog's own Confirm. */ -async function collectParams(dispatch: Record) { +async function collectParams(dispatch: Record, interpolate?: (text: string) => string) { render( @@ -207,7 +207,7 @@ async function collectParams(dispatch: Record) { const collect = recordPageParamCollection()!; paramDialogState = null; await act(async () => { - void collect(PARAMS, dispatch as any); + void collect(PARAMS, dispatch as any, interpolate); await Promise.resolve(); }); } @@ -283,3 +283,20 @@ describe('RecordDetailView — the param dialog titles itself from `label` alone expect(paramDialogState.description).toBe('A rejection is final for every approver.'); }); }); + +describe('RecordDetailView — the param dialog fills the runner\'s template holes (objectui#12967)', () => { + /** Stands in for the runner's evaluator over this dispatch's `record`. */ + const interpolate = (text: string) => text.replace(/\$\{record\.name\}/g, 'Intro call'); + + it('fills a record hole in the description and hands `interpolate` to the dialog for `helpText`', async () => { + await collectParams({ name: 'log_call', label: 'Log', description: 'Logging ${record.name}' }, interpolate); + expect(paramDialogState.description).toBe('Logging Intro call'); + expect(paramDialogState.interpolate).toBe(interpolate); + }); + + it('CONTROL: a runner that supplies no `interpolate` leaves the description as authored', async () => { + await collectParams({ name: 'log_call', label: 'Log', description: 'Logging ${record.name}' }); + expect(paramDialogState.description).toBe('Logging ${record.name}'); + expect(paramDialogState.interpolate).toBeUndefined(); + }); +}); diff --git a/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts b/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts new file mode 100644 index 0000000000..05eb83f6d4 --- /dev/null +++ b/packages/core/src/actions/__tests__/ActionRunner.rowRecordTemplates-12967.test.ts @@ -0,0 +1,116 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12967 — a row action's templates read the row as `record`, the way + * the record header's do. + * + * The record header binds `record` in the runner's context (`RecordDetailView`'s + * `ActionProvider`). A list row binds nothing there: `ObjectGrid` dispatches a + * row action as `{ ...def, params: { _rowRecord: row } }` through the shared + * runner. The runner read `confirmText` in its own context only, so one + * action's `${record.display_name}` read filled from the header and raw from a + * row. The param dialog's `description` / `helpText` were never filled on + * either door. + * + * Each pin drives the SAME action through both doors: + * - header — a runner whose context carries `record`, dispatch without a row; + * - row — a runner whose context carries no `record` (a list page), the + * dispatch carrying the row exactly as `ObjectGrid` stashes it. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ActionRunner, type ActionDef } from '../ActionRunner'; + +const ROW = { id: 'env_1', display_name: 'Acme' }; + +const PAUSE: ActionDef = { + name: 'pause_environment', + label: 'Pause', + confirmText: 'Pause ${record.display_name}?', + onClick: () => {}, +}; + +/** The record header: `record` bound in the runner's context. */ +const headerDoor = () => new ActionRunner({ objectName: 'sys_environment', record: { ...ROW } }); +/** A list page: the runner's context names the object, not a record. */ +const listDoor = () => new ActionRunner({ objectName: 'sys_environment' }); +/** `ObjectGrid`'s row dispatch shape: the row under `params._rowRecord`. */ +const asRow = (def: ActionDef, row: Record = ROW): ActionDef => ({ ...def, params: { _rowRecord: row } }); + +async function confirmTextFor(runner: ActionRunner, def: ActionDef): Promise { + const confirm = vi.fn().mockResolvedValue(false); + runner.setConfirmHandler(confirm); + const result = await runner.execute(def); + expect(result).toMatchObject({ success: false, error: 'Action cancelled by user' }); + expect(confirm).toHaveBeenCalledTimes(1); + return confirm.mock.calls[0][0] as string; +} + +describe('ActionRunner — a row action reads the row as `record` (objectui#12967)', () => { + it('(a) the same `confirmText` reads filled from the record header and from a list row', async () => { + expect(await confirmTextFor(headerDoor(), PAUSE)).toBe('Pause Acme?'); + expect(await confirmTextFor(listDoor(), asRow(PAUSE))).toBe('Pause Acme?'); + }); + + it('the row wins over a page-level `record` (a related-list row on a record page)', async () => { + const onParentPage = new ActionRunner({ objectName: 'sys_org', record: { id: 'org_1', display_name: 'Parent' } }); + expect(await confirmTextFor(onParentPage, asRow(PAUSE))).toBe('Pause Acme?'); + }); + + it('(b) the param dialog is handed an `interpolate` that fills a record hole on both doors', async () => { + const def: ActionDef = { + ...PAUSE, + confirmText: undefined, + description: 'Pausing ${record.display_name}', + actionParams: [{ name: 'reason', label: 'Reason', type: 'text', helpText: 'Why pause ${record.display_name}?' }], + }; + const readThrough = async (runner: ActionRunner, dispatched: ActionDef) => { + let filled: string[] = []; + runner.setParamCollectionHandler(async (params, action, interpolate) => { + expect(interpolate).toBeTypeOf('function'); + filled = [String(action?.description), String(params[0].helpText)].map((text) => interpolate!(text)); + return null; + }); + await runner.execute(dispatched); + return filled; + }; + const expected = ['Pausing Acme', 'Why pause Acme?']; + expect(await readThrough(headerDoor(), def)).toEqual(expected); + expect(await readThrough(listDoor(), asRow(def))).toEqual(expected); + }); + + it('(c) CONTROL: a hole naming a field the record lacks degrades the same way on both doors', async () => { + const missing: ActionDef = { ...PAUSE, confirmText: 'Pause ${record.no_such_field}?' }; + const header = await confirmTextFor(headerDoor(), missing); + const row = await confirmTextFor(listDoor(), asRow(missing)); + // The header's degradation, measured: the member reads `undefined` and the + // hole prints empty. The row now matches it — never the raw source. + expect(header).toBe('Pause ?'); + expect(row).toBe(header); + }); + + it('CONTROL: a dispatch without a row reads the runner context unchanged', async () => { + // A list page firing a row-less action: `record` is unbound, so the hole + // stays as authored — the evaluator's existing answer for an unbound root. + expect(await confirmTextFor(listDoor(), PAUSE)).toBe('Pause ${record.display_name}?'); + }); + + it('(d) CONTROL: a toast\'s `${result.*}` is read from the result, unchanged by the row binding', async () => { + const runner = listDoor(); + const toast = vi.fn(); + runner.setToastHandler(toast); + runner.registerHandler('custom', async () => ({ success: true, data: { display_name: 'From result' } })); + const result = await runner.execute(asRow({ + name: 'custom_run', + type: 'custom' as never, + successMessage: 'Done: ${result.display_name}', + } as ActionDef)); + expect(result.success).toBe(true); + expect(toast).toHaveBeenCalledWith('Done: From result', expect.objectContaining({ type: 'success' })); + }); +}); diff --git a/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx b/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx new file mode 100644 index 0000000000..412c2aaa5e --- /dev/null +++ b/packages/plugin-grid/src/__tests__/rowActionRecordHole-12967.test.tsx @@ -0,0 +1,113 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12967 — one action, two doors: a `${record.*}` hole in its + * `confirmText` reads filled from the record header and from a list row. + * + * The header door is an `ActionProvider` whose context carries `record` (the + * shape `RecordDetailView` mounts) firing the action. The row door is the real + * `ObjectGrid` firing the same action from a row's menu, which dispatches it as + * `{ ...def, params: { _rowRecord: row } }` under a provider whose context + * names no record (a list page). Before the fix the row door's confirm read + * `Pause ${record.display_name}?`, the template source. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import React from 'react'; + +import { ObjectGrid } from '../ObjectGrid'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider, SchemaRendererProvider, useAction } from '@object-ui/react'; + +registerAllFields(); + +const OBJECT = 'sys_environment'; +const ROW = { id: 'env_1', name: 'acme-prod', display_name: 'Acme' }; + +const pauseAction = (confirmText: string) => ({ + name: 'pause_environment', + label: 'Pause', + type: 'api', + locations: ['list_item', 'record_header'], + target: '/api/v1/environments/pause', + confirmText, +}); + +const makeDataSource = () => ({ + find: vi.fn(async () => ({ data: [{ ...ROW }], total: 1 })), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn(async () => ({ + name: OBJECT, + fields: { name: { type: 'text', label: 'Name' }, display_name: { type: 'text', label: 'Display name' } }, + })), +}); + +/** Cancel every confirm, recording the body the user would read. */ +const recordingConfirm = () => vi.fn(async (_message: string) => false); + +/** Header door: fire the action under a provider that binds `record`. */ +async function headerConfirm(confirmText: string): Promise { + const onConfirm = recordingConfirm(); + function Fire() { + const { execute } = useAction(); + return ; + } + render( + + + , + ); + await userEvent.click(screen.getByText('fire')); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + cleanup(); + return onConfirm.mock.calls[0][0]; +} + +/** Row door: fire the same action from the real grid's row menu. */ +async function rowConfirm(confirmText: string): Promise { + const onConfirm = recordingConfirm(); + const ds = makeDataSource(); + render( + + + + + , + ); + await waitFor(() => expect(screen.getByText('acme-prod')).toBeInTheDocument()); + await userEvent.click(await screen.findByTestId('row-action-trigger')); + await userEvent.click(await screen.findByTestId('row-action-pause_environment')); + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + cleanup(); + return onConfirm.mock.calls[0][0]; +} + +afterEach(() => cleanup()); + +describe('one action, two doors: a record hole in `confirmText` (objectui#12967)', () => { + it('(a) reads filled from the record header and from a list row', async () => { + expect(await headerConfirm('Pause ${record.display_name}?')).toBe('Pause Acme?'); + expect(await rowConfirm('Pause ${record.display_name}?')).toBe('Pause Acme?'); + }); + + it('(c) CONTROL: a hole naming a field the record lacks degrades the same way on both doors', async () => { + const header = await headerConfirm('Pause ${record.no_such_field}?'); + const row = await rowConfirm('Pause ${record.no_such_field}?'); + expect(header).toBe('Pause ?'); + expect(row).toBe(header); + }); +}); From 9bcaded76ffc487e889b9c28260d70a17625ab1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 16:06:14 +0000 Subject: [PATCH 3/3] test(app-shell): the param-runtime parity pin reads `interpolate` as a dialog field Both param runtimes now put the runner's `interpolate` on the dialog state (objectui#12967), so the field list the parity pin enumerates gains it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- ...RecordDetailView.paramRuntimeParity-6431.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx b/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx index 4a28d91c7a..ece186e5c2 100644 --- a/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx @@ -215,7 +215,17 @@ const ACTION = { * than derived, so two equally empty field sets cannot satisfy the parity * assertions, and a runtime that stops supplying one is red here rather than * silently blank in the dialog. */ -const DIALOG_READS = ['description', 'open', 'params', 'resolve', 'title']; +const DIALOG_READS = [ + 'description', + // The runner's template filler for each param's `helpText` (objectui#12967). + // This suite's handlers are called without one, so it is written `undefined`; + // the key is still on both runtimes' state, which is what parity reads. + 'interpolate', + 'open', + 'params', + 'resolve', + 'title', +]; function makeDataSource() { return {