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/12967-row-action-record-hole.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
async function collectParams(dispatch: Record<string, unknown>, interpolate?: (text: string) => string) {
paramDialogState = null;
let collect: any;
render(<Harness onReady={(fn) => { collect = fn; }} />);
await act(async () => {
void collect(PARAMS, dispatch as any);
void collect(PARAMS, dispatch as any, interpolate);
await Promise.resolve();
});
}
Expand Down Expand Up @@ -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`);
});
});
12 changes: 10 additions & 2 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParamCollectionHandler>((params: ActionParamDef[], action?: ConsoleActionDispatch) => {
const paramCollectionHandler = useCallback<ParamCollectionHandler>((params: ActionParamDef[], action?: ConsoleActionDispatch, interpolate?: (text: string) => string) => {
return new Promise<Record<string, any> | 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
Expand Down Expand Up @@ -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;
Expand All @@ -320,6 +327,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
description: overrideNotice
? [overrideNotice, declaredDescription].filter(Boolean).join('\n\n')
: declaredDescription,
interpolate,
resolve,
});
})(); });
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ParamDialogState>) {
render(
<ActionParamDialog
state={{ open: true, resolve: vi.fn(), params: [], ...state } as ParamDialogState}
onOpenChange={() => {}}
/>,
);
}

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();
});
});
18 changes: 17 additions & 1 deletion packages/app-shell/src/views/ActionParamDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> | null) => void;
}

Expand All @@ -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<T>(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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
async function collectParams(dispatch: Record<string, unknown>, interpolate?: (text: string) => string) {
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MetadataCtx.Provider value={METADATA}>
Expand All @@ -207,7 +207,7 @@ async function collectParams(dispatch: Record<string, unknown>) {
const collect = recordPageParamCollection()!;
paramDialogState = null;
await act(async () => {
void collect(PARAMS, dispatch as any);
void collect(PARAMS, dispatch as any, interpolate);
await Promise.resolve();
});
}
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any> | 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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
});
Expand Down
53 changes: 51 additions & 2 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any> | null>;

/**
Expand Down Expand Up @@ -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' };
Expand All @@ -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)' };
}
Expand Down Expand Up @@ -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<string, unknown>)._rowRecord
: undefined;
if (!row || typeof row !== 'object' || Array.isArray(row)) return this.evaluator;
return new ExpressionEvaluator({ ...this.context, record: row });
}

updateContext(newContext: Partial<ActionContext>): void {
this.context = withIdentityAlias({ ...this.context, ...newContext });
// Feed the merged (re-aliased) context so a refreshed `user` also
Expand Down
Loading
Loading