{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你好 {{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 new file mode 100644 index 00000000000..7eddb55327c --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.tsx @@ -0,0 +1,478 @@ +// 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 (`{translateMetadataType(type, locale)}
++ {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 ? ( +