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
49 changes: 49 additions & 0 deletions .changeset/action-param-option-visible-when.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/spec": major
---

feat(spec): action param 的 `options[]` 讲得出逐选项 `visibleWhen` —— 一个接好线却被门挡着的门控能力 (#5016)

`ActionParamSchema.options[]` 的契约一直是 `{ label, value }`,#4001 批 14 把它从「靠删除来执行」改成了说出口。批 14 同时记下一个它不打算猜的能力问题:这个选项列表该不该讲 `SelectOptionSchema`(`data/field.zod.ts`)已经声明的那套逐选项词汇?#5016 逐键量了一遍,答案**不是整套照搬,而是一个键**。

## 只开 `visibleWhen`,因为只有它有读者

| 键 | 声明在 | action param 选项这条路上的消费者 | 本次 |
|:--|:--|:--|:--|
| `visibleWhen` | `SelectOptionSchema` | **有** —— 四个选项控件全都经 `useCascadingOptions` → `resolveCascadingOptions` 按它过滤(ADR-0058 / objectui#2284) | **开放** |
| `color` | `SelectOptionSchema` | 无 —— 只有**已存值**的展示渲染器读(网格单元格 / 详情徽章);对话框只拿列表建输入控件,提交完就丢 | 继续拒绝,附指路 |
| `default` | `SelectOptionSchema` | 无,且是**层级写错** —— 对话框参数的默认值走参数自己的 `defaultValue`,高一层 | 继续拒绝,附改法 |
| `icon` / `disabled` | 仓里任何 spec 形状都没有 | 无 —— 只活在 objectui 内部 `SelectOptionMetadata` 接口里,四个选项控件里每一个 `disabled` 都是**字段级**的 `props.disabled` | 继续拒绝(#5016 的 C 选项未采纳) |

挡在作者和一个**能工作**的逐选项门控之间的,此前就只有 spec 这道门:内联参数的 `options` 是逐字下沉的(objectui `resolveActionParam` 内联分支 `options: param.options` → `ActionParamDialog` 逐条 spread 只翻译 `label` → `paramToField` 原样交给控件),而 `ExpressionInputSchema` 产出的 `{ dialect, source }` 信封正是 `evalFieldPredicate` 接受的形状。

## 行为激活面 —— FROM → TO

**这是本次最需要注意的一行:同一份元数据,以前写了等于没写,现在真的生效。**

```diff
params: [{
name: 'severity', type: 'select',
options: [
{ label: 'Normal', value: 'normal' },
{ label: 'Overload', value: 'overload',
visibleWhen: "record.status == 'open'" },
],
}]
```

| 版本 | 上面这份 metadata 的下场 |
|:--|:--|
| 16.x | parse **成功**,出来的是 `{"label":"Overload","value":"overload"}` —— `visibleWhen` 在任何渲染器看到它之前就被静默剥掉,选项**永远可选** |
| 17.0.0-rc(#4001 批 14 起) | parse **失败**,`unrecognized_keys` 明确报错 |
| 17.0.0(本次) | parse 成功,键**保留并生效** —— `record.status != 'open'` 时该选项**不再出现在下拉里** |

所以从 16.x 升上来的应用,如果曾经推测性地写过逐选项 `visibleWhen`(当时无害,因为它被丢掉了),升级后选项集会**变窄**。请复查这些谓词是否是你今天真正想要的:不想要就删掉键,想要就确认表达式对 `record` / `current_user` 求值的结果符合预期。`color` / `icon` / `disabled` / `default` 在 16.x 同样被静默剥掉,本次**不会**突然生效 —— 它们改为在 publish 时响亮拒绝,并各自指向该词汇真正生效的地方。

⚠️ **客户端隐藏是 UX,不是授权。** `enforceActionParams` 按声明的选项**值**校验提交(ADR-0104 D2),它不求值逐选项 `visibleWhen`;因访问控制而屏蔽的选项必须由 action 自身的 body 或权限检查再拒一次,只把它从下拉里藏掉是可绕过的。

## 本次**没有**修的一件事(objectui 侧,已另行记录)

**字段回退那条路仍然丢键**:`resolveActionParam` 走的是 `param.options ?? normaliseOptions(field.options, …)`,而 `normaliseOptions` 把每个**继承来的**条目重建成 `{ label, value }`。这条丢弃早于本次改动、也不受本次影响(作者显式写的 `options` 数组优先级更高,压根不经过它),修复归属 objectui。因此本次的拒绝文案仍然刻意**不**开「把参数改成 field-backed 去继承」这张药方 —— 那是一条不存在的路(账本 finding 18:错误里的文案是行为,自信而错的处方比没有更糟)。

`bulk-action.zod.ts` 的 `.passthrough()` 特例维持不动:#4909 那两条理由(逐字到达 grid、objectui `BulkActionParam` 有显式 `[key: string]: unknown` 兜底)在这条路上都不成立,而这里的目标词汇是封闭的 —— 目标词汇封闭,正是「声明」胜过「容忍」的场合。
2 changes: 1 addition & 1 deletion content/docs/references/ui/action.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ const result = ActionSchema.parse(data);
| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
| **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | optional | |
| **required** | `boolean` | optional | |
| **options** | `{ label: string; value: string }[]` | optional | |
| **options** | `{ label: string; value: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }[]` | optional | |
| **placeholder** | `string` | optional | |
| **helpText** | `string` | optional | |
| **defaultValue** | `any` | optional | |
Expand Down
16 changes: 16 additions & 0 deletions packages/qa/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
'system/settings-manifest.zod.ts:visible',
],
},
{
id: 'cel-action-param-option-visible',
summary: "action param option-list per-option gating (params[].options[].visibleWhen, #5016)",
// Same key, same evaluator and same binding environment as the per-option
// `visibleWhen` on a FIELD's option list — which is why it is `cel`,
// `interpret` and `fail-soft-log` like `cel-field-rule` rather than
// fail-closed: `evalFieldPredicate` is called with `fallback: true`, so a
// broken predicate leaves the option OFFERED instead of silently deleting a
// choice the author never meant to remove. It is a SEPARATE row from
// `cel-ui` because the evaluator differs: `cel-ui`'s surfaces hide an
// element through the SchemaRenderer, this one narrows an option LIST
// inside the field widgets.
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
enforcement: 'console (objectui) ActionParamDialog → paramToField → SelectField / MultiSelectField / RadioField / CheckboxesField → useCascadingOptions → resolveCascadingOptions (core/evaluator/optionRules.ts) → evalFieldPredicate → @objectstack/formula celEngine (interpret), evaluated per OPTION against the live param bag + current_user; a value no longer offered is dropped from the param. UI gating only — `enforceActionParams` (ADR-0104 D2) validates the submitted value against the declared option VALUES and does not evaluate this predicate, so access-control gating must also be enforced by the action body / permissions',
covers: ['ui/action.zod.ts:visibleWhen'],
},
{
id: 'cel-bulk-action-visible',
summary: "selection-bar bulk action per-record eligibility (bulkActionDefs[].visible, objectui#3067)",
Expand Down
163 changes: 163 additions & 0 deletions packages/spec/src/ui/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1407,3 +1407,166 @@ describe('#3896 close-out — retired shortcut/bulkEnabled', () => {
expect(message).toMatch(/#3896/);
});
});

// ---------------------------------------------------------------------------
// #5016 — per-option `visibleWhen` on an action param's option list
// ---------------------------------------------------------------------------

/**
* #4001 批 14 closed this option entry at `{ label, value }` and filed the
* capability question as #5016. #5016 answered it PER KEY, on measurement of
* what an action param's option list can actually reach in objectui:
*
* - `visibleWhen` has a reader on this exact path, so it is declared.
* - `color` / `default` / `icon` / `disabled` do not, so they stay rejected —
* with the guidance that says where each vocabulary IS real.
*
* Every assertion below goes through a REAL door — `getMetadataTypeSchema('action')`
* (what `MetadataManager.validate` / `GET /api/v1/meta` / the Studio form use)
* or `ObjectSchema.actions[]` — rather than through `ActionParamSchema`
* directly, because the defect #5016 records was not "the sub-schema strips it"
* but "the key never survives the door an author's metadata actually crosses".
*/
describe('#5016 — action param option vocabulary', () => {
const gatedAction = {
name: 'escalate',
label: 'Escalate',
type: 'script' as const,
target: 'escalate_handler',
params: [{
name: 'severity',
label: 'Severity',
type: 'select' as const,
options: [
{ label: 'Normal', value: 'normal' },
{ label: 'Overload', value: 'overload', visibleWhen: "record.status == 'open'" },
],
}],
};

it('SURVIVES the metadata door — declared AND delivered, not declared-then-stripped', () => {
const schema = getMetadataTypeSchema('action');
expect(schema, "the 'action' metadata type must resolve to a schema").toBeDefined();
const result = schema!.safeParse(gatedAction);
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);

// The load-bearing half. Batch 14 measured this same payload coming back as
// `{"label":"Overload","value":"overload"}` — parsed clean, key gone before
// any renderer saw it. Asserting only `success` would still pass in that
// world, which is exactly the ADR-0078 shape this change exists to end.
const options = (result.data as any).params[0].options;
expect(options[1]).toMatchObject({
label: 'Overload',
value: 'overload',
// `ExpressionInputSchema` normalises the authored string into the wire
// envelope objectui's `evalFieldPredicate` accepts (`FieldRulePredicate =
// string | { dialect?, source }`).
visibleWhen: { dialect: 'cel', source: "record.status == 'open'" },
});
// An option that declares no predicate stays predicate-free — `visibleWhen`
// is optional, not defaulted to an always-true expression.
expect(options[0].visibleWhen).toBeUndefined();
});

it('survives the other real door too — nested in `object.actions[]`', () => {
const result = ObjectSchema.safeParse({
name: 'crm_case',
label: 'Case',
fields: { status: { label: 'Status', type: 'text' } },
actions: [gatedAction],
});
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
expect((result.data as any).actions[0].params[0].options[1].visibleWhen)
.toEqual({ dialect: 'cel', source: "record.status == 'open'" });
});

it('accepts the canonical `{ dialect, source }` envelope as authored', () => {
const result = getMetadataTypeSchema('action')!.safeParse({
...gatedAction,
params: [{
name: 'severity',
type: 'select' as const,
options: [{
label: 'Overload',
value: 'overload',
visibleWhen: { dialect: 'cel', source: "'admin' in current_user.positions" },
}],
}],
});
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});

it('does NOT open the keys whose readers this surface cannot reach', () => {
// `color` / `default` are declared one layer down on `SelectOptionSchema`;
// `icon` / `disabled` are declared nowhere in the spec. Neither group has a
// consumer an action param's option list reaches — the dialog builds an
// INPUT from the list and discards it — so both stay rejected. Opening them
// for vocabulary symmetry would be the parses-clean-changes-nothing key.
for (const key of ['color', 'default', 'icon', 'disabled']) {
const result = getMetadataTypeSchema('action')!.safeParse({
...gatedAction,
params: [{
name: 'severity',
type: 'select' as const,
options: [{ label: 'Overload', value: 'overload', [key]: key === 'disabled' || key === 'default' ? true : 'x' }],
}],
});
expect(result.success, `\`${key}\` must stay rejected on an action param option`).toBe(false);
}
});

it('keeps each rejection pointing at where that vocabulary IS real', () => {
const messageFor = (option: Record<string, unknown>): string => {
const r = getMetadataTypeSchema('action')!.safeParse({
...gatedAction,
params: [{ name: 'severity', type: 'select' as const, options: [option] }],
});
return JSON.stringify(r.error?.issues ?? []);
};

// `color`: real one layer down, on the STORED-value display path. The
// sentence must no longer defer to #5016 as an open question — it is
// decided — and must not promise the field-backed inheritance route, which
// `normaliseOptions` still drops (ledger finding 18).
const color = messageFor({ label: 'A', value: 'a', color: 'red' });
expect(color).toContain('SelectOptionSchema');
expect(color).not.toContain('do not rely on it today');

// `default`: a wrong-LAYER key, not a missing capability. The prescription
// is the param's own `defaultValue`, one level up.
expect(messageFor({ label: 'A', value: 'a', default: true })).toContain('defaultValue');

// `icon`: declared nowhere — claiming it lives on `SelectOptionSchema`
// would be the false-prescription class.
const icon = messageFor({ label: 'A', value: 'a', icon: 'x' });
expect(icon).toContain('no option shape in the spec declares');
expect(icon).not.toContain('is a per-option key of a FIELD');
});

it('points the two rival spellings at the newly declared key', () => {
for (const alias of ['visible', 'showWhen']) {
const r = getMetadataTypeSchema('action')!.safeParse({
...gatedAction,
params: [{
name: 'severity',
type: 'select' as const,
options: [{ label: 'A', value: 'a', [alias]: "record.status == 'open'" }],
}],
});
expect(r.success).toBe(false);
expect(JSON.stringify(r.error?.issues)).toContain(`\`${alias}\` → \`visibleWhen\``);
}
});

it('leaves the PARAM-level canonical spelling alone — `visibleWhen` there still means `visible`', () => {
// The two surfaces have opposite canonical spellings on purpose (a param
// gates itself with `visible`; an option gates itself with `visibleWhen`),
// so opening the option key must not blur the one level up.
const r = getMetadataTypeSchema('action')!.safeParse({
...gatedAction,
params: [{ name: 'severity', type: 'text' as const, visibleWhen: 'features.x == true' }],
});
expect(r.success).toBe(false);
expect(JSON.stringify(r.error?.issues)).toContain('`visibleWhen` → `visible`');
});
});
Loading
Loading