Skip to content

Commit 38490db

Browse files
fix(ui): Setup opens each email template locale as its own member (#27036)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ89oiN73SssvvwL4crhXoM3j)_ Fixes #26649 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 的元数据列表给邮件模板加了「语言」列,每一行链接带上自己的 `?member=语言`;编辑页把这个 member 传给读取(分层视图、草稿)和写入(发布、重置、放弃草稿)。 - 为什么改:同一模板的两种语言各占一行,但点哪一行都打开同一种语言,第二种语言在 Setup 里打不开也改不了(裁决 6444406836 选 A,服务端 `/layers?member=` 已由 #26861 落地)。 - 风险与回滚:只影响声明了 member 字段的类型(目前只有 `email_template`);其它类型的链接和请求逐字节不变。回滚 = revert 本 PR。 - 先看:`ResourceEditPage.tsx`(读写都带 member,尤其重置/发布那几处)、`ResourceListPage.tsx`(行链接与语言列)、`metadata-client.ts`(五个方法的 `member` 参数)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/ResourceEditPage.bundleMember-26649.test.tsx`,预期 5 个用例全绿;或在 Setup → Email Template 里点 zh-CN 那一行,地址栏带 `member=zh-CN`,打开的是中文内容。 - 需要你做的:无,席位落地。 ## What changes - **Registry** (`registry.ts`, `default-schemas.ts`): a resource config may declare `bundleMemberField`, the body field that tells two members of one name apart; `email_template` declares `locale`. `BUNDLE_MEMBER_PARAM` (`member`) and `bundleMemberOf` live beside it. The server serves no bundle member key: its table (`ITEM_KEY_DISCRIMINATORS`) lives in `@objectstack/metadata-core`, which no ui package depends on, and a `GET /meta/email_template` row carries only the body's own `locale`. So the field is declared UI-side, on the registry entry. - **List** (`ResourceListPage.tsx`): for such a type, a Locale column sits second (label from `engine.list.col.locale`, en + zh rows in `i18n.ts`), and each row links `./NAME` plus the existing suffix (`?scope=environment` / `?package=`) plus `member=LOCALE`. A row with no locale (the canonical member) links by name alone, as before. Every other type's columns and links are unchanged. - **Editor** (`ResourceEditPage.tsx`, `DraftConflictDialog.tsx`): `?member=` is read once and passed to the load's layered and draft reads (ruling 6444406836), and to the post-save, post-publish, post-reset and post-discard re-reads. It is also passed to the page's by-name writes, `publish`, `reset` and the draft discard: each of those doors addresses the canonical member when named none, so without it Reset on the `zh-CN` page would remove the `en-US` overlay and Publish would promote the `en-US` draft. The save sends no member (it is addressed by its body's `locale`). `readDraftForSave` gains an optional `member`. - **Client** (`metadata-client.ts`, `@object-ui/data-objectstack`, patch changeset): `layered`, `get`, `getDraft`, `publish` and `reset` take an optional `member`, sent as `?member=` beside `?package=`, omitted (never empty) when absent. `getDraft` with a member skips the draft read when the ledger lists that name only as another member's draft. ## Tests - `metadata-client.bundleMember-26649.test.ts` (8): each method sends `?member=`, beside `?package=`; no member means no parameter on every door; the ledger skip and its control. - `ResourceListPage.bundleMember-26649.test.tsx` (4): two members of one template are two rows whose links differ by `member` and keep `scope=environment`; the canonical row links by name; the Locale column with each row's value; CONTROL: `page` (no members, body carrying `locale`) gets no column and no `member`. - `ResourceEditPage.bundleMember-26649.test.tsx` (5): the real page over a real `MetadataClient` and an in-memory server keyed by `(name, member)`. Opening `?member=zh-CN` reads that member's layers and draft and shows its body; CONTROL with no `member` sends no selector; Publish, Discard draft and Reset each send `member=zh-CN` and change only the `zh-CN` rows. Ablation, once: the load's layered read without `member` (`ablation-replace.mjs`, anchor 1 to 0, blob `64744a88` to `55c8f52b`) turns 2 of the 5 editor tests red ("reads the member's layers and draft…", "Reset removes the opened member's overlay…": `expected null to be 'zh-CN'`); restored, blob equals HEAD, `git diff HEAD` empty. A first attempt whose replacement text already occurred in the file was refused by the tool before any test ran. ## Acceptance notes - The server serves no bundle member key for a type (list rows, `/meta/types`), so the ui declares `email_template`'s field itself. A served key would let this registry row go. - Changing the `locale` field of an existing template in the editor and saving writes another member (the save is body-addressed); the page keeps addressing the member in its URL. The create form asks for no locale, so a create lands on whatever the body defaults to. - The history page and the references sheet send no member: history is out of this card's ruling, and references have no member concept. ## Verification Gates and suites are recorded below at the head this body names. All at head `6b9ab868` (origin/main `7eb613c3` merged in; no incoming file overlaps this diff): - Dependency closure `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build` under the verify lock: exit 0. - `pnpm --dir ui exec vitest run packages/data-objectstack/ packages/app-shell/src/views/metadata-admin/` (pre-merge head `3cbf7325`): 726 files / 8020 tests passed, 1 skipped. - After the merge: `vitest run packages/data-objectstack/` plus the metadata-admin `ResourceListPage*`, `ResourceEditPage*` and `DraftConflictDialog*` files: 153 files / 1491 tests passed. - `pnpm --filter @object-ui/data-objectstack --filter @object-ui/app-shell run type-check`: exit 0. `pnpm --dir ui run type-check:scripts`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 derived families, each run, all exit 0 (including `node scripts/check-sdui-manifest.mjs` and its `--self-test`, and `pnpm --dir ui exec vitest run scripts/`); `--ran` reconciliation: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; the repo-wide lint is CI's run, not measured locally.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer brief is Chinese by the dispatch order). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXSXt7nn6pE5WVHGgq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b15b2fc commit 38490db

12 files changed

Lines changed: 697 additions & 21 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/data-objectstack': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(data-objectstack): the metadata client's layered read, item and draft reads, publish and reset name the bundle member they address
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
`MetadataClient.layered()`, `get()`, `getDraft()`, `publish()` and `reset()` take an optional `member` and send it as `?member=`, the selector the by-name doors read for a metadata type whose identity is a pair (today only `email_template`, `(name, locale)`). Omitted, every call sends exactly the URL it sent before and the door addresses the canonical member. The parameter is never sent empty.
12+
13+
`getDraft()` with a `member` skips the draft read when the pending-drafts ledger lists that name only as another member's draft.
14+
15+
Setup's metadata list now shows an email template's locale in its own column and links each row to its own member (`?member=LOCALE`), and the editor passes that member to its layered and draft reads and to its publish, reset and discard, so each language of a template opens and is edited on its own.
16+
17+
`@object-ui/i18n`'s `studio` namespace gains the column's label, `engine.list.col.locale` (`Locale` / `语言`).

‎ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,15 @@ export async function readDraftForSave<T = unknown>(
164164
type: string,
165165
name: string,
166166
packageId: string | null | undefined,
167+
// objectstack#26649 — the bundle member whose draft this is; omitted, the
168+
// canonical member's. The save it pins is addressed by its body's member.
169+
member?: string,
167170
): Promise<{ answer: T | null; pin: DraftPin | null }> {
168171
try {
169-
const answer = await client.getDraft<T>(type, name, packageId ? { packageId } : {});
172+
const answer = await client.getDraft<T>(type, name, {
173+
...(packageId ? { packageId } : {}),
174+
...(member ? { member } : {}),
175+
});
170176
return { answer, pin: draftPinOf(answer) };
171177
} catch {
172178
return { answer: null, pin: null };
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26649 — the metadata edit page opened with `?member=` reads and
5+
* writes THAT bundle member.
6+
*
7+
* Setup's list links each language of an `email_template` to
8+
* `…/email_template/NAME?member=LOCALE`. The page forwards the member to its
9+
* layered read and its draft read (ruling 6444406836), and to every by-name
10+
* write it issues for the item — publish, reset and discard — because each of
11+
* those doors addresses the CANONICAL member when it is named none: Reset on
12+
* the `zh-CN` page would otherwise remove the `en-US` overlay.
13+
*
14+
* The REAL page over a REAL `MetadataClient`, whose transport is an in-memory
15+
* server keyed by `(name, member)` as the framework keys a bundle (canonical
16+
* member `en-US`), so a request that drops the member lands on the other
17+
* language and the assertion on what is stored goes red.
18+
*/
19+
20+
import '@testing-library/jest-dom/vitest';
21+
import * as React from 'react';
22+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
23+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
24+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
25+
import { MetadataClient } from '@object-ui/data-objectstack';
26+
27+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } }));
28+
29+
const CANONICAL = 'en-US';
30+
const NAME = 'duly.task_overdue';
31+
32+
const tpl = (locale: string, subject: string) => ({
33+
name: NAME,
34+
label: `Overdue ${locale}`,
35+
locale,
36+
subject,
37+
bodyHtml: `<p>${subject}</p>`,
38+
});
39+
40+
/** One member's stored state, as the framework holds a bundle row. */
41+
interface MemberRows {
42+
code: Record<string, unknown> | null;
43+
overlay: Record<string, unknown> | null;
44+
draft: Record<string, unknown> | null;
45+
}
46+
47+
const server = {
48+
members: new Map<string, MemberRows>(),
49+
requests: [] as Array<{ method: string; path: string; query: URLSearchParams }>,
50+
};
51+
52+
const json = (status: number, body: unknown) =>
53+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
54+
55+
async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
56+
const method = (init?.method ?? 'GET').toUpperCase();
57+
const url = new URL(String(input), 'http://console.test');
58+
const q = url.searchParams;
59+
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
60+
if (!meta) return json(200, []);
61+
const seg = meta[1].split('/').map(decodeURIComponent);
62+
server.requests.push({ method, path: seg.join('/'), query: q });
63+
if (seg[0] === '_drafts') {
64+
return json(200, {
65+
drafts: [...server.members.entries()]
66+
.filter(([, rows]) => rows.draft)
67+
.map(([member]) => ({ type: 'email_template', name: NAME, member, packageId: null })),
68+
});
69+
}
70+
if (seg.length === 1) return json(200, { items: [] });
71+
const member = q.get('member') ?? CANONICAL;
72+
const rows = server.members.get(member);
73+
if (seg.length === 3 && seg[2] === 'layers') {
74+
if (!rows || (!rows.code && !rows.overlay)) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
75+
const effective = rows.overlay ?? rows.code;
76+
return json(200, {
77+
code: rows.code, overlay: rows.overlay, overlayScope: rows.overlay ? 'env' : null, effective,
78+
editable: true, deletable: true, resettable: !!(rows.code && rows.overlay), lock: 'none',
79+
});
80+
}
81+
if (seg.length === 3 && seg[2] === 'references') return json(200, []);
82+
if (seg.length === 3 && seg[2] === 'publish' && method === 'POST') {
83+
if (!rows?.draft) return json(404, { error: { code: 'NO_DRAFT', message: 'no draft' } });
84+
rows.overlay = rows.draft;
85+
rows.draft = null;
86+
return json(200, { success: true, version: 'sha256:1', seq: 2, message: 'published' });
87+
}
88+
if (seg.length === 2 && method === 'DELETE') {
89+
if (rows) {
90+
if (q.get('state') === 'draft') rows.draft = null;
91+
else rows.overlay = null;
92+
}
93+
return json(200, {});
94+
}
95+
if (seg.length === 2 && method === 'GET') {
96+
const draftRead = q.get('state') === 'draft';
97+
const row = draftRead ? rows?.draft : (rows?.overlay ?? rows?.code);
98+
return row
99+
? json(200, { type: 'email_template', name: NAME, item: draftRead ? { ...row, _draft: true } : row, ...(draftRead ? { version: 'sha256:d' } : {}) })
100+
: json(404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } });
101+
}
102+
return json(200, {});
103+
}
104+
105+
const client = vi.hoisted(() => ({ current: null as unknown }));
106+
107+
vi.mock('./useMetadata', async (importOriginal) => {
108+
const mod = await importOriginal<typeof import('./useMetadata')>();
109+
return {
110+
...mod,
111+
useMetadataClient: () => client.current,
112+
useMetadataTypes: () => ({
113+
loading: false,
114+
entries: [
115+
{ type: 'email_template', label: 'Email Template', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true },
116+
],
117+
}),
118+
};
119+
});
120+
121+
import { MetadataResourceEditPage } from './ResourceEditPage';
122+
import { registerDefaultMetadataSchemas } from './default-schemas';
123+
124+
client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
125+
registerDefaultMetadataSchemas();
126+
127+
beforeEach(() => {
128+
server.members.clear();
129+
server.requests.length = 0;
130+
server.members.set(CANONICAL, { code: tpl(CANONICAL, 'Overdue'), overlay: null, draft: null });
131+
server.members.set('zh-CN', {
132+
code: tpl('zh-CN', '逾期'),
133+
overlay: tpl('zh-CN', '逾期了'),
134+
draft: tpl('zh-CN', '逾期了,请处理'),
135+
});
136+
window.localStorage.setItem('metadata-admin:autosave', '0');
137+
Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: vi.fn(() => true) });
138+
});
139+
afterEach(() => {
140+
cleanup();
141+
window.localStorage.removeItem('metadata-admin:autosave');
142+
});
143+
144+
async function settle() {
145+
await act(async () => {
146+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
147+
});
148+
}
149+
150+
function renderEdit(search: string) {
151+
render(
152+
<MemoryRouter initialEntries={[`/metadata/email_template/${NAME}${search}`]}>
153+
<Routes>
154+
<Route path="/metadata/email_template/:name" element={<MetadataResourceEditPage type="email_template" name={NAME} />} />
155+
</Routes>
156+
</MemoryRouter>,
157+
);
158+
}
159+
160+
/** The requests to one door: `layers`, `publish`, the draft read, or a DELETE. */
161+
const sent = (pick: (r: (typeof server.requests)[number]) => boolean) => server.requests.filter(pick);
162+
const layerReads = () => sent((r) => r.path.endsWith('/layers'));
163+
const draftReads = () => sent((r) => r.method === 'GET' && r.query.get('state') === 'draft');
164+
165+
async function confirmDialog() {
166+
const dialog = await screen.findByRole('alertdialog');
167+
fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action'));
168+
}
169+
170+
describe('MetadataResourceEditPage — ?member= opens that bundle member (objectstack#26649)', () => {
171+
it('reads the member\'s layers and draft, and shows its own body', async () => {
172+
renderEdit('?scope=environment&member=zh-CN');
173+
expect(await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 })).toBeInTheDocument();
174+
await settle();
175+
176+
expect(layerReads().length).toBeGreaterThan(0);
177+
for (const r of layerReads()) expect(r.query.get('member')).toBe('zh-CN');
178+
expect(draftReads().length).toBeGreaterThan(0);
179+
for (const r of draftReads()) expect(r.query.get('member')).toBe('zh-CN');
180+
expect(screen.queryByDisplayValue('Overdue')).not.toBeInTheDocument();
181+
});
182+
183+
it('CONTROL: with no ?member= the page reads the canonical member and sends no selector', async () => {
184+
renderEdit('?scope=environment');
185+
expect(await screen.findByDisplayValue('Overdue', undefined, { timeout: 8000 })).toBeInTheDocument();
186+
await settle();
187+
188+
for (const r of server.requests) expect(r.query.has('member')).toBe(false);
189+
});
190+
191+
it('Publish promotes the opened member\'s draft, not the canonical member\'s', async () => {
192+
renderEdit('?scope=environment&member=zh-CN');
193+
await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 });
194+
await settle();
195+
196+
fireEvent.click(screen.getByTitle('Publish'));
197+
await waitFor(() => expect(server.members.get('zh-CN')!.draft).toBeNull());
198+
await settle();
199+
200+
const publishes = sent((r) => r.path.endsWith('/publish'));
201+
expect(publishes).toHaveLength(1);
202+
expect(publishes[0]!.query.get('member')).toBe('zh-CN');
203+
expect(server.members.get('zh-CN')!.overlay).toMatchObject({ subject: '逾期了,请处理' });
204+
});
205+
206+
it('Discard draft discards the opened member\'s draft', async () => {
207+
renderEdit('?scope=environment&member=zh-CN');
208+
await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 });
209+
await settle();
210+
211+
fireEvent.click(screen.getAllByTitle('Discard draft')[0]!);
212+
await confirmDialog();
213+
await waitFor(() => expect(server.members.get('zh-CN')!.draft).toBeNull());
214+
215+
const deletes = sent((r) => r.method === 'DELETE');
216+
expect(deletes).toHaveLength(1);
217+
expect(deletes[0]!.query.get('state')).toBe('draft');
218+
expect(deletes[0]!.query.get('member')).toBe('zh-CN');
219+
});
220+
221+
it('Reset removes the opened member\'s overlay and leaves the other language alone', async () => {
222+
server.members.get('zh-CN')!.draft = null;
223+
server.members.get(CANONICAL)!.overlay = tpl(CANONICAL, 'Overdue, customised');
224+
renderEdit('?scope=environment&member=zh-CN');
225+
await screen.findByDisplayValue('逾期了', undefined, { timeout: 8000 });
226+
await settle();
227+
228+
fireEvent.click(screen.getByTestId('reset-or-delete-button'));
229+
await confirmDialog();
230+
await waitFor(() => expect(server.members.get('zh-CN')!.overlay).toBeNull());
231+
await settle();
232+
233+
const deletes = sent((r) => r.method === 'DELETE');
234+
expect(deletes).toHaveLength(1);
235+
expect(deletes[0]!.query.get('member')).toBe('zh-CN');
236+
expect(server.members.get(CANONICAL)!.overlay).toMatchObject({ subject: 'Overdue, customised' });
237+
// The re-read after the reset is the same member's layers.
238+
expect(layerReads().at(-1)!.query.get('member')).toBe('zh-CN');
239+
expect(await screen.findByDisplayValue('逾期')).toBeInTheDocument();
240+
});
241+
});

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

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ import {
125125
// objectui#11773 — the draft save's optimistic-concurrency guard and its dialog.
126126
import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js';
127127
import {
128+
BUNDLE_MEMBER_PARAM,
128129
getMetadataResource,
129130
resolveResourceConfig,
130131
listAnchorsFor,
@@ -498,6 +499,13 @@ function MetadataResourceEditPageImpl({
498499
// package's item. NOT the active Studio app's package — Studio edits items
499500
// across all installed packages.
500501
const ownerPackageId = searchParams.get('package') ?? undefined;
502+
// objectstack#26649 — the bundle member this page edits, carried on the edit
503+
// URL as `?member=` by the list's row link (`bundleMemberField`): every
504+
// by-name read and write below addresses it, so two locales of one template
505+
// open, publish, reset and discard as two items. Absent (never empty) for
506+
// the canonical member and for a type with no members.
507+
const bundleMember = searchParams.get(BUNDLE_MEMBER_PARAM) || undefined;
508+
const memberScope = bundleMember ? { member: bundleMember } : {};
501509
const client = useMetadataClient();
502510
const { entries, loading: typesLoading } = useMetadataTypes(client);
503511
const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type);
@@ -1167,7 +1175,10 @@ function MetadataResourceEditPageImpl({
11671175
setLoadFailed(false);
11681176
(async () => {
11691177
try {
1170-
const scope = ownerPackageId ? { packageId: ownerPackageId } : {};
1178+
const scope = {
1179+
...(ownerPackageId ? { packageId: ownerPackageId } : {}),
1180+
...(bundleMember ? { member: bundleMember } : {}),
1181+
};
11711182
// objectui#11773 — the draft is read at the address `doSave` writes
11721183
// (`readActivePackageBinding`, the folded `?package=`), so the version
11731184
// it serves is the one that save compares against.
@@ -1177,7 +1188,7 @@ function MetadataResourceEditPageImpl({
11771188
// Draft reads are best-effort — a 404/error must not block
11781189
// the page; readers without overlay-write permission still
11791190
// see the published item.
1180-
readDraftForSave<any>(client, type, name, draftPackage),
1191+
readDraftForSave<any>(client, type, name, draftPackage, bundleMember),
11811192
]);
11821193
if (cancelled) return;
11831194
const draftResp = draftRead.answer;
@@ -1250,7 +1261,7 @@ function MetadataResourceEditPageImpl({
12501261
return () => {
12511262
cancelled = true;
12521263
};
1253-
}, [client, type, name, ownerPackageId, createMode, reloadKey, locale, recordDraftRead]);
1264+
}, [client, type, name, ownerPackageId, bundleMember, createMode, reloadKey, locale, recordDraftRead]);
12541265

12551266
// Lazy-load references the first time the References sheet opens.
12561267
//
@@ -1787,7 +1798,7 @@ function MetadataResourceEditPageImpl({
17871798
// Refresh layered + draft state after save — scope to the same package
17881799
// as the initial load (ADR-0048) so a same-name collision re-reads this
17891800
// package's own row, not another's.
1790-
const refreshScope = ownerPackageId ? { packageId: ownerPackageId } : {};
1801+
const refreshScope = { ...(ownerPackageId ? { packageId: ownerPackageId } : {}), ...memberScope };
17911802
// objectui#11799 — this save went into a draft, which makes no layer. An
17921803
// item this page loaded with no layer still has none, and an item it
17931804
// just created has never been published (the page then navigates to the
@@ -1982,12 +1993,13 @@ function MetadataResourceEditPageImpl({
19821993
const activePackage = readActivePackageBinding();
19831994
await client.reset(type, name, {
19841995
...(activePackage ? { packageId: activePackage } : {}),
1996+
...memberScope,
19851997
});
19861998
// objectui#11773 — the buffer below is re-read from the layers, with no
19871999
// draft read behind it, so nothing pins its next save.
19882000
forgetDraftVersion();
19892001
if (resetSemantic) {
1990-
const lay = await client.layered<any>(type, name);
2002+
const lay = await client.layered<any>(type, name, memberScope);
19912003
setLayered(lay);
19922004
const fresh = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
19932005
setDraft(fresh);
@@ -2025,12 +2037,13 @@ function MetadataResourceEditPageImpl({
20252037
const activePackage = readActivePackageBinding();
20262038
await client.publish<any>(type, name, {
20272039
...(activePackage ? { packageId: activePackage } : {}),
2040+
...memberScope,
20282041
});
20292042
// objectui#11773 — the draft is re-read at the address the next save
20302043
// writes, so its answer pins that save (see the load effect).
20312044
const [lay, draftRead] = await Promise.all([
2032-
client.layered<any>(type, name),
2033-
readDraftForSave<any>(client, type, name, activePackage),
2045+
client.layered<any>(type, name, memberScope),
2046+
readDraftForSave<any>(client, type, name, activePackage, bundleMember),
20342047
]);
20352048
const draftResp = draftRead.answer;
20362049
setLayered(lay);
@@ -2087,13 +2100,14 @@ function MetadataResourceEditPageImpl({
20872100
await client.reset(type, name, {
20882101
state: 'draft',
20892102
...(activePackage ? { packageId: activePackage } : {}),
2103+
...memberScope,
20902104
});
20912105
// objectui#11773 — the discard dropped the draft the held version named,
20922106
// and no draft read follows it: nothing pins the next save.
20932107
forgetDraftVersion();
20942108
// objectui#11799 — a discard makes no layer: an item this page loaded
20952109
// with no layer still has none, and `/layers` would answer 404.
2096-
const lay = layered && holdsNoLayer(layered) ? layered : await client.layered<any>(type, name);
2110+
const lay = layered && holdsNoLayer(layered) ? layered : await client.layered<any>(type, name, memberScope);
20972111
setLayered(lay);
20982112
const fresh = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
20992113
setDraft(fresh);

0 commit comments

Comments
 (0)