askCustomize(headerCustomize.key)}
+ />
+ )}
+ {readOnly && !headerUnlocked && (
)}
{isObjectLeaf && openSurface && current && (
@@ -5896,8 +6317,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}
/>
) : (
@@ -5970,7 +6392,10 @@ export function InterfacesPillar({
@@ -6045,7 +6471,7 @@ export function InterfacesPillar({
setBlockingReport({ key: inspectorKey, count })
}
// objectui#11136 — same flag, same threading as the block inspector.
- readOnly={readOnly}
+ readOnly={leafReadOnly}
locale={locale}
/>
@@ -6086,7 +6512,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"
>
@@ -6163,6 +6596,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/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);
});
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 a211e4fb670..6eea83632a1 100644
--- a/ui/packages/i18n/src/namespaces/studio/en.ts
+++ b/ui/packages/i18n/src/namespaces/studio/en.ts
@@ -3196,6 +3196,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 {package} 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 {package} is installed, so its items cannot be customized here.',
+ 'engine.studio.customize.overrides': 'Overrides {package}',
+ 'engine.studio.customize.overridesTitle': 'This item replaces {package}’s item of the same name on this deployment. It was taken from {package} {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 {package}’s “{item}”?',
+ 'engine.studio.customize.restoreBody': 'This deployment’s customization of “{item}” is deleted, and {package}’s version is served again.',
+ 'engine.studio.customize.restoreDraft': 'Its unpublished draft is discarded first.',
+ 'engine.studio.customize.restoreDone': '“{item}” is back to {package}’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 2187ed651a7..825c433d9b7 100644
--- a/ui/packages/i18n/src/namespaces/studio/zh.ts
+++ b/ui/packages/i18n/src/namespaces/studio/zh.ts
@@ -2831,6 +2831,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': '为本部署修改此项。第一次编辑会保存为自定义草稿,{package} 本身保持安装时的样子。',
+ 'engine.studio.customize.walled': '在此部署中,只有平台运营方可以自定义已安装的软件包。',
+ 'engine.studio.customize.noVersion': '软件包列表没有给出已安装的 {package} 版本,因此无法在此自定义它的项。',
+ 'engine.studio.customize.overrides': '覆盖 {package}',
+ 'engine.studio.customize.overridesTitle': '此项在本部署中替换 {package} 中的同名项,取自 {package} {basedOn}。',
+ 'engine.studio.customize.draftFooter': '编辑会保存为本部署自定义的草稿;发布后生效。',
+ 'engine.studio.customize.restore': '恢复软件包原版',
+ 'engine.studio.customize.restoreConfirm': '恢复 {package} 的「{item}」?',
+ 'engine.studio.customize.restoreBody': '本部署对「{item}」的自定义会被删除,重新使用 {package} 的版本。',
+ 'engine.studio.customize.restoreDraft': '会先丢弃它未发布的草稿。',
+ 'engine.studio.customize.restoreDone': '「{item}」已恢复为 {package} 的版本',
'engine.studio.app.scaffoldNav': '将本包的对象添加为菜单项',
'engine.studio.landing.title': '应用构建',
'engine.studio.landing.description': '在一个可写软件包里设计对象、表单、自动化与界面;编辑存为草稿,整包一次发布。源码与已安装的软件包为只读:可在此浏览,并用环境覆盖进行定制。',