Skip to content

Commit 7bbc0b9

Browse files
fix(app-shell): pending changes folds platform-written system fields and derived keys into one collapsed line (#25984)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9CEUVquf4gyNp5rXBUzghZZ)_ Fixes #25890 Clause-②: no Metadata-protocol: none ## What changes Studio's pending-changes drill-in (`DraftChangesPanel`) listed a new object's platform-injected system columns (`created_at`, `created_by`, `owner_id`, `updated_at`, `updated_by`) as fields the author added, and its derived `nameField` under "Also changed", next to the one field the author actually typed. Following the triage ruling ("Pending changes lists what the author changed. Platform-provisioned system fields and derived keys belong in a collapsed 'generated' group, not in the author's diff."): - **System fields fold.** In `computeChangeDetail`, a field this publish adds or removes, where every definition the two bodies carry has `system: true`, moves to a new `generated` list. `system: true` is the spec's own mark (`FieldSchema.system`: "Auto-injected system/audit field … Tools that surface system fields separately from author-declared business fields should branch on this flag"). No name list is kept in the panel. The read localises the injected columns' labels, so byte-identity against `injectedSystemColumnDefs` cannot answer this on a served body; the mark can. - **Derived keys fold.** A top-level `nameField` goes to `generated` when its value equals the `@objectstack/spec/data` `provisionPrimary` derivation over the body without the pointer. That is the same test the registry's write-side strip (`stripProvisionedPrimaryFrom`) uses. A pointer naming a field the derivation would not pick is the author's choice and stays an author row. - **What stays an author row.** A system field whose definition *differs* between the published and draft bodies (for example, the author relabelled `owner_id`) stays an author row: the platform injects the same definition on both reads, so a difference is an edit. The objectui#13125 rule (`system` AND `hidden`, e.g. `__search`) still drops those rows outright, unchanged. - **The fold renders as one collapsed line**, "Generated by the platform (N)" (「平台生成(N)」), with a toggle that shows those rows. It stays collapsed in the confirm posture too. - **A new item's keys are headed "Includes:"** (「包含:」) instead of "Also changed:" (「其他变更:」). Every key of an item that does not exist yet is part of it, not some other change. - Two new i18n keys (`preview.changes.detailNewKeys`, `preview.changes.generatedGroup`) are added to all ten packs, because the key-parity test requires every pack. - `scripts/sdui-manifest.record.json`: the registry-source hash is re-recorded by `node scripts/gen-sdui-manifest-node.mjs`. The manifest bytes did not change (sha256 `1f6e1ccb925a…`, 106 components). The card's repro (new object 「报修工单」, OWD private) now expands to `+ 名称 name`, then `Includes: + label + name + sharingModel`, then one collapsed `Generated by the platform (6)` line (the five system columns plus `nameField`). ## Acceptance notes - **Deviation from the dispatch's acceptance wording ("only the authored field(s) plus one folded system-fields line").** The new object's authored top-level keys `label`, `name` and `sharingModel` are still listed, under "Includes:". The ruling folds *platform-provisioned* fields and *derived* keys, and those three are authored in the create dialog. A first cut dropped `name`/`label` for new items (the row already shows them), but that reversed seven existing pins in four files (`DraftChangesPanel.test.tsx` "classifies a NEW item", `noChangeAndKeySplit-11807`, `posture-12517`, `protectionEnvelope-12594`), which the dispatch asked to keep green. So it was reverted. If the maintainer wants a new item's name/label hidden as well, that is a follow-up with those pins rewritten deliberately. - **One existing pin updated on purpose.** In `DraftChangesPanel.fieldRows-13125.test.tsx`, `owner_id` and `created_at` were "listed" controls ("this card does not widen the rule"). They now assert placement in the generated group, and that the drop rule still leaves them in the detail behind the toggle. - The panel only sees served bodies, so an author who declares a column that the platform also injects, with `system: true` on it, reads as platform-written when added. That is the spec's stated meaning of the mark. ## Tests At head `f96a8519`: - `pnpm exec vitest run --maxWorkers=2 packages/i18n/ packages/app-shell/src/preview/` (from `ui/`): **Test Files 120 passed (120), Tests 1585 passed | 13 skipped**. - New `DraftChangesPanel.generatedGroup-25890.test.tsx` (9 tests). It pins the card's repro using the spec's real `injectedSystemColumnDefs` with zh-localised labels, plus four controls: a system field the author changed, an author-chosen `nameField`, a system field added on an update, and an audit-shaped field without the mark. - **Ablation, once.** I disabled the fold predicate through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `6c92a1c5…` to `805a4a90…`). The new suite went **4 failed | 5 passed**. The file was restored to its HEAD blob `6c92a1c5…`. The suite resolves `DraftChangesPanel` from source through the ui vitest alias, so no dist rebuild was involved. - `pnpm --filter @object-ui/i18n --filter @object-ui/app-shell run type-check` (both `tsc --noEmit` and `tsconfig.test.json`): **Done / Done**. - `node scripts/check-i18n-call-site-keys.mjs` (ui): exit 0. eslint on the touched files: 0 errors. The 6 warnings are all in code this PR does not change. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `f96a8519` derived 46 families. All 46 ran with exit 0. `--ran` reconciliation: "46 derived famil(ies) accounted for — 46 run, 0 NOT-MEASURED (a DERIVED zero …)". At the earlier head `6577bce6`, `check-sdui-manifest` was red because the ui source hash had moved. The regeneration commit fixed it, and the check is green at `f96a8519`. CI families are left to the PR's check-runs. Out of bounds and untouched: `ui/.github/**` and the root `.github/workflows/ci.yml`. --- _Generated by [Claude Code](https://claude.ai/code/session_01P4aqt32BH9VTnLdDgkBUMx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbf7061 commit 7bbc0b9

15 files changed

Lines changed: 422 additions & 25 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): pending changes folds what the platform wrote into one collapsed line
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Publishing a new object from Studio listed the platform's injected system columns (`created_at`, `created_by`, `owner_id`, `updated_at`, `updated_by`) as fields the author added, and the object's derived `nameField` under "Also changed", beside the one field the author typed.
12+
13+
- A field the publish adds or removes whose every definition carries `system: true` (the spec's mark for an auto-injected system/audit column), and a `nameField` equal to the registry's own `provisionPrimary` derivation, now fold into one collapsed "Generated by the platform (N)" line; opening it shows those rows. A system field whose definition differs between the published and draft bodies is an edit and stays an author row, as does a `nameField` the derivation would not pick.
14+
- A new item's top-level keys are headed "Includes:" rather than "Also changed:": every key of an item that does not exist yet is part of it.
15+
- The columns the platform injects and hides (`__search`, `organization_id`, `owning_business_unit_id`) are still not listed at all.
16+
17+
New strings `preview.changes.detailNewKeys` and `preview.changes.generatedGroup` ship in all ten locale packs.

‎scripts/sdui-manifest.record.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@
4949
"ui/packages/types"
5050
],
5151
"registrySources": 922,
52-
"registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063"
52+
"registrySourcesSha256": "0e127be18a1104d857822dc5c71a6380e4f00b0db729cd8b8a230a11c120fe78"
5353
}
5454
}

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

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
* Each entry expands into the fields (objects) and top-level keys it adds,
1515
* changes and removes, lazily fetched on first expand. An entry whose drill-in
1616
* finds the draft equal to its published version drops the UPDATE mark
17-
* (objectui#11807): publishing still promotes it, but it changes nothing. This
17+
* (objectui#11807): publishing still promotes it, but it changes nothing. What
18+
* the platform wrote rather than the author — the system columns it provisions,
19+
* the keys it derives — folds into one collapsed line (objectstack#25890). This
1820
* is the review surface that turns Publish from a leap of faith into an
1921
* informed click.
2022
*
@@ -83,7 +85,7 @@ import { MetadataProtectionFields, stripReadDecorations } from '@objectstack/spe
8385
// objectui#12929 — the ONE discriminator of an authored default's shape, the
8486
// same function the batch door's backfill judgement calls. Moves nothing onto
8587
// the eager graph for the reason recorded above (`vendor-objectstack`).
86-
import { discriminateDefaultValueShape } from '@objectstack/spec/data';
88+
import { discriminateDefaultValueShape, provisionPrimary } from '@objectstack/spec/data';
8789
import { diffFields, readFields } from '../views/metadata-admin/previews/object-fields-io.js';
8890
// objectui#13125 — the Data pillar's rule for a column the platform injected
8991
// and hides (objectui#11780), reused rather than restated. A leaf module with
@@ -285,20 +287,68 @@ export interface EntryChangeDetail {
285287
changed: string[];
286288
removed: string[];
287289
};
290+
/**
291+
* What publishing installs that the PLATFORM wrote, not the author: the
292+
* system columns it provisions for the object and the keys it derives
293+
* (objectstack#25890). Listed once, in a collapsed group of its own, never in
294+
* `fields` or `keys` above. Field rows sort before key rows; each sorts by
295+
* name. A generated field's label, when it declares one, is in
296+
* `fields.labels`.
297+
*/
298+
generated: Array<{ name: string; kind: 'added' | 'changed' | 'removed'; scope: 'field' | 'key' }>;
299+
/**
300+
* True when there is no published body, so publishing ADDS the item
301+
* (objectstack#25890). Every key it lists is then something the new item
302+
* includes, not something else that changed, and the drill-in heads them so.
303+
*/
304+
isNew: boolean;
288305
}
289306

290307
/** Stable equality for metadata values (small JSON — order-sensitive is fine). */
291308
function valueEqual(a: unknown, b: unknown): boolean {
292309
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
293310
}
294311

295-
/** True when the detail lists nothing: no field row and no key row. */
312+
/** True when the detail lists nothing: no field row, no key row and no generated row. */
296313
function isEmptyChangeDetail(detail: EntryChangeDetail): boolean {
297-
const { fields, keys } = detail;
314+
const { fields, keys, generated } = detail;
298315
const fieldRows = fields ? fields.added.length + fields.changed.length + fields.removed.length : 0;
299-
return fieldRows + keys.added.length + keys.changed.length + keys.removed.length === 0;
316+
return fieldRows + keys.added.length + keys.changed.length + keys.removed.length + generated.length === 0;
317+
}
318+
319+
/**
320+
* Did the platform write this field definition (objectstack#25890)? The spec's own
321+
* mark: `system: true`, which `FieldSchema` declares as the auto-injected
322+
* system/audit field and tells tools that surface system fields apart from the
323+
* author's to branch on. Read as a boolean and nothing else, as the spec
324+
* declares it.
325+
*/
326+
function isPlatformSystemField(def: Record<string, unknown> | undefined): boolean {
327+
return def?.system === true;
300328
}
301329

330+
/**
331+
* Is `body.nameField` the pointer the registry DERIVES, rather than one the
332+
* author chose (objectstack#25890)? The `/meta` reads replay the registry's
333+
* materialization seam, which stamps `nameField` through `provisionPrimary` in
334+
* designate-only mode. The answer is that same function over the body without
335+
* the pointer — the registry's own write-side test for the same question — so
336+
* no derivation rule is restated here. A pointer naming any other field is the
337+
* author's choice and stays an author row; an absent pointer is no stamp.
338+
*/
339+
function isDerivedNameField(body: Record<string, unknown>): boolean {
340+
const present = body.nameField;
341+
if (typeof present !== 'string' || present === '') return false;
342+
const withoutPointer = { ...body };
343+
delete withoutPointer.nameField;
344+
return provisionPrimary(withoutPointer, { synthesize: false }).nameField === present;
345+
}
346+
347+
/** Top-level keys whose every present value is platform-derived (objectstack#25890). */
348+
const DERIVED_KEY_TESTS: Readonly<Record<string, (body: Record<string, unknown>) => boolean>> = {
349+
nameField: isDerivedNameField,
350+
};
351+
302352
/**
303353
* The ADR-0010 protection envelope (`_lock`, `_packageId`, `_provenance`, …),
304354
* read off the spec's own declaration so no key list lives here. The review
@@ -351,9 +401,17 @@ function fieldLabelOf(def: Record<string, unknown> | undefined): string | undefi
351401
* the Data pillar's own test (`isStudioHiddenSystemField`, objectui#11780).
352402
* The `/meta` reads serve an object with the platform's columns already
353403
* stamped on, so a NEW object's drill-in listed the search companion
354-
* `__search` as a field the author added. Only that rule decides: a column
355-
* the platform injects but does not hide (the audit columns, `owner_id`) is
356-
* still listed.
404+
* `__search` as a field the author added. Only that rule drops a row
405+
* outright.
406+
*
407+
* A column the platform injects but does NOT hide — the audit columns,
408+
* `owner_id` — is a row, but not the author's (objectstack#25890): a field this
409+
* publish adds or removes whose every definition carries `system: true` moves
410+
* to `generated`, the collapsed group of what the platform wrote. So does a
411+
* top-level key whose value is the platform's derivation (`nameField`, see
412+
* {@link isDerivedNameField}). A system field CHANGED between two bodies stays
413+
* an author row: the platform injects the same definition on both reads, so a
414+
* difference there is an edit someone made.
357415
*
358416
* Why not `computeDiffRows` from `LayeredDiff.tsx`, which splits top-level keys
359417
* the same way: that module imports the metadata-admin string table
@@ -368,6 +426,7 @@ export function computeChangeDetail(
368426
const pub = withoutProtectionEnvelope(published ?? {});
369427
const cur = withoutProtectionEnvelope(draft ?? {});
370428
let fields: EntryChangeDetail['fields'] = null;
429+
const generatedFields: EntryChangeDetail['generated'] = [];
371430
if (pub.fields != null || cur.fields != null) {
372431
const d = diffFields(pub.fields, cur.fields);
373432
const pubDefs = fieldDefsByName(pub.fields);
@@ -376,38 +435,55 @@ export function computeChangeDetail(
376435
const defs = [pubDefs.get(name), curDefs.get(name)].filter((def) => def !== undefined);
377436
return !(defs.length > 0 && defs.every((def) => isStudioHiddenSystemField(def)));
378437
};
379-
const added = Object.values(d.byName)
438+
// objectstack#25890 — added or removed, and the platform's on every side.
439+
const platformWrote = (name: string): boolean =>
440+
[pubDefs.get(name), curDefs.get(name)].every((def) => def === undefined || isPlatformSystemField(def));
441+
const addedAll = Object.values(d.byName)
380442
.filter((e) => e.status === 'added' && listed(e.name))
381443
.map((e) => e.name)
382444
.sort();
383445
const changed = Object.values(d.byName)
384446
.filter((e) => e.status === 'changed' && listed(e.name))
385447
.map((e) => ({ name: e.name, keys: e.changedKeys }))
386448
.sort((a, b) => a.name.localeCompare(b.name));
387-
const removed = d.removed
449+
const removedAll = d.removed
388450
.map((e) => e.name)
389451
.filter(listed)
390452
.sort();
453+
const added = addedAll.filter((name) => !platformWrote(name));
454+
const removed = removedAll.filter((name) => !platformWrote(name));
455+
for (const name of addedAll) if (platformWrote(name)) generatedFields.push({ name, kind: 'added', scope: 'field' });
456+
for (const name of removedAll) {
457+
if (platformWrote(name)) generatedFields.push({ name, kind: 'removed', scope: 'field' });
458+
}
459+
generatedFields.sort((a, b) => a.name.localeCompare(b.name));
391460
const labels = new Map<string, string>();
392-
for (const name of [...added, ...changed.map((f) => f.name), ...removed]) {
461+
for (const name of [...addedAll, ...changed.map((f) => f.name), ...removedAll]) {
393462
// The draft's definition where there is one: it is what publishing installs.
394463
const label = fieldLabelOf(curDefs.get(name) ?? pubDefs.get(name));
395464
if (label !== undefined) labels.set(name, label);
396465
}
397466
fields = { added, changed, removed, labels };
398467
}
468+
const isNew = published === null;
399469
const names = new Set([...Object.keys(pub), ...Object.keys(cur)]);
400470
names.delete('fields');
401471
const keys: EntryChangeDetail['keys'] = { added: [], changed: [], removed: [] };
472+
const generatedKeys: EntryChangeDetail['generated'] = [];
402473
for (const k of [...names].sort()) {
403474
const before = (pub as Record<string, unknown>)[k];
404475
const after = (cur as Record<string, unknown>)[k];
405476
if (valueEqual(before, after)) continue;
406-
if (before == null) keys.added.push(k);
407-
else if (after == null) keys.removed.push(k);
408-
else keys.changed.push(k);
477+
const kind = before == null ? 'added' : after == null ? 'removed' : 'changed';
478+
const derived = Object.prototype.hasOwnProperty.call(DERIVED_KEY_TESTS, k) ? DERIVED_KEY_TESTS[k] : undefined;
479+
// Derived only when every body that carries the key carries the derivation.
480+
if (derived && [before == null ? null : pub, after == null ? null : cur].every((b) => b === null || derived(b))) {
481+
generatedKeys.push({ name: k, kind, scope: 'key' });
482+
continue;
483+
}
484+
keys[kind].push(k);
409485
}
410-
return { fields, keys };
486+
return { fields, keys, generated: [...generatedFields, ...generatedKeys], isNew };
411487
}
412488

413489
/**
@@ -678,7 +754,7 @@ function EntryDetail({
678754
);
679755
}
680756

681-
const { fields, keys } = detail;
757+
const { fields, keys, generated, isNew } = detail;
682758
const hasKeyRows = keys.added.length > 0 || keys.changed.length > 0 || keys.removed.length > 0;
683759
if (isEmptyChangeDetail(detail)) {
684760
return (
@@ -737,7 +813,10 @@ function EntryDetail({
737813
))}
738814
{hasKeyRows && (
739815
<p className="text-xs text-muted-foreground">
740-
{t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })}
816+
{isNew
817+
? // objectstack#25890 — every key of a NEW item is new: what it includes, not what else changed.
818+
t('preview.changes.detailNewKeys', { defaultValue: 'Includes:' })
819+
: t('preview.changes.detailChangedKeys', { defaultValue: 'Also changed:' })}
741820
</p>
742821
)}
743822
{keys.added.map((name) => (
@@ -755,6 +834,55 @@ function EntryDetail({
755834
− {name}
756835
</p>
757836
))}
837+
{generated.length > 0 && <GeneratedGroup rows={generated} labels={fields?.labels} />}
838+
</div>
839+
);
840+
}
841+
842+
/**
843+
* objectstack#25890 — what the platform wrote into this publish, folded into
844+
* one line: the system columns it provisions and the keys it derives. Collapsed
845+
* on every open, the confirm posture's too (that posture expands the item rows,
846+
* not this group); the toggle shows the rows, read-only, in the same shape as
847+
* the author's.
848+
*/
849+
function GeneratedGroup({
850+
rows,
851+
labels,
852+
}: {
853+
rows: EntryChangeDetail['generated'];
854+
labels: ReadonlyMap<string, string> | undefined;
855+
}) {
856+
const { t } = useObjectTranslation();
857+
const [open, setOpen] = useState(false);
858+
return (
859+
<div data-testid="draft-generated-group">
860+
<button
861+
type="button"
862+
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
863+
aria-expanded={open}
864+
data-testid="draft-generated-toggle"
865+
onClick={() => setOpen((v) => !v)}
866+
>
867+
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
868+
{t('preview.changes.generatedGroup', {
869+
n: rows.length,
870+
defaultValue: 'Generated by the platform ({{n}})',
871+
})}
872+
</button>
873+
{open && (
874+
<div className="ml-4 flex flex-col gap-0.5 opacity-80">
875+
{rows.map((row) =>
876+
row.scope === 'field' ? (
877+
<FieldRow key={`g${row.kind}${row.name}`} kind={row.kind} name={row.name} label={labels?.get(row.name)} />
878+
) : (
879+
<p key={`gk${row.kind}${row.name}`} className={`font-mono text-xs ${FIELD_ROW_TONE[row.kind]}`}>
880+
{FIELD_ROW_MARK[row.kind]} {row.name}
881+
</p>
882+
),
883+
)}
884+
</div>
885+
)}
758886
</div>
759887
);
760888
}

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@
3030
*
3131
* - `internal_note` — an author field with `hidden: true` and no `system`:
3232
* listed.
33-
* - `owner_id` — `system: true` without `hidden`: listed.
34-
* - `created_at` — an audit column, `system` without `hidden`: listed. The
35-
* rule does not hide it, and this card does not widen the rule.
33+
* - `owner_id` — `system: true` without `hidden`: not dropped.
34+
* - `created_at` — an audit column, `system` without `hidden`: not dropped.
35+
* The rule does not hide it, and this card does not widen the rule. Both are
36+
* rows of the collapsed group of what the platform wrote, not the author's
37+
* field rows (objectstack#25890, pinned in the generatedGroup test beside
38+
* this one); here they are the controls that the drop rule leaves them in
39+
* the detail at all.
3640
*/
3741

3842
import '@testing-library/jest-dom/vitest';
@@ -95,17 +99,19 @@ const VISIT_DRAFT = {
9599
code: { type: 'text', label: 'code' },
96100
// Control: author-hidden, not system — listed.
97101
internal_note: { type: 'text', label: 'Internal Note', hidden: true },
98-
// Control: system, not hidden — listed.
102+
// Control: system, not hidden — not dropped (a generated row, objectstack#25890).
99103
owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner', required: false, readonly: false, system: true },
100-
// Control: audit column — listed; the rule does not hide it.
104+
// Control: audit column — not dropped; the rule does not hide it (a generated row).
101105
created_at: CREATED_AT_DEF,
102106
owning_business_unit_id: OWNING_BUSINESS_UNIT_DEF,
103107
__search: SEARCH_COMPANION_DEF,
104108
},
105109
};
106110

107111
const HIDDEN_SYSTEM = ['__search', 'owning_business_unit_id'] as const;
108-
const VISIT_LISTED = ['bare', 'code', 'created_at', 'internal_note', 'owner_id', 'visit_date'];
112+
const VISIT_LISTED = ['bare', 'code', 'internal_note', 'visit_date'];
113+
/** The system columns the drop rule keeps: rows of the generated group (objectstack#25890). */
114+
const VISIT_GENERATED = ['created_at', 'owner_id'];
109115

110116
afterEach(() => {
111117
cleanup();
@@ -116,6 +122,7 @@ describe('computeChangeDetail — a column the platform injected and hides is no
116122
it("a NEW object's added fields leave out the injected-and-hidden columns, and keep every control", () => {
117123
const d = computeChangeDetail(null, VISIT_DRAFT);
118124
expect(d.fields?.added).toEqual(VISIT_LISTED);
125+
expect(d.generated.filter((g) => g.scope === 'field').map((g) => g.name)).toEqual(VISIT_GENERATED);
119126
});
120127

121128
it('an UPDATE lists no changed or removed row for one either, and lists the author rows beside it', () => {
@@ -254,7 +261,11 @@ describe('the drill-in of a NEW object (objectui#13125)', () => {
254261
for (const name of HIDDEN_SYSTEM) expect(text).not.toContain(name);
255262
expect(text).not.toContain(SEARCH_COMPANION_DEF.label);
256263
expect(text).not.toContain(OWNING_BUSINESS_UNIT_DEF.label);
257-
for (const label of ['Internal Note', 'Owner', 'Created At']) fieldRow(detail, `+ ${label}`);
264+
fieldRow(detail, '+ Internal Note');
265+
// The two system controls are in the detail, behind the generated group's toggle.
266+
fireEvent.click(within(detail).getByTestId('draft-generated-toggle'));
267+
for (const label of ['Owner', 'Created At']) fieldRow(detail, `+ ${label}`);
268+
for (const name of HIDDEN_SYSTEM) expect(detail.textContent ?? '').not.toContain(name);
258269
});
259270

260271
it('CONTROL: a field with no label reads as its name alone, once, in monospace', async () => {

0 commit comments

Comments
 (0)