Skip to content

Commit a2f523a

Browse files
fix(app-shell): Setup's metadata editor edits views, apps and dashboards as authored, not as translated (#30858)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao5ZvZUE3RgkiyeEu1r7wiff)_ Part of #30786: Wanted 1, 2 and 4 for Setup's metadata editor. Wanted 3 (the two plugin-designer pages) is left open for a second PR; the reason is in the report on the card. Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 元数据编辑器编辑视图、应用、仪表盘(以及服务端会翻译的其它类型)时,可编辑副本改为取分层读取里作者原样存的草稿,不再取按控制台语言翻译过的草稿读取。 - 为什么改:中文控制台里,第一次保存正确,保存后刷新装入的是中文草稿,第二次保存就把中文当成作者原文存了进去,英文用户随后看到中文;在 zh-CN 实测栈上视图、应用、仪表盘三种都复现。 - 风险与回滚:只改客户端读哪一层,不改服务端和协议;服务端不翻译的类型行为不变。回滚即 revert 本 PR。 - 先看:`authoredObjectBuffer.ts`(一处读法,按 `TRANSLATABLE_METADATA_TYPES` 判定)、`ResourceEditPage.tsx`(加载、保存后刷新、发布后刷新三处改用它)、测试文件里新增的 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ResourceEditPage.authoredObjectBuffer-29793.test.tsx`,应见 12 个测试全绿。 - 需要你做的:无,席位落地。 - **席位意见**:ui 席 2 对照 head `e594200c` 的 diff 复核:13 个文件全在 `app-shell/src/views/metadata-admin/` 加根 `.changeset/`,只改客户端读哪一层(按 `TRANSLATABLE_METADATA_TYPES` 判定),对象分支与 Studio 不动;zh-CN 实测栈三种类型复现、`effective` 未翻译;九个旧测试替身补上服务端本就会给的 `draft` 层,断言未改;CI 全绿,Clause-② no,不欠契约复审。Part of #30786:Wanted 3(plugin-designer 两页)走第二个 PR;同类的 Studio 编辑器问题已另立 #30859。同意落地。 ## What changes Setup's metadata editor (`MetadataResourceEditPage`) seeded the edit buffer of every type but `object` from `?state=draft`, which the server translates for the console's `Accept-Language`. PR #30784 had moved only `object` onto the layered read's author-owned layers. - `authoredObjectBuffer.ts` now holds the one reading for every type whose served reads are translated. `editsAuthoredLayers(type)` asks the spec's `TRANSLATABLE_METADATA_TYPES`, the same set the REST read translates by, so the client and the server answer "is this read translated" from one source. `authoredBuffer(type, layers, servedDraft)` is the stored `draft` layer as-is, else the published item as authored; a served draft with no `draft` layer is `unserved` (no buffer, nothing sent), as for objects. `authoredPublished(type, layers)` is `overlay ?? code` for an object (its `effective` is governed) and `effective ?? code` for the others, which the layered read serves untranslated. `authoredObjectBuffer` / `authoredPublishedObject` stay as the object's spelling of the same functions, so Studio's object editor (`StudioDesignSurface.tsx`) is untouched. - `ResourceEditPage.tsx`: the load, the save refresh and the publish refresh use that reading for those types; reset and discard use `authoredPublished` (byte-identical to the old `effective ?? code` for non-objects). The save refresh now re-reads `/layers` for those types even for a draft-only item, because their buffer is the `draft` layer (the server answers it for a draft-only name to an admitted author). Wanted 2's three named cases: a flow and a code-defined datasource are not in `TRANSLATABLE_METADATA_TYPES`, so their editor is unchanged and keeps the server's reading. An orphaned row is never read as `overlay ?? code` here for a non-object type: the published half stays the layered read's own `effective ?? code`, so whatever the server decides `effective` is (orphan included) is what the editor edits, as before. ## Tests `ResourceEditPage.authoredObjectBuffer-29793.test.tsx` gains a top-level `describe.each` over view, app and dashboard, with zh-CN served drafts shaped as measured below: two edits in a row each PUT source text (the second after the save refresh re-read the draft), a pending draft is edited as stored, and a served draft without its `draft` layer installs nothing. Nine existing page and report suites (`draftPublishVerdict-26707`, `draftUnreadable-29148`, `draftVersionConflict-11773`, `navigationWrites-28680`, `pageRequiresStamp-11357`, `publishPackageBinding`, `readDecorationStrip`, `servedDraftTakenAsIs-10765`, `serverVerdict-26858`) used client doubles whose `/layers` answer had no `draft` key, so the editor now read their pending draft as unserved. Each double now serves the pending draft as stored beside its readable draft, as the server does for an admitted author; no assertion changed. `readDecorationStrip` keeps both directions: its stored draft and its served (decorated) draft describe the same pending draft, and the undeclared key is still refused by name. <details><summary>Ablation (head 0ac8ae4)</summary> `node scripts/ablation-replace.mjs` on `authoredObjectBuffer.ts`, anchor `return TRANSLATABLE_METADATA_TYPES.has(type);` replaced by `return TRANSLATABLE_METADATA_TYPES.has(type) && type === 'object';` (the pre-fix gate): anchor 1 to 0, blob 21daac1 to d754ef68e3a6. Result: `Tests 9 failed | 3 passed (12)`, every view/app/dashboard case red, the three object cases green. Restored: blob equals HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - The reading is keyed on the translatable set, so pages, reports, actions, datasets, picklists, positions, permission sets and books take it too. Live-measured beyond the card's three: page, report, action and book reproduce the same way (readings below). Position and permission could not be customised on the stack (a shipped one takes no override), so they are NOT MEASURED live; dataset and picklist were not measured either. They take the same reading because the server translates their served draft by the same predicate. - Not fixed here, same class, outside this file surface: Studio's editors that seed a buffer from the served draft for these types (for example the app draft in `StudioDesignSurface.tsx`, `StudioNavItemInspector.tsx`, `ObjectListViewInspector.tsx`, `useFormViewDraft.ts`, `StudioBundleItemEditor.tsx`), and `EmbeddedItemEditor.tsx`, which bases an object parent's save on the served draft or `effective`. Reported to the seat. - `doc` is not translated by the server's predicate (its read is a locale collapse, ADR-0046), so its editor is unchanged. - The file keeps its name, `authoredObjectBuffer.ts`, so that Studio's import is not touched by this PR. ## Verification <details><summary>Wanted 1: live measurement on a zh-CN stack (origin/main 9b24f3c)</summary> `pnpm dev -- --fresh` (app-showcase), signed in as the seeded admin, `Accept-Language: zh-CN`, the showcase's customization package ensured (`POST /api/v1/packages/com.example.showcase/ensure-customization`), every read and write at `?package=com.example.showcase.custom`. The sequence is the editor's: load (`/layers` plus `?state=draft`, 404), PUT 1 of `effective ?? code`, the save refresh, PUT 2 of the buffer the refresh installs today (the served draft). | type / name | `/layers` at load | PUT 1 body | `?state=draft` after PUT 1 | `/layers` `draft` after PUT 1 | stored draft after PUT 2 | |:---|:---|:---|:---|:---|:---| | view `showcase_task.urgent` | `draft: null`, `overlay: null`, `code` = `effective`, label `Urgent` | label `Urgent` | label `紧急` (1 string differs) | label `Urgent` | label `紧急` | | app `showcase_app` | same shape, label `Showcase` | label `Showcase` | label `功能展示`, navigation `能力地图`, `工作区`, `我的工作`, … (58 strings differ) | source text | label `功能展示`, navigation in Chinese | | dashboard `showcase_ops_dashboard` | same shape, label `Delivery Operations` | source text | label `交付运营`, widget titles `进行中的项目`, `任务状态分布`, … (16 strings differ) | source text | label `交付运营`, widget titles in Chinese | All three reproduce. Per type, `/layers` served `draft`, `overlay` and `code`; `effective` equalled `code` (no overlay row), and the served draft and the stored `draft` layer had the same keys, so translation is the only difference. PUT 2 answered `200` with no advisory for these types. /layers `effective` (zh-CN, at load and after PUT 1): view label `Urgent`; app label `Showcase`, `navigation[0]` `Capability Map`; dashboard label `Delivery Operations`, `widgets[0]` `Active Projects`; equal to `code` in all three; the only CJK string is the app's authored `Command Center (大屏)`, also in `code`. So `effective` is served untranslated and the published half of the buffer stays source text. Beyond the three, the same sequence (no edit): page `showcase_capability_map` label `Capability Map` served `能力地图`; report `showcase_hours_by_status` label and description translated; action `showcase_mark_done` label and `successMessage` translated; book `showcase_manual` 5 strings translated. Position `manager` and permission `showcase_manager`: PUT refused `NOT_OVERRIDABLE` (a shipped one takes no override), not measured. </details> <details><summary>Local gates and tests (head e594200)</summary> Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` against this diff (35 families, plus `pnpm check:changeset-fixed`, whose roster sits under `.changeset/`), each run with stdin closed; reconciled with `--ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. ```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:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: 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 pnpm check:changeset-fixed :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 ``` UI tests (vitest root `ui/`, under the verify lock): `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/` plus `studio-design/DataPillar.authoredBuffer-29793`, `StudioDesignSurface.itemTools-28239`, `StudioDesignSurface.servedDraftTakenAsIs-10765`: `Test Files 732 passed (732)`, `Tests 7726 passed | 1 skipped (7727)`, on head e594200. </details> <!-- signed: domain:ui#2 · os-tesla · session_01L9qibw7DekEe3wx5f3zphf --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f41ddbd commit a2f523a

13 files changed

Lines changed: 352 additions & 62 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): Setup's metadata editor edits a view, an app or a dashboard as its author wrote it, not as the console is served it
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Setup's metadata editor (`MetadataResourceEditPage`) took the editable copy of every type but `object` from the `?state=draft` read, which the server translates into the console's language. In a Chinese console the first save of a published view, app or dashboard was correct, but the refresh after it installed the translated draft, so the next save stored the Chinese text (a view's label, an app's navigation labels, a dashboard's widget titles) as the author's own, and English readers then saw Chinese. The editor now takes the copy from the layered read for every type whose served reads are translated (`TRANSLATABLE_METADATA_TYPES`, the set the server itself translates by): the pending draft as stored (`draft`) when one is pending, otherwise the published item as authored (`overlay ?? code` for an object, `effective ?? code` for the others, which the layered read serves untranslated). That covers pages, reports, actions, datasets, picklists, positions, permission sets and books the same way. When a draft is pending but the layered read does not carry it, the editor installs nothing, shows the existing "draft unreadable" refusal and sends nothing, as it already did for objects. Types the server does not translate keep their editor exactly as it was.

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.authoredObjectBuffer-29793.test.tsx‎

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
* The editor's canvas is a stub that prints the buffer and patches one field
1717
* label, so the page's own load, autosave and publish refreshes are what is
1818
* under test.
19+
*
20+
* objectstack#30786 — the second describe holds the same for a view, an app and
21+
* a dashboard, whose served draft is translated the same way: measured on a
22+
* zh-CN stack, the save refresh installed the served draft (`紧急`, `功能展示`,
23+
* the dashboard's widget titles) and the next save stored it as source text.
1924
*/
2025

2126
import '@testing-library/jest-dom/vitest';
@@ -102,7 +107,13 @@ vi.mock('./useMetadata', async (importOriginal) => {
102107
...mod,
103108
useMetadataClient: () => mockClient,
104109
useMetadataTypes: () => ({
105-
entries: [{ type: 'object', name: 'object', label: 'Object', allowEnvironmentOverlay: true, schema: { required: [] } }],
110+
entries: ['object', 'view', 'app', 'dashboard'].map((type) => ({
111+
type,
112+
name: type,
113+
label: type,
114+
allowEnvironmentOverlay: true,
115+
schema: { required: [] },
116+
})),
106117
}),
107118
};
108119
});
@@ -239,3 +250,199 @@ describe('MetadataResourceEditPage in a zh-CN console — an object buffer is th
239250
expect(srv.saves).toEqual([]);
240251
});
241252
});
253+
254+
// ── objectstack#30786 — a view, an app and a dashboard ──────────────────────
255+
256+
interface TranslatedType {
257+
type: 'view' | 'app' | 'dashboard';
258+
name: string;
259+
/** The item as its author wrote it (the stored row; source text only). */
260+
authored: Row;
261+
/** The served `?state=draft` body for a zh-CN console, as measured. */
262+
zh: (doc: Row) => Row;
263+
/** One edit, through the page's own patch door. */
264+
patch: (draft: Row) => Row;
265+
/** Every translatable string of `doc`, in source text. */
266+
sourceText: (doc: Row) => unknown;
267+
}
268+
269+
const TRANSLATED_TYPES: TranslatedType[] = [
270+
{
271+
type: 'view',
272+
name: 'showcase_task.urgent',
273+
authored: {
274+
name: 'showcase_task.urgent',
275+
object: 'showcase_task',
276+
viewKind: 'list',
277+
label: 'Urgent',
278+
config: { label: 'Urgent', type: 'grid', data: { provider: 'object', object: 'showcase_task' }, columns: [{ field: 'title' }] },
279+
order: 1,
280+
},
281+
zh: (doc) => ({ ...doc, label: '紧急' }),
282+
patch: (draft) => ({ order: Number(draft.order ?? 0) + 1 }),
283+
sourceText: (doc) => doc.label,
284+
},
285+
{
286+
type: 'app',
287+
name: 'showcase_app',
288+
authored: {
289+
name: 'showcase_app',
290+
label: 'Showcase',
291+
navigation: [
292+
{ id: 'nav_capability_map', type: 'page', label: 'Capability Map', pageName: 'showcase_capability_map' },
293+
{ id: 'nav_workspace', type: 'group', label: 'Workspace', children: [{ id: 'nav_my_work', type: 'page', label: 'My Work', pageName: 'showcase_my_work' }] },
294+
],
295+
},
296+
zh: (doc) => {
297+
const nav = doc.navigation as Row[];
298+
return {
299+
...doc,
300+
label: '功能展示',
301+
navigation: [
302+
{ ...nav[0], label: '能力地图' },
303+
{ ...nav[1], label: '工作区', children: [{ ...(nav[1].children as Row[])[0], label: '我的工作' }] },
304+
],
305+
};
306+
},
307+
patch: (draft) => ({ description: `${String(draft.description ?? '')}edited` }),
308+
sourceText: (doc) => {
309+
const nav = doc.navigation as Row[];
310+
return [doc.label, nav[0].label, nav[1].label, (nav[1].children as Row[])[0].label];
311+
},
312+
},
313+
{
314+
type: 'dashboard',
315+
name: 'showcase_ops_dashboard',
316+
authored: {
317+
name: 'showcase_ops_dashboard',
318+
label: 'Delivery Operations',
319+
widgets: [
320+
{ id: 'active_projects', type: 'metric', title: 'Active Projects', layout: { x: 0, y: 0, w: 3, h: 2 } },
321+
{ id: 'tasks_by_status', type: 'bar', title: 'Tasks by Status', layout: { x: 3, y: 0, w: 6, h: 4 } },
322+
],
323+
},
324+
zh: (doc) => {
325+
const widgets = doc.widgets as Row[];
326+
return { ...doc, label: '交付运营', widgets: [{ ...widgets[0], title: '进行中的项目' }, { ...widgets[1], title: '任务状态分布' }] };
327+
},
328+
patch: (draft) => ({ description: `${String(draft.description ?? '')}edited` }),
329+
sourceText: (doc) => {
330+
const widgets = doc.widgets as Row[];
331+
return [doc.label, widgets[0].title, widgets[1].title];
332+
},
333+
},
334+
];
335+
336+
const SOURCE_TEXT: Record<TranslatedType['type'], unknown> = {
337+
view: 'Urgent',
338+
app: ['Showcase', 'Capability Map', 'Workspace', 'My Work'],
339+
dashboard: ['Delivery Operations', 'Active Projects', 'Tasks by Status'],
340+
};
341+
342+
/** Canvas stand-in for one type: prints the buffer, and makes the type's one edit. */
343+
function stubCanvasFor(spec: TranslatedType) {
344+
return function TypeStubCanvas({ draft, onPatch }: { draft: Row; onPatch?: (patch: Row) => void }) {
345+
return (
346+
<>
347+
<pre data-testid="editor-draft">{JSON.stringify(draft)}</pre>
348+
<button type="button" onClick={() => onPatch?.(spec.patch(draft))}>
349+
edit once
350+
</button>
351+
</>
352+
);
353+
};
354+
}
355+
356+
function renderTypeZh(spec: TranslatedType) {
357+
const path = `/metadata/${spec.type}/${spec.name}`;
358+
window.history.replaceState(null, '', path);
359+
render(
360+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
361+
<MemoryRouter initialEntries={[path]}>
362+
<MetadataResourceEditPage type={spec.type} name={spec.name} />
363+
</MemoryRouter>
364+
</I18nProvider>,
365+
);
366+
}
367+
368+
describe.each(TRANSLATED_TYPES)(
369+
'MetadataResourceEditPage in a zh-CN console — a $type buffer is the authored document (objectstack#30786)',
370+
(spec) => {
371+
const realTypePreview = getMetadataPreview(spec.type);
372+
373+
beforeEach(() => {
374+
srv.overlay = null;
375+
srv.draft = null;
376+
srv.servesDraftLayer = true;
377+
srv.saves.length = 0;
378+
registerMetadataPreview(spec.type, stubCanvasFor(spec) as never);
379+
// A shipped item: the stored row is the package's (`code`), and the
380+
// layered read serves every layer untranslated.
381+
mockClient.layered.mockImplementation(async () => ({
382+
code: wire(spec.authored),
383+
overlay: srv.overlay,
384+
overlayScope: srv.overlay ? 'env' : null,
385+
effective: wire(srv.overlay ?? spec.authored),
386+
...(srv.servesDraftLayer ? { draft: srv.draft } : {}),
387+
editable: true,
388+
deletable: true,
389+
resettable: true,
390+
lock: 'none',
391+
}));
392+
mockClient.getDraft.mockImplementation(async () => {
393+
if (!srv.draft) {
394+
throw Object.assign(new Error(`No pending draft exists for ${spec.type}/${spec.name}.`), { code: 'NO_DRAFT', status: 404 });
395+
}
396+
return { type: spec.type, name: spec.name, item: { ...spec.zh(srv.draft), _diagnostics: { valid: true } } };
397+
});
398+
});
399+
400+
afterEach(() => {
401+
if (realTypePreview) registerMetadataPreview(spec.type, realTypePreview);
402+
});
403+
404+
it('no pending draft: two edits in a row each PUT source text, the second after the save refresh re-read the draft', async () => {
405+
renderTypeZh(spec);
406+
await waitFor(() => expect(editorDraft().name).toBe(spec.name), { timeout: 8000 });
407+
expect(spec.sourceText(editorDraft())).toStrictEqual(SOURCE_TEXT[spec.type]);
408+
409+
fireEvent.click(screen.getByRole('button', { name: 'edit once' }));
410+
await waitFor(() => expect(srv.saves.length).toBe(1), { timeout: 8000 });
411+
expect(srv.saves[0]!.opts).toMatchObject({ mode: 'draft' });
412+
expect(spec.sourceText(srv.saves[0]!.body)).toStrictEqual(SOURCE_TEXT[spec.type]);
413+
414+
// The save refresh: the served draft is now the zh-CN one, the layers' the stored one.
415+
await waitFor(() => expect(mockClient.getDraft.mock.calls.length).toBeGreaterThan(1), { timeout: 8000 });
416+
await act(async () => {
417+
await new Promise((resolve) => setTimeout(resolve, 50));
418+
});
419+
expect(spec.sourceText(editorDraft())).toStrictEqual(SOURCE_TEXT[spec.type]);
420+
421+
fireEvent.click(screen.getByRole('button', { name: 'edit once' }));
422+
await waitFor(() => expect(srv.saves.length).toBe(2), { timeout: 8000 });
423+
expect(spec.sourceText(srv.saves[1]!.body)).toStrictEqual(SOURCE_TEXT[spec.type]);
424+
expect(spec.sourceText(srv.draft!)).toStrictEqual(SOURCE_TEXT[spec.type]);
425+
});
426+
427+
it('a pending draft: the buffer is the stored draft, and one edit PUTs it in source text', async () => {
428+
srv.draft = wire({ ...spec.authored, ...spec.patch(spec.authored) });
429+
renderTypeZh(spec);
430+
await waitFor(() => expect(editorDraft().name).toBe(spec.name), { timeout: 8000 });
431+
expect(spec.sourceText(editorDraft())).toStrictEqual(SOURCE_TEXT[spec.type]);
432+
433+
fireEvent.click(screen.getByRole('button', { name: 'edit once' }));
434+
await waitFor(() => expect(srv.saves.length).toBeGreaterThan(0), { timeout: 8000 });
435+
expect(spec.sourceText(srv.saves.at(-1)!.body)).toStrictEqual(SOURCE_TEXT[spec.type]);
436+
});
437+
438+
it('a served draft whose stored copy the layers do not carry installs no buffer, and nothing is sent', async () => {
439+
srv.draft = wire(spec.authored);
440+
srv.servesDraftLayer = false;
441+
renderTypeZh(spec);
442+
443+
await screen.findByText(t('engine.studio.refusal.draftUnreadable', 'zh-CN'), undefined, { timeout: 8000 });
444+
expect(screen.queryByTestId('editor-draft')).toBeNull();
445+
expect(srv.saves).toEqual([]);
446+
});
447+
},
448+
);

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.draftPublishVerdict-26707.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ const mockClient = vi.hoisted(() => {
6767
overlay: eff,
6868
overlayScope: eff ? 'env' : null,
6969
effective: eff,
70+
// The pending draft as stored: the `draft` layer the server serves an
71+
// admitted author (objectstack#29814).
72+
draft: server.drafts.get(k(type, name)) ?? null,
7073
// The PUBLISHED body's verdict — clean.
7174
...(eff ? { _diagnostics: { valid: true } } : {}),
7275
editable: true,

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.draftUnreadable-29148.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ const door = {
5151
const mockClient = {
5252
list: vi.fn(async () => []),
5353
listDrafts: vi.fn(async () => []),
54-
layered: vi.fn(async () => ({ effective: PAGE, code: PAGE, editable: true })),
54+
// The `draft` layer is served beside a readable draft (objectstack#29814),
55+
// and withheld with it when its read is refused.
56+
layered: vi.fn(async () => ({ effective: PAGE, code: PAGE, editable: true, ...(door.gated ? {} : { draft: door.draft }) })),
5557
getDraft: vi.fn(async (_type: string, _name: string, _opts?: { packageId?: string }) => {
5658
if (door.gated) {
5759
throw Object.assign(new Error(GATE_REFUSAL), {

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.draftVersionConflict-11773.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ function writeDraft(body: Record<string, unknown>): string {
7676
const mockClient = {
7777
list: vi.fn(async () => []),
7878
listDrafts: vi.fn(async () => []),
79-
layered: vi.fn(async () => ({ effective: PAGE, code: PAGE, editable: true })),
79+
// …and the `draft` layer, the pending draft as stored (objectstack#29814).
80+
layered: vi.fn(async () => ({ effective: PAGE, code: PAGE, editable: true, draft: door.draft ? JSON.parse(JSON.stringify(door.draft.body)) : null })),
8081
// The draft read serves the body and its version; no draft is `null`.
8182
getDraft: vi.fn(async () =>
8283
door.draft

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.navigationWrites-28680.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@ const mockClient = {
4848
listDrafts: vi.fn(async () => []),
4949
layered: vi.fn(async () => {
5050
const effective = server.overlay ?? server.code;
51-
if (!effective) return { code: null, overlay: null, overlayScope: null, effective: null };
51+
// The pending draft as stored (objectstack#29814): always pending here, as `getDraft` below.
52+
if (!effective) return { code: null, overlay: null, overlayScope: null, effective: null, draft: PAGE };
5253
return {
54+
draft: PAGE,
5355
code: server.code,
5456
overlay: server.overlay,
5557
overlayScope: server.overlay ? 'env' : null,

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.pageRequiresStamp-11357.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ const mockClient = vi.hoisted(() => {
152152
references: vi.fn(async () => []),
153153
layered: vi.fn(async (type: string, name: string, _opts?: { packageId?: string }) => {
154154
const eff = server.active.get(k(type, name)) ?? null;
155-
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' };
155+
// `draft`: the pending draft as stored, the server's stamp included (objectstack#29814).
156+
const draft = server.drafts.get(k(type, name)) ?? null;
157+
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, draft, editable: true, deletable: true, resettable: false, lock: 'none' };
156158
}),
157159
getDraft: vi.fn(async (type: string, name: string, _opts?: { packageId?: string }) => {
158160
const row = server.drafts.get(k(type, name));

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.publishPackageBinding.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ const mockClient = {
7171
layered: vi.fn(async (_type: string, _name: string, _opts?: { packageId?: string }) => ({
7272
effective: PAGE,
7373
code: PAGE,
74+
// The pending draft as stored (objectstack#29814), as `getDraft` below serves it.
75+
draft: PAGE,
7476
editable: true,
7577
})),
7678
// A pending draft is what makes the Publish button exist at all.

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.readDecorationStrip.test.tsx‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,23 @@ const READ_DECORATIONS = {
8888
_draft: true,
8989
};
9090

91+
/** The pending draft as stored: what the layered read's `draft` layer serves (objectstack#29814). */
92+
const storedDraft = (extra: Record<string, unknown> = {}) => ({ ...PUBLISHED_PAGE, label: DRAFT_ONLY_LABEL, ...extra });
93+
9194
/** A served draft envelope: `{ type, name, item }` with the item decorated. */
9295
const draftEnvelope = (extra: Record<string, unknown> = {}) => ({
9396
type: 'page',
9497
name: 'home',
95-
item: { ...PUBLISHED_PAGE, label: DRAFT_ONLY_LABEL, ...READ_DECORATIONS, ...extra },
98+
item: { ...storedDraft(extra), ...READ_DECORATIONS },
9699
});
97100

101+
/**
102+
* The stored draft's extra keys, shared by both doubles below. A page's buffer
103+
* is the layers' `draft` (objectstack#30786: its served draft is translated),
104+
* so the two reads must describe the same pending draft.
105+
*/
106+
const pending = vi.hoisted(() => ({ extra: {} as Record<string, unknown> }));
107+
98108
/**
99109
* Records what the REAL gate was asked to judge and what it answered.
100110
*
@@ -109,8 +119,8 @@ const gate = vi.hoisted(() => ({
109119
const mockClient = vi.hoisted(() => ({
110120
list: vi.fn(async () => []),
111121
listDrafts: vi.fn(async () => []),
112-
layered: vi.fn(async () => ({ effective: PUBLISHED_PAGE, code: PUBLISHED_PAGE, editable: true })),
113-
getDraft: vi.fn(async () => draftEnvelope()),
122+
layered: vi.fn(async () => ({ effective: PUBLISHED_PAGE, code: PUBLISHED_PAGE, editable: true, draft: storedDraft(pending.extra) })),
123+
getDraft: vi.fn(async () => draftEnvelope(pending.extra)),
114124
get: vi.fn(async () => null),
115125
saveDraft: vi.fn(async () => ({})),
116126
}));
@@ -168,7 +178,8 @@ registerBuiltinInspectors();
168178

169179
beforeEach(() => {
170180
gate.calls.length = 0;
171-
mockClient.getDraft.mockImplementation(async () => draftEnvelope());
181+
pending.extra = {};
182+
mockClient.getDraft.mockImplementation(async () => draftEnvelope(pending.extra));
172183
});
173184

174185
afterEach(() => {
@@ -241,7 +252,7 @@ describe('MetadataResourceEditPage — read decorations never reach the edit gat
241252
});
242253

243254
it('still refuses a genuinely undeclared key riding the same decorated draft', async () => {
244-
mockClient.getDraft.mockImplementation(async () => draftEnvelope({ [UNDECLARED_KEY]: 1 }));
255+
pending.extra = { [UNDECLARED_KEY]: 1 };
245256
openEditor();
246257
const verdict = await awaitMergedVerdict();
247258

‎ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.servedDraftTakenAsIs-10765.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ const mockClient = vi.hoisted(() => {
119119
overlay: eff,
120120
overlayScope: eff ? 'env' : null,
121121
effective: eff,
122+
// The pending draft row as stored (objectstack#29814): the buffer of a
123+
// type whose served reads are translated (objectstack#30786).
124+
draft: server.drafts.get(k(type, name)) ?? null,
122125
editable: true,
123126
deletable: true,
124127
resettable: false,

0 commit comments

Comments
 (0)