Skip to content

Commit d31329f

Browse files
fix(plugin-detail): record:path leaves out a stage the record's status option rules out (#26784)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbRJWyNMbDFFa4LtiRqCeDqp)_ Fixes #26581 Clause-②: no Metadata-protocol: none Ruling-ref: 6442747364 (letter A, with its guardrail); claim amendment 6442931479 ## 维护者速读 - **改了什么**:记录页的阶段路径(`record:path`)不再画出本记录按规则不可能经过的阶段。状态字段的某个选项若声明了 `visibleWhen`,且对这条记录求值为假,该阶段在桌面和移动两行都不画。 - **为什么改**:订单合同(ORD)按类型跳过法务审查,但路径照位置打勾,批准后显示「审查中 ✓」,审计和申请人会读成"法务审过了"。 - **风险与回滚**:只改一个前端渲染组件,无新键、无新请求。引用当前用户的规则不隐藏阶段;规则求值出错时保留阶段;记录当前所在的阶段始终保留。回滚即撤销本 PR。 - **先看**:`ui/packages/plugin-detail/src/renderers/record-path.tsx` 的 `stagesThisRecordWalks` 与 `readsActingUser`;新测试 `record-path.optionVisibleWhen-26581.test.tsx`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/plugin-detail/src/renderers/__tests__/record-path.optionVisibleWhen-26581.test.tsx`,预期 7 个用例全部通过。 - **需要你做的**:无,席位落地。 ## What changes - `record:path` filters its stages through a new `stagesThisRecordWalks`, before won/lost classification, so both rows (desktop and mobile) read one list. A stage is omitted when the `statusField` option with the same value declares a `visibleWhen` that evaluates FALSE against the bound record (`evalFieldPredicate` from `@object-ui/core`, the evaluator the option widgets use). - The guardrail from the ruling: a predicate that reads the acting user (`current_user`, `user`, `ctx` or `os`, the roots the server's option gate treats as the acting user) never hides a stage. It is detected off the predicate source with string literals blanked. That reading can only over-match, and an over-match keeps a stage. - A predicate that cannot be evaluated keeps its stage (render side fail-open, ADR-0137 D3). The stage the record currently stands on is always kept. - Authored `stages[]` are matched to the status options by value, the way `declaredStageKinds` already reads `kind`. - No new spec key, no new server read: the record and object definition come from the record context the block already reads. - One root changeset, `@object-ui/plugin-detail` patch. ## Why hotclm order contracts (ORD) skip legal review by design, yet their path drew 草稿 ✓ 已提交 ✓ 审查中 ✓ 审批中 ✓ 已批准, because every stage before the current one ticked by position. The record's status history is not reachable from this block without a new read (measured in os-dev-report 6441110297), so the ruling took the status option's own declaration instead: `visibleWhen` means "offered only when TRUE (else omitted)", and the server refuses a write of a value whose predicate is false. ## Verification All readings below are on head `a5c3c69f` (base `origin/main` `94bab5a2`, which carries #26689), run from `ui/` under `scripts/pm/os-verify-lock.sh`. <details><summary>Tests</summary> - New file `record-path.optionVisibleWhen-26581.test.tsx`: 7/7. - Every `record:path` test (`record-path*` and `recordPathStagesMembers-8071`): 13 files, 102/102. - Full `packages/plugin-detail/`: Test Files 315 passed, 1 skipped (316); Tests 2911 passed, 18 skipped (2929); lock VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-detail run type-check` (src and `tsconfig.test.json`): exit 0. </details> <details><summary>Ablations (via scripts/ablation-replace.mjs, each restored to the HEAD blob with an empty git diff HEAD)</summary> The test imports the renderer from source (`../../index`, vitest alias table), so no `dist/` sits in between. 1. Filter off (`gates.size === 0` → `gates.size >= 0`): 3 failed / 4 passed. Red: (a) the ORD path, (e) authored stages, (f) the quoted `'user'`. 2. Acting-user guard off (`!readsActingUser(…)` → `!(readsActingUser(…) && false)`): 1 failed / 6 passed. Red: (b), so without the guard `false && current_user…` would hide the stage. 3. Current-stage guard off: 1 failed / 6 passed. Red: (d). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `a5c3c69f` derived 34 commands from 3 changed paths. Every one exited 0, each exit code captured before any pipe. Reconciliation: `--ran` reports "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code and none of them is 3)". The three PR-body gates (`check-adr-0087-registration --base origin/main`, `check-closing-keyword-parity`, `check-changeset-no-major --base origin/main`) were re-run against this exact body: exit 0. Repo-wide lint and the remaining families are CI's. </details> ## Acceptance notes - hotclm still has to declare `visibleWhen` on its `in_review` option (for example `record.contract_type != 'ORD'`); that is the hotclm seat's card, per the ruling. - A rule that reads a related record (`record.account.tier`) is refused by `objectstack validate` on an object field's option, so only the record's own columns can drive a hidden stage. - Edge left as is: if every stage were ruled out and the record's status matched none of them, the block would show its "no stages configured" placeholder. - Serial: PR #26689 edits the same file and is ahead in the merge queue. Once it merges, this branch merges main and keeps this filter ahead of its new classify/layout. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is left to CI. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section is the Chinese brief the PR contract asks for). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTgHpQBzM5GaoKifNh)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a122269 commit d31329f

3 files changed

Lines changed: 283 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): `record:path` leaves out a stage the record's status option rules out, instead of ticking it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
Ruling-ref: #26581 ruling `6442747364` letter A, with its guardrail
10+
11+
A path ticked every stage before the current one by position, so a stage the record could never have stood on read as passed: an order contract that skips legal review by type showed 「审查中 ✓」 after approval. The status field's option already says which records it applies to. Its `visibleWhen` means "offered only when TRUE (else omitted)", and the server refuses a write of a value whose predicate is false. So `record:path` now omits, on both the desktop and the mobile row, any stage whose matching option declares a `visibleWhen` that is FALSE for the record.
12+
13+
- Stages match options by value, so authored `stages[]` are filtered the same way as stages derived from the picklist.
14+
- A predicate that reads the acting user (`current_user`, `user`, `ctx` or `os`) hides nothing. It says who may pick the value, not which route the record takes.
15+
- A predicate that cannot be evaluated keeps its stage, as an option list keeps an option (render side fail-open, ADR-0137 D3).
16+
- The stage the record stands on is always drawn.
17+
18+
No new key and no new read: the predicate is evaluated against the record and object definition the record page already holds.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* ══════════════════════════════════════════════════════════════════════════
9+
* A stage the record's own columns rule out is not drawn (objectstack#26581)
10+
* ══════════════════════════════════════════════════════════════════════════
11+
*
12+
* hotclm's order contracts skip legal review by type, yet their path drew
13+
* 草稿 ✓ 已提交 ✓ 审查中 ✓ 审批中 ✓ 已批准: every stage before the current one
14+
* ticks by position. The status option already says which records it applies
15+
* to — `visibleWhen`, "offered only when TRUE (else omitted)", refused on write
16+
* by the server when false — so `record:path` omits a stage whose option's
17+
* predicate is FALSE for the record. Ruling 6442747364, letter A with its
18+
* guardrail.
19+
*
20+
* Rows:
21+
* (a) the card's contract: an ORD contract's path omits 审查中 on both rows;
22+
* CONTROL, a type the rule admits keeps it;
23+
* (b) a predicate that reads the acting user hides nothing, even where its
24+
* record half alone is FALSE;
25+
* (c) a predicate that cannot be evaluated keeps its stage (fail-open);
26+
* (d) the stage the record stands on is kept even when its rule is FALSE;
27+
* (e) authored `stages[]` are matched to the options by value;
28+
* (f) a quoted `'user'` is a literal, not the acting user.
29+
*
30+
* Resolution: `../../index` is this package's own source, and
31+
* `@object-ui/react` / `@object-ui/core` / `@object-ui/i18n` map to their `src`
32+
* through the root `vitest.config.mts` alias table, so no `dist/` sits between
33+
* this file and the renderer.
34+
*/
35+
36+
import * as React from 'react';
37+
import { describe, it, expect, afterEach, vi } from 'vitest';
38+
import '@testing-library/jest-dom';
39+
import { render, cleanup, within, type RenderResult } from '@testing-library/react';
40+
import { I18nProvider } from '@object-ui/i18n';
41+
import { RecordContextProvider, SchemaRenderer } from '@object-ui/react';
42+
// Registers `record:path`, the block under test.
43+
import '../../index';
44+
45+
const OBJECT_NAME = 'clm_contract';
46+
47+
const NOT_ORD = "record.contract_type != 'ORD'";
48+
49+
const contractDef = (reviewGate: string | undefined) => ({
50+
name: OBJECT_NAME,
51+
label: 'Contract',
52+
fields: {
53+
contract_type: { name: 'contract_type', label: 'Type', type: 'text' },
54+
status: {
55+
name: 'status',
56+
label: 'Status',
57+
type: 'select',
58+
options: [
59+
{ value: 'draft', label: '草稿' },
60+
{ value: 'submitted', label: '已提交' },
61+
{ value: 'in_review', label: '审查中', ...(reviewGate !== undefined ? { visibleWhen: reviewGate } : {}) },
62+
{ value: 'in_approval', label: '审批中' },
63+
{ value: 'approved', label: '已批准' },
64+
],
65+
},
66+
},
67+
});
68+
69+
const doc = (properties: Record<string, unknown>) => ({ type: 'record:path', properties });
70+
71+
function mount(node: unknown, objectSchema: unknown, data: Record<string, unknown>): RenderResult {
72+
return render(
73+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }}>
74+
<RecordContextProvider
75+
objectName={OBJECT_NAME}
76+
recordId="c1"
77+
data={{ id: 'c1', ...data }}
78+
objectSchema={objectSchema as never}
79+
>
80+
<SchemaRenderer schema={node as never} />
81+
</RecordContextProvider>
82+
</I18nProvider>,
83+
);
84+
}
85+
86+
/** Both rows (desktop and mobile), in DOM order. */
87+
function rowsOf(r: RenderResult): [HTMLElement, HTMLElement] {
88+
const text = r.container.textContent ?? '';
89+
expect(text).not.toContain('failed to render');
90+
expect(text).not.toContain('no stages configured');
91+
const rows = Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[];
92+
expect(rows).toHaveLength(2);
93+
return [rows[0], rows[1]];
94+
}
95+
96+
const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem');
97+
const textOf = (stage: HTMLElement): string => (stage.textContent ?? '').replace(/[✓✗]/g, '').trim();
98+
/** `label:state` per stage, so one assertion pins what is drawn AND how. */
99+
const pathOf = (row: HTMLElement): string[] =>
100+
stagesOf(row).map((s) => `${textOf(s)}:${s.getAttribute('data-stage-state')}`);
101+
102+
const ORD_APPROVED_PATH = ['草稿:completed', '已提交:completed', '审批中:completed', '已批准:current'];
103+
const FULL_APPROVED_PATH = ['草稿:completed', '已提交:completed', '审查中:completed', '审批中:completed', '已批准:current'];
104+
105+
afterEach(() => {
106+
cleanup();
107+
vi.restoreAllMocks();
108+
});
109+
110+
describe('(a) a stage the option rules out for this record is not drawn (objectstack#26581)', () => {
111+
it('an ORD contract that was approved: 审查中 is omitted on both rows, not ticked', () => {
112+
const [desktop, mobile] = rowsOf(
113+
mount(doc({ statusField: 'status' }), contractDef(NOT_ORD), { contract_type: 'ORD', status: 'approved' }),
114+
);
115+
expect(pathOf(desktop)).toEqual(ORD_APPROVED_PATH);
116+
expect(pathOf(mobile)).toEqual(ORD_APPROVED_PATH);
117+
});
118+
119+
it('CONTROL: a contract type the rule admits keeps 审查中 as a passed stage', () => {
120+
const [desktop, mobile] = rowsOf(
121+
mount(doc({ statusField: 'status' }), contractDef(NOT_ORD), { contract_type: 'SVC', status: 'approved' }),
122+
);
123+
expect(pathOf(desktop)).toEqual(FULL_APPROVED_PATH);
124+
expect(pathOf(mobile)).toEqual(FULL_APPROVED_PATH);
125+
});
126+
});
127+
128+
describe('(b) a predicate that reads the acting user hides nothing', () => {
129+
it("FALSE on its record half alone, it still keeps 审查中: it says who may pick, not the record's route", () => {
130+
const gate = `${NOT_ORD} && 'legal' in current_user.positions`;
131+
const [desktop, mobile] = rowsOf(
132+
mount(doc({ statusField: 'status' }), contractDef(gate), { contract_type: 'ORD', status: 'approved' }),
133+
);
134+
expect(pathOf(desktop)).toEqual(FULL_APPROVED_PATH);
135+
expect(pathOf(mobile)).toEqual(FULL_APPROVED_PATH);
136+
});
137+
});
138+
139+
describe('(c) a predicate that cannot be evaluated keeps its stage (render fail-open, ADR-0137 D3)', () => {
140+
it('a predicate that does not parse leaves 审查中 drawn', () => {
141+
vi.spyOn(console, 'warn').mockImplementation(() => {});
142+
const [desktop, mobile] = rowsOf(
143+
mount(doc({ statusField: 'status' }), contractDef('record.contract_type !='), {
144+
contract_type: 'ORD',
145+
status: 'approved',
146+
}),
147+
);
148+
expect(pathOf(desktop)).toEqual(FULL_APPROVED_PATH);
149+
expect(pathOf(mobile)).toEqual(FULL_APPROVED_PATH);
150+
});
151+
});
152+
153+
describe('(d) the stage the record stands on is always drawn', () => {
154+
it('an ORD contract standing on 审查中 shows it as current, though its rule is FALSE now', () => {
155+
const [desktop, mobile] = rowsOf(
156+
mount(doc({ statusField: 'status' }), contractDef(NOT_ORD), { contract_type: 'ORD', status: 'in_review' }),
157+
);
158+
const expected = ['草稿:completed', '已提交:completed', '审查中:current', '审批中:upcoming', '已批准:upcoming'];
159+
expect(pathOf(desktop)).toEqual(expected);
160+
expect(pathOf(mobile)).toEqual(expected);
161+
});
162+
});
163+
164+
describe('(e) authored stages are matched to the status options by value', () => {
165+
it('an authored 审查中 stage is omitted for an ORD contract, as the derived one is', () => {
166+
const stages = [
167+
{ value: 'draft', label: 'Draft' },
168+
{ value: 'in_review', label: 'Legal review' },
169+
{ value: 'approved', label: 'Approved' },
170+
];
171+
const [desktop, mobile] = rowsOf(
172+
mount(doc({ statusField: 'status', stages }), contractDef(NOT_ORD), { contract_type: 'ORD', status: 'approved' }),
173+
);
174+
expect(stagesOf(desktop).map(textOf)).toEqual(['Draft', 'Approved']);
175+
expect(stagesOf(mobile).map(textOf)).toEqual(['Draft', 'Approved']);
176+
});
177+
});
178+
179+
describe("(f) a quoted 'user' is a literal, not the acting user", () => {
180+
it('a record-only predicate comparing against the string "user" still hides its stage', () => {
181+
const [desktop] = rowsOf(
182+
mount(doc({ statusField: 'status' }), contractDef("record.contract_type != 'user'"), {
183+
contract_type: 'user',
184+
status: 'approved',
185+
}),
186+
);
187+
expect(stagesOf(desktop).map(textOf)).toEqual(['草稿', '已提交', '审批中', '已批准']);
188+
});
189+
});

‎ui/packages/plugin-detail/src/renderers/record-path.tsx‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
* stages render as upcoming.
1313
*
1414
* When `stages` is absent, the stages come from `statusField`'s picklist on
15-
* the record's object (objectui#11106) — see the `rawStages` read below.
15+
* the record's object (objectui#11106) — see the `rawStages` read below. A
16+
* stage whose option's `visibleWhen` rules this record out is not drawn at all
17+
* (objectstack#26581) — see `stagesThisRecordWalks`.
1618
*
1719
* ── This surface is a READOUT, and it must look like one (objectui#5768) ──
1820
*
@@ -60,6 +62,7 @@ import { useRecordContext, useSafeFieldLabel } from '@object-ui/react';
6062
import { useObjectTranslation, pickLocalized } from '@object-ui/i18n';
6163
import type { RecordPathComponentProps } from '@object-ui/types';
6264
import { cn } from '@object-ui/components';
65+
import { evalFieldPredicate, type FieldRulePredicate } from '@object-ui/core';
6366
import { useDetailTranslation } from '../useDetailTranslation';
6467
import { declaredStageKinds, deriveStages, stageTerminalOfKind } from '../synth/buildDefaultPageSchema';
6568
import { useRecordAriaProps } from './recordComponentAria';
@@ -146,6 +149,70 @@ function localizeStageLabels(stages: unknown, language: string): PathStage[] {
146149
return out ?? (stages as PathStage[]);
147150
}
148151

152+
/**
153+
* The roots that name the ACTING USER, the list the server's option gate reads
154+
* a predicate's roots against (`USER_SCOPE_ROOTS`, objectql
155+
* `validation/rule-validator.ts`).
156+
*/
157+
const ACTING_USER_ROOT = /(^|[^.\w$])(current_user|user|ctx|os)(?![\w$])/;
158+
159+
/**
160+
* Does this predicate read the acting user? Read off the source with its
161+
* string literals blanked, so a quoted `'user'` never counts. Its only error
162+
* is an over-match, and an over-match keeps a stage, never hides one.
163+
*/
164+
function readsActingUser(pred: FieldRulePredicate): boolean {
165+
const source = typeof pred === 'string' ? pred : typeof pred?.source === 'string' ? pred.source : '';
166+
return ACTING_USER_ROOT.test(source.replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, "''"));
167+
}
168+
169+
/**
170+
* The stages THIS record can walk (objectstack#26581): every stage, less those
171+
* whose status-field option declares a `visibleWhen` that is FALSE for the
172+
* record.
173+
*
174+
* The path used to show every option, so a stage the record's own columns rule
175+
* out read as passed once the record was beyond it: hotclm's order contracts
176+
* skip legal review by type, and their path ticked 「审查中 ✓」 anyway. The
177+
* option already says which records it applies to: `visibleWhen` is "offered
178+
* only when TRUE (else omitted)" (`SelectOptionSchema`, spec `data/field.zod.ts`),
179+
* and the server refuses a write of a value whose predicate is false, so the
180+
* record could not have stood on such a stage. Ruling: objectstack#26581
181+
* comment 6442747364, letter A with its guardrail.
182+
*
183+
* • Stages match options by VALUE, as `declaredStageKinds` reads `kind`, so
184+
* authored `stages[]` are filtered too.
185+
* • A predicate that reads the acting user hides nothing: it answers "who may
186+
* pick this value", not "does this record walk this stage".
187+
* • A predicate that cannot be evaluated keeps its stage: the render side
188+
* stays fail-open (ADR-0137 D3), as an option list does.
189+
* • The stage the record stands on is always kept: it is where the record is.
190+
*
191+
* No read is added: the record and its object definition are the ones the
192+
* record context already holds.
193+
*/
194+
function stagesThisRecordWalks(
195+
stages: PathStage[],
196+
options: unknown,
197+
record: Record<string, unknown> | undefined,
198+
current: unknown,
199+
): PathStage[] {
200+
if (!record || !Array.isArray(options)) return stages;
201+
const gates = new Map<unknown, FieldRulePredicate>();
202+
for (const o of options) {
203+
if (o?.visibleWhen != null && !readsActingUser(o.visibleWhen)) gates.set(o.value, o.visibleWhen);
204+
}
205+
if (gates.size === 0) return stages;
206+
const kept = stages.filter((s) => {
207+
const gate = gates.get(s.value);
208+
if (gate === undefined || s.value === current) return true;
209+
return evalFieldPredicate(gate, record, true, undefined, undefined, {
210+
context: `record:path stage '${String(s.value)}' (option visibleWhen)`,
211+
});
212+
});
213+
return kept.length === stages.length ? stages : kept;
214+
}
215+
149216
export interface RecordPathRendererProps {
150217
schema?: RecordPathComponentProps & Record<string, any>;
151218
className?: string;
@@ -238,7 +305,7 @@ export const RecordPathRenderer: React.FC<RecordPathRendererProps> = ({
238305
// record context knows which object owns the field. Falls back to the
239306
// schema's own labels (already English in synth, possibly authored in
240307
// any language for full Lightning pages) when no translation is found.
241-
const stages: PathStage[] = React.useMemo(() => {
308+
const localizedStages: PathStage[] = React.useMemo(() => {
242309
if (rawStages.length === 0 || !statusField || !ctx?.objectName) return rawStages;
243310
const translated = translateOptions(ctx.objectName, statusField, rawStages as any);
244311
if (Array.isArray(translated) && translated.length === rawStages.length) {
@@ -247,6 +314,13 @@ export const RecordPathRenderer: React.FC<RecordPathRendererProps> = ({
247314
return rawStages;
248315
}, [rawStages, statusField, ctx?.objectName, translateOptions]);
249316
const current = statusField && ctx?.data ? (ctx.data as any)[statusField] : undefined;
317+
// Less the stages this record's own columns rule out (objectstack#26581) —
318+
// see `stagesThisRecordWalks`. Both rows below read this one list.
319+
const statusOptions = statusField ? (objectSchema as any)?.fields?.[statusField]?.options : undefined;
320+
const stages: PathStage[] = React.useMemo(
321+
() => stagesThisRecordWalks(localizedStages, statusOptions, ctx?.data as Record<string, unknown> | undefined, current),
322+
[localizedStages, statusOptions, ctx?.data, current],
323+
);
250324

251325
// The lifecycle `kind` the status field's options declare (objectui#12907),
252326
// read off the same `objectSchema` the derivation above reads.

0 commit comments

Comments
 (0)