From 9efe4fdd058b821026646180d45ac5b996b27655 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:33:17 +0000 Subject: [PATCH 1/5] wip(studio): customize a read-only base's view, dashboard or report into its pair Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQpUMdR7QmHKTFR8iB --- .../studio-design/ObjectListViewInspector.tsx | 80 ++- .../studio-design/StudioCustomizeControls.tsx | 347 ++++++++++++ .../studio-design/StudioDesignSurface.tsx | 511 ++++++++++++++++-- .../src/views/studio-design/packages-io.ts | 89 ++- ui/packages/i18n/src/namespaces/studio/en.ts | 13 + ui/packages/i18n/src/namespaces/studio/zh.ts | 12 + 6 files changed, 1006 insertions(+), 46 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx index 3b6fdbda3e6..21f81620675 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx @@ -378,6 +378,28 @@ export type NewViewPayload = Parameters[0]; /** Why "Add view" saved nothing: a view already holds the name, or the save failed. */ export type ViewCreateRefusal = { taken: string } | { error: unknown }; +/** + * objectstack#28562 (ADR-0146 D2) — where a read-only base's list view is + * customized: read in the base's paired customization package, written there + * as an override of the base's view. The Interfaces pillar passes it on a + * read-only base only; without it the view is read and written at `packageId`. + */ +export interface ListViewCustomization { + /** + * The package the view's draft is read in when a load runs: the pair when + * the package list lists one, else `null` (the view is then read at + * `packageId`, where no pair draft can be). Asked at load time, so a pair + * the first save creates is never a reason to reload the buffer it saved. + */ + readAt: () => string | null; + /** + * The write: the package the save addresses (the ensure door's answer) and + * the body it sends (the view, declaring the override). Rejects with the + * door's refusal, and then nothing is saved. + */ + writeTarget: (viewId: string, row: Row) => Promise<{ packageId: string; body: Row }>; +} + /** * The open leaf's list-view buffer, held by the Interfaces pillar so the * canvas and the panel read one copy (the folded layout unmounts the panel @@ -391,6 +413,7 @@ export function useObjectListViewDraft({ publishNonce, draftOnlyObjects, onDraftSaved, + customization = null, }: { client: MetadataClient; packageId: string; @@ -403,6 +426,12 @@ export function useObjectListViewDraft({ */ draftOnlyObjects?: DraftOnlyObjects | null; onDraftSaved?: () => void; + /** + * objectstack#28562 — on a read-only base, where the view is customized + * ({@link ListViewCustomization}). Read through a ref: only its presence is + * a reason to load again. + */ + customization?: ListViewCustomization | null; }): ObjectListViewDraft { const metadata = useMetadata(); const { user, activeOrganization } = useAuth(); @@ -494,6 +523,16 @@ export function useObjectListViewDraft({ React.useEffect(() => { draftOnlyRef.current = draftOnlyObjects; }, [draftOnlyObjects]); + // objectstack#28562 — read when a load or a save runs; `customizing` is + // the one fact of it a load is keyed on. + const customizationRef = React.useRef(customization); + React.useLayoutEffect(() => { + customizationRef.current = customization; + }); + const customizing = customization !== null; + // objectstack#28562 — the view and package the installed buffer's draft + // read was asked at: the address its pin describes. + const readAtRef = React.useRef<{ viewId: string; at: string } | null>(null); React.useEffect(() => { setFailure(null); @@ -505,8 +544,10 @@ export function useObjectListViewDraft({ (async () => { try { // objectui#11773 — read at the address the save below writes, so the - // version it serves pins that save. - const draftRead = await readDraftForSave(client, 'view', viewId, packageId); + // version it serves pins that save. objectstack#28562 — on a + // read-only base that is the pair, when one is listed. + const readAt = customizationRef.current?.readAt() ?? packageId; + const draftRead = await readDraftForSave(client, 'view', viewId, readAt); if (cancelled) return; const draftResp = draftRead.answer; // A served draft is the whole document, taken as-is (objectui#10765); @@ -558,7 +599,8 @@ export function useObjectListViewDraft({ ? publishedRow : undefined; // objectui#11773 — the next save is pinned to what the draft read served. - recordRead('view', viewId, packageId, draftRead.pin); + recordRead('view', viewId, readAt, draftRead.pin); + readAtRef.current = { viewId, at: readAt }; setBuffer({ for: listViewBufferKey(viewId), row, @@ -574,7 +616,7 @@ export function useObjectListViewDraft({ return () => { cancelled = true; }; - }, [client, viewId, objectName, packageId, publishNonce, reloadNonce, recordRead]); + }, [client, viewId, objectName, packageId, publishNonce, reloadNonce, recordRead, customizing]); const edit = React.useCallback((row: Row) => { setBuffer((b) => ({ ...b, row, dirty: true })); @@ -618,7 +660,24 @@ export function useObjectListViewDraft({ if (!viewId || !row || notList) return; setSaving(true); try { - const outcome = await guardedSave('view', viewId, row, { mode: 'draft', packageId, surface: 'studio' }); + // objectstack#28562 — a customization is written where the ensure + // door says, with its declaration; a refused door saves nothing. + const custom = customizationRef.current; + const target = custom ? await custom.writeTarget(viewId, row) : { packageId, body: row }; + // The buffer was read at another package (the base, before its pair + // was listed): the pair is taken to hold no draft of the view, so a + // draft there is a conflict the author is asked about, never one + // this save overwrites. Its receipt pins the saves after it. + const readAt = readAtRef.current; + if (custom && !(readAt?.viewId === viewId && readAt.at === target.packageId)) { + recordRead('view', viewId, target.packageId, { ifNoneMatch: '*' }); + readAtRef.current = { viewId, at: target.packageId }; + } + const outcome = await guardedSave('view', viewId, target.body, { + mode: 'draft', + packageId: target.packageId, + surface: 'studio', + }); // objectui#11773 — the author chose the saved version; the load replaces the buffer. if (outcome === 'reloaded') return; // objectui#11204 — clean only if nothing was edited while it was in flight. @@ -632,7 +691,7 @@ export function useObjectListViewDraft({ setSaving(false); } }, - [guardedSave, viewId, row, notList, packageId, onDraftSaved], + [guardedSave, recordRead, viewId, row, notList, packageId, onDraftSaved], ); const current = buffer.for === targetKey && targetKey !== ''; @@ -1057,6 +1116,7 @@ function KanbanCardSection({ export function ObjectListViewInspector({ listView, readOnly, + canCreate = !readOnly, locale, selection, onSelectionChange, @@ -1065,6 +1125,12 @@ export function ObjectListViewInspector({ }: { listView: ObjectListViewDraft; readOnly: boolean; + /** + * objectstack#28562 — whether "Add view" is offered; `!readOnly` by default. + * A read-only base's view is customized in its pair, while a new view would + * be a new item in the base, which it does not take. + */ + canCreate?: boolean; locale: string; /** * objectstack#27836 — called as "Add view" opens its dialog: the host sends @@ -1320,7 +1386,7 @@ export function ObjectListViewInspector({ variant="outline" size="sm" className="h-7 gap-1 text-xs" - disabled={readOnly || fieldState.loading || !!fieldState.error} + disabled={!canCreate || fieldState.loading || !!fieldState.error} onClick={() => { onAddViewOpen?.(); setAddOpen(true); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx new file mode 100644 index 00000000000..f7a30c342da --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx @@ -0,0 +1,347 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * 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 + * 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, each an `overridable` type of the spec's closed + * classification (`isOverridableMetadataType`); + * - the body the pair's draft is written with ({@link overrideDraftBody}): + * the document the pillar holds, without its `_` keys, declaring + * `overrides: { package, basedOn }`; + * - the declaration the server serves for an item ({@link useServedOverride}): + * the pair's draft as the draft door serves it, else the layered read's + * `overlay` (the pair's row the read serves over the base, N-10). The badge + * reads it, ⛔ never a computation (no diff, no `.custom` spelling); + * - whether the ensure door refuses this caller + * ({@link customizeDoorWalled}), from the two facts the server publishes; + * - the header's Customize button, the override badge and the item menu's + * Restore entry with its confirmation. + * + * The pair's id for a write is the ensure door's answer + * (`packages-io.ts#ensureCustomizationPackage`); the pillar asks it. + */ + +import * as React from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + DropdownMenuItem, + DropdownMenuSeparator, + cn, +} from '@object-ui/components'; +import { useAuth } from '@object-ui/auth'; +import type { TenancyPosture } from '@object-ui/auth'; +import { extractDraftBody, type MetadataClient } from '@object-ui/data-objectstack'; +import { + OverrideDeclarationSchema, + isOverridableMetadataType, + type OverrideDeclarationParsed, +} from '@objectstack/spec/kernel'; +import { Layers, Pencil, RotateCcw } from 'lucide-react'; +import { postureHasOrgWall, useTenancyPosture } from '../../hooks/useTenancyPosture.js'; +import { t, tFormat } from '../metadata-admin/i18n.js'; + +/** + * The types Studio's Customize door covers: the Interfaces pillar's view, + * dashboard and report (an `object` leaf's list view is a `view`). Each is an + * `overridable` type of the spec's classification; translations and email + * templates are too, but no pillar designs them here. + */ +export const STUDIO_CUSTOMIZE_TYPES: readonly string[] = ['view', 'dashboard', 'report']; + +/** Whether Studio customizes an item of `type` into a read-only base's pair. */ +export function isStudioCustomizeType(type: string | undefined | null): boolean { + return !!type && STUDIO_CUSTOMIZE_TYPES.includes(type) && isOverridableMetadataType(type); +} + +/** + * The override declaration of `base` that `doc` carries (`doc.overrides`, + * parsed by the spec's `OverrideDeclarationSchema`), or `null`: a document + * that declares nothing, declares another base, or carries a malformed + * declaration declares no override of `base`. + */ +export function declaredOverrideOf(doc: unknown, base: string): OverrideDeclarationParsed | null { + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return null; + const parsed = OverrideDeclarationSchema.safeParse((doc as { overrides?: unknown }).overrides); + return parsed.success && parsed.data.package === base ? parsed.data : null; +} + +/** + * The body a customization's draft is written into the pair with: `doc` + * without its top-level `_` keys (the read's decorations and the base's + * protection envelope, `_packageId` included, belong to the base's item, not + * to the pair's), declaring the override of `base`. A declaration of `base` + * the document already carries (an item customized before) is kept as it is, + * its `basedOn` included: re-basing is a person's call, never a save's. + * `null` when there is no declaration to keep and no version to declare + * (`basedOn: null`, a base whose listed manifest states none): nothing is + * guessed, and the caller says so. + */ +export function overrideDraftBody( + doc: Record, + base: string, + basedOn: string | null, +): Record | null { + const declaration = declaredOverrideOf(doc, base) ?? (basedOn ? { package: base, basedOn } : null); + if (declaration === null) return null; + const body: Record = {}; + for (const [key, value] of Object.entries(doc)) { + if (!key.startsWith('_')) body[key] = value; + } + body.overrides = declaration; + return body; +} + +/** + * Whether the ensure door refuses this caller on this deployment. The door + * (`POST /packages/:id/ensure-customization`) judges the caller as the + * package install door does: under the walled `group` / `isolated` postures + * only the platform operator may call it, and under `single` (or with no + * posture served) the `manage_metadata` holder Studio already requires. Read + * from the two facts the server publishes for that verdict, the posture + * (`features.tenancyPosture`) and the session's `user.isPlatformAdmin`, the + * ones `setup/packagedFlowLedger.ts#useFlowLedgerWalledReadOnly` reads. The + * server stays the judge: a refusal after an attempt is shown as it is sent. + */ +export function customizeDoorWalled(input: { + posture: TenancyPosture | undefined; + isPlatformOperator: boolean; +}): boolean { + return postureHasOrgWall(input.posture) && !input.isPlatformOperator; +} + +/** {@link customizeDoorWalled} for this session and this deployment. */ +export function useCustomizeDoorWalled(): boolean { + const posture = useTenancyPosture(); + const { user } = useAuth(); + return customizeDoorWalled({ posture, isPlatformOperator: user?.isPlatformAdmin === true }); +} + +/** The override the server serves for one item, and where it is served from. */ +export interface ServedOverride { + declaration: OverrideDeclarationParsed | null; + /** `draft`: the pair's pending draft declares it; `active`: the served row does. */ + state: 'draft' | 'active' | null; +} + +const NO_OVERRIDE: ServedOverride = { declaration: null, state: null }; + +/** + * The override of `base` the server serves for `item`: the pair's pending + * draft, read through the draft door at `pair`, when it declares one; else the + * layered read's `overlay` at `base`, which is the pair's row the read serves + * over the base's item. Read again whenever `nonce` moves (a publish, a + * restore, the item's first save into the pair). `item: null` reads nothing. + * + * A failed read is no declaration, as an absent one: no fact, no badge. + */ +export function useServedOverride( + client: Pick, + item: { type: string; name: string } | null, + base: string | null, + pair: string | null, + nonce: string | number, +): ServedOverride { + const itemType = item?.type ?? ''; + const itemName = item?.name ?? ''; + const key = itemType && itemName && base ? JSON.stringify([itemType, itemName, base]) : ''; + const [answer, setAnswer] = React.useState<{ key: string; served: ServedOverride }>({ key: '', served: NO_OVERRIDE }); + React.useEffect(() => { + if (!key || !base) return; + let cancelled = false; + (async (): Promise => { + if (pair) { + const draft = await client.getDraft(itemType, itemName, { packageId: pair }).catch(() => null); + const declared = declaredOverrideOf(extractDraftBody(draft), base); + if (declared) return { declaration: declared, state: 'draft' }; + } + const layered = await client.layered(itemType, itemName, { packageId: base }).catch(() => null); + const declared = declaredOverrideOf(layered?.overlay, base); + return declared ? { declaration: declared, state: 'active' } : NO_OVERRIDE; + })().then((served) => { + if (!cancelled) setAnswer({ key, served }); + }); + return () => { + cancelled = true; + }; + }, [client, key, itemType, itemName, base, pair, nonce]); + // A re-read of the same item keeps the last answer until it lands, so the + // badge does not blink off and on after a save. + return key && answer.key === key ? answer.served : NO_OVERRIDE; +} + +/** Why Customize is not offered for an item it covers, or `null` when it is. */ +export type CustomizeBlock = 'walled' | 'noVersion' | null; + +/** + * The header's Customize button. Pressing it only unlocks the item's editors: + * nothing is written until the first edit is saved. Disabled with its reason + * as the wrapper's tooltip and description (objectui#12303's pattern: a + * natively disabled button shows no `title`). + */ +export function StudioCustomizeButton({ + block, + baseLabel, + locale, + onCustomize, +}: { + block: CustomizeBlock; + baseLabel: string; + locale: string; + onCustomize: () => void; +}): React.ReactElement { + const reasonId = React.useId(); + const reason = + block === 'walled' + ? t('engine.studio.customize.walled', locale) + : block === 'noVersion' + ? tFormat('engine.studio.customize.noVersion', locale, { base: baseLabel }) + : null; + const button = ( + + ); + if (reason === null) return button; + return ( + + {button} + + {reason} + + + ); +} + +/** + * The header's badge: the item overrides the base. Drawn from a served + * declaration ({@link useServedOverride}); `basedOn` is the version it names. + */ +export function StudioOverrideChip({ + baseLabel, + basedOn, + locale, +}: { + baseLabel: string; + basedOn: string; + locale: string; +}): React.ReactElement { + return ( + + + ); +} + +/** + * "Restore package original" on the item's ⋯ menu. The confirmation is + * {@link StudioRestoreOriginalDialog}, held by the host outside the menu: a + * menu's content unmounts as the menu closes on select, and a dialog inside it + * would close with it. + */ +export function StudioRestoreOriginalItem({ + disabled, + locale, + onSelect, +}: { + disabled?: boolean; + locale: string; + onSelect: () => void; +}): React.ReactElement { + return ( + <> + + + + + ); +} + +/** + * The confirmation of "Restore package original": it says what goes, the + * customization, and its unpublished draft when one is pending. `onConfirm` + * sends both deletes; nothing is sent on Cancel. + */ +export function StudioRestoreOriginalDialog({ + open, + onOpenChange, + label, + baseLabel, + hasDraft, + locale, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The item's display label; its machine name when it has none. */ + label: string; + baseLabel: string; + hasDraft: boolean; + locale: string; + onConfirm: () => void; +}): React.ReactElement { + const vars = { item: label, base: baseLabel }; + const body = tFormat('engine.studio.customize.restoreBody', locale, vars); + return ( + + + + {tFormat('engine.studio.customize.restoreConfirm', locale, vars)} + + {hasDraft ? `${body} ${t('engine.studio.customize.restoreDraft', locale)}` : body} + + + + {t('engine.cancel', locale)} + { + onOpenChange(false); + onConfirm(); + }} + > + {t('engine.studio.customize.restore', locale)} + + + + + ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index ec2be499d9f..8ed92b017f3 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -211,6 +211,7 @@ import { ObjectListViewInspector, useObjectListViewDraft, type DraftOnlyObjects, + type ListViewCustomization, } from './ObjectListViewInspector.js'; import { DEFAULT_PAGE_SOURCE_KIND, @@ -380,7 +381,28 @@ import { ObjectApiPanel } from './ObjectApiPanel.js'; import { ObjectHooksPanel } from './ObjectHooksPanel.js'; import { ObjectActionsPanel } from './ObjectActionsPanel.js'; import { getIcon } from '../../utils/getIcon.js'; -import { fetchListedPackageIds, fetchPackages, prefixObjectName, type PkgEntry } from './packages-io.js'; +import { + ensureCustomizationPackage, + fetchListedPackageIds, + fetchListedPackageManifests, + fetchPackages, + listedCustomizationPairOf, + listedPackageVersion, + prefixObjectName, + type PkgEntry, +} from './packages-io.js'; +import { + StudioCustomizeButton, + StudioOverrideChip, + StudioRestoreOriginalDialog, + StudioRestoreOriginalItem, + isStudioCustomizeType, + overrideDraftBody, + useCustomizeDoorWalled, + useServedOverride, + type CustomizeBlock, + type ServedOverride, +} from './StudioCustomizeControls.js'; import { DraftChangesPanel, type DraftChangeEntry, @@ -838,6 +860,10 @@ const PACKAGE_LIST_TOAST_ID = 'studio-package-list'; * overlay is edited. Any other item, no open item, or a registry not yet * read draws a plain "Read-only" mark that links nowhere. * + * objectstack#28562 (ADR-0146 D2) — nor does a view, dashboard or report + * (`isStudioCustomizeType`): Studio customizes those itself, into the base's + * paired customization package, so Setup's overlay page is not their editor. + * * objectui#12707 — at a tab row's end, the mark folds to its glyph when the * row cannot hold it whole (`useStudioTabRowFold`), rather than being cut * mid-word at the row's edge. Folded, its text stays as its accessible name @@ -855,7 +881,10 @@ export function StudioReadOnlyLink({ }): React.ReactElement { const client = useMetadataClient(); const { entries } = useMetadataTypes(client); - const overlayable = item !== null && entries.some((e) => e.type === item.type && e.allowEnvironmentOverlay === true); + const overlayable = + item !== null && + !isStudioCustomizeType(item.type) && + entries.some((e) => e.type === item.type && e.allowEnvironmentOverlay === true); const ref = React.useRef(null); const label = t(overlayable ? 'engine.studio.pkg.readonlyLink' : 'engine.studio.pkg.readonly', locale); const folded = useStudioTabRowFold(ref, label); @@ -1574,6 +1603,15 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): // (`fetchListedPackageIds`, the same request), because the switcher's // entries hide the kernel packages that ARE installed. const [pkgMissing, setPkgMissing] = React.useState(false); + // objectstack#28562 (ADR-0146 D2, D3) — what customizing a read-only base + // reads off the same list: the version its manifest declares (an override's + // `basedOn`) and its paired customization package when one is listed. Held + // with the id it answered for; `null` until read, and on a writable package. + const [baseFacts, setBaseFacts] = React.useState<{ + for: string; + basedOn: string | null; + listedPair: string | null; + } | null>(null); React.useEffect(() => { // objectui#11553 — the package-less scope has no package to look up; its // writability is decided below, not fetched. @@ -1581,16 +1619,32 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): let cancelled = false; setPkgWritable(undefined); setPkgMissing(false); + setBaseFacts(null); // Asked beside the entries read, so both share its one request. A failure // is reported once, by the entries read below. const listedIds = fetchListedPackageIds(); listedIds.catch(() => {}); + const listedManifests = fetchListedPackageManifests(); + listedManifests.catch(() => {}); fetchPackages() .then((list) => { if (cancelled) return; const entry = list.find((p) => p.id === packageId) ?? null; setPkgWritable(entry?.writable ?? null); setPkgEntry(entry); + if (entry && !entry.writable) { + listedManifests.then( + (manifests) => { + if (cancelled) return; + setBaseFacts({ + for: packageId, + basedOn: listedPackageVersion(manifests, packageId), + listedPair: listedCustomizationPairOf(manifests, packageId), + }); + }, + () => {}, + ); + } if (entry) return; listedIds.then( (ids) => { @@ -1617,6 +1671,43 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): // own, so they open editable whatever the last package answered. The // server's write gate stays the authority, exactly as for a writable package. const readOnly = packageId !== null && pkgWritable === false; + // objectstack#28562 — the base's pair: the ensure door's answer once this + // surface has asked it, else the listed one. Pending, the changes panel and + // Publish act on its drafts (`draftScope`); with no pair, on the base's own, + // as before. ⛔ A write names the door's answer only (`ensurePair`). + const [ensuredPair, setEnsuredPair] = React.useState<{ base: string; packageId: string } | null>(null); + const baseRead = readOnly && baseFacts !== null && baseFacts.for === packageId ? baseFacts : null; + const pairId = + !readOnly || packageId === null + ? null + : ensuredPair?.base === packageId + ? ensuredPair.packageId + : (baseRead?.listedPair ?? null); + const draftScope = pairId ?? packageId; + // A read-only base with no pair has nothing of its own to publish. + const publishReadOnly = readOnly && pairId === null; + const customizeWalled = useCustomizeDoorWalled(); + const ensurePair = React.useCallback(async (): Promise => { + if (packageId === null) throw new Error('No package is open.'); + const answer = await ensureCustomizationPackage(packageId); + setEnsuredPair({ base: packageId, packageId: answer.packageId }); + return answer.packageId; + }, [packageId]); + const baseLabel = pkgEntry?.id === packageId ? pkgEntry.name : (packageId ?? ''); + const customization = React.useMemo( + () => + baseRead && packageId !== null + ? { + base: packageId, + baseLabel, + basedOn: baseRead.basedOn, + walled: customizeWalled, + pairId, + ensure: ensurePair, + } + : null, + [baseRead, packageId, baseLabel, customizeWalled, pairId, ensurePair], + ); // objectui#2600 — the header's pillar links, Home button and PackageSwitcher // are pure SPA client navigation, so the editors' `beforeunload` guard never @@ -1723,7 +1814,7 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): entries: pendingEntries, refresh: refreshPending, behindSave, - } = usePendingDrafts({ packageId }); + } = usePendingDrafts({ packageId: draftScope }); const pendingCount = packageId !== null ? feedCount : feedCount === null ? null : pendingEntries.filter(isOrgScopeDraft).length; @@ -1769,7 +1860,7 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): const body = Array.isArray(f.issues) && f.issues.length > 0 ? await shellClient - .getDraft>(f.type, f.name, packageId !== null ? { packageId } : {}) + .getDraft>(f.type, f.name, draftScope !== null ? { packageId: draftScope } : {}) .then((resp) => extractDraftBody(resp) as Record | null, () => null) : null; return [publishRefusalKey(type, f.name), f.name, publishRefusal(f, type, body, locale)] as const; @@ -1849,9 +1940,11 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): // PR objectui#10038 made for the two sibling call sites. // objectui#12929 — the sheet's options ride only when it gave some, so an // unchecked publish is the very call it was before. + // objectstack#28562 — on a read-only base, its pair's drafts. + const publishTarget = pairId ?? packageId; const payload = (await (options - ? shellClient.publishPackageDrafts(packageId, options) - : shellClient.publishPackageDrafts(packageId))) as { + ? shellClient.publishPackageDrafts(publishTarget, options) + : shellClient.publishPackageDrafts(publishTarget))) as { success?: boolean; outcome?: string; error?: { message?: string; details?: { issues?: unknown } }; @@ -1960,7 +2053,7 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): setPublishing(false); } await refreshPending(); - }, [shellClient, packageId, refreshPending, locale, heldEdit]); + }, [shellClient, packageId, pairId, draftScope, refreshPending, locale, heldEdit]); // objectui#11787 — a Studio draft save reports here when it lands, and the // count follows it: the read it asks for is sent after the save, never @@ -2307,9 +2400,9 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): setChangesPosture('confirm'); setChangesOpen(true); }} - disabled={publishing || !hasPending || readOnly} + disabled={publishing || !hasPending || publishReadOnly} aria-describedby={ - readOnly + publishReadOnly ? publishReadOnlyReasonId : !hasPending && !publishing ? publishNoneReasonId @@ -2318,7 +2411,7 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): // A read-only package's reason is the wrapper's (objectui#12303), not a // `title` the disabled button can never show. title={ - readOnly + publishReadOnly ? undefined : hasPending ? t('engine.studio.publishTitle', locale) @@ -2331,11 +2424,11 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): className={cn( // The border is on both states so the swap never shifts the bar. 'inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-[13px] font-medium', - publishing || (hasPending && !readOnly) + publishing || (hasPending && !publishReadOnly) ? 'border-transparent bg-primary text-primary-foreground disabled:opacity-50' : 'text-muted-foreground', // The wrapper takes the hover: the disabled button lets it through. - readOnly && 'disabled:pointer-events-none', + publishReadOnly && 'disabled:pointer-events-none', )} > {publishing ? : } @@ -2471,7 +2564,7 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): objectui#12459 moves the sentence off the strip into the Publish button's description, where readers and the tooltip still find it. */} - {!hasPending && !readOnly && !publishing && ( + {!hasPending && !publishReadOnly && !publishing && ( @@ -2644,6 +2739,7 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): onCreateApp={readOnly ? undefined : () => setAppCreating(true)} readOnly={readOnly} readOnlySettled={pkgWritable !== undefined} + customization={customization} onDirtyChange={setPillarDirty} onSurfaceLabelChange={setSurfaceLabel} onHeldEditChange={setHeldEdit} @@ -2688,9 +2784,10 @@ export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): `${s?.type ?? ''}:${s?.name ?? ''}`; +/** + * objectstack#28562 (ADR-0146 D2, D3) — what the Interfaces pillar customizes + * a read-only base with, from the Studio surface: passed only on a read-only + * base, once the package list has answered for it. + */ +export interface StudioBaseCustomization { + /** The read-only base package. */ + base: string; + /** Its display name, as the top bar shows it. */ + baseLabel: string; + /** The version its listed manifest declares: an override's `basedOn`. `null` when the list states none. */ + basedOn: string | null; + /** The ensure door refuses this caller (`customizeDoorWalled`). */ + walled: boolean; + /** The base's pair, for READS (its drafts); `null` while it has none. */ + pairId: string | null; + /** The ensure door: resolves the pair's id from its answer, which every write names; rejects with its refusal. */ + ensure: () => Promise; +} + +/** The key an item of `base` is customized under: the base, `type` and `name`. */ +const customizeItemKey = (base: string, type: string, name: string): string => JSON.stringify([base, type, name]); + export function InterfacesPillar({ packageId, packageLabel, @@ -4111,12 +4231,17 @@ export function InterfacesPillar({ onDirtyChange, onSurfaceLabelChange, onHeldEditChange, + customization = null, }: { packageId: string; /** objectui#12718 — the package's display name, as the Studio top bar * reads it; `undefined` until the package list answers. The pillar names a * package with no app by it, so the id shows only when neither exists. */ packageLabel?: string; + /** objectstack#28562 — on a read-only base, what its views, dashboards and + * reports are customized with ({@link StudioBaseCustomization}); `null` + * otherwise, and then nothing here changes. */ + customization?: StudioBaseCustomization | null; publishNonce?: number; /** Bumped when a draft is saved elsewhere (e.g. the header's create-app flow) — a * re-resolve signal so a just-created app appears without a reload. */ @@ -4765,6 +4890,74 @@ export function InterfacesPillar({ // running app, not an editable draft — schema editing is the Data pillar's // job — so those leaves are not draft-editable in this canvas. const isEditable = !!Preview && !StudioCanvas; + // objectstack#28562 (ADR-0146 D2, D3) — Customize. On a read-only base a + // view, dashboard or report (and an `object` leaf's list view) is edited in + // the base's paired customization package, as a declared override of the + // base's item. Its editors stay read-only until Customize is pressed, or + // while the server serves no override of it; nothing is written until an + // edit is saved, and that save asks the ensure door for the pair (once per + // item) and writes there. ⛔ The pair a write names is the door's answer, + // never a `.custom` spelled here; the pair a READ names is the + // surface's (`StudioBaseCustomization.pairId`), read through this ref, so a + // pair the first save creates never reloads the buffer it saved. + const customizationRef = React.useRef(customization); + React.useLayoutEffect(() => { + customizationRef.current = customization; + }); + const customizing = readOnly && customization !== null; + const customizeBase = customizing && customization ? customization.base : null; + const customizePair = customizing && customization ? customization.pairId : null; + const customizeBaseLabel = customization?.baseLabel ?? packageId; + const customizeWalled = customizing && customization?.walled === true; + const customizeBlock: CustomizeBlock = customizeWalled + ? 'walled' + : customizing && !customization?.basedOn + ? 'noVersion' + : null; + // The items Customize was pressed on (`customizeItemKey`): unlocked, nothing saved yet. + const [customizeAsked, setCustomizeAsked] = React.useState>(() => new Set()); + const askCustomize = React.useCallback((key: string) => { + setCustomizeAsked((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + }, []); + // The ensure door's answer for each item's first saved edit: asked once + // per item, and again only after a refusal. + const pairsByItemRef = React.useRef(new Map>()); + const pairFor = React.useCallback( + (custom: StudioBaseCustomization, type: string, name: string): Promise => { + const key = customizeItemKey(custom.base, type, name); + const held = pairsByItemRef.current.get(key); + if (held) return held; + const asked = custom.ensure(); + pairsByItemRef.current.set(key, asked); + asked.catch(() => { + if (pairsByItemRef.current.get(key) === asked) pairsByItemRef.current.delete(key); + }); + return asked; + }, + [], + ); + // Re-reads the served declarations: an item's first save into the pair, a restore. + const [servedNonce, setServedNonce] = React.useState(0); + const servedKey = `${publishNonce}:${servedNonce}`; + // A restore in flight: no save is sent beside it. + const [restoring, setRestoring] = React.useState(false); + // objectstack#28562 — the package the leaf buffer's draft read was asked + // at: the address its pin describes (see `doSave`). + const leafReadAtRef = React.useRef<{ key: string; at: string } | null>(null); + const leafCustomizable = customizing && !!current && isEditable && isStudioCustomizeType(current.type); + const leafServed = useServedOverride(client, leafCustomizable ? current : null, customizeBase, customizePair, servedKey); + const leafCustomizeKey = + current && customizeBase ? customizeItemKey(customizeBase, current.type, current.name) : ''; + const leafInPair = leafCustomizable && leafServed.declaration !== null; + const leafUnlocked = + leafCustomizable && + !customizeWalled && + (leafInPair || (customizeBlock === null && customizeAsked.has(leafCustomizeKey))); + const leafReadOnly = readOnly && !leafUnlocked; + const leafInPairRef = React.useRef(leafInPair); + React.useLayoutEffect(() => { + leafInPairRef.current = leafInPair; + }); // objectui#5800 — Design ⇄ Run: one canvas, two modes (ADR-0080's pivot made // visible). Run mode is pure subtraction: `editing=false` drops the design // overlays (dashboard widget overlays, page block canvas) and the SAME @@ -4802,6 +4995,7 @@ export function InterfacesPillar({ setDraft({}); setDraftFor(leafKeyOf(current)); forgetLeafVersion(); + leafReadAtRef.current = null; setHasDraft(false); setIfDirty(false); return; @@ -4821,8 +5015,11 @@ export function InterfacesPillar({ (async () => { try { // objectui#11773 — read at the address the leaf save writes, so the - // version it serves pins that save. - const draftRead = await readDraftForSave>(client, current.type, current.name, packageId); + // version it serves pins that save. objectstack#28562 — a read-only + // base's view, dashboard or report: its pair, when one is listed. + const pair = customizing ? customizationRef.current?.pairId : null; + const readAt = pair && isStudioCustomizeType(current.type) ? pair : packageId; + const draftRead = await readDraftForSave>(client, current.type, current.name, readAt); if (cancelled) return; const body = extractDraftBody(draftRead.answer); // objectui#11799 — the baseline is read only when it is used, for a @@ -4844,7 +5041,8 @@ export function InterfacesPillar({ setDraft(body ?? baseline); setDraftFor(leafKeyOf(current)); // objectui#11773 — the next save is pinned to what the draft read served. - recordLeafRead(current.type, current.name, packageId, draftRead.pin); + recordLeafRead(current.type, current.name, readAt, draftRead.pin); + leafReadAtRef.current = { key: leafKeyOf(current), at: readAt }; setHasDraft(!!body); setIfDirty(false); } catch (e) { @@ -4866,7 +5064,7 @@ export function InterfacesPillar({ // same rule. if (!settled) setLoading(false); }; - }, [client, current, isEditable, packageId, publishNonce, leafReloadNonce, recordLeafRead, forgetLeafVersion]); + }, [client, current, isEditable, packageId, publishNonce, leafReloadNonce, recordLeafRead, forgetLeafVersion, customizing]); // objectui#12688 — the open dashboard as the server serves it for this // session's language: the `/meta` dashboard list the console's metadata @@ -4934,9 +5132,46 @@ export function InterfacesPillar({ setError(null); const body = interfacesSaveBody(current.type, draft); try { - const outcome = await saveLeafDraft(current.type, current.name, body, { mode: 'draft', packageId, surface: 'studio' }); + // objectstack#28562 — a read-only base's view, dashboard or report is + // saved into the pair the ensure door answers, declaring the override. + // A refused door is shown as it was sent, and nothing is saved. + const custom = readOnly ? customizationRef.current : null; + let target: { packageId: string; body: Record } = { packageId, body }; + let entering = false; + if (custom && isStudioCustomizeType(current.type)) { + let pair: string; + try { + pair = await pairFor(custom, current.type, current.name); + } catch (e) { + setError(plainRefusal(e)); + return; + } + const declared = overrideDraftBody(body, custom.base, custom.basedOn); + if (declared === null) { + setError({ message: tFormat('engine.studio.customize.noVersion', locale, { base: custom.baseLabel }) }); + return; + } + // The buffer was read at another package (the base, before its pair + // was listed): the pair is taken to hold no draft of the item, so a + // draft there is a conflict the author is asked about, never one + // this save overwrites. Its receipt pins the saves after it. + const key = leafKeyOf(current); + if (!(leafReadAtRef.current?.key === key && leafReadAtRef.current.at === pair)) { + recordLeafRead(current.type, current.name, pair, { ifNoneMatch: '*' }); + leafReadAtRef.current = { key, at: pair }; + } + entering = !leafInPairRef.current; + target = { packageId: pair, body: declared }; + } + const outcome = await saveLeafDraft(current.type, current.name, target.body, { + mode: 'draft', + packageId: target.packageId, + surface: 'studio', + }); // objectui#11773 — the author chose the saved version; the load replaces the buffer. if (outcome === 'reloaded') return; + // The badge reads the declaration the server now serves. + if (entering) setServedNonce((n) => n + 1); setHasDraft(true); setLeafSavedAt({ key: leafKeyOf(current), at: new Date() }); // objectui#11204 — clean only if nothing was edited while it was in flight. @@ -4951,7 +5186,7 @@ export function InterfacesPillar({ } finally { setSaving(false); } - }, [saveLeafDraft, current, draft, onDraftSaved, packageId, locale]); + }, [saveLeafDraft, current, draft, onDraftSaved, packageId, locale, readOnly, pairFor, recordLeafRead]); // objectui#11910 — a dashboard edit that leaves a widget's binding out (a // widget just added, before its dataset and measures are picked) is held, not // sent: the draft door would refuse it with a 422 naming that input. The @@ -4976,7 +5211,8 @@ export function InterfacesPillar({ target: leafKey, loadedFor: draftFor, dirty: ifDirty, - blocked: !current || !isEditable || !!saving || readOnly || inspectorBlocking > 0 || leafIncomplete !== null, + blocked: + !current || !isEditable || !!saving || leafReadOnly || restoring || inspectorBlocking > 0 || leafIncomplete !== null, snapshot: draft, save: doSave, }); @@ -4998,7 +5234,59 @@ export function InterfacesPillar({ // objectui#11823 — an `object` leaf's list view: the third buffer, edited in // the Properties panel and shown on the canvas, autosaved to the package // draft like the other two. Blocked on `readOnly` like them. - const listView = useObjectListViewDraft({ client, packageId, leaf: current, publishNonce, draftOnlyObjects, onDraftSaved }); + // objectstack#28562 — on a read-only base the view is read in its pair and + // customized there (`ListViewCustomization`); a restore reloads it. + const listViewCustomization = React.useMemo( + () => + customizing + ? { + readAt: () => customizationRef.current?.pairId ?? null, + writeTarget: async (viewId, row) => { + const custom = customizationRef.current; + if (!custom) return { packageId, body: row }; + const pair = await pairFor(custom, 'view', viewId); + const body = overrideDraftBody(row, custom.base, custom.basedOn); + if (body === null) { + throw new Error(tFormat('engine.studio.customize.noVersion', locale, { base: custom.baseLabel })); + } + return { packageId: pair, body }; + }, + } + : null, + [customizing, pairFor, packageId, locale], + ); + const [listViewRestoreNonce, setListViewRestoreNonce] = React.useState(0); + const listView = useObjectListViewDraft({ + client, + packageId, + leaf: current, + publishNonce: publishNonce + listViewRestoreNonce, + draftOnlyObjects, + onDraftSaved, + customization: listViewCustomization, + }); + const listViewTargetId = listView.target?.viewId ?? null; + const listViewCustomizable = customizing && listViewTargetId !== null; + const listViewServed = useServedOverride( + client, + listViewCustomizable && listViewTargetId ? { type: 'view', name: listViewTargetId } : null, + customizeBase, + customizePair, + `${servedKey}:${listViewRestoreNonce}`, + ); + const listViewCustomizeKey = + listViewTargetId && customizeBase ? customizeItemKey(customizeBase, 'view', listViewTargetId) : ''; + const listViewInPair = listViewCustomizable && listViewServed.declaration !== null; + const listViewUnlocked = + listViewCustomizable && + !customizeWalled && + (listViewInPair || (customizeBlock === null && customizeAsked.has(listViewCustomizeKey))); + const listViewReadOnly = readOnly && !listViewUnlocked; + // The view's first save into the pair: the badge reads what is now served. + const listViewSavedInPair = listViewUnlocked && !listViewInPair && listView.hasDraft; + React.useEffect(() => { + if (listViewSavedInPair) setServedNonce((n) => n + 1); + }, [listViewSavedInPair]); // objectstack#27895 — an edit that clears a required setting of the view // type's own block (a calendar's start date) is held, not sent, as a // leaf's is: asked of the spec's list-view schema (`listViewHeldEdit`). @@ -5007,7 +5295,7 @@ export function InterfacesPillar({ target: listView.targetKey, loadedFor: listView.loadedFor, dirty: listView.dirty, - blocked: !listView.target || listView.saving || readOnly || listViewHeld !== null, + blocked: !listView.target || listView.saving || listViewReadOnly || restoring || listViewHeld !== null, snapshot: listView.row, save: listView.save, }); @@ -5553,9 +5841,116 @@ export function InterfacesPillar({ () => toast.error(t('engine.studio.if.copyFailed', locale)), ); }, [draft, locale]); + // objectstack#28562 — the item this header customizes on a read-only base: + // an `object` leaf's list view, else the open view, dashboard or report. + const headerCustomize: { + type: string; + name: string; + key: string; + label: string; + listView: boolean; + declaration: ServedOverride['declaration']; + inPair: boolean; + unlocked: boolean; + /** The item is there to customize: loaded, and (a list view) one the server holds. */ + ready: boolean; + hasDraft: boolean; + busy: boolean; + } | null = isObjectLeaf + ? listViewCustomizable && listViewTargetId + ? { + type: 'view', + name: listViewTargetId, + key: listViewCustomizeKey, + label: + navItemLabelText(listView.row?.label as I18nLabel | undefined, locale) || listViewTargetId, + listView: true, + declaration: listViewServed.declaration, + inPair: listViewInPair, + unlocked: listViewUnlocked, + ready: listView.loadedFor === listView.targetKey && listView.row !== null && !listView.notList, + hasDraft: listView.hasDraft, + busy: listView.saving, + } + : null + : leafCustomizable && current + ? { + type: current.type, + name: current.name, + key: leafCustomizeKey, + label: ifTitleText || current.name, + listView: false, + declaration: leafServed.declaration, + inPair: leafInPair, + unlocked: leafUnlocked, + ready: draftLoaded, + hasDraft: hasDraft && draftLoaded, + busy: !!saving, + } + : null; + const headerUnlocked = headerCustomize?.unlocked === true; + // objectstack#28562 — "Restore package original": the pair's draft of the + // item, then its row, deleted at the pair. The pair is the door's answer + // this item's saves named, else the surface's; never asked of the door + // again. The base's item reads back, and the server's refusal is shown as + // it is sent. + const [restoreFor, setRestoreFor] = React.useState | null>(null); + const restoreOriginal = React.useCallback( + async (item: { type: string; name: string; key: string; label: string; listView: boolean }) => { + const custom = customizationRef.current; + if (!custom) return; + const held = pairsByItemRef.current.get(item.key); + const pair = (held ? await held.catch(() => null) : null) ?? custom.pairId; + if (!pair) return; + setRestoring(true); + setError(null); + try { + await client.reset(item.type, item.name, { state: 'draft', packageId: pair }); + await client.reset(item.type, item.name, { packageId: pair }); + pairsByItemRef.current.delete(item.key); + setCustomizeAsked((prev) => { + if (!prev.has(item.key)) return prev; + const next = new Set(prev); + next.delete(item.key); + return next; + }); + if (item.listView) { + setListViewRestoreNonce((n) => n + 1); + } else { + setIfDirty(false); + forgetLeafVersion(); + reloadLeafDraft(); + } + setServedNonce((n) => n + 1); + emitMetadataRefresh(); + toast.success(tFormat('engine.studio.customize.restoreDone', locale, { item: item.label, base: custom.baseLabel })); + } catch (e) { + setError(plainRefusal(e)); + } finally { + setRestoring(false); + } + }, + [client, forgetLeafVersion, reloadLeafDraft, locale], + ); + const restoreDialogEl = ( + { + if (!open) setRestoreFor(null); + }} + label={restoreFor?.label ?? ''} + baseLabel={customizeBaseLabel} + hasDraft={restoreFor?.hasDraft ?? false} + locale={locale} + onConfirm={() => { + if (restoreFor) void restoreOriginal(restoreFor); + }} + /> + ); // objectui#12725 — the item's ⋯ menu: its definition, and its document as // JSON. Rename and delete are not offered here: this header has neither // today (a nav entry is renamed and removed in nav editing). + // objectstack#28562 — and, for an item customized into the pair, Restore. const itemMoreEl = current && draftLoaded ? ( @@ -5579,6 +5974,13 @@ export function InterfacesPillar({ ) : null; @@ -5653,10 +6055,20 @@ export function InterfacesPillar({ } chips={ current ? ( - - {ifTitleText} · - {ifKindName} - + <> + + {ifTitleText} · + {ifKindName} + + {/* objectstack#28562 — the override the server serves, never a computation. */} + {headerCustomize?.declaration && ( + + )} + ) : undefined } // objectui#12524 — the draft status sits after the kind chip, as in @@ -5671,7 +6083,16 @@ export function InterfacesPillar({ {t('engine.studio.autoSaving', locale)} )} - {readOnly && ( + {/* objectstack#28562 — Customize unlocks the item's editors; nothing is saved until an edit. */} + {headerCustomize && !headerCustomize.unlocked && !headerCustomize.inPair && headerCustomize.ready && ( + askCustomize(headerCustomize.key)} + /> + )} + {readOnly && !headerUnlocked && ( )} {isObjectLeaf && openSurface && current && ( @@ -5886,8 +6307,9 @@ export function InterfacesPillar({ // preview contract the canvas is then read-only (no add, drag, // rename or delete — each a write the blocked autosave would // discard), while selecting a block or widget still opens the - // inspector, read-only. - onPatch={readOnly ? undefined : onPatch} + // inspector, read-only. objectstack#28562 — unless the item is + // being customized into the base's pair. + onPatch={leafReadOnly ? undefined : onPatch} locale={locale} /> ) : ( @@ -5960,7 +6382,10 @@ export function InterfacesPillar({
@@ -6035,7 +6461,7 @@ export function InterfacesPillar({ setBlockingReport({ key: inspectorKey, count }) } // objectui#11136 — same flag, same threading as the block inspector. - readOnly={readOnly} + readOnly={leafReadOnly} locale={locale} /> @@ -6076,7 +6502,14 @@ export function InterfacesPillar({ onClose={navInspectorHead ? () => setNavSel(null) : selection && !StudioCanvas ? () => setSelection(null) : undefined} closeLabel={t('engine.studio.deselect', locale)} // objectui#12453 — a read-only package saves no draft: no footer. - footer={readOnly ? null : t('engine.studio.inspector.draftFooter', locale)} + // objectstack#28562 — a customized item's drafts go to the base's pair. + footer={ + !readOnly + ? t('engine.studio.inspector.draftFooter', locale) + : headerUnlocked + ? t('engine.studio.customize.draftFooter', locale) + : null + } bodyClassName="flex min-h-0 flex-1 flex-col" >
@@ -6153,6 +6586,8 @@ export function InterfacesPillar({ {leafConflictDialog} {navConflictDialog} {listView.conflictDialog} + {/* objectstack#28562 — Restore's confirmation, outside the menu that opens it. */} + {restoreDialogEl} {/* objectui#11823 (steps 2 and 3) — the shared create dialog; a report also asks for its dataset and one measure (`ReportCreateFields`), a page for its source kind (`PageCreateFields`). */} diff --git a/ui/packages/app-shell/src/views/studio-design/packages-io.ts b/ui/packages/app-shell/src/views/studio-design/packages-io.ts index c0413fe675d..24484822dc5 100644 --- a/ui/packages/app-shell/src/views/studio-design/packages-io.ts +++ b/ui/packages/app-shell/src/views/studio-design/packages-io.ts @@ -27,7 +27,13 @@ * verdict says: that filter is about visibility, not writability. */ -import { ManifestSchema, deriveNamespaceFromPackageId, validateObjectNamespacePrefix } from '@objectstack/spec/kernel'; +import { EnsureCustomizationPackageResponseSchema } from '@objectstack/spec/api'; +import { + ManifestSchema, + customizationPackageId, + deriveNamespaceFromPackageId, + validateObjectNamespacePrefix, +} from '@objectstack/spec/kernel'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; export interface PkgEntry { @@ -183,6 +189,87 @@ export async function fetchListedPackageManifests(): Promise, + id: string, +): string | null { + const version = manifests.get(id)?.manifest.version; + return typeof version === 'string' && version ? version : null; +} + +/** + * [objectstack#28562, ADR-0146 D3] The paired customization package of the + * base `baseId` when the package list lists it, else `null`. The pair's id is + * asked of the spec's pairing rule (`customizationPackageId`) and then looked + * up, as the Studio landing links a base's pair: a base with no pair has + * nothing to read, and this read never creates one. + * + * READS only: what Studio shows of a pair that is already there (its pending + * drafts, an item's draft in it). ⛔ A write into the pair takes the pair's id + * from {@link ensureCustomizationPackage}'s answer, never from here. + */ +export function listedCustomizationPairOf( + manifests: ReadonlyMap, + baseId: string, +): string | null { + const pair = customizationPackageId(baseId); + return pair.ok && manifests.has(pair.value) ? pair.value : null; +} + +/** + * [objectstack#28562] The answer of the ensure door, as Studio reads it. The + * spec's own declaration (`EnsureCustomizationPackageResponseSchema`) judges it. + */ +export interface EnsuredCustomizationPackage { + basePackageId: string; + /** The pair: every write of a customization of the base addresses it. */ + packageId: string; + /** True when this call created the pair (HTTP 201), false when it was there (200). */ + created: boolean; + writable: boolean; +} + +/** + * [objectstack#28562, ADR-0146 D3] `POST /api/v1/packages/:id/ensure-customization`: + * the base's paired customization package, created on first use. The ONE + * creation path of the pair, so Studio never spells the pair's id itself for + * a write: the answer names it. + * + * Sends no body (the door reads none: the pair's id, namespace and dependency + * are derived by the server). Rejects with the server's sentence, verbatim by + * the rule every reader here uses ({@link readEnvelopeFailureText}): the wall's + * `403` names who may call it, a `409` what the existing pair lacks. A created + * pair is a new row of the package list, so the pending list read is dropped + * and `objectui:packages-changed` is announced, as a package created anywhere + * else in the app announces it. + */ +export async function ensureCustomizationPackage(baseId: string): Promise { + const res = await fetch(`/api/v1/packages/${encodeURIComponent(baseId)}/ensure-customization`, { + method: 'POST', + credentials: 'include', + headers: { Accept: 'application/json' }, + }).finally(dropPendingPackageRead); + const payload = (await res.json().catch(() => null)) as Record | null; + if (!res.ok) { + throw new Error(readEnvelopeFailureText(payload) ?? `HTTP ${res.status}`); + } + // The dispatcher envelope `{ success, data }`; the declaration is the `data`. + const parsed = EnsureCustomizationPackageResponseSchema.safeParse(payload?.data); + if (!parsed.success) { + throw new Error(`The customization package of '${baseId}' was answered with a body its declaration refuses.`); + } + const { basePackageId, packageId, created, writable } = parsed.data; + if (created && typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('objectui:packages-changed')); + } + return { basePackageId, packageId, created, writable }; +} + /** * The package list every Studio surface reads — the switcher, the writability * courtesy gate, the namespace lookup and the builder landing page. diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 05d68eb5f3c..1bfb56ad8a9 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3165,6 +3165,19 @@ const STUDIO_EN: Record = { 'engine.studio.pkg.readonlyHint': 'Read-only package — switch to or create a writable package to edit.', // objectui#12453 — the item header's read-only link, every pillar. 'engine.studio.pkg.readonlyLink': 'Read-only · Customize with an overlay', + // objectstack#28562 (ADR-0146 D2, D3) — Customize a read-only package's view, dashboard or report. + 'engine.studio.customize.action': 'Customize', + 'engine.studio.customize.actionTitle': 'Change this item for this deployment. Your first edit is saved as a customization draft, and {base} itself stays as it was installed.', + 'engine.studio.customize.walled': 'On this deployment, only the platform operator can customize an installed package.', + 'engine.studio.customize.noVersion': 'The package list does not say which version of {base} is installed, so its items cannot be customized here.', + 'engine.studio.customize.overrides': 'Overrides {base}', + 'engine.studio.customize.overridesTitle': 'This item replaces {base}’s item of the same name on this deployment. It was taken from {base} {basedOn}.', + 'engine.studio.customize.draftFooter': 'Edits save as a draft of this deployment’s customization; Publish applies them.', + 'engine.studio.customize.restore': 'Restore package original', + 'engine.studio.customize.restoreConfirm': 'Restore {base}’s “{item}”?', + 'engine.studio.customize.restoreBody': 'This deployment’s customization of “{item}” is deleted, and {base}’s version is served again.', + 'engine.studio.customize.restoreDraft': 'Its unpublished draft is discarded first.', + 'engine.studio.customize.restoreDone': '“{item}” is back to {base}’s version', 'engine.studio.app.scaffoldNav': "Add this package's objects as menu items", 'engine.studio.landing.title': 'App Builder', 'engine.studio.landing.description': 'Design objects, forms, automations and interfaces inside a writable package; edits save as drafts and the whole package publishes in one release. Code and installed packages are read-only: browse them here, and customize them with an environment overlay.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index b7a315d4036..06f1738bd29 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2808,6 +2808,18 @@ const STUDIO_ZH: Record = { 'engine.studio.pkg.readonly': '只读', 'engine.studio.pkg.readonlyHint': '只读软件包 — 请切换或新建可写软件包后再编辑。', 'engine.studio.pkg.readonlyLink': '只读 · 用覆盖定制', + 'engine.studio.customize.action': '自定义', + 'engine.studio.customize.actionTitle': '为本部署修改此项。第一次编辑会保存为自定义草稿,{base} 本身保持安装时的样子。', + 'engine.studio.customize.walled': '在此部署中,只有平台运营方可以自定义已安装的软件包。', + 'engine.studio.customize.noVersion': '软件包列表没有给出已安装的 {base} 版本,因此无法在此自定义它的项。', + 'engine.studio.customize.overrides': '覆盖 {base}', + 'engine.studio.customize.overridesTitle': '此项在本部署中替换 {base} 中的同名项,取自 {base} {basedOn}。', + 'engine.studio.customize.draftFooter': '编辑会保存为本部署自定义的草稿;发布后生效。', + 'engine.studio.customize.restore': '恢复软件包原版', + 'engine.studio.customize.restoreConfirm': '恢复 {base} 的「{item}」?', + 'engine.studio.customize.restoreBody': '本部署对「{item}」的自定义会被删除,重新使用 {base} 的版本。', + 'engine.studio.customize.restoreDraft': '会先丢弃它未发布的草稿。', + 'engine.studio.customize.restoreDone': '「{item}」已恢复为 {base} 的版本', 'engine.studio.app.scaffoldNav': '将本包的对象添加为菜单项', 'engine.studio.landing.title': '应用构建', 'engine.studio.landing.description': '在一个可写软件包里设计对象、表单、自动化与界面;编辑存为草稿,整包一次发布。源码与已安装的软件包为只读:可在此浏览,并用环境覆盖进行定制。', From 596956d99a008e09d9c3ce2db3006f3d72bd1a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:43:44 +0000 Subject: [PATCH 2/5] test(studio): pin customizing a read-only base into its pair Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQpUMdR7QmHKTFR8iB --- .../InterfacesPillar.customize-28562.test.tsx | 565 ++++++++++++++++++ ...CustomizeControls.customize-28562.test.tsx | 235 ++++++++ ...ignSurface.customizePublish-28562.test.tsx | 323 ++++++++++ .../StudioReadOnlyLink.overlay-27356.test.tsx | 20 +- 4 files changed, 1138 insertions(+), 5 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx 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 new file mode 100644 index 00000000000..c54504e4f1a --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.customize-28562.test.tsx @@ -0,0 +1,565 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28562 (ADR-0146 D2, D3) — on a read-only base, the Interfaces + * pillar customizes a view, dashboard or report (and an `object` leaf's list + * view) into the base's paired customization package. + * + * Through the mounted pillar, its real autosaves, the real header controls + * and the real ensure-door client (`ensureCustomizationPackage`) over a fetch + * stub that answers 201 when it creates the pair and 200 after. The metadata + * client is a server double holding the base's rows, the pair's rows and the + * pair's drafts, and recording every write. The pillar is handed what the + * Studio surface hands it (`StudioBaseCustomization`) by a host that, like + * the surface, takes the pair's id from the door's answer. + * + * Pinned: + * - Customize is offered on the base's item and writes nothing; the first + * saved edit asks the door once and writes the draft into the pair, with + * the override declared at the base's version and no `_` key, the base + * untouched; the badge then reads the served declaration; + * - a second item asks the door again and is answered 200; + * - a walled caller sees Customize disabled, and nothing is asked or sent; + * - Restore deletes the pair's draft and row of the item, and the base's + * item reads back, on a dashboard and on a list view; + * - the read-only mark does not link these types to Setup's overlay page; + * - CONTROL: a writable package edits and saves in place, with no door and + * no declaration. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ComponentRegistry } from '@object-ui/core'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; + +const BASE = 'com.example.hotcrm'; +const PAIR = 'com.example.hotcrm.custom'; + +const OBJECT_DEF = { + name: 'opportunity', + label: 'Opportunity', + fields: { + name: { name: 'name', label: 'Name', type: 'text' }, + stage: { name: 'stage', label: 'Stage', type: 'text' }, + amount: { name: 'amount', label: 'Amount', type: 'currency' }, + close_date: { name: 'close_date', label: 'Close date', type: 'date' }, + start_date: { name: 'start_date', label: 'Start date', type: 'date' }, + }, +}; + +const NAV = [ + { id: 'nav_pipeline', type: 'dashboard', label: 'Pipeline', dashboardName: 'pipeline' }, + { id: 'nav_forecast', type: 'dashboard', label: 'Forecast', dashboardName: 'forecast' }, + { id: 'nav_opps', type: 'object', label: 'Opportunities', objectName: 'opportunity' }, +]; +const APP = { name: 'crm_app', label: 'CRM', navigation: NAV }; + +/** The base's rows as its read serves them: the protection envelope rides along. */ +const BASE_ROWS: Record> = { + 'dashboard/pipeline': { name: 'pipeline', label: 'Pipeline', widgets: [], _packageId: BASE }, + 'dashboard/forecast': { name: 'forecast', label: 'Forecast', widgets: [], _packageId: BASE }, + 'view/opportunity.default': { + name: 'opportunity.default', + object: 'opportunity', + viewKind: 'list', + label: 'Close dates', + isDefault: true, + config: { + type: 'calendar', + data: { provider: 'object', object: 'opportunity' }, + columns: ['name', 'stage'], + calendar: { startDateField: 'close_date', titleField: 'name' }, + }, + }, +}; + +const srv = vi.hoisted(() => ({ + base: new Map>(), + pairActive: new Map>(), + pairDrafts: new Map>(), + pairExists: false, + ensure: [] as number[], + ensureRefusal: null as null | { status: number; message: string }, + saves: [] as Array<{ type: string; name: string; body: Record; opts: Record }>, + resets: [] as Array<{ type: string; name: string; opts: Record }>, + version: 0, +})); + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +const k = (type: string, name: string) => `${type}/${name}`; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string) => { + if (type === 'app') return [{ name: 'crm_app', label: 'CRM' }]; + if (type === 'object') return [{ name: 'opportunity', label: 'Opportunity' }]; + return []; + }), + listDrafts: vi.fn(async () => []), + get: vi.fn(async (type: string) => (type === 'object' ? OBJECT_DEF : undefined)), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + if (type === 'app') return { effective: APP }; + if (type === 'object') return { effective: OBJECT_DEF, code: OBJECT_DEF }; + // N-10: the pair's row is served over the base's item. + const overlay = srv.pairActive.get(k(type, name)) ?? null; + const code = srv.base.get(k(type, name)) ?? null; + return { + code: code ? clone(code) : null, + overlay: overlay ? clone(overlay) : null, + overlayScope: overlay ? 'env' : null, + effective: clone(overlay ?? code), + }; + }), + getDraft: vi.fn(async (type: string, name: string, opts: { packageId?: string } = {}) => { + const draft = opts.packageId === PAIR ? srv.pairDrafts.get(k(type, name)) : undefined; + return draft ? { type, name, item: clone(draft), version: `v${srv.version}` } : null; + }), + save: vi.fn(async (type: string, name: string, item: unknown, opts: Record = {}) => { + srv.saves.push({ type, name, body: clone(item) as Record, opts: clone(opts) }); + if (opts.packageId !== PAIR) { + throw Object.assign(new Error(`Package '${String(opts.packageId)}' is read-only.`), { status: 403 }); + } + srv.pairDrafts.set(k(type, name), clone(item) as Record); + srv.version += 1; + return { success: true, version: `v${srv.version}`, state: 'draft' }; + }), + reset: vi.fn(async (type: string, name: string, opts: Record = {}) => { + srv.resets.push({ type, name, opts: clone(opts) }); + if (opts.state === 'draft') srv.pairDrafts.delete(k(type, name)); + else srv.pairActive.delete(k(type, name)); + return { success: true }; + }), + withPreviewDrafts() { + return this; + }, +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + // Both take environment overlays, so the read-only mark would link them to Setup. + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { type: 'dashboard', allowEnvironmentOverlay: true }, + { type: 'view', allowEnvironmentOverlay: true }, + ], + }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); + +import { InterfacesPillar, type StudioBaseCustomization } from './StudioDesignSurface'; +import { ensureCustomizationPackage } from './packages-io'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { t, tFormat } from '../metadata-admin/i18n'; + +/** The dashboard canvas, reduced to its label and one in-place edit. */ +function BoardCanvas({ draft, onPatch }: { draft: Record; onPatch?: (patch: Record) => void }) { + return ( +
+

{String(draft?.label ?? '')}

+ +
+ ); +} + +/** Stands in for `plugin-view`'s `object-view`, recording the schema the canvas hands it. */ +function ObjectViewRecorder({ schema }: { schema: Record }) { + return
{JSON.stringify(schema)}
; +} + +const METADATA: MetadataContextValue = { + apps: [], + objects: [OBJECT_DEF], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +beforeAll(() => { + registerMetadataPreview('dashboard', BoardCanvas as never); + ComponentRegistry.register('object-view', ObjectViewRecorder as never, { namespace: 'plugin-view' }); +}); + +beforeEach(() => { + srv.base.clear(); + for (const [key, row] of Object.entries(BASE_ROWS)) srv.base.set(key, clone(row)); + srv.pairActive.clear(); + srv.pairDrafts.clear(); + srv.pairExists = false; + srv.ensure.length = 0; + srv.ensureRefusal = null; + srv.saves.length = 0; + srv.resets.length = 0; + srv.version = 0; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear?: () => void }).mockClear?.(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: RequestInit) => { + const path = new URL(String(input), 'http://localhost').pathname; + if (path === `/api/v1/packages/${BASE}/ensure-customization` && init?.method === 'POST') { + if (srv.ensureRefusal) { + srv.ensure.push(srv.ensureRefusal.status); + return json({ success: false, error: { code: 'FORBIDDEN', message: srv.ensureRefusal.message } }, srv.ensureRefusal.status); + } + const created = !srv.pairExists; + srv.pairExists = true; + srv.ensure.push(created ? 201 : 200); + return json( + { + success: true, + data: { basePackageId: BASE, packageId: PAIR, namespace: 'hotcrm_custom', dependencyRange: '^4', created, writable: true }, + }, + created ? 201 : 200, + ); + } + return json([]); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** What the Studio surface hands the pillar on a read-only base: the pair's id comes from the door's answer. */ +function Host({ + readOnly = true, + walled = false, + basedOn = '4.0.0', + listedPair = null, +}: { + readOnly?: boolean; + walled?: boolean; + basedOn?: string | null; + listedPair?: string | null; +}) { + const [pairId, setPairId] = React.useState(listedPair); + const ensure = React.useCallback(async () => { + const answer = await ensureCustomizationPackage(BASE); + setPairId(answer.packageId); + return answer.packageId; + }, []); + const customization = React.useMemo( + () => (readOnly ? { base: BASE, baseLabel: 'HotCRM', basedOn, walled, pairId, ensure } : null), + [readOnly, basedOn, walled, pairId, ensure], + ); + return ; +} + +function mount(props: React.ComponentProps = {}) { + return render( + + + + + , + ); +} + +const header = () => screen.getByTestId('if-item-header'); +const patchBoard = () => fireEvent.click(screen.getByRole('button', { name: 'patch the board' })); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** The Pipeline dashboard open, as the pillar opens the first leaf. */ +async function openPipeline(): Promise { + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent(/^Pipeline/), { timeout: 8000 }); +} + +async function openForecast(): Promise { + fireEvent.click(screen.getByRole('button', { name: /Forecast/ })); + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent(/^Forecast/), { timeout: 8000 }); +} + +async function customize(): Promise { + fireEvent.click(await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 })); + await waitFor(() => expect(within(header()).queryByTestId('studio-customize')).toBeNull()); +} + +async function restore(): Promise { + fireEvent.pointerDown(await screen.findByTestId('if-item-more', undefined, { timeout: 8000 }), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByTestId('studio-restore-original')); + const dialog = await screen.findByTestId('studio-restore-original-dialog'); + fireEvent.click(within(dialog).getByTestId('studio-restore-original-confirm')); +} + +describe('Customize a read-only base’s dashboard into its pair (objectstack#28562)', () => { + it('Customize is offered and writes nothing; until it is pressed the canvas edits nothing', async () => { + mount(); + await openPipeline(); + expect(within(header()).getByTestId('studio-customize')).toBeEnabled(); + + patchBoard(); + expect(screen.getByTestId('board-label')).toHaveTextContent(/^Pipeline$/); + + await customize(); + await sleep(2000); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + expect(within(header()).queryByTestId('studio-override-chip')).toBeNull(); + }); + + it('the first saved edit asks the door once and writes the pair’s draft, declaring the override; the base is untouched', async () => { + mount(); + await openPipeline(); + await customize(); + + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + const [saved] = srv.saves; + expect(saved).toMatchObject({ type: 'dashboard', name: 'pipeline', opts: { mode: 'draft', packageId: PAIR } }); + expect(saved!.body).toStrictEqual({ + name: 'pipeline', + label: 'Pipeline (ours)', + widgets: [], + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + expect(srv.base.get('dashboard/pipeline')).toStrictEqual(BASE_ROWS['dashboard/pipeline']); + + // The badge reads the declaration the pair's draft now serves. + const chip = await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { base: 'HotCRM' })); + expect(chip).toHaveAttribute('title', tFormat('engine.studio.customize.overridesTitle', 'en', { base: 'HotCRM', basedOn: '4.0.0' })); + + // A later edit of the same item asks the door no more. + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(2), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + expect(srv.saves[1]!.opts.packageId).toBe(PAIR); + }); + + it('a second item asks the door again and is answered 200; it is written into the same pair', async () => { + mount(); + await openPipeline(); + await customize(); + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + + await openForecast(); + await customize(); + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(2), { timeout: 8000 }); + expect(srv.ensure).toEqual([201, 200]); + expect(srv.saves[1]).toMatchObject({ type: 'dashboard', name: 'forecast', opts: { packageId: PAIR } }); + expect(srv.saves[1]!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' }); + }); + + it('a refused door is shown as the server sent it, and nothing is saved', async () => { + const sentence = 'Only the platform operator may create a customization package under the group posture.'; + srv.ensureRefusal = { status: 403, message: sentence }; + mount(); + await openPipeline(); + await customize(); + + patchBoard(); + await waitFor(() => expect(srv.ensure).toEqual([403]), { timeout: 8000 }); + // The envelope's sentence and code, as every package read here shows a refusal. + expect(await screen.findByTestId('studio-refusal-message', undefined, { timeout: 8000 })).toHaveTextContent( + `${sentence} (FORBIDDEN)`, + ); + expect(srv.saves).toEqual([]); + }); + + it('a walled caller sees Customize disabled, with the reason; nothing is asked or sent', async () => { + mount({ walled: true }); + await openPipeline(); + const button = within(header()).getByTestId('studio-customize'); + expect(button).toBeDisabled(); + expect(within(header()).getByTestId('studio-customize-blocked')).toHaveAttribute( + 'title', + t('engine.studio.customize.walled', 'en'), + ); + + fireEvent.click(button); + patchBoard(); + await sleep(2000); + expect(screen.getByTestId('board-label')).toHaveTextContent(/^Pipeline$/); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + }); + + it('a base whose listed manifest states no version: Customize is disabled, saying so', async () => { + mount({ basedOn: null }); + await openPipeline(); + expect(within(header()).getByTestId('studio-customize')).toBeDisabled(); + expect(within(header()).getByTestId('studio-customize-blocked')).toHaveAttribute('data-block', 'noVersion'); + }); + + it('the read-only mark does not link a dashboard to Setup’s overlay page', async () => { + mount(); + await openPipeline(); + const mark = within(header()).getByTestId('studio-readonly-link'); + expect(mark.tagName).toBe('SPAN'); + expect(mark).toHaveTextContent(t('engine.studio.pkg.readonly', 'en')); + }); +}); + +describe('Restore package original (objectstack#28562)', () => { + it('on a customized dashboard: the pair’s draft and row are deleted at the pair, and the base reads back', async () => { + srv.pairExists = true; + srv.pairActive.set('dashboard/pipeline', { + name: 'pipeline', + label: 'Pipeline (ours)', + widgets: [], + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + mount({ listedPair: PAIR }); + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent('Pipeline (ours)'), { timeout: 8000 }); + // Served as an override: badged, editable, and no Customize to press. + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + expect(within(header()).queryByTestId('studio-customize')).toBeNull(); + expect(within(header()).queryByTestId('studio-readonly-link')).toBeNull(); + + await restore(); + await waitFor(() => expect(srv.resets).toHaveLength(2), { timeout: 8000 }); + expect(srv.resets).toEqual([ + { type: 'dashboard', name: 'pipeline', opts: { state: 'draft', packageId: PAIR } }, + { type: 'dashboard', name: 'pipeline', opts: { packageId: PAIR } }, + ]); + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent(/^Pipeline$/), { timeout: 8000 }); + await waitFor(() => expect(within(header()).queryByTestId('studio-override-chip')).toBeNull()); + expect(within(header()).getByTestId('studio-customize')).toBeInTheDocument(); + expect(srv.ensure).toEqual([]); + expect(srv.saves).toEqual([]); + }); + + it('Cancel deletes nothing', async () => { + srv.pairExists = true; + srv.pairActive.set('dashboard/pipeline', { + name: 'pipeline', + label: 'Pipeline (ours)', + widgets: [], + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + mount({ listedPair: PAIR }); + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + fireEvent.pointerDown(screen.getByTestId('if-item-more'), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByTestId('studio-restore-original')); + fireEvent.click(within(await screen.findByTestId('studio-restore-original-dialog')).getByTestId('studio-restore-original-cancel')); + await sleep(300); + expect(srv.resets).toEqual([]); + }); +}); + +/** The calendar setting `key` of the list-view panel. */ +function setting(key: string): HTMLElement { + const control = document.getElementById(`mdf-studio-list-view-calendar.${key}`); + if (!control) throw new Error(`no calendar.${key} setting`); + return control; +} + +async function pick(key: string, text: string) { + await waitFor(() => expect(setting(key)).toBeEnabled(), { timeout: 8000 }); + fireEvent.keyDown(setting(key), { key: 'ArrowDown' }); + const listbox = await screen.findByRole('listbox'); + const hit = within(listbox) + .getAllByRole('option') + .find((o) => (o.querySelector('code')?.previousElementSibling?.textContent ?? o.textContent) === text); + if (!hit) throw new Error(`calendar.${key} lists no "${text}"`); + fireEvent.click(hit); +} + +async function openOpportunities(): Promise { + await openPipeline(); + fireEvent.click(screen.getByRole('button', { name: /Opportunities/ })); + await screen.findByTestId('studio-list-view-inspector', undefined, { timeout: 8000 }); + await waitFor(() => expect(setting('startDateField')).toHaveTextContent('Close date'), { timeout: 8000 }); +} + +const canvasSchema = () => JSON.parse(screen.getByTestId('object-view-schema').textContent ?? '{}') as Record; + +describe('Customize an object leaf’s list view into the pair (objectstack#28562)', () => { + it('the panel is read-only until Customize; the first edit writes the view’s draft into the pair, declaring the override', async () => { + mount(); + await openOpportunities(); + expect(setting('startDateField')).toBeDisabled(); + // A new view would be a new item of the base: not offered. + expect(screen.getByRole('button', { name: t('engine.studio.inspector.listView.addView', 'en') })).toBeDisabled(); + + await customize(); + expect(srv.ensure).toEqual([]); + await pick('startDateField', 'Start date'); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + const [saved] = srv.saves; + expect(saved).toMatchObject({ type: 'view', name: 'opportunity.default', opts: { mode: 'draft', packageId: PAIR } }); + expect((saved!.body.config as Record).calendar).toEqual({ startDateField: 'start_date', titleField: 'name' }); + expect(saved!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' }); + expect(srv.base.get('view/opportunity.default')).toStrictEqual(BASE_ROWS['view/opportunity.default']); + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + expect(screen.getByRole('button', { name: t('engine.studio.inspector.listView.addView', 'en') })).toBeDisabled(); + }); + + it('Restore brings back the base’s columns', async () => { + const base = BASE_ROWS['view/opportunity.default']!; + srv.pairExists = true; + srv.pairActive.set('view/opportunity.default', { + ...clone(base), + config: { ...(clone(base.config) as Record), columns: ['name', 'amount', 'stage'] }, + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + mount({ listedPair: PAIR }); + await openPipeline(); + fireEvent.click(screen.getByRole('button', { name: /Opportunities/ })); + await waitFor(() => expect(JSON.stringify(canvasSchema())).toContain('amount'), { timeout: 8000 }); + await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); + + await restore(); + await waitFor(() => expect(srv.resets).toHaveLength(2), { timeout: 8000 }); + expect(srv.resets.map((r) => [r.type, r.name, r.opts])).toEqual([ + ['view', 'opportunity.default', { state: 'draft', packageId: PAIR }], + ['view', 'opportunity.default', { packageId: PAIR }], + ]); + await waitFor(() => expect(JSON.stringify(canvasSchema())).not.toContain('amount'), { timeout: 8000 }); + await waitFor(() => expect(within(header()).queryByTestId('studio-override-chip')).toBeNull()); + }); +}); + +describe('CONTROL: a writable package is edited in place (objectstack#28562)', () => { + it('no Customize and no badge; an edit saves at the package with no declaration, and no door is asked', async () => { + srv.base.set('dashboard/pipeline', { name: 'pipeline', label: 'Pipeline', widgets: [] }); + 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 openPipeline(); + expect(within(header()).queryByTestId('studio-customize')).toBeNull(); + expect(within(header()).queryByTestId('studio-readonly-link')).toBeNull(); + + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.saves[0]).toMatchObject({ type: 'dashboard', name: 'pipeline', opts: { packageId: BASE } }); + expect(srv.saves[0]!.body).toStrictEqual({ name: 'pipeline', label: 'Pipeline (ours)', widgets: [] }); + expect(srv.ensure).toEqual([]); + expect(within(header()).queryByTestId('studio-override-chip')).toBeNull(); + }); +}); 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 new file mode 100644 index 00000000000..f6e98c4745f --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.customize-28562.test.tsx @@ -0,0 +1,235 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28562 (ADR-0146 D2, D3) — the pieces Studio's Customize door is + * built from, each pinned on its own: + * + * - which types Studio customizes, asked of the spec's classification; + * - the override declaration a document carries, parsed by the spec's schema; + * - the body a customization's draft is written with: no `_` keys, the + * declaration of the base, an existing declaration kept as it is, and + * nothing at all when there is no version to declare; + * - whether the ensure door refuses this caller, from the posture and the + * platform-operator flag the server publishes; + * - the ensure door's client (`packages-io.ts#ensureCustomizationPackage`): + * the pair's id from the answer, 201 and 200 told apart, a refusal + * rejected with the server's own sentence, and the package list told only + * when a pair was created; + * - what the package list says of a base: its version, its listed pair; + * - the Customize button's reasons and the override badge. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +import { + StudioCustomizeButton, + StudioOverrideChip, + customizeDoorWalled, + declaredOverrideOf, + isStudioCustomizeType, + overrideDraftBody, +} from './StudioCustomizeControls'; +import { + ensureCustomizationPackage, + listedCustomizationPairOf, + listedPackageVersion, + parseListedPackageManifests, +} from './packages-io'; +import { t, tFormat } from '../metadata-admin/i18n'; + +const BASE = 'com.example.hotcrm'; +const PAIR = 'com.example.hotcrm.custom'; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('the types Studio customizes (objectstack#28562)', () => { + it.each(['view', 'dashboard', 'report'])('%s is customized', (type) => { + expect(isStudioCustomizeType(type)).toBe(true); + }); + + it.each(['page', 'object', 'flow', 'app', '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); + }, + ); +}); + +describe('the declaration a document carries (objectstack#28562)', () => { + it('reads a declaration of this base', () => { + expect(declaredOverrideOf({ name: 'pipeline', overrides: { package: BASE, basedOn: '4.0.0' } }, BASE)).toEqual({ + package: BASE, + basedOn: '4.0.0', + }); + }); + + it.each([ + ['no declaration', { name: 'pipeline' }], + ['a declaration of another base', { overrides: { package: 'com.example.other', basedOn: '1.0.0' } }], + ['a declaration with no version', { overrides: { package: BASE } }], + ['not a document', null], + ])('%s declares no override of the base', (_case, doc) => { + expect(declaredOverrideOf(doc, BASE)).toBeNull(); + }); +}); + +describe('the body a customization is written with (objectstack#28562)', () => { + it('drops the top-level `_` keys and declares the override of the base at its version', () => { + const doc = { name: 'pipeline', label: 'Pipeline', widgets: [{ id: 'w1', _note: 'kept below the top' }], _packageId: BASE, _lock: 'full' }; + expect(overrideDraftBody(doc, BASE, '4.0.0')).toStrictEqual({ + name: 'pipeline', + label: 'Pipeline', + widgets: [{ id: 'w1', _note: 'kept below the top' }], + overrides: { package: BASE, basedOn: '4.0.0' }, + }); + }); + + it('keeps a declaration of the base the document already carries, its version included', () => { + const doc = { name: 'pipeline', overrides: { package: BASE, basedOn: '3.2.0' } }; + expect(overrideDraftBody(doc, BASE, '4.0.0')?.overrides).toEqual({ package: BASE, basedOn: '3.2.0' }); + }); + + it('declares nothing it would have to guess: no version and no declaration is no body', () => { + expect(overrideDraftBody({ name: 'pipeline' }, BASE, null)).toBeNull(); + }); + + it('with no version, a document that already declares the base is still written', () => { + const doc = { name: 'pipeline', overrides: { package: BASE, basedOn: '3.2.0' } }; + expect(overrideDraftBody(doc, BASE, null)).toStrictEqual(doc); + }); +}); + +describe('whether the ensure door refuses this caller (objectstack#28562)', () => { + it.each([ + ['group', false, true], + ['isolated', false, true], + ['group', true, false], + ['isolated', true, false], + ['single', false, false], + [undefined, false, false], + ] as const)('posture %s, platform operator %s: walled %s', (posture, isPlatformOperator, walled) => { + expect(customizeDoorWalled({ posture, isPlatformOperator })).toBe(walled); + }); +}); + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +const answer = (created: boolean) => ({ + success: true, + data: { basePackageId: BASE, packageId: PAIR, namespace: 'hotcrm_custom', dependencyRange: '^4', created, writable: true }, +}); + +describe('the ensure door client (objectstack#28562)', () => { + it('POSTs no body to the base’s door, and names the pair from the answer; a created pair tells the package list', async () => { + const fetchMock = vi.fn(async () => json(answer(true), 201)); + vi.stubGlobal('fetch', fetchMock); + const changed = vi.fn(); + window.addEventListener('objectui:packages-changed', changed); + try { + await expect(ensureCustomizationPackage(BASE)).resolves.toEqual({ + basePackageId: BASE, + packageId: PAIR, + created: true, + writable: true, + }); + } finally { + window.removeEventListener('objectui:packages-changed', changed); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(`/api/v1/packages/${BASE}/ensure-customization`); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('a pair that was there (200) is named the same way, and the package list is not told', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json(answer(false), 200))); + const changed = vi.fn(); + window.addEventListener('objectui:packages-changed', changed); + try { + await expect(ensureCustomizationPackage(BASE)).resolves.toMatchObject({ packageId: PAIR, created: false }); + } finally { + window.removeEventListener('objectui:packages-changed', changed); + } + expect(changed).not.toHaveBeenCalled(); + }); + + it('a refusal rejects with the server’s own sentence', async () => { + const sentence = 'Only the platform operator may create a customization package under the group posture.'; + vi.stubGlobal('fetch', vi.fn(async () => json({ success: false, error: { code: 'FORBIDDEN', message: sentence } }, 403))); + await expect(ensureCustomizationPackage(BASE)).rejects.toMatchObject({ message: `${sentence} (FORBIDDEN)` }); + }); + + it('an answer its declaration refuses names no pair', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ success: true, data: { packageId: PAIR } }, 201))); + await expect(ensureCustomizationPackage(BASE)).rejects.toThrow(BASE); + }); +}); + +describe('what the package list says of a base (objectstack#28562)', () => { + const listed = (withPair: boolean, version?: string) => + parseListedPackageManifests({ + data: [ + { manifest: { id: BASE, name: 'HotCRM', ...(version ? { version } : {}) }, writable: false }, + ...(withPair ? [{ manifest: { id: PAIR, name: 'HotCRM customizations', version: '0.0.0' }, writable: true }] : []), + ], + }); + + it('the version its manifest declares, else none', () => { + expect(listedPackageVersion(listed(false, '4.0.0'), BASE)).toBe('4.0.0'); + expect(listedPackageVersion(listed(false), BASE)).toBeNull(); + expect(listedPackageVersion(listed(false, '4.0.0'), 'com.example.unlisted')).toBeNull(); + }); + + it('its pair when the list lists one, else none: the read never spells a pair that is not there', () => { + expect(listedCustomizationPairOf(listed(true, '4.0.0'), BASE)).toBe(PAIR); + expect(listedCustomizationPairOf(listed(false, '4.0.0'), BASE)).toBeNull(); + }); +}); + +describe('the Customize button and the override badge (objectstack#28562)', () => { + it('offered: enabled, titled with the base, and pressing it calls back', () => { + const onCustomize = vi.fn(); + render(); + const button = screen.getByTestId('studio-customize'); + expect(button).toBeEnabled(); + expect(button).toHaveTextContent(t('engine.studio.customize.action', 'en')); + expect(button).toHaveAttribute('title', tFormat('engine.studio.customize.actionTitle', 'en', { base: 'HotCRM' })); + button.click(); + expect(onCustomize).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('studio-customize-blocked')).toBeNull(); + }); + + it.each([ + ['walled', t('engine.studio.customize.walled', 'en')], + ['noVersion', tFormat('engine.studio.customize.noVersion', 'en', { base: 'HotCRM' })], + ] as const)('blocked (%s): disabled, and its wrapper says why', (block, reason) => { + const onCustomize = vi.fn(); + render(); + const button = screen.getByTestId('studio-customize'); + expect(button).toBeDisabled(); + const wrapper = screen.getByTestId('studio-customize-blocked'); + expect(wrapper).toHaveAttribute('data-block', block); + expect(wrapper).toHaveAttribute('title', reason); + expect(document.getElementById(button.getAttribute('aria-describedby')!)).toHaveTextContent(reason); + }); + + it('the badge names the base, and its title the version the override is based on', () => { + render(); + const chip = screen.getByTestId('studio-override-chip'); + expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { base: 'HotCRM' })); + expect(chip).toHaveAttribute( + 'title', + tFormat('engine.studio.customize.overridesTitle', 'en', { base: 'HotCRM', basedOn: '4.0.0' }), + ); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx new file mode 100644 index 00000000000..6c26c43d570 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.customizePublish-28562.test.tsx @@ -0,0 +1,323 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28562 (ADR-0146 D2, D3) — on a read-only base, the Studio + * surface customizes into the base's paired customization package: the + * pending count, the changes panel and Publish act on the pair's drafts, the + * version an override is based on is the one the base's listed manifest + * declares, and whether the ensure door refuses this caller is read from the + * posture and the platform-operator flag the server publishes. + * + * The REAL `StudioDesignSurface` and the REAL metadata client over a fetch + * stub that serves the package list, the ensure door (201 when it creates the + * pair, 200 after), the pair's drafts and the batch publish door. Stubbed: + * the dashboard canvas (its label and one edit), `DraftChangesPanel` (reduced + * to the package it reviews and its publish button; the sheet has its own + * tests) and the session (`useAuth`'s user and served config). + * + * Pinned: + * - a base with its pair listed: Pending reads the pair's drafts, the panel + * reviews the pair, and Publish is enabled and publishes the pair; + * - end to end: the first edit asks the door, writes the pair's draft + * declaring the override at the listed version, and Pending and Publish + * follow the pair the door named; + * - a listed manifest that states no version: Customize disabled, saying so; + * - a walled admin (a group posture, not the platform operator) sees + * Customize disabled, and the platform operator does not; + * - CONTROL: a base with no pair keeps the read-only Publish and reviews + * the base, as before. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const BASE = 'com.example.hotcrm'; +const PAIR = 'com.example.hotcrm.custom'; + +const NAV = [{ id: 'nav_pipeline', type: 'dashboard', label: 'Pipeline', dashboardName: 'pipeline' }]; +const APP = { name: 'crm_app', label: 'CRM', navigation: NAV }; +const PIPELINE = { name: 'pipeline', label: 'Pipeline', widgets: [] }; + +const srv = vi.hoisted(() => ({ + pairListed: false, + baseVersion: '4.0.0' as string | undefined, + pairDrafts: new Map>(), + reads: [] as string[], + ensure: [] as number[], + saves: [] as Array<{ path: string; search: string; body: Record }>, + publishes: [] as string[], +})); + +const session = vi.hoisted(() => ({ + posture: undefined as string | undefined, + user: null as null | { id: string; name: string; email: string; isPlatformAdmin?: boolean }, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const actual = await importOriginal(); + const getAuthConfig = async () => ({ features: session.posture ? { tenancyPosture: session.posture } : {} }); + return { + ...actual, + useAuth: () => ({ ...actual.useAuth(), user: session.user, getAuthConfig }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('sonner', () => ({ + toast: { warning: vi.fn(), error: vi.fn(), success: vi.fn(), info: vi.fn(), message: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ + DraftChangesPanel: ({ packageId, onPublish }: { packageId: string | null; onPublish?: () => void | Promise }) => ( +
+ {onPublish && ( + + )} +
+ ), +})); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { t } from '../metadata-admin/i18n'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = + (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +function BoardCanvas({ draft, onPatch }: { draft: Record; onPatch?: (patch: Record) => void }) { + return ( +
+

{String(draft?.label ?? '')}

+ +
+ ); +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +} + +function packageRows() { + return [ + { + manifest: { id: BASE, name: 'HotCRM', namespace: 'hotcrm', ...(srv.baseVersion ? { version: srv.baseVersion } : {}) }, + writable: false, + }, + ...(srv.pairListed + ? [{ manifest: { id: PAIR, name: 'HotCRM customizations', namespace: 'hotcrm_custom', version: '0.1.0' }, writable: true }] + : []), + ]; +} + +const draftRows = () => + [...srv.pairDrafts.entries()].map(([key, body]) => { + const [type, name] = key.split('/') as [string, string]; + return { type, name, packageId: PAIR, label: body.label }; + }); + +beforeAll(() => { + registerMetadataPreview('dashboard', BoardCanvas as never); +}); + +beforeEach(() => { + srv.pairListed = false; + srv.baseVersion = '4.0.0'; + srv.pairDrafts.clear(); + srv.reads.length = 0; + srv.ensure.length = 0; + srv.saves.length = 0; + srv.publishes.length = 0; + session.posture = undefined; + session.user = null; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown, init?: RequestInit) => { + const url = new URL(String(input), 'http://localhost'); + const path = url.pathname; + const method = init?.method ?? 'GET'; + if (method === 'GET') srv.reads.push(`${path}${url.search}`); + if (path === '/api/v1/packages') return json({ success: true, data: packageRows() }); + if (path === `/api/v1/packages/${BASE}/ensure-customization` && method === 'POST') { + const created = !srv.pairListed; + srv.pairListed = true; + srv.ensure.push(created ? 201 : 200); + return json( + { + success: true, + data: { basePackageId: BASE, packageId: PAIR, namespace: 'hotcrm_custom', dependencyRange: '^4', created, writable: true }, + }, + created ? 201 : 200, + ); + } + const publish = /^\/api\/v1\/packages\/([^/]+)\/publish-drafts$/.exec(path); + if (publish && method === 'POST') { + const id = decodeURIComponent(publish[1]!); + srv.publishes.push(id); + const published = id === PAIR ? draftRows().map((d) => ({ type: d.type, name: d.name, version: 'sha256:1' })) : []; + if (id === PAIR) srv.pairDrafts.clear(); + return json({ + success: true, + data: { success: published.length > 0, outcome: published.length > 0 ? 'published' : 'nothing_to_publish', publishedCount: published.length, failedCount: 0, published, failed: [] }, + }); + } + if (path === '/api/v1/meta/_drafts') { + const scope = url.searchParams.get('packageId'); + return json({ drafts: !scope || scope === PAIR ? draftRows() : [] }); + } + if (path === '/api/v1/meta/app') return json([{ name: 'crm_app', label: 'CRM' }]); + if (path === '/api/v1/meta/app/crm_app/layers') return json({ code: null, overlay: null, overlayScope: null, effective: APP }); + if (path === '/api/v1/meta/dashboard/pipeline/layers') { + return json({ code: PIPELINE, overlay: null, overlayScope: null, effective: PIPELINE }); + } + if (path === '/api/v1/meta/dashboard/pipeline') { + if (method === 'PUT') { + const body = JSON.parse(String(init?.body ?? '{}')) as Record; + srv.saves.push({ path, search: url.search, body }); + if (url.searchParams.get('package') !== PAIR) return json({ success: false, error: { message: 'read-only' } }, 403); + srv.pairDrafts.set('dashboard/pipeline', body); + return json({ success: true, version: `v${srv.saves.length}`, state: 'draft' }); + } + const draft = url.searchParams.get('package') === PAIR ? srv.pairDrafts.get('dashboard/pipeline') : undefined; + return draft ? json({ type: 'dashboard', name: 'pipeline', item: draft, version: 'v1' }) : json({}, 404); + } + if (path.endsWith('/layers')) return json({ code: null, overlay: null, overlayScope: null, effective: null }); + if (/^\/api\/v1\/meta\/[^/]+\/[^/]+$/.test(path)) return json({}, 404); + return json([]); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderWorkbench() { + return render( + + + } /> + + , + ); +} + +const publishButton = () => screen.getByRole('button', { name: /^Publish/ }); +const panelPackage = () => screen.getByTestId('changes-panel').getAttribute('data-package'); +const header = () => screen.getByTestId('if-item-header'); + +async function openPipeline(): Promise { + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent(/^Pipeline/), { timeout: 8000 }); +} + +describe('a read-only base with its pair listed: Pending, the panel and Publish act on the pair (objectstack#28562)', () => { + it('reads the pair’s drafts, reviews the pair, and publishes the pair', async () => { + srv.pairListed = true; + srv.pairDrafts.set('dashboard/pipeline', { ...PIPELINE, label: 'Pipeline (ours)', overrides: { package: BASE, basedOn: '4.0.0' } }); + renderWorkbench(); + + await waitFor(() => expect(panelPackage()).toBe(PAIR), { timeout: 8000 }); + await waitFor(() => expect(srv.reads).toContain(`/api/v1/meta/_drafts?packageId=${encodeURIComponent(PAIR)}`)); + await waitFor(() => expect(publishButton()).toBeEnabled(), { timeout: 8000 }); + expect(publishButton().parentElement).not.toHaveAttribute('data-disabled-reason'); + + fireEvent.click(screen.getByTestId('publish-all-drafts')); + await waitFor(() => expect(srv.publishes).toEqual([PAIR])); + }); + + it('CONTROL: a base with no pair keeps the read-only Publish, and the panel reviews the base', async () => { + renderWorkbench(); + await waitFor(() => expect(publishButton().parentElement).toHaveAttribute('data-disabled-reason'), { timeout: 8000 }); + expect(publishButton()).toBeDisabled(); + expect(panelPackage()).toBe(BASE); + expect(screen.queryByTestId('publish-all-drafts')).toBeNull(); + }); +}); + +describe('end to end: the first edit makes the pair, and Pending and Publish follow it (objectstack#28562)', () => { + it('asks the door once, writes the pair’s draft at the listed version, then reviews and publishes the pair', async () => { + renderWorkbench(); + await openPipeline(); + fireEvent.click(await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 })); + + fireEvent.click(screen.getByRole('button', { name: 'patch the board' })); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.ensure).toEqual([201]); + const [saved] = srv.saves; + expect(new URLSearchParams(saved!.search).get('package')).toBe(PAIR); + expect(new URLSearchParams(saved!.search).get('mode')).toBe('draft'); + expect(saved!.body).toStrictEqual({ ...PIPELINE, label: 'Pipeline (ours)', overrides: { package: BASE, basedOn: '4.0.0' } }); + + await waitFor(() => expect(panelPackage()).toBe(PAIR), { timeout: 8000 }); + await waitFor(() => expect(publishButton()).toBeEnabled(), { timeout: 8000 }); + fireEvent.click(screen.getByTestId('publish-all-drafts')); + await waitFor(() => expect(srv.publishes).toEqual([PAIR])); + }); + + it('a listed manifest that states no version: Customize is disabled, saying so, and nothing is asked', async () => { + srv.baseVersion = undefined; + renderWorkbench(); + await openPipeline(); + const blocked = await within(header()).findByTestId('studio-customize-blocked', undefined, { timeout: 8000 }); + expect(blocked).toHaveAttribute('data-block', 'noVersion'); + expect(within(blocked).getByTestId('studio-customize')).toBeDisabled(); + expect(srv.ensure).toEqual([]); + }); +}); + +describe('the wall is read from what the server publishes (objectstack#28562)', () => { + it('a group posture and an admin who is not the platform operator: Customize disabled, with the reason', async () => { + session.posture = 'group'; + session.user = { id: 'u1', name: 'Ada', email: 'ada@example.com', isPlatformAdmin: false }; + renderWorkbench(); + await openPipeline(); + await waitFor( + () => expect(within(header()).getByTestId('studio-customize-blocked')).toHaveAttribute('data-block', 'walled'), + { timeout: 8000 }, + ); + expect(within(header()).getByTestId('studio-customize-blocked')).toHaveAttribute( + 'title', + t('engine.studio.customize.walled', 'en'), + ); + }); + + it('CONTROL: the platform operator under the same posture is offered Customize', async () => { + session.posture = 'group'; + session.user = { id: 'u0', name: 'Op', email: 'op@example.com', isPlatformAdmin: true }; + renderWorkbench(); + await openPipeline(); + const button = await within(header()).findByTestId('studio-customize', undefined, { timeout: 8000 }); + await new Promise((r) => setTimeout(r, 300)); + expect(button).toBeEnabled(); + expect(within(header()).queryByTestId('studio-customize-blocked')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx index 093f51b0d59..39dfc54dacb 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx @@ -7,6 +7,10 @@ * "Read-only · Customize with an overlay", linking to that item's Setup page. * Every other case (a type that takes no overlay, no open item, a registry not * read) gets a plain "Read-only" that links nowhere. + * + * objectstack#28562 (ADR-0146 D2) — so do a view, a dashboard and a report, + * overlay-able or not: Studio customizes those itself, into the base's paired + * customization package, so Setup's overlay page is not their editor. */ import '@testing-library/jest-dom/vitest'; @@ -32,6 +36,9 @@ const LINK = t('engine.studio.pkg.readonlyLink', 'en'); const READ_ONLY = t('engine.studio.pkg.readonly', 'en'); const SERVED = [ { type: 'view', allowEnvironmentOverlay: true }, + { type: 'dashboard', allowEnvironmentOverlay: true }, + { type: 'report', allowEnvironmentOverlay: true }, + { type: 'email_template', allowEnvironmentOverlay: true }, { type: 'page', allowEnvironmentOverlay: false }, { type: 'object', allowEnvironmentOverlay: false }, ]; @@ -51,16 +58,19 @@ afterEach(() => { }); describe('the read-only mark offers an overlay only for a type that takes one (#27356 row 13)', () => { - it('an open view (overlay-able): the overlay link, landing on that item’s Setup page', () => { + it('an open overlay-able item Studio does not customize: the overlay link, landing on that item’s Setup page', () => { registry.entries = SERVED; - const mark = renderMark({ type: 'view', name: 'open_tickets' }); + const mark = renderMark({ type: 'email_template', name: 'welcome' }); expect(mark.tagName).toBe('A'); expect(mark).toHaveTextContent(LINK); - expect(mark).toHaveAttribute('href', '/apps/setup/metadata/view/open_tickets'); + expect(mark).toHaveAttribute('href', '/apps/setup/metadata/email_template/welcome'); expect(mark).toHaveAttribute('title', 'hint'); }); it.each([ + ['an open view (Studio customizes it, objectstack#28562)', { type: 'view', name: 'open_tickets' }], + ['an open dashboard (Studio customizes it, objectstack#28562)', { type: 'dashboard', name: 'pipeline' }], + ['an open report (Studio customizes it, objectstack#28562)', { type: 'report', name: 'won_deals' }], ['an open page (no overlay)', { type: 'page', name: 'home' }], ['an open object (no overlay)', { type: 'object', name: 'account' }], ['no open item', null], @@ -74,8 +84,8 @@ describe('the read-only mark offers an overlay only for a type that takes one (# expect(mark).toHaveAttribute('title', 'hint'); }); - it('a registry not read yet: no overlay promised, even for a view', () => { - const mark = renderMark({ type: 'view', name: 'open_tickets' }); + it('a registry not read yet: no overlay promised, even for an overlay-able type', () => { + const mark = renderMark({ type: 'email_template', name: 'welcome' }); expect(mark.tagName).toBe('SPAN'); expect(mark).toHaveTextContent(READ_ONLY); }); From c1fa2b5886fe7f80052dc31cce224d90364f69ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:45:07 +0000 Subject: [PATCH 3/5] test(studio): pin the pair saves' preconditions Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQpUMdR7QmHKTFR8iB --- .../InterfacesPillar.customize-28562.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 c54504e4f1a..64ab2ff79fc 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 @@ -356,6 +356,22 @@ describe('Customize a read-only base’s dashboard into its pair (objectstack#28 expect(srv.saves[1]!.opts.packageId).toBe(PAIR); }); + it('the first save into the pair is pinned to "no draft there", the next to the version it was answered', async () => { + mount(); + await openPipeline(); + await customize(); + + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.saves[0]!.opts).toMatchObject({ packageId: PAIR, ifNoneMatch: '*' }); + expect(srv.saves[0]!.opts).not.toHaveProperty('ifMatch'); + + patchBoard(); + await waitFor(() => expect(srv.saves).toHaveLength(2), { timeout: 8000 }); + expect(srv.saves[1]!.opts).toMatchObject({ packageId: PAIR, ifMatch: 'v1' }); + expect(srv.saves[1]!.opts).not.toHaveProperty('ifNoneMatch'); + }); + it('a second item asks the door again and is answered 200; it is written into the same pair', async () => { mount(); await openPipeline(); @@ -513,6 +529,8 @@ describe('Customize an object leaf’s list view into the pair (objectstack#2856 expect(saved).toMatchObject({ type: 'view', name: 'opportunity.default', opts: { mode: 'draft', packageId: PAIR } }); expect((saved!.body.config as Record).calendar).toEqual({ startDateField: 'start_date', titleField: 'name' }); expect(saved!.body.overrides).toEqual({ package: BASE, basedOn: '4.0.0' }); + // Read at the base before the pair was there: pinned to "no draft in the pair". + expect(saved!.opts).toMatchObject({ ifNoneMatch: '*' }); expect(srv.base.get('view/opportunity.default')).toStrictEqual(BASE_ROWS['view/opportunity.default']); await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); expect(screen.getByRole('button', { name: t('engine.studio.inspector.listView.addView', 'en') })).toBeDisabled(); From 9f2e6cb69b5f78ac02886cf0558631fe2e7f8373 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:18:59 +0000 Subject: [PATCH 4/5] chore(changeset): studio customizes a read-only base into its pair Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQpUMdR7QmHKTFR8iB --- .../28562-studio-customize-read-only-base.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/28562-studio-customize-read-only-base.md diff --git a/.changeset/28562-studio-customize-read-only-base.md b/.changeset/28562-studio-customize-read-only-base.md new file mode 100644 index 00000000000..1f2b095e491 --- /dev/null +++ b/.changeset/28562-studio-customize-read-only-base.md @@ -0,0 +1,21 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): customize a read-only package's view, dashboard or report into its `.custom` pair, with an override badge and "Restore package original" + +Clause-②: no +Metadata-protocol: none + +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 (`.custom`, ADR-0146 D2 and D3), as a declared override of the base's item. + +- **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. +- **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. +- **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. +- **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. +- **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. +- **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=`). The base's item reads back. +- **The read-only mark** no longer links a view, dashboard or report to Setup's overlay page. +- **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. +- **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. From 4bff2978d75893fff2955de8dd36d04a77614b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 14:02:25 +0000 Subject: [PATCH 5/5] fix(studio): name the customize strings' package placeholder {package} The zh wording census reads a bare Latin "base" in a value as an untranslated word, and {base} is one. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jQpUMdR7QmHKTFR8iB --- .../InterfacesPillar.customize-28562.test.tsx | 4 ++-- ...tudioCustomizeControls.customize-28562.test.tsx | 8 ++++---- .../studio-design/StudioCustomizeControls.tsx | 10 +++++----- .../views/studio-design/StudioDesignSurface.tsx | 6 +++--- ui/packages/i18n/src/namespaces/studio/en.ts | 14 +++++++------- ui/packages/i18n/src/namespaces/studio/zh.ts | 14 +++++++------- 6 files changed, 28 insertions(+), 28 deletions(-) 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 64ab2ff79fc..3a3c9b01069 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 @@ -346,8 +346,8 @@ describe('Customize a read-only base’s dashboard into its pair (objectstack#28 // The badge reads the declaration the pair's draft now serves. const chip = await within(header()).findByTestId('studio-override-chip', undefined, { timeout: 8000 }); - expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { base: 'HotCRM' })); - expect(chip).toHaveAttribute('title', tFormat('engine.studio.customize.overridesTitle', 'en', { base: 'HotCRM', basedOn: '4.0.0' })); + expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { package: 'HotCRM' })); + expect(chip).toHaveAttribute('title', tFormat('engine.studio.customize.overridesTitle', 'en', { package: 'HotCRM', basedOn: '4.0.0' })); // A later edit of the same item asks the door no more. patchBoard(); 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 f6e98c4745f..00bd3f43e50 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 @@ -203,7 +203,7 @@ describe('the Customize button and the override badge (objectstack#28562)', () = const button = screen.getByTestId('studio-customize'); expect(button).toBeEnabled(); expect(button).toHaveTextContent(t('engine.studio.customize.action', 'en')); - expect(button).toHaveAttribute('title', tFormat('engine.studio.customize.actionTitle', 'en', { base: 'HotCRM' })); + expect(button).toHaveAttribute('title', tFormat('engine.studio.customize.actionTitle', 'en', { package: 'HotCRM' })); button.click(); expect(onCustomize).toHaveBeenCalledTimes(1); expect(screen.queryByTestId('studio-customize-blocked')).toBeNull(); @@ -211,7 +211,7 @@ describe('the Customize button and the override badge (objectstack#28562)', () = it.each([ ['walled', t('engine.studio.customize.walled', 'en')], - ['noVersion', tFormat('engine.studio.customize.noVersion', 'en', { base: 'HotCRM' })], + ['noVersion', tFormat('engine.studio.customize.noVersion', 'en', { package: 'HotCRM' })], ] as const)('blocked (%s): disabled, and its wrapper says why', (block, reason) => { const onCustomize = vi.fn(); render(); @@ -226,10 +226,10 @@ describe('the Customize button and the override badge (objectstack#28562)', () = it('the badge names the base, and its title the version the override is based on', () => { render(); const chip = screen.getByTestId('studio-override-chip'); - expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { base: 'HotCRM' })); + expect(chip).toHaveTextContent(tFormat('engine.studio.customize.overrides', 'en', { package: 'HotCRM' })); expect(chip).toHaveAttribute( 'title', - tFormat('engine.studio.customize.overridesTitle', 'en', { base: 'HotCRM', basedOn: '4.0.0' }), + tFormat('engine.studio.customize.overridesTitle', 'en', { package: 'HotCRM', basedOn: '4.0.0' }), ); }); }); 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 f7a30c342da..759799ba270 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioCustomizeControls.tsx @@ -207,7 +207,7 @@ export function StudioCustomizeButton({ block === 'walled' ? t('engine.studio.customize.walled', locale) : block === 'noVersion' - ? tFormat('engine.studio.customize.noVersion', locale, { base: baseLabel }) + ? tFormat('engine.studio.customize.noVersion', locale, { package: baseLabel }) : null; const button = (