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
18 changes: 18 additions & 0 deletions .changeset/28569-studio-email-template-translation-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

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

Clause-②: no
Metadata-protocol: none

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

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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

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

Expand Down Expand Up @@ -144,30 +145,40 @@ const NO_OVERRIDE: ServedOverride = { declaration: null, state: null };
* layered read's `overlay` at `base`, which is the pair's row the read serves
* over the base's item. Read again whenever `nonce` moves (a publish, a
* restore, the item's first save into the pair). `item: null` reads nothing.
* `item.member` names one bundle member (an email template's locale,
* objectstack#28569): both reads address that member alone, so the override
* of one locale is never read as the template's.
*
* A failed read is no declaration, as an absent one: no fact, no badge.
*/
export function useServedOverride(
client: Pick<MetadataClient, 'getDraft' | 'layered'>,
item: { type: string; name: string } | null,
item: { type: string; name: string; member?: string } | null,
base: string | null,
pair: string | null,
nonce: string | number,
): ServedOverride {
const itemType = item?.type ?? '';
const itemName = item?.name ?? '';
const key = itemType && itemName && base ? JSON.stringify([itemType, itemName, base]) : '';
const itemMember = item?.member ?? '';
const key = itemType && itemName && base ? JSON.stringify([itemType, itemName, itemMember, base]) : '';
const [answer, setAnswer] = React.useState<{ key: string; served: ServedOverride }>({ key: '', served: NO_OVERRIDE });
React.useEffect(() => {
if (!key || !base) return;
let cancelled = false;
// Omitted, never sent empty, for the canonical member and a type with none.
const memberScope = itemMember ? { member: itemMember } : {};
(async (): Promise<ServedOverride> => {
if (pair) {
const draft = await client.getDraft(itemType, itemName, { packageId: pair }).catch(() => null);
const draft = await client
.getDraft(itemType, itemName, { packageId: pair, ...memberScope })
.catch(() => null);
const declared = declaredOverrideOf(extractDraftBody(draft), base);
if (declared) return { declaration: declared, state: 'draft' };
}
const layered = await client.layered(itemType, itemName, { packageId: base }).catch(() => null);
const layered = await client
.layered(itemType, itemName, { packageId: base, ...memberScope })
.catch(() => null);
const declared = declaredOverrideOf(layered?.overlay, base);
return declared ? { declaration: declared, state: 'active' } : NO_OVERRIDE;
})().then((served) => {
Expand All @@ -176,7 +187,7 @@ export function useServedOverride(
return () => {
cancelled = true;
};
}, [client, key, itemType, itemName, base, pair, nonce]);
}, [client, key, itemType, itemName, itemMember, base, pair, nonce]);
// A re-read of the same item keeps the last answer until it lands, so the
// badge does not blink off and on after a save.
return key && answer.key === key ? answer.served : NO_OVERRIDE;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

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

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

/**
* objectstack#28569 — the area's form editor, a stub printing what the surface
* hands it (the customization, without its door) and saving a draft on click.
*/
vi.mock('./StudioBundleItemEditor.js', () => ({
StudioBundleItemEditor: ({
customization,
onDraftSaved,
type,
name,
}: {
customization: { ensure?: unknown } | null;
onDraftSaved?: (saved?: { type: string; name: string }) => void;
type: string;
name: string;
}) => {
const { ensure, ...facts } = customization ?? {};
return (
<div data-testid="form-editor" data-customization={customization ? JSON.stringify(facts) : 'null'}>
<span data-testid="form-editor-door">{typeof ensure}</span>
<button type="button" onClick={() => onDraftSaved?.({ type, name })}>
save a draft
</button>
</div>
);
},
}));

import { StudioDesignSurface } from './StudioDesignSurface';
import { t } from '../metadata-admin/i18n';

Expand All @@ -90,6 +118,25 @@ function servePackages() {
return fetchMock;
}

/** objectstack#28569 — the list with an installed, read-only base that declares its version. */
const BASE_ID = 'com.vendor.hotcrm';
function serveWithBase() {
const fetchMock = vi.fn(async (input: unknown) => {
if (String(input) !== '/api/v1/packages') return answer([]);
return answer({
success: true,
data: {
packages: [
{ manifest: { id: PACKAGE_ID, name: 'Field Ops' }, writable: true },
{ manifest: { id: BASE_ID, name: 'HotCRM', version: '4.0.0', scope: 'installed' }, writable: false },
],
},
});
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}

function Where() {
const { pathname, search } = useLocation();
return <div data-testid="where">{`${pathname}${search}`}</div>;
Expand Down Expand Up @@ -170,7 +217,7 @@ describe('Studio: the "All metadata" area in the builder (objectstack#28561)', (

it('a kernel package the switcher hides opens its area read-only', async () => {
servePackages();
renderAt(`/studio/${KERNEL_ID}/metadata/translation/t1?package=${KERNEL_ID}`);
renderAt(`/studio/${KERNEL_ID}/metadata/agent/a1?package=${KERNEL_ID}`);
expect(await areaPage()).toEqual({
page: 'item',
host: { packageId: KERNEL_ID, readOnly: true, basePath: `/studio/${KERNEL_ID}/metadata` },
Expand All @@ -185,3 +232,31 @@ describe('Studio: the "All metadata" area in the builder (objectstack#28561)', (
expect(screen.queryByTestId('studio-metadata-area')).toBeNull();
});
});

describe('the area\'s form editor in the builder (objectstack#28569)', () => {
it('on a read-only base, the editor is handed the surface\'s customization: the base, its version and the door', async () => {
serveWithBase();
renderAt(`/studio/${BASE_ID}/metadata/email_template/welcome?package=${BASE_ID}&member=zh-CN`);
const editor = await screen.findByTestId('form-editor', undefined, { timeout: 8000 });
await waitFor(() => expect(editor.getAttribute('data-customization')).not.toBe('null'));
expect(JSON.parse(editor.getAttribute('data-customization') ?? 'null')).toEqual({
base: BASE_ID,
baseLabel: 'HotCRM',
basedOn: '4.0.0',
walled: false,
pairId: null,
});
expect(screen.getByTestId('form-editor-door')).toHaveTextContent('function');
});

it('on a writable package, the editor has no customization, and its saved draft counts in the header', async () => {
serveWithBase();
renderAt(`/studio/${PACKAGE_ID}/metadata/translation/ui_en?package=${PACKAGE_ID}`);
const editor = await screen.findByTestId('form-editor', undefined, { timeout: 8000 });
expect(editor).toHaveAttribute('data-customization', 'null');
expect(screen.queryByTestId('studio-changes-count')).toBeNull();
fireEvent.click(within(editor).getByRole('button', { name: 'save a draft' }));
// The header counts the draft the save wrote, before its own re-read answers.
expect(await screen.findByTestId('studio-changes-count')).toHaveTextContent('1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2682,7 +2682,17 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps):
) : tab === STUDIO_METADATA_AREA ? (
// objectstack#28561 — keyed by package like the pillars: every
// page under it holds that package's scope and gate.
<StudioMetadataArea key={packageId} packageId={packageId} />
// objectstack#28569 — its form editor customizes an email
// template or a translation of a read-only base with the
// Interfaces pillar's customization, and Publish and the
// pending count are this header's.
<StudioMetadataArea
key={packageId}
packageId={packageId}
customization={customization}
publishNonce={publishNonce}
onDraftSaved={onDraftSaved}
/>
) : !pillarKnown ? (
<StudioEmptyState
icon={FileQuestion}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,19 @@ vi.mock('../metadata-admin/index.js', () => ({
MetadataResourceHistoryPage: () => <PageStub page="history" />,
}));

/**
* objectstack#28569 — the form editor of an email template or a translation,
* a stub printing the host and the member it opens (the editor itself is
* pinned in `StudioBundleItemEditor.customize-28569.test.tsx`).
*/
vi.mock('./StudioBundleItemEditor.js', () => ({
StudioBundleItemEditor: ({ type, name, member, readOnly }: { type: string; name: string; member?: string; readOnly: boolean }) => (
<div data-testid="form-editor" data-item={JSON.stringify({ type, name, member: member ?? null, readOnly })}>
<PageStub page="form-editor" />
</div>
),
}));

/** `GET /api/v1/packages` as the listed manifests read it: one writable, one read-only, one kernel. */
let listed: Map<string, { id: string; name: string; writable: boolean; manifest: Record<string, unknown> }>;
let listFails = false;
Expand Down Expand Up @@ -127,7 +140,7 @@ describe('the area mounts the metadata pages (objectstack#28561)', () => {
});

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

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

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

Expand All @@ -211,3 +224,31 @@ describe('a pillar type opened from the area opens its pillar', () => {
expect(screen.queryByTestId('pillar')).toBeNull();
});
});

describe('an email template or a translation opens the form editor (objectstack#28569)', () => {
const item = () => JSON.parse(screen.getByTestId('form-editor').getAttribute('data-item') ?? 'null');

it('a translation of a writable package', async () => {
mount(`/studio/${WRITABLE}/metadata/translation/ui_en?package=${WRITABLE}`);
expect((await page()).page).toBe('form-editor');
expect(item()).toEqual({ type: 'translation', name: 'ui_en', member: null, readOnly: false });
});

it('an email template of a read-only package, at the locale the list row names', async () => {
mount(`/studio/${READ_ONLY}/metadata/email_template/welcome?package=${READ_ONLY}&member=zh-CN`);
expect((await page()).host.readOnly).toBe(true);
expect(item()).toEqual({ type: 'email_template', name: 'welcome', member: 'zh-CN', readOnly: true });
});

it('`?review=1` opens the generic page and its review', async () => {
mount(`/studio/${WRITABLE}/metadata/email_template/welcome?package=${WRITABLE}&review=1`);
expect((await page()).page).toBe('item');
expect(screen.queryByTestId('form-editor')).toBeNull();
});

it('the cross-package leg opens the generic page: it names no package to write', async () => {
mount('/studio/~all/metadata/translation/ui_en?package=all');
expect((await page()).page).toBe('item');
expect(screen.queryByTestId('form-editor')).toBeNull();
});
});
Loading
Loading