From 928161cc9b2c2b9a3a95833ab6ec9dc07f47a227 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:33:49 +0000 Subject: [PATCH 1/3] feat(studio): edit email templates and translations in the All metadata area An email template or a translation opens a simplified form editor in a package's All metadata area: Setup's form and the type's preview, mounted. A writable package saves its draft; a read-only base customizes the item into its pair with N-17's controls, one email template locale at a time. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTJcKuqRSJyv696zZ9 --- ...tudio-email-template-translation-editor.md | 18 + .../studio-design/StudioBundleItemEditor.tsx | 476 ++++++++++++++++++ .../studio-design/StudioCustomizeControls.tsx | 25 +- .../studio-design/StudioDesignSurface.tsx | 12 +- .../studio-design/StudioMetadataArea.tsx | 82 ++- .../src/views/studio-design/metadataArea.ts | 25 + ui/packages/i18n/src/namespaces/studio/en.ts | 4 + ui/packages/i18n/src/namespaces/studio/zh.ts | 3 + 8 files changed, 625 insertions(+), 20 deletions(-) create mode 100644 .changeset/28569-studio-email-template-translation-editor.md create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx diff --git a/.changeset/28569-studio-email-template-translation-editor.md b/.changeset/28569-studio-email-template-translation-editor.md new file mode 100644 index 00000000000..f9230c8dd60 --- /dev/null +++ b/.changeset/28569-studio-email-template-translation-editor.md @@ -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 (`.custom`, ADR-0146 D2). + +- **The editor.** In a package's All metadata area (`/studio//metadata//`), 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. diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx new file mode 100644 index 00000000000..0f6fe2f020d --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -0,0 +1,476 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28569 (ADR-0146 Consequences, D1, D2) — the All metadata area's + * simplified form editor for an email template and a translation. + * + * Setup edits both on the generic item page. Here the area opens them in this + * editor instead (`metadataArea.ts#areaItemOpensFormEditor`): the fields the + * Setup form offers, beside the type's preview. Both are the Setup pieces, + * mounted, not copied: the form is `SchemaForm` over the schema the server + * serves for the type, else `default-schemas.ts`'s entry (the generic page's + * own resolution), and the preview is the one `previews/index.ts` registers + * for the type (`EmailTemplatePreview`, `TranslationPreview`). + * + * ## Where an edit is written + * + * - On a writable package: that package's draft, as the pillars save one + * (the shared autosave, the version pin of `useDraftSaveGuard`). + * - On a read-only base: nothing until Customize is pressed. The first saved + * edit asks the ensure door for the base's pair (`.custom`) and + * writes the pair's draft, declaring `overrides: { package, basedOn }` + * (`overrideDraftBody`). Publish is the Studio header's, on the pair's + * drafts. "Restore package original" deletes the pair's draft and row. + * These are N-17's controls (`StudioCustomizeControls.tsx`), reused. + * + * ## One member at a time + * + * An email template is one item per `(name, locale)`: the editor opens the + * member the list's row link names (`?member=`), reads, writes, declares and + * restores that member alone, and keeps the locale out of the form, so an + * edit never moves into another member. A translation is one locale bundle + * whose override replaces the whole bundle (ADR-0146 D2), and the editor says + * so before the first save. + */ + +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + cn, +} from '@object-ui/components'; +import { extractDraftBody } from '@object-ui/data-objectstack'; +import { History, Lock, MoreHorizontal } from 'lucide-react'; +import { SchemaForm } from '../metadata-admin/SchemaForm.js'; +import { resolveResourceConfig } from '../metadata-admin/registry.js'; +import { useRegisteredMetadataPreview } from '../metadata-admin/preview-registry.js'; +import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata.js'; +import { readDraftForSave, useDraftSaveGuard } from '../metadata-admin/DraftConflictDialog.js'; +import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; +import { plainRefusal, type StudioRefusal } from './metadataError.js'; +import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js'; +import { + StudioCustomizeButton, + StudioOverrideChip, + StudioRestoreOriginalDialog, + StudioRestoreOriginalItem, + overrideDraftBody, + useServedOverride, + type CustomizeBlock, +} from './StudioCustomizeControls.js'; +import type { StudioBaseCustomization } from './StudioDesignSurface.js'; + +/** The keys the form never offers: the identity, and the declaration Customize writes. */ +function hiddenFieldsOf(type: string): string[] { + // An email template's locale is its member: an edit of it would write another member. + return type === 'email_template' ? ['name', 'locale', 'overrides'] : ['name', 'overrides']; +} + +/** `doc` without its top-level `_` keys: the read's decorations are not the item's. */ +function savedBody(doc: Record): Record { + const body: Record = {}; + for (const [key, value] of Object.entries(doc)) { + if (!key.startsWith('_')) body[key] = value; + } + return body; +} + +export interface StudioBundleItemEditorProps { + /** The package the area is open on. */ + packageId: string; + /** `email_template` or `translation` (`AREA_FORM_EDITOR_TYPES`). */ + type: string; + name: string; + /** The bundle member the URL names (`?member=`); absent for the canonical one and for a translation. */ + member?: string; + /** The package is not writable (the area's gate). */ + readOnly: boolean; + /** On a read-only base, what the Studio surface customizes it with; `null` otherwise. */ + customization?: StudioBaseCustomization | null; + /** Bumped by the header's Publish: the buffer re-reads what is served. */ + publishNonce?: number; + /** Told when a draft save lands, so the header's pending count follows it. */ + onDraftSaved?: (saved?: { type: string; name: string }) => void; + /** The area's root (`MetadataAreaHost.basePath`), for the item's history link. */ + basePath: string; +} + +export function StudioBundleItemEditor({ + packageId, + type, + name, + member, + readOnly, + customization = null, + publishNonce = 0, + onDraftSaved, + basePath, +}: StudioBundleItemEditorProps): React.ReactElement { + const locale = useMetadataLocale(); + const navigate = useNavigate(); + const client = useMetadataClient(); + const { entries } = useMetadataTypes(client); + const entry = entries.find((e) => e.type === type); + const config = resolveResourceConfig(type, entry); + // The generic page's resolution: the served schema first, the fallback second. + const schema = + (entry?.schema as Record | undefined) ?? + (config.defaultSchema as Record | undefined); + const Preview = useRegisteredMetadataPreview(type); + const memberScope = React.useMemo(() => (member ? { member } : {}), [member]); + const target = JSON.stringify([packageId, type, name, member ?? '']); + + // The customization as last handed down, read by the load and the saves, so + // the pair the ensure door answers mid-edit never reloads the buffer. + const customizationRef = React.useRef(customization); + React.useLayoutEffect(() => { + customizationRef.current = customization; + }); + const customizationKnown = customization !== null; + + const [draft, setDraft] = React.useState>({}); + const [loadedFor, setLoadedFor] = React.useState(''); + const [hasDraft, setHasDraft] = React.useState(false); + const [dirty, setDirty] = React.useState(false); + const [saving, setSaving] = React.useState(false); + const [restoring, setRestoring] = React.useState(false); + const [error, setError] = React.useState(null); + const [loadError, setLoadError] = React.useState(null); + const [reloadNonce, setReloadNonce] = React.useState(0); + const reload = React.useCallback(() => setReloadNonce((n) => n + 1), []); + const guard = useDraftSaveGuard(client, reload); + // Stable for the editor's life: the guard's own members. + const { recordRead, forget } = guard; + // The package the buffer's draft read was asked at: the address its pin describes. + const readAtRef = React.useRef(null); + + React.useEffect(() => { + let cancelled = false; + setLoadError(null); + (async () => { + const custom = readOnly ? customizationRef.current : null; + // A read-only base's draft lives in its pair, when one is listed. + const readAt = readOnly ? (custom?.pairId ?? null) : packageId; + const draftRead = readAt + ? await readDraftForSave>(client, type, name, readAt, member) + : { answer: null, pin: null }; + if (cancelled) return; + const body = extractDraftBody(draftRead.answer) as Record | null; + // The served document only when there is no pending draft: a spread + // over it would bring back every key the draft deleted. + const lay = body + ? null + : await client.layered>(type, name, { packageId, ...memberScope }); + if (cancelled) return; + const baseline = (lay?.effective ?? lay?.code ?? {}) as Record; + setDraft(body ?? baseline); + setLoadedFor(target); + setHasDraft(!!body); + setDirty(false); + if (readAt) recordRead(type, name, readAt, draftRead.pin); + else forget(); + readAtRef.current = readAt; + })().catch((e: unknown) => { + if (!cancelled) setLoadError(plainRefusal(e)); + }); + return () => { + cancelled = true; + }; + }, [ + client, + type, + name, + member, + memberScope, + packageId, + readOnly, + target, + customizationKnown, + publishNonce, + reloadNonce, + recordRead, + forget, + ]); + + // Customize (N-17): the served declaration, the button's block and its press. + const [servedNonce, setServedNonce] = React.useState(0); + const base = customization?.base ?? null; + const served = useServedOverride( + client, + customization ? { type, name, ...memberScope } : null, + base, + customization?.pairId ?? null, + `${publishNonce}:${servedNonce}`, + ); + const inPair = served.declaration !== null; + const inPairRef = React.useRef(inPair); + React.useLayoutEffect(() => { + inPairRef.current = inPair; + }); + const [asked, setAsked] = React.useState(false); + const block: CustomizeBlock = customization?.walled + ? 'walled' + : customization && !inPair && customization.basedOn === null + ? 'noVersion' + : null; + const unlocked = !!customization && !customization.walled && (inPair || (block === null && asked)); + const editable = loadedFor === target && !loadError && (readOnly ? unlocked : true); + + // The ensure door, asked once for this item's first saved edit, and again only after a refusal. + const pairRef = React.useRef | null>(null); + const pairFor = React.useCallback((custom: StudioBaseCustomization): Promise => { + if (pairRef.current) return pairRef.current; + const asking = custom.ensure(); + pairRef.current = asking; + asking.catch(() => { + if (pairRef.current === asking) pairRef.current = null; + }); + return asking; + }, []); + + const doSave = React.useCallback( + async (sent: DraftSend) => { + setSaving(true); + setError(null); + try { + let at = packageId; + let body = savedBody(draft); + let entering = false; + if (readOnly) { + const custom = customizationRef.current; + if (!custom) return; + let pair: string; + try { + pair = await pairFor(custom); + } catch (e) { + setError(plainRefusal(e)); + return; + } + const declared = overrideDraftBody(draft, custom.base, custom.basedOn); + if (declared === null) { + setError({ message: tFormat('engine.studio.customize.noVersion', locale, { package: custom.baseLabel }) }); + return; + } + // Read at another package (the base, before its pair was listed): + // the pair is taken to hold no draft of this member, so one there is + // a conflict the author is asked about, never overwritten. + if (readAtRef.current !== pair) { + recordRead(type, name, pair, { ifNoneMatch: '*' }); + readAtRef.current = pair; + } + entering = !inPairRef.current; + at = pair; + body = declared; + } + const outcome = await guard.save(type, name, body, { mode: 'draft', packageId: at, surface: 'studio' }); + if (outcome === 'reloaded') return; + // The badge reads the declaration the server now serves. + if (entering) setServedNonce((n) => n + 1); + setHasDraft(true); + if (sent.unmoved()) setDirty(false); + onDraftSaved?.({ type, name }); + } catch (e) { + setError(plainRefusal(e)); + } finally { + setSaving(false); + } + }, + [draft, guard, locale, name, onDraftSaved, packageId, pairFor, readOnly, recordRead, type], + ); + useDraftAutoSave({ + target, + loadedFor, + dirty, + blocked: !editable || restoring, + snapshot: draft, + save: doSave, + }); + + const [restoreOpen, setRestoreOpen] = React.useState(false); + const label = (typeof draft.label === 'string' && draft.label) || name; + const restoreOriginal = React.useCallback(async () => { + const custom = customizationRef.current; + if (!custom) return; + const held = pairRef.current ? await pairRef.current.catch(() => null) : null; + const pair = held ?? custom.pairId; + if (!pair) return; + setRestoring(true); + setError(null); + try { + await client.reset(type, name, { state: 'draft', packageId: pair, ...memberScope }); + await client.reset(type, name, { packageId: pair, ...memberScope }); + pairRef.current = null; + setAsked(false); + setDirty(false); + setServedNonce((n) => n + 1); + reload(); + emitMetadataRefresh(); + toast.success(tFormat('engine.studio.customize.restoreDone', locale, { item: label, package: custom.baseLabel })); + } catch (e) { + setError(plainRefusal(e)); + } finally { + setRestoring(false); + } + }, [client, label, locale, memberScope, name, reload, type]); + + const onChange = React.useCallback((next: Record) => { + setDraft(next); + setDirty(true); + }, []); + + const baseLabel = customization?.baseLabel ?? packageId; + // The member the URL names, else the one the canonical read served. + const shownLocale = member ?? (typeof draft.locale === 'string' ? draft.locale : ''); + const customizing = readOnly && customization !== null; + const status = saving + ? t('engine.studio.autoSaving', locale) + : hasDraft + ? t('engine.studio.unpublishedDraft', locale) + : null; + + return ( +
+
+
+

{translateMetadataType(type, locale)}

+

+ {label} +

+
+ {type === 'email_template' && shownLocale && ( + + {tFormat('engine.studio.area.formEditor.locale', locale, { locale: shownLocale })} + + )} + {customizing && served.declaration && ( + + )} + {status && ( + + {status} + + )} + {customizing && !unlocked && ( + setAsked(true)} /> + )} + {readOnly && !customizing && ( + + + )} + + + + + + { + const qs = new URLSearchParams({ package: packageId, ...memberScope }).toString(); + navigate(`${basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history?${qs}`); + }} + > + + {customizing && inPair && ( + setRestoreOpen(true)} + /> + )} + + +
+ + {customizing && !inPair && type === 'translation' && ( +

+ {tFormat('engine.studio.area.formEditor.translationWhole', locale, { + package: baseLabel, + locale: shownLocale || name, + })} +

+ )} + {customizing && !inPair && type === 'email_template' && ( +

+ {tFormat('engine.studio.area.formEditor.oneLocale', locale, { + package: baseLabel, + locale: shownLocale, + })} +

+ )} + {customizing && unlocked && ( +

{t('engine.studio.customize.draftFooter', locale)}

+ )} + {(loadError ?? error) && ( +

+ {(loadError ?? error)?.message} +

+ )} + + {loadedFor !== target && !loadError ? ( +
+ {t('engine.studio.loading', locale)} +
+ ) : ( +
+
+ +
+ {Preview && ( +
+ +
+ )} +
+ )} + + {guard.dialog} + {customizing && ( + void restoreOriginal()} + /> + )} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx index 759799ba270..49a3510b0c5 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx @@ -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']; @@ -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, - 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 => { 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) => { @@ -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; diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 0d9d1637397..8d93570b2b1 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -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. - + // 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. + ) : !pillarKnown ? ( /_diagnostics diagnostics * /:type a type's list * /:type/new create (a writable package only) - * /:type/:name the generic item page, or the item's pillar + * /:type/:name the generic item page, the item's pillar, or + * the form editor of an email template or a + * translation (`StudioBundleItemEditor`, #28569) * /:type/:name/history the item's history * * Mirroring Setup's shapes keeps every relative link the pages emit pointing @@ -39,10 +41,14 @@ import { t, useMetadataLocale } from '../metadata-admin/i18n.js'; import { fetchListedPackageManifests } from './packages-io.js'; import { STUDIO_ALL_PACKAGES_SEGMENT, + areaItemOpensFormEditor, areaItemPillarPath, areaLocationRedirect, studioMetadataAreaPath, } from './metadataArea.js'; +import { StudioBundleItemEditor } from './StudioBundleItemEditor.js'; +import { BUNDLE_MEMBER_PARAM } from '../metadata-admin/registry.js'; +import type { StudioBaseCustomization } from './StudioDesignSurface.js'; /* * The pages load with the area, through the specifier `AppContent` loads them @@ -107,28 +113,64 @@ function AreaLoading({ locale }: { locale: string }): React.ReactElement { ); } +/** What the Studio surface hands the area's form editor (`StudioBundleItemEditor`). */ +interface AreaEditorProps { + customization: StudioBaseCustomization | null; + publishNonce: number; + onDraftSaved?: (saved?: { type: string; name: string }) => void; +} + /** - * `/:type/:name`: a pillar type opens its pillar, every other type the - * generic item page (`areaItemPillarPath` lists when). + * `/:type/:name`: a pillar type opens its pillar, an email template or a + * translation the form editor (`areaItemOpensFormEditor`), every other type + * the generic item page (`areaItemPillarPath` lists when). * * Decided once per item, when it opens (the item is the key below): the item * page itself rewrites the query it opened with (it drops `?review=1` once the * review sheet is open), and that must not send an open review on to the * pillar. */ -function AreaItem(): React.ReactElement { +function AreaItem({ editor }: { editor: AreaEditorProps }): React.ReactElement { const { type = '', name = '' } = useParams<{ type: string; name: string }>(); + const [search] = useSearchParams(); + // One editor buffer per member: two locales of one template are two items. + const member = search.get(BUNDLE_MEMBER_PARAM) ?? ''; // A type never holds a `/`, so this names one item. - return ; + return ; } -function AreaItemOpen({ type, name }: { type: string; name: string }): React.ReactElement { +function AreaItemOpen({ + type, + name, + editor, +}: { + type: string; + name: string; + editor: AreaEditorProps; +}): React.ReactElement { const [search] = useSearchParams(); const host = useMetadataAreaHost(); - const [pillarPath] = React.useState(() => - areaItemPillarPath({ packageId: host?.packageId ?? null, type, name, search }), - ); - if (pillarPath) return ; + const [opens] = React.useState(() => { + const at = { packageId: host?.packageId ?? null, type, search }; + if (areaItemOpensFormEditor(at)) return { formEditor: true, pillarPath: null }; + return { formEditor: false, pillarPath: areaItemPillarPath({ ...at, name }) }; + }); + if (opens.pillarPath) return ; + if (opens.formEditor && host?.packageId) { + return ( + + ); + } return ; } @@ -146,9 +188,24 @@ function AreaCreate(): React.ReactElement { export interface StudioMetadataAreaProps { /** The package the area is open on, or `null` for the cross-package leg. */ packageId: string | null; + /** + * objectstack#28569 — on a read-only base, what the surface customizes it + * with (the Interfaces pillar's `StudioBaseCustomization`): the form editor + * offers Customize from it. `null` otherwise. + */ + customization?: StudioBaseCustomization | null; + /** Bumped by the header's Publish: the form editor re-reads what is served. */ + publishNonce?: number; + /** Told when the form editor saves a draft, so the header's pending count follows it. */ + onDraftSaved?: (saved?: { type: string; name: string }) => void; } -export function StudioMetadataArea({ packageId }: StudioMetadataAreaProps): React.ReactElement { +export function StudioMetadataArea({ + packageId, + customization = null, + publishNonce = 0, + onDraftSaved, +}: StudioMetadataAreaProps): React.ReactElement { const { pathname, search } = useLocation(); const locale = useMetadataLocale(); const segment = packageId ?? STUDIO_ALL_PACKAGES_SEGMENT; @@ -162,6 +219,7 @@ export function StudioMetadataArea({ packageId }: StudioMetadataAreaProps): Reac () => ({ packageId, readOnly: packageId !== null && writable === false, basePath }), [packageId, writable, basePath], ); + const editor: AreaEditorProps = { customization, publishNonce, onDraftSaved }; // The query names the leg (`areaLocationRedirect`). Settled before any page // mounts, so no page reads a scope the URL is about to change. @@ -181,7 +239,7 @@ export function StudioMetadataArea({ packageId }: StudioMetadataAreaProps): Reac } /> } /> } /> - } /> + } /> } /> diff --git a/ui/packages/app-shell/src/views/studio-design/metadataArea.ts b/ui/packages/app-shell/src/views/studio-design/metadataArea.ts index f60ea6cf4c6..fa15b9e2963 100644 --- a/ui/packages/app-shell/src/views/studio-design/metadataArea.ts +++ b/ui/packages/app-shell/src/views/studio-design/metadataArea.ts @@ -107,6 +107,31 @@ export function areaItemPillarPath(input: { return studioPillarItemPath(input.packageId, pillar, { type: input.type, name: input.name }); } +/** + * objectstack#28569 (ADR-0146 Consequences) — the types whose item opens the + * area's simplified form editor (`StudioBundleItemEditor`): the fields Setup's + * form offers beside the type's preview, and Customize on a read-only base. + * Both are `overridable` in the spec's classification. + */ +export const AREA_FORM_EDITOR_TYPES: readonly string[] = ['email_template', 'translation']; + +/** + * Whether an item of the area opens the form editor rather than the generic + * item page: a form-editor type on a package's leg, under the same exceptions + * as {@link areaItemPillarPath} (the cross-package leg and the environment + * scope name no package to write, and `?review=1` opens the generic page's + * draft review). + */ +export function areaItemOpensFormEditor(input: { + packageId: string | null; + type: string; + search: URLSearchParams; +}): boolean { + if (!AREA_FORM_EDITOR_TYPES.includes(input.type) || !input.packageId) return false; + if (input.search.get(CATALOG_SCOPE_PARAM) === ENVIRONMENT_SCOPE) return false; + return input.search.get('review') !== '1'; +} + /** `?package=all` is the metadata list's "every package" scope, not a package (`ResourceEditPage`'s `'all'` fold). */ const ALL_PACKAGES_VALUE = 'all'; diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 6eea83632a1..b16ba4624a3 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3255,6 +3255,10 @@ const STUDIO_EN: Record = { 'engine.studio.area.assignments.position': 'The people who hold this position are assigned in Setup.', 'engine.studio.area.assignments.permission': 'The people this permission set is assigned to are managed in Setup.', 'engine.studio.area.assignments.open': 'Open in Setup', + // objectstack#28569 — the area's form editor of an email template or a translation. + 'engine.studio.area.formEditor.locale': 'Locale {locale}', + 'engine.studio.area.formEditor.oneLocale': 'Only the {locale} version of this template is customized. {package}’s other languages of it stay as they are.', + 'engine.studio.area.formEditor.translationWhole': 'Customizing a translation replaces {package}’s whole {locale} bundle: every string in it is then served from your copy, the ones you leave unchanged included.', 'engine.studio.publishedAllFlows': 'Published {count} flows', 'engine.studio.publishedAllFlowsOne': 'Published flow "{label}"', 'engine.studio.designer.search': 'Search…', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 825c433d9b7..6d2a2faaf28 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2880,6 +2880,9 @@ const STUDIO_ZH: Record = { 'engine.studio.area.assignments.position': '此岗位的任职人员在设置中分配。', 'engine.studio.area.assignments.permission': '此权限集分配给哪些人,在设置中管理。', 'engine.studio.area.assignments.open': '在设置中打开', + 'engine.studio.area.formEditor.locale': '语言 {locale}', + 'engine.studio.area.formEditor.oneLocale': '只自定义此模板的 {locale} 版本,{package} 中它的其他语言版本保持不变。', + 'engine.studio.area.formEditor.translationWhole': '自定义翻译会替换 {package} 的整个 {locale} 语言包:其中每一条文本都改由你的副本提供,包括未修改的条目。', 'engine.studio.publishedAllFlows': '已发布 {count} 个流程', 'engine.studio.publishedAllFlowsOne': '已发布流程「{label}」', 'engine.studio.designer.search': '搜索…', From 1f6a135c3a0d445bd075da06328d4a3e65cc93a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:42:22 +0000 Subject: [PATCH 2/3] test(studio): pin the All metadata area's email template and translation editor The editor over a per-member server double and the real ensure door: a writable package's draft, a read-only base's pair draft for one template locale and one translation bundle, the other locale untouched, Restore and the walled caller. The area routes the two types to it, and the surface hands it the customization and its pending count. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTJcKuqRSJyv696zZ9 --- ...oBundleItemEditor.customize-28569.test.tsx | 426 ++++++++++++++++++ .../studio-design/StudioBundleItemEditor.tsx | 3 +- ...oDesignSurface.metadataArea-28561.test.tsx | 79 +++- .../StudioMetadataArea.routes-28561.test.tsx | 47 +- 4 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx new file mode 100644 index 00000000000..f39f919ee31 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx @@ -0,0 +1,426 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28569 (ADR-0146 Consequences, D2) — the All metadata area's form + * editor of an email template and a translation. + * + * Through the mounted editor, its real autosave and version pin, Setup's real + * form (`SchemaForm` over `default-schemas.ts`'s entries), the real Customize + * controls and the real ensure-door client (`ensureCustomizationPackage`) + * over a fetch stub. The metadata client is a server double that keys every + * row by `(type, name, member)`, as the server does since ruling 6116165299: + * an email template's member is its locale, a translation has none. The + * editor is handed what the Studio surface hands it (`StudioBaseCustomization`) + * by a host that takes the pair's id from the door's answer. + * + * Pinned: + * - a writable package: an edit writes that package's draft, no door, no declaration; + * - a read-only base: Customize and an edit write the pair's draft declaring + * the override, for one email template locale and for one translation + * bundle; the base is untouched; + * - the other locale of the same template is neither read for the badge nor + * written, and still serves the base's version; + * - the translation says it replaces the whole bundle before the first save; + * - Restore deletes the pair's draft and row of that one member, and the + * base's version reads back; + * - a walled caller sees Customize disabled, and nothing is asked or sent. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const BASE = 'com.example.hotcrm'; +const PAIR = 'com.example.hotcrm.custom'; +const WRITABLE = 'com.example.crm'; + +const srv = vi.hoisted(() => ({ + /** `type/name/member` → body, per package. */ + rows: new Map>>(), + drafts: new Map>>(), + pairExists: false, + ensure: [] as number[], + saves: [] as Array<{ type: string; name: string; body: Record; opts: Record }>, + resets: [] as Array<{ type: string; name: string; opts: Record }>, + reads: [] as Array<{ op: string; type: string; name: string; opts: Record }>, + version: 0, +})); + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +/** The canonical member when none is named, as the server reads a bare name. */ +const slot = (type: string, name: string, member?: string) => + `${type}/${name}/${type === 'email_template' ? (member ?? 'en-US') : ''}`; +const at = (map: Map>>, pkg: string) => { + if (!map.has(pkg)) map.set(pkg, new Map()); + return map.get(pkg)!; +}; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + getDraft: vi.fn(async (type: string, name: string, opts: { packageId?: string; member?: string } = {}) => { + srv.reads.push({ op: 'getDraft', type, name, opts: clone(opts) }); + const draft = srv.drafts.get(opts.packageId ?? '')?.get(slot(type, name, opts.member)); + return draft ? { type, name, item: clone(draft), version: `v${srv.version}` } : null; + }), + layered: vi.fn(async (type: string, name: string, opts: { packageId?: string; member?: string } = {}) => { + srv.reads.push({ op: 'layered', type, name, opts: clone(opts) }); + const key = slot(type, name, opts.member); + const code = srv.rows.get(opts.packageId ?? '')?.get(key) ?? null; + // N-10: the pair's declaring row is served over the base's item, member by member. + const overlay = opts.packageId === BASE ? (srv.rows.get(PAIR)?.get(key) ?? null) : null; + if (!code && !overlay) throw Object.assign(new Error('Not found'), { status: 404 }); + return { + code: code ? clone(code) : null, + overlay: overlay ? clone(overlay) : null, + overlayScope: overlay ? 'env' : null, + effective: clone(overlay ?? code), + }; + }), + save: vi.fn(async (type: string, name: string, item: unknown, opts: Record = {}) => { + srv.saves.push({ type, name, body: clone(item) as Record, opts: clone(opts) }); + const pkg = String(opts.packageId ?? ''); + if (pkg !== PAIR && pkg !== WRITABLE) { + throw Object.assign(new Error(`Package '${pkg}' is read-only.`), { status: 403 }); + } + const body = item as Record; + at(srv.drafts, pkg).set(slot(type, name, body.locale as string | undefined), clone(body)); + srv.version += 1; + return { success: true, version: `v${srv.version}`, state: 'draft' }; + }), + reset: vi.fn(async (type: string, name: string, opts: Record = {}) => { + srv.resets.push({ type, name, opts: clone(opts) }); + const map = opts.state === 'draft' ? srv.drafts : srv.rows; + map.get(String(opts.packageId ?? ''))?.delete(slot(type, name, opts.member as string | undefined)); + return { success: true }; + }), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + // No served schema: the form is `default-schemas.ts`'s, as against a server that serves none. + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); + +import { StudioBundleItemEditor } from './StudioBundleItemEditor'; +import type { StudioBaseCustomization } from './StudioDesignSurface'; +import { ensureCustomizationPackage } from './packages-io'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerDefaultMetadataSchemas } from '../metadata-admin/default-schemas'; + +/** The preview slot, printing the live draft it is handed. */ +function PreviewProbe({ draft }: { draft: Record }) { + return

{String(draft.subject ?? draft.label ?? '')}

; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +const WELCOME_EN = { + name: 'welcome', + label: 'Welcome', + locale: 'en-US', + subject: 'Welcome to HotCRM', + bodyHtml: '

Hello {{user.name}}

', + _packageId: BASE, +}; +const WELCOME_ZH = { ...WELCOME_EN, locale: 'zh-CN', subject: '欢迎使用 HotCRM', bodyHtml: '

你好 {{user.name}}

' }; +const BUNDLE_ZH = { + name: 'hotcrm_zh', + label: 'HotCRM zh-CN', + locale: 'zh-CN', + strings: { greeting: '你好', farewell: '再见' }, + _packageId: BASE, +}; + +beforeAll(() => { + registerDefaultMetadataSchemas(); + registerMetadataPreview('email_template', PreviewProbe as never); + registerMetadataPreview('translation', PreviewProbe as never); +}); + +beforeEach(() => { + srv.rows.clear(); + srv.drafts.clear(); + at(srv.rows, BASE).set(slot('email_template', 'welcome', 'en-US'), clone(WELCOME_EN)); + at(srv.rows, BASE).set(slot('email_template', 'welcome', 'zh-CN'), clone(WELCOME_ZH)); + at(srv.rows, BASE).set(slot('translation', 'hotcrm_zh'), clone(BUNDLE_ZH)); + at(srv.rows, WRITABLE).set(slot('email_template', 'welcome', 'zh-CN'), { ...clone(WELCOME_ZH), _packageId: WRITABLE }); + srv.pairExists = false; + srv.ensure.length = 0; + srv.saves.length = 0; + srv.resets.length = 0; + srv.reads.length = 0; + srv.version = 0; + for (const fn of Object.values(mockClient)) fn.mockClear(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: RequestInit) => { + const path = new URL(String(input), 'http://localhost').pathname; + if (path === `/api/v1/packages/${BASE}/ensure-customization` && init?.method === 'POST') { + const created = !srv.pairExists; + srv.pairExists = true; + srv.ensure.push(created ? 201 : 200); + return json( + { + success: true, + data: { basePackageId: BASE, packageId: PAIR, namespace: 'hotcrm_custom', dependencyRange: '^4', created, writable: true }, + }, + created ? 201 : 200, + ); + } + return json([]); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** What the Studio surface hands the editor on a read-only base: the pair's id comes from the door's answer. */ +function Host({ + type, + name, + member, + packageId = BASE, + walled = false, + listedPair = null, +}: { + type: string; + name: string; + member?: string; + packageId?: string; + walled?: boolean; + listedPair?: string | null; +}) { + const readOnly = packageId === BASE; + const [pairId, setPairId] = React.useState(listedPair); + const ensure = React.useCallback(async () => { + const answer = await ensureCustomizationPackage(BASE); + setPairId(answer.packageId); + return answer.packageId; + }, []); + const customization = React.useMemo( + () => (readOnly ? { base: BASE, baseLabel: 'HotCRM', basedOn: '4.0.0', walled, pairId, ensure } : null), + [readOnly, walled, pairId, ensure], + ); + return ( + + ); +} + +function mount(props: React.ComponentProps) { + return render( + + + , + ); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** A form field by its label; a required one carries a mark after its title. */ +const labelled = (title: string) => new RegExp(`^${title}\\b`); +async function field(title: string): Promise { + return (await screen.findByLabelText(labelled(title), undefined, { timeout: 8000 })) as HTMLInputElement; +} + +async function customize(): Promise { + fireEvent.click(await screen.findByTestId('studio-customize', undefined, { timeout: 8000 })); + await waitFor(() => expect(screen.queryByTestId('studio-customize')).toBeNull()); +} + +async function restore(): Promise { + fireEvent.pointerDown(await screen.findByTestId('studio-bundle-editor-more'), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByTestId('studio-restore-original')); + const dialog = await screen.findByTestId('studio-restore-original-dialog'); + fireEvent.click(within(dialog).getByTestId('studio-restore-original-confirm')); +} + +describe('a writable package (objectstack#28569)', () => { + it('an edit writes that package’s draft of the member, with no door and no declaration', async () => { + mount({ type: 'email_template', name: 'welcome', member: 'zh-CN', packageId: WRITABLE }); + const subject = await field('Subject'); + expect(subject).toHaveValue('欢迎使用 HotCRM'); + expect(subject).not.toHaveAttribute('readonly'); + // The member is the item: the form never offers it. + expect(screen.queryByLabelText(labelled('Locale'))).toBeNull(); + expect(screen.getByTestId('studio-bundle-editor-locale')).toHaveTextContent('zh-CN'); + expect(screen.queryByTestId('studio-customize')).toBeNull(); + + fireEvent.change(subject, { target: { value: '欢迎!' } }); + // The preview is handed the live draft. + expect(screen.getByTestId('preview-probe')).toHaveTextContent('欢迎!'); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 5000 }); + + const [save] = srv.saves; + expect(save).toMatchObject({ type: 'email_template', name: 'welcome' }); + expect(save.opts).toMatchObject({ mode: 'draft', packageId: WRITABLE, ifNoneMatch: '*' }); + expect(save.body).toMatchObject({ locale: 'zh-CN', subject: '欢迎!' }); + expect(save.body).not.toHaveProperty('overrides'); + expect(Object.keys(save.body).filter((key) => key.startsWith('_'))).toEqual([]); + expect(srv.ensure).toEqual([]); + expect(await screen.findByTestId('studio-bundle-editor-status')).toHaveTextContent('Unpublished draft'); + }); +}); + +describe('Customize a read-only base’s email template, one locale (objectstack#28569)', () => { + it('Customize unlocks the form and writes nothing; the first saved edit asks the door once and writes the pair’s draft of that locale', async () => { + mount({ type: 'email_template', name: 'welcome', member: 'zh-CN' }); + const subject = await field('Subject'); + expect(subject).toHaveAttribute('readonly'); + expect(screen.getByTestId('studio-bundle-editor-one-locale')).toHaveTextContent('zh-CN'); + + await customize(); + expect(await field('Subject')).not.toHaveAttribute('readonly'); + await sleep(2000); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + expect(screen.queryByTestId('studio-override-chip')).toBeNull(); + + fireEvent.change(await field('Subject'), { target: { value: '欢迎加入' } }); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 5000 }); + expect(srv.ensure).toEqual([201]); + const [save] = srv.saves; + expect(save.opts).toMatchObject({ mode: 'draft', packageId: PAIR, ifNoneMatch: '*' }); + expect(save.body).toEqual({ + name: 'welcome', + label: 'Welcome', + locale: 'zh-CN', + subject: '欢迎加入', + bodyHtml: '

你好 {{user.name}}

', + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + // The badge reads the declaration the server now serves for this member. + expect(await screen.findByTestId('studio-override-chip')).toHaveTextContent('Overrides HotCRM'); + expect(screen.queryByTestId('studio-bundle-editor-one-locale')).toBeNull(); + // The base is untouched. + expect(srv.rows.get(BASE)?.get(slot('email_template', 'welcome', 'zh-CN'))).toEqual(WELCOME_ZH); + + // The pair's draft is asked for this member, never the canonical one. + const pairDraftReads = srv.reads.filter((r) => r.op === 'getDraft' && r.opts.packageId === PAIR); + expect(pairDraftReads.length).toBeGreaterThan(0); + expect(pairDraftReads.every((r) => r.opts.member === 'zh-CN')).toBe(true); + }); + + it('the other locale of the same template is untouched: no badge, the base’s version, nothing written', async () => { + // zh-CN is customized and published in the pair; en-US is not. + at(srv.rows, PAIR).set(slot('email_template', 'welcome', 'zh-CN'), { + ...clone(WELCOME_ZH), + subject: '欢迎加入', + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + delete at(srv.rows, PAIR).get(slot('email_template', 'welcome', 'zh-CN'))!._packageId; + srv.pairExists = true; + + mount({ type: 'email_template', name: 'welcome', member: 'en-US', listedPair: PAIR }); + expect(await field('Subject')).toHaveValue('Welcome to HotCRM'); + await sleep(500); + expect(screen.queryByTestId('studio-override-chip')).toBeNull(); + expect(screen.getByTestId('studio-customize')).toBeEnabled(); + const memberReads = srv.reads.filter((r) => r.opts.packageId === BASE || r.opts.packageId === PAIR); + expect(memberReads.every((r) => r.opts.member === 'en-US')).toBe(true); + + cleanup(); + mount({ type: 'email_template', name: 'welcome', member: 'zh-CN', listedPair: PAIR }); + expect(await field('Subject')).toHaveValue('欢迎加入'); + expect(await screen.findByTestId('studio-override-chip')).toBeInTheDocument(); + expect(srv.saves).toEqual([]); + }); + + it('Restore deletes the pair’s draft and row of that locale alone, and the base’s version reads back', async () => { + at(srv.rows, PAIR).set(slot('email_template', 'welcome', 'zh-CN'), { + name: 'welcome', + label: 'Welcome', + locale: 'zh-CN', + subject: '欢迎加入', + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + at(srv.drafts, PAIR).set(slot('email_template', 'welcome', 'zh-CN'), { + name: 'welcome', + label: 'Welcome', + locale: 'zh-CN', + subject: '欢迎回来', + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + srv.pairExists = true; + + mount({ type: 'email_template', name: 'welcome', member: 'zh-CN', listedPair: PAIR }); + expect(await field('Subject')).toHaveValue('欢迎回来'); + expect(await screen.findByTestId('studio-override-chip')).toBeInTheDocument(); + + await restore(); + await waitFor(() => expect(srv.resets).toHaveLength(2)); + expect(srv.resets).toEqual([ + { type: 'email_template', name: 'welcome', opts: { state: 'draft', packageId: PAIR, member: 'zh-CN' } }, + { type: 'email_template', name: 'welcome', opts: { packageId: PAIR, member: 'zh-CN' } }, + ]); + await waitFor(() => expect(screen.getByLabelText(labelled('Subject'))).toHaveValue('欢迎使用 HotCRM')); + await waitFor(() => expect(screen.queryByTestId('studio-override-chip')).toBeNull()); + expect(screen.getByTestId('studio-customize')).toBeInTheDocument(); + expect(srv.saves).toEqual([]); + }); + + it('a walled caller sees Customize disabled with its reason, and nothing is asked or sent', async () => { + mount({ type: 'email_template', name: 'welcome', member: 'zh-CN', walled: true }); + expect(await field('Subject')).toHaveAttribute('readonly'); + const blocked = await screen.findByTestId('studio-customize-blocked'); + expect(blocked).toHaveAttribute('data-block', 'walled'); + expect(within(blocked).getByTestId('studio-customize')).toBeDisabled(); + fireEvent.click(within(blocked).getByTestId('studio-customize')); + await sleep(2000); + expect(screen.getByLabelText(labelled('Subject'))).toHaveAttribute('readonly'); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + }); +}); + +describe('Customize a read-only base’s translation bundle (objectstack#28569)', () => { + it('says the bundle is replaced whole before the first save, then writes the whole bundle into the pair, declared', async () => { + mount({ type: 'translation', name: 'hotcrm_zh' }); + const label = await field('Label'); + expect(label).toHaveValue('HotCRM zh-CN'); + expect(screen.getByTestId('studio-bundle-editor-whole-bundle')).toHaveTextContent( + 'Customizing a translation replaces HotCRM’s whole zh-CN bundle', + ); + + await customize(); + expect(screen.getByTestId('studio-bundle-editor-whole-bundle')).toBeInTheDocument(); + fireEvent.change(await field('Label'), { target: { value: 'HotCRM 中文' } }); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 5000 }); + expect(srv.ensure).toEqual([201]); + const [save] = srv.saves; + expect(save.opts).toMatchObject({ mode: 'draft', packageId: PAIR }); + // The whole bundle, every string included, as one replacement. + expect(save.body).toEqual({ + name: 'hotcrm_zh', + label: 'HotCRM 中文', + locale: 'zh-CN', + strings: { greeting: '你好', farewell: '再见' }, + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + // A translation has no member: no read or write names one. + expect(srv.reads.every((r) => !('member' in r.opts))).toBe(true); + expect(await screen.findByTestId('studio-override-chip')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByTestId('studio-bundle-editor-whole-bundle')).toBeNull()); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx index 0f6fe2f020d..724646ef718 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -386,7 +386,8 @@ export function StudioBundleItemEditor({ { - const qs = new URLSearchParams({ package: packageId, ...memberScope }).toString(); + const qs = new URLSearchParams({ package: packageId }); + if (member) qs.set('member', member); navigate(`${basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history?${qs}`); }} > diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx index 8a3e3740f14..619f7e93260 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx @@ -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'; @@ -67,6 +67,34 @@ vi.mock('../metadata-admin/index.js', () => ({ MetadataResourceHistoryPage: () => , })); +/** + * 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 ( +
+ {typeof ensure} + +
+ ); + }, +})); + import { StudioDesignSurface } from './StudioDesignSurface'; import { t } from '../metadata-admin/i18n'; @@ -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
{`${pathname}${search}`}
; @@ -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` }, @@ -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'); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx index c75018e538d..6a2a52fafa7 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx @@ -48,6 +48,19 @@ vi.mock('../metadata-admin/index.js', () => ({ MetadataResourceHistoryPage: () => , })); +/** + * 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 }) => ( +
+ +
+ ), +})); + /** `GET /api/v1/packages` as the listed manifests read it: one writable, one read-only, one kernel. */ let listed: Map }>; let listFails = false; @@ -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); }); @@ -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'); }); @@ -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(); + }); +}); From 37edd173a61b4aeb8676d75c8c7a51695988a686 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:00:31 +0000 Subject: [PATCH 3/3] chore(studio): mark the registered preview as a stable component for the hooks lint Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTJcKuqRSJyv696zZ9 --- .../app-shell/src/views/studio-design/StudioBundleItemEditor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx index 724646ef718..7eddb55327c 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -454,6 +454,7 @@ export function StudioBundleItemEditor({ {Preview && (
+ {/* eslint-disable-next-line react-hooks/static-components -- useRegisteredMetadataPreview returns a registered component (stable), not one created during render */}
)}