diff --git a/.changeset/action-param-visible.md b/.changeset/action-param-visible.md new file mode 100644 index 000000000..7c61fb6cd --- /dev/null +++ b/.changeset/action-param-visible.md @@ -0,0 +1,13 @@ +--- +"@object-ui/core": patch +"@object-ui/app-shell": patch +--- + +Action params support a `visible` CEL predicate — the param dialog omits a param +when it evaluates false, against the same scope as action `visible` (features / +user / app / data). Fixes the create-user form offering a **Phone Number** field +the default backend rejects ("Phone numbers require the phoneNumber auth plugin"): +paired with the framework gating that param on `features.phoneNumber`, the form +now follows the plugin — no phone field unless the opt-in phoneNumber auth plugin +is loaded. `filterVisibleParams` is exported + unit-tested (feature-off hides, +feature-on shows, malformed predicate fails open). diff --git a/packages/app-shell/src/views/ActionParamDialog.test.tsx b/packages/app-shell/src/views/ActionParamDialog.test.tsx new file mode 100644 index 000000000..07caee7b9 --- /dev/null +++ b/packages/app-shell/src/views/ActionParamDialog.test.tsx @@ -0,0 +1,65 @@ +/** + * 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. + */ + +/** + * filterVisibleParams — action params gated by a `visible` CEL predicate + * (evaluated against the features/user/app/data scope). Fixes the create-user + * form offering a `phoneNumber` field the default backend rejects: the param is + * `visible: 'features.phoneNumber == true'`, so it's hidden unless the opt-in + * phoneNumber auth plugin is loaded. + */ +import { describe, it, expect } from 'vitest'; +import type { ActionParamDef } from '@object-ui/core'; +import { filterVisibleParams } from './ActionParamDialog'; + +const p = (name: string, visible?: string): ActionParamDef => ({ + name, + label: name, + type: 'text', + ...(visible ? { visible } : {}), +}); + +describe('filterVisibleParams', () => { + it('keeps params that have no visible predicate', () => { + const params = [p('email'), p('name')]; + expect(filterVisibleParams(params, {}).map((x) => x.name)).toEqual(['email', 'name']); + }); + + it('hides the phoneNumber param when features.phoneNumber is false', () => { + const params = [p('email'), p('phoneNumber', 'features.phoneNumber == true'), p('name')]; + const out = filterVisibleParams(params, { features: { phoneNumber: false } }); + expect(out.map((x) => x.name)).toEqual(['email', 'name']); + }); + + it('shows the phoneNumber param when features.phoneNumber is true', () => { + const params = [p('email'), p('phoneNumber', 'features.phoneNumber == true'), p('name')]; + const out = filterVisibleParams(params, { features: { phoneNumber: true } }); + expect(out.map((x) => x.name)).toEqual(['email', 'phoneNumber', 'name']); + }); + + it('hides a feature-gated param when the flag is absent (conservative)', () => { + const params = [p('phoneNumber', 'features.phoneNumber == true')]; + expect(filterVisibleParams(params, { features: {} })).toEqual([]); + }); + + it('defaults to visible when the predicate is malformed (fail-open)', () => { + const params = [p('x', 'this is ((( not valid')]; + expect(filterVisibleParams(params, {}).map((x) => x.name)).toEqual(['x']); + }); + + it('handles the normalized {dialect, source} form the spec serializes to', () => { + // The framework's ExpressionInputSchema normalizes the authored string to + // `{ dialect: 'cel', source: '...' }`, so the served param carries the object + // form — the evaluator unwraps `.source`, so gating still works. + const params: ActionParamDef[] = [ + { name: 'phoneNumber', label: 'Phone', type: 'text', visible: { dialect: 'cel', source: 'features.phoneNumber == true' } as any }, + ]; + expect(filterVisibleParams(params, { features: { phoneNumber: false } })).toEqual([]); + expect(filterVisibleParams(params, { features: { phoneNumber: true } }).map((x) => x.name)).toEqual(['phoneNumber']); + }); +}); diff --git a/packages/app-shell/src/views/ActionParamDialog.tsx b/packages/app-shell/src/views/ActionParamDialog.tsx index f86f349ec..c5281c017 100644 --- a/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/packages/app-shell/src/views/ActionParamDialog.tsx @@ -10,7 +10,7 @@ * Returns collected param values or null on cancel. */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Dialog, DialogContent, @@ -31,6 +31,8 @@ import { } from '@object-ui/components'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { ActionParamDef } from '@object-ui/core'; +import { ExpressionEvaluator } from '@object-ui/core'; +import { usePredicateScope } from '@object-ui/react'; import { LookupField } from '@object-ui/fields'; export interface ParamDialogState { @@ -49,16 +51,46 @@ interface ActionParamDialogProps { onOpenChange: (open: boolean) => void; } +/** + * Filter action params by their optional `visible` CEL predicate, evaluated + * against the expression scope (features / user / app / data). A param with no + * predicate is always kept; a predicate that throws defaults to visible (mirrors + * the ExpressionProvider "auth config not loaded yet → visible" contract). Pure + * + exported so the gating is unit-testable without the dialog render tree. + */ +export function filterVisibleParams( + params: ActionParamDef[], + scope: Record, +): ActionParamDef[] { + const evaluator = new ExpressionEvaluator(scope); + return params.filter((p) => { + if (!p.visible) return true; + try { + return evaluator.evaluateCondition(p.visible); + } catch { + return true; + } + }); +} + export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProps) { const { t, language } = useObjectTranslation(); const [values, setValues] = useState>({}); const [errors, setErrors] = useState>({}); + // A param may carry a `visible` predicate (CEL) gating it on the same scope as + // action visibility (features / user / app / data) — e.g. `create_user`'s + // phoneNumber param is `features.phoneNumber == true`, so the form never offers + // a field the backend rejects. Absent = visible; a predicate that errors + // defaults to visible (mirrors the ExpressionProvider "config not loaded" note). + const scope = usePredicateScope(); + const visibleParams = useMemo(() => filterVisibleParams(state.params, scope), [state.params, scope]); + // Reset values when params change useEffect(() => { if (state.open) { const defaults: Record = {}; - for (const param of state.params) { + for (const param of visibleParams) { if (param.defaultValue !== undefined) { defaults[param.name] = param.defaultValue; } @@ -66,7 +98,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp setValues(defaults); setErrors({}); } - }, [state.open, state.params]); + }, [state.open, visibleParams]); const isMissingValue = (value: unknown): boolean => { if (value === undefined || value === null) return true; @@ -80,7 +112,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp const handleSubmit = () => { // Validate required fields const newErrors: Record = {}; - for (const param of state.params) { + for (const param of visibleParams) { if (param.required && isMissingValue(values[param.name])) { newErrors[param.name] = true; } @@ -116,7 +148,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp
- {state.params.map((rawParam) => { + {visibleParams.map((rawParam) => { const param = { ...rawParam, label: pickLocalized(rawParam.label, language), diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 0f49d83f6..7fdf4f002 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -261,6 +261,14 @@ export interface ActionParamDef { helpText?: string; placeholder?: string; validation?: string; + /** + * Visibility predicate (CEL) evaluated against the same scope as action + * `visible` (`current_user` / `app` / `data` / `features`). When it evaluates + * false the param dialog omits this param — used to hide a param the backend + * only accepts under an opt-in capability (e.g. `create_user.phoneNumber` + * gated on `features.phoneNumber`). Absent = always visible. + */ + visible?: string; // ── Lookup-/reference-type metadata ─────────────────────────────── // Populated by `resolveActionParams()` when a field-backed param resolves