diff --git a/.changeset/resolve-action-param-visible.md b/.changeset/resolve-action-param-visible.md new file mode 100644 index 000000000..0e29128fe --- /dev/null +++ b/.changeset/resolve-action-param-visible.md @@ -0,0 +1,15 @@ +--- +"@object-ui/app-shell": patch +--- + +fix(app-shell): propagate action-param `visible` predicate through resolveActionParams + +The create-user phone fix (#2406) gated the `phoneNumber` param with +`visible: 'features.phoneNumber == true'`, but `resolveActionParam` dropped +`visible` when flattening raw spec params into `ActionParamDef` — so +`ActionParamDialog`'s `filterVisibleParams` never saw the predicate and the +phone field kept rendering even with the phoneNumber auth plugin off. + +Propagate `visible` in all three resolve branches (inline / field-backed / +missing-field), unwrapping the spec's `{ dialect, source }` ExpressionInput +envelope to a plain CEL string. Completes the create-user phone fix end to end. diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts new file mode 100644 index 000000000..bad05eeb6 --- /dev/null +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -0,0 +1,69 @@ +/** + * 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. + */ + +/** + * resolveActionParams — regression coverage for `visible`-predicate propagation. + * + * The create-user phone bug (framework #2871 + objectui #2406) hid the + * `phoneNumber` param via `visible: 'features.phoneNumber == true'`, but the + * resolver dropped `visible` on the way to `ActionParamDialog`, so the field + * kept rendering. These tests lock in that the predicate survives resolution — + * in both the raw-string and the spec-normalised `{ dialect, source }` envelope + * forms, and across inline / field-backed / missing-field branches. + */ +import { describe, it, expect } from 'vitest'; +import { resolveActionParams, type ResolveActionParamsContext, type RawActionParam } from './resolveActionParams'; + +const ctx = (over: Partial = {}): ResolveActionParamsContext => ({ + objectName: 'sys_user', + objects: [ + { name: 'sys_user', fields: { phone_number: { type: 'text', label: 'Phone' } } }, + ], + fieldLabel: (_o, _f, fallback) => fallback, + ...over, +}); + +describe('resolveActionParams — visible propagation', () => { + it('propagates a raw-string visible on an inline param', () => { + const params: RawActionParam[] = [{ name: 'phoneNumber', visible: 'features.phoneNumber == true' }]; + expect(resolveActionParams(params, ctx())[0].visible).toBe('features.phoneNumber == true'); + }); + + it('unwraps the { dialect, source } envelope the spec serialises to', () => { + const params: RawActionParam[] = [ + { name: 'phoneNumber', visible: { dialect: 'cel', source: 'features.phoneNumber == true' } }, + ]; + expect(resolveActionParams(params, ctx())[0].visible).toBe('features.phoneNumber == true'); + }); + + it('propagates visible on a field-backed param', () => { + const params: RawActionParam[] = [{ field: 'phone_number', visible: 'features.phoneNumber == true' }]; + expect(resolveActionParams(params, ctx())[0].visible).toBe('features.phoneNumber == true'); + }); + + it('propagates visible on the missing-field fallback branch', () => { + const params: RawActionParam[] = [{ field: 'does_not_exist', visible: 'features.phoneNumber == true' }]; + // No such field on the object → text-input fallback, but visible still survives. + expect(resolveActionParams(params, ctx())[0].visible).toBe('features.phoneNumber == true'); + }); + + it('leaves visible undefined when the param has no predicate', () => { + const params: RawActionParam[] = [{ name: 'email' }]; + expect(resolveActionParams(params, ctx())[0].visible).toBeUndefined(); + }); + + it('treats an empty predicate as absent (always visible)', () => { + const params: RawActionParam[] = [ + { name: 'a', visible: '' }, + { name: 'b', visible: { dialect: 'cel', source: '' } }, + ]; + const out = resolveActionParams(params, ctx()); + expect(out[0].visible).toBeUndefined(); + expect(out[1].visible).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 80b48b907..19d16240b 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -38,6 +38,15 @@ export interface RawActionParam { defaultValue?: unknown; /** When true, seed defaultValue from the row record using the field name. */ defaultFromRow?: boolean; + /** + * Visibility predicate (CEL) — mirrors the spec `ActionParamSchema.visible`. + * The server serialises it through `ExpressionInputSchema` as an + * `{ dialect, source }` envelope, so accept both the raw string and the + * envelope. Propagated to `ActionParamDef.visible` so `ActionParamDialog` + * can gate the param (e.g. hide `phoneNumber` unless `features.phoneNumber`). + * Absent = always visible. + */ + visible?: string | { dialect?: string; source?: string }; } /** Field metadata as exposed by `useMetadata().objects[].fields`. */ @@ -109,6 +118,20 @@ function normaliseOptions( }); } +/** + * Flatten a param `visible` predicate to a plain CEL string. The spec's + * `ExpressionInputSchema` normalises the authored string into an + * `{ dialect, source }` envelope, so unwrap `.source`; a raw string passes + * through untouched. Empty / absent → `undefined` (always visible). + */ +function normaliseVisible(visible: RawActionParam['visible']): string | undefined { + if (typeof visible === 'string') return visible || undefined; + if (visible && typeof visible === 'object' && typeof visible.source === 'string') { + return visible.source || undefined; + } + return undefined; +} + /** * Resolve a single raw param against object metadata. Inline params pass * through (with safe defaults); field-backed params inherit from the @@ -137,6 +160,7 @@ export function resolveActionParam( placeholder: param.placeholder, helpText: param.helpText, defaultValue: rowDefault ?? param.defaultValue, + visible: normaliseVisible(param.visible), }; } @@ -157,6 +181,7 @@ export function resolveActionParam( placeholder: param.placeholder, helpText: param.helpText, defaultValue: rowDefault ?? param.defaultValue, + visible: normaliseVisible(param.visible), }; } @@ -194,6 +219,7 @@ export function resolveActionParam( placeholder: param.placeholder ?? field.placeholder, helpText: param.helpText ?? field.help ?? field.description, defaultValue: rowDefault ?? param.defaultValue ?? field.defaultValue, + visible: normaliseVisible(param.visible), ...lookupExtras, }; }