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. 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/StudioBundleItemEditor.customize-28569.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx index f39f919ee31..cd94b5347a8 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioBundleItemEditor.customize-28569.test.tsx @@ -206,14 +206,18 @@ function Host({ }) { const readOnly = packageId === BASE; 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: '4.0.0', walled, pairId, ensure } : null), - [readOnly, walled, pairId, ensure], + () => + readOnly + ? { base: BASE, baseLabel: 'HotCRM', basedOn: '4.0.0', walled, pairId, ensure, ensurePackage, marks: { status: 'off' } } + : null, + [readOnly, walled, pairId, ensure, ensurePackage], ); return ( { 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.metadataArea-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx index 619f7e93260..88a41d2a639 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx @@ -245,6 +245,8 @@ describe('the area\'s form editor in the builder (objectstack#28569)', () => { basedOn: '4.0.0', walled: false, pairId: null, + // objectstack#28572 — the base's served customization read, which the rails mark rows from. + marks: expect.any(Object), }); expect(screen.getByTestId('form-editor-door')).toHaveTextContent('function'); }); 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 8d93570b2b1..149b9b902a3 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,22 @@ 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, + useDeepLinkFallback, + useRailCustomizationsOnly, + withPairItems, + type RailCustomizations, +} from './StudioRailCustomizations.js'; import { StudioCustomizeButton, StudioOverrideChip, @@ -1018,6 +1033,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 +1466,11 @@ function PackageSwitcher({ {pkgListState === 'loaded' && pkgs?.length === 0 && (

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

)} - {pkgs?.map((p) => ( + {pkgs !== null && folded.rows.map((p) => (
- + {/* objectstack#28573 — the clone door's dialog, shared with Setup's catalog: a name and a label, nothing else, refusals verbatim. */} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioRail.tsx b/ui/packages/app-shell/src/views/studio-design/StudioRail.tsx index 805aceee7ed..30182786d0b 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioRail.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioRail.tsx @@ -69,6 +69,8 @@ export interface StudioRailProps { /** Anything else the heading row carries at its right (an edit toggle). */ headerExtra?: React.ReactNode; search?: StudioRailSearch; + /** objectstack#28572 — a row of filters under the search (the "only customizations" toggle). */ + filters?: React.ReactNode; /** A block above the heading row (a pinned sibling surface). */ pinned?: React.ReactNode; /** A block pinned at the bottom (a legend). */ @@ -90,6 +92,7 @@ export function StudioRail({ addTestId, headerExtra, search, + filters, pinned, footer, isMobile = false, @@ -158,6 +161,7 @@ export function StudioRail({ )} + {filters}
{children} {noMatches && ( diff --git a/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx b/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx new file mode 100644 index 00000000000..d4eecbd4a03 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28572 (ADR-0146 D3, "On top, one surface") — the pieces every + * Studio pillar rail uses to show a read-only base and its paired + * `.custom` package as one list: + * + * - {@link withPairItems}: the base's rows, then each row of the pair whose + * name the base's list does not hold, stamped `addedIn: `. A row of + * the pair that overrides a base item is served IN the base's list, under + * the base's name (ADR-0146 D8 item 2), so it is the base's row here; + * - {@link railCustomizationsOf}: which mark a row carries, from the base's + * served customization read (N-15) and the row's own package, by the one + * rule `customizationMarks.ts` holds. ⛔ Nothing is computed from the + * documents: no diff against the base; + * - {@link RailCustomizationFilter}: the rail's "only customizations" + * toggle, from the same read; + * - {@link StudioPairItemChip}: the item header's naming of the pair an + * added item lives in (D3: "the item header … say[s] where the item + * went"). + * + * An added row opens writable on the base's surface: it reads and saves at + * the pair its row was listed in (the listed pair, or the one the ensure door + * answered), never at a `.custom` id spelled here. A New on a read-only + * base asks the ensure door for the pair first (`StudioBaseCustomization`). + * + * 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 contributing package (objectstack#28722), + * and the rail derives none. + */ + +import * as React from 'react'; +import { cn } from '@object-ui/components'; +import { Boxes } from 'lucide-react'; +import { customizationBasePackageId } from '@objectstack/spec/kernel'; +import { tFormat } from '../metadata-admin/i18n.js'; +import { CustomizationMark, type PackageCustomizationsState } from './CustomizationMark.js'; +import { customizationMarkOf, type CustomizationMarkKind } from './customizationMarks.js'; + +/** A rail row as the one-surface helpers read it. */ +export interface PairRailRow { + type: string; + name: string; + /** The pair this row is an item of, when the base does not hold its name (an item the pair adds). */ + addedIn?: string; +} + +/** + * The base's rows, then the pair's rows the base's list does not name, each + * stamped `addedIn: pairId`. Order: the base's order, then the pair's. + * `pairRows` empty (no pair, or its read failed) returns the base's rows as + * they are. + */ +export function withPairItems( + baseRows: readonly T[], + pairRows: readonly T[], + pairId: string | null, +): Array { + if (pairId === null || pairRows.length === 0) return [...baseRows]; + const held = new Set(baseRows.map((r) => r.name)); + const added = pairRows.filter((r) => r.name && !held.has(r.name)).map((r) => ({ ...r, addedIn: pairId })); + return [...baseRows, ...added]; +} + +/** What a rail draws of the base's customizations. */ +export interface RailCustomizations { + /** The mark a row carries, or `null`. */ + markOf(row: PairRailRow): CustomizationMarkKind | null; + /** The base's display name, for the mark's text. */ + baseName: string; + /** The base's pair id, for the mark's title. */ + pairId: string; + /** The read's failure sentence, or `null`. */ + failed: string | null; +} + +/** + * The marks of a base's rail, or `null` when there is nothing to mark: no + * read (`state` null or `off`) or a read still in flight. A failed read marks + * nothing and carries the server's sentence. The row's package is its + * `addedIn` pair, else the base: a base row the read lists is "customized", + * an added row "added", every other row nothing. + */ +export function railCustomizationsOf( + state: PackageCustomizationsState | null | undefined, + base: string, +): RailCustomizations | null { + if (!state || state.status === 'off' || state.status === 'loading') return null; + if (state.status === 'failed') { + return { markOf: () => null, baseName: base, pairId: '', failed: state.message }; + } + const { index, baseName } = state; + return { + markOf: (row) => + customizationMarkOf({ type: row.type, name: row.name, packageId: row.addedIn ?? base }, index), + baseName, + pairId: index.pairId, + failed: null, + }; +} + +/** A row's mark in the rail's trailing slot, or nothing. */ +export function RailCustomizationMark({ + row, + marks, + locale, +}: { + row: PairRailRow; + marks: RailCustomizations | null; + locale: string; +}): React.ReactElement | null { + const kind = marks?.markOf(row) ?? null; + if (kind === null || !marks) return null; + return ( + + ); +} + +/** + * The rail's "only customizations (n)" toggle, under its heading. Drawn once + * the read has answered and some row of the rail carries a mark, and kept + * while it is on (so it can be turned off after the last mark goes). A failed + * read shows its sentence instead, and no toggle. + */ +export function RailCustomizationFilter({ + marks, + marked, + on, + onToggle, + locale, + testId = 'studio-rail-customizations-only', +}: { + marks: RailCustomizations | null; + /** How many of the rail's rows carry a mark. */ + marked: number; + on: boolean; + onToggle: () => void; + locale: string; + testId?: string; +}): React.ReactElement | null { + if (!marks) return null; + if (marks.failed !== null) { + return ( +

+ {tFormat('engine.customizationMark.readFailed', locale, { message: marks.failed })} +

+ ); + } + if (marked === 0 && !on) return null; + return ( +
+ +
+ ); +} + +/** 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]; +} + +/** + * 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 + * 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 6d2a2faaf28..89990eb64a6 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': '在一个可写软件包里设计对象、表单、自动化与界面;编辑存为草稿,整包一次发布。源码与已安装的软件包为只读:可在此浏览,并用环境覆盖进行定制。',