diff --git a/.changeset/26649-setup-email-template-member.md b/.changeset/26649-setup-email-template-member.md new file mode 100644 index 00000000000..863b2101c5c --- /dev/null +++ b/.changeset/26649-setup-email-template-member.md @@ -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` / `语言`). diff --git a/ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx b/ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx index 600f21ea552..207af84a66d 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/DraftConflictDialog.tsx @@ -164,9 +164,15 @@ export async function readDraftForSave( 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(type, name, packageId ? { packageId } : {}); + const answer = await client.getDraft(type, name, { + ...(packageId ? { packageId } : {}), + ...(member ? { member } : {}), + }); return { answer, pin: draftPinOf(answer) }; } catch { return { answer: null, pin: null }; diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.bundleMember-26649.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.bundleMember-26649.test.tsx new file mode 100644 index 00000000000..1e8e3c01270 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.bundleMember-26649.test.tsx @@ -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: `

${subject}

`, +}); + +/** One member's stored state, as the framework holds a bundle row. */ +interface MemberRows { + code: Record | null; + overlay: Record | null; + draft: Record | null; +} + +const server = { + members: new Map(), + 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 { + 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(); + 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( + + + } /> + + , + ); +} + +/** 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(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 5a4eff23c97..ca68d43eef3 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -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, @@ -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); @@ -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. @@ -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(client, type, name, draftPackage), + readDraftForSave(client, type, name, draftPackage, bundleMember), ]); if (cancelled) return; const draftResp = draftRead.answer; @@ -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. // @@ -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 @@ -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(type, name); + const lay = await client.layered(type, name, memberScope); setLayered(lay); const fresh = (lay.effective ?? lay.code ?? {}) as Record; setDraft(fresh); @@ -2025,12 +2037,13 @@ function MetadataResourceEditPageImpl({ const activePackage = readActivePackageBinding(); await client.publish(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(type, name), - readDraftForSave(client, type, name, activePackage), + client.layered(type, name, memberScope), + readDraftForSave(client, type, name, activePackage, bundleMember), ]); const draftResp = draftRead.answer; setLayered(lay); @@ -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(type, name); + const lay = layered && holdsNoLayer(layered) ? layered : await client.layered(type, name, memberScope); setLayered(lay); const fresh = (lay.effective ?? lay.code ?? {}) as Record; setDraft(fresh); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.bundleMember-26649.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.bundleMember-26649.test.tsx new file mode 100644 index 00000000000..c3e94420e58 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.bundleMember-26649.test.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26649 — Setup's metadata list tells two members of one name + * apart, and each row opens its OWN member. + * + * Measured on the card: a template carried in two locales listed as two rows + * that both linked to `…/email_template/NAME?scope=environment`, so both opened + * the same member and the other language could not be opened from Setup. The + * list read serves each member as its own row with the body's `locale`; the + * type declares that field as its bundle member (`bundleMemberField`, + * `default-schemas.ts`), because the server serves no member key. + * + * What is pinned, each against a control: + * + * - two members of one name are two rows whose links differ by `?member=`, + * and the scope the link already carried is kept; + * - the member shows in its own column, labelled through the default-column + * vocabulary; + * - a row that declares no member (the canonical one) links by its name alone; + * - CONTROL: a type with no members gets no column and no `?member=`. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, cleanup, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +const TEMPLATES: Record[] = [ + { name: 'duly.task_overdue', label: 'Task overdue escalation — owner', locale: 'en', subject: 'Overdue' }, + { name: 'duly.task_overdue', label: '任务逾期升级提醒 — 负责人', locale: 'zh-CN', subject: '逾期' }, + { name: 'auth.welcome', label: 'Welcome', subject: 'Hi' }, +]; +const PAGES: Record[] = [{ name: 'home_page', label: 'Home', locale: 'en' }]; + +/** Stable singleton — the page's load effect keys on the client identity. */ +const CLIENT = { + list: async (type: string) => (type === 'email_template' ? TEMPLATES : type === 'page' ? PAGES : []), +}; + +const ENTRIES = [ + { type: 'email_template', label: 'Email Template', allowEnvironmentOverlay: true, allowRuntimeCreate: true }, + { type: 'page', label: 'Page', allowEnvironmentOverlay: true, allowRuntimeCreate: true }, +]; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }), +})); + +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ + useCanAuthorMetadata: () => true, +})); + +// Imported AFTER the mocks so the page picks them up. +import { MetadataResourceListPage } from './ResourceListPage'; +import { registerDefaultMetadataSchemas } from './default-schemas'; +import { t } from './i18n'; + +registerDefaultMetadataSchemas(); + +afterEach(() => { + cleanup(); +}); + +function mount(type: string, search: string) { + return render( + + + , + ); +} + +/** Every row link's href, in row order, for items named `name`. */ +async function hrefsOf(name: string): Promise { + const rows = await screen.findAllByTestId(`metadata-row-${name}`); + return rows.map((row) => within(row).getByRole('link').getAttribute('href') ?? ''); +} + +function queryOf(href: string): URLSearchParams { + const q = href.indexOf('?'); + return new URLSearchParams(q === -1 ? '' : href.slice(q + 1)); +} + +describe('Setup list: each bundle member opens itself (objectstack#26649)', () => { + it('two members of one template are two rows whose links differ by ?member=, keeping the scope', async () => { + mount('email_template', '?scope=environment'); + const hrefs = await hrefsOf('duly.task_overdue'); + + expect(hrefs).toHaveLength(2); + expect(hrefs.map((h) => queryOf(h).get('member'))).toEqual(['en', 'zh-CN']); + for (const href of hrefs) { + expect(href).toMatch(/\/duly\.task_overdue\?/); + expect(queryOf(href).get('scope')).toBe('environment'); + } + expect(new Set(hrefs).size).toBe(2); + }); + + it('a member that declares no locale (the canonical one) links by its name alone', async () => { + mount('email_template', '?scope=environment'); + const [href] = await hrefsOf('auth.welcome'); + + expect(queryOf(href!).has('member')).toBe(false); + expect(queryOf(href!).get('scope')).toBe('environment'); + }); + + it('the member shows in its own column, second, labelled Locale', async () => { + mount('email_template', '?scope=environment'); + await hrefsOf('duly.task_overdue'); + + const headers = screen.getAllByRole('columnheader').map((h) => h.textContent?.trim()); + expect(headers[1]).toBe(t('engine.list.col.locale', 'en-US')); + expect(t('engine.list.col.locale', 'en-US')).toBe('Locale'); + const [enRow, zhRow] = screen.getAllByTestId('metadata-row-duly.task_overdue'); + expect(within(enRow!).getAllByRole('cell')[1]).toHaveTextContent('en'); + expect(within(zhRow!).getAllByRole('cell')[1]).toHaveTextContent('zh-CN'); + }); + + it('CONTROL: a type with no members gets no Locale column and no ?member=, whatever its body carries', async () => { + mount('page', '?scope=environment'); + const [href] = await hrefsOf('home_page'); + + expect(queryOf(href!).has('member')).toBe(false); + const headers = screen.getAllByRole('columnheader').map((h) => h.textContent?.trim()); + expect(headers).not.toContain(t('engine.list.col.locale', 'en-US')); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 93ff514537c..039f53c000c 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -61,6 +61,8 @@ import { type RichMetadataTypeEntry, } from './useMetadata.js'; import { + BUNDLE_MEMBER_PARAM, + bundleMemberOf, getMetadataResource, resolveResourceConfig, } from './registry.js'; @@ -570,7 +572,10 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string [scopedItems], ); - const columns = config.listColumns ?? defaultColumns(config.primaryKey ?? 'name'); + const columns = withBundleMemberColumn( + config.listColumns ?? defaultColumns(config.primaryKey ?? 'name'), + config.bundleMemberField, + ); const typeLabel = translateMetadataType(type, locale, entry?.label ?? type); // The type offers SOME runtime write channel (the editors' type tier), and — // in the environment scope — the caller holds the capability the metadata @@ -883,7 +888,14 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string row.source === 'artifact' ? `${t('engine.list.source.artifactDesc', locale)}${row.item._packageId ? ` (${row.item._packageId})` : ''}` : t('engine.list.source.runtimeDesc', locale); - const rowHref = `./${encodeURIComponent(name)}${rowEditSuffix}`; + // objectstack#26649 — two members of one name are two rows; + // each opens its OWN member. A row that declares none is the + // canonical member, which the bare name already opens. + const rowMember = bundleMemberOf(row.item, config.bundleMemberField); + const rowHref = `./${encodeURIComponent(name)}${withQueryParam( + rowEditSuffix, + rowMember ? `${BUNDLE_MEMBER_PARAM}=${encodeURIComponent(rowMember)}` : '', + )}`; return ( , open: () => v const ROW_OWN_CONTROLS = 'a,button,input,select,textarea,label,summary,[role="button"],[role="switch"],[role="checkbox"],[role="menuitem"],[role="link"],[contenteditable="true"]'; -function defaultColumns(primaryKey: string): NonNullable { +type ListColumns = NonNullable; + +/** + * objectstack#26649 — the member column of a type whose identity is a pair, + * second after the primary key, unless the type's own columns already show it. + * Its label is localised like every default column (`engine.list.col.`). + */ +function withBundleMemberColumn(columns: ListColumns, field: string | undefined): ListColumns { + if (!field || columns.some((c) => c.key === field)) return columns; + return [...columns.slice(0, 1), { key: field, label: field }, ...columns.slice(1)]; +} + +/** `suffix` (`''` or `?a=b`) with one more `key=value` pair, when there is one. */ +function withQueryParam(suffix: string, pair: string): string { + if (!pair) return suffix; + return suffix ? `${suffix}&${pair}` : `?${pair}`; +} + +function defaultColumns(primaryKey: string): ListColumns { return [ { key: primaryKey, label: primaryKey, width: '30%' }, { key: 'label', label: 'Label', width: '30%' }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts b/ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts index c6b68ab7ed2..0b8aed34f48 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/default-schemas.ts @@ -358,4 +358,8 @@ export function registerDefaultMetadataSchemas(): void { type: 'position', createFields: ['label', 'name', 'description', 'permissionSets'], }); + + // objectstack#26649 — two locales of one template are two members of one + // name; the list tells them apart by `locale` and opens each by it. + registerMetadataResource({ type: 'email_template', bundleMemberField: 'locale' }); } diff --git a/ui/packages/app-shell/src/views/metadata-admin/registry.ts b/ui/packages/app-shell/src/views/metadata-admin/registry.ts index 655f8719958..9c355aa2b74 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/registry.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/registry.ts @@ -29,6 +29,30 @@ import type { ComponentType, ReactNode } from 'react'; +/** + * objectstack#26649 — the edit route's bundle member query parameter + * (`metadata/:type/:name?member=zh-CN`), written by the list's row link for a + * type that declares {@link MetadataResourceConfig.bundleMemberField} and read + * by the edit page. The same name as the by-name doors' own `?member=` + * selector, which the page forwards verbatim. Page-scoped, like `package`. + */ +export const BUNDLE_MEMBER_PARAM = 'member'; + +/** + * objectstack#26649 — the bundle member a body IS, read off its own + * {@link MetadataResourceConfig.bundleMemberField}, trimmed as the doors read + * the selector; `undefined` when the type declares no field or the body none + * (the canonical member, which the bare name already addresses). + */ +export function bundleMemberOf( + item: Record | null | undefined, + field: string | undefined, +): string | undefined { + if (!field) return undefined; + const value = item?.[field]; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + /** * Live field-to-field derivation rule used by the generic create form. * See {@link MetadataResourceConfig.createDerive}. @@ -106,6 +130,16 @@ export interface MetadataResourceConfig { * are this type's own `listColumns`. Omit for the plain list. */ listInclude?: 'grants'; + /** + * objectstack#26649 — the body field that tells two members of one name + * apart, for a type whose identity is a pair (`email_template`: + * `(name, locale)`). The list then shows it as a column and links each row + * to its own member (`?member=`), which the edit page passes to every + * by-name read and write. Declared HERE because the server serves no such + * key: its table (`ITEM_KEY_DISCRIMINATORS`, `@objectstack/metadata-core`) + * stays server-side, and a list row carries only the body's own field. + */ + bundleMemberField?: string; /** Columns rendered in the list page. */ listColumns?: Array<{ diff --git a/ui/packages/data-objectstack/src/metadata-client.bundleMember-26649.test.ts b/ui/packages/data-objectstack/src/metadata-client.bundleMember-26649.test.ts new file mode 100644 index 00000000000..52fc20bc8b9 --- /dev/null +++ b/ui/packages/data-objectstack/src/metadata-client.bundleMember-26649.test.ts @@ -0,0 +1,141 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#26649 — the editor's by-name doors name the bundle member they + * address. + * + * Setup's metadata editor opens one member of an `email_template` (one + * language) by `?member=`. The doors it reads and writes through take the same + * selector: the item read (`state=draft` included), the layered read since + * objectstack#26699, the publish and the delete (ruling 6116165299). A call + * that names none addresses the CANONICAL member, so each method has to carry + * the member or the editor reads one language and writes another. + * + * ## What is pinned, per method + * + * - given a member -> `?member=VALUE`, beside `?package=` when both are named; + * - no member -> the parameter is ABSENT, never sent empty (the door + * refuses a blank selector, and any selector on a member-less type); + * - `getDraft` asks the ledger first, and a ledger row of the same name that + * is ANOTHER member's draft is no reason to send the draft read. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { MetadataClient } from './metadata-client'; + +const BASE = 'http://localhost:3000/api/v1/meta'; + +function record(drafts: Array> = []) { + const seen: { url: string; method: string }[] = []; + const client = new MetadataClient({ + baseUrl: 'http://localhost:3000', + fetch: vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + seen.push({ url, method }); + const body = String(url).includes('/_drafts') + ? { drafts } + : String(url).includes('/layers') + ? { code: null, overlay: null, overlayScope: null, effective: { name: 'welcome' } } + : { type: 'email_template', name: 'welcome', item: { name: 'welcome' }, success: true }; + return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch, + }); + return { seen, client }; +} + +function queryOf(url: string): URLSearchParams { + const q = url.indexOf('?'); + return new URLSearchParams(q === -1 ? '' : url.slice(q + 1)); +} + +/** The requests that were not the drafts-ledger read. */ +const doorCalls = (seen: { url: string; method: string }[]) => seen.filter((s) => !s.url.includes('/_drafts')); + +describe('MetadataClient — the bundle member on the editor\'s by-name doors (objectstack#26649)', () => { + it('layered() reads one member\'s layers: ?member=, beside ?package=', async () => { + const { seen, client } = record(); + await client.layered('email_template', 'welcome', { member: 'zh-CN' }); + await client.layered('email_template', 'welcome', { packageId: 'com.acme', member: 'zh-CN' }); + + expect(seen.map((s) => s.url)).toEqual([ + `${BASE}/email_template/welcome/layers?member=zh-CN`, + `${BASE}/email_template/welcome/layers?package=com.acme&member=zh-CN`, + ]); + }); + + it('get() reads one member: ?member=', async () => { + const { seen, client } = record(); + await client.get('email_template', 'welcome', { member: 'zh-CN' }); + + expect(seen[0]?.url).toBe(`${BASE}/email_template/welcome?member=zh-CN`); + }); + + it('getDraft() reads one member\'s draft when the ledger lists that member', async () => { + const { seen, client } = record([ + { type: 'email_template', name: 'welcome', member: 'en-US' }, + { type: 'email_template', name: 'welcome', member: 'zh-CN' }, + ]); + await client.getDraft('email_template', 'welcome', { member: 'zh-CN' }); + + const reads = doorCalls(seen); + expect(reads).toHaveLength(1); + expect(queryOf(reads[0]!.url).get('state')).toBe('draft'); + expect(queryOf(reads[0]!.url).get('member')).toBe('zh-CN'); + }); + + it('getDraft() sends no draft read when the ledger\'s row of that name is another member\'s', async () => { + const { seen, client } = record([{ type: 'email_template', name: 'welcome', member: 'en-US' }]); + await expect(client.getDraft('email_template', 'welcome', { member: 'zh-CN' })).resolves.toBeNull(); + + expect(doorCalls(seen)).toHaveLength(0); + }); + + it('CONTROL: getDraft() with no member still reads on a same-name row of any member', async () => { + const { seen, client } = record([{ type: 'email_template', name: 'welcome', member: 'zh-CN' }]); + await client.getDraft('email_template', 'welcome'); + + const reads = doorCalls(seen); + expect(reads).toHaveLength(1); + expect(queryOf(reads[0]!.url).has('member')).toBe(false); + }); + + it('publish() promotes one member\'s draft: ?member=, beside ?package=', async () => { + const { seen, client } = record(); + await client.publish('email_template', 'welcome', { packageId: 'com.acme', member: 'zh-CN' }); + + expect(seen[0]?.method).toBe('POST'); + expect(seen[0]?.url).toBe(`${BASE}/email_template/welcome/publish?package=com.acme&member=zh-CN`); + }); + + it('reset() removes one member\'s row, active and draft: ?member=', async () => { + const { seen, client } = record(); + await client.reset('email_template', 'welcome', { member: 'zh-CN' }); + await client.reset('email_template', 'welcome', { state: 'draft', member: 'zh-CN' }); + + expect(seen.map((s) => [s.method, s.url])).toEqual([ + ['DELETE', `${BASE}/email_template/welcome?member=zh-CN`], + ['DELETE', `${BASE}/email_template/welcome?state=draft&member=zh-CN`], + ]); + }); + + it('omits the parameter on every door when there is no member: absent, not empty', async () => { + for (const member of [undefined, '']) { + const { seen, client } = record([{ type: 'email_template', name: 'welcome' }]); + await client.layered('email_template', 'welcome', { member }); + await client.get('email_template', 'welcome', { member }); + await client.getDraft('email_template', 'welcome', { member }); + await client.publish('email_template', 'welcome', { member }); + await client.reset('email_template', 'welcome', { member }); + + const calls = doorCalls(seen); + expect(calls).toHaveLength(5); + for (const call of calls) expect(queryOf(call.url).has('member')).toBe(false); + } + }); +}); diff --git a/ui/packages/data-objectstack/src/metadata-client.ts b/ui/packages/data-objectstack/src/metadata-client.ts index 750eba24adb..7d457a4dfd0 100644 --- a/ui/packages/data-objectstack/src/metadata-client.ts +++ b/ui/packages/data-objectstack/src/metadata-client.ts @@ -473,6 +473,15 @@ export interface MetadataGetOptions { * passes the edited item's owning package). Omit for context-free reads. */ packageId?: string; + /** + * The bundle member to read (objectstack#26649), sent as `?member=`: for a + * type whose identity is a pair (today only `email_template`, + * `(name, locale)`), the member's discriminator value (`zh-CN`). Omitted, + * the door answers the canonical member, as a bare-name read always did. + * OMITTED, never sent empty, when the caller has none: the door refuses a + * blank selector, and any selector on a type with no members. + */ + member?: string; } // `surface` is a SAVE input (`?surface=` on `PUT`); the delete door reads no @@ -485,6 +494,14 @@ export interface MetadataDeleteOptions extends Omit { @@ -1277,16 +1295,20 @@ export class MetadataClient { * only when the ledger lists the name. The item read answers 404 when there * is no draft, so opening every item in Studio used to log one error per * item for an answer the ledger gives with a 200. + * + * objectstack#26649 — `member` reads one bundle member's draft + * ({@link MetadataGetOptions.member}); omitted, the canonical member's. */ async getDraft( type: string, name: string, - options: { packageId?: string } = {}, + options: { packageId?: string; member?: string } = {}, ): Promise { - if (!(await this.draftLedgerMayHold(name))) return null; + if (!(await this.draftLedgerMayHold(name, options.member))) return null; return this.readItemResponse(type, name, { state: 'draft', ...(options.packageId ? { packageId: options.packageId } : {}), + ...(options.member ? { member: options.member } : {}), }); } @@ -1311,15 +1333,22 @@ export class MetadataClient { * A ledger that cannot be read (403 without an authoring capability, 501 on * a kernel without drafts, a network fault) is UNKNOWN, not empty: the item * read is sent, as before. + * + * objectstack#26649 — asked for one bundle `member`, a row of that name that + * is another member's draft is no answer: the ledger names every draft of a + * bundled type with its `member` (objectstack#23073). A row that carries no + * `member` may still be it, so it counts. */ - private async draftLedgerMayHold(name: string): Promise { + private async draftLedgerMayHold(name: string, member?: string): Promise { let drafts: MetadataDraftHeader[]; try { drafts = await this.listDrafts(); } catch { return true; } - return drafts.some((d) => d?.name === name); + return drafts.some( + (d) => d?.name === name && (!member || d.member === undefined || d.member === member), + ); } /** @@ -1651,11 +1680,15 @@ export class MetadataClient { * answer a different resource under this one's declared shape, so it returns * 501 `NOT_IMPLEMENTED` instead — which surfaces here as a thrown error * rather than a view with `code` and `overlay` silently blank. + * + * objectstack#26649 — `member` reads the layers of one bundle member + * ({@link MetadataGetOptions.member}), which this door takes since + * objectstack#26699; omitted, the canonical member's layers. */ async layered( type: string, name: string, - options: { packageId?: string } = {}, + options: { packageId?: string; member?: string } = {}, ): Promise> { // ADR-0048 — `?package=` scopes resolution to one installed package (the // editor passes the edited item's owning package, not the Studio app's). @@ -1663,7 +1696,10 @@ export class MetadataClient { // keeping the `&` would have appended it to the last PATH segment // (`…/layers&package=crm`), which matches no route — a 404 that this // method turns into an empty-but-successful layered view. - const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : ''; + const params: string[] = []; + if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`); + if (options.member) params.push(`member=${encodeURIComponent(options.member)}`); + const qs = params.length ? `?${params.join('&')}` : ''; const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/layers${qs}`; const res = await this.fetchImpl(url, { method: 'GET', headers: this.headers, cache: 'no-store' }); if (res.status === 404) { @@ -1838,6 +1874,9 @@ export class MetadataClient { const params: string[] = []; if (options.state === 'draft') params.push('state=draft'); if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`); + // objectstack#26649 — the member whose row this removes; omitted, the + // canonical member's (`MetadataDeleteOptions.member`). + if (options.member) params.push(`member=${encodeURIComponent(options.member)}`); const qs = params.length ? `?${params.join('&')}` : ''; const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${qs}`; const headers: Record = { ...this.headers }; @@ -1859,11 +1898,15 @@ export class MetadataClient { * (#5026 / objectstack#9176), with `door: 'publish'`. This is the door the * Studio designer takes on every edit, so it is the one whose findings an * author actually sees. + * + * objectstack#26649 — `member` promotes one bundle member's draft, sent as + * `?member=` under the same rule as {@link publishDraft}'s; omitted, the + * canonical member's draft. */ async publish( type: string, name: string, - options: { message?: string; packageId?: string } = {}, + options: { message?: string; packageId?: string; member?: string } = {}, ): Promise { // objectstack `9e04c3e35` (`@objectstack/rest` 17.2.0) — this door accepts // `?package=` and forwards it as the promotion's package binding, so @@ -1878,7 +1921,10 @@ export class MetadataClient { // both mean "env-local overlay, no package"), so this is not a behaviour // difference on the current server — it is the shape the save door already // follows, and the two calls of one loop must not disagree about it. - const qs = options.packageId ? `?package=${encodeURIComponent(options.packageId)}` : ''; + const params: string[] = []; + if (options.packageId) params.push(`package=${encodeURIComponent(options.packageId)}`); + if (options.member) params.push(`member=${encodeURIComponent(options.member)}`); + const qs = params.length ? `?${params.join('&')}` : ''; const url = `${this.base}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/publish${qs}`; const headers: Record = { ...this.headers, @@ -1900,7 +1946,13 @@ export class MetadataClient { // // `mode` is `'publish'` because the promotion lands the body in the active // overlay; `door` is what distinguishes it from a direct active save. - this.emitAdvisories(body, { type, name, door: 'publish', mode: 'publish' }); + this.emitAdvisories(body, { + type, + name, + ...(options.member ? { member: options.member } : {}), + door: 'publish', + mode: 'publish', + }); return body as T; } diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 6dcc35bcc29..6bd22fd68ea 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3343,6 +3343,9 @@ const STUDIO_EN: Record = { 'engine.list.col.accessLevel': 'Access', 'engine.list.col._packageId': 'Package', 'engine.list.col.criteria': 'Criteria', + // objectstack#26649 — the member column of a type whose identity is + // `(name, locale)` (`bundleMemberField`). + 'engine.list.col.locale': 'Locale', 'engine.list.col.actions': 'Actions', // objectstack#25333 — the position list's served columns (`PositionGrantCells.tsx`). 'engine.list.col._holderCount': 'Holders', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 9aaa1b909e9..a088c9722e2 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2984,6 +2984,7 @@ const STUDIO_ZH: Record = { 'engine.list.col.accessLevel': '访问级别', 'engine.list.col._packageId': '软件包', 'engine.list.col.criteria': '条件', + 'engine.list.col.locale': '语言', 'engine.list.col.actions': '操作', 'engine.list.col._holderCount': '持有人数', 'engine.list.col._grantedPermissionSets': '权限集',