Skip to content

Commit 797f925

Browse files
feat(app-shell): Studio customizes an installed package's page into its customization package (#30063) (#30233)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB7LEx1K2Cto3SeXFE6Vzmep)_ Fixes #30063 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:已安装(只读)包里的页面,在 Studio「界面」中打开时现在会出现「定制」按钮;按下后可在页面设计器里编辑,首次保存把整页复制进 `包名.custom` 配对包并声明 `overrides: { package, basedOn }`,发布后运行中的应用显示定制后的页面;「恢复原版」删除这份副本。导轨的「已定制」标记、「仅看定制」计数和「全部元数据」的页面卡片计数也都覆盖页面。 - **为什么改**:服务器早已支持页面覆盖(卡片实测过),只缺 Studio 的入口,客户想改欢迎页、首页只能等代码发版。 - **风险与回滚**:很小。只把 `page` 加进 Studio 可定制类型清单,首存、恢复、标记都走仪表板/视图现成的通用路径;无 spec、服务端、元数据类型改动;可写包的页面编辑不变。回滚即 revert 本 PR。定制后的页面是整份副本,厂商后续版本对该页的修改要恢复原版后重做才会出现。 - **先看**:`StudioCustomizeControls.tsx` 的 `STUDIO_CUSTOMIZE_TYPES` 一行;`InterfacesPillar.customize-28562.test.tsx` 新增的 "Customize a read-only base’s page into its pair (objectstack#30063)" 一组测试。 - **两分钟验证**:在仓根运行 `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx -t 30063`,预期 5 条通过、其余跳过。 - **需要你做的**:无,席位落地。 ## What changes Studio's Customize door (`STUDIO_CUSTOMIZE_TYPES` in `StudioCustomizeControls.tsx`) now lists `page` beside view, dashboard, report and app. `page` is `overridable` in the spec's classification (`isOverridableMetadataType`), so `isStudioCustomizeType('page')` now answers true. That one line is the whole runtime change. Everything else a page needs was already type-generic in the Interfaces pillar, and is now reached for pages: 1. **Customize, first save, later saves.** A read-only base's page shows Customize in its header (the leaf path `leafCustomizable`). Until it is pressed, the designer edits nothing. The first saved designer edit asks the ensure door once and writes the whole page into the pair (`overrideDraftBody`: the page without its `_` keys, plus `overrides: { package: BASE, basedOn: BASE_MANIFEST_VERSION }`), pinned to "no draft there" (`ifNoneMatch: '*'`). Later edits save into the pair's copy at the version they were answered. The base is never written. 2. **Designer edits reach the running app.** The page's draft sits in the pair's pending changes; publishing it serves the customized page at `/apps/APP/page/NAME`. 3. **Restore package original.** The header's override mark offers Restore, which deletes the pair's draft and row of the page after a confirmation; the shipped page reads back. 4. **Marks, "only customizations", All metadata tile.** These read the server's customization ledger and are type-agnostic. With the page's override served, the rail row carries the "customized" mark, "only customizations" counts and lists it, and the All metadata page tile counts it. No spec, server or metadata-type change (card Acceptance 5): the browser check below ran end to end against an unchanged server. Out of scope, as the card says: a renamed page's label can be hidden by the base's translation. That is filed as #30062 (its third finding) and is not fixed here. Diff: 244 changed lines (+233 / −11, 5 files), under the 1,500 split line, so Acceptance 1–4 ship together. ## Tests `InterfacesPillar.customize-28562.test.tsx` (the shared harness), new top-level describe "Customize a read-only base’s page into its pair (objectstack#30063)", 5 tests. The page designer is reduced to a stand-in canvas (its first text block and one in-place edit), the way the harness already stands in for the dashboard canvas: - Customize is offered and writes nothing; the first designer edit asks the door once (201) and writes the whole page into the pair with `overrides: { package, basedOn: '4.0.0' }` and `ifNoneMatch: '*'`; the base row is unchanged; the override chip appears; the next edit saves into the pair at `ifMatch: 'v1'` without asking the door again. - A served override opens as the pair's copy, editable, with no Customize to press, and an edit saves into the pair. - Restore deletes the pair's draft and row (`DELETE …state=draft&package=PAIR`, then `DELETE …package=PAIR`), and the shipped page reads back with Customize offered again. - The rail marks the customized page (`data-kind="customized"`), "only customizations" reads 1 and lists the page alone. - CONTROL: on a writable package a page edit saves at the package, with no door and no `overrides`. Also: - `StudioCustomizeControls.customize-28562.test.tsx`: `page` moves from the "is not customized" list to the "is customized" list. - `metadataAreaHost.customizations-28568.test.tsx`: the All metadata page tile counts a page's override ("1 customized"), and the header's total includes it. - CONTROL for the other types: the existing dashboard, view, list-view and app (navigation) describes in the same harness ran unchanged and green. <details><summary>Ablation (one-time, not kept)</summary> Committed first, then `node scripts/ablation-replace.mjs` replaced `'report', 'page', 'app'` with `'report', /* ablated-30063 */ 'app'` in `StudioCustomizeControls.tsx` (anchor 1 → 0, blob `1452690e` → `f2d008e3`) and ran `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx -t 30063` (ui vitest resolves `@object-ui/*` to src, so no build leg): - Red: 3 failed, 2 passed. The first-save pin, the served-override pin and the Restore pin failed (no Customize button, no override chip). - Green as expected under the ablation: the rail-mark pin (marks read the server's ledger, not this list) and the writable-package CONTROL. - Restored: blob back to `1452690e` = HEAD, `git diff HEAD` empty (the script's own check). </details> ## Acceptance notes - **Browser check (card Acceptance 4).** Dev boot of `examples/app-crm` from this branch (`objectstack dev --seed-admin --fresh -p 4317`, Console Vite on 5317 against it), admin, zh-CN, head `eee39383` (before the `origin/main` merge; the merge brought no change to this PR's diff, and the suites below were rerun after it). Screenshots are in the project's `ui-orders/30063-shots/` folder; file names below. 1. `欢迎页` (`crm_welcome`) in Studio → Interfaces reads 只读 and now offers 定制 (`01-studio-page-readonly-offers-customize.png`). 2. 定制 → selected the main text block → changed 内容. The first save sent `POST /packages/com.example.crm/ensure-customization` → 201, then `PUT /meta/page/crm_welcome?mode=draft&package=com.example.crm.custom` → 200; the header shows the override chip and "保存到 CRM (minimal example) 的定制" (`02-first-edit-saved-into-custom-pair.png`). 3. 发布 lists one page change saved to the pair (`03-publish-lists-the-page-in-the-pair.png`); 全部发布 sent `POST /packages/com.example.crm.custom/publish-drafts` → 200. 4. `/apps/com.example.crm/page/crm_welcome` shows the edited text (`04-runtime-serves-the-customized-page.png`). `GET /packages/com.example.crm/customizations` then listed `{type: page, name: crm_welcome, basedOn: 4.0.0}`. 5. The rail marks 欢迎页 "customized" and 仅看定制(1)lists it (`05-rail-marks-the-page-and-only-customizations-counts-it.png`); the All metadata page tile reads 已定制 1 and the header "CRM (minimal example) 已定制 1 项" (`06-all-metadata-page-tile-counts-the-customization.png`). 6. 恢复原版 asks first (`07-restore-package-original-confirm.png`), then sent `DELETE …?state=draft&package=com.example.crm.custom` and `DELETE …?package=com.example.crm.custom` (both 200). Studio shows the shipped text with 定制 offered again (`08-restored-shipped-page-customize-offered-again.png`), the runtime serves the shipped page (`09-runtime-serves-the-shipped-page-again.png`), the customization listing is empty, and the base's page body read before and after is identical. - **Seen in the browser, not fixed here (reported to the seat for filing):** - After 恢复原版, the rail keeps the "customized" mark and 仅看定制(1)until the page is reloaded (visible in `08-…png`; a reload shows 0 marks and no filter). The marks read (`usePackageCustomizations`) re-reads only on `publishNonce`, and Restore does not bump it. That code path is shared with dashboards, views and the app, so it is not page-specific. - The All metadata page tile's state word reads 只读 while Studio now customizes pages (`06-…png`); the app tile reads the same since #30059. The word comes from `typeStateWord`, which reads the type's `allowEnvironmentOverlay`, not the customize door. Carrier: #30062 item 4 (branch `claude/project-thread-yenpdr`) makes `typeStateWord` in `PageShell.tsx` read the customize door's own type table (`isStudioCustomizeType`). Serial: whichever of the two PRs lands second updates the #29705 test's tile expectation for `page` from 只读 to 可定制; this PR does not touch `PageShell.tsx`. - The page designer in this PR's pins is a stand-in canvas. The real block designer's save into the pair is the browser check above. - **Open PRs on the same files, re-checked at PR time.** #30224 (#30064, dataset Customize) edits the same `STUDIO_CUSTOMIZE_TYPES` doc comment in `StudioCustomizeControls.tsx`: the two hunks overlap, so whichever lands second merges `origin/main` and keeps both sentences (the page in the types list, the dataset in the area editor). #30226 (#30062) edits `PageShell.tsx` (see the tile note above); this PR does not touch it. #30211 edits `StudioDesignSurface.tsx`, which this PR does not touch. ## Verification All under `scripts/pm/os-verify-lock.sh` unless noted. Head `a73ecd64` (`origin/main` `dfafae81` merged, clean) for the rows marked "after the merge"; the first rows ran on `eee39383`. - Build: `turbo run build --filter='@object-ui/app-shell...'`, 68/68 tasks; then the full build (`--filter=!@objectstack/docs`), 119/119, for the dev boot. - The three touched test files: 3 files, 73 tests, all passed. - The whole `packages/app-shell/src/views/studio-design/` directory: 324 files, 2397 tests, all passed. After the merge (full build 119/119 first), that directory plus `metadataAreaHost.customizations-28568.test.tsx`: 325 files, 2415 tests, all passed. - `@object-ui/app-shell` `type-check` (both tsconfigs) → exit 0, before and after the merge. - `npx eslint` on the 4 touched source/test files (unlocked): 0 errors, 15 warnings (all `react-refresh/only-export-components` on existing exports of `StudioCustomizeControls.tsx`). - `node scripts/check-sdui-manifest.mjs` → exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 commands (the same 35 before and after the merge), each run on `a73ecd64` and recorded `:: exit 0`; `--ran` → "35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN". Record: project file `ui-orders/30063-gates-ran.list`. - Changed lines vs the human-merge threshold: 244 (+233 / −11, 5 files), under 3,000. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzhKo5SDER9fGbQQqSa)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d7c0389 commit 797f925

5 files changed

Lines changed: 233 additions & 11 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(app-shell): Studio customizes an installed package's page into the base's customization package (#30063)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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`).
11+
12+
- 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.
13+
- The edit is a draft in the pair's pending changes; once published, the running app serves the customized page at its usual address.
14+
- 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.
15+
- The Interfaces rail marks a customized page, "only customizations" counts and lists it, and the All metadata directory's page tile counts it.
16+
17+
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.

‎ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
* - the directory's object tile counts the extension, and the header the base's total;
1919
* - a failed read draws no mark, no filter and no number, with the server's sentence;
2020
* - a base without a pair is unchanged, and Setup (no area) never reads;
21-
* - in the cross-package leg an item of a pair is labelled with its base.
21+
* - in the cross-package leg an item of a pair is labelled with its base;
22+
* - objectstack#30063 — the page tile counts a page's override.
2223
*/
2324

2425
import '@testing-library/jest-dom/vitest';
@@ -69,6 +70,7 @@ const ENTRIES = [
6970
entry('translation', 'ui'),
7071
entry('email_template', 'system'),
7172
entry('permission', 'security'),
73+
entry('page', 'ui'),
7274
];
7375

7476
vi.mock('./useMetadata', async (importOriginal) => ({
@@ -81,7 +83,7 @@ vi.mock('./useMetadata', async (importOriginal) => ({
8183
summary: { entries: [], total: 0, scannedTypes: 0, scannedItems: 0 },
8284
byType: {},
8385
warnByType: {},
84-
countsByType: { object: 3, translation: 2, email_template: 1 },
86+
countsByType: { object: 3, translation: 2, email_template: 1, page: 1 },
8587
lockedByType: {},
8688
packagesByType: Object.fromEntries(ENTRIES.map((e) => [e.type, [BASE, PLAIN]])),
8789
allPackages: [BASE, PLAIN],
@@ -119,6 +121,13 @@ const HOTCRM_READ = {
119121
const EMPTY_READ = { packageId: PLAIN, overrides: [], extensions: [], switchedOff: [], contributions: [], counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 } };
120122

121123
let customizationsFail = false;
124+
/** objectstack#30063 — the read also lists a page's override. */
125+
let readListsPage = false;
126+
const withPage = (read: typeof HOTCRM_READ) => ({
127+
...read,
128+
overrides: [...read.overrides, { type: 'page', name: 'crm_welcome', basedOn: '2.1.0' }],
129+
counts: { ...read.counts, overrides: read.counts.overrides + 1 },
130+
});
122131
const fetchMock = vi.fn(async (url: string) => {
123132
const json = (status: number, body: unknown) =>
124133
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
@@ -131,7 +140,8 @@ const fetchMock = vi.fn(async (url: string) => {
131140
if (customizationsFail) {
132141
return json(503, { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The customization ledger is unavailable.' } });
133142
}
134-
return json(200, { success: true, data: decodeURIComponent(m[1]) === BASE ? HOTCRM_READ : EMPTY_READ });
143+
const hotcrm = readListsPage ? withPage(HOTCRM_READ) : HOTCRM_READ;
144+
return json(200, { success: true, data: decodeURIComponent(m[1]) === BASE ? hotcrm : EMPTY_READ });
135145
}
136146
if (u.startsWith('/api/v1/data/sys_metadata_activation')) {
137147
return json(200, { object: 'sys_metadata_activation', records: [], hasMore: false });
@@ -141,6 +151,7 @@ const fetchMock = vi.fn(async (url: string) => {
141151

142152
beforeEach(() => {
143153
customizationsFail = false;
154+
readListsPage = false;
144155
fetchMock.mockClear();
145156
vi.stubGlobal('fetch', fetchMock);
146157
});
@@ -265,6 +276,18 @@ describe('the All metadata directory counts a base\'s customizations (objectstac
265276
expect(screen.queryByTestId('directory-tile-customized-permission')).toBeNull();
266277
});
267278

279+
it('objectstack#30063 — the page tile counts a page\'s override, and the header the total with it', async () => {
280+
readListsPage = true;
281+
mountDirectory(area(BASE));
282+
const total = await screen.findByTestId('directory-customizations-total');
283+
expect(total).toHaveTextContent(
284+
tFormat('engine.directory.customizations.total', 'en-US', { customized: 4, baseName: 'HotCRM' }),
285+
);
286+
expect(screen.getByTestId('directory-tile-customized-page')).toHaveTextContent(
287+
tFormat('engine.directory.customizations.tile', 'en-US', { customized: 1 }),
288+
);
289+
});
290+
268291
it('a failed read draws no number, never "0", and says the server\'s sentence', async () => {
269292
customizationsFail = true;
270293
mountDirectory(area(BASE));

‎ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
* editor: the first saved edit copies the whole app into the pair, declaring
3131
* the override; the pickers offer the pair's items; the rail marks the
3232
* override and counts it under "only customizations"; Restore deletes it.
33+
*
34+
* objectstack#30063 — and a page, through its designer as a dashboard is:
35+
* the first saved edit copies the whole page into the pair, declaring the
36+
* override; Restore deletes it; the rail marks the page and counts it under
37+
* "only customizations"; on a writable package the page saves in place.
3338
*/
3439

3540
import '@testing-library/jest-dom/vitest';
@@ -67,6 +72,15 @@ const BASE_ROWS: Record<string, Record<string, unknown>> = {
6772
'app/crm_app': { ...APP, _packageId: BASE },
6873
'dashboard/pipeline': { name: 'pipeline', label: 'Pipeline', widgets: [], _packageId: BASE },
6974
'dashboard/forecast': { name: 'forecast', label: 'Forecast', widgets: [], _packageId: BASE },
75+
// objectstack#30063 — a page no navigation entry opens: the rail lists it under its type.
76+
'page/crm_welcome': {
77+
name: 'crm_welcome',
78+
label: 'Welcome',
79+
type: 'home',
80+
kind: 'full',
81+
regions: [{ name: 'main', components: [{ type: 'element:text', properties: { content: 'Shipped text' } }] }],
82+
_packageId: BASE,
83+
},
7084
'view/opportunity.default': {
7185
name: 'opportunity.default',
7286
object: 'opportunity',
@@ -104,6 +118,7 @@ const mockClient = vi.hoisted(() => ({
104118
if (opts.packageId === PAIR) return clone(srv.pairListed.get(type) ?? []);
105119
if (type === 'app') return [{ name: 'crm_app', label: 'CRM' }];
106120
if (type === 'object') return [{ name: 'opportunity', label: 'Opportunity' }];
121+
if (type === 'page') return [{ name: 'crm_welcome', label: 'Welcome', _packageId: BASE }];
107122
return [];
108123
}),
109124
listDrafts: vi.fn(async () => []),
@@ -188,6 +203,27 @@ function BoardCanvas({ draft, onPatch }: { draft: Record<string, unknown>; onPat
188203
);
189204
}
190205

206+
/** objectstack#30063 — the page designer, reduced to its first text block and one in-place edit of it. */
207+
function PageCanvas({ draft, onPatch }: { draft: Record<string, unknown>; onPatch?: (patch: Record<string, unknown>) => void }) {
208+
const regions = (draft?.regions ?? []) as Array<{ name: string; components: Array<{ type: string; properties: { content: string } }> }>;
209+
const content = regions[0]?.components[0]?.properties.content ?? '';
210+
return (
211+
<div>
212+
<p data-testid="page-text">{content}</p>
213+
<button
214+
type="button"
215+
onClick={() =>
216+
onPatch?.({
217+
regions: [{ ...regions[0], components: [{ type: 'element:text', properties: { content: `${content} (ours)` } }] }],
218+
})
219+
}
220+
>
221+
patch the page
222+
</button>
223+
</div>
224+
);
225+
}
226+
191227
/** Stands in for `plugin-view`'s `object-view`, recording the schema the canvas hands it. */
192228
function ObjectViewRecorder({ schema }: { schema: Record<string, unknown> }) {
193229
return <pre data-testid="object-view-schema">{JSON.stringify(schema)}</pre>;
@@ -214,6 +250,7 @@ function json(body: unknown, status = 200): Response {
214250

215251
beforeAll(() => {
216252
registerMetadataPreview('dashboard', BoardCanvas as never);
253+
registerMetadataPreview('page', PageCanvas as never);
217254
ComponentRegistry.register('object-view', ObjectViewRecorder as never, { namespace: 'plugin-view' });
218255
});
219256

@@ -849,3 +886,145 @@ describe('Customize a read-only base’s navigation into its pair (objectstack#3
849886
expect(within(rail()).queryByTestId('if-nav-saved-to')).toBeNull();
850887
});
851888
});
889+
890+
/* ───────────── objectstack#30063 — a page ───────────── */
891+
892+
const SHIPPED_REGIONS = BASE_ROWS['page/crm_welcome']!.regions;
893+
const OURS_REGIONS = [{ name: 'main', components: [{ type: 'element:text', properties: { content: 'Shipped text (ours)' } }] }];
894+
/** The served page override: the pair's whole copy, its text changed. */
895+
const PAGE_OVERRIDE = {
896+
name: 'crm_welcome',
897+
label: 'Welcome',
898+
type: 'home',
899+
kind: 'full',
900+
regions: OURS_REGIONS,
901+
overrides: { package: BASE, basedOn: '4.0.0' },
902+
};
903+
const patchPage = () => fireEvent.click(screen.getByRole('button', { name: 'patch the page' }));
904+
905+
/** The Welcome page open from its rail row, past the first leaf the pillar opens. */
906+
async function openWelcome(text: RegExp = /^Shipped text$/): Promise<void> {
907+
await openPipeline();
908+
fireEvent.click(await within(rail()).findByRole('button', { name: /Welcome/ }, { timeout: 8000 }));
909+
await waitFor(() => expect(screen.getByTestId('page-text')).toHaveTextContent(text), { timeout: 8000 });
910+
}
911+
912+
/** The base's customization read listing the page's override. */
913+
function marksListingThePage(): PackageCustomizationsState {
914+
const read = {
915+
packageId: BASE,
916+
overrides: [{ type: 'page', name: 'crm_welcome', basedOn: '4.0.0' }],
917+
extensions: [],
918+
switchedOff: [],
919+
contributions: [],
920+
counts: { overrides: 1, extensions: 0, switchedOff: 0 },
921+
} as never;
922+
return { status: 'read', read, index: indexCustomizations(BASE, read)!, baseName: 'HotCRM', contributorNames: new Map() };
923+
}
924+
925+
describe('Customize a read-only base’s page into its pair (objectstack#30063)', () => {
926+
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 () => {
927+
mount();
928+
await openWelcome();
929+
expect(within(header()).getByTestId('studio-customize')).toBeEnabled();
930+
// Until it is pressed the designer edits nothing.
931+
patchPage();
932+
expect(screen.getByTestId('page-text')).toHaveTextContent(/^Shipped text$/);
933+
934+
await customize();
935+
await sleep(1500);
936+
expect(srv.ensure).toEqual([]);
937+
expect(srv.saves).toEqual([]);
938+
939+
patchPage();
940+
await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 });
941+
expect(srv.ensure).toEqual([201]);
942+
const [saved] = srv.saves;
943+
// Read at the base before the pair was there: pinned to "no draft in the pair".
944+
expect(saved).toMatchObject({ type: 'page', name: 'crm_welcome', opts: { mode: 'draft', packageId: PAIR, ifNoneMatch: '*' } });
945+
// The whole page, without the base's `_` keys, declaring the override at the base's version.
946+
expect(saved!.body).toStrictEqual({
947+
name: 'crm_welcome',
948+
label: 'Welcome',
949+
type: 'home',
950+
kind: 'full',
951+
regions: OURS_REGIONS,
952+
overrides: { package: BASE, basedOn: '4.0.0' },
953+
});
954+
expect(srv.base.get('page/crm_welcome')).toStrictEqual(BASE_ROWS['page/crm_welcome']);
955+
await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
956+
957+
// The next designer edit saves into the pair's copy, at the version it was answered.
958+
patchPage();
959+
await waitFor(() => expect(srv.saves).toHaveLength(2), { timeout: 8000 });
960+
expect(srv.ensure).toEqual([201]);
961+
expect(srv.saves[1]!.opts).toMatchObject({ packageId: PAIR, ifMatch: 'v1' });
962+
expect(srv.saves[1]!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' });
963+
});
964+
965+
it('a served override opens in the designer as the pair’s copy, editable, with no Customize to press', async () => {
966+
srv.pairExists = true;
967+
srv.pairActive.set('page/crm_welcome', clone(PAGE_OVERRIDE));
968+
mount({ listedPair: PAIR });
969+
await openWelcome(/^Shipped text \(ours\)$/);
970+
await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 });
971+
expect(within(header()).queryByTestId('studio-customize')).toBeNull();
972+
973+
patchPage();
974+
await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 });
975+
expect(srv.saves[0]).toMatchObject({ type: 'page', name: 'crm_welcome', opts: { packageId: PAIR } });
976+
expect(srv.saves[0]!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' });
977+
});
978+
979+
it('Restore deletes the pair’s draft and row of the page, and the shipped page reads back', async () => {
980+
srv.pairExists = true;
981+
srv.pairActive.set('page/crm_welcome', clone(PAGE_OVERRIDE));
982+
mount({ listedPair: PAIR });
983+
await openWelcome(/^Shipped text \(ours\)$/);
984+
await restore();
985+
await waitFor(() => expect(srv.resets).toHaveLength(2), { timeout: 8000 });
986+
expect(srv.resets).toEqual([
987+
{ type: 'page', name: 'crm_welcome', opts: { state: 'draft', packageId: PAIR } },
988+
{ type: 'page', name: 'crm_welcome', opts: { packageId: PAIR } },
989+
]);
990+
await waitFor(() => expect(screen.getByTestId('page-text')).toHaveTextContent(/^Shipped text$/), { timeout: 8000 });
991+
await waitFor(() => expect(within(header()).queryByTestId('studio-override-chip')).toBeNull());
992+
expect(within(header()).getByTestId('studio-customize')).toBeInTheDocument();
993+
expect(srv.base.get('page/crm_welcome')!.regions).toStrictEqual(SHIPPED_REGIONS);
994+
expect(srv.saves).toEqual([]);
995+
});
996+
997+
it('the rail marks a customized page and counts it under "only customizations", which lists it', async () => {
998+
srv.pairExists = true;
999+
srv.pairActive.set('page/crm_welcome', clone(PAGE_OVERRIDE));
1000+
mount({ listedPair: PAIR, marks: marksListingThePage() });
1001+
await openPipeline();
1002+
const row = await within(rail()).findByRole('button', { name: /Welcome/ }, { timeout: 8000 });
1003+
await waitFor(() => expect(within(row).getByTestId('customization-mark')).toHaveAttribute('data-kind', 'customized'), {
1004+
timeout: 8000,
1005+
});
1006+
const only = await within(rail()).findByTestId('studio-rail-customizations-only', undefined, { timeout: 8000 });
1007+
expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en', { marked: 1 }));
1008+
1009+
fireEvent.click(only);
1010+
expect(only).toHaveAttribute('aria-pressed', 'true');
1011+
expect(within(rail()).getByRole('button', { name: /Welcome/ })).toBeInTheDocument();
1012+
expect(within(rail()).queryByRole('button', { name: /Forecast/ })).toBeNull();
1013+
});
1014+
1015+
it('CONTROL: on a writable package a page edit saves at the package, with no door and no declaration', async () => {
1016+
mockClient.save.mockImplementationOnce(async (type: string, name: string, item: unknown, opts: Record<string, unknown> = {}) => {
1017+
srv.saves.push({ type, name, body: clone(item) as Record<string, unknown>, opts: clone(opts) });
1018+
return { success: true, version: 'v1', state: 'draft' };
1019+
});
1020+
mount({ readOnly: false });
1021+
await openWelcome();
1022+
expect(within(header()).queryByTestId('studio-customize')).toBeNull();
1023+
patchPage();
1024+
await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 });
1025+
expect(srv.saves[0]).toMatchObject({ type: 'page', name: 'crm_welcome', opts: { packageId: BASE } });
1026+
expect(srv.saves[0]!.body).not.toHaveProperty('overrides');
1027+
expect(srv.saves[0]!.body.regions).toStrictEqual(OURS_REGIONS);
1028+
expect(srv.ensure).toEqual([]);
1029+
});
1030+
});

‎ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,12 @@ afterEach(() => {
5050

5151
describe('the types Studio customizes (objectstack#28562)', () => {
5252
// objectstack#30059 — the app, whose navigation the Interfaces rail customizes.
53-
it.each(['view', 'dashboard', 'report', 'app'])('%s is customized', (type) => {
53+
// objectstack#30063 — the page, edited in its designer as a dashboard is.
54+
it.each(['view', 'dashboard', 'report', 'page', 'app'])('%s is customized', (type) => {
5455
expect(isStudioCustomizeType(type)).toBe(true);
5556
});
5657

57-
it.each(['page', 'object', 'flow', 'translation', '', undefined, null])(
58+
it.each(['object', 'flow', 'translation', '', undefined, null])(
5859
'%s is not: no pillar here customizes it, or it is not overridable',
5960
(type) => {
6061
expect(isStudioCustomizeType(type as string | undefined)).toBe(false);

‎ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
/**
44
* objectstack#28562 (ADR-0146 D2, D3) — Customize in Studio: a read-only
5-
* base's view, dashboard or report becomes an item of the base's paired
5+
* base's view, dashboard, report or page becomes an item of the base's paired
66
* customization package `<base>.custom`, declared as an override of the
77
* base's item, and "Restore package original" brings the base's item back.
88
*
99
* What this module holds, and what it reads:
1010
*
1111
* - which types Studio customizes ({@link STUDIO_CUSTOMIZE_TYPES}): the
12-
* Interfaces pillar's three and the app its rail navigates, each an
12+
* Interfaces pillar's four and the app its rail navigates, each an
1313
* `overridable` type of the spec's closed classification
1414
* (`isOverridableMetadataType`);
1515
* - the body the pair's draft is written with ({@link overrideDraftBody}):
@@ -66,16 +66,18 @@ import { readFields, writeFields, type FieldEntry } from '../metadata-admin/prev
6666

6767
/**
6868
* The types Studio's Customize door covers: the Interfaces pillar's view,
69-
* dashboard and report (an `object` leaf's list view is a `view`), and the
70-
* app whose navigation that pillar's rail edits (objectstack#30059: the whole
71-
* app is copied into the pair on the first saved navigation edit). Each is an
69+
* dashboard, report and page (an `object` leaf's list view is a `view`;
70+
* objectstack#30063: a page, block or source, is edited in its designer as
71+
* a dashboard is), and the app whose navigation that pillar's rail edits
72+
* (objectstack#30059: the whole app is copied into the pair on the first
73+
* saved navigation edit). Each is an
7274
* `overridable` type of the spec's classification. Translations and email
7375
* templates are too: no pillar designs them, and the All metadata area's form
7476
* editor customizes them (`StudioBundleItemEditor`, objectstack#28569). So is
7577
* a read-only package's dataset, in that editor with its own designer
7678
* (objectstack#30064, `metadataArea.ts#AREA_CUSTOMIZE_EDITOR_TYPES`).
7779
*/
78-
export const STUDIO_CUSTOMIZE_TYPES: readonly string[] = ['view', 'dashboard', 'report', 'app'];
80+
export const STUDIO_CUSTOMIZE_TYPES: readonly string[] = ['view', 'dashboard', 'report', 'page', 'app'];
7981

8082
/** Whether Studio customizes an item of `type` into a read-only base's pair. */
8183
export function isStudioCustomizeType(type: string | undefined | null): boolean {

0 commit comments

Comments
 (0)