diff --git a/.changeset/12105-bulk-param-field-resolve.md b/.changeset/12105-bulk-param-field-resolve.md new file mode 100644 index 0000000000..d6c0114a41 --- /dev/null +++ b/.changeset/12105-bulk-param-field-resolve.md @@ -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. diff --git a/content/docs/core/enhanced-actions.mdx b/content/docs/core/enhanced-actions.mdx index bb6921c89e..f621ef32e4 100644 --- a/content/docs/core/enhanced-actions.mdx +++ b/content/docs/core/enhanced-actions.mdx @@ -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 diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 03da740367..51e1ec4ef0 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -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`, diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index a0b3fc0f22..2fdb547fd1 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -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'; diff --git a/packages/app-shell/src/utils/__tests__/referenceArms-6837.divergent.test.ts b/packages/app-shell/src/utils/__tests__/referenceArms-6837.divergent.test.ts index cfe06f5fab..ba9d11ea2e 100644 --- a/packages/app-shell/src/utils/__tests__/referenceArms-6837.divergent.test.ts +++ b/packages/app-shell/src/utils/__tests__/referenceArms-6837.divergent.test.ts @@ -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' }; diff --git a/packages/app-shell/src/utils/decisionOutputParams.test.ts b/packages/app-shell/src/utils/decisionOutputParams.test.ts index 005e875af0..893a219f55 100644 --- a/packages/app-shell/src/utils/decisionOutputParams.test.ts +++ b/packages/app-shell/src/utils/decisionOutputParams.test.ts @@ -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}`; diff --git a/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts b/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts index 74c89f5ead..8b95bcf332 100644 --- a/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts +++ b/packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts @@ -60,7 +60,7 @@ import { resolveActionParams, type ResolveActionParamsContext, type RawActionParam, -} from './resolveActionParams'; +} from '@object-ui/fields'; /** * The spec's own `FieldType` vocabulary. diff --git a/packages/app-shell/src/utils/paramToField.test.ts b/packages/app-shell/src/utils/paramToField.test.ts index d19552eacd..16aa994f15 100644 --- a/packages/app-shell/src/utils/paramToField.test.ts +++ b/packages/app-shell/src/utils/paramToField.test.ts @@ -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 => ({ name: 'x', diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts index c6e140d9ab..a8a75ed0d0 100644 --- a/packages/app-shell/src/utils/paramToField.ts +++ b/packages/app-shell/src/utils/paramToField.ts @@ -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 = { - 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, diff --git a/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts b/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts index fac96a36b2..3a2814339d 100644 --- a/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.declaredLookupLegs-7435.test.ts @@ -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. */ diff --git a/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts b/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts index d295a8d41c..704f4ae70f 100644 --- a/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.i18nLabel.test.ts @@ -28,7 +28,7 @@ import { resolveActionParams, type ResolveActionParamsContext, type RawActionParam, -} from './resolveActionParams'; +} from '@object-ui/fields'; const ctx = (over: Partial = {}): ResolveActionParamsContext => ({ objectName: 'sys_user', diff --git a/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx b/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx index c1dd86d95d..92038f66bb 100644 --- a/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx +++ b/packages/app-shell/src/utils/resolveActionParams.optionVisibleWhen.test.tsx @@ -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'; /** diff --git a/packages/app-shell/src/utils/resolveActionParams.remainingLookupLegs-7435.test.ts b/packages/app-shell/src/utils/resolveActionParams.remainingLookupLegs-7435.test.ts index ee82b3c12c..01d565c05c 100644 --- a/packages/app-shell/src/utils/resolveActionParams.remainingLookupLegs-7435.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.remainingLookupLegs-7435.test.ts @@ -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. */ diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts index ef3d35194d..4dfe003508 100644 --- a/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -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 => ({ diff --git a/packages/app-shell/src/views/ActionParamDialog.carryOver-6246.test.tsx b/packages/app-shell/src/views/ActionParamDialog.carryOver-6246.test.tsx index 2bb706fb43..a46a8e0978 100644 --- a/packages/app-shell/src/views/ActionParamDialog.carryOver-6246.test.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.carryOver-6246.test.tsx @@ -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 */ diff --git a/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx b/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx index fb4d869c68..d7fc5545e3 100644 --- a/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.fieldBackedPickerReach-10129.test.tsx @@ -87,7 +87,7 @@ import { resolveActionParams, withKnownObjects, type RawActionParam, -} from '../utils/resolveActionParams'; +} from '@object-ui/fields'; import { paramToField, paramDegradesWithoutTarget } from '../utils/paramToField'; /* ────────────────────────────────────────────────────────────────────────── */ diff --git a/packages/app-shell/src/views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx b/packages/app-shell/src/views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx index 74e93c26bc..294ba5a868 100644 --- a/packages/app-shell/src/views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.lookupDependsOnReach-8672.test.tsx @@ -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; diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index eba397e802..f546227c11 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -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'; diff --git a/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx index 56ce3881e6..2d549539ce 100644 --- a/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx @@ -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'; diff --git a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx index 6ce761bd5b..984fec6a70 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/ActionPreview.tsx @@ -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'; diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.inheritedTargetCitation-10159.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.inheritedTargetCitation-10159.test.ts index 6f493b8569..6392a62c96 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.inheritedTargetCitation-10159.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/ActionPreview.inheritedTargetCitation-10159.test.ts @@ -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 { diff --git a/packages/core/src/utils/__tests__/column-identity.ratchet.test.ts b/packages/core/src/utils/__tests__/column-identity.ratchet.test.ts index 7f7a0caffb..0428d8417f 100644 --- a/packages/core/src/utils/__tests__/column-identity.ratchet.test.ts +++ b/packages/core/src/utils/__tests__/column-identity.ratchet.test.ts @@ -122,8 +122,10 @@ interface Entry { const INVENTORY: Record = { // ── 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.', @@ -138,11 +140,6 @@ const INVENTORY: Record = { 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', @@ -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', () => { diff --git a/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts b/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts index 431c2e69a2..d5b17c807f 100644 --- a/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts +++ b/packages/core/src/utils/__tests__/reference-keys.retiredDialect-7650.test.ts @@ -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'); diff --git a/packages/core/src/utils/expand-fields.ts b/packages/core/src/utils/expand-fields.ts index 9cfa4da255..c295fcc422 100644 --- a/packages/core/src/utils/expand-fields.ts +++ b/packages/core/src/utils/expand-fields.ts @@ -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 diff --git a/packages/core/src/utils/predicate-fields.ts b/packages/core/src/utils/predicate-fields.ts index b83a051014..ea10b254a0 100644 --- a/packages/core/src/utils/predicate-fields.ts +++ b/packages/core/src/utils/predicate-fields.ts @@ -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`. */ diff --git a/packages/core/src/utils/reference-keys.ts b/packages/core/src/utils/reference-keys.ts index de7f39a3f5..6ec5fda287 100644 --- a/packages/core/src/utils/reference-keys.ts +++ b/packages/core/src/utils/reference-keys.ts @@ -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 diff --git a/packages/fields/README.md b/packages/fields/README.md index a54fdc496a..26bcd7c593 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -110,6 +110,60 @@ The app-shell `ActionParamDialog` uses these to render declared action params through the exact same widgets as the object form — with a drift test pinning param support ⊇ form support. +### Resolving declared action params + +An action param is declared either inline (`name` + `type`) or field-backed +(`field`, plus `objectOverride` when the field lives on another object). Before +a dialog can render one, it has to be resolved against the object's field +definitions. This package holds the one resolver for that, shared by the +record page's action dialog (`@object-ui/app-shell`) and the selection bar's +bulk dialog (`@object-ui/plugin-grid`): + +```ts +import { + resolveActionParams, + withKnownObjects, + type RawActionParam, + type ResolveActionParamsContext, +} from '@object-ui/fields'; + +type KnownObject = ResolveActionParamsContext['objects'][number]; + +// The object definitions the caller renders, and the ones the session's +// metadata store holds (`useMetadata().objects` from `@object-ui/react`). +declare const callerObjects: KnownObject[]; +declare const storeObjects: KnownObject[]; + +const params: RawActionParam[] = [ + { field: 'crm_campaign', objectOverride: 'crm_campaign_member' }, // field-backed + { name: 'note', type: 'textarea', label: 'Note' }, // inline +]; + +const resolved = resolveActionParams(params, { + objectName: 'crm_lead', // the owner when a param names no objectOverride + objects: withKnownObjects(callerObjects, storeObjects), // the caller's defs win by name + fieldLabel: (_objectName, _fieldName, fallback) => fallback, + locale: 'en', +}); +// resolved[0] takes its type, label and `referenceTo` from the +// `crm_campaign_member.crm_campaign` field definition; resolved[1] is as declared. +``` + +- `resolveActionParam(param, ctx)` / `resolveActionParams(params, ctx)` — a + field-backed param inherits the field's type, label, options, placeholder, + help text, default and picker config. Inline keys on the param override the + field. A param whose field is not in `ctx.objects` comes back with + `unresolvedField: '.'`, and both dialogs refuse it rather than + drawing an input. +- `withKnownObjects(supplied, stored)` — unions two object lists by `name`, + the caller's first. +- `resolveParamWidgetType(type)` — the param-type fold (`checkbox`, + `reference`, `datetime-local` aliases) onto `resolveFormWidgetType`. +- `RESOLVED_ONLY_PARAM_KEYS` — keys of the resolved shape an author may not + write on a param (`referenceTo`, `displayField`, …), each with the fix the + resolver's dev warning prints. +- Types: `RawActionParam`, `RawActionParamOption`, `ResolveActionParamsContext`. + ### Enumerating the CELL renderer registry `listCellRendererTypes()` is the read-side twin of `FORM_FIELD_TYPES`: every diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index bd77516ca2..2d040cfcd9 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -4347,6 +4347,27 @@ export function resolveFormWidgetType(fieldType: string): string { */ export { CASCADE_OPTION_WIDGET_TYPES } from '@object-ui/core'; +/** + * The ONE action-param resolver (objectui#12105): a declared action param — + * inline, or field-backed via `{ field, objectOverride }` — resolved against + * the object's field definitions into the `ActionParamDef` a param dialog + * renders. Exported from here because this is the package both of its callers + * depend on: app-shell's single-record `ActionParamDialog` and plugin-grid's + * selection-bar bulk dialog (a promoted action's params). See the module. + */ +export { + resolveActionParam, + resolveActionParams, + resolveParamWidgetType, + withKnownObjects, + RESOLVED_ONLY_PARAM_KEYS, +} from './resolveActionParams.js'; +export type { + RawActionParam, + RawActionParamOption, + ResolveActionParamsContext, +} from './resolveActionParams.js'; + /** Cache so each widget type creates one lazy component (and one chunk request). */ const lazyFieldWidgets = new Map>(); diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/fields/src/resolveActionParams.ts similarity index 91% rename from packages/app-shell/src/utils/resolveActionParams.ts rename to packages/fields/src/resolveActionParams.ts index 832df9b3ad..02b2e2f440 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/fields/src/resolveActionParams.ts @@ -19,6 +19,24 @@ * expected by `ActionParamDialog`, so the dialog itself stays agnostic to * field references. * + * ## ONE resolver, two dialogs (objectui#12105) + * + * This module used to live in `@object-ui/app-shell` (`src/utils/`), which only + * the single-record `ActionParamDialog` could reach. The selection bar's bulk + * dialog lives in `@object-ui/plugin-grid`, which `app-shell` depends on and + * which therefore can never import from it, so a promoted bulk action's + * field-backed param (`{ field, objectOverride }`) was mapped by hand in + * plugin-grid's `toBulkParam` — never against the field definition — and drew a + * raw text box where the record dialog drew the record picker. + * + * It moved here because this is the one package BOTH sides already depend on + * that holds everything the resolver reads: `@object-ui/core` (the + * `ActionParamDef` it emits and the reference-bearing family), this package's + * {@link resolveFormWidgetType} (the widget fold `resolveParamWidgetType` asks) + * and `@objectstack/spec` (the inline per-locale label). Nothing app-shell-local + * came with it except that three-row param alias table. Both dialogs now call + * these functions; ⛔ never a second copy of the resolution logic on either side. + * * AUTHORING vs RESOLVED is the load-bearing distinction here, and the two * vocabularies are not interchangeable (objectui#3174). What an author may * write is exactly the spec's `ActionParamSchema` key set — mirrored by @@ -31,15 +49,56 @@ */ import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; import type { ActionParamDef, ActionParamOption } from '@object-ui/core'; -// The fold from a PARAM type spelling to the widget key that renders it — -// the one alias table, read rather than restated. See `isLookupResolvedType`. -import { resolveParamWidgetType } from './paramToField.js'; +// The form's widget fold. Imported from the barrel this module is re-exported +// by — a cycle in the SOURCE graph only, and a call-time one: nothing at this +// module's top level reads it, and the published bundle is a single file. +import { resolveFormWidgetType } from './index.js'; import type { I18nLabel } from '@objectstack/spec/ui'; // Aliased per PR #4169's convention — app-shell has its OWN `resolveI18nLabel` // (renamed `resolveKeyedI18nLabel` by objectui#4167) over the translation-KEY // vocabulary, which does not accept the inline per-locale map this resolves. import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; +/** + * The bundler-replaced `process.env.NODE_ENV` idiom the dev-only warnings below + * read, declared for THIS module only. This package's source program carries no + * node typings on purpose — a browser library, the same stance and the same + * reasoning as `@object-ui/components`' `browser-process-shim.d.ts`. Declared + * here, module-scoped, rather than as a second ambient global, so it can never + * shadow the real node global in a program that does load `@types/node` (the + * defect that shim's header records). The runtime read is unchanged from when + * this module lived in app-shell: a consumer's bundler replaces it. + */ +declare const process: { env: { NODE_ENV?: string } }; + +/** + * 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 = { + 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 — the fold from + * a PARAM type spelling to its widget, the one alias table, read rather than + * restated (see `isLookupResolvedType` below, and app-shell's `paramToField`, + * which asks the same question one step later). Any type in `FORM_FIELD_TYPES` + * resolves to itself (identity — asserted by app-shell's 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); +} + /** * Resolved params keep raw `FieldType` values (`text` / `email` / `select` / * `file` / …), matching the `FieldType` enum in `@objectstack/spec`. **Do @@ -599,7 +658,7 @@ export function resolveActionParam( // — the unexplained "paste a UUID" box objectui#3405 exists to prevent. // Gaining them RESTORES the rule this block states; it does not widen it. // - it carried `reference`, and that spelling survives the change: the fold - // is where it belongs (`PARAM_TYPE_ALIASES` in `paramToField.ts` keeps it + // is where it belongs (`PARAM_TYPE_ALIASES` at the top of this file keeps it // as a legacy param dialect, folded to `lookup`), not hand-copied into a // membership test. Measured, not assumed — `reference` is refused by // `ActionParamSchema` and absent from `@objectstack/spec`'s `FieldType`, @@ -667,7 +726,7 @@ export function resolveActionParam( // then refused to ship, routing the disposition to objectui#8672. // // The dialog now supplies that record (see `paramNeedsDependentValues` - // in `views/ActionParamDialog.tsx`), so the gate lifts as soon as the + // in app-shell's `views/ActionParamDialog.tsx`), so the gate lifts as soon as the // named parent carries a value and `LookupField` narrows the picker by // it. The two halves ship together on purpose: either alone is a // regression, and this comment is the reason they may not be split diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 36bfe509aa..4d10f88537 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -26,7 +26,7 @@ import type { ObjectGridSchema, DataSource, ListColumn, TableColumn, ViewData, T import { isSystemManagedField, normalizeTableColumnType } from '@object-ui/types'; import type { I18nLabel } from '@objectstack/spec/ui'; import { parseFilterAST, resolveFieldScale, type FilterCondition } from '@objectstack/spec/data'; -import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions, RelatedRecordActionsProvider, useDataInvalidation, useFilterScope } from '@object-ui/react'; +import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction, useSafeFieldLabel, usePredicateScope, useRelatedRecordActions, RelatedRecordActionsProvider, useDataInvalidation, useFilterScope, useMetadata } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; // objectui#8920 — the grid reaches a cell renderer through THIS module and // nowhere else. `getCellRenderer` / `resolveCellRendererType` are deliberately @@ -35,7 +35,7 @@ import { createSafeTranslation } from '@object-ui/i18n'; // what stops a seventh site picking a convention of its own. import { resolveGridCellRendering, gridCellRendererForFixedKey, linkCellRenderer, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution'; import { isMaskedGridColumn, isWithheldGridColumn } from './maskedColumn'; -import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, percentCellScale, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer } from '@object-ui/fields'; +import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, percentCellScale, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer, withKnownObjects } from '@object-ui/fields'; import { useLocalization, useDisplayLocale, resolveFieldCurrency } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -74,7 +74,7 @@ import { resolveRowCrudAffordances, resolveRowRecordCrudAffordance } from './row import { useRecordCrudVerdicts } from './hooks/useRecordCrudVerdicts'; import { resolveLegacyRowActions } from './resolveLegacyRowActions'; import { applyRelationalMeta } from './relationalMetaKeys'; -import { resolveBulkActions, describeUnusableBulkActionDefs } from './resolveBulkActions'; +import { resolveBulkActions, describeUnusableBulkActionDefs, resolvePromotedBulkParams } from './resolveBulkActions'; import { partitionBulkRows } from './bulkEligibility'; import { resolvesToDataColumn, describeUnresolvedColumns } from './columnSpellingDiagnostics'; import { RowActionMenu, formatActionLabel } from './components/RowActionMenu'; @@ -1530,8 +1530,13 @@ export const ObjectGrid: React.FC = ({ // The one date/number locale resolver: tenant regional default → active UI // language → 'en' (objectui#4272). Read unconditionally at component level. const displayLocale = useDisplayLocale(); - const { t } = useGridTranslation(); - const { fieldLabel: resolveFieldLabel, translateOptions, actionLabel: resolveActionLabel } = useSafeFieldLabel(); + const { t, language: uiLanguage } = useGridTranslation(); + const { fieldLabel: resolveFieldLabel, fieldOptionLabel: resolveFieldOptionLabel, translateOptions, actionLabel: resolveActionLabel } = useSafeFieldLabel(); + // The session's object metadata (objectui#12105) — what a promoted bulk + // action's `objectOverride` param resolves against, as on the record page. + // The CONTEXT VALUE is closed over, never a snapshot of its `objects` + // getter, which reads the live cache at call time. + const metadata = useMetadata(); const [objectSchema, setObjectSchema] = useState(null); const [useCardView, setUseCardView] = useState(false); const [refreshKey, setRefreshKey] = useState(0); @@ -5089,6 +5094,27 @@ export const ObjectGrid: React.FC = ({ })(); }; + // [objectui#12105] A PROMOTED def's params are its action's, resolved here — + // when the dialog opens — through the one resolver the record page uses + // (`resolveActionParam`, `@object-ui/fields`), so a field-backed param + // (`{ field, objectOverride }`) inherits its type, label, options and picker + // target instead of drawing a raw text box. The order is the record dialog's: + // ask the session for its object metadata first, so an `objectOverride` + // target that has not loaded yet is a wait rather than a refusal; then union + // this grid's own schema with the session's objects, the grid's FIRST (it is + // the world this grid renders). An authored def comes back unchanged. + const resolveDialogParams = async (def: BulkActionDef): Promise => { + if (!def.actionDef) return def; + await metadata.ensureType('object').catch(() => []); + return resolvePromotedBulkParams(def, { + objectName: objectName ?? '', + objects: withKnownObjects(objectSchema ? [objectSchema] : undefined, metadata.objects), + fieldLabel: resolveFieldLabel, + fieldOptionLabel: resolveFieldOptionLabel, + locale: uiLanguage, + }); + }; + // Rich BulkActionDef dispatcher — opens the BulkActionDialog (params → // confirm → progress → result). When the user closes the dialog after a // run, refresh data so the grid reflects mutations. @@ -5103,7 +5129,7 @@ export const ObjectGrid: React.FC = ({ scope: predicateScope, fields: objectSchema?.fields, }); - setActiveBulkDef(def); + setActiveBulkDef(await resolveDialogParams(def)); setActiveBulkRows(eligible); setActiveBulkSkipped(skipped); })(); diff --git a/packages/plugin-grid/src/__tests__/bulkFieldBackedParam-12105.test.tsx b/packages/plugin-grid/src/__tests__/bulkFieldBackedParam-12105.test.tsx new file mode 100644 index 0000000000..fa0cd8211b --- /dev/null +++ b/packages/plugin-grid/src/__tests__/bulkFieldBackedParam-12105.test.tsx @@ -0,0 +1,369 @@ +/** + * 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#12105 — a PROMOTED bulk action's field-backed param resolves through + * the same resolver as the single-record dialog. + * + * ## The defect + * + * A view's `bulkActions: ['add_to_campaign']` promotes the object's action, and + * the action declares `params: [{ field: 'crm_campaign', objectOverride: + * 'crm_campaign_member' }]` — spec `ActionParamSchema`'s field-backed form. + * plugin-grid's hand-mapper (`toBulkParam`) renamed `field` onto `name`, + * defaulted `type` to `'text'` and read `object` only off an inline + * `reference`, so it never consulted the field definition: the selection bar's + * dialog drew ``, and the + * confirm step listed the raw id. The record page renders the record picker for + * the very same action, because app-shell's `resolveActionParams` resolves the + * field — and plugin-grid could not import it (app-shell depends on + * plugin-grid). Measured on HotCRM (`Add to Campaign`, objectstack-ai/hotcrm#2058). + * + * ## What is pinned + * + * 1. The field-backed lookup param resolves onto the bulk vocabulary — type, + * label and picker target from the FIELD — and `bulkParamToField` builds the + * picker, not a text widget. Its answer is the single-record resolver's + * answer for the same declaration (parity, not a second copy). + * 2. CONTROL: a literal-typed (inline) param comes out as it did — same widget, + * same label, help and default, an inline `reference` still the target. + * 3. A field-backed param whose field is NOT in the metadata is refused, as the + * record dialog refuses it, instead of becoming a text box again. + * 4. Through the REAL `ObjectGrid` + `BulkActionDialog`: the picker renders on + * the selection bar's dialog, the confirm step shows the record's LABEL, the + * run sends the id — and the grid awaits the session's object metadata before + * resolving, so an `objectOverride` target that loads late is waited for. + */ + +import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + usePermissions: () => ({ isLoaded: false, checkField: () => true, getObjectApiOperations: () => undefined, can: () => true }), + }; +}); + +import type { BulkActionDef } from '@object-ui/types'; +import { registerAllFields, resolveActionParam, type ResolveActionParamsContext } from '@object-ui/fields'; +import { ActionProvider, MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; +import { resolveBulkActions, resolvePromotedBulkParams, unresolvedFieldOf } from '../resolveBulkActions'; +import { bulkParamToField } from '../components/bulkParamToField'; + +registerAllFields(); + +const GRID_OBJECT = 'crm_lead'; + +/** The junction object that OWNS the referenced field — only the session store knows it. */ +const CAMPAIGN_MEMBER = { + name: 'crm_campaign_member', + fields: { + crm_campaign: { + type: 'lookup', + label: 'Campaign', + reference: 'crm_campaign', + displayField: 'name', + required: true, + defaultValue: 'c1', + }, + }, +}; + +const ADD_TO_CAMPAIGN = { + name: 'add_to_campaign', + label: 'Add to Campaign', + type: 'api', + target: '/api/v1/campaigns/add', + params: [{ field: 'crm_campaign', objectOverride: 'crm_campaign_member' }], +}; + +const ctx = (objects: ResolveActionParamsContext['objects']): ResolveActionParamsContext => ({ + objectName: GRID_OBJECT, + objects, + fieldLabel: (_o, _f, fallback) => fallback, +}); + +/** Promote one action through the real fold, the way `ObjectGrid` does. */ +function promote(action: Record): BulkActionDef { + const { defs } = resolveBulkActions({ bulkActions: [String(action.name)], objectActions: [action] }); + expect(defs).toHaveLength(1); + return defs[0]; +} + +describe('resolvePromotedBulkParams — the shared resolver on the bulk path (objectui#12105)', () => { + it('resolves a field-backed lookup param against the field, and the bulk adapter builds the picker', () => { + const def = resolvePromotedBulkParams(promote(ADD_TO_CAMPAIGN), ctx([CAMPAIGN_MEMBER])); + const [param] = def.params ?? []; + + expect(param).toEqual( + expect.objectContaining({ + name: 'crm_campaign', + label: 'Campaign', + type: 'lookup', + object: 'crm_campaign', + labelField: 'name', + default: 'c1', + }), + ); + expect(unresolvedFieldOf(param)).toBeUndefined(); + + // The widget the dialog renders: the record picker with its target, where + // the hand-mapper produced `type: 'text'` and no target at all. + const field = bulkParamToField(param, false); + expect(field.type).toBe('lookup'); + expect(field.reference).toBe('crm_campaign'); + expect(field.displayField).toBe('name'); + }); + + it("answers what the single-record resolver answers for the same declaration", () => { + const context = ctx([CAMPAIGN_MEMBER]); + const single = resolveActionParam(ADD_TO_CAMPAIGN.params[0], context); + const [bulk] = resolvePromotedBulkParams(promote(ADD_TO_CAMPAIGN), context).params ?? []; + + expect({ + name: bulk.name, + label: bulk.label, + type: bulk.type, + required: bulk.required, + default: bulk.default, + object: bulk.object, + labelField: bulk.labelField, + }).toEqual({ + name: single.name, + label: single.label, + type: single.type, + required: single.required, + default: single.defaultValue, + object: single.referenceTo, + labelField: single.displayField, + }); + }); + + it('CONTROL: a literal-typed param is unchanged', () => { + const action = { + name: 'log_note', + params: [ + { name: 'note', type: 'textarea', label: 'Note', helpText: 'Why', defaultValue: 'x', required: true, placeholder: 'Say why' }, + { name: 'owner', type: 'lookup', label: 'Owner', reference: 'sys_user' }, + ], + }; + // No metadata at all — an inline param never needed any. + const def = resolvePromotedBulkParams(promote(action), ctx([])); + const [note, owner] = def.params ?? []; + + expect(note).toEqual( + expect.objectContaining({ name: 'note', type: 'textarea', label: 'Note', help: 'Why', default: 'x', required: true, placeholder: 'Say why' }), + ); + expect(bulkParamToField(note, false)).toEqual( + expect.objectContaining({ name: 'note', type: 'textarea', label: 'Note', required: true, placeholder: 'Say why' }), + ); + // The inline `reference` is still the picker target — and `sys_user` + // still promotes to the person picker, as it did. + expect(owner).toEqual(expect.objectContaining({ name: 'owner', type: 'lookup', object: 'sys_user' })); + expect(bulkParamToField(owner, false).type).toBe('user'); + expect(unresolvedFieldOf(note)).toBeUndefined(); + expect(unresolvedFieldOf(owner)).toBeUndefined(); + }); + + it('names a field-backed param whose field the metadata does not hold, instead of degrading it', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const [param] = resolvePromotedBulkParams(promote(ADD_TO_CAMPAIGN), ctx([])).params ?? []; + expect(unresolvedFieldOf(param)).toBe('crm_campaign_member.crm_campaign'); + } finally { + warn.mockRestore(); + } + }); + + it('leaves an authored def, and authored params, exactly as authored', () => { + const authored: BulkActionDef = { + name: 'set_queue', + operation: 'update', + params: [{ name: 'queue', type: 'lookup', object: 'queues' }], + }; + expect(resolvePromotedBulkParams(authored, ctx([CAMPAIGN_MEMBER]))).toBe(authored); + }); +}); + +// ─── Through the real grid and dialog ──────────────────────────────────────── + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } + if (!(Element.prototype as any).hasPointerCapture) { + (Element.prototype as any).hasPointerCapture = () => false; + } + if (!(Element.prototype as any).setPointerCapture) { + (Element.prototype as any).setPointerCapture = () => {}; + } +}); + +let fetchMock: ReturnType; + +beforeEach(() => { + // happy-dom lacks matchMedia; the lookup widgets' useIsMobile needs it. + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as any; + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => 'application/json' }, + json: async () => ({ success: true }), + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const LEADS = [ + { id: 'l1', name: 'Ada Lovelace' }, + { id: 'l2', name: 'Grace Hopper' }, +]; +const CAMPAIGNS = [{ id: 'c1', name: 'Spring Launch' }]; + +/** + * A session store whose `object` type is NOT loaded until `ensureType('object')` + * is asked — so a grid that resolved without waiting would find no owner for + * the `objectOverride` param and refuse it. + */ +function lateLoadingMetadata(objects: unknown[]) { + let loaded = false; + const ensureType = vi.fn(async (type: string) => { + if (type === 'object') loaded = true; + return loaded ? objects : []; + }); + const value = { + apps: [], + get objects() { + return loaded ? objects : []; + }, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType, + getItem: async () => null, + getItemsByType: () => [], + } as unknown as MetadataContextValue; + return { value, ensureType }; +} + +async function openBulkDialog(action: Record, metadataObjects: unknown[]) { + const dataSource: any = { + find: vi.fn(async (resource: string) => { + const data = resource === 'crm_campaign' ? CAMPAIGNS : LEADS; + return { data: data.map((r) => ({ ...r })), total: data.length, hasMore: false, pageSize: 50 }; + }), + findOne: vi.fn(async (resource: string, id: string) => + resource === 'crm_campaign' ? CAMPAIGNS.find((c) => c.id === id) ?? null : null), + getObjectSchema: async (name: string) => ({ + name, + fields: { id: { type: 'text' }, name: { type: 'text', label: 'Name' } }, + actions: [action], + }), + }; + const { value, ensureType } = lateLoadingMetadata(metadataObjects); + render( + + + + + , + ); + await waitFor(() => expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()); + await waitFor(() => expect(document.querySelector('thead [role="checkbox"]')).toBeTruthy()); + fireEvent.click(document.querySelector('thead [role="checkbox"]') as HTMLElement); + fireEvent.click(await screen.findByTestId(`bulk-action-${String(action.name)}`)); + return { dataSource, ensureType }; +} + +describe('ObjectGrid bulk dialog — a field-backed param on the selection bar (objectui#12105)', () => { + it('renders the record picker, shows the label on confirm, and sends the id', async () => { + const { dataSource, ensureType } = await openBulkDialog(ADD_TO_CAMPAIGN, [CAMPAIGN_MEMBER]); + + // The picker, not the text box the hand-mapper drew. + expect(await screen.findByTestId('lookup-trigger-crm_campaign')).toBeInTheDocument(); + expect(document.querySelector('input#bulk-param-crm_campaign')).toBeNull(); + expect(screen.queryByTestId('bulk-param-unresolved-crm_campaign')).not.toBeInTheDocument(); + // It waited for the session's objects before resolving. + expect(ensureType).toHaveBeenCalledWith('object'); + + // The field's default seeded the value; the confirm step names the record. + const next = screen.getByRole('button', { name: 'Next' }); + await waitFor(() => expect(next).toBeEnabled()); + fireEvent.click(next); + await screen.findByRole('button', { name: 'Run' }); + await waitFor(() => expect(screen.getByText(/Spring Launch/)).toBeInTheDocument()); + expect(dataSource.findOne).toHaveBeenCalledWith('crm_campaign', 'c1'); + expect(screen.queryByText(/:\s*c1$/)).not.toBeInTheDocument(); + + // One dispatch per lead, each carrying the picked campaign's id. + fireEvent.click(screen.getByRole('button', { name: 'Run' })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const bodies = fetchMock.mock.calls.map((c) => JSON.parse((c[1] as any).body)); + expect(bodies.map((b) => b.crm_campaign)).toEqual(['c1', 'c1']); + }); + + it('CONTROL: a literal-typed param still renders its own widget', async () => { + await openBulkDialog( + { name: 'log_note', label: 'Log note', type: 'api', target: '/api/v1/notes', params: [{ name: 'note', type: 'text', label: 'Note' }] }, + [], + ); + const input = await waitFor(() => { + const el = document.querySelector('input#bulk-param-note'); + expect(el).toBeTruthy(); + return el as HTMLInputElement; + }); + expect(input.type).toBe('text'); + }); + + it('refuses a field-backed param whose field is missing, and blocks the run', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + // The session store holds no `crm_campaign_member` at all. + await openBulkDialog(ADD_TO_CAMPAIGN, []); + const refusal = await screen.findByTestId('bulk-param-unresolved-crm_campaign'); + expect(refusal).toHaveAttribute('role', 'alert'); + expect(refusal).toHaveTextContent('crm_campaign_member.crm_campaign'); + expect(document.querySelector('input#bulk-param-crm_campaign')).toBeNull(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts b/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts index cee3e0f6fb..096e555ef2 100644 --- a/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts +++ b/packages/plugin-grid/src/__tests__/resolveBulkActions.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from 'vitest'; -import { resolveBulkActions, PROMOTED_BULK_BATCH_SIZE } from '../resolveBulkActions'; +import { resolveBulkActions, resolvePromotedBulkParams, PROMOTED_BULK_BATCH_SIZE } from '../resolveBulkActions'; import type { BulkActionDef } from '@object-ui/types'; const PUSH_DOWN = { @@ -102,6 +102,10 @@ describe('resolveBulkActions', () => { }); it('maps spec param keys onto the dialog vocabulary', () => { + // [objectui#12105] The fold attaches no `params` of its own; they are the + // action's, resolved when the dialog opens (`resolvePromotedBulkParams`) + // through the shared resolver. `owner` is field-backed, so the context + // carries the field it binds — the inline keys still override it. const withParams = { name: 'reassign', params: [ @@ -122,8 +126,14 @@ describe('resolveBulkActions', () => { bulkActions: ['reassign'], objectActions: [withParams], }); + expect(defs[0].params).toBeUndefined(); - expect(defs[0].params).toEqual([ + const resolved = resolvePromotedBulkParams(defs[0], { + objectName: 'job', + objects: [{ name: 'job', fields: { owner: { type: 'lookup', label: 'Owner', reference: 'sys_user' } } }], + fieldLabel: (_o, _f, fallback) => fallback, + }); + expect(resolved.params).toEqual([ expect.objectContaining({ name: 'owner', label: 'New owner', diff --git a/packages/plugin-grid/src/components/BulkActionDialog.tsx b/packages/plugin-grid/src/components/BulkActionDialog.tsx index 600fdd3309..2004fac21b 100644 --- a/packages/plugin-grid/src/components/BulkActionDialog.tsx +++ b/packages/plugin-grid/src/components/BulkActionDialog.tsx @@ -38,6 +38,7 @@ import { isLookupishParam, lookupTargetObject, } from './bulkParamToField'; +import { unresolvedFieldOf } from '../resolveBulkActions'; export interface BulkActionDialogProps { /** The action being executed. */ @@ -247,6 +248,11 @@ export const BulkActionDialog: React.FC = ({ const paramsValid = useMemo(() => { for (const p of params) { + // [objectui#12105] A promoted param whose backing field the shared + // resolver could not find is refused below, so it can never be filled: + // the run is blocked, as the single-record dialog blocks its submit, + // rather than dispatched without a value nobody could supply. + if (unresolvedFieldOf(p)) return false; if (p.required) { const v = values[p.name]; if (v === undefined || v === null || v === '') return false; @@ -382,21 +388,49 @@ export const BulkActionDialog: React.FC = ({ {step === 'params' && (
- {params.map(p => ( - setValues(prev => ({ ...prev, [p.name]: v }))} - /> - ))} + {params.map(p => { + // [objectui#12105] The refusal comes FIRST, ahead of + // `bulkParamToField`, which would otherwise build a widget from + // the placeholder type the resolver fell back to — the same order, + // and the same copy, as app-shell's `ActionParamDialog`. + const unresolved = unresolvedFieldOf(p); + if (unresolved) { + return ( +
+ +
+

+ {t('actionDialog.unresolvedParam', { + defaultValue: + 'This parameter cannot be shown: the field it is backed by is missing from the object metadata, so the control it needs cannot be built. Ask an administrator to fix the action definition.', + })} +

+ {/* An identifier, rendered verbatim in every locale. */} +

{unresolved}

+
+
+ ); + } + return ( + setValues(prev => ({ ...prev, [p.name]: v }))} + /> + ); + })}
)} @@ -647,9 +681,13 @@ interface ParamFieldProps { * strict; its strict sibling `ActionParamSchema` refuses both. That accept is a * NULL reading, measured with both controls in * `__tests__/bulkLookupDependsOnReach-8755.test.tsx` leg B. Whether the key - * should become authorable BY CONTRACT on this surface — by closing that schema, - * or by giving bulk the field-backed route `resolveActionParams` gives the - * single-record dialog — is upstream of this repo and is left open there. + * should become authorable BY CONTRACT on this surface by closing that schema + * is upstream of this repo and is left open there. The other route this + * sentence used to name — the field-backed one `resolveActionParams` gives the + * single-record dialog — now exists for a PROMOTED action's params + * (objectui#12105, `resolvePromotedBulkParams`): a field that declares + * `dependsOn` hands it to the bulk param exactly as it does on the record page. + * An AUTHORED `bulkActionDefs[].params` entry still has no such route. * * ⭐ **Why this is not `CASCADE_OPTION_WIDGET_TYPES.add('lookup')`.** That set is * shared verbatim with the object form's cascade-CLEAR loop and with diff --git a/packages/plugin-grid/src/resolveBulkActions.ts b/packages/plugin-grid/src/resolveBulkActions.ts index a566032c5d..e2e6b01a27 100644 --- a/packages/plugin-grid/src/resolveBulkActions.ts +++ b/packages/plugin-grid/src/resolveBulkActions.ts @@ -49,6 +49,25 @@ * per-record dispatch — it has nowhere to carry an `execution` flag, so * aggregate mode (objectui#3139) requires the authored-def form above. * + * ## A promoted def's params are its ACTION's, resolved when its dialog opens + * + * A promoted def carries no `params` of its own out of this fold. Its params + * are the source action's (`actionDef.params`, spec `ActionParamSchema`), and + * {@link resolvePromotedBulkParams} resolves them at DISPATCH time through + * `resolveActionParam` — the one resolver the single-record dialog uses, which + * lives in `@object-ui/fields` so this package can reach it (objectui#12105). + * That is what lets a field-backed param (`{ field, objectOverride }`) inherit + * its type, label, options and picker target from the field definition here + * exactly as it does on the record page. + * + * Until objectui#12105 this module mapped those params by hand (`toBulkParam` + * renamed `field` onto `name`, defaulted `type` to `'text'`, and read `object` + * only off an inline `reference`), so every field-backed param drew a raw text + * box on the selection bar and the confirm step listed raw ids. Resolving at + * dispatch rather than here, during render, mirrors the record path's timing + * too: the grid awaits the session's object metadata first, so an + * `objectOverride` target that has not loaded yet is a wait, not a refusal. + * * A promoted def carries `operation: 'custom'` plus the source action under * {@link BulkActionDef.actionDef}: it is NOT a data-plane mass update, it is * one action run over N records through the action runner. `useBulkExecutor` @@ -64,6 +83,14 @@ */ import type { BulkActionDef, BulkActionParam } from '@object-ui/types'; +import type { ActionParamDef } from '@object-ui/core'; +// The ONE action-param resolver, shared with app-shell's single-record dialog +// (objectui#12105). ⛔ Never re-implement any of its field lookup here. +import { + resolveActionParams, + type RawActionParam, + type ResolveActionParamsContext, +} from '@object-ui/fields'; // One authority for `NamedActionDef` (objectui#6349): `resolveLegacyRowActions.ts` // declares the `ActionDef` subset both folds read, and this module re-exports // it so the name stays reachable from here exactly as before. A re-export, not @@ -84,25 +111,104 @@ export const PROMOTED_BULK_BATCH_SIZE = 25; const BULK_VARIANTS = new Set(['primary', 'secondary', 'danger', 'ghost']); /** - * Map a spec `ActionParam` onto the dialog's `BulkActionParam`. The two carry - * the same information under different key names — collecting the values ONCE - * in the bulk dialog is the whole point, so the runner must not be handed the - * param defs again (it would re-prompt per record). + * The plugin-grid-local hand-off key a promoted param carries when the shared + * resolver could NOT find its backing field (`ActionParamDef.unresolvedField`, + * `.`). Written only by {@link toBulkParam} and read only by + * `BulkActionDialog`, which refuses the param on it exactly as the + * single-record dialog does, instead of drawing an input from the placeholder + * type the resolver fell back to. + * + * Deliberately NOT a declared `BulkActionParam` member: that interface is also + * the AUTHORING face of `bulkActionDefs[].params`, and a key only the host + * writes at runtime does not belong there (AGENTS.md #0.1). It rides the + * interface's catch-all, and {@link unresolvedFieldOf} is its one reader. */ -function toBulkParam(p: Record): BulkActionParam | null { - const name = (typeof p.name === 'string' && p.name) || (typeof p.field === 'string' && p.field) || ''; - if (!name) return null; - const out: BulkActionParam = { - ...p, - name, - type: typeof p.type === 'string' ? p.type : 'text', +const UNRESOLVED_FIELD_KEY = 'unresolvedField'; + +/** The `.` a promoted param could not be resolved against, if any. */ +export function unresolvedFieldOf(param: BulkActionParam): string | undefined { + const value = param[UNRESOLVED_FIELD_KEY]; + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** + * Map ONE resolved param onto the bulk dialog's `BulkActionParam` vocabulary. + * + * The input is the shared resolver's output, never the authored param: the + * field lookup, the label (i18n via `fieldLabel`, inline per-locale maps via + * `locale`), the option labels and the picker target were all settled there. + * This function only RENAMES onto the bulk spelling — `helpText` → `help`, + * `defaultValue` → `default`, `referenceTo` → `object`, `displayField` → + * `labelField` — and carries `dependsOn`, which the bulk param declares. + * `accept` / `maxSize` ride along because the file widgets read them and a + * promoted param always delivered them (the hand-mapper spread the raw param). + * + * The rest of the resolved picker config (`descriptionField`, `lookupColumns`, + * `lookupFilters`, …) is NOT forwarded: the bulk adapter (`bulkParamToField`) + * has no rename for those widget keys, and growing one is a convergence of the + * two param adapters, not this card. Named in objectui#12105's PR. + * + * Collecting the values ONCE in the bulk dialog is the whole point, so the + * runner must not be handed the param defs again (it would re-prompt per + * record) — see `stripCollectedActionKeys` in `ObjectGrid`. + */ +function toBulkParam(resolved: ActionParamDef): BulkActionParam | null { + // An inline param with neither `name` nor `field` resolves to `name: ''`: it + // has no body key to collect a value under, so it is not offered. + if (!resolved.name) return null; + return { + name: resolved.name, + label: resolved.label, + type: resolved.type, + ...(resolved.required !== undefined && { required: resolved.required }), + ...(resolved.helpText !== undefined && { help: resolved.helpText }), + ...(resolved.defaultValue !== undefined && { default: resolved.defaultValue }), + ...(resolved.options !== undefined && { options: resolved.options }), + ...(resolved.placeholder !== undefined && { placeholder: resolved.placeholder }), + ...(resolved.multiple !== undefined && { multiple: resolved.multiple }), + ...(resolved.referenceTo !== undefined && { object: resolved.referenceTo }), + ...(resolved.displayField !== undefined && { labelField: resolved.displayField }), + ...(resolved.dependsOn !== undefined && { dependsOn: resolved.dependsOn }), + ...(resolved.accept !== undefined && { accept: resolved.accept }), + ...(resolved.maxSize !== undefined && { maxSize: resolved.maxSize }), + ...(resolved.unresolvedField !== undefined && { [UNRESOLVED_FIELD_KEY]: resolved.unresolvedField }), }; - if (typeof p.label === 'string') out.label = p.label; - if (typeof p.helpText === 'string') out.help = p.helpText; - if (p.defaultValue !== undefined) out.default = p.defaultValue; - // `reference` is the spec spelling of the lookup target; the dialog reads `object`. - if (typeof p.reference === 'string') out.object = p.reference; - return out; +} + +/** + * Resolve a promoted def's params for its dialog, through the ONE resolver + * (objectui#12105). Called by `ObjectGrid` when the user opens the def, after + * it has asked the session for its object metadata — the single-record + * dialog's order exactly (`useConsoleActionRuntime`'s param handler). + * + * Returned unchanged (by reference): + * - an authored def with no attached action — its `params` are already in the + * bulk vocabulary and are used as authored; + * - a def whose author wrote `params` itself (an `execution: 'aggregate'` def + * naming an action): authored keys win every collision, as they do in the + * fold below; + * - an action that declares no params. + * + * `ctx` is the resolver's own context, unchanged: the owning object + * (`objectName`), every object known to the caller (`objects` — union the + * grid's own schema with the session's via `withKnownObjects`, caller first), + * and the label translators. + */ +export function resolvePromotedBulkParams( + def: BulkActionDef, + ctx: ResolveActionParamsContext, +): BulkActionDef { + const source = def.actionDef; + if (!source || Array.isArray(def.params)) return def; + const declared = Array.isArray(source.params) ? source.params : []; + const raw = declared.filter( + (p): p is RawActionParam => !!p && typeof p === 'object', + ); + if (raw.length === 0) return def; + const params = resolveActionParams(raw, ctx) + .map(toBulkParam) + .filter((p): p is BulkActionParam => p !== null); + return params.length > 0 ? { ...def, params } : def; } /** Resolves an action's display label through the app's i18n bundle. */ @@ -111,10 +217,8 @@ export type ActionLabelResolver = (actionName: string, fallback: string) => stri /** Build the selection-bar def for an object action. */ function toBulkActionDef(action: NamedActionDef, localize?: ActionLabelResolver): BulkActionDef { const name = String(action.name); - const rawParams = Array.isArray(action.params) ? action.params : []; - const params = rawParams - .map(p => (p && typeof p === 'object' ? toBulkParam(p as Record) : null)) - .filter((p): p is BulkActionParam => p !== null); + // No `params` here: the action's params are resolved when its dialog opens + // (`resolvePromotedBulkParams`, objectui#12105) — see the module header. const variant = typeof action.variant === 'string' && BULK_VARIANTS.has(action.variant) ? (action.variant as BulkActionDef['variant']) : undefined; @@ -138,7 +242,6 @@ function toBulkActionDef(action: NamedActionDef, localize?: ActionLabelResolver) // Not an `update`/`delete` mass mutation — the source action owns what it // does. `actionDef` below is what makes this run (see useBulkExecutor). operation: 'custom', - ...(params.length > 0 && { params }), ...(confirmText !== undefined && { confirmText }), ...(action.visible != null && { visible: action.visible as BulkActionDef['visible'] }), // [ADR-0066 D4 / objectui#3492] The capability gate travels with the action.