Skip to content

Commit d53fd02

Browse files
fix(app-shell): Studio's Data pillar keeps platform-injected hidden system fields out of the grid, Form preview and designer (objectui#11780) (#11828)
Fixes #11780 Clause-②: no ## What changed Studio's Data pillar kept framework fields out of the records grid (`gridColumns`), the Form preview (`formFields`) and the form designer by one fixed name list, `STUDIO_SYSTEM_FIELD_NAMES`. The platform's search companion `__search` and `owning_business_unit_id` are not on that list, so every author saw "Search Index" and "Owning Business Unit" in all three views, while the runtime list hides both. - New module-private predicate `isStudioHiddenSystemField(def)` in `studio-design/studioHiddenSystemField.ts` (not on the package entry): `system === true && hidden === true` on the served field definition, per the seat's ruling. An author field with `hidden: true` and no `system` stays visible; so does `owner_id` (`system`, not hidden). - `gridColumns` and `formFields` drop entries that match it, beside the unchanged name-list check. Memo keys are unchanged: `objDraft.fields`, plus `publishedFieldNames` for the grid. - `ObjectFormDesigner`: one module-level test, `isKeptOffLayout(entry, systemFieldNames)` (the name list OR the predicate), now serves all three of its readers: the density count, the containers, and the commit's write-back. A field kept off the canvas is therefore always written back. No prop change; the mount still passes `STUDIO_SYSTEM_FIELD_NAMES`. - `STUDIO_SYSTEM_FIELD_NAMES` keeps its job, measured below: the platform's audit columns are `system` without `hidden`, so the marks alone would put them back. Its doc block now says so. - Patch changeset for `@object-ui/app-shell`. Fence held: no `MetadataClient.save` call site, no version-token read, no change to the object-draft load effect, no edit to the metadata-admin `i18n.ts`, no package-entry export, no type member, no locale key. `ObjectFormDesigner` is not re-exported from the app-shell `index.ts` (grep: zero hits; the positive control `registerMetadataPreview` hits). ## Measurements against the PM hypotheses **H1, measured live.** objectstack `a543e244`, `examples/app-showcase` booted with `objectstack dev --seed-admin --fresh` on an isolated port, read through the same endpoints `MetadataClient.layered` and `getDraft` call. - No pending draft, `showcase_account`, `GET /api/v1/meta/object/showcase_account/layers`. `effective.fields` is the record shape. `__search`: hidden true, system true, readonly true. `owning_business_unit_id`: hidden true, system true, readonly true. `organization_id`: the same. `owner_id`: system true, readonly false, no hidden. `created_at`: system true, readonly true, no hidden. Author field `name`: hidden false, readonly false, no system. (`code.fields` has no `__search`; it is provisioned into `effective`.) - Served draft: an object created the way `doCreateObject` creates one (the skeleton with one `name` field, saved with mode=draft into a Studio-created package), then `GET ...?state=draft`. `item.fields` is organization_id, created_at, created_by, updated_at, updated_by, owner_id, owning_business_unit_id, name, __search. `__search` and `owning_business_unit_id`: hidden true, system true, readonly true. Author field `name`: label only. An object of a code-provided package takes no draft (the save answers 403, read-only package), so the served-draft case is a Studio-authored object. - So the marks sit on the entries `readFields(objDraft.fields)` returns in both cases. No second source was needed. **H2, confirmed.** The three readers above are the leak. No separate seed carries the injected fields: `buildObjectSkeleton` seeds one `name` text field, the server adds the injected columns to the served draft, and the designer's ungrouped bucket rendered them. That bucket is the filer's "default layout seed". **H3, confirmed and kept.** The designer's commit writes the kept-off entries followed by the canvas entries. With the shared test, the injected hidden fields ride the same path as the audit columns. Pinned through the real pillar: a designer drop is auto-saved with both fields present and their definitions deep-equal to the served ones. **H4, not touched.** `ObjectFormCanvas` (the `object` metadata preview, mounted through `ObjectPreview` in the metadata-admin editor) renders every entry through `groupEntries(view, ...)` with no system filter of any kind; it shows the audit columns too. It is a full field-inventory editor, and the Data pillar does not mount it (the pillar mounts `ObjectFormDesigner` and the grid). Hiding fields there would be a new design decision, not this leak. **H5, kept.** Both memo keys are unchanged. A new pin holds the grid columns array at the same identity across an object-label edit made through the real `onPatch`. ## Live check in the browser The console dev server from this branch, proxied to that backend, headless Chromium at 1440x900, signed in as the seeded admin. - This branch: the `showcase_account` Records grid headers run Account Name, Industry, ..., Owner, Loyalty Tier, LinkedIn URL, CSAT Score, Actions. Neither "Search Index" nor "Owning Business Unit" appears. Both designers (`showcase_account`, and the Studio-created object) show neither label. The new object's designer shows Owner and Name. - The same session with both source files set to the base commit (restored afterwards; blob hashes equal HEAD, `git diff HEAD` empty): the headers include "Owning Business Unit" and "Search Index", and both designers show both labels. ## Pins `DataPillar.hiddenSystemFields-11780.test.tsx` drives the real `DataPillar`. The fixture copies the platform's own literals (`provisionSearchCompanion`, `OWNING_BUSINESS_UNIT_FIELD_DEF`, `TENANT_SCOPE_FIELD_DEF`, the `created_at` row of `AUDIT_FIELD_DEFS`). Each case runs twice: once on an object with no pending draft, and once with a served draft. - Records grid, Form preview, designer: `__search`, `owning_business_unit_id` and `organization_id` are absent. The author fields are present. - Controls: `internal_note` (author `hidden: true`, no `system`) and `owner_id` (`system`, not hidden) stay. `created_at` stays out, by the name list. - Write-back: a designer drop (the captured `onDragEnd`, with the real `DndContext` rendered) auto-saves `fields`. In those fields `industry` now leads `name`, every hidden system field's definition is deep-equal to the served one, and the key set is unchanged. - Identity: an unrelated draft edit (the object label) leaves the grid columns array at the same identity, and the save carries the new label. - The predicate's truth table: both marks, booleans only. Reverse checks. The fix was committed first. Each leg is restored with `git checkout HEAD --`, then proven by a blob hash equal to HEAD and an empty `git diff HEAD`. Predicted direction first: 1. Both source files at the base commit (marker counts 4 and 4 fell to 0 and 0): 6 failed, 5 passed. The failures are grid, Form preview and designer, each twice; the first reads "expected [ 'name', 'industry', ... ] to not include '__search'". Write-back, identity and the predicate stay green, as predicted. 2. The designer commit's write-back test changed back to the name list alone: 2 failed (write-back, twice), "expected undefined to deeply equal { type: 'text', ... }". This leg proves the hidden fields would be dropped. 3. The `gridColumns` key loosened to `objDraft`: 2 failed (identity, twice). The first attempt at leg 3 was a no-op: the script hit a syntax error before the mutation landed (anchor count 1/0 unchanged, nothing written). It was rerun with a corrected script, and the counts after the mutation read 0/1. ## Gates (local HEAD 7343b3a) - `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0 - `pnpm exec vitest run` on `StudioDesignSurface.gridColumns`, `StudioDesignSurface.formFields`, `ObjectFormDesigner`, every `DataPillar.*` test and the new pins: exit 0, 13 files, 56 tests passed - `pnpm exec vitest run packages/app-shell/src/views/studio-design/` (at 9feaf8b; 7343b3a changes only the new test file's mock typing): exit 0, 93 files, 548 tests passed - `pnpm --filter @object-ui/app-shell type-check` (echoes `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 - `pnpm check:control-bytes` OK · `check:new-line-citations` VERDICT 0 new citation(s) · `check:changeset-claims` OK · `check:pending-changeset-literals` OK: all exit 0 - Added for this diff: `check:vi-mock-specifiers` · `check:vi-mock-inherit` · `check:vi-mock-override-shape` (new `vi.mock` doubles) · `check:test-path-roots` · `check:unreferenced-sources` (new source file) · `check:metadata-write-doors`: all exit 0. Also `node scripts/check-changeset-presence.mjs`, `node scripts/check-changeset-no-major.mjs` and `node scripts/check-governed-queue-guard.mjs --test` on the changed paths (NOT GOVERNED): all exit 0. - Targeted eslint, as the package's `lint` runs it, on the four touched source files: 0 errors. On the two modified files the warnings are the same set as at the base commit (ObjectFormDesigner 1, StudioDesignSurface 17). The new files have none. Type-aware linting is not enabled in `eslint.config.js`, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` belongs to CI. ## Acceptance notes - The designer's write-back puts the kept-off fields first in `fields`. That was already true for the audit columns, and is now true for the injected hidden ones. Their definitions are unchanged. Before this change these fields sat in the ungrouped bucket and were rewritten at that bucket's position, so a designer edit moved them before too. - The object header's "N fields" count still counts every served field, the system ones included. It did so before this change, and it is not on this card's surface. - One line outside the claim's listed regions of `StudioDesignSurface.tsx`: the import of the new helper, in the module's import block. In-flight draft PR #11826 also adds an import there, in an earlier part of the block near `useMetadataClient`. The two hunks do not overlap, and neither do its `DataPillar` hunks with this branch's. Session: `https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z` (dispatching seat domain:ui#1). --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dec305 commit d53fd02

5 files changed

Lines changed: 422 additions & 6 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Studio's Data pillar no longer shows the columns the platform adds to an object and hides (objectui#11780).
6+
7+
The records grid, the Form preview and the form designer showed "Search Index" (`__search`) and "Owning Business Unit" (`owning_business_unit_id`) on every object, while the runtime list hides both. The three views now leave out every field the served metadata marks both `system: true` and `hidden: true`, which is how the platform marks the columns it injects for its own use. The tenant column `organization_id` carries the same marks and stays out as before.
8+
9+
- A field you hid yourself (`hidden: true` without `system`) still shows in Studio, so you can open it and un-hide it.
10+
- A system field that is not hidden, such as the reassignable `owner_id`, still shows.
11+
- The audit columns (`created_at`, `updated_by` and the rest) stay out of these views as before.
12+
- The form designer only hides these fields. Moving, reordering or regrouping fields saves them back with their definitions unchanged.
13+
14+
Nothing is added to the package entry: no export, prop, type member or language-pack key.
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Studio's Data pillar keeps the columns the platform injects AND hides out of
5+
* the author's views — objectui#11780.
6+
*
7+
* The defect: the records grid, the Form preview and the form designer dropped
8+
* framework fields by one fixed name list (`STUDIO_SYSTEM_FIELD_NAMES`). The
9+
* platform's search companion `__search` and `owning_business_unit_id` are not
10+
* on it, so every author saw "Search Index" and "Owning Business Unit" in all
11+
* three, while the runtime list hides both. The platform marks both
12+
* `system: true` + `hidden: true` on the served definition; that pair is the
13+
* test now (`isStudioHiddenSystemField`).
14+
*
15+
* The fixture's injected definitions copy the platform's own literals, not a
16+
* paraphrase: `provisionSearchCompanion` (objectstack objectql's
17+
* search-companion) for `__search`, `OWNING_BUSINESS_UNIT_FIELD_DEF` and
18+
* `TENANT_SCOPE_FIELD_DEF` (objectstack spec's injected-column provenance), and
19+
* the `created_at` row of `AUDIT_FIELD_DEFS`. `fields` is the record shape the
20+
* `/meta` read serves.
21+
*
22+
* Three controls ride every case, because each one is a way to get the
23+
* predicate wrong while the headline assertion stays green:
24+
*
25+
* - `internal_note` — an AUTHOR field with `hidden: true` and no `system`. It
26+
* stays: its grid column and designer card are how the author reaches the
27+
* inspector to un-hide it. A `hidden`-only filter would strand it.
28+
* - `owner_id` — `system: true` without `hidden` (reassignable ownership). It
29+
* stays. A `system`-only filter would take a field authors design around.
30+
* - `created_at` — an audit column, `system` without `hidden`. It stays OUT, as
31+
* it was, by the name list the marks do not replace.
32+
*
33+
* Every case runs twice: once on an object with no pending draft (the pillar
34+
* edits `layered().effective`) and once on one with a served draft (the pillar
35+
* edits the draft body as served, objectui#10765).
36+
*/
37+
38+
import '@testing-library/jest-dom/vitest';
39+
import * as React from 'react';
40+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
41+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
42+
import userEvent from '@testing-library/user-event';
43+
import { MemoryRouter } from 'react-router-dom';
44+
45+
/** `provisionSearchCompanion`'s literal (description shortened; not read). */
46+
const SEARCH_COMPANION_DEF = {
47+
type: 'text',
48+
label: 'Search Index',
49+
required: false,
50+
hidden: true,
51+
readonly: true,
52+
system: true,
53+
searchable: false,
54+
description: 'Search-normalized forms of the display/name field.',
55+
};
56+
57+
/** `OWNING_BUSINESS_UNIT_FIELD_DEF`. */
58+
const OWNING_BUSINESS_UNIT_DEF = {
59+
type: 'lookup',
60+
reference: 'sys_business_unit',
61+
label: 'Owning Business Unit',
62+
required: false,
63+
hidden: true,
64+
readonly: true,
65+
system: true,
66+
description: 'Record-level business-unit ownership (ADR-0117 D1).',
67+
};
68+
69+
/** `TENANT_SCOPE_FIELD_DEF`. */
70+
const TENANT_SCOPE_DEF = {
71+
type: 'lookup',
72+
reference: 'sys_organization',
73+
label: 'Organization',
74+
required: false,
75+
hidden: true,
76+
readonly: true,
77+
system: true,
78+
description: 'Tenant scope.',
79+
};
80+
81+
const HIDDEN_SYSTEM = ['__search', 'owning_business_unit_id', 'organization_id'] as const;
82+
const HIDDEN_SYSTEM_LABELS = ['Search Index', 'Owning Business Unit', 'Organization'] as const;
83+
const HIDDEN_SYSTEM_DEFS: Record<(typeof HIDDEN_SYSTEM)[number], Record<string, unknown>> = {
84+
__search: SEARCH_COMPANION_DEF,
85+
owning_business_unit_id: OWNING_BUSINESS_UNIT_DEF,
86+
organization_id: TENANT_SCOPE_DEF,
87+
};
88+
89+
const servedObject = {
90+
name: 'showcase_account',
91+
label: 'Account',
92+
fields: {
93+
name: { type: 'text', label: 'Account Name' },
94+
industry: { type: 'text', label: 'Industry' },
95+
// Control: author-hidden, not system — stays visible to the author.
96+
internal_note: { type: 'text', label: 'Internal Note', hidden: true },
97+
// Control: system, not hidden — stays visible to the author.
98+
owner_id: { type: 'lookup', reference: 'sys_user', label: 'Owner', required: false, readonly: false, system: true },
99+
// Control: audit — stays out by the name list, as before.
100+
created_at: { type: 'datetime', label: 'Created At', required: false, readonly: true, system: true },
101+
organization_id: TENANT_SCOPE_DEF,
102+
owning_business_unit_id: OWNING_BUSINESS_UNIT_DEF,
103+
__search: SEARCH_COMPANION_DEF,
104+
},
105+
};
106+
107+
const AUTHOR_VISIBLE = ['name', 'industry', 'internal_note', 'owner_id'] as const;
108+
const AUTHOR_VISIBLE_LABELS = ['Account Name', 'Industry', 'Internal Note', 'Owner'] as const;
109+
110+
/** `null` = no pending draft; otherwise the `getDraft` envelope. */
111+
let servedDraft: unknown = null;
112+
113+
const mockClient = {
114+
list: vi.fn(async () => [{ name: 'showcase_account', label: 'Account' }]),
115+
listDrafts: vi.fn(async () => []),
116+
layered: vi.fn(async () => ({ effective: servedObject, code: servedObject })),
117+
getDraft: vi.fn(async () => servedDraft),
118+
save: vi.fn<(type: string, name: string, body: Record<string, unknown>, opts?: unknown) => Promise<unknown>>(
119+
async () => ({ ok: true }),
120+
),
121+
};
122+
123+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
124+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
125+
return {
126+
...mod,
127+
useMetadataClient: () => mockClient,
128+
useMetadataTypes: () => ({ entries: [] }),
129+
};
130+
});
131+
132+
vi.mock('./packages-io', async (importOriginal) => {
133+
const mod = await importOriginal<typeof import('./packages-io')>();
134+
return { ...mod, fetchPackages: vi.fn(async () => []) };
135+
});
136+
137+
vi.mock('@object-ui/react', async (importOriginal) => {
138+
const mod = await importOriginal<typeof import('@object-ui/react')>();
139+
return { ...mod, useAdapter: () => ({}) };
140+
});
141+
142+
/** Every columns array the pillar hands the records grid, in render order. */
143+
const seenColumns: string[][] = [];
144+
vi.mock('@object-ui/plugin-view', async (importOriginal) => {
145+
const mod = await importOriginal<Record<string, unknown>>();
146+
return {
147+
...mod,
148+
ObjectView: ({ schema }: { schema?: { table?: { fields?: string[] } } }) => {
149+
const cols = schema?.table?.fields ?? [];
150+
seenColumns.push(cols);
151+
return <div data-testid="grid-columns">{cols.join(',')}</div>;
152+
},
153+
};
154+
});
155+
156+
/** Every `fields` array the pillar hands the Form preview, in render order. */
157+
const seenFormFields: string[][] = [];
158+
vi.mock('@object-ui/plugin-form', async (importOriginal) => {
159+
const actual = await importOriginal<typeof import('@object-ui/plugin-form')>();
160+
return {
161+
...actual,
162+
ObjectForm: ({ schema }: { schema?: { fields?: string[] } }) => {
163+
const fields = schema?.fields ?? [];
164+
seenFormFields.push(fields);
165+
return <div data-testid="form-fields">{fields.join(',')}</div>;
166+
},
167+
};
168+
});
169+
170+
/** An unrelated draft edit: the object's label, through the real `onPatch`. */
171+
vi.mock('./ObjectSettingsPanel', () => ({
172+
ObjectSettingsPanel: ({ onPatch }: { onPatch: (patch: Record<string, unknown>) => void }) => (
173+
<button type="button" onClick={() => onPatch({ label: 'Account (renamed)' })}>
174+
rename-object
175+
</button>
176+
),
177+
}));
178+
179+
/**
180+
* The designer's drag-end handler. The REAL `DndContext` still renders; this
181+
* only records the handler so a drop can be driven without pointer geometry
182+
* (the `UnifiedSidebar.groupedNavOrder-11626` instrument).
183+
*/
184+
type DragEnd = (event: { active: { id: string }; over: { id: string } | null }) => void;
185+
const dnd: { onDragEnd: DragEnd | null } = { onDragEnd: null };
186+
vi.mock('@dnd-kit/core', async (importOriginal) => {
187+
const actual = await importOriginal<typeof import('@dnd-kit/core')>();
188+
const ReactMod = await import('react');
189+
const CapturingDndContext = (props: Record<string, unknown>) => {
190+
dnd.onDragEnd = props.onDragEnd as DragEnd;
191+
return ReactMod.createElement(actual.DndContext, props as never);
192+
};
193+
return { ...actual, DndContext: CapturingDndContext };
194+
});
195+
196+
import { DataPillar } from './StudioDesignSurface';
197+
import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel';
198+
import { isStudioHiddenSystemField } from './studioHiddenSystemField';
199+
200+
beforeEach(() => {
201+
seenColumns.length = 0;
202+
seenFormFields.length = 0;
203+
dnd.onDragEnd = null;
204+
});
205+
206+
afterEach(() => {
207+
cleanup();
208+
vi.clearAllMocks();
209+
servedDraft = null;
210+
});
211+
212+
function renderPillar() {
213+
return render(
214+
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
215+
<SurfaceDeepLinkProvider>
216+
<DataPillar packageId="com.example.showcase" />
217+
</SurfaceDeepLinkProvider>
218+
</MemoryRouter>,
219+
);
220+
}
221+
222+
const listed = (testId: string) => (screen.getByTestId(testId).textContent ?? '').split(',');
223+
224+
const CASES = [
225+
{ title: 'no pending draft (edits the served object)', draft: null },
226+
{
227+
title: 'a served draft (edits the draft body as served)',
228+
draft: {
229+
type: 'object',
230+
name: 'showcase_account',
231+
item: { ...servedObject, description: 'pending draft' },
232+
},
233+
},
234+
] as const;
235+
236+
describe.each(CASES)('Data pillar on an object with $title (objectui#11780)', ({ draft }) => {
237+
beforeEach(() => {
238+
servedDraft = draft;
239+
});
240+
241+
it('records grid: the injected-and-hidden columns are not columns; the author fields are', async () => {
242+
renderPillar();
243+
await waitFor(() => expect(listed('grid-columns')).toContain('name'), { timeout: 4000 });
244+
245+
const cols = listed('grid-columns');
246+
for (const name of HIDDEN_SYSTEM) expect(cols).not.toContain(name);
247+
for (const name of AUTHOR_VISIBLE) expect(cols).toContain(name);
248+
expect(cols).not.toContain('created_at');
249+
// Not through any render along the way either.
250+
expect(seenColumns.some((c) => c.some((n) => (HIDDEN_SYSTEM as readonly string[]).includes(n)))).toBe(false);
251+
});
252+
253+
it('Form preview: the injected-and-hidden fields are not form fields; the author fields are', async () => {
254+
renderPillar();
255+
await waitFor(() => expect(screen.getByTestId('grid-columns')).toBeInTheDocument(), { timeout: 4000 });
256+
fireEvent.click(screen.getByRole('button', { name: 'Form' }));
257+
fireEvent.click(screen.getByRole('button', { name: 'Preview' }));
258+
await waitFor(() => expect(screen.getByTestId('form-fields')).toBeInTheDocument(), { timeout: 4000 });
259+
260+
const fields = listed('form-fields');
261+
for (const name of HIDDEN_SYSTEM) expect(fields).not.toContain(name);
262+
for (const name of AUTHOR_VISIBLE) expect(fields).toContain(name);
263+
expect(fields).not.toContain('created_at');
264+
});
265+
266+
it('form designer: no card for the injected-and-hidden fields; the author fields have one', async () => {
267+
renderPillar();
268+
await waitFor(() => expect(screen.getByTestId('grid-columns')).toBeInTheDocument(), { timeout: 4000 });
269+
fireEvent.click(screen.getByRole('button', { name: 'Form' }));
270+
await waitFor(() => expect(screen.getByText('Account Name')).toBeInTheDocument(), { timeout: 4000 });
271+
272+
for (const label of AUTHOR_VISIBLE_LABELS) expect(screen.getByText(label)).toBeInTheDocument();
273+
for (const label of HIDDEN_SYSTEM_LABELS) expect(screen.queryByText(label)).not.toBeInTheDocument();
274+
expect(screen.queryByText('Created At')).not.toBeInTheDocument();
275+
});
276+
277+
it('write-back: a designer drop saves `fields` that still carry every hidden system field, definition unchanged', async () => {
278+
renderPillar();
279+
await waitFor(() => expect(screen.getByTestId('grid-columns')).toBeInTheDocument(), { timeout: 4000 });
280+
fireEvent.click(screen.getByRole('button', { name: 'Form' }));
281+
await waitFor(() => expect(screen.getByText('Account Name')).toBeInTheDocument(), { timeout: 4000 });
282+
expect(dnd.onDragEnd).not.toBeNull();
283+
284+
// Drop `industry` onto `name`: a reorder inside the ungrouped section.
285+
act(() => dnd.onDragEnd!({ active: { id: 'f:industry' }, over: { id: 'f:name' } }));
286+
287+
// The auto-save sends the draft the designer committed.
288+
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 5000 });
289+
const body = mockClient.save.mock.calls.at(-1)![2] as { fields: Record<string, Record<string, unknown>> };
290+
291+
// The edit really committed — `industry` now leads `name` …
292+
const order = Object.keys(body.fields).filter((k) => k === 'name' || k === 'industry');
293+
expect(order).toEqual(['industry', 'name']);
294+
// … and nothing the canvas hid was dropped or rewritten.
295+
for (const name of HIDDEN_SYSTEM) expect(body.fields[name]).toEqual(HIDDEN_SYSTEM_DEFS[name]);
296+
expect(body.fields.created_at).toEqual(servedObject.fields.created_at);
297+
expect(Object.keys(body.fields).sort()).toEqual(Object.keys(servedObject.fields).sort());
298+
});
299+
300+
it('identity: an unrelated draft edit (the object label) leaves the grid columns array at the SAME identity', async () => {
301+
renderPillar();
302+
await waitFor(() => expect(listed('grid-columns')).toContain('name'), { timeout: 4000 });
303+
const before = seenColumns[seenColumns.length - 1];
304+
305+
await userEvent.click(await screen.findByTestId('data-tabs-advanced'));
306+
await userEvent.click(await screen.findByRole('menuitem', { name: 'Settings' }));
307+
fireEvent.click(await screen.findByRole('button', { name: 'rename-object' }));
308+
fireEvent.click(screen.getByRole('button', { name: 'Records' }));
309+
await waitFor(() => expect(screen.getByTestId('grid-columns')).toBeInTheDocument(), { timeout: 4000 });
310+
311+
expect(seenColumns[seenColumns.length - 1]).toBe(before);
312+
// The edit was real: the draft the pillar saves carries the new label.
313+
await waitFor(() => expect(mockClient.save).toHaveBeenCalled(), { timeout: 5000 });
314+
expect(mockClient.save.mock.calls.at(-1)![2].label).toBe('Account (renamed)');
315+
});
316+
});
317+
318+
describe('isStudioHiddenSystemField — both marks, as booleans (objectui#11780)', () => {
319+
it('is true only for `system: true` together with `hidden: true`', () => {
320+
expect(isStudioHiddenSystemField(SEARCH_COMPANION_DEF)).toBe(true);
321+
expect(isStudioHiddenSystemField(OWNING_BUSINESS_UNIT_DEF)).toBe(true);
322+
expect(isStudioHiddenSystemField({ system: true })).toBe(false);
323+
expect(isStudioHiddenSystemField({ hidden: true })).toBe(false);
324+
expect(isStudioHiddenSystemField({ system: true, hidden: false })).toBe(false);
325+
expect(isStudioHiddenSystemField({ system: 'true', hidden: true })).toBe(false);
326+
expect(isStudioHiddenSystemField(undefined)).toBe(false);
327+
});
328+
});

0 commit comments

Comments
 (0)