Skip to content

Commit f6609e6

Browse files
qq9340100os-devclaude
authored
feat(spec): action param 的 options[] 声明逐选项 visibleWhen (#5016) (#6235)
* feat(spec): action param 的 options[] 声明逐选项 visibleWhen (#5016) 批 14 把 ActionParamSchema.options[] 关成 { label, value } 并把能力问题 留给 #5016。逐键量过消费面后,只开 visibleWhen 一个键 —— 它是唯一一个 在 action param 这条路上真有读者的:内联参数的 options 逐字下沉 (resolveActionParam 内联分支 → ActionParamDialog → paramToField),四个 选项控件都经 useCascadingOptions → resolveCascadingOptions 按它过滤, 且 evalFieldPredicate 接受 ExpressionInputSchema 产出的 { dialect, source } 信封。此前挡在作者和这个能工作的门控之间的,只有 spec 这道门。 color / default 继续拒绝:前者只被"已存值"的展示渲染器读(网格单元格 / 详情徽章),对话框只拿列表建输入控件;后者是层级写错,参数的默认值走 高一层的 defaultValue。icon / disabled 也未升级进 SelectOptionSchema (#5016 的 C 选项)—— 重测确认 objectui 无任何读者,四个控件里的 disabled 全是字段级 props.disabled。四个键各自保留指路的 guidance。 visibleWhen 的 guidance 条目必须移除(声明键到不了 unrecognized_keys 这条路,留着就是 alias-integrity 判定的死条目),并把 SelectOptionSchema 的两个拼法 visible / showWhen 作为 alias 接过来。 新测试全部走真实的门(getMetadataTypeSchema('action') 与 ObjectSchema.actions[]),并断言键在 parse 输出里"活着到达",而不只是 parse 成功 —— 只断言 success 在批 14 之前那个静默剥离的世界里同样会绿。 Co-Authored-By: Claude <noreply@anthropic.com> * test(dogfood): 给 action param 选项的 visibleWhen 补 ADR-0058 D7 表达式面账本行 (#5016) expression-conformance 的 ratchet 会重新扫描 packages/spec/src 下每个 ExpressionInputSchema 声明,新增的 ui/action.zod.ts:visibleWhen 没有归类, CI 的 Dogfood Regression Gate (3/3) 因此报 "UNCLASSIFIED surface"。本仓 scoped 的 spec / runtime 测试看不到这一条 —— 它只在 dogfood 分片里跑。 单开一行而不是并进 cel-ui:cel-ui 那批是 SchemaRenderer 藏元素,这一条是 在字段控件内部收窄一个选项列表,evaluator 不同。tier 取 fail-soft-log, 与 cel-field-rule 一致 —— evalFieldPredicate 的 fallback 是 true,谓词坏掉 时选项保持可选,而不是无声删掉一个作者没打算拿走的选择。 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: os-dev <sales@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent e4a03d2 commit f6609e6

5 files changed

Lines changed: 344 additions & 42 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec): action param 的 `options[]` 讲得出逐选项 `visibleWhen` —— 一个接好线却被门挡着的门控能力 (#5016)
6+
7+
`ActionParamSchema.options[]` 的契约一直是 `{ label, value }`,#4001 批 14 把它从「靠删除来执行」改成了说出口。批 14 同时记下一个它不打算猜的能力问题:这个选项列表该不该讲 `SelectOptionSchema`(`data/field.zod.ts`)已经声明的那套逐选项词汇?#5016 逐键量了一遍,答案**不是整套照搬,而是一个键**
8+
9+
## 只开 `visibleWhen`,因为只有它有读者
10+
11+
|| 声明在 | action param 选项这条路上的消费者 | 本次 |
12+
|:--|:--|:--|:--|
13+
| `visibleWhen` | `SelectOptionSchema` | **** —— 四个选项控件全都经 `useCascadingOptions``resolveCascadingOptions` 按它过滤(ADR-0058 / objectui#2284) | **开放** |
14+
| `color` | `SelectOptionSchema` | 无 —— 只有**已存值**的展示渲染器读(网格单元格 / 详情徽章);对话框只拿列表建输入控件,提交完就丢 | 继续拒绝,附指路 |
15+
| `default` | `SelectOptionSchema` | 无,且是**层级写错** —— 对话框参数的默认值走参数自己的 `defaultValue`,高一层 | 继续拒绝,附改法 |
16+
| `icon` / `disabled` | 仓里任何 spec 形状都没有 | 无 —— 只活在 objectui 内部 `SelectOptionMetadata` 接口里,四个选项控件里每一个 `disabled` 都是**字段级**`props.disabled` | 继续拒绝(#5016 的 C 选项未采纳) |
17+
18+
挡在作者和一个**能工作**的逐选项门控之间的,此前就只有 spec 这道门:内联参数的 `options` 是逐字下沉的(objectui `resolveActionParam` 内联分支 `options: param.options``ActionParamDialog` 逐条 spread 只翻译 `label``paramToField` 原样交给控件),而 `ExpressionInputSchema` 产出的 `{ dialect, source }` 信封正是 `evalFieldPredicate` 接受的形状。
19+
20+
## 行为激活面 —— FROM → TO
21+
22+
**这是本次最需要注意的一行:同一份元数据,以前写了等于没写,现在真的生效。**
23+
24+
```diff
25+
params: [{
26+
name: 'severity', type: 'select',
27+
options: [
28+
{ label: 'Normal', value: 'normal' },
29+
{ label: 'Overload', value: 'overload',
30+
visibleWhen: "record.status == 'open'" },
31+
],
32+
}]
33+
```
34+
35+
| 版本 | 上面这份 metadata 的下场 |
36+
|:--|:--|
37+
| 16.x | parse **成功**,出来的是 `{"label":"Overload","value":"overload"}` —— `visibleWhen` 在任何渲染器看到它之前就被静默剥掉,选项**永远可选** |
38+
| 17.0.0-rc(#4001 批 14 起) | parse **失败**,`unrecognized_keys` 明确报错 |
39+
| 17.0.0(本次) | parse 成功,键**保留并生效** —— `record.status != 'open'` 时该选项**不再出现在下拉里** |
40+
41+
所以从 16.x 升上来的应用,如果曾经推测性地写过逐选项 `visibleWhen`(当时无害,因为它被丢掉了),升级后选项集会**变窄**。请复查这些谓词是否是你今天真正想要的:不想要就删掉键,想要就确认表达式对 `record` / `current_user` 求值的结果符合预期。`color` / `icon` / `disabled` / `default` 在 16.x 同样被静默剥掉,本次**不会**突然生效 —— 它们改为在 publish 时响亮拒绝,并各自指向该词汇真正生效的地方。
42+
43+
⚠️ **客户端隐藏是 UX,不是授权。** `enforceActionParams` 按声明的选项****校验提交(ADR-0104 D2),它不求值逐选项 `visibleWhen`;因访问控制而屏蔽的选项必须由 action 自身的 body 或权限检查再拒一次,只把它从下拉里藏掉是可绕过的。
44+
45+
## 本次**没有**修的一件事(objectui 侧,已另行记录)
46+
47+
**字段回退那条路仍然丢键**:`resolveActionParam` 走的是 `param.options ?? normaliseOptions(field.options, …)`,而 `normaliseOptions` 把每个**继承来的**条目重建成 `{ label, value }`。这条丢弃早于本次改动、也不受本次影响(作者显式写的 `options` 数组优先级更高,压根不经过它),修复归属 objectui。因此本次的拒绝文案仍然刻意****开「把参数改成 field-backed 去继承」这张药方 —— 那是一条不存在的路(账本 finding 18:错误里的文案是行为,自信而错的处方比没有更糟)。
48+
49+
`bulk-action.zod.ts``.passthrough()` 特例维持不动:#4909 那两条理由(逐字到达 grid、objectui `BulkActionParam` 有显式 `[key: string]: unknown` 兜底)在这条路上都不成立,而这里的目标词汇是封闭的 —— 目标词汇封闭,正是「声明」胜过「容忍」的场合。

content/docs/references/ui/action.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ const result = ActionSchema.parse(data);
167167
| **label** | `string` | optional | Display label (plain string; i18n keys are auto-generated by the framework) |
168168
| **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 | |
169169
| **required** | `boolean` | optional | |
170-
| **options** | `{ label: string; value: string }[]` | optional | |
170+
| **options** | `{ label: string; value: string; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }[]` | optional | |
171171
| **placeholder** | `string` | optional | |
172172
| **helpText** | `string` | optional | |
173173
| **defaultValue** | `any` | optional | |

packages/qa/dogfood/test/expression-conformance.ledger.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,22 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
146146
'system/settings-manifest.zod.ts:visible',
147147
],
148148
},
149+
{
150+
id: 'cel-action-param-option-visible',
151+
summary: "action param option-list per-option gating (params[].options[].visibleWhen, #5016)",
152+
// Same key, same evaluator and same binding environment as the per-option
153+
// `visibleWhen` on a FIELD's option list — which is why it is `cel`,
154+
// `interpret` and `fail-soft-log` like `cel-field-rule` rather than
155+
// fail-closed: `evalFieldPredicate` is called with `fallback: true`, so a
156+
// broken predicate leaves the option OFFERED instead of silently deleting a
157+
// choice the author never meant to remove. It is a SEPARATE row from
158+
// `cel-ui` because the evaluator differs: `cel-ui`'s surfaces hide an
159+
// element through the SchemaRenderer, this one narrows an option LIST
160+
// inside the field widgets.
161+
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
162+
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',
163+
covers: ['ui/action.zod.ts:visibleWhen'],
164+
},
149165
{
150166
id: 'cel-bulk-action-visible',
151167
summary: "selection-bar bulk action per-record eligibility (bulkActionDefs[].visible, objectui#3067)",

packages/spec/src/ui/action.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,3 +1407,166 @@ describe('#3896 close-out — retired shortcut/bulkEnabled', () => {
14071407
expect(message).toMatch(/#3896/);
14081408
});
14091409
});
1410+
1411+
// ---------------------------------------------------------------------------
1412+
// #5016 — per-option `visibleWhen` on an action param's option list
1413+
// ---------------------------------------------------------------------------
1414+
1415+
/**
1416+
* #4001 批 14 closed this option entry at `{ label, value }` and filed the
1417+
* capability question as #5016. #5016 answered it PER KEY, on measurement of
1418+
* what an action param's option list can actually reach in objectui:
1419+
*
1420+
* - `visibleWhen` has a reader on this exact path, so it is declared.
1421+
* - `color` / `default` / `icon` / `disabled` do not, so they stay rejected —
1422+
* with the guidance that says where each vocabulary IS real.
1423+
*
1424+
* Every assertion below goes through a REAL door — `getMetadataTypeSchema('action')`
1425+
* (what `MetadataManager.validate` / `GET /api/v1/meta` / the Studio form use)
1426+
* or `ObjectSchema.actions[]` — rather than through `ActionParamSchema`
1427+
* directly, because the defect #5016 records was not "the sub-schema strips it"
1428+
* but "the key never survives the door an author's metadata actually crosses".
1429+
*/
1430+
describe('#5016 — action param option vocabulary', () => {
1431+
const gatedAction = {
1432+
name: 'escalate',
1433+
label: 'Escalate',
1434+
type: 'script' as const,
1435+
target: 'escalate_handler',
1436+
params: [{
1437+
name: 'severity',
1438+
label: 'Severity',
1439+
type: 'select' as const,
1440+
options: [
1441+
{ label: 'Normal', value: 'normal' },
1442+
{ label: 'Overload', value: 'overload', visibleWhen: "record.status == 'open'" },
1443+
],
1444+
}],
1445+
};
1446+
1447+
it('SURVIVES the metadata door — declared AND delivered, not declared-then-stripped', () => {
1448+
const schema = getMetadataTypeSchema('action');
1449+
expect(schema, "the 'action' metadata type must resolve to a schema").toBeDefined();
1450+
const result = schema!.safeParse(gatedAction);
1451+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
1452+
1453+
// The load-bearing half. Batch 14 measured this same payload coming back as
1454+
// `{"label":"Overload","value":"overload"}` — parsed clean, key gone before
1455+
// any renderer saw it. Asserting only `success` would still pass in that
1456+
// world, which is exactly the ADR-0078 shape this change exists to end.
1457+
const options = (result.data as any).params[0].options;
1458+
expect(options[1]).toMatchObject({
1459+
label: 'Overload',
1460+
value: 'overload',
1461+
// `ExpressionInputSchema` normalises the authored string into the wire
1462+
// envelope objectui's `evalFieldPredicate` accepts (`FieldRulePredicate =
1463+
// string | { dialect?, source }`).
1464+
visibleWhen: { dialect: 'cel', source: "record.status == 'open'" },
1465+
});
1466+
// An option that declares no predicate stays predicate-free — `visibleWhen`
1467+
// is optional, not defaulted to an always-true expression.
1468+
expect(options[0].visibleWhen).toBeUndefined();
1469+
});
1470+
1471+
it('survives the other real door too — nested in `object.actions[]`', () => {
1472+
const result = ObjectSchema.safeParse({
1473+
name: 'crm_case',
1474+
label: 'Case',
1475+
fields: { status: { label: 'Status', type: 'text' } },
1476+
actions: [gatedAction],
1477+
});
1478+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
1479+
expect((result.data as any).actions[0].params[0].options[1].visibleWhen)
1480+
.toEqual({ dialect: 'cel', source: "record.status == 'open'" });
1481+
});
1482+
1483+
it('accepts the canonical `{ dialect, source }` envelope as authored', () => {
1484+
const result = getMetadataTypeSchema('action')!.safeParse({
1485+
...gatedAction,
1486+
params: [{
1487+
name: 'severity',
1488+
type: 'select' as const,
1489+
options: [{
1490+
label: 'Overload',
1491+
value: 'overload',
1492+
visibleWhen: { dialect: 'cel', source: "'admin' in current_user.positions" },
1493+
}],
1494+
}],
1495+
});
1496+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
1497+
});
1498+
1499+
it('does NOT open the keys whose readers this surface cannot reach', () => {
1500+
// `color` / `default` are declared one layer down on `SelectOptionSchema`;
1501+
// `icon` / `disabled` are declared nowhere in the spec. Neither group has a
1502+
// consumer an action param's option list reaches — the dialog builds an
1503+
// INPUT from the list and discards it — so both stay rejected. Opening them
1504+
// for vocabulary symmetry would be the parses-clean-changes-nothing key.
1505+
for (const key of ['color', 'default', 'icon', 'disabled']) {
1506+
const result = getMetadataTypeSchema('action')!.safeParse({
1507+
...gatedAction,
1508+
params: [{
1509+
name: 'severity',
1510+
type: 'select' as const,
1511+
options: [{ label: 'Overload', value: 'overload', [key]: key === 'disabled' || key === 'default' ? true : 'x' }],
1512+
}],
1513+
});
1514+
expect(result.success, `\`${key}\` must stay rejected on an action param option`).toBe(false);
1515+
}
1516+
});
1517+
1518+
it('keeps each rejection pointing at where that vocabulary IS real', () => {
1519+
const messageFor = (option: Record<string, unknown>): string => {
1520+
const r = getMetadataTypeSchema('action')!.safeParse({
1521+
...gatedAction,
1522+
params: [{ name: 'severity', type: 'select' as const, options: [option] }],
1523+
});
1524+
return JSON.stringify(r.error?.issues ?? []);
1525+
};
1526+
1527+
// `color`: real one layer down, on the STORED-value display path. The
1528+
// sentence must no longer defer to #5016 as an open question — it is
1529+
// decided — and must not promise the field-backed inheritance route, which
1530+
// `normaliseOptions` still drops (ledger finding 18).
1531+
const color = messageFor({ label: 'A', value: 'a', color: 'red' });
1532+
expect(color).toContain('SelectOptionSchema');
1533+
expect(color).not.toContain('do not rely on it today');
1534+
1535+
// `default`: a wrong-LAYER key, not a missing capability. The prescription
1536+
// is the param's own `defaultValue`, one level up.
1537+
expect(messageFor({ label: 'A', value: 'a', default: true })).toContain('defaultValue');
1538+
1539+
// `icon`: declared nowhere — claiming it lives on `SelectOptionSchema`
1540+
// would be the false-prescription class.
1541+
const icon = messageFor({ label: 'A', value: 'a', icon: 'x' });
1542+
expect(icon).toContain('no option shape in the spec declares');
1543+
expect(icon).not.toContain('is a per-option key of a FIELD');
1544+
});
1545+
1546+
it('points the two rival spellings at the newly declared key', () => {
1547+
for (const alias of ['visible', 'showWhen']) {
1548+
const r = getMetadataTypeSchema('action')!.safeParse({
1549+
...gatedAction,
1550+
params: [{
1551+
name: 'severity',
1552+
type: 'select' as const,
1553+
options: [{ label: 'A', value: 'a', [alias]: "record.status == 'open'" }],
1554+
}],
1555+
});
1556+
expect(r.success).toBe(false);
1557+
expect(JSON.stringify(r.error?.issues)).toContain(`\`${alias}\` → \`visibleWhen\``);
1558+
}
1559+
});
1560+
1561+
it('leaves the PARAM-level canonical spelling alone — `visibleWhen` there still means `visible`', () => {
1562+
// The two surfaces have opposite canonical spellings on purpose (a param
1563+
// gates itself with `visible`; an option gates itself with `visibleWhen`),
1564+
// so opening the option key must not blur the one level up.
1565+
const r = getMetadataTypeSchema('action')!.safeParse({
1566+
...gatedAction,
1567+
params: [{ name: 'severity', type: 'text' as const, visibleWhen: 'features.x == true' }],
1568+
});
1569+
expect(r.success).toBe(false);
1570+
expect(JSON.stringify(r.error?.issues)).toContain('`visibleWhen` → `visible`');
1571+
});
1572+
});

0 commit comments

Comments
 (0)