Skip to content

Commit 272fec0

Browse files
feat(studio): edit an email template or a translation in the All metadata area, and customize a read-only package's one per locale (#28743)
Fixes #28569 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio「All metadata」区里,`email_template` 与 `translation` 不再落到通用条目页,而是打开一个简化表单编辑器:Setup 表单的字段(served schema,缺省用内置 schema)+ 该类型已注册的预览(`EmailTemplatePreview` / `TranslationPreview`,挂载复用,非拷贝)。可写包写自己的草稿;只读基座走 N-17 的 Customize 路径(ensure → 草稿写入 `<base>.custom` 并声明 `overrides: {package, basedOn}` → 头部 Publish 发布 pair → ⋯ 菜单「Restore package original」)。 - **为什么改**:此前改一个已安装包的邮件模板或翻译只能去 Setup,写出的是不属于任何包的行;本卡让它成为 pair 里的声明式覆盖(ADR-0146 D2),且邮件模板按 (name, locale) 逐语言覆盖,翻译整包替换并在首次保存前明说。 - **风险与回滚**:改动只在 ui 的 app-shell(新增 `StudioBundleItemEditor.tsx`,`StudioMetadataArea` 的条目路由多一个分支,`useServedOverride` 多一个可选 `member`)与 studio 命名空间三条字符串;服务端零改动。Setup 页面、跨包 `~all` 腿、`?scope=environment`、`?review=1` 仍走通用条目页。回滚 = revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx` 的 `doSave`(只读基座分支:ensure 一次、`overrideDraftBody`、`recordRead(..., {ifNoneMatch:'*'})`)和 `restoreOriginal`(先删 pair 草稿再删行,均带 `member`);再看 `metadataArea.ts#areaItemOpensFormEditor`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx`,应全部通过。 - **需要你做的**:无需决策;正常评审即可。Acceptance notes 里有一条与 Interfaces 柱一致的已知行为(Restore 后头部待发布计数不即时刷新)。 ## What changes - **Routing.** `metadataArea.ts` gains `AREA_FORM_EDITOR_TYPES` (`email_template`, `translation`) and `areaItemOpensFormEditor({packageId, type, search})`: true inside one package, outside `?scope=environment` and `?review=1`. `StudioMetadataArea`'s item route (`AreaItemOpen`) decides once: the form editor, else the pillar, else `MetadataResourceEditPage` as before. The item is keyed by `type/name/member`, so another locale is another editor (the draft-save guard's version pin is keyed by type, name and package, not member). - **The editor (`StudioBundleItemEditor.tsx`, new).** `SchemaForm` over the served schema, else the default schema (as the generic item page resolves it), with `name` and `overrides` hidden, and `locale` too for an email template. Beside it, the type's registered preview. Autosave through `useDraftAutoSave` and the shared `useDraftSaveGuard` (same version pin and conflict dialog as the pillars). Header: type, label, locale chip, override badge, autosave status, Customize, ⋯ (History, Restore package original). - **A read-only base.** Customize (`StudioCustomizeButton`) only unlocks the form. The first saved edit calls the surface's `ensure()` once, saves the draft into the pair with `overrides: {package, basedOn}` (`overrideDraftBody`), and bumps the badge. Restore (`StudioRestoreOriginalDialog`) deletes the pair's draft, then its row, at the item's member, then re-reads the base. A walled caller sees Customize disabled with the reason. - **Per member.** Every read, save, badge read and restore of an email template carries `?member=<locale>` from the list row's link (`BUNDLE_MEMBER_PARAM`). `useServedOverride` takes an optional `member` and passes it to `getDraft` / `layered`. A translation has no member; before the first save on a read-only base the editor says the whole bundle is replaced. For an email template it says only this locale is customized. - **Surface.** `StudioDesignSurface` passes its existing `customization`, `publishNonce` and `onDraftSaved` to `StudioMetadataArea`; nothing else in the surface moves. - **Strings.** Three `engine.studio.area.formEditor.*` keys in the studio namespace (`en`, `zh`). That table is `Record<string, string>` outside the locales packs, so `Clause-②: no` holds as claimed. ## Tests - `StudioBundleItemEditor.customize-28569.test.tsx` (new, 6): writable draft of one member (no door, no declaration, no `_` keys, locale field hidden, preview live); read-only Customize of email template zh-CN (ensure once, pair draft body exact with the declaration, badge, base untouched, pair read at member zh-CN); the other locale (en-US) untouched; Restore deletes the pair's draft and row of zh-CN alone and the base reads back; walled admin sees Customize disabled; translation whole-bundle note then the whole bundle saved into the pair, no member in any read. - `StudioMetadataArea.routes-28561.test.tsx` (+4): writable translation and read-only email template at member zh-CN open the editor; `?review=1` and `~all` open the generic page. - `StudioDesignSurface.metadataArea-28561.test.tsx` (+2): the surface hands the area a read-only base's customization (`base`, `baseLabel`, `basedOn`, `walled`, `pairId`, `ensure`), and null for a writable package, whose saved draft moves the changes count. <details><summary>Ablations (6, through <code>scripts/ablation-replace.mjs</code>; each landed on disk, went red, and was restored to HEAD by the tool)</summary> | # | Mutation | Result | |---|---|---| | A1 | the editor's layered read drops `member` | editor file: 4 failed, 2 passed | | A2 | the badge read (`useServedOverride`) drops `member` | 3 failed, 3 passed | | A3 | `locale` shown in the email template form | 1 failed (writable case), 5 passed | | A4 | the pair draft saved without the `overrides` declaration | 2 failed (email Customize, translation), 4 passed | | A5 | Restore's draft delete drops `member` | 1 failed (Restore), 5 passed | | A6 | `areaItemOpensFormEditor` always false | routes file: 2 failed, 18 passed | </details> ## Acceptance notes - Restore package original does not refresh the header's pending count at once; it emits the metadata-refresh pulse, exactly as the Interfaces pillar's Restore (#28711) does. - An email template's draft PUT takes no `?member=` (400 `VALIDATION_ERROR`, supported: force, mode, package, surface); the member is the body's `locale`. The editor sends the body it read, locale included and hidden from the form, so a save stays on its locale. - Server serving was checked before any code: the layered read and the served-override lookup are per member (`overlayMemberWhere`, `memberOption(member)`), so the card's stop rule ("server serves or gates a per-(name, locale) override wrongly") did not fire on reads. The write gate's shipper lookup is per (type, name); see the report's finding. ## Verification - The three card test files: editor 6/6, routes 20/20, surface metadata-area cases green. - Wider set under `scripts/pm/os-verify-lock.sh`: every test file naming `StudioCustomizeControls`, `useServedOverride`, `StudioMetadataArea`, `metadataArea`, `StudioBundleItemEditor` or `InterfacesPillar`, plus every test importing the studio namespace (`zh-base-term.i18n-13131`, the count-family census among them): 159 files, 2064 tests passed, at `37edd173` (main's merge after it touches no ui file). - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n`: exit 0 (at `1f6a135c`; the later commits add one lint comment and main's non-ui files). ESLint on the 10 changed ts/tsx: 0 errors (warnings are the files' existing ones plus `react-hooks` advisories). - `node scripts/check-sdui-manifest.mjs`: exit 0. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands`) at `ca3cc565`: 34 of 34 run, each exit 0 (`check:type-check-debt` under the lock); `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped. - Live, on `objectstack dev --fresh` for `examples/app-showcase` (base `com.example.showcase` 0.1.0, which ships `showcase_task_done_email` in `en-US` only): ensure answered 201 then 200 with `com.example.showcase.custom`; a declared draft of `en-US` in the pair saved 200. The write side of the per-locale gate is the report's finding, not this PR's. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jTJcKuqRSJyv696zZ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92a1638 commit 272fec0

11 files changed

Lines changed: 1174 additions & 25 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): edit an email template or a translation in the All metadata area, and customize a read-only package's one into its pair
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Until now an admin changed an email template or a translation in Setup, which writes a row bound to no package. Studio's All metadata area now opens both in a simplified form editor, and a change to an installed package's item is a declared override in the package's paired customization package (`<base>.custom`, ADR-0146 D2).
12+
13+
- **The editor.** In a package's All metadata area (`/studio/<package>/metadata/<type>/<name>`), an `email_template` or a `translation` opens the fields Setup's form offers, beside the type's preview (the sandboxed email preview, the translation coverage). Both are Setup's components, mounted, not copied: the form is the served schema, else the built-in fallback, as on the generic item page. The cross-package leg, the environment scope and `?review=1` keep opening the generic item page. Setup's pages are unchanged.
14+
- **A writable package.** An edit saves that package's draft through the shared Studio autosave, with the same version pin as the pillars. The header's Publish puts it live.
15+
- **A read-only package.** The editor offers Customize, the same control as a view, dashboard or report (the Interfaces pillar). The first saved edit calls the ensure door once and saves the draft into the pair it answers, declaring `overrides: { package, basedOn }`. The badge reads the served declaration. "Restore package original" in the ⋯ menu deletes the pair's draft, then its row. A walled caller sees Customize disabled, with the reason.
16+
- **One email template locale at a time.** An email template is one item per `(name, locale)`. The editor opens the locale the list row names (`?member=`). It reads, saves, declares, badges and restores that locale alone, and keeps the locale out of the form, so an edit never lands in another locale. The other locales of the template are untouched. Before the first save on a read-only package, the editor says only that locale is customized.
17+
- **A translation is replaced whole.** A translation's override is its whole locale bundle (ADR-0146 D2). Before the first save on a read-only package, the editor says so.
18+
- **New strings.** `@object-ui/i18n`'s studio namespace gains three `engine.studio.area.formEditor.*` keys in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does.

‎ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx‎

Lines changed: 426 additions & 0 deletions
Large diffs are not rendered by default.

‎ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx‎

Lines changed: 478 additions & 0 deletions
Large diffs are not rendered by default.

‎ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ import { t, tFormat } from '../metadata-admin/i18n.js';
5656
/**
5757
* The types Studio's Customize door covers: the Interfaces pillar's view,
5858
* dashboard and report (an `object` leaf's list view is a `view`). Each is an
59-
* `overridable` type of the spec's classification; translations and email
60-
* templates are too, but no pillar designs them here.
59+
* `overridable` type of the spec's classification. Translations and email
60+
* templates are too: no pillar designs them, and the All metadata area's form
61+
* editor customizes them (`StudioBundleItemEditor`, objectstack#28569).
6162
*/
6263
export const STUDIO_CUSTOMIZE_TYPES: readonly string[] = ['view', 'dashboard', 'report'];
6364

@@ -144,30 +145,40 @@ const NO_OVERRIDE: ServedOverride = { declaration: null, state: null };
144145
* layered read's `overlay` at `base`, which is the pair's row the read serves
145146
* over the base's item. Read again whenever `nonce` moves (a publish, a
146147
* restore, the item's first save into the pair). `item: null` reads nothing.
148+
* `item.member` names one bundle member (an email template's locale,
149+
* objectstack#28569): both reads address that member alone, so the override
150+
* of one locale is never read as the template's.
147151
*
148152
* A failed read is no declaration, as an absent one: no fact, no badge.
149153
*/
150154
export function useServedOverride(
151155
client: Pick<MetadataClient, 'getDraft' | 'layered'>,
152-
item: { type: string; name: string } | null,
156+
item: { type: string; name: string; member?: string } | null,
153157
base: string | null,
154158
pair: string | null,
155159
nonce: string | number,
156160
): ServedOverride {
157161
const itemType = item?.type ?? '';
158162
const itemName = item?.name ?? '';
159-
const key = itemType && itemName && base ? JSON.stringify([itemType, itemName, base]) : '';
163+
const itemMember = item?.member ?? '';
164+
const key = itemType && itemName && base ? JSON.stringify([itemType, itemName, itemMember, base]) : '';
160165
const [answer, setAnswer] = React.useState<{ key: string; served: ServedOverride }>({ key: '', served: NO_OVERRIDE });
161166
React.useEffect(() => {
162167
if (!key || !base) return;
163168
let cancelled = false;
169+
// Omitted, never sent empty, for the canonical member and a type with none.
170+
const memberScope = itemMember ? { member: itemMember } : {};
164171
(async (): Promise<ServedOverride> => {
165172
if (pair) {
166-
const draft = await client.getDraft(itemType, itemName, { packageId: pair }).catch(() => null);
173+
const draft = await client
174+
.getDraft(itemType, itemName, { packageId: pair, ...memberScope })
175+
.catch(() => null);
167176
const declared = declaredOverrideOf(extractDraftBody(draft), base);
168177
if (declared) return { declaration: declared, state: 'draft' };
169178
}
170-
const layered = await client.layered(itemType, itemName, { packageId: base }).catch(() => null);
179+
const layered = await client
180+
.layered(itemType, itemName, { packageId: base, ...memberScope })
181+
.catch(() => null);
171182
const declared = declaredOverrideOf(layered?.overlay, base);
172183
return declared ? { declaration: declared, state: 'active' } : NO_OVERRIDE;
173184
})().then((served) => {
@@ -176,7 +187,7 @@ export function useServedOverride(
176187
return () => {
177188
cancelled = true;
178189
};
179-
}, [client, key, itemType, itemName, base, pair, nonce]);
190+
}, [client, key, itemType, itemName, itemMember, base, pair, nonce]);
180191
// A re-read of the same item keeps the last answer until it lands, so the
181192
// badge does not blink off and on after a save.
182193
return key && answer.key === key ? answer.served : NO_OVERRIDE;

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx‎

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import '@testing-library/jest-dom/vitest';
1616
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17-
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
17+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
1818
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
1919
import { useMetadataAreaHost } from '../metadata-admin/metadataAreaHost';
2020

@@ -67,6 +67,34 @@ vi.mock('../metadata-admin/index.js', () => ({
6767
MetadataResourceHistoryPage: () => <PageStub page="history" />,
6868
}));
6969

70+
/**
71+
* objectstack#28569 — the area's form editor, a stub printing what the surface
72+
* hands it (the customization, without its door) and saving a draft on click.
73+
*/
74+
vi.mock('./StudioBundleItemEditor.js', () => ({
75+
StudioBundleItemEditor: ({
76+
customization,
77+
onDraftSaved,
78+
type,
79+
name,
80+
}: {
81+
customization: { ensure?: unknown } | null;
82+
onDraftSaved?: (saved?: { type: string; name: string }) => void;
83+
type: string;
84+
name: string;
85+
}) => {
86+
const { ensure, ...facts } = customization ?? {};
87+
return (
88+
<div data-testid="form-editor" data-customization={customization ? JSON.stringify(facts) : 'null'}>
89+
<span data-testid="form-editor-door">{typeof ensure}</span>
90+
<button type="button" onClick={() => onDraftSaved?.({ type, name })}>
91+
save a draft
92+
</button>
93+
</div>
94+
);
95+
},
96+
}));
97+
7098
import { StudioDesignSurface } from './StudioDesignSurface';
7199
import { t } from '../metadata-admin/i18n';
72100

@@ -90,6 +118,25 @@ function servePackages() {
90118
return fetchMock;
91119
}
92120

121+
/** objectstack#28569 — the list with an installed, read-only base that declares its version. */
122+
const BASE_ID = 'com.vendor.hotcrm';
123+
function serveWithBase() {
124+
const fetchMock = vi.fn(async (input: unknown) => {
125+
if (String(input) !== '/api/v1/packages') return answer([]);
126+
return answer({
127+
success: true,
128+
data: {
129+
packages: [
130+
{ manifest: { id: PACKAGE_ID, name: 'Field Ops' }, writable: true },
131+
{ manifest: { id: BASE_ID, name: 'HotCRM', version: '4.0.0', scope: 'installed' }, writable: false },
132+
],
133+
},
134+
});
135+
});
136+
vi.stubGlobal('fetch', fetchMock);
137+
return fetchMock;
138+
}
139+
93140
function Where() {
94141
const { pathname, search } = useLocation();
95142
return <div data-testid="where">{`${pathname}${search}`}</div>;
@@ -170,7 +217,7 @@ describe('Studio: the "All metadata" area in the builder (objectstack#28561)', (
170217

171218
it('a kernel package the switcher hides opens its area read-only', async () => {
172219
servePackages();
173-
renderAt(`/studio/${KERNEL_ID}/metadata/translation/t1?package=${KERNEL_ID}`);
220+
renderAt(`/studio/${KERNEL_ID}/metadata/agent/a1?package=${KERNEL_ID}`);
174221
expect(await areaPage()).toEqual({
175222
page: 'item',
176223
host: { packageId: KERNEL_ID, readOnly: true, basePath: `/studio/${KERNEL_ID}/metadata` },
@@ -185,3 +232,31 @@ describe('Studio: the "All metadata" area in the builder (objectstack#28561)', (
185232
expect(screen.queryByTestId('studio-metadata-area')).toBeNull();
186233
});
187234
});
235+
236+
describe('the area\'s form editor in the builder (objectstack#28569)', () => {
237+
it('on a read-only base, the editor is handed the surface\'s customization: the base, its version and the door', async () => {
238+
serveWithBase();
239+
renderAt(`/studio/${BASE_ID}/metadata/email_template/welcome?package=${BASE_ID}&member=zh-CN`);
240+
const editor = await screen.findByTestId('form-editor', undefined, { timeout: 8000 });
241+
await waitFor(() => expect(editor.getAttribute('data-customization')).not.toBe('null'));
242+
expect(JSON.parse(editor.getAttribute('data-customization') ?? 'null')).toEqual({
243+
base: BASE_ID,
244+
baseLabel: 'HotCRM',
245+
basedOn: '4.0.0',
246+
walled: false,
247+
pairId: null,
248+
});
249+
expect(screen.getByTestId('form-editor-door')).toHaveTextContent('function');
250+
});
251+
252+
it('on a writable package, the editor has no customization, and its saved draft counts in the header', async () => {
253+
serveWithBase();
254+
renderAt(`/studio/${PACKAGE_ID}/metadata/translation/ui_en?package=${PACKAGE_ID}`);
255+
const editor = await screen.findByTestId('form-editor', undefined, { timeout: 8000 });
256+
expect(editor).toHaveAttribute('data-customization', 'null');
257+
expect(screen.queryByTestId('studio-changes-count')).toBeNull();
258+
fireEvent.click(within(editor).getByRole('button', { name: 'save a draft' }));
259+
// The header counts the draft the save wrote, before its own re-read answers.
260+
expect(await screen.findByTestId('studio-changes-count')).toHaveTextContent('1');
261+
});
262+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2682,7 +2682,17 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps):
26822682
) : tab === STUDIO_METADATA_AREA ? (
26832683
// objectstack#28561 — keyed by package like the pillars: every
26842684
// page under it holds that package's scope and gate.
2685-
<StudioMetadataArea key={packageId} packageId={packageId} />
2685+
// objectstack#28569 — its form editor customizes an email
2686+
// template or a translation of a read-only base with the
2687+
// Interfaces pillar's customization, and Publish and the
2688+
// pending count are this header's.
2689+
<StudioMetadataArea
2690+
key={packageId}
2691+
packageId={packageId}
2692+
customization={customization}
2693+
publishNonce={publishNonce}
2694+
onDraftSaved={onDraftSaved}
2695+
/>
26862696
) : !pillarKnown ? (
26872697
<StudioEmptyState
26882698
icon={FileQuestion}

‎ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@ vi.mock('../metadata-admin/index.js', () => ({
4848
MetadataResourceHistoryPage: () => <PageStub page="history" />,
4949
}));
5050

51+
/**
52+
* objectstack#28569 — the form editor of an email template or a translation,
53+
* a stub printing the host and the member it opens (the editor itself is
54+
* pinned in `StudioBundleItemEditor.customize-28569.test.tsx`).
55+
*/
56+
vi.mock('./StudioBundleItemEditor.js', () => ({
57+
StudioBundleItemEditor: ({ type, name, member, readOnly }: { type: string; name: string; member?: string; readOnly: boolean }) => (
58+
<div data-testid="form-editor" data-item={JSON.stringify({ type, name, member: member ?? null, readOnly })}>
59+
<PageStub page="form-editor" />
60+
</div>
61+
),
62+
}));
63+
5164
/** `GET /api/v1/packages` as the listed manifests read it: one writable, one read-only, one kernel. */
5265
let listed: Map<string, { id: string; name: string; writable: boolean; manifest: Record<string, unknown> }>;
5366
let listFails = false;
@@ -127,7 +140,7 @@ describe('the area mounts the metadata pages (objectstack#28561)', () => {
127140
});
128141

129142
it('a kernel package, which the switcher hides, is read-only too', async () => {
130-
mount(`/studio/${KERNEL}/metadata/translation/t1?package=${KERNEL}`);
143+
mount(`/studio/${KERNEL}/metadata/agent/a1?package=${KERNEL}`);
131144
expect((await page()).host.readOnly).toBe(true);
132145
});
133146

@@ -191,8 +204,8 @@ describe('a pillar type opened from the area opens its pillar', () => {
191204
});
192205
}
193206

194-
it('a translation opens the generic page', async () => {
195-
mount(`/studio/${WRITABLE}/metadata/translation/ui_en?package=${WRITABLE}`);
207+
it('a type no pillar designs opens the generic page', async () => {
208+
mount(`/studio/${WRITABLE}/metadata/agent/support_bot?package=${WRITABLE}`);
196209
expect((await page()).page).toBe('item');
197210
});
198211

@@ -211,3 +224,31 @@ describe('a pillar type opened from the area opens its pillar', () => {
211224
expect(screen.queryByTestId('pillar')).toBeNull();
212225
});
213226
});
227+
228+
describe('an email template or a translation opens the form editor (objectstack#28569)', () => {
229+
const item = () => JSON.parse(screen.getByTestId('form-editor').getAttribute('data-item') ?? 'null');
230+
231+
it('a translation of a writable package', async () => {
232+
mount(`/studio/${WRITABLE}/metadata/translation/ui_en?package=${WRITABLE}`);
233+
expect((await page()).page).toBe('form-editor');
234+
expect(item()).toEqual({ type: 'translation', name: 'ui_en', member: null, readOnly: false });
235+
});
236+
237+
it('an email template of a read-only package, at the locale the list row names', async () => {
238+
mount(`/studio/${READ_ONLY}/metadata/email_template/welcome?package=${READ_ONLY}&member=zh-CN`);
239+
expect((await page()).host.readOnly).toBe(true);
240+
expect(item()).toEqual({ type: 'email_template', name: 'welcome', member: 'zh-CN', readOnly: true });
241+
});
242+
243+
it('`?review=1` opens the generic page and its review', async () => {
244+
mount(`/studio/${WRITABLE}/metadata/email_template/welcome?package=${WRITABLE}&review=1`);
245+
expect((await page()).page).toBe('item');
246+
expect(screen.queryByTestId('form-editor')).toBeNull();
247+
});
248+
249+
it('the cross-package leg opens the generic page: it names no package to write', async () => {
250+
mount('/studio/~all/metadata/translation/ui_en?package=all');
251+
expect((await page()).page).toBe('item');
252+
expect(screen.queryByTestId('form-editor')).toBeNull();
253+
});
254+
});

0 commit comments

Comments
 (0)