Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/30063-studio-customize-page.md
Original file line number Diff line number Diff line change
@@ -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 (`<base>.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: <base>, basedOn: <base manifest version> }`. 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +70,7 @@ const ENTRIES = [
entry('translation', 'ui'),
entry('email_template', 'system'),
entry('permission', 'security'),
entry('page', 'ui'),
];

vi.mock('./useMetadata', async (importOriginal) => ({
Expand All @@ -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],
Expand Down Expand Up @@ -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' } });
Expand All @@ -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 });
Expand All @@ -141,6 +151,7 @@ const fetchMock = vi.fn(async (url: string) => {

beforeEach(() => {
customizationsFail = false;
readListsPage = false;
fetchMock.mockClear();
vi.stubGlobal('fetch', fetchMock);
});
Expand Down Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +72,15 @@ const BASE_ROWS: Record<string, Record<string, unknown>> = {
'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',
Expand Down Expand Up @@ -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 () => []),
Expand Down Expand Up @@ -188,6 +203,27 @@ function BoardCanvas({ draft, onPatch }: { draft: Record<string, unknown>; 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<string, unknown>; onPatch?: (patch: Record<string, unknown>) => 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 (
<div>
<p data-testid="page-text">{content}</p>
<button
type="button"
onClick={() =>
onPatch?.({
regions: [{ ...regions[0], components: [{ type: 'element:text', properties: { content: `${content} (ours)` } }] }],
})
}
>
patch the page
</button>
</div>
);
}

/** Stands in for `plugin-view`'s `object-view`, recording the schema the canvas hands it. */
function ObjectViewRecorder({ schema }: { schema: Record<string, unknown> }) {
return <pre data-testid="object-view-schema">{JSON.stringify(schema)}</pre>;
Expand All @@ -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' });
});

Expand Down Expand Up @@ -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<void> {
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<string, unknown> = {}) => {
srv.saves.push({ type, name, body: clone(item) as Record<string, unknown>, 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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>.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}):
Expand Down Expand Up @@ -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 {
Expand Down
Loading