Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/26649-setup-email-template-member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/i18n': patch
---

fix(data-objectstack): the metadata client's layered read, item and draft reads, publish and reset name the bundle member they address

Clause-②: no
Metadata-protocol: none

`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.

`getDraft()` with a `member` skips the draft read when the pending-drafts ledger lists that name only as another member's draft.

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.

`@object-ui/i18n`'s `studio` namespace gains the column's label, `engine.list.col.locale` (`Locale` / `语言`).
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,15 @@ export async function readDraftForSave<T = unknown>(
type: string,
name: string,
packageId: string | null | undefined,
// objectstack#26649 — the bundle member whose draft this is; omitted, the
// canonical member's. The save it pins is addressed by its body's member.
member?: string,
): Promise<{ answer: T | null; pin: DraftPin | null }> {
try {
const answer = await client.getDraft<T>(type, name, packageId ? { packageId } : {});
const answer = await client.getDraft<T>(type, name, {
...(packageId ? { packageId } : {}),
...(member ? { member } : {}),
});
return { answer, pin: draftPinOf(answer) };
} catch {
return { answer: null, pin: null };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26649 — the metadata edit page opened with `?member=` reads and
* writes THAT bundle member.
*
* Setup's list links each language of an `email_template` to
* `…/email_template/NAME?member=LOCALE`. The page forwards the member to its
* layered read and its draft read (ruling 6444406836), and to every by-name
* write it issues for the item — publish, reset and discard — because each of
* those doors addresses the CANONICAL member when it is named none: Reset on
* the `zh-CN` page would otherwise remove the `en-US` overlay.
*
* The REAL page over a REAL `MetadataClient`, whose transport is an in-memory
* server keyed by `(name, member)` as the framework keys a bundle (canonical
* member `en-US`), so a request that drops the member lands on the other
* language and the assertion on what is stored goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { MetadataClient } from '@object-ui/data-objectstack';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } }));

const CANONICAL = 'en-US';
const NAME = 'duly.task_overdue';

const tpl = (locale: string, subject: string) => ({
name: NAME,
label: `Overdue ${locale}`,
locale,
subject,
bodyHtml: `<p>${subject}</p>`,
});

/** One member's stored state, as the framework holds a bundle row. */
interface MemberRows {
code: Record<string, unknown> | null;
overlay: Record<string, unknown> | null;
draft: Record<string, unknown> | null;
}

const server = {
members: new Map<string, MemberRows>(),
requests: [] as Array<{ method: string; path: string; query: URLSearchParams }>,
};

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

async function serve(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
const url = new URL(String(input), 'http://console.test');
const q = url.searchParams;
const meta = url.pathname.match(/^\/api\/v1\/meta\/(.+)$/);
if (!meta) return json(200, []);
const seg = meta[1].split('/').map(decodeURIComponent);
server.requests.push({ method, path: seg.join('/'), query: q });
if (seg[0] === '_drafts') {
return json(200, {
drafts: [...server.members.entries()]
.filter(([, rows]) => rows.draft)
.map(([member]) => ({ type: 'email_template', name: NAME, member, packageId: null })),
});
}
if (seg.length === 1) return json(200, { items: [] });
const member = q.get('member') ?? CANONICAL;
const rows = server.members.get(member);
if (seg.length === 3 && seg[2] === 'layers') {
if (!rows || (!rows.code && !rows.overlay)) return json(404, { error: { code: 'NOT_FOUND', message: 'absent' } });
const effective = rows.overlay ?? rows.code;
return json(200, {
code: rows.code, overlay: rows.overlay, overlayScope: rows.overlay ? 'env' : null, effective,
editable: true, deletable: true, resettable: !!(rows.code && rows.overlay), lock: 'none',
});
}
if (seg.length === 3 && seg[2] === 'references') return json(200, []);
if (seg.length === 3 && seg[2] === 'publish' && method === 'POST') {
if (!rows?.draft) return json(404, { error: { code: 'NO_DRAFT', message: 'no draft' } });
rows.overlay = rows.draft;
rows.draft = null;
return json(200, { success: true, version: 'sha256:1', seq: 2, message: 'published' });
}
if (seg.length === 2 && method === 'DELETE') {
if (rows) {
if (q.get('state') === 'draft') rows.draft = null;
else rows.overlay = null;
}
return json(200, {});
}
if (seg.length === 2 && method === 'GET') {
const draftRead = q.get('state') === 'draft';
const row = draftRead ? rows?.draft : (rows?.overlay ?? rows?.code);
return row
? json(200, { type: 'email_template', name: NAME, item: draftRead ? { ...row, _draft: true } : row, ...(draftRead ? { version: 'sha256:d' } : {}) })
: json(404, { error: { code: draftRead ? 'NO_DRAFT' : 'NOT_FOUND', message: 'absent' } });
}
return json(200, {});
}

const client = vi.hoisted(() => ({ current: null as unknown }));

vi.mock('./useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('./useMetadata')>();
return {
...mod,
useMetadataClient: () => client.current,
useMetadataTypes: () => ({
loading: false,
entries: [
{ type: 'email_template', label: 'Email Template', domain: 'system', allowEnvironmentOverlay: true, allowRuntimeCreate: true },
],
}),
};
});

import { MetadataResourceEditPage } from './ResourceEditPage';
import { registerDefaultMetadataSchemas } from './default-schemas';

client.current = new MetadataClient({ baseUrl: '', fetch: vi.fn(serve) as unknown as typeof fetch });
registerDefaultMetadataSchemas();

beforeEach(() => {
server.members.clear();
server.requests.length = 0;
server.members.set(CANONICAL, { code: tpl(CANONICAL, 'Overdue'), overlay: null, draft: null });
server.members.set('zh-CN', {
code: tpl('zh-CN', '逾期'),
overlay: tpl('zh-CN', '逾期了'),
draft: tpl('zh-CN', '逾期了,请处理'),
});
window.localStorage.setItem('metadata-admin:autosave', '0');
Object.defineProperty(window, 'confirm', { configurable: true, writable: true, value: vi.fn(() => true) });
});
afterEach(() => {
cleanup();
window.localStorage.removeItem('metadata-admin:autosave');
});

async function settle() {
await act(async () => {
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
});
}

function renderEdit(search: string) {
render(
<MemoryRouter initialEntries={[`/metadata/email_template/${NAME}${search}`]}>
<Routes>
<Route path="/metadata/email_template/:name" element={<MetadataResourceEditPage type="email_template" name={NAME} />} />
</Routes>
</MemoryRouter>,
);
}

/** The requests to one door: `layers`, `publish`, the draft read, or a DELETE. */
const sent = (pick: (r: (typeof server.requests)[number]) => boolean) => server.requests.filter(pick);
const layerReads = () => sent((r) => r.path.endsWith('/layers'));
const draftReads = () => sent((r) => r.method === 'GET' && r.query.get('state') === 'draft');

async function confirmDialog() {
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByTestId('resource-edit-confirm-action'));
}

describe('MetadataResourceEditPage — ?member= opens that bundle member (objectstack#26649)', () => {
it('reads the member\'s layers and draft, and shows its own body', async () => {
renderEdit('?scope=environment&member=zh-CN');
expect(await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 })).toBeInTheDocument();
await settle();

expect(layerReads().length).toBeGreaterThan(0);
for (const r of layerReads()) expect(r.query.get('member')).toBe('zh-CN');
expect(draftReads().length).toBeGreaterThan(0);
for (const r of draftReads()) expect(r.query.get('member')).toBe('zh-CN');
expect(screen.queryByDisplayValue('Overdue')).not.toBeInTheDocument();
});

it('CONTROL: with no ?member= the page reads the canonical member and sends no selector', async () => {
renderEdit('?scope=environment');
expect(await screen.findByDisplayValue('Overdue', undefined, { timeout: 8000 })).toBeInTheDocument();
await settle();

for (const r of server.requests) expect(r.query.has('member')).toBe(false);
});

it('Publish promotes the opened member\'s draft, not the canonical member\'s', async () => {
renderEdit('?scope=environment&member=zh-CN');
await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 });
await settle();

fireEvent.click(screen.getByTitle('Publish'));
await waitFor(() => expect(server.members.get('zh-CN')!.draft).toBeNull());
await settle();

const publishes = sent((r) => r.path.endsWith('/publish'));
expect(publishes).toHaveLength(1);
expect(publishes[0]!.query.get('member')).toBe('zh-CN');
expect(server.members.get('zh-CN')!.overlay).toMatchObject({ subject: '逾期了,请处理' });
});

it('Discard draft discards the opened member\'s draft', async () => {
renderEdit('?scope=environment&member=zh-CN');
await screen.findByDisplayValue('逾期了,请处理', undefined, { timeout: 8000 });
await settle();

fireEvent.click(screen.getAllByTitle('Discard draft')[0]!);
await confirmDialog();
await waitFor(() => expect(server.members.get('zh-CN')!.draft).toBeNull());

const deletes = sent((r) => r.method === 'DELETE');
expect(deletes).toHaveLength(1);
expect(deletes[0]!.query.get('state')).toBe('draft');
expect(deletes[0]!.query.get('member')).toBe('zh-CN');
});

it('Reset removes the opened member\'s overlay and leaves the other language alone', async () => {
server.members.get('zh-CN')!.draft = null;
server.members.get(CANONICAL)!.overlay = tpl(CANONICAL, 'Overdue, customised');
renderEdit('?scope=environment&member=zh-CN');
await screen.findByDisplayValue('逾期了', undefined, { timeout: 8000 });
await settle();

fireEvent.click(screen.getByTestId('reset-or-delete-button'));
await confirmDialog();
await waitFor(() => expect(server.members.get('zh-CN')!.overlay).toBeNull());
await settle();

const deletes = sent((r) => r.method === 'DELETE');
expect(deletes).toHaveLength(1);
expect(deletes[0]!.query.get('member')).toBe('zh-CN');
expect(server.members.get(CANONICAL)!.overlay).toMatchObject({ subject: 'Overdue, customised' });
// The re-read after the reset is the same member's layers.
expect(layerReads().at(-1)!.query.get('member')).toBe('zh-CN');
expect(await screen.findByDisplayValue('逾期')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import {
// objectui#11773 — the draft save's optimistic-concurrency guard and its dialog.
import { readDraftForSave, useDraftSaveGuard } from './DraftConflictDialog.js';
import {
BUNDLE_MEMBER_PARAM,
getMetadataResource,
resolveResourceConfig,
listAnchorsFor,
Expand Down Expand Up @@ -498,6 +499,13 @@ function MetadataResourceEditPageImpl({
// package's item. NOT the active Studio app's package — Studio edits items
// across all installed packages.
const ownerPackageId = searchParams.get('package') ?? undefined;
// objectstack#26649 — the bundle member this page edits, carried on the edit
// URL as `?member=` by the list's row link (`bundleMemberField`): every
// by-name read and write below addresses it, so two locales of one template
// open, publish, reset and discard as two items. Absent (never empty) for
// the canonical member and for a type with no members.
const bundleMember = searchParams.get(BUNDLE_MEMBER_PARAM) || undefined;
const memberScope = bundleMember ? { member: bundleMember } : {};
const client = useMetadataClient();
const { entries, loading: typesLoading } = useMetadataTypes(client);
const entry: RichMetadataTypeEntry | undefined = entries.find((t) => t.type === type);
Expand Down Expand Up @@ -1167,7 +1175,10 @@ function MetadataResourceEditPageImpl({
setLoadFailed(false);
(async () => {
try {
const scope = ownerPackageId ? { packageId: ownerPackageId } : {};
const scope = {
...(ownerPackageId ? { packageId: ownerPackageId } : {}),
...(bundleMember ? { member: bundleMember } : {}),
};
// objectui#11773 — the draft is read at the address `doSave` writes
// (`readActivePackageBinding`, the folded `?package=`), so the version
// it serves is the one that save compares against.
Expand All @@ -1177,7 +1188,7 @@ function MetadataResourceEditPageImpl({
// Draft reads are best-effort — a 404/error must not block
// the page; readers without overlay-write permission still
// see the published item.
readDraftForSave<any>(client, type, name, draftPackage),
readDraftForSave<any>(client, type, name, draftPackage, bundleMember),
]);
if (cancelled) return;
const draftResp = draftRead.answer;
Expand Down Expand Up @@ -1250,7 +1261,7 @@ function MetadataResourceEditPageImpl({
return () => {
cancelled = true;
};
}, [client, type, name, ownerPackageId, createMode, reloadKey, locale, recordDraftRead]);
}, [client, type, name, ownerPackageId, bundleMember, createMode, reloadKey, locale, recordDraftRead]);

// Lazy-load references the first time the References sheet opens.
//
Expand Down Expand Up @@ -1787,7 +1798,7 @@ function MetadataResourceEditPageImpl({
// Refresh layered + draft state after save — scope to the same package
// as the initial load (ADR-0048) so a same-name collision re-reads this
// package's own row, not another's.
const refreshScope = ownerPackageId ? { packageId: ownerPackageId } : {};
const refreshScope = { ...(ownerPackageId ? { packageId: ownerPackageId } : {}), ...memberScope };
// objectui#11799 — this save went into a draft, which makes no layer. An
// item this page loaded with no layer still has none, and an item it
// just created has never been published (the page then navigates to the
Expand Down Expand Up @@ -1982,12 +1993,13 @@ function MetadataResourceEditPageImpl({
const activePackage = readActivePackageBinding();
await client.reset(type, name, {
...(activePackage ? { packageId: activePackage } : {}),
...memberScope,
});
// objectui#11773 — the buffer below is re-read from the layers, with no
// draft read behind it, so nothing pins its next save.
forgetDraftVersion();
if (resetSemantic) {
const lay = await client.layered<any>(type, name);
const lay = await client.layered<any>(type, name, memberScope);
setLayered(lay);
const fresh = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
setDraft(fresh);
Expand Down Expand Up @@ -2025,12 +2037,13 @@ function MetadataResourceEditPageImpl({
const activePackage = readActivePackageBinding();
await client.publish<any>(type, name, {
...(activePackage ? { packageId: activePackage } : {}),
...memberScope,
});
// objectui#11773 — the draft is re-read at the address the next save
// writes, so its answer pins that save (see the load effect).
const [lay, draftRead] = await Promise.all([
client.layered<any>(type, name),
readDraftForSave<any>(client, type, name, activePackage),
client.layered<any>(type, name, memberScope),
readDraftForSave<any>(client, type, name, activePackage, bundleMember),
]);
const draftResp = draftRead.answer;
setLayered(lay);
Expand Down Expand Up @@ -2087,13 +2100,14 @@ function MetadataResourceEditPageImpl({
await client.reset(type, name, {
state: 'draft',
...(activePackage ? { packageId: activePackage } : {}),
...memberScope,
});
// objectui#11773 — the discard dropped the draft the held version named,
// and no draft read follows it: nothing pins the next save.
forgetDraftVersion();
// objectui#11799 — a discard makes no layer: an item this page loaded
// with no layer still has none, and `/layers` would answer 404.
const lay = layered && holdsNoLayer(layered) ? layered : await client.layered<any>(type, name);
const lay = layered && holdsNoLayer(layered) ? layered : await client.layered<any>(type, name, memberScope);
setLayered(lay);
const fresh = (lay.effective ?? lay.code ?? {}) as Record<string, unknown>;
setDraft(fresh);
Expand Down
Loading
Loading