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
18 changes: 18 additions & 0 deletions .changeset/12105-bulk-param-field-resolve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-grid': patch
'@object-ui/app-shell': patch
---

A bulk action's field-backed param now renders its real control on the selection bar, and the confirm step names the picked record (objectui#12105).

A list view's `bulkActions: ['add_to_campaign']` runs the object's own action over the selected records. When that action declares a field-backed param, `{ field: 'crm_campaign', objectOverride: 'crm_campaign_member' }`, the selection bar's dialog drew a plain text box for it, and the confirm step listed the raw record id. The record page drew the record picker for the same action, because only its dialog looked the field up.

- **One resolver for both dialogs.** The function that resolves an action param against the object's field definitions (`resolveActionParam` / `resolveActionParams`) moves, unchanged, from `@object-ui/app-shell` into `@object-ui/fields`, the one package both dialogs depend on. The record dialog and the bulk dialog both call it. A field-backed param on the selection bar now gets its field's type, label (translated), options, default and picker target, as it does on the record page. A lookup param renders the record picker, and the confirm step shows the record's label.
- **When it resolves.** The grid resolves a promoted action's params when the dialog opens, after it has loaded the session's object metadata, so a param whose owning object (`objectOverride`) has not loaded yet is waited for.
- **A missing field is refused, not drawn as a text box.** A field-backed param whose field is not in the object metadata is shown as a refusal naming `object.field`, and the run is blocked, as the record dialog already does. The copy is the existing `actionDialog.unresolvedParam` string.
- **Inline params are unchanged.** A param declared with its own `name` and `type` keeps the same widget, label, help text, default and `reference` target.

New `@object-ui/fields` exports: `resolveActionParam`, `resolveActionParams`, `resolveParamWidgetType`, `withKnownObjects`, `RESOLVED_ONLY_PARAM_KEYS`, and the types `RawActionParam`, `RawActionParamOption` and `ResolveActionParamsContext`. Before this change these were internal to `@object-ui/app-shell` and were not exported from it. No other export, prop, context member or language-pack key is added.

Not covered yet: on the selection bar, a resolved lookup param uses only its field's picker target, display field and `dependsOn`. The field's other picker settings (`lookupFilters`, `lookupColumns`, `descriptionField` and the rest) do not reach the bulk dialog yet.
5 changes: 5 additions & 0 deletions content/docs/core/enhanced-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ gets its real widget, not a text box (ADR-0059):
- **Field-backed params** declare `field` (+ optional `objectOverride`) and
inherit label, type, options, lookup picker config, `multiple`, `accept`,
and `maxSize` from the object's field definition; inline properties override.
The same resolution runs when the action is named in a list's `bulkActions`
and launched from the selection bar, so a field-backed lookup param gets the
record picker there too. On the selection bar, the field's picker target,
display field and `dependsOn` reach the picker; its other picker settings
(`lookupFilters`, `lookupColumns`, …) do not yet.
- `required` blocks submit while the value is empty; `visible` (a CEL
predicate over `features` / `current_user` / `data`) hides a param
entirely — e.g. gate a param on an opt-in server capability. There is no
Expand Down
9 changes: 9 additions & 0 deletions content/docs/plugins/plugin-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,15 @@ carrying callbacks.
}
```

A name in `bulkActions` runs that action once per selected record. If the
action declares `params`, the selection bar's dialog collects them once, before
the run, and resolves them as the record page's action dialog does. A
field-backed param (`{ "field": "crm_campaign", "objectOverride":
"crm_campaign_member" }`) takes its type, label, options, default and picker
target from that field, so a lookup param renders the record picker and the
confirm step shows the picked record's label. A param whose field is not in
the object metadata is refused, and the run is blocked.

### Row callbacks are component props

`onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`, `onBatchSave`,
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDi
import { ActionResultDialog, type ResultDialogState } from '../views/ActionResultDialog.js';
import { FlowRunner, type ScreenFlowState, type ScreenSpec } from '../views/FlowRunner.js';
import { FlowRefusalNotice, type FlowRefusalState } from '../views/FlowRefusalNotice.js';
import { resolveActionParams, withKnownObjects } from '../utils/resolveActionParams.js';
import { resolveActionParams, withKnownObjects } from '@object-ui/fields';
import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../environment/EnvironmentEntitlementDialog.js';
import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements.js';
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
*/
import { describe, it, expect } from 'vitest';
import { deriveRelatedLists } from '../deriveRelatedLists';
import { resolveActionParams } from '../resolveActionParams';
import { resolveActionParams } from '@object-ui/fields';

/** Broken-on-purpose: the two keys disagree, so the answer names the reader's choice. */
const DIVERGENT = { type: 'master_detail' as const, reference: 'canonical_target', reference_to: 'legacy_target' };
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/utils/decisionOutputParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
decisionOutputParams,
foldDecisionOutputs,
} from './decisionOutputParams';
import { resolveActionParams } from './resolveActionParams';
import { resolveActionParams } from '@object-ui/fields';
import { paramToField } from './paramToField';

const t = (key: string) => `t:${key}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from './resolveActionParams';
} from '@object-ui/fields';

/**
* The spec's own `FieldType` vocabulary.
Expand Down
3 changes: 2 additions & 1 deletion packages/app-shell/src/utils/paramToField.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { FORM_FIELD_TYPES } from '@object-ui/fields';
import { EXPANDABLE_FIELD_TYPES, type ActionParamDef } from '@object-ui/core';
import { paramToField, paramDegradesWithoutTarget, resolveParamWidgetType } from './paramToField';
import { paramToField, paramDegradesWithoutTarget } from './paramToField';
import { resolveParamWidgetType } from '@object-ui/fields';

const p = (over: Partial<ActionParamDef>): ActionParamDef => ({
name: 'x',
Expand Down
31 changes: 5 additions & 26 deletions packages/app-shell/src/utils/paramToField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,11 @@
* asserting param support ⊇ form support.
*/
import { EXPANDABLE_FIELD_TYPES, type ActionParamDef } from '@object-ui/core';
import { resolveFormWidgetType } from '@object-ui/fields';

/**
* Param-only type spellings the dialog historically accepted, folded onto the
* canonical form widget vocabulary. These are legacy dialect entries kept for
* params already authored with them — new params should use spec `FieldType`
* values directly.
*/
const PARAM_TYPE_ALIASES: Record<string, string> = {
checkbox: 'boolean',
reference: 'lookup',
'datetime-local': 'datetime',
// NOTE: spec's `autonumber` (vs the widget-map key `auto_number`) is folded
// in the shared `mapFieldTypeToFormType`, so `resolveFormWidgetType` already
// handles it — no param-only alias needed here.
};

/**
* Resolve a param `type` to the form widget key that renders it. Any type in
* `FORM_FIELD_TYPES` resolves to itself (identity — asserted by the drift
* test); aliases and unknown types resolve through the same fallback chain the
* form applies (unknown → `text`).
*/
export function resolveParamWidgetType(paramType: string): string {
return resolveFormWidgetType(PARAM_TYPE_ALIASES[paramType] ?? paramType);
}
// The param-type fold (its alias table included) moved to `@object-ui/fields`
// beside the resolver that reads it first (objectui#12105). This adapter asks
// the same question one step later, so it reads the same function — never a
// local copy of the table.
import { resolveParamWidgetType } from '@object-ui/fields';

/**
* Widget keys whose picker cannot query without an explicitly DECLARED target,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from './resolveActionParams';
} from '@object-ui/fields';
import { paramToField } from './paramToField';

/** The camel filter value — kept by reference so identity is assertable. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from './resolveActionParams';
} from '@object-ui/fields';

const ctx = (over: Partial<ResolveActionParamsContext> = {}): ResolveActionParamsContext => ({
objectName: 'sys_user',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import '@testing-library/jest-dom';
import { PredicateScopeProvider, SchemaRendererContext } from '@object-ui/react';
import { SelectField } from '@object-ui/fields';
import type { ActionParamOption } from '@object-ui/core';
import { resolveActionParams, type ResolveActionParamsContext } from './resolveActionParams';
import { resolveActionParams, type ResolveActionParamsContext } from '@object-ui/fields';
import { paramToField } from './paramToField';

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from './resolveActionParams';
} from '@object-ui/fields';
import { paramToField } from './paramToField';

/** The camel column list — kept by reference so identity is assertable. */
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/utils/resolveActionParams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from './resolveActionParams';
} from '@object-ui/fields';
import { paramToField } from './paramToField';

const ctx = (over: Partial<ResolveActionParamsContext> = {}): ResolveActionParamsContext => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import '@object-ui/fields';

import { ActionParamDialog, serializeParamValues } from './ActionParamDialog';
import { ActionPreview } from './metadata-admin/previews/ActionPreview';
import { resolveActionParams, type RawActionParam } from '../utils/resolveActionParams';
import { resolveActionParams, type RawActionParam } from '@object-ui/fields';

/* ────────────────────────────────────────────────────────────────────────── */
/* The shipped declaration */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import {
resolveActionParams,
withKnownObjects,
type RawActionParam,
} from '../utils/resolveActionParams';
} from '@object-ui/fields';
import { paramToField, paramDegradesWithoutTarget } from '../utils/paramToField';

/* ────────────────────────────────────────────────────────────────────────── */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from '../utils/resolveActionParams';
} from '@object-ui/fields';

beforeAll(() => {
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = vi.fn() as never;
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js
import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge.js';
import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync.js';
import { RECORD_DETAIL_TAB_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams.js';
import { resolveActionParams } from '../utils/resolveActionParams.js';
import { resolveActionParams } from '@object-ui/fields';
import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js';
import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js';
import { actionContextOrg } from '../utils/actionContextOrg.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ vi.mock('./useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../utils/paramToField.js';
import { paramDegradesWithoutTarget } from '../../utils/paramToField.js';
import { resolveParamWidgetType } from '@object-ui/fields';
import { ActionPreview } from './previews/ActionPreview';
import { ActionDefaultInspector, PARAM_TYPE_OPTS } from './inspectors/ActionDefaultInspector';
import { __setCelFormulaLoader } from './celAuthoring';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ import {
SelectValue,
} from '@object-ui/components';
import type { ActionParam } from '@object-ui/types';
import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../../utils/paramToField.js';
import { paramDegradesWithoutTarget } from '../../../utils/paramToField.js';
import { resolveParamWidgetType } from '@object-ui/fields';
import type { MetadataPreviewProps } from '../preview-registry.js';
import { t as tr, tFormat } from '../i18n.js';
import { PreviewShell, PreviewMessage, PreviewErrorBoundary } from './PreviewShell.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ const mask: (source: string) => string = maskComments;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../../../..');

const PREVIEW = 'packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx';
const RESOLVER = 'packages/app-shell/src/utils/resolveActionParams.ts';
// Moved out of app-shell into `@object-ui/fields` by objectui#12105, so the
// bulk dialog reads the same resolver (the read this docblock quotes is unchanged).
const RESOLVER = 'packages/fields/src/resolveActionParams.ts';

/** Read a tracked file, and prove the read landed or every assertion on it is vacuous. */
function read(rel: string): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,10 @@ interface Entry {
const INVENTORY: Record<string, Entry> = {
// ── the column-identity family: EMPTY (was 24 in PR1; 22 after re-triage) ──

// ── two layers, not two spellings (7) ────────────────────────────────────
'app-shell/src/utils/resolveActionParams.ts': {
// ── two layers, not two spellings (6) ────────────────────────────────────
// Moved from `app-shell/src/utils/` by objectui#12105 so the bulk dialog
// reads the same resolver; its two named readers are unchanged.
'fields/src/resolveActionParams.ts': {
count: 2,
verdict: 'two-layer',
why: 'BOTH orders appear here and both are right: `field ?? name` picks the key to read off the ROW (row data is keyed by object field), `name ?? field` names the PARAM in the action payload, defaulting to the field it binds. Two concepts, not two spellings. Lowered 3 -> 2 in objectui#3174: each order is now exactly ONE named reader (`rowValueKey` / `paramName`) that every call site in the file goes through, so the count is the number of CONCEPTS here rather than the number of times they were open-coded. Deliberately NOT converged onto `columnIdentity()`: that reader is canonical-first because a column IS the object field it shows, whereas a param merely BINDS one, so borrowing it would invert the param-name precedence and rename every field-backed param that also names itself.',
Expand All @@ -138,11 +140,6 @@ const INVENTORY: Record<string, Entry> = {
verdict: 'two-layer',
why: 'Action param name, same layering as resolveActionParams.',
},
'plugin-grid/src/resolveBulkActions.ts': {
count: 1,
verdict: 'two-layer',
why: 'Action param name, same layering as resolveActionParams.',
},
'core/src/utils/predicate-fields.ts': {
count: 2,
verdict: 'two-layer',
Expand Down Expand Up @@ -267,8 +264,10 @@ describe('column identity dual-read ratchet (#3104)', () => {
// two orders through one named reader each; 12 again since objectui#10277
// mirrored that file's row-key reader into the `$select` harvest; 13 since
// objectui#10404 mirrored its param-name reader there too; 12 since
// objectui#10475 deleted `ObjectForm.tsx`'s section-entry join.
expect(sum(() => true)).toBe(12);
// objectui#10475 deleted `ObjectForm.tsx`'s section-entry join; 11 since
// objectui#12105 retired plugin-grid's hand-mapped `toBulkParam`, whose
// param-name read now comes from the shared resolver's `paramName`.
expect(sum(() => true)).toBe(11);
});

it('records a precedence for every read left in the family', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe('the id_field diagnostic carries the PUBLISHED spec reason (objectui#76
});

it('the `id_field` line never claims NO reader sees the value — kept snake reads do', () => {
// `resolveActionParam` (app-shell) reads `id_field` on purpose — objectui#7435
// `resolveActionParam` (fields) reads `id_field` on purpose — objectui#7435
// kept it — and `resolveGroupByLabels` (plugin-charts) reads it too. So the
// line may only speak for the consumers of the declared spellings.
normalizeFieldReferenceKeys(plainField({ id_field: 'code' }), 'owner', 'account');
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils/expand-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ import { columnIdentity } from './column-identity.js';
* - the dashboard's relation/link test — `isLookupType` in
* `packages/plugin-dashboard/src/recordFields.tsx` (objectui#5692);
* - the action-param RESOLVER (one step before `paramToField`) —
* `packages/app-shell/src/utils/resolveActionParams.ts` (objectui#5874);
* `packages/fields/src/resolveActionParams.ts` (objectui#5874; moved out of
* app-shell by objectui#12105 so the bulk dialog shares it);
* - the detail header's relation test — `HeaderHighlight.tsx` in
* `packages/plugin-detail` (objectui#5874);
* - the detail drawer's relation test — `RecordDetailDrawer.tsx` in
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/utils/predicate-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const TARGET_ROW_TOKEN = /\{([a-z_][a-z0-9_]*)\}/gi;
/**
* The row key a `defaultFromRow` param seeds its value from — `field` wins,
* because row data is keyed by object field; `name` is the fallback. The same
* precedence `resolveActionParam` (`@object-ui/app-shell`) reads, which is the
* precedence `resolveActionParam` (`@object-ui/fields`) reads, which is the
* point: the projection must carry the key the runtime looks up, not the
* param's payload name. `remove_team_member` seeds `teamId` from `team_id`.
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/utils/reference-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ type UnfoldableReason = 'no-declared-twin' | 'ambiguous-probe' | 'canonical-occu
* ⚠️ Not NO reader, and the line never says so (objectui#7650). Some reads of a
* retired spelling were KEPT on purpose and still see a value this arm leaves
* alone. For the two `no-declared-twin` keys: `resolveActionParam` in
* `@object-ui/app-shell` reads `id_field` and `title_format` (objectui#7435
* `@object-ui/fields` reads `id_field` and `title_format` (objectui#7435
* kept them; its pin `resolveActionParams.declaredLookupLegs-7435.test.ts`
* calls them the snake spellings that must keep working), and
* `resolveGroupByLabels` in `@object-ui/plugin-charts` reads
Expand Down
Loading
Loading