Skip to content

Commit d62d973

Browse files
feat(studio): customize a read-only package's view, dashboard or report into its <base>.custom pair, with an override badge and "Restore package original" (#28711)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb7ALAiYy5ZTzr6TSya1KhXp)_ Fixes #28562 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 打开只读基础包(如 HotCRM)的视图、仪表板、报表,或对象条目的列表视图时,标题栏多一个「自定义」。点它只解锁编辑器,不写任何东西。第一次保存编辑时,每个条目调用一次 ensure 门(`POST /packages/:id/ensure-customization`)。之后把草稿写进门回答的配对包 `<base>.custom`,草稿声明 `overrides: { package, basedOn }`,`basedOn` 取包列表里基础包的 `manifest.version`。待发布数、变更面板和发布都改为作用于配对包。标题栏显示「覆盖 HotCRM」徽标,读自服务器返回的声明。⋯ 菜单新增「恢复软件包原版」:确认后先删草稿,再删配对包里的这一行,基础包的版本随即读回。只读标记不再把这三种类型链接到 Setup。 - **为什么改**:已安装包在 Studio 里只读,管理员想改 HotCRM 的列表列、仪表板或报表,又不想动 HotCRM 本身(ADR-0146 D2/D3)。 - **风险与回滚**:纯前端改动,只调用已上线的门:ensure、草稿保存、批量发布和删除。可写包的行为不变(有对照测试)。纯 revert 即可回滚。 - **先看**:`StudioDesignSurface.tsx` 里 `InterfacesPillar` 的 `doSave`(配对包 id 取自门的回答)、`restoreOriginal`,以及 Studio 外壳里的 `draftScope` / `publishTarget`;新文件 `StudioCustomizeControls.tsx`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx`,预期 19 例全绿。 - **需要你做的**:无,由席位落地。 ## What changes - **Customize** (`StudioCustomizeControls.tsx`, new) appears in the item header of a view, dashboard or report, and of an `object` leaf's list view, when the package is read-only. Pressing it only unlocks that item's editors (canvas, inspectors, list-view panel). Nothing is written until an edit is saved, so an unedited Customize leaves nothing behind. - **First saved edit** (`InterfacesPillar` `doSave`, and the list-view hook's save via its new `customization` option). It calls `ensureCustomizationPackage` (`packages-io.ts`, new) once per item, with no prompt and no body. It then saves the draft into **the package the door's answer names**. The body is the item as held, without its top-level `_` keys, with `overrides: { package: <base>, basedOn: <listed manifest.version> }`. The answer is judged by the spec's `EnsureCustomizationPackageResponseSchema`. A declaration the item already carries is kept as it is. The first save into the pair is pinned `If-None-Match: *` when the buffer was read at the base, so a draft someone else left there is asked about, never overwritten. - **Walled.** Under the `group` or `isolated` posture, an admin who is not the platform operator sees Customize disabled, with the reason. This is read from `features.tenancyPosture` and `user.isPlatformAdmin`, the facts `useFlowLedgerWalledReadOnly` reads. Any refusal from the door is shown as the envelope reader writes it (`<sentence> (<CODE>)`), and nothing is saved. - **No version, no guess.** A base whose listed manifest states no `version` gets Customize disabled, with a sentence that says so. - **Pending, panel, Publish** (Studio surface). On a read-only base with a pair (the door's answer, else the pair the package list lists), the pending feed, `DraftChangesPanel` and `publishPackageDrafts` name the pair. A refusal at the publish door goes through the existing refusal path, verbatim. A read-only base with no pair keeps the read-only Publish and its reason. - **Badge** (`StudioOverrideChip`). It reads the declaration the server serves: the pair's draft through the draft door, else the layered read's `overlay` (N-10). Its title names the version. It is never computed. - **Restore package original** (`StudioRestoreOriginalItem` + `StudioRestoreOriginalDialog`) is on the ⋯ menu of an item served as an override. On confirm it sends `DELETE /meta/:type/:name?state=draft&package=<pair>`, then `DELETE /meta/:type/:name?package=<pair>`, and reloads the item. The base reads back. The pair is the door's answer this item's saves named, else the surface's. The door is not asked again. - **`StudioReadOnlyLink`** no longer links a view, dashboard or report to Setup's overlay page. Other overlay-able types keep the link. - **Strings.** Twelve `engine.studio.customize.*` keys in the studio namespace (en + zh). The placeholder is `{package}`, because the zh census refuses a bare Latin `base`. ### Surface additions (approved by the seat) - `ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx`. `useObjectListViewDraft` gains an optional `customization` (`readAt` / `writeTarget`), and `ObjectListViewInspector` gains `canCreate` (default `!readOnly`). Without them nothing changes. - `ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx` (new). ## Tests - `StudioCustomizeControls.customize-28562.test.tsx` (36): type gate, declaration parse, override body (`_` keys dropped, an existing declaration kept, `null` with no version), the wall matrix, the ensure client (201 and 200, no body, refusal text, a malformed answer refused, `packages-changed` only on create), listed version and pair, and the button and badge. - `InterfacesPillar.customize-28562.test.tsx` (13). The mounted pillar, real autosaves, and the real ensure client over a fetch stub that answers 201, then 200, are driven against a server double. Covered: - Customize writes nothing. - One ensure call per first edit, with the draft at the pair carrying the declaration and the base untouched. - A second item answered 200. - Pins: `If-None-Match: *`, then `If-Match`. - A refused door shown verbatim. - Walled disabled, and no version disabled. - No Setup link. - Restore on a dashboard (two deletes naming the pair, base label back) and Cancel. - The list view customized into the pair ("Add view" stays off). - Restore bringing back the base's columns. - CONTROL: a writable package saves in place with no door and no declaration. - `StudioDesignSurface.customizePublish-28562.test.tsx` (6). The real surface and the real client over fetch. Covered: - Pending reads `_drafts?packageId=<pair>`, the panel reviews the pair, and Publish posts `/packages/<pair>/publish-drafts`. - End to end, from first edit to door to pair draft at the listed `4.0.0`, then Pending and Publish follow the pair. - No version disabled. - A group posture plus a non-operator is walled, and the platform operator is not. - CONTROL: no pair keeps the read-only Publish on the base. - `StudioReadOnlyLink.overlay-27356.test.tsx` was updated. View, dashboard and report are plain. An overlay-able `email_template` still links (control). ### Ablations Nineteen, each through `scripts/ablation-replace.mjs`. Each one landed, its filtered test went red, and the file was restored to HEAD: | # | Mutation | Red | |---|---|---| | A1 | `pairFor` never reuses an answer | one ensure per first edit | | A2 | save sends the body without the declaration | first saved edit | | A3 | save writes at the base, not the pair | first saved edit | | A4 | ensure keyed per base, not per item | second item gets 200 | | A5 | `customizeDoorWalled` always false | walled admin (surface) | | A19 | pillar ignores `walled` | walled caller (pillar) | | A6 | `publishTarget = packageId` | Publish targets the pair | | A7 | `draftScope = packageId` | Pending/panel on the pair | | A8 | `publishReadOnly = readOnly` | Publish enabled on the pair | | A9 | Restore skips the row delete | Restore brings back the base's columns | | A10 | served overlay declaration ignored | badge on a served override | | A11 | no `noVersion` block | no version disabled | | A12 | `StudioReadOnlyLink` ignores the three types | no Setup link (3 cases) | | A13 | `canCreate` not passed | "Add view" stays off | | A14 | no `If-None-Match` pin (leaf) | pins | | A15 | no `If-None-Match` pin (list view) | list view customize | | A16 | `leafReadOnly` always false | Customize writes nothing | | A17 | `basedOn` not read from the listed manifest | end to end at `4.0.0` | | A18 | save uses the listed pair, not the door's answer | first saved edit | ## Live measure This was measured on `pnpm dev:crm` (base `com.example.crm`, read-only, view `crm_lead.all`) through the server's doors with curl. The browser UI was not driven; the UI is covered by the tests above. - `POST /api/v1/packages/com.example.crm/ensure-customization` answered **201** with `created: true` on the first call and **200** with `created: false` after. `data.packageId` was `com.example.crm.custom`, and the UI takes the pair from that answer. - `GET /api/v1/packages` served `com.example.crm` with `manifest.version` **`4.0.0`** and `writable: false`. - `PUT /meta/view/crm_lead.all?mode=draft&package=com.example.crm.custom` (`If-None-Match: *`) answered 200. The draft read at the pair carried `overrides: { package: "com.example.crm", basedOn: "4.0.0" }`, and `_drafts?packageId=com.example.crm.custom` listed it. - `POST /packages/com.example.crm.custom/publish-drafts` answered `published`, 1. Then `GET …/layers?package=com.example.crm` returned **`overlay.overrides` = the declaration** and `effective.label` "All leads (customized)". - Restore: `DELETE …?state=draft&package=com.example.crm.custom` answered 200, then `DELETE …?package=com.example.crm.custom` answered 200. The layered read then returned `overlay` null and `effective.label` "All Leads", **the base read back**. ## Acceptance notes - **Not here (per the card):** rail marks, filter, switcher fold, New (N-19), flows (N-18), translations and email templates (N-25), the rest of the link and the package-less write (N-16b), and migration (N-16a). - **Deviations:** - The item tools (history, source) stay read-only on a read-only base, also while customizing. - "Add view" stays off on a read-only base. A new view would be a new item of the base, which is N-19. - A list view the server does not hold yet, such as an object's default view that was never created, offers no Customize. - A walled admin's Restore is not disabled ahead of time. The server's verdict is shown. - The `If-None-Match: *` pin on the first pair save is an addition. ## Verification - `pnpm --filter @object-ui/app-shell run type-check` (src + tests): exit 0 at `6674886524`. - All 291 `views/studio-design` test files: green at `6674886524` (2108 tests). The 85 tests outside it that read the studio namespace: green at `4bff2978d7`. `packages/i18n` (94 files): green. - The 34 derived gates (`dispatch-gates --commands`) ran at `0513534c9f`: 34 of 34 exit 0, reconciled with `--ran` (0 NOT-MEASURED, 0 UNRUN). `check:sdui-manifest`, `check:nul-bytes`, `check:i18n-keys`, `check:i18n-drift` and `check:i18n-dead-keys` were re-run at `4bff2978d7`: exit 0. ESLint on the touched files reported 0 errors. - Merged `origin/main` three times. The second merge brought #28668 (the card #28567) and the third brought #28669 (the card #28573); both merged clean. At the head `1ae8358eb7`: app-shell `type-check` exit 0; this card's four test files, #28573's two Studio and catalog test files, `packages/i18n` (94 files) and the zh wording census green; `check-sdui-manifest`, `check:nul-bytes`, `check:i18n-keys`, `check:i18n-drift` and `check:i18n-dead-keys` exit 0. No parallel fix of this card is on main. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jQpUMdR7QmHKTFR8iB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b63f5f commit d62d973

11 files changed

Lines changed: 2183 additions & 51 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): customize a read-only package's view, dashboard or report into its `<base>.custom` pair, with an override badge and "Restore package original"
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
An installed package is read-only in Studio, so until now its views, dashboards and reports could only be looked at there. Studio now customizes them into the base's paired customization package (`<base>.custom`, ADR-0146 D2 and D3), as a declared override of the base's item.
12+
13+
- **Customize.** The item header of a view, dashboard or report on a read-only base, and of an object leaf's list view, offers Customize. Pressing it only unlocks the item's editors. Nothing is written until an edit is saved.
14+
- **The first saved edit.** It calls `POST /api/v1/packages/:id/ensure-customization` once for that item, with no prompt and no body. It then saves the draft into the package the answer names (`201` when the door created the pair, `200` when it was there). The draft is the item as Studio holds it, without its `_` keys, with `overrides: { package, basedOn }`. `basedOn` is the base's `manifest.version` as the package list serves it. ⛔ The pair's id for a write is the door's answer, never a `.custom` spelled in the browser. The first save is pinned to "no draft there" (`If-None-Match: *`), so a draft someone else left in the pair is asked about, never overwritten. A base whose listed manifest states no version gets a disabled Customize that says so. Nothing is guessed.
15+
- **Walled callers.** Under a `group` or `isolated` posture, an admin who is not the platform operator sees Customize disabled, with the reason. This is read from the two facts the server publishes (`features.tenancyPosture`, `user.isPlatformAdmin`). A door that refuses anyway is shown as the server sent it.
16+
- **Pending and Publish.** On a read-only base with a pair, the pending count, the changes panel and Publish act on the pair's drafts. A refusal at the publish door is shown as it is sent. A read-only base with no pair keeps the read-only Publish, as before.
17+
- **Badge.** The header says the item overrides the base (for example "Overrides HotCRM", with the version in its title). It is read from the declaration the server serves: the pair's draft, else the layered read's overlay. It is never computed.
18+
- **Restore package original.** The item's ⋯ menu offers it on an item customized into the pair. After a confirmation, it deletes the pair's draft of the item, then its row (`DELETE /meta/:type/:name?package=<pair>`). The base's item reads back.
19+
- **The read-only mark** no longer links a view, dashboard or report to Setup's overlay page.
20+
- **Unchanged.** A writable package is edited in place, as before. A new view ("Add view") on a read-only base stays unavailable. The item tools (history, source) stay read-only on the base.
21+
- **New strings.** `@object-ui/i18n`'s studio namespace gains twelve `engine.studio.customize.*` keys in `en` and `zh`. The other languages fall back to `en`, as the whole namespace does.

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

Lines changed: 583 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,28 @@ export type NewViewPayload = Parameters<CreateViewDialogProps['onCreate']>[0];
378378
/** Why "Add view" saved nothing: a view already holds the name, or the save failed. */
379379
export type ViewCreateRefusal = { taken: string } | { error: unknown };
380380

381+
/**
382+
* objectstack#28562 (ADR-0146 D2) — where a read-only base's list view is
383+
* customized: read in the base's paired customization package, written there
384+
* as an override of the base's view. The Interfaces pillar passes it on a
385+
* read-only base only; without it the view is read and written at `packageId`.
386+
*/
387+
export interface ListViewCustomization {
388+
/**
389+
* The package the view's draft is read in when a load runs: the pair when
390+
* the package list lists one, else `null` (the view is then read at
391+
* `packageId`, where no pair draft can be). Asked at load time, so a pair
392+
* the first save creates is never a reason to reload the buffer it saved.
393+
*/
394+
readAt: () => string | null;
395+
/**
396+
* The write: the package the save addresses (the ensure door's answer) and
397+
* the body it sends (the view, declaring the override). Rejects with the
398+
* door's refusal, and then nothing is saved.
399+
*/
400+
writeTarget: (viewId: string, row: Row) => Promise<{ packageId: string; body: Row }>;
401+
}
402+
381403
/**
382404
* The open leaf's list-view buffer, held by the Interfaces pillar so the
383405
* canvas and the panel read one copy (the folded layout unmounts the panel
@@ -391,6 +413,7 @@ export function useObjectListViewDraft({
391413
publishNonce,
392414
draftOnlyObjects,
393415
onDraftSaved,
416+
customization = null,
394417
}: {
395418
client: MetadataClient;
396419
packageId: string;
@@ -403,6 +426,12 @@ export function useObjectListViewDraft({
403426
*/
404427
draftOnlyObjects?: DraftOnlyObjects | null;
405428
onDraftSaved?: () => void;
429+
/**
430+
* objectstack#28562 — on a read-only base, where the view is customized
431+
* ({@link ListViewCustomization}). Read through a ref: only its presence is
432+
* a reason to load again.
433+
*/
434+
customization?: ListViewCustomization | null;
406435
}): ObjectListViewDraft {
407436
const metadata = useMetadata();
408437
const { user, activeOrganization } = useAuth();
@@ -494,6 +523,16 @@ export function useObjectListViewDraft({
494523
React.useEffect(() => {
495524
draftOnlyRef.current = draftOnlyObjects;
496525
}, [draftOnlyObjects]);
526+
// objectstack#28562 — read when a load or a save runs; `customizing` is
527+
// the one fact of it a load is keyed on.
528+
const customizationRef = React.useRef(customization);
529+
React.useLayoutEffect(() => {
530+
customizationRef.current = customization;
531+
});
532+
const customizing = customization !== null;
533+
// objectstack#28562 — the view and package the installed buffer's draft
534+
// read was asked at: the address its pin describes.
535+
const readAtRef = React.useRef<{ viewId: string; at: string } | null>(null);
497536

498537
React.useEffect(() => {
499538
setFailure(null);
@@ -505,8 +544,10 @@ export function useObjectListViewDraft({
505544
(async () => {
506545
try {
507546
// objectui#11773 — read at the address the save below writes, so the
508-
// version it serves pins that save.
509-
const draftRead = await readDraftForSave<Row>(client, 'view', viewId, packageId);
547+
// version it serves pins that save. objectstack#28562 — on a
548+
// read-only base that is the pair, when one is listed.
549+
const readAt = customizationRef.current?.readAt() ?? packageId;
550+
const draftRead = await readDraftForSave<Row>(client, 'view', viewId, readAt);
510551
if (cancelled) return;
511552
const draftResp = draftRead.answer;
512553
// A served draft is the whole document, taken as-is (objectui#10765);
@@ -558,7 +599,8 @@ export function useObjectListViewDraft({
558599
? publishedRow
559600
: undefined;
560601
// objectui#11773 — the next save is pinned to what the draft read served.
561-
recordRead('view', viewId, packageId, draftRead.pin);
602+
recordRead('view', viewId, readAt, draftRead.pin);
603+
readAtRef.current = { viewId, at: readAt };
562604
setBuffer({
563605
for: listViewBufferKey(viewId),
564606
row,
@@ -574,7 +616,7 @@ export function useObjectListViewDraft({
574616
return () => {
575617
cancelled = true;
576618
};
577-
}, [client, viewId, objectName, packageId, publishNonce, reloadNonce, recordRead]);
619+
}, [client, viewId, objectName, packageId, publishNonce, reloadNonce, recordRead, customizing]);
578620

579621
const edit = React.useCallback((row: Row) => {
580622
setBuffer((b) => ({ ...b, row, dirty: true }));
@@ -618,7 +660,24 @@ export function useObjectListViewDraft({
618660
if (!viewId || !row || notList) return;
619661
setSaving(true);
620662
try {
621-
const outcome = await guardedSave('view', viewId, row, { mode: 'draft', packageId, surface: 'studio' });
663+
// objectstack#28562 — a customization is written where the ensure
664+
// door says, with its declaration; a refused door saves nothing.
665+
const custom = customizationRef.current;
666+
const target = custom ? await custom.writeTarget(viewId, row) : { packageId, body: row };
667+
// The buffer was read at another package (the base, before its pair
668+
// was listed): the pair is taken to hold no draft of the view, so a
669+
// draft there is a conflict the author is asked about, never one
670+
// this save overwrites. Its receipt pins the saves after it.
671+
const readAt = readAtRef.current;
672+
if (custom && !(readAt?.viewId === viewId && readAt.at === target.packageId)) {
673+
recordRead('view', viewId, target.packageId, { ifNoneMatch: '*' });
674+
readAtRef.current = { viewId, at: target.packageId };
675+
}
676+
const outcome = await guardedSave('view', viewId, target.body, {
677+
mode: 'draft',
678+
packageId: target.packageId,
679+
surface: 'studio',
680+
});
622681
// objectui#11773 — the author chose the saved version; the load replaces the buffer.
623682
if (outcome === 'reloaded') return;
624683
// objectui#11204 — clean only if nothing was edited while it was in flight.
@@ -632,7 +691,7 @@ export function useObjectListViewDraft({
632691
setSaving(false);
633692
}
634693
},
635-
[guardedSave, viewId, row, notList, packageId, onDraftSaved],
694+
[guardedSave, recordRead, viewId, row, notList, packageId, onDraftSaved],
636695
);
637696

638697
const current = buffer.for === targetKey && targetKey !== '';
@@ -1057,6 +1116,7 @@ function KanbanCardSection({
10571116
export function ObjectListViewInspector({
10581117
listView,
10591118
readOnly,
1119+
canCreate = !readOnly,
10601120
locale,
10611121
selection,
10621122
onSelectionChange,
@@ -1065,6 +1125,12 @@ export function ObjectListViewInspector({
10651125
}: {
10661126
listView: ObjectListViewDraft;
10671127
readOnly: boolean;
1128+
/**
1129+
* objectstack#28562 — whether "Add view" is offered; `!readOnly` by default.
1130+
* A read-only base's view is customized in its pair, while a new view would
1131+
* be a new item in the base, which it does not take.
1132+
*/
1133+
canCreate?: boolean;
10681134
locale: string;
10691135
/**
10701136
* objectstack#27836 — called as "Add view" opens its dialog: the host sends
@@ -1320,7 +1386,7 @@ export function ObjectListViewInspector({
13201386
variant="outline"
13211387
size="sm"
13221388
className="h-7 gap-1 text-xs"
1323-
disabled={readOnly || fieldState.loading || !!fieldState.error}
1389+
disabled={!canCreate || fieldState.loading || !!fieldState.error}
13241390
onClick={() => {
13251391
onAddViewOpen?.();
13261392
setAddOpen(true);

0 commit comments

Comments
 (0)