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 c0532867e99..2db78c65c19 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' });
});
@@ -849,3 +886,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 {