Skip to content

Commit 5ec398d

Browse files
feat(app-shell): the Studio option editor authors an option's lifecycle kind (#26931)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8DR4ycmjDiMqujsSBS8TVCg)_ Fixes #26729 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 字段检查器的选项编辑器,在单选 select / radio 字段的每个选项上新增「生命周期类别」选择(未设 / 进行中 / 成功结束 / 失败结束),写入 spec 已发布的 `kind`。 - 为什么改:对象把这个字段选为 Lifecycle 字段时,spec 要求每个选项都声明 `kind`,而 Studio 原来没有任何地方能写它,保存一律 422,只能走 API。 - 风险与回滚:只动 Studio 编辑器与 i18n 文案,不改 spec、不改引擎;多值字段不画控件也不写 `kind`,改成多值类型时会去掉 `kind`;回滚即 revert 本 PR。 - 先看:`ObjectFieldInspector.tsx` 的 `patchOptions` 与 `changeType`(写入与去除 `kind` 的两处),其次 `classifyOption`(非法 `kind` 走异常行)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.optionKind-26729.test.tsx`,预期 16 个测试全过。 - 需要你做的:无,席位落地。 ## What changes - **`kind` becomes an owned key of the picklist option editor.** `Option` gains `kind?: SelectOptionKind`; `classifyOption` moves it out of the `rest` bag into its own slot (the docblock's "the three parts move together": `Option`, `readOptions`, `patchOptions`). The vocabulary is read from the installed contract (`SelectOptionSchema.shape.kind.unwrap().options`), not re-listed. - **A picker per option row** (`data-testid="option-kind"`, the shared Radix `Select`): "No kind" / Open / Won / Lost. "No kind" removes the key outright — absent stays absent, which is what lets `ObjectSchema` tell "declared open" from "forgot the key" under `stageField`. - **Only where the spec accepts it.** The picker is drawn, and `patchOptions` writes `kind`, only on a single-value `select` / `radio` (`takesOptionKind`: `SINGLE_OPTION_TYPES` and `multiple !== true`, the same test `FieldSchema` applies). On a multi-value field no picker is drawn and no `kind` is written; a kind authored there by other means (which `FieldSchema` already refuses) is not written back. A retype to a type that cannot carry one drops the kinds, as a retype already drops a `picklist` the new type cannot name; a retype to `radio` keeps them. - **An out-of-vocabulary `kind`** (`kind: 'closed'`) is a malformed row (`kind-not-valid`), carried verbatim like the other malformed shapes, rather than silently dropped. - **The lifecycle hint.** When the object names this field as its `stageField` and an option still has no kind, the inspector shows one held-hint line saying so; it clears once every option has one. - **i18n.** New `designer.field.*` rows in the `studio` namespace, `en` and `zh`: `optKind`, `optKind.unset` / `.open` / `.won` / `.lost`, `optMalformed.kindNotValid`, `hint.stageKinds`. No `engine.studio.settings.*` row. Patch changeset for `@object-ui/i18n` (app-shell is private). - Not touched: `ObjectSettingsPanel.tsx` (the Lifecycle picker itself), the spec, the engine. ## Why The Lifecycle field picker refused a Studio-authored select (`Option 'new' of field status declares no kind`), and the option editor had no `kind` control: the triage grade asks for the control for the published option `kind` (#23809). ## Tests New `ObjectFieldInspector.optionKind-26729.test.tsx` (16 tests), every case ending at `ObjectSchema` / `FieldSchema` or the written document's keys: - CONTROL: the Studio-built select is refused as the stage field at `fields.status.options.{0,1,2}.kind`. - Picking Open / Won / Lost writes the three kinds, and `ObjectSchema` then accepts the object with `stageField: 'status'`; with one option left unset, the refusal names only that option. - "No kind" removes the key; an existing kind round-trips through an edit of its own row and another row; the picker shows the stored kind. - multiselect / checkboxes / `select` with `multiple: true`: no picker, no `kind` written, `FieldSchema` accepts. - A kind authored on a multiselect is not written back; an out-of-vocabulary kind is a malformed row kept verbatim. - A retype to Multi-Select drops the kinds; a retype to Radio keeps them; `FieldSchema` accepts both. - The lifecycle hint appears while an option lacks a kind and clears once all have one; CONTROL: no hint when the object names another stage field. - zh: the picker's name and the kind label come from the studio catalogue (`await loadStudioCatalogue('zh')` at import). Ablation (one-shot, through `scripts/ablation-replace.mjs`, mutation committed-then-restored): deleting `if (o.kind && kindAllowed) out.kind = o.kind;` from `patchOptions` turned 5 of the 16 tests red (the three authoring cases, the round trip, the hint); restore verified blob == HEAD and `git diff HEAD` empty. ## Acceptance notes - `ObjectSettingsPanel`'s Lifecycle picker is unchanged; the order marked that line optional and the inspector's hint covers what the picker would have said. - Two whole-tree scans in `ui/scripts/__tests__` (`check-i18n-dead-keys.test.ts` "on the real tree", `check-vi-mock-inherit.test.ts` "the sweep") each hit their 15 s test budget once on this shared box during the full `vitest run scripts/` (a different test each run); each file passes alone. Load-sensitive, not this diff; CI's run is the verdict. ## Verification <details><summary>Local runs (worktree head 7a53b40, after merging origin/main 96e7a43)</summary> - Closure build `pnpm --filter '@object-ui/app-shell^...' build` under the verify lock: VERDICT command-exit 0. - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/ packages/app-shell/src/views/studio-design/ packages/i18n/ packages/app-shell/src/__tests__/`: Test Files 527 passed (527), Tests 6021 passed, 14 skipped. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/i18n run type-check`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 families, all run; 33 exit 0; `pnpm --dir ui exec vitest run scripts/` exit 1 on the timeout noted above (177 of 178 files green, both affected files green alone). `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED. - Beyond the derivation, all exit 0: `pnpm check:changeset-fixed`, `pnpm --dir ui run check:i18n-keys`, `check:i18n-dead-keys`, `check:i18n-designer-parity`, `check:i18n-drift`, `check:new-line-citations`. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (spec untouched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI owns `pnpm lint`) - [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: n/a. - [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. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbX9Xgog3zJyBcPdC2R)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fd0ba1 commit 5ec398d

5 files changed

Lines changed: 443 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n): the Studio option editor's lifecycle `kind` picker has its labels
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- Studio's picklist option editor can now set an option's lifecycle `kind` (`open` / `won` / `lost`, the key `SelectOption.kind` already publishes), so a select built in Studio can be chosen as the object's Lifecycle field (`stageField`) instead of the save being refused for options that declare no kind. The picker is drawn only on a single-value `select` / `radio` field, the one shape the spec lets carry a kind. The `studio` namespace gains its labels (`designer.field.optKind`, `designer.field.optKind.unset` / `.open` / `.won` / `.lost`), the malformed-option reason `designer.field.optMalformed.kindNotValid`, and the lifecycle-field hint `designer.field.hint.stageKinds`, in `en` and `zh`. No export changes.
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The option editor authors an option's lifecycle `kind` (objectstack#26729).
5+
*
6+
* The spec publishes `SelectOption.kind` (`open` / `won` / `lost`,
7+
* objectstack#23809), and `ObjectSchema` refuses an object whose `stageField`
8+
* names a field with an option that declares none. The editor used to own only
9+
* `value` / `label` / `color` and carry `kind` through its `rest` bag: an
10+
* existing kind survived, but nothing in Studio could write one, so a select
11+
* built in Studio could never be chosen as the Lifecycle field — the save came
12+
* back 422 naming a key the author had no control for.
13+
*
14+
* Every case ends at the spec (`ObjectSchema` / `FieldSchema`) or at the
15+
* written document's own keys, never at "it rendered".
16+
*/
17+
18+
import * as React from 'react';
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
21+
import userEvent from '@testing-library/user-event';
22+
import { FieldSchema, ObjectSchema } from '@objectstack/spec/data';
23+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
24+
25+
vi.mock('../useMetadata', () => ({
26+
useMetadataClient: () => ({
27+
list: vi.fn().mockResolvedValue([]),
28+
listDrafts: vi.fn().mockResolvedValue([]),
29+
}),
30+
}));
31+
32+
vi.mock('../previews/useObjectFields', () => ({
33+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
34+
}));
35+
36+
import { ObjectFieldInspector } from './ObjectFieldInspector';
37+
import { t, type SupportedLocale } from '../i18n';
38+
39+
// The zh case below reads the lazy `studio` catalogue; load it at import so no
40+
// assertion races the loader.
41+
await loadStudioCatalogue('zh');
42+
43+
afterEach(cleanup);
44+
45+
type Def = Record<string, unknown>;
46+
47+
function renderField(
48+
fields: Record<string, Def>,
49+
opts: { stageField?: string; locale?: SupportedLocale } = {},
50+
) {
51+
const onPatch = vi.fn();
52+
const initial: Record<string, unknown> = { name: 'probe_widget', fields };
53+
if (opts.stageField !== undefined) initial.stageField = opts.stageField;
54+
// A host that feeds each patch back, as Studio's draft does, so what the
55+
// inspector derives from the draft (the lifecycle hint) sees the edits.
56+
function Host() {
57+
const [draft, setDraft] = React.useState(initial);
58+
return (
59+
<ObjectFieldInspector
60+
type="object"
61+
name="probe_widget"
62+
draft={draft}
63+
selection={{ kind: 'field', id: 'status' }}
64+
onPatch={(patch: Record<string, unknown>) => {
65+
onPatch(patch);
66+
setDraft((d) => ({ ...d, ...patch }));
67+
}}
68+
onClearSelection={vi.fn()}
69+
onSelectionChange={vi.fn()}
70+
readOnly={false}
71+
locale={opts.locale ?? 'en-US'}
72+
/>
73+
);
74+
}
75+
const utils = render(<Host />);
76+
/** The fields map the host's Save would persist after the last edit. */
77+
const savedFields = () => onPatch.mock.calls.at(-1)![0].fields as Record<string, Def>;
78+
return { ...utils, onPatch, savedFields };
79+
}
80+
81+
const savedOptions = (fields: Record<string, Def>): Def[] =>
82+
(fields.status as { options?: Def[] }).options ?? [];
83+
84+
const has = (o: object, key: string) => Object.prototype.hasOwnProperty.call(o, key);
85+
86+
type SpecIssue = { code: string; path: ReadonlyArray<PropertyKey> };
87+
type SpecSchema = {
88+
safeParse: (value: unknown) => { success: boolean; error?: { issues: SpecIssue[] } };
89+
};
90+
const rejectionsOf = (schema: SpecSchema, doc: unknown): string[] => {
91+
const r = schema.safeParse(doc);
92+
return r.success ? [] : (r.error?.issues ?? []).map((i) => `${i.code}@[${i.path.join('.')}]`);
93+
};
94+
95+
/** The object the host saves: the inspector's fields, with `status` as the lifecycle field. */
96+
const stageObject = (fields: Record<string, Def>) => ({
97+
name: 'probe_widget',
98+
label: 'Probe widget',
99+
fields,
100+
stageField: 'status',
101+
});
102+
103+
const STATUS_OPTIONS: Def[] = [
104+
{ value: 'new', label: 'New' },
105+
{ value: 'won', label: 'Won' },
106+
{ value: 'lost', label: 'Lost' },
107+
];
108+
109+
const field = (type: string, options: Def[], extra: Def = {}): Record<string, Def> => ({
110+
status: { type, label: 'Status', options, ...extra },
111+
});
112+
113+
/** Open row `i`'s kind picker from the keyboard and pick the item labelled `label`. */
114+
async function pickKind(i: number, label: string): Promise<void> {
115+
fireEvent.keyDown(screen.getAllByTestId('option-kind')[i], { key: 'ArrowDown' });
116+
const listbox = await screen.findByRole('listbox');
117+
const items = within(listbox).getAllByRole('option');
118+
const item = items.find((o) => o.textContent === label);
119+
if (!item) throw new Error(`kind picker lists no "${label}": ${items.map((o) => o.textContent).join(' | ')}`);
120+
fireEvent.click(item);
121+
}
122+
123+
const editLabel = (i: number, to: string) =>
124+
fireEvent.change(screen.getAllByPlaceholderText('Label')[i], { target: { value: to } });
125+
126+
describe('ObjectFieldInspector · the option editor authors `kind` (objectstack#26729)', () => {
127+
it('CONTROL — the Studio-built select is refused as the stage field before any kind is set', () => {
128+
expect(rejectionsOf(ObjectSchema, stageObject(field('select', STATUS_OPTIONS)))).toEqual([
129+
'custom@[fields.status.options.0.kind]',
130+
'custom@[fields.status.options.1.kind]',
131+
'custom@[fields.status.options.2.kind]',
132+
]);
133+
});
134+
135+
it('picking a kind on each option writes it, and the object then saves with the field as `stageField`', async () => {
136+
const { savedFields } = renderField(field('select', STATUS_OPTIONS), { stageField: 'status' });
137+
138+
expect(screen.getAllByTestId('option-kind')).toHaveLength(3);
139+
await pickKind(0, 'Open');
140+
await pickKind(1, 'Won');
141+
await pickKind(2, 'Lost');
142+
143+
const fields = savedFields();
144+
const options = savedOptions(fields);
145+
expect(options).toHaveLength(3);
146+
expect(options.map((o) => o.kind)).toEqual(['open', 'won', 'lost']);
147+
expect(rejectionsOf(FieldSchema, { name: 'status', ...fields.status })).toEqual([]);
148+
expect(rejectionsOf(ObjectSchema, stageObject(fields))).toEqual([]);
149+
});
150+
151+
it('an option still without a kind keeps the object refused at that option, and only there', async () => {
152+
const { savedFields } = renderField(field('select', STATUS_OPTIONS), { stageField: 'status' });
153+
154+
await pickKind(0, 'Open');
155+
await pickKind(1, 'Won');
156+
157+
const fields = savedFields();
158+
expect(has(savedOptions(fields)[2], 'kind')).toBe(false);
159+
expect(rejectionsOf(ObjectSchema, stageObject(fields))).toEqual(['custom@[fields.status.options.2.kind]']);
160+
});
161+
162+
it('"No kind" removes the key outright', async () => {
163+
const { savedFields } = renderField(
164+
field('select', [
165+
{ value: 'new', label: 'New', kind: 'open' },
166+
{ value: 'won', label: 'Won', kind: 'won' },
167+
]),
168+
);
169+
170+
await pickKind(1, 'No kind');
171+
172+
const options = savedOptions(savedFields());
173+
expect(options).toHaveLength(2);
174+
expect(options[0].kind).toBe('open');
175+
expect(has(options[1], 'kind')).toBe(false);
176+
expect(options[1]).toEqual({ value: 'won', label: 'Won' });
177+
});
178+
179+
it('an existing kind round-trips through an edit of its own row and of another row', () => {
180+
const { savedFields } = renderField(
181+
field('radio', [
182+
{ value: 'new', label: 'New', kind: 'open' },
183+
{ value: 'done', label: 'Done', kind: 'won' },
184+
]),
185+
);
186+
187+
editLabel(1, 'Done!');
188+
189+
const options = savedOptions(savedFields());
190+
expect(options).toEqual([
191+
{ value: 'new', label: 'New', kind: 'open' },
192+
{ value: 'done', label: 'Done!', kind: 'won' },
193+
]);
194+
});
195+
196+
it('the picker shows the stored kind', () => {
197+
renderField(field('select', [{ value: 'done', label: 'Done', kind: 'lost' }]));
198+
expect(screen.getByTestId('option-kind').textContent).toBe('Lost');
199+
});
200+
201+
it.each([
202+
['multiselect', {}],
203+
['checkboxes', {}],
204+
['select with multiple: true', { multiple: true }],
205+
] as const)('a %s field draws no kind picker and writes no kind', (label, extra) => {
206+
const type = label.startsWith('select') ? 'select' : label;
207+
const { savedFields } = renderField(field(type, STATUS_OPTIONS, extra));
208+
209+
expect(screen.queryByTestId('option-kind')).toBeNull();
210+
// POSITIVE CONTROL for the query above: the rows themselves are drawn.
211+
expect(screen.getAllByPlaceholderText('Label')).toHaveLength(3);
212+
213+
editLabel(0, 'Fresh');
214+
215+
const fields = savedFields();
216+
const options = savedOptions(fields);
217+
expect(options).toHaveLength(3);
218+
expect(options[0].label).toBe('Fresh');
219+
expect(options.some((o) => has(o, 'kind'))).toBe(false);
220+
expect(rejectionsOf(FieldSchema, { name: 'status', ...fields.status })).toEqual([]);
221+
});
222+
223+
it('a kind authored on a multi-value field is not written back — the spec refuses it there', () => {
224+
const fields0 = field('multiselect', [{ value: 'alpha', label: 'Alpha', kind: 'won' }]);
225+
expect(rejectionsOf(FieldSchema, { name: 'status', ...fields0.status })).toEqual([
226+
'custom@[options.0.kind]',
227+
]);
228+
const { savedFields } = renderField(fields0);
229+
230+
editLabel(0, 'Alpha II');
231+
232+
const fields = savedFields();
233+
expect(savedOptions(fields)).toEqual([{ value: 'alpha', label: 'Alpha II' }]);
234+
expect(rejectionsOf(FieldSchema, { name: 'status', ...fields.status })).toEqual([]);
235+
});
236+
237+
it('a kind outside open / won / lost is reported on its own row and kept verbatim', () => {
238+
const bogus = { value: 'beta', label: 'Beta', kind: 'closed' };
239+
const { savedFields } = renderField(field('select', [{ value: 'alpha', label: 'Alpha' }, bogus]));
240+
241+
expect(screen.getAllByTestId('option-malformed')).toHaveLength(1);
242+
expect(screen.getAllByTestId('option-kind')).toHaveLength(1);
243+
244+
editLabel(0, 'Alpha II');
245+
246+
expect(savedOptions(savedFields())[1]).toBe(bogus);
247+
});
248+
249+
it.each([
250+
['Multi-Select', 'multiselect', false],
251+
['Radio', 'radio', true],
252+
] as const)('a retype to %s %s the option kinds', async (typeLabel, nextType, keeps) => {
253+
const kinded = [
254+
{ value: 'new', label: 'New', kind: 'open' },
255+
{ value: 'won', label: 'Won', kind: 'won' },
256+
];
257+
const { savedFields } = renderField(field('select', kinded));
258+
259+
await userEvent.click(screen.getByRole('combobox', { name: t('designer.field.type', 'en-US') }));
260+
const list = await screen.findByRole('listbox', { name: t('designer.field.type', 'en-US') });
261+
await userEvent.click(within(list).getByRole('option', { name: typeLabel }));
262+
263+
const fields = savedFields();
264+
expect(fields.status.type).toBe(nextType);
265+
const options = savedOptions(fields);
266+
expect(options).toHaveLength(2);
267+
expect(options).toEqual(keeps ? kinded : kinded.map(({ kind: _k, ...o }) => o));
268+
expect(rejectionsOf(FieldSchema, { name: 'status', ...fields.status })).toEqual([]);
269+
});
270+
271+
it('the inspector says what the lifecycle field needs while an option lacks a kind', async () => {
272+
renderField(field('select', STATUS_OPTIONS), { stageField: 'status' });
273+
expect(screen.getByTestId('field-held-hint').textContent).toMatch(/lifecycle field/);
274+
275+
await pickKind(0, 'Open');
276+
await pickKind(1, 'Won');
277+
await pickKind(2, 'Lost');
278+
expect(screen.queryByTestId('field-held-hint')).toBeNull();
279+
});
280+
281+
it('CONTROL — no lifecycle hint on a select the object does not name as its stage field', () => {
282+
renderField(field('select', STATUS_OPTIONS), { stageField: 'other' });
283+
expect(screen.queryByTestId('field-held-hint')).toBeNull();
284+
});
285+
286+
it('zh: the picker and its kinds are labelled from the studio catalogue', async () => {
287+
renderField(field('select', [{ value: 'done', label: 'Done', kind: 'won' }]), { locale: 'zh-CN' });
288+
const trigger = screen.getByTestId('option-kind');
289+
expect(trigger.getAttribute('aria-label')).toBe('生命周期类别');
290+
expect(trigger.textContent).toBe('成功结束');
291+
});
292+
});

0 commit comments

Comments
 (0)