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
13 changes: 13 additions & 0 deletions .changeset/action-param-visible.md
Original file line number Diff line number Diff line change
@@ -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).
65 changes: 65 additions & 0 deletions packages/app-shell/src/views/ActionParamDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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']);
});
});
42 changes: 37 additions & 5 deletions packages/app-shell/src/views/ActionParamDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -49,24 +51,54 @@ 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<string, any>,
): 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<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, boolean>>({});

// 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<string, any> = {};
for (const param of state.params) {
for (const param of visibleParams) {
if (param.defaultValue !== undefined) {
defaults[param.name] = param.defaultValue;
}
}
setValues(defaults);
setErrors({});
}
}, [state.open, state.params]);
}, [state.open, visibleParams]);

const isMissingValue = (value: unknown): boolean => {
if (value === undefined || value === null) return true;
Expand All @@ -80,7 +112,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp
const handleSubmit = () => {
// Validate required fields
const newErrors: Record<string, boolean> = {};
for (const param of state.params) {
for (const param of visibleParams) {
if (param.required && isMissingValue(values[param.name])) {
newErrors[param.name] = true;
}
Expand Down Expand Up @@ -116,7 +148,7 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp
</DialogHeader>

<div className="grid gap-4 py-4">
{state.params.map((rawParam) => {
{visibleParams.map((rawParam) => {
const param = {
...rawParam,
label: pickLocalized(rawParam.label, language),
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/actions/ActionRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading