Skip to content

Commit 784dc9a

Browse files
feat(studio): the publish panel names each change in words — properties by their form label, rules and groups by their label (#27885)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:发布前的「待发布更改」面板展开某项后,不再列裸键名(`+ sharingModel`、`+ config`、`+ validations`),改为按该类型编辑表单上的字段名称显示(Sharing Model、Columns、Validations),验证规则和字段分组逐条按名称列出新增 / 修改 / 删除。 - 为什么改:作者发布前看不懂这次到底改了什么,尤其改了哪条验证规则完全看不出来(#27835,#27521 第 28 行的命名半)。 - 风险与回滚:只改面板的展示与对比方式,不改发布行为;视图改为在 config 内一层对比。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/preview/DraftChangesPanel.tsx`(`computeChangeDetail` 的视图展开与列表条目),`ui/packages/app-shell/src/preview/changeKeyLabels.ts`(标签取自 spec 的 `METADATA_FORM_REGISTRY`),新测试 `DraftChangesPanel.namedChanges-27835.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/preview/`,预期 40 个文件全绿;或在 Studio 改一条验证规则后点发布,展开对象,看到「~ Validations」下列出该规则名称。 - 需要你做的:无,席位落地。 Fixes #27835 Clause-②: no Metadata-protocol: none ## What changes The Pending changes panel (`DraftChangesPanel`) is the Studio publish summary too: `StudioDesignSurface` renders the same component in its confirm posture, so one change covers both surfaces the card names. - **Properties by their form label.** A new leaf, `preview/changeKeyLabels.ts`, reads the item type's authoring form from the spec's `METADATA_FORM_REGISTRY` (the type-to-form map `/meta/types` serves) and names a property the way the inspector's `SchemaForm` row does: the row's declared `label`, else its prettified key (`prettifyFieldName`, the helper the Studio's held line already shares). The key follows as secondary text only when the label does not spell it, `SchemaForm`'s machine-name rule. A property the form has no row for reads as its key, in monospace, as before. No key-to-label table lives in the panel. Changed field properties read through the `field` form the same way. - **Named list entries.** The flow-node diff is generalised into one `diffListEntries(before, after, idKey)`. Flows keep calling it for `nodes` by `id` (node rows unchanged). For every other type, a top-level list whose entries carry a distinct `name` (validation rules) or `key` (field groups) gets its entries listed under the list's key row: added, changed (with what differs, by the list row's declared sub-row labels) and removed, each led by its `label`. A list of plain strings, or one with no such identity, stays one key row. The diff lands in `EntryChangeDetail.entries`, a Map keyed by the key as it appears in `keys`; `keys` itself is unchanged in shape. - **Views one level down.** A view is a ViewItem whose inspector edits `draft.config`, so a `view` is compared inside `config` (`config.columns`), labelled from the view form. A non-view item still compares `config` whole. - The item rows themselves already lead with the item's label under its type heading (objectui#11862); they are unchanged. ## Tests - New `DraftChangesPanel.namedChanges-27835.test.tsx` (12 tests): form labels for object, field, view-body and flow properties; a declared sub-row label; controls for a property, a type no form names; the rule added / changed / removed and the field group changed; controls for a string list and for a flow's other lists; a new view and a view edit compared inside `config`, with a non-view control; and the rendered drill-in for both. - Seven existing panel tests updated where they pinned the raw-key text this card changes (`+ sharingModel` → `+ Sharing Model`, `~ label` → `~ Label`, `~ edges` → `~ Edges`, `· label` → `· Label`, `+ subject` → `+ Subject`); `protectionEnvelope`'s row reader now reads field and key rows by test id instead of the monospace class. Flow node row assertions are untouched and green. - `pnpm --dir ui exec vitest run packages/app-shell/src/preview/` → Test Files 40 passed (40), Tests 274 passed (274). - `pnpm --filter @object-ui/app-shell type-check` → exit 0. <details><summary>Ablations (each through scripts/ablation-replace.mjs, mutation proven on disk, restored to the HEAD blob)</summary> - `formKeyLabel` returns no label (`if (row || !row) return undefined;`): 6 failed in `namedChanges-27835` + `noChangeAndKeySplit-11807`, restored. - Named-list entries never recorded (`listEntryRowCount(list) < 0`): 3 failed in `namedChanges-27835`, restored. - View not compared inside `config` (`type === 'NOT_view'`): 3 failed in `namedChanges-27835`, restored. No build step: the panel tests import the source directly. </details> ## Acceptance notes - View, validation rule, field and object changes now name the item in words; property keys read as their form labels where the type's form has a row; flow node naming unchanged. Reading taken: a row with no declared `label` counts as "a form label", the prettified key the inspector itself shows (most spec form rows declare none), and a property with no form row falls back to its key. - Labels are the spec's English form labels in every locale. The inspector's own zh overlays (`translateSchemaFieldLabel`, the dashboard `metadataForms` bundle) live in the metadata-admin string table, which this panel's eager graph must not import (the reason recorded on `computeChangeDetail`). A zh author therefore reads English property labels here, as on most inspector rows; noted, not filed. - A form row with no declared label whose schema carries a `title` shows that title in the inspector (`SchemaForm` reads the JSON schema) but its prettified key here; this panel holds no JSON schema. Noted, not filed. - Importing `@objectstack/spec/system` adds no new eager weight: every `@objectstack/*` module already rides the console's `vendor-objectstack` chunk (the measurement recorded in the panel's import comment). - No i18n key was added or changed: the list-entry rows reuse the field-row shape under the list's own key row. ## Verification Local derived gates on head `b4396c78`: `node scripts/pm/dispatch-gates.mjs --commands` derived 34 families; all 34 ran with exit 0, and `--ran` reconciled "34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN". `pnpm --dir ui run check:new-line-citations` → "0 new citation(s)". Every family CI runs is read from this PR's checks. <details><summary>Derived gates, exit codes captured before any pipe</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CZ5jdK8tmGe3uqdgUo --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZ5jdK8tmGe3uqdgUo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 816889e commit 784dc9a

11 files changed

Lines changed: 529 additions & 72 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(studio): the Pending changes panel names each change in words — a property by its form label, each validation rule and field group by its label, a view by the settings that changed
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
In the Pending changes panel (Studio's publish review and the console's draft preview), an item's drill-in used to list what changed as raw keys: `+ label + name + sharingModel` for a new object, `+ config + isDefault + viewKind` for a new view, `+ fieldGroups + validations` for an object edit, with no word for which rule moved.
11+
12+
- **Properties by their label.** Each property reads as the label its item type's authoring form gives it, the same row the inspector shows: `Sharing Model`, `Field Groups`, `Validations`. The machine key follows only where the label does not already spell it. A property the form has no row for still reads as its key. A changed field's properties read the same way (`· Label, Required`).
13+
- **Rules and groups by name.** An object's `validations` and `fieldGroups` list each entry publishing adds, changes or removes under their row, led by the entry's label with its name beside it, the way a flow already names its nodes. A changed field group names what differs by its declared sub-row labels.
14+
- **Views by their settings.** A view is compared inside its body, so a view edit names `Columns` or `Filter` instead of one `config` key, and a new view lists the settings it includes.
15+
16+
Flow node rows are unchanged. The labels are the spec's English form labels in every locale, as most of the inspector shows them.

‎ui/packages/app-shell/src/preview/DraftChangesPanel.tsx‎

Lines changed: 190 additions & 57 deletions
Large diffs are not rendered by default.

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.bundleMember-12271.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('two members of one email_template are two rows (objectui#12271)', () =
149149
const detail = screen.getByTestId('draft-entry-detail');
150150
// zh-CN is new: its draft adds `body` and `subject` against no published body.
151151
expect(detail).toHaveTextContent('+ body');
152-
expect(detail).toHaveTextContent('+ subject');
152+
expect(detail).toHaveTextContent('+ Subject');
153153
expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=zh-CN');
154154
// The canonical body was never read for this row.
155155
expect(urls).not.toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x');
@@ -166,7 +166,7 @@ describe('two members of one email_template are two rows (objectui#12271)', () =
166166
fireEvent.click(memberRow('en-US'));
167167

168168
await waitFor(() => expect(screen.getByTestId('draft-entry-detail')).toBeInTheDocument());
169-
expect(screen.getByTestId('draft-entry-detail')).toHaveTextContent('~ subject');
169+
expect(screen.getByTestId('draft-entry-detail')).toHaveTextContent('~ Subject');
170170
expect(screen.getByTestId('draft-entry-detail')).not.toHaveTextContent('body');
171171
expect(urls).toContain('/api/v1/meta/email_template/welcome?state=draft&package=com.x&member=en-US');
172172
});

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.fieldRows-13125.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ describe('the drill-in of an UPDATE (objectui#13125)', () => {
287287
const detail = await expandRow('Ticket');
288288
const changed = fieldRow(detail, '~ Subject');
289289
expect(within(changed).getByText('subject')).toHaveClass('font-mono');
290-
expect(changed).toHaveTextContent('· label');
290+
expect(changed).toHaveTextContent('· Label');
291291
const removed = fieldRow(detail, '− Legacy Code');
292292
expect(within(removed).getByText('legacy_code')).toHaveClass('font-mono');
293293
expect(detail.textContent ?? '').not.toContain('__search');

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.flowNodeRows-27356.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,9 @@ describe('the publish drill-in of a flow edit (objectstack#27356)', () => {
144144
]);
145145
expect(within(rows[1]!).getByText('create_task')).toHaveClass('font-mono');
146146
expect(detail.textContent).not.toContain('~ nodes');
147-
// `edges` is still a key row under "Also changed:".
147+
// `edges` is still a key row under "Also changed:", by its flow form label
148+
// (objectstack#27835).
148149
expect(within(detail).getByText('Also changed:')).toBeInTheDocument();
149-
expect(within(detail).getByText('~ edges')).toBeInTheDocument();
150+
expect(within(detail).getByTestId('draft-key-row')).toHaveTextContent('~ Edges');
150151
});
151152
});

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.generatedGroup-25890.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ describe("the card's repro in the sheet (objectstack#25890)", () => {
204204
const detail = await expandRepairTicket();
205205
expect(within(detail).getByText('Includes:')).toBeInTheDocument();
206206
expect(within(detail).queryByText('Also changed:')).toBeNull();
207-
expect(within(detail).getByText('+ sharingModel')).toBeInTheDocument();
207+
expect(within(detail).getByText('+ Sharing Model')).toBeInTheDocument();
208208
});
209209

210210
it('opens the group to the five system columns, label first, and the derived nameField', async () => {
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27835 — the publish panel listed what a change touches as raw
5+
* keys: "+ label + name + sharingModel" for a new object, "+ config + isDefault
6+
* + viewKind" for a new view, "+ fieldGroups + validations" for an object edit,
7+
* with no word for which rule moved.
8+
*
9+
* Now a property reads as the label its item type's form gives it (the spec's
10+
* `METADATA_FORM_REGISTRY`, the form the inspector renders), its key beside it
11+
* only where the label does not already spell it; a property the form has no
12+
* row for keeps its key. A view is compared one level down, inside `config`,
13+
* where its inspector edits it. A list of named entries (`validations` by
14+
* `name`, `fieldGroups` by `key`) names each entry it adds, changes or removes,
15+
* by its label, as a flow names its nodes.
16+
*
17+
* Controls: a list of plain strings stays one key row; a flow's lists are still
18+
* only its nodes; a property no form row names reads as its key.
19+
*/
20+
21+
import '@testing-library/jest-dom/vitest';
22+
import { afterEach, describe, expect, it, vi } from 'vitest';
23+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
24+
25+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
26+
...(await importOriginal<Record<string, unknown>>()),
27+
useObjectTranslation: () => ({
28+
t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
29+
language: 'en',
30+
}),
31+
}));
32+
33+
import { DraftChangesPanel, computeChangeDetail } from '../DraftChangesPanel';
34+
import { formKeyLabel } from '../changeKeyLabels';
35+
36+
const HOURS_RULE = {
37+
name: 'hours_over_40',
38+
label: 'Hours over 40',
39+
type: 'script',
40+
condition: 'hours > 40',
41+
message: 'A week holds at most 40 hours',
42+
};
43+
const DISCOUNT_RULE = { name: 'max_discount', type: 'script', condition: 'discount > 0.4', message: 'Too much' };
44+
const OLD_RULE = { name: 'legacy_check', label: 'Legacy check', type: 'script', condition: 'false', message: 'x' };
45+
46+
const PUBLISHED_OBJECT = {
47+
name: 'work_order',
48+
label: 'Work Order',
49+
fields: { hours: { type: 'number', label: 'Hours' } },
50+
validations: [DISCOUNT_RULE, OLD_RULE],
51+
fieldGroups: [{ key: 'basics', label: 'Basics' }],
52+
highlightFields: ['hours'],
53+
};
54+
const DRAFT_OBJECT = {
55+
...PUBLISHED_OBJECT,
56+
validations: [{ ...DISCOUNT_RULE, condition: 'discount > 0.5' }, HOURS_RULE],
57+
fieldGroups: [{ key: 'basics', label: 'Basics', collapse: 'collapsed' }],
58+
highlightFields: ['hours', 'name'],
59+
};
60+
61+
const NEW_VIEW = {
62+
name: 'work_order_list',
63+
object: 'work_order',
64+
viewKind: 'list',
65+
label: 'Work Order list',
66+
isDefault: true,
67+
config: { type: 'grid', columns: ['name', 'hours'], data: { provider: 'object', object: 'work_order' } },
68+
};
69+
70+
afterEach(() => {
71+
cleanup();
72+
vi.unstubAllGlobals();
73+
});
74+
75+
describe('a property reads as its label on the item type form (objectstack#27835)', () => {
76+
it('names an object property, a field property and a view body property by the form row', () => {
77+
expect(formKeyLabel('object', 'sharingModel')).toBe('Sharing Model');
78+
expect(formKeyLabel('object', 'validations')).toBe('Validations');
79+
expect(formKeyLabel('field', 'required')).toBe('Required');
80+
expect(formKeyLabel('view', 'config.columns')).toBe('Columns');
81+
expect(formKeyLabel('flow', 'edges')).toBe('Edges');
82+
});
83+
84+
it('a declared sub-row label wins over the prettified key', () => {
85+
// `fieldGroups` declares its sub-rows; `relatedList` is labelled "Related List".
86+
expect(formKeyLabel('object', 'fieldGroups.relatedList')).toBe('Related List');
87+
expect(formKeyLabel('object', 'fieldGroups.collapse')).toBe('Collapse');
88+
});
89+
90+
it('CONTROL: a property no form row names has no label', () => {
91+
expect(formKeyLabel('view', 'viewKind')).toBeUndefined();
92+
expect(formKeyLabel('object', 'no_such_key')).toBeUndefined();
93+
expect(formKeyLabel('no_such_type', 'label')).toBeUndefined();
94+
});
95+
});
96+
97+
describe('computeChangeDetail names the entries of a named list (objectstack#27835)', () => {
98+
it('lists the rule added, the rule changed with its property, and the rule removed', () => {
99+
const d = computeChangeDetail(PUBLISHED_OBJECT, DRAFT_OBJECT, 'object');
100+
expect(d.keys.changed).toEqual(['fieldGroups', 'highlightFields', 'validations']);
101+
expect(d.entries.get('validations')).toEqual({
102+
added: [{ id: 'hours_over_40', label: 'Hours over 40' }],
103+
changed: [{ id: 'max_discount', keys: ['condition'] }],
104+
removed: [{ id: 'legacy_check', label: 'Legacy check' }],
105+
});
106+
expect(d.entries.get('fieldGroups')).toEqual({
107+
added: [],
108+
changed: [{ id: 'basics', label: 'Basics', keys: ['collapse'] }],
109+
removed: [],
110+
});
111+
});
112+
113+
it('CONTROL: a list of plain strings stays one key row with no entries', () => {
114+
const d = computeChangeDetail(PUBLISHED_OBJECT, DRAFT_OBJECT, 'object');
115+
expect(d.entries.has('highlightFields')).toBe(false);
116+
});
117+
118+
it('a NEW object adds every rule', () => {
119+
const d = computeChangeDetail(null, DRAFT_OBJECT, 'object');
120+
expect(d.entries.get('validations')?.added.map((e) => e.id)).toEqual(['hours_over_40', 'max_discount']);
121+
});
122+
123+
it('CONTROL: a flow names only its nodes, never another list', () => {
124+
const before = { name: 'f', nodes: [], edges: [{ id: 'e1', name: 'e1' }] };
125+
const after = { name: 'f', nodes: [], edges: [{ id: 'e1', name: 'e1' }, { id: 'e2', name: 'e2' }] };
126+
expect(computeChangeDetail(before, after, 'flow').entries.size).toBe(0);
127+
});
128+
});
129+
130+
describe('computeChangeDetail compares a view inside its body (objectstack#27835)', () => {
131+
it('a NEW view lists its body properties, not one `config` key', () => {
132+
const d = computeChangeDetail(null, NEW_VIEW, 'view');
133+
expect(d.keys.added).toEqual([
134+
'config.columns',
135+
'config.data',
136+
'config.type',
137+
'isDefault',
138+
'label',
139+
'name',
140+
'object',
141+
'viewKind',
142+
]);
143+
});
144+
145+
it('a view edit names the body property that changed', () => {
146+
const draft = { ...NEW_VIEW, config: { ...NEW_VIEW.config, columns: ['name'] } };
147+
expect(computeChangeDetail(NEW_VIEW, draft, 'view').keys).toEqual({
148+
added: [],
149+
changed: ['config.columns'],
150+
removed: [],
151+
});
152+
});
153+
154+
it('CONTROL: an item that is not a view compares `config` whole', () => {
155+
const draft = { ...NEW_VIEW, config: { ...NEW_VIEW.config, columns: ['name'] } };
156+
expect(computeChangeDetail(NEW_VIEW, draft, 'page').keys.changed).toEqual(['config']);
157+
});
158+
});
159+
160+
/* ─────────────── the panel ─────────────── */
161+
162+
function stubServer(type: string, name: string, label: string, published: unknown, draft: unknown) {
163+
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
164+
vi.stubGlobal(
165+
'fetch',
166+
vi.fn(async (input: RequestInfo | URL) => {
167+
const url = String(input);
168+
if (url.includes('/_drafts')) return ok({ drafts: [{ type, name, packageId: 'com.x', label }] });
169+
if (new RegExp(`/meta/${type}s?(\\?|$)`).test(url)) return ok(published ? [{ name }] : []);
170+
if (new RegExp(`/meta/${type}s?/${name}\\b`).test(url)) {
171+
if (url.includes('state=draft')) return ok({ item: draft });
172+
return published ? ok(published) : { ok: false, status: 404, json: async () => ({}) };
173+
}
174+
return { ok: false, status: 404, json: async () => ({}) };
175+
}) as unknown as typeof fetch,
176+
);
177+
}
178+
179+
async function openDetail(label: string): Promise<HTMLElement> {
180+
render(<DraftChangesPanel open onOpenChange={() => {}} packageId="com.x" />);
181+
const toggle = (await screen.findByText(label)).closest('[data-testid="draft-entry-toggle"]') as HTMLElement;
182+
fireEvent.click(toggle);
183+
return screen.findByTestId('draft-entry-detail');
184+
}
185+
186+
describe('the publish drill-in names each change in words (objectstack#27835)', () => {
187+
it('names each validation rule under its list, by its label', async () => {
188+
stubServer('object', 'work_order', 'Work Order', PUBLISHED_OBJECT, DRAFT_OBJECT);
189+
const detail = await openDetail('Work Order');
190+
191+
const keyRows = await within(detail).findAllByTestId('draft-key-row');
192+
const validations = keyRows.find((r) => r.textContent?.startsWith('~ Validations'))!;
193+
expect(validations).toBeDefined();
194+
const entries = within(validations).getAllByTestId('draft-list-entry-row');
195+
expect(entries.map((r) => r.textContent)).toEqual([
196+
'+ Hours over 40 hours_over_40',
197+
// `validations` is a JSON row with no declared sub-rows: the property keeps its key.
198+
'~ max_discount · condition',
199+
'− Legacy check legacy_check',
200+
]);
201+
expect(within(entries[0]!).getByText('hours_over_40')).toHaveClass('font-mono');
202+
// A field group's changed property reads by its declared sub-row label.
203+
const groups = keyRows.find((r) => r.textContent?.startsWith('~ Field Groups'))!;
204+
expect(within(groups).getByTestId('draft-list-entry-row')).toHaveTextContent('~ Basics basics · Collapse');
205+
expect(detail.textContent).not.toContain('~ validations');
206+
});
207+
208+
it('heads a NEW view with its body properties by their labels, keeping a key no form row names', async () => {
209+
stubServer('view', 'work_order_list', 'Work Order list', null, NEW_VIEW);
210+
const detail = await openDetail('Work Order list');
211+
212+
const rows = (await within(detail).findAllByTestId('draft-key-row')).map((r) => r.textContent);
213+
expect(rows).toContain('+ Columns');
214+
expect(rows).toContain('+ Label');
215+
expect(rows).toContain('+ viewKind');
216+
expect(rows).not.toContain('+ config');
217+
});
218+
});

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.noChangeAndKeySplit-11807.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ describe('a draft equal to its published version is not listed as Update (object
154154
expect(within(row('repairs_repair_ticket')).getByRole('img', { name: NO_CHANGE })).toBeInTheDocument();
155155
expect(row('repairs_repair_ticket')).toHaveAttribute('title', NO_CHANGE);
156156
// CONTROL: the drafted change keeps its mark, and its drill-in did load.
157-
await waitFor(() => expect(screen.getByText('~ label')).toBeInTheDocument());
157+
await waitFor(() => expect(screen.getByText('~ Label')).toBeInTheDocument());
158158
expect(row('visit').textContent).toContain('Update');
159159
expect(row('visit')).not.toHaveAttribute('title');
160160
expect(within(row('visit')).queryByRole('img', { name: NO_CHANGE })).toBeNull();
@@ -211,7 +211,8 @@ describe('the drill-in lists top-level keys as added, changed and removed (objec
211211
fireEvent.click(row('visit'));
212212
const detail = await screen.findByTestId('draft-entry-detail');
213213
const lines = [...detail.querySelectorAll('p')].map((p) => p.textContent);
214-
expect(lines).toEqual(['+ status', 'Also changed:', '+ description', '~ label', '− icon']);
214+
// objectstack#27835 — each key by its label on the object form.
215+
expect(lines).toEqual(['+ status', 'Also changed:', '+ Description', '~ Label', '− Icon']);
215216
});
216217
});
217218

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.protectionEnvelope-12594.test.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,13 @@ async function openEntryDetail() {
8484
fireEvent.click(await screen.findByTestId('draft-entry-toggle'));
8585
}
8686

87-
/** The text of every key row the drill-in rendered, `+ name` / `~ name` / `− name`. */
87+
/** The text of every field and key row the drill-in rendered, `+ name` / `~ name` / `− name`. */
8888
function renderedRows(): string[] {
89-
return [...screen.getByTestId('draft-entry-detail').querySelectorAll('p.font-mono')].map((p) =>
90-
(p.textContent ?? '').trim(),
91-
);
89+
return [
90+
...screen
91+
.getByTestId('draft-entry-detail')
92+
.querySelectorAll('[data-testid="draft-field-row"], [data-testid="draft-key-row"]'),
93+
].map((row) => (row.textContent ?? '').trim());
9294
}
9395

9496
describe('DraftChangesPanel — the protection envelope never lists as authored (objectui#12594)', () => {
@@ -108,7 +110,7 @@ describe('DraftChangesPanel — the protection envelope never lists as authored
108110
await openEntryDetail();
109111

110112
// CONTROL: the authored key and the field are listed, so the diff ran.
111-
await waitFor(() => expect(renderedRows()).toContain('+ label'));
113+
await waitFor(() => expect(renderedRows()).toContain('+ Label'));
112114
expect(renderedRows()).toContain('+ subject');
113115

114116
const text = screen.getByTestId('draft-entry-detail').textContent ?? '';
@@ -122,7 +124,7 @@ describe('DraftChangesPanel — the protection envelope never lists as authored
122124
await openEntryDetail();
123125

124126
await waitFor(() => expect(renderedRows()).toContain('+ _unpublished'));
125-
expect(renderedRows()).toContain('+ label');
127+
expect(renderedRows()).toContain('+ Label');
126128
const text = screen.getByTestId('draft-entry-detail').textContent ?? '';
127129
for (const key of ENVELOPE_KEYS) expect(text).not.toContain(key);
128130
});

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.readDecorationStrip.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ describe('DraftChangesPanel — read decorations never reach the review diff (ob
111111

112112
// The CONTROL first: the real authored change IS reported, so the diff ran
113113
// and this harness reaches the changed-keys strip.
114-
await waitFor(() => expect(detail.textContent).toContain('label'));
114+
await waitFor(() => expect(detail.textContent).toContain('Label'));
115115

116116
// …and the framework's own keys are not sitting next to it. `_draft` is the
117117
// deterministic one — the server stamps it on the draft read and never on

0 commit comments

Comments
 (0)