From eee39383fbe33b4282dc70f61365a810f6c5bb42 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 01:01:12 +0000 Subject: [PATCH] feat(app-shell): Studio customizes an installed package's page into its customization package Studio's Customize door now covers page: on a read-only base a page opened in the Interfaces pillar offers Customize, the first designer edit copies the whole page into the base's .custom pair declaring overrides { package, basedOn }, and Restore package original deletes the copy. Marks, the only-customizations count and the All metadata page tile cover it through the existing type-agnostic paths. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzhKo5SDER9fGbQQqSa --- .changeset/30063-studio-customize-page.md | 17 ++ ...dataAreaHost.customizations-28568.test.tsx | 29 ++- .../InterfacesPillar.customize-28562.test.tsx | 179 ++++++++++++++++++ ...CustomizeControls.customize-28562.test.tsx | 5 +- .../studio-design/StudioCustomizeControls.tsx | 14 +- 5 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 .changeset/30063-studio-customize-page.md diff --git a/.changeset/30063-studio-customize-page.md b/.changeset/30063-studio-customize-page.md new file mode 100644 index 00000000000..a924ca6e519 --- /dev/null +++ b/.changeset/30063-studio-customize-page.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +--- + +feat(app-shell): Studio customizes an installed package's page into the base's customization package (#30063) + +Clause-②: no +Metadata-protocol: none + +On a read-only (installed or code-shipped) package, a page opened in the Interfaces pillar now offers Customize, the same button a dashboard, report or view shows. Before, Studio showed the page as read-only with no way to change it, although the server already serves a page override from the paired customization package (`.custom`). + +- Customize unlocks the page designer. Nothing is written until an edit is saved. The first saved edit copies the whole page into the pair, declaring `overrides: { package: , basedOn: }`. Later edits update that copy. The base package is never written. +- The edit is a draft in the pair's pending changes; once published, the running app serves the customized page at its usual address. +- The page's header carries the override mark, whose popover offers "Restore package original": it deletes the pair's draft and copy of the page after a confirmation, and the shipped page reads back. +- The Interfaces rail marks a customized page, "only customizations" counts and lists it, and the All metadata directory's page tile counts it. + +A customized page is a whole copy: a change the vendor ships to that page in a later version does not appear until the customization is restored and redone. A writable package's page editing is unchanged. No spec, server or metadata-type change. diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx index 1d342770c7f..2dfe3a3cc2f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx @@ -18,7 +18,8 @@ * - the directory's object tile counts the extension, and the header the base's total; * - a failed read draws no mark, no filter and no number, with the server's sentence; * - a base without a pair is unchanged, and Setup (no area) never reads; - * - in the cross-package leg an item of a pair is labelled with its base. + * - in the cross-package leg an item of a pair is labelled with its base; + * - objectstack#30063 — the page tile counts a page's override. */ import '@testing-library/jest-dom/vitest'; @@ -69,6 +70,7 @@ const ENTRIES = [ entry('translation', 'ui'), entry('email_template', 'system'), entry('permission', 'security'), + entry('page', 'ui'), ]; vi.mock('./useMetadata', async (importOriginal) => ({ @@ -81,7 +83,7 @@ vi.mock('./useMetadata', async (importOriginal) => ({ summary: { entries: [], total: 0, scannedTypes: 0, scannedItems: 0 }, byType: {}, warnByType: {}, - countsByType: { object: 3, translation: 2, email_template: 1 }, + countsByType: { object: 3, translation: 2, email_template: 1, page: 1 }, lockedByType: {}, packagesByType: Object.fromEntries(ENTRIES.map((e) => [e.type, [BASE, PLAIN]])), allPackages: [BASE, PLAIN], @@ -119,6 +121,13 @@ const HOTCRM_READ = { const EMPTY_READ = { packageId: PLAIN, overrides: [], extensions: [], switchedOff: [], contributions: [], counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 } }; let customizationsFail = false; +/** objectstack#30063 — the read also lists a page's override. */ +let readListsPage = false; +const withPage = (read: typeof HOTCRM_READ) => ({ + ...read, + overrides: [...read.overrides, { type: 'page', name: 'crm_welcome', basedOn: '2.1.0' }], + counts: { ...read.counts, overrides: read.counts.overrides + 1 }, +}); const fetchMock = vi.fn(async (url: string) => { const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); @@ -131,7 +140,8 @@ const fetchMock = vi.fn(async (url: string) => { if (customizationsFail) { return json(503, { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The customization ledger is unavailable.' } }); } - return json(200, { success: true, data: decodeURIComponent(m[1]) === BASE ? HOTCRM_READ : EMPTY_READ }); + const hotcrm = readListsPage ? withPage(HOTCRM_READ) : HOTCRM_READ; + return json(200, { success: true, data: decodeURIComponent(m[1]) === BASE ? hotcrm : EMPTY_READ }); } if (u.startsWith('/api/v1/data/sys_metadata_activation')) { return json(200, { object: 'sys_metadata_activation', records: [], hasMore: false }); @@ -141,6 +151,7 @@ const fetchMock = vi.fn(async (url: string) => { beforeEach(() => { customizationsFail = false; + readListsPage = false; fetchMock.mockClear(); vi.stubGlobal('fetch', fetchMock); }); @@ -265,6 +276,18 @@ describe('the All metadata directory counts a base\'s customizations (objectstac expect(screen.queryByTestId('directory-tile-customized-permission')).toBeNull(); }); + it('objectstack#30063 — the page tile counts a page\'s override, and the header the total with it', async () => { + readListsPage = true; + mountDirectory(area(BASE)); + const total = await screen.findByTestId('directory-customizations-total'); + expect(total).toHaveTextContent( + tFormat('engine.directory.customizations.total', 'en-US', { customized: 4, baseName: 'HotCRM' }), + ); + expect(screen.getByTestId('directory-tile-customized-page')).toHaveTextContent( + tFormat('engine.directory.customizations.tile', 'en-US', { customized: 1 }), + ); + }); + it('a failed read draws no number, never "0", and says the server\'s sentence', async () => { customizationsFail = true; mountDirectory(area(BASE)); diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx index 782820514a6..feae9f8ca1d 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx @@ -30,6 +30,11 @@ * editor: the first saved edit copies the whole app into the pair, declaring * the override; the pickers offer the pair's items; the rail marks the * override and counts it under "only customizations"; Restore deletes it. + * + * objectstack#30063 — and a page, through its designer as a dashboard is: + * the first saved edit copies the whole page into the pair, declaring the + * override; Restore deletes it; the rail marks the page and counts it under + * "only customizations"; on a writable package the page saves in place. */ import '@testing-library/jest-dom/vitest'; @@ -67,6 +72,15 @@ const BASE_ROWS: Record> = { 'app/crm_app': { ...APP, _packageId: BASE }, 'dashboard/pipeline': { name: 'pipeline', label: 'Pipeline', widgets: [], _packageId: BASE }, 'dashboard/forecast': { name: 'forecast', label: 'Forecast', widgets: [], _packageId: BASE }, + // objectstack#30063 — a page no navigation entry opens: the rail lists it under its type. + 'page/crm_welcome': { + name: 'crm_welcome', + label: 'Welcome', + type: 'home', + kind: 'full', + regions: [{ name: 'main', components: [{ type: 'element:text', properties: { content: 'Shipped text' } }] }], + _packageId: BASE, + }, 'view/opportunity.default': { name: 'opportunity.default', object: 'opportunity', @@ -104,6 +118,7 @@ const mockClient = vi.hoisted(() => ({ if (opts.packageId === PAIR) return clone(srv.pairListed.get(type) ?? []); if (type === 'app') return [{ name: 'crm_app', label: 'CRM' }]; if (type === 'object') return [{ name: 'opportunity', label: 'Opportunity' }]; + if (type === 'page') return [{ name: 'crm_welcome', label: 'Welcome', _packageId: BASE }]; return []; }), listDrafts: vi.fn(async () => []), @@ -188,6 +203,27 @@ function BoardCanvas({ draft, onPatch }: { draft: Record; onPat ); } +/** objectstack#30063 — the page designer, reduced to its first text block and one in-place edit of it. */ +function PageCanvas({ draft, onPatch }: { draft: Record; onPatch?: (patch: Record) => void }) { + const regions = (draft?.regions ?? []) as Array<{ name: string; components: Array<{ type: string; properties: { content: string } }> }>; + const content = regions[0]?.components[0]?.properties.content ?? ''; + return ( +
+

{content}

+ +
+ ); +} + /** Stands in for `plugin-view`'s `object-view`, recording the schema the canvas hands it. */ function ObjectViewRecorder({ schema }: { schema: Record }) { return
{JSON.stringify(schema)}
; @@ -214,6 +250,7 @@ function json(body: unknown, status = 200): Response { beforeAll(() => { registerMetadataPreview('dashboard', BoardCanvas as never); + registerMetadataPreview('page', PageCanvas as never); ComponentRegistry.register('object-view', ObjectViewRecorder as never, { namespace: 'plugin-view' }); }); @@ -839,3 +876,145 @@ describe('Customize a read-only base’s navigation into its pair (objectstack#3 expect(within(rail()).queryByTestId('if-nav-saved-to')).toBeNull(); }); }); + +/* ───────────── objectstack#30063 — a page ───────────── */ + +const SHIPPED_REGIONS = BASE_ROWS['page/crm_welcome']!.regions; +const OURS_REGIONS = [{ name: 'main', components: [{ type: 'element:text', properties: { content: 'Shipped text (ours)' } }] }]; +/** The served page override: the pair's whole copy, its text changed. */ +const PAGE_OVERRIDE = { + name: 'crm_welcome', + label: 'Welcome', + type: 'home', + kind: 'full', + regions: OURS_REGIONS, + overrides: { package: BASE, basedOn: '4.0.0' }, +}; +const patchPage = () => fireEvent.click(screen.getByRole('button', { name: 'patch the page' })); + +/** The Welcome page open from its rail row, past the first leaf the pillar opens. */ +async function openWelcome(text: RegExp = /^Shipped text$/): Promise { + await openPipeline(); + fireEvent.click(await within(rail()).findByRole('button', { name: /Welcome/ }, { timeout: 8000 })); + await waitFor(() => expect(screen.getByTestId('page-text')).toHaveTextContent(text), { timeout: 8000 }); +} + +/** The base's customization read listing the page's override. */ +function marksListingThePage(): PackageCustomizationsState { + const read = { + packageId: BASE, + overrides: [{ type: 'page', name: 'crm_welcome', basedOn: '4.0.0' }], + extensions: [], + switchedOff: [], + contributions: [], + counts: { overrides: 1, extensions: 0, switchedOff: 0 }, + } as never; + return { status: 'read', read, index: indexCustomizations(BASE, read)!, baseName: 'HotCRM', contributorNames: new Map() }; +} + +describe('Customize a read-only base’s page into its pair (objectstack#30063)', () => { + it('Customize is offered and writes nothing; the first designer edit copies the whole page into the pair, declaring the override; the base is untouched', async () => { + mount(); + await openWelcome(); + expect(within(header()).getByTestId('studio-customize')).toBeEnabled(); + // Until it is pressed the designer edits nothing. + patchPage(); + expect(screen.getByTestId('page-text')).toHaveTextContent(/^Shipped text$/); + + await customize(); + await sleep(1500); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + + patchPage(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + const [saved] = srv.saves; + // Read at the base before the pair was there: pinned to "no draft in the pair". + expect(saved).toMatchObject({ type: 'page', name: 'crm_welcome', opts: { mode: 'draft', packageId: PAIR, ifNoneMatch: '*' } }); + // The whole page, without the base's `_` keys, declaring the override at the base's version. + expect(saved!.body).toStrictEqual({ + name: 'crm_welcome', + label: 'Welcome', + type: 'home', + kind: 'full', + regions: OURS_REGIONS, + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + expect(srv.base.get('page/crm_welcome')).toStrictEqual(BASE_ROWS['page/crm_welcome']); + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + + // The next designer edit saves into the pair's copy, at the version it was answered. + patchPage(); + await waitFor(() => expect(srv.saves).toHaveLength(2), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + expect(srv.saves[1]!.opts).toMatchObject({ packageId: PAIR, ifMatch: 'v1' }); + expect(srv.saves[1]!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' }); + }); + + it('a served override opens in the designer as the pair’s copy, editable, with no Customize to press', async () => { + srv.pairExists = true; + srv.pairActive.set('page/crm_welcome', clone(PAGE_OVERRIDE)); + mount({ listedPair: PAIR }); + await openWelcome(/^Shipped text \(ours\)$/); + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + expect(within(header()).queryByTestId('studio-customize')).toBeNull(); + + patchPage(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.saves[0]).toMatchObject({ type: 'page', name: 'crm_welcome', opts: { packageId: PAIR } }); + expect(srv.saves[0]!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' }); + }); + + it('Restore deletes the pair’s draft and row of the page, and the shipped page reads back', async () => { + srv.pairExists = true; + srv.pairActive.set('page/crm_welcome', clone(PAGE_OVERRIDE)); + mount({ listedPair: PAIR }); + await openWelcome(/^Shipped text \(ours\)$/); + await restore(); + await waitFor(() => expect(srv.resets).toHaveLength(2), { timeout: 8000 }); + expect(srv.resets).toEqual([ + { type: 'page', name: 'crm_welcome', opts: { state: 'draft', packageId: PAIR } }, + { type: 'page', name: 'crm_welcome', opts: { packageId: PAIR } }, + ]); + await waitFor(() => expect(screen.getByTestId('page-text')).toHaveTextContent(/^Shipped text$/), { timeout: 8000 }); + await waitFor(() => expect(within(header()).queryByTestId('studio-override-chip')).toBeNull()); + expect(within(header()).getByTestId('studio-customize')).toBeInTheDocument(); + expect(srv.base.get('page/crm_welcome')!.regions).toStrictEqual(SHIPPED_REGIONS); + expect(srv.saves).toEqual([]); + }); + + it('the rail marks a customized page and counts it under "only customizations", which lists it', async () => { + srv.pairExists = true; + srv.pairActive.set('page/crm_welcome', clone(PAGE_OVERRIDE)); + mount({ listedPair: PAIR, marks: marksListingThePage() }); + await openPipeline(); + const row = await within(rail()).findByRole('button', { name: /Welcome/ }, { timeout: 8000 }); + await waitFor(() => expect(within(row).getByTestId('customization-mark')).toHaveAttribute('data-kind', 'customized'), { + timeout: 8000, + }); + const only = await within(rail()).findByTestId('studio-rail-customizations-only', undefined, { timeout: 8000 }); + expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en', { marked: 1 })); + + fireEvent.click(only); + expect(only).toHaveAttribute('aria-pressed', 'true'); + expect(within(rail()).getByRole('button', { name: /Welcome/ })).toBeInTheDocument(); + expect(within(rail()).queryByRole('button', { name: /Forecast/ })).toBeNull(); + }); + + it('CONTROL: on a writable package a page edit saves at the package, with no door and no declaration', async () => { + mockClient.save.mockImplementationOnce(async (type: string, name: string, item: unknown, opts: Record = {}) => { + srv.saves.push({ type, name, body: clone(item) as Record, opts: clone(opts) }); + return { success: true, version: 'v1', state: 'draft' }; + }); + mount({ readOnly: false }); + await openWelcome(); + expect(within(header()).queryByTestId('studio-customize')).toBeNull(); + patchPage(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.saves[0]).toMatchObject({ type: 'page', name: 'crm_welcome', opts: { packageId: BASE } }); + expect(srv.saves[0]!.body).not.toHaveProperty('overrides'); + expect(srv.saves[0]!.body.regions).toStrictEqual(OURS_REGIONS); + expect(srv.ensure).toEqual([]); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx index 1d03598d837..ddfa6466d03 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx @@ -50,11 +50,12 @@ afterEach(() => { describe('the types Studio customizes (objectstack#28562)', () => { // objectstack#30059 — the app, whose navigation the Interfaces rail customizes. - it.each(['view', 'dashboard', 'report', 'app'])('%s is customized', (type) => { + // objectstack#30063 — the page, edited in its designer as a dashboard is. + it.each(['view', 'dashboard', 'report', 'page', 'app'])('%s is customized', (type) => { expect(isStudioCustomizeType(type)).toBe(true); }); - it.each(['page', 'object', 'flow', 'translation', '', undefined, null])( + it.each(['object', 'flow', 'translation', '', undefined, null])( '%s is not: no pillar here customizes it, or it is not overridable', (type) => { expect(isStudioCustomizeType(type as string | undefined)).toBe(false); 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 26d44b89b6f..1452690e85a 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx @@ -2,14 +2,14 @@ /** * objectstack#28562 (ADR-0146 D2, D3) — Customize in Studio: a read-only - * base's view, dashboard or report becomes an item of the base's paired + * base's view, dashboard, report or page becomes an item of the base's paired * customization package `.custom`, declared as an override of the * base's item, and "Restore package original" brings the base's item back. * * What this module holds, and what it reads: * * - which types Studio customizes ({@link STUDIO_CUSTOMIZE_TYPES}): the - * Interfaces pillar's three and the app its rail navigates, each an + * Interfaces pillar's four and the app its rail navigates, each an * `overridable` type of the spec's closed classification * (`isOverridableMetadataType`); * - the body the pair's draft is written with ({@link overrideDraftBody}): @@ -66,14 +66,16 @@ import { readFields, writeFields, type FieldEntry } from '../metadata-admin/prev /** * The types Studio's Customize door covers: the Interfaces pillar's view, - * dashboard and report (an `object` leaf's list view is a `view`), and the - * app whose navigation that pillar's rail edits (objectstack#30059: the whole - * app is copied into the pair on the first saved navigation edit). Each is an + * dashboard, report and page (an `object` leaf's list view is a `view`; + * objectstack#30063: a page, block or source, is edited in its designer as + * a dashboard is), and the app whose navigation that pillar's rail edits + * (objectstack#30059: the whole app is copied into the pair on the first + * saved navigation edit). Each is an * `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', 'app']; +export const STUDIO_CUSTOMIZE_TYPES: readonly string[] = ['view', 'dashboard', 'report', 'page', 'app']; /** Whether Studio customizes an item of `type` into a read-only base's pair. */ export function isStudioCustomizeType(type: string | undefined | null): boolean {