From b8bb9830e33adb6ec43b75fb894f102a0c7a852c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:45:05 +0000 Subject: [PATCH 1/3] feat(studio): a read-only base and its customization package as one surface Each Studio pillar rail lists the pair's added items beside the base's, marks customized and added rows from the served customization read, and offers an only-customizations filter. The package switcher folds the pair under its base, a pair's pillar URL lands on the base, and New on a read-only base lands in the pair through the ensure door. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jV3gDN72FtkqvGmfBd --- .../views/studio-design/CustomizationMark.tsx | 8 +- .../InterfacesPillar.customize-28562.test.tsx | 12 +- ...CustomizeControls.customize-28562.test.tsx | 1 + .../studio-design/StudioDesignSurface.tsx | 699 ++++++++++++++---- .../src/views/studio-design/StudioRail.tsx | 4 + .../StudioRailCustomizations.tsx | 229 ++++++ .../src/views/studio-design/navSurface.ts | 9 + .../src/views/studio-design/packages-io.ts | 10 +- ui/packages/i18n/src/namespaces/studio/en.ts | 7 + ui/packages/i18n/src/namespaces/studio/zh.ts | 6 + 10 files changed, 839 insertions(+), 146 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx diff --git a/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx b/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx index 8de2e3436db..de7f2baede2 100644 --- a/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx +++ b/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx @@ -94,8 +94,12 @@ export type PackageCustomizationsState = * * The base's name comes from the package list the area already reads (one * request, `packages-io.ts`); when that list cannot name it, the id stands in. + * + * `refresh` (objectstack#28572): read again whenever it moves (Studio's + * publish, which is what changes the served overrides). A re-read keeps the + * last answer for the same base until it lands, so the marks do not blink. */ -export function usePackageCustomizations(baseId: string | null): PackageCustomizationsState { +export function usePackageCustomizations(baseId: string | null, refresh: unknown = 0): PackageCustomizationsState { const readable = baseId !== null && customizationPackageId(baseId).ok; const [answer, setAnswer] = React.useState<{ id: string; state: PackageCustomizationsState } | null>(null); React.useEffect(() => { @@ -119,7 +123,7 @@ export function usePackageCustomizations(baseId: string | null): PackageCustomiz return () => { cancelled = true; }; - }, [baseId, readable]); + }, [baseId, readable, refresh]); if (!readable) return { status: 'off' }; return answer?.id === baseId ? answer.state : { status: 'loading' }; } 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 3a3c9b01069..9e00cc6da7d 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 @@ -262,14 +262,18 @@ function Host({ listedPair?: string | null; }) { const [pairId, setPairId] = React.useState(listedPair); - const ensure = React.useCallback(async () => { + const ensurePackage = React.useCallback(async () => { const answer = await ensureCustomizationPackage(BASE); setPairId(answer.packageId); - return answer.packageId; + return answer; }, []); + const ensure = React.useCallback(async () => (await ensurePackage()).packageId, [ensurePackage]); const customization = React.useMemo( - () => (readOnly ? { base: BASE, baseLabel: 'HotCRM', basedOn, walled, pairId, ensure } : null), - [readOnly, basedOn, walled, pairId, ensure], + () => + readOnly + ? { base: BASE, baseLabel: 'HotCRM', basedOn, walled, pairId, ensure, ensurePackage, marks: { status: 'off' } } + : null, + [readOnly, basedOn, walled, pairId, ensure, ensurePackage], ); return ; } 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 00bd3f43e50..454ffcf25b5 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 @@ -137,6 +137,7 @@ describe('the ensure door client (objectstack#28562)', () => { await expect(ensureCustomizationPackage(BASE)).resolves.toEqual({ basePackageId: BASE, packageId: PAIR, + namespace: 'hotcrm_custom', created: true, writable: true, }); 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 0d9d1637397..d3b77e1754d 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -36,6 +36,7 @@ import type { FlowRuntimeState as SpecFlowRuntimeState } from '@objectstack/spec import { canonicalMetaUrlType } from '@objectstack/spec/shared'; import { resolveI18nLabel, type I18nLabel } from '@objectstack/spec/ui'; import { isPlainRecord } from '@objectstack/spec/data'; +import { customizationNamespace, customizationPackageId } from '@objectstack/spec/kernel'; import { StudioChatDock, type StudioCopilotStatus, type StudioSurfaceLabel } from './StudioAiCopilot.js'; import { StudioRightSlot } from './StudioRightSlot.js'; import { @@ -393,8 +394,21 @@ import { listedCustomizationPairOf, listedPackageVersion, prefixObjectName, + type EnsuredCustomizationPackage, type PkgEntry, } from './packages-io.js'; +import { usePackageCustomizations, type PackageCustomizationsState } from './CustomizationMark.js'; +import { + RailCustomizationFilter, + RailCustomizationMark, + StudioPairItemChip, + customizationPairBaseOf, + foldCustomizationPairs, + railCustomizationsOf, + useRailCustomizationsOnly, + withPairItems, + type RailCustomizations, +} from './StudioRailCustomizations.js'; import { StudioCustomizeButton, StudioOverrideChip, @@ -1018,6 +1032,9 @@ function PackageSwitcher({ }, [packageId]); const current = pkgs?.find((p) => p.id === packageId) ?? null; + // objectstack#28572 (ADR-0146 D3) — a base's paired `.custom` has no + // row of its own: it is folded under its base, which names it. + const folded = React.useMemo(() => foldCustomizationPairs(pkgs ?? []), [pkgs]); /** * Which of the three the trigger is showing (objectui#7368). A failure wins * over a list already in hand: after a failed refresh the names on screen are @@ -1448,9 +1465,11 @@ function PackageSwitcher({ {pkgListState === 'loaded' && pkgs?.length === 0 && (

{t('engine.studio.pkg.none', locale)}

)} - {pkgs?.map((p) => ( + {pkgs !== null && folded.rows.map((p) => ( + + ); +} + +/** The item header's chip for an item the pair adds: it names the pair (ADR-0146 D3). */ +export function StudioPairItemChip({ pairId, locale }: { pairId: string; locale: string }): React.ReactElement { + return ( + + + ); +} + +/** The rail's state for "only customizations": on or off, per pillar. */ +export function useRailCustomizationsOnly(): [boolean, () => void] { + const [on, setOn] = React.useState(false); + const toggle = React.useCallback(() => setOn((v) => !v), []); + return [on, toggle]; +} + +/** + * The package switcher's rows with each base's pair folded under it (ADR-0146 + * D3: "the package switcher folds `hotcrm.custom` under HotCRM instead of + * giving it a row"): a row whose id is a pair (`customizationBasePackageId`) + * of a base the list also holds has no row of its own; `pairOf` names it by + * its base. A pair whose base is not listed keeps its row. + */ +export function foldCustomizationPairs( + rows: readonly T[], +): { rows: T[]; pairOf: ReadonlyMap } { + const ids = new Set(rows.map((r) => r.id)); + const pairOf = new Map(); + const out: T[] = []; + for (const row of rows) { + const base = customizationBasePackageId(row.id); + if (base !== null && ids.has(base)) { + pairOf.set(base, row); + continue; + } + out.push(row); + } + return { rows: out, pairOf }; +} + +/** + * The base a Studio pillar URL of a pair lands on (ADR-0146 D3: the pair has + * no surface of its own), or `null` for any other package. By the spec's + * pairing rule alone: a pair exists only beside its installed base (the ensure + * door creates it for one, and the base cannot be uninstalled while it + * exists). + */ +export function customizationPairBaseOf(packageId: string | null): string | null { + return packageId === null ? null : customizationBasePackageId(packageId); +} diff --git a/ui/packages/app-shell/src/views/studio-design/navSurface.ts b/ui/packages/app-shell/src/views/studio-design/navSurface.ts index 595ebbe8c06..437dd708e95 100644 --- a/ui/packages/app-shell/src/views/studio-design/navSurface.ts +++ b/ui/packages/app-shell/src/views/studio-design/navSurface.ts @@ -57,6 +57,15 @@ export interface Surface { filters?: ObjectNavItem['filters']; /** An `object` entry's `viewName`, carried as authored for the canvas preview (objectui#11774). */ viewName?: ObjectNavItem['viewName']; + /** + * objectstack#28572 (ADR-0146 D3) — on a read-only base's surface, the + * base's paired `.custom` package when this row is an item that pair + * ADDS (the base's own list does not hold its name). Such an item opens + * writable there: read and saved at this package. Absent for every item of + * the open package, an override the pair serves under the base's name + * included. + */ + addedIn?: string; } /** 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 24484822dc5..d5eb52d2ccb 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 @@ -229,6 +229,12 @@ export interface EnsuredCustomizationPackage { basePackageId: string; /** The pair: every write of a customization of the base addresses it. */ packageId: string; + /** + * [objectstack#28572] The pair's namespace as the door answers it + * (`_custom`, ADR-0146 D3): a new object authored into the + * pair is prefixed with it. Never derived here. + */ + namespace: string; /** True when this call created the pair (HTTP 201), false when it was there (200). */ created: boolean; writable: boolean; @@ -263,11 +269,11 @@ export async function ensureCustomizationPackage(baseId: string): Promise = { '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', + // objectstack#28572 — one surface for a read-only base and its customization package. + 'engine.studio.customize.addedIn': 'In {pair}', + 'engine.studio.customize.addedInTitle': 'This item is added by {pair}, this deployment’s customization package, and is saved there.', + 'engine.studio.pkg.withPair': 'with {pair}', + 'engine.studio.if.addInPairTitle': 'New in the customization of {baseName}', + 'engine.studio.interfaces.create.descriptionInPair': + 'Saved as a draft in this deployment’s customization of {baseName}, and opened on the canvas. The navigation of {baseName} stays as it was installed.', '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 825c433d9b7..4e14978b78b 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2843,6 +2843,12 @@ const STUDIO_ZH: Record = { 'engine.studio.customize.restoreBody': '本部署对「{item}」的自定义会被删除,重新使用 {package} 的版本。', 'engine.studio.customize.restoreDraft': '会先丢弃它未发布的草稿。', 'engine.studio.customize.restoreDone': '「{item}」已恢复为 {package} 的版本', + // objectstack#28572 — 只读基础包与其定制包合为一个界面。 + 'engine.studio.customize.addedIn': '位于 {pair}', + 'engine.studio.customize.addedInTitle': '此项由本部署的定制包 {pair} 新增,并保存在其中。', + 'engine.studio.pkg.withPair': '含 {pair}', + 'engine.studio.if.addInPairTitle': '新建到 {baseName} 的定制', + 'engine.studio.interfaces.create.descriptionInPair': '存为本部署对 {baseName} 定制的草稿,并在画布上打开。{baseName} 的导航保持安装时的样子。', 'engine.studio.app.scaffoldNav': '将本包的对象添加为菜单项', 'engine.studio.landing.title': '应用构建', 'engine.studio.landing.description': '在一个可写软件包里设计对象、表单、自动化与界面;编辑存为草稿,整包一次发布。源码与已安装的软件包为只读:可在此浏览,并用环境覆盖进行定制。', From c27e0efe34bd5882e21f7fb789b266e5bb2b3583 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:04:37 +0000 Subject: [PATCH 2/3] test(studio): pin one surface for a base and its customization package Pins the marks, the only-customizations filter, the switcher fold, the pair URL redirect and its metadata-area exemption, New into the pair, the added item's save at the pair, and that nothing third-party is drawn. A deep link to an item the pair adds opens once the pair is known. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jV3gDN72FtkqvGmfBd --- ...dioDesignSurface.oneSurface-28572.test.tsx | 431 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 61 ++- .../StudioRailCustomizations.tsx | 32 ++ 3 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx new file mode 100644 index 00000000000..25f1c162efd --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx @@ -0,0 +1,431 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28572 (ADR-0146 D3, "On top, one surface") — Studio shows a + * read-only base and its paired `.custom` package as ONE surface. + * + * The REAL `StudioDesignSurface` and the REAL metadata client over a fetch + * stub that serves the package list (the base read-only, its pair writable, + * and a third package), the base's customization read (N-15), the ensure + * door, and the base's and the pair's item lists. Stubbed: the dashboard + * canvas (its label and one edit), the "All metadata" area (the package it + * holds), `DraftChangesPanel`, and the session. + * + * Pinned: + * - the Interfaces rail lists the base's items and the pair's added item as + * one list: the override the read lists is marked "customized", the + * pair's own item "added", every other row nothing; + * - "only customizations" lists exactly those two; + * - the package switcher gives the pair no row, and names it under the base; + * - a pillar URL of the pair lands on the base, its query kept; the pair's + * "All metadata" URL is NOT sent to the base (no loop); + * - New on the read-only base asks the ensure door, writes the item into the + * pair the door named, links no nav entry, and opens it naming the pair; + * - an added item opens writable: its edit saves at the pair, declaring no + * override, with no Customize and no read-only link; + * - the Data rail lists the pair's added object, marked, opening it names + * the pair; + * - nothing a third package contributes is drawn: no read names it + * (objectstack#28722), and the rail asks none; + * - CONTROL: a base with no pair reads no pair list, and draws no mark and + * no filter. + */ + +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 userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const BASE = 'com.example.hotcrm'; +const PAIR = 'com.example.hotcrm.custom'; +const ADDON = 'com.acme.addon'; + +const NAV = [{ id: 'nav_pipeline', type: 'dashboard', label: 'Pipeline', dashboardName: 'pipeline' }]; +const APP = { name: 'crm_app', label: 'CRM', navigation: NAV }; + +const srv = vi.hoisted(() => ({ + pairListed: true, + /** Items of the pair by `type/name`: published rows and drafts the stub keeps. */ + pairItems: new Map>(), + pairDrafts: new Map>(), + reads: [] as string[], + ensure: [] as number[], + saves: [] as Array<{ path: string; search: string; body: Record }>, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAuth: () => ({ ...actual.useAuth(), user: null, getAuthConfig: async () => ({ features: {} }) }) }; +}); +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: () => null })); +vi.mock('./StudioMetadataArea', () => ({ + StudioMetadataArea: ({ packageId }: { packageId: string | null }) => ( +
+ ), +})); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { t, tFormat } 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', version: '4.0.0' }, writable: false }, + ...(srv.pairListed + ? [{ manifest: { id: PAIR, name: 'HotCRM customizations', namespace: 'hotcrm_custom', version: '0.1.0' }, writable: true }] + : []), + { manifest: { id: ADDON, name: 'Acme add-on', namespace: 'acme', version: '1.0.0' }, writable: false }, + ]; +} + +/** The base's read: its `pipeline` dashboard overridden, and a field its pair adds to `crm_account`. */ +const READ = { + packageId: BASE, + overrides: [{ type: 'dashboard', name: 'pipeline', basedOn: '4.0.0' }], + extensions: [{ object: 'crm_account', kind: 'field', name: 'tier__c' }], + switchedOff: [], + counts: { overrides: 1, extensions: 1, switchedOff: 0 }, +}; +const EMPTY_READ = { packageId: BASE, overrides: [], extensions: [], switchedOff: [], counts: { overrides: 0, extensions: 0, switchedOff: 0 } }; + +/** The base's own lists, by type. The override is served under the base's name (ADR-0146 D8 item 2). */ +const BASE_LISTS: Record>> = { + dashboard: [ + { name: 'pipeline', label: 'Pipeline (ours)', _packageId: BASE }, + { name: 'forecast', label: 'Forecast', _packageId: BASE }, + ], + object: [ + { name: 'crm_account', label: 'Account', _packageId: BASE }, + { name: 'crm_deal', label: 'Deal', _packageId: BASE }, + ], +}; + +function pairList(type: string) { + return [...srv.pairItems.entries()] + .filter(([key]) => key.startsWith(`${type}/`)) + .map(([, body]) => ({ ...body, _packageId: PAIR })); +} + +const draftRows = (scope: string | null, type: string | null) => + scope === PAIR + ? [...srv.pairDrafts.entries()] + .map(([key, body]) => { + const [ty, name] = key.split('/') as [string, string]; + return { type: ty, name, packageId: PAIR, label: body.label }; + }) + .filter((d) => !type || d.type === type) + : []; + +function item(type: string, name: string, search: URLSearchParams): Record | undefined { + const pkg = search.get('package'); + if (pkg === PAIR) return srv.pairDrafts.get(`${type}/${name}`) ?? srv.pairItems.get(`${type}/${name}`); + return undefined; +} + +beforeAll(() => { + registerMetadataPreview('dashboard', BoardCanvas as never); +}); + +beforeEach(() => { + srv.pairListed = true; + srv.pairItems.clear(); + srv.pairDrafts.clear(); + srv.pairItems.set('dashboard/pipeline', { name: 'pipeline', label: 'Pipeline (ours)', widgets: [], overrides: { package: BASE, basedOn: '4.0.0' } }); + srv.pairItems.set('dashboard/wins', { name: 'wins', label: 'Wins', widgets: [] }); + srv.pairItems.set('object/hotcrm_custom_survey', { name: 'hotcrm_custom_survey', label: 'Survey' }); + srv.reads.length = 0; + srv.ensure.length = 0; + srv.saves.length = 0; + 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}/customizations`) { + return json({ success: true, data: srv.pairListed ? READ : EMPTY_READ }); + } + 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, + ); + } + if (path === '/api/v1/meta/_drafts') { + return json({ drafts: draftRows(url.searchParams.get('packageId'), url.searchParams.get('type')) }); + } + if (path === '/api/v1/meta/app') { + return json(url.searchParams.get('package') === PAIR ? [] : [{ name: 'crm_app', label: 'CRM' }]); + } + if (path === '/api/v1/meta/app/crm_app/layers') return json({ code: null, overlay: null, overlayScope: null, effective: APP }); + const list = /^\/api\/v1\/meta\/([^/]+)$/.exec(path); + if (list && method === 'GET') { + const type = decodeURIComponent(list[1]!); + const pkg = url.searchParams.get('package'); + if (pkg === PAIR) return json(srv.pairListed ? pairList(type) : []); + if (pkg === BASE) return json(BASE_LISTS[type] ?? []); + return json([...(BASE_LISTS[type] ?? []), ...pairList(type)]); + } + const one = /^\/api\/v1\/meta\/([^/]+)\/([^/]+)$/.exec(path); + if (one) { + const [type, name] = [decodeURIComponent(one[1]!), decodeURIComponent(one[2]!)]; + 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(`${type}/${name}`, body); + return json({ success: true, version: `v${srv.saves.length}`, state: 'draft' }); + } + const held = item(type, name, url.searchParams); + return held ? json({ type, name, item: held, version: 'v1' }) : json({}, 404); + } + if (path.endsWith('/layers')) { + const m = /^\/api\/v1\/meta\/([^/]+)\/([^/]+)\/layers$/.exec(path); + const base = m ? BASE_LISTS[decodeURIComponent(m[1]!)]?.find((r) => r.name === decodeURIComponent(m[2]!)) : undefined; + const held = m ? srv.pairItems.get(`${decodeURIComponent(m[1]!)}/${decodeURIComponent(m[2]!)}`) : undefined; + const doc = base ?? held ?? null; + return json({ code: doc, overlay: null, overlayScope: null, effective: doc }); + } + return json([]); + }) as unknown as typeof fetch, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function LocationProbe() { + const location = useLocation(); + return

{`${location.pathname}${location.search}`}

; +} + +function renderAt(path: string) { + return render( + + + } /> + } /> + + + , + ); +} + +const ifRail = () => screen.getByTestId('studio-rail-interfaces'); +const dataRail = () => screen.getByTestId('studio-rail-data'); +/** The rail row that opens `type:name` (its tooltip is `type · name`). */ +const rowOf = (rail: HTMLElement, type: string, name: string) => within(rail).queryByTitle(`${type} · ${name}`); +const markOf = (row: HTMLElement | null) => row?.querySelector('[data-testid="customization-mark"]')?.getAttribute('data-kind') ?? null; + +async function interfacesReady(): Promise { + await waitFor(() => expect(rowOf(ifRail(), 'dashboard', 'wins')).not.toBeNull(), { timeout: 8000 }); + await waitFor(() => expect(markOf(rowOf(ifRail(), 'dashboard', 'wins'))).toBe('added'), { timeout: 8000 }); +} + +describe('the Interfaces rail lists a base and its pair as one list (objectstack#28572)', () => { + it('marks the served override "customized" and the pair’s own item "added", and nothing else', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + // The nav entry opens the overridden dashboard: marked from the read. + expect(markOf(rowOf(ifRail(), 'dashboard', 'pipeline'))).toBe('customized'); + // The base's own dashboard, which the read does not list. + expect(rowOf(ifRail(), 'dashboard', 'forecast')).not.toBeNull(); + expect(markOf(rowOf(ifRail(), 'dashboard', 'forecast'))).toBeNull(); + // Listed once each: the pair's copy of the override is the base's row. + expect(within(ifRail()).getAllByTitle('dashboard · pipeline')).toHaveLength(1); + }); + + it('"only customizations" lists exactly the customized and the added item', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + const only = within(ifRail()).getByTestId('studio-rail-customizations-only'); + expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en', { marked: 2 })); + expect(only).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(only); + expect(only).toHaveAttribute('aria-pressed', 'true'); + const rows = within(ifRail()) + .getAllByRole('button') + .map((b) => b.getAttribute('title')) + .filter((title): title is string => !!title && title.includes(' · ')); + expect(rows.sort()).toEqual(['dashboard · pipeline', 'dashboard · wins']); + + fireEvent.click(only); + await waitFor(() => expect(rowOf(ifRail(), 'dashboard', 'forecast')).not.toBeNull()); + }); + + it('an added item opens writable: its edit saves at the pair, declaring no override', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + fireEvent.click(rowOf(ifRail(), 'dashboard', 'wins')!); + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent('Wins'), { timeout: 8000 }); + const header = screen.getByTestId('if-item-header'); + expect(within(header).getByTestId('studio-pair-item-chip')).toHaveTextContent( + tFormat('engine.studio.customize.addedIn', 'en', { pair: PAIR }), + ); + expect(within(header).queryByTestId('studio-customize')).toBeNull(); + expect(within(header).queryByTestId('studio-readonly-link')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'patch the board' })); + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + const [saved] = srv.saves; + expect(saved!.path).toBe('/api/v1/meta/dashboard/wins'); + expect(new URLSearchParams(saved!.search).get('package')).toBe(PAIR); + expect(saved!.body).toStrictEqual({ name: 'wins', label: 'Wins (ours)', widgets: [] }); + // An added item is the pair's already: the door is not asked. + expect(srv.ensure).toEqual([]); + }); + + it('New on the read-only base asks the ensure door, writes into the pair it names, and links no nav entry', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + await userEvent.click(await within(ifRail()).findByTestId('if-create-menu')); + // Only the kinds that create an item: a nav entry of the base takes no edit. + expect(screen.queryByTestId('if-add-nav-url')).toBeNull(); + await userEvent.click(await screen.findByTestId('if-create-dashboard')); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(tFormat('engine.studio.interfaces.create.descriptionInPair', 'en', { baseName: 'HotCRM' })); + fireEvent.change(within(dialog).getByPlaceholderText(t('engine.studio.interfaces.create.dashboardNamePlaceholder', 'en')), { + target: { value: 'Churn watch' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: t('engine.studio.createDraft', 'en') })); + + await waitFor(() => expect(srv.saves).toHaveLength(1), { timeout: 8000 }); + expect(srv.ensure).toEqual([200]); + const [saved] = srv.saves; + expect(saved!.path).toBe('/api/v1/meta/dashboard/churn_watch'); + expect(new URLSearchParams(saved!.search).get('package')).toBe(PAIR); + // No app save: the base's navigation stays as installed. + expect(srv.saves.some((s) => s.path.startsWith('/api/v1/meta/app/'))).toBe(false); + + // It opens at once, naming the pair, and the rail lists it as added. + await waitFor(() => expect(within(screen.getByTestId('if-item-header')).getByTestId('studio-pair-item-chip')).toBeInTheDocument(), { + timeout: 8000, + }); + await waitFor(() => expect(markOf(rowOf(ifRail(), 'dashboard', 'churn_watch'))).toBe('added'), { timeout: 8000 }); + }); +}); + +describe('the Data rail lists the pair’s objects with the base’s (objectstack#28572)', () => { + it('marks the pair’s object "added"; the base’s objects carry no mark; opening it names the pair', async () => { + renderAt(`/studio/${BASE}/data`); + await waitFor(() => expect(within(dataRail()).getByText('Survey')).toBeInTheDocument(), { timeout: 8000 }); + const survey = within(dataRail()).getByText('Survey').closest('button'); + await waitFor(() => expect(markOf(survey)).toBe('added'), { timeout: 8000 }); + // The extension the pair adds to `crm_account` is no override and no + // added item: the object is the base's, unmarked. + expect(markOf(within(dataRail()).getByText('Account').closest('button'))).toBeNull(); + expect(markOf(within(dataRail()).getByText('Deal').closest('button'))).toBeNull(); + + fireEvent.click(survey!); + await waitFor( + () => expect(within(screen.getByTestId('data-object-header')).getByTestId('studio-pair-item-chip')).toBeInTheDocument(), + { timeout: 8000 }, + ); + }); +}); + +describe('the package switcher and the pair’s URLs (objectstack#28572)', () => { + it('the switcher gives the pair no row, and names it under its base', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await waitFor(() => expect(document.querySelector('[data-slot="pkg-name"]')).toHaveTextContent('HotCRM'), { timeout: 8000 }); + fireEvent.click(document.querySelector('[data-slot="pkg-name"]')!.closest('button')!); + await waitFor(() => expect(screen.getAllByTestId('studio-switcher-row').length).toBeGreaterThan(0), { timeout: 8000 }); + const rows = screen.getAllByTestId('studio-switcher-row'); + expect(rows.map((r) => r.getAttribute('data-package'))).toEqual([BASE, ADDON]); + expect(within(rows[0]!).getByTestId('studio-switcher-pair')).toHaveTextContent( + tFormat('engine.studio.pkg.withPair', 'en', { pair: PAIR }), + ); + }); + + it('a pillar URL of the pair lands on the base’s surface, its query kept', async () => { + renderAt(`/studio/${encodeURIComponent(PAIR)}/interfaces?surface=dashboard:wins`); + await waitFor(() => expect(screen.getByTestId('location').textContent).toMatch(new RegExp(`^/studio/${BASE}/interfaces\\?`))); + // The item the pair adds opens once the pair is known, at the pair. + await waitFor(() => expect(screen.getByTestId('board-label')).toHaveTextContent('Wins'), { timeout: 8000 }); + expect(within(screen.getByTestId('if-item-header')).getByTestId('studio-pair-item-chip')).toBeInTheDocument(); + }); + + it('the pair’s "All metadata" URL stays on the pair: it is not sent to the base, so nothing loops', async () => { + renderAt(`/studio/${encodeURIComponent(PAIR)}/metadata`); + const area = await screen.findByTestId('metadata-area', undefined, { timeout: 8000 }); + expect(area).toHaveAttribute('data-package', PAIR); + await new Promise((r) => setTimeout(r, 300)); + expect(screen.getByTestId('location')).toHaveTextContent(`/studio/${encodeURIComponent(PAIR)}/metadata`); + }); +}); + +describe('nothing a third package contributes is drawn (objectstack#28722)', () => { + it('the rails ask no list of another package, and no row names one', async () => { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + expect(srv.reads.filter((r) => r.includes(encodeURIComponent(ADDON)) || r.includes(`package=${ADDON}`))).toEqual([]); + expect(within(ifRail()).queryByText(/acme/i)).toBeNull(); + }); +}); + +describe('CONTROL: a base with no pair is unchanged (objectstack#28572)', () => { + it('reads no pair list, and draws no mark and no filter', async () => { + srv.pairListed = false; + renderAt(`/studio/${BASE}/interfaces`); + await waitFor(() => expect(rowOf(ifRail(), 'dashboard', 'forecast')).not.toBeNull(), { timeout: 8000 }); + await new Promise((r) => setTimeout(r, 300)); + expect(srv.reads.filter((r) => r.includes(`package=${encodeURIComponent(PAIR)}`))).toEqual([]); + expect(within(ifRail()).queryByTestId('customization-mark')).toBeNull(); + expect(within(ifRail()).queryByTestId('studio-rail-customizations-only')).toBeNull(); + expect(rowOf(ifRail(), 'dashboard', 'wins')).toBeNull(); + }); +}); 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 d3b77e1754d..4ed11739719 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -405,6 +405,7 @@ import { customizationPairBaseOf, foldCustomizationPairs, railCustomizationsOf, + useDeepLinkFallback, useRailCustomizationsOnly, withPairItems, type RailCustomizations, @@ -4586,6 +4587,11 @@ export function InterfacesPillar({ initialSurface && (OFF_NAV_TYPES as readonly string[]).includes(initialSurface.type) ? 'pending' : 'done', ); const offNavFallbackRef = React.useRef(null); + const offNavUpgrade = useDeepLinkFallback(); + const currentRef = React.useRef(current); + React.useLayoutEffect(() => { + currentRef.current = current; + }); // objectui#8219 — lift the open leaf's label to the dock (see the prop doc). // Keyed on the primitives, and the callback read through a ref, so neither a // re-created Surface object nor a non-memoized callback refires the report. @@ -4808,17 +4814,28 @@ export function InterfacesPillar({ // both reads it waits on have answered: the app (or its absence), and this // package's off-nav lists. React.useEffect(() => { - if (offNavDeepLinkRef.current !== 'pending' || !initialSurface) return; + if (!initialSurface) return; if (appStatus === 'loading' || packageItems?.packageId !== packageId) return; - offNavDeepLinkRef.current = 'done'; const item = (packageItems.byType[initialSurface.type as OffNavType] ?? []).find( (i) => i.name === initialSurface.name, ); - const target: Surface | null = item + const linked: Surface | null = item ? { type: item.type, name: item.name, label: item.label, ...(item.addedIn ? { addedIn: item.addedIn } : {}) } - : offNavFallbackRef.current; + : null; + // objectstack#28572 — an item the pair adds is listed once the pair is + // known, after the package list answers: a link the first lists could not + // resolve opens it then, while the default it opened is still open. + if (offNavDeepLinkRef.current === 'done') { + if (linked && offNavUpgrade.take(currentRef.current ? leafKeyOf(currentRef.current) : null, leafKeyOf(linked))) { + setCurrent(linked); + } + return; + } + offNavDeepLinkRef.current = 'done'; + const target = linked ?? offNavFallbackRef.current; + if (!linked && target && currentRef.current === null) offNavUpgrade.note(leafKeyOf(target)); setCurrent((cur) => cur ?? target); - }, [appStatus, packageItems, packageId, initialSurface]); + }, [appStatus, packageItems, packageId, initialSurface, offNavUpgrade]); // Resolve THIS package's App → load its navigation tree. The query is scoped // to the package (`list('app', { packageId })`) so a design surface only ever @@ -7317,6 +7334,12 @@ export function DataPillar({ // (ADR-0080, `appStudioObjectPath`) lands here with a specific object; // shared plumbing (see useSurfaceDeepLink). const initialSurface = useSurfaceDeepLink(current); + // objectstack#28572 — see `useDeepLinkFallback`. + const deepLinkUpgrade = useDeepLinkFallback(); + const currentRef = React.useRef(current); + React.useLayoutEffect(() => { + currentRef.current = current; + }); // The LIVE half of the same plumbing (see surfaceDeepLinkChannel): a // producer already inside this mounted pillar — the pending-changes sheet's // security block, naming the object the publish door would refuse — asks for @@ -7741,7 +7764,16 @@ export function DataPillar({ // Honor a `?surface=object:` deep-link when it names an object we // actually have; otherwise open the first object as before. const deepLinked = resolveSurfaceDeepLink(items, initialSurface, 'object'); - setCurrent((c) => c ?? deepLinked ?? items[0] ?? null); + // objectstack#28572 — an object the pair adds is listed once the pair + // is known (`useDeepLinkFallback`). + if (deepLinked && deepLinkUpgrade.take(currentRef.current?.name ?? null, deepLinked.name)) { + setCurrent(deepLinked); + } else { + if (!deepLinked && initialSurface?.type === 'object' && currentRef.current === null) { + deepLinkUpgrade.note(items[0]?.name ?? null); + } + setCurrent((c) => c ?? deepLinked ?? items[0] ?? null); + } // An empty writable package does NOT auto-open the creator dialog — // it used to, which forced an unrequested modal on EVERY visit to an // empty package (dogfood #2555). The empty-state panel carries the @@ -11142,6 +11174,12 @@ export function AccessPillar({ [owdOpen, current], ); const initialSurface = useSurfaceDeepLink(currentSurface); + // objectstack#28572 — see `useDeepLinkFallback`. + const deepLinkUpgrade = useDeepLinkFallback(); + const currentRef = React.useRef(current); + React.useLayoutEffect(() => { + currentRef.current = current; + }); const [query, setQuery] = React.useState(''); // inline creator (same rail pattern as the Data pillar's object creator) const [creating, setCreating] = React.useState(false); @@ -11308,7 +11346,16 @@ export function AccessPillar({ setOwdOpen(true); } const deepLinked = resolveSurfaceDeepLink(scoped, initialSurface, 'permission'); - setCurrent((c) => c ?? deepLinked?.name ?? scoped[0]?.name ?? null); + // objectstack#28572 — a set the pair adds is listed once the pair is + // known (`useDeepLinkFallback`). + if (deepLinked && deepLinkUpgrade.take(currentRef.current, deepLinked.name)) { + setCurrent(deepLinked.name); + } else { + if (!deepLinked && initialSurface?.type === 'permission' && currentRef.current === null) { + deepLinkUpgrade.note(scoped[0]?.name ?? null); + } + setCurrent((c) => c ?? deepLinked?.name ?? scoped[0]?.name ?? null); + } } catch (e) { if (seq === loadSeqRef.current) setError(formatMetadataError(e)); } finally { diff --git a/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx b/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx index b6d39fded7d..d4eecbd4a03 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx @@ -193,6 +193,38 @@ export function useRailCustomizationsOnly(): [boolean, () => void] { return [on, toggle]; } +/** + * A `?surface=` deep link to an item the base's pair adds, on a rail that is + * read before the pair is known: the package list answers after the rail's + * first read, so that read cannot list the item, and the rail opens its + * default. The rail notes the default it opened for a link it could not + * resolve ({@link DeepLinkFallback.note}); a later read that lists the linked + * item opens it instead, while the author has not moved off that default + * ({@link DeepLinkFallback.take}). Keys are the rail's own (an item's name). + */ +export interface DeepLinkFallback { + note(key: string | null): void; + /** The linked item's key to open now, or `null` to leave the rail as it is. */ + take(currentKey: string | null, linkedKey: string | null): string | null; +} + +export function useDeepLinkFallback(): DeepLinkFallback { + const ref = React.useRef(null); + return React.useMemo( + () => ({ + note(key) { + ref.current = key; + }, + take(currentKey, linkedKey) { + if (linkedKey === null || ref.current === null || currentKey !== ref.current) return null; + ref.current = null; + return linkedKey; + }, + }), + [], + ); +} + /** * The package switcher's rows with each base's pair folded under it (ADR-0146 * D3: "the package switcher folds `hotcrm.custom` under HotCRM instead of From a727515770adb5576d7041b5f8fa1b95b4501a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:16:41 +0000 Subject: [PATCH 3/3] chore(changeset): studio base and pair as one surface Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jV3gDN72FtkqvGmfBd --- .../28572-studio-base-and-pair-one-surface.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/28572-studio-base-and-pair-one-surface.md diff --git a/.changeset/28572-studio-base-and-pair-one-surface.md b/.changeset/28572-studio-base-and-pair-one-surface.md new file mode 100644 index 00000000000..98998f50c0f --- /dev/null +++ b/.changeset/28572-studio-base-and-pair-one-surface.md @@ -0,0 +1,22 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): a read-only base and its `.custom` pair as one surface, with "customized" and "added" marks, an only-customizations filter, and New landing in the pair + +Clause-②: no +Metadata-protocol: none + +Studio now shows a read-only package and its paired customization package (`.custom`, ADR-0146 D3, "On top, one surface") as one surface. The pair no longer looks like a second package beside its base. + +- **One list per pillar.** The Data, Automations, Access and Interfaces rails list the base's items, then each item of the pair whose name the base does not hold. An override the pair serves under the base's name stays the base's row. +- **Marks.** A row the base's customization read (`GET /api/v1/packages/:id/customizations`) lists as an override is marked "customized". Any other row of the pair is marked "added". Every other row carries no mark. ⛔ Nothing is computed from the documents. A failed read marks nothing and says why. +- **Only customizations.** Each rail gets an "Only customizations (n)" toggle, shown once a row carries a mark. On the Interfaces rail it lists every marked page, dashboard, report and view in place of the navigation tree, including the ones a nav entry opens. +- **Package switcher.** The pair has no row of its own. Its base's row names it ("with ``"). A pair whose base is not listed keeps its row. +- **The pair's URLs.** A pillar URL of the pair (`/studio//`) lands on the base's surface, with its query and state kept. A `?surface=` link to an item the pair adds opens that item once the pair is known. The pair's "All metadata" URL (`/studio//metadata`) is not redirected, because the area lists the pair's items there and the redirect would loop. +- **Items the pair adds** open writable on the base's surface. They are read and saved at the pair they were listed in, as the items they are. They declare no override. Their item header names the pair ("In ``"). +- **New on a read-only base** goes in the pair. It calls `POST /api/v1/packages/:id/ensure-customization`, then saves into the package that answer names. ⛔ A create never names a `.custom` id spelled in the browser. On Interfaces, a new page, dashboard or report links no nav entry, because the base's app takes no edit. The "+" offers only those three kinds there. A walled admin is offered no New. +- **Not drawn.** Items another package contributes to the base (fields extending its objects, flows on its objects, ADR-0029 D7 navigation entries) are not drawn. No served read names the package that contributes them (objectstack#28722). +- **Unchanged.** A writable package, and a read-only base with no pair, read and draw as before. Customized items keep the objectstack#28562 Customize path. +- **New strings.** `@object-ui/i18n`'s studio namespace gains five keys in `en` and `zh` (`engine.studio.customize.addedIn`, `engine.studio.customize.addedInTitle`, `engine.studio.pkg.withPair`, `engine.studio.if.addInPairTitle`, `engine.studio.interfaces.create.descriptionInPair`). The other languages fall back to `en`, as the whole namespace does.