From 11323c0be707df1c9f1107c25ac3b80fa347850d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:52:20 +0000 Subject: [PATCH 1/4] fix(studio): Interfaces lists a package's pages, dashboards, reports and views no nav entry names The Interfaces rail listed only the app's navigation tree, so an item no entry opens, and every item of a package with no app, could not be opened in Studio. Under the tree, one section per type now lists the package's items no entry opens (published and draft, through loadPackageSurfaces narrowed by each type's list filter); a row opens its item in the existing canvas and inspector. A no-app package keeps its notice with the sections under it, and its canvas leaves the empty state once an item is opened. Claude-Session: https://claude.ai/code/session_01JQwxbZJXVERQKCsD3gtpru Co-authored-by: Claude --- .changeset/26889-interfaces-off-nav-items.md | 11 + ...nterfacesPillar.offNavItems-26889.test.tsx | 257 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 186 ++++++++++++- .../views/studio-design/packageSurfaces.ts | 9 +- ui/packages/i18n/src/namespaces/studio/en.ts | 6 + ui/packages/i18n/src/namespaces/studio/zh.ts | 6 + 6 files changed, 460 insertions(+), 15 deletions(-) create mode 100644 .changeset/26889-interfaces-off-nav-items.md create mode 100644 ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx diff --git a/.changeset/26889-interfaces-off-nav-items.md b/.changeset/26889-interfaces-off-nav-items.md new file mode 100644 index 00000000000..69ab91abecf --- /dev/null +++ b/.changeset/26889-interfaces-off-nav-items.md @@ -0,0 +1,11 @@ +--- +'@object-ui/i18n': patch +--- + +fix(studio): Interfaces lists a package's pages, dashboards, reports and views whether or not a navigation entry names them + +Clause-②: no +Metadata-protocol: none + +- Studio › Interfaces used to list only what the app's navigation names, so a list view off the menu, a record page, or any item of a package with no app could not be opened in Studio at all. Under the navigation tree the rail now has one section per type ("Not in navigation": Pages, Dashboards, Reports, Views) listing the package's items no entry opens, published and draft, and each row opens its item in the same canvas and inspector, read-only on an installed package. A package with no app keeps its "no app yet" notice and lists what it ships under it. An item a navigation entry opens keeps its row in the tree and is not listed again. +- The `studio` namespace gains the six `engine.studio.if.offNav.*` strings, in `en` and `zh`. No export changes. diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx new file mode 100644 index 00000000000..8fdf9ae8132 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx @@ -0,0 +1,257 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#26889 — the Interfaces pillar lists a package's views, pages, + * dashboards and reports whether or not a navigation entry names them, and a + * package with no app still lists what it ships. + * + * The rail used to be the app's navigation tree and nothing else, so an item + * no entry opens (a list view off the menu, a record page, a module package's + * dashboard) could not be opened in Studio at all, and a package with no app + * showed only its "no app yet" notice. Under the tree there is now one section + * per type with the items no entry opens; each row opens its item in the + * pillar's canvas, read-only on a read-only package. + * + * - an item a nav entry opens keeps its tree row and is not listed again (a + * page / dashboard entry's target, and the view an `object` entry's + * `viewName` names); + * - a draft-only item is listed, by the label its `_drafts` row carries; + * - the aggregated view container the server keeps for dual-read is not an + * item (Setup › Metadata leaves it out the same way); + * - the "no app yet" notice stays, with the sections under it. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const PKG = 'com.acme.crm'; + +const NAV = [ + { id: 'nav_home', type: 'page', label: 'Home menu', pageName: 'home' }, + { id: 'nav_ops', type: 'dashboard', label: 'Ops board', dashboardName: 'ops' }, + { id: 'nav_my_leads', type: 'object', label: 'My leads', objectName: 'crm_lead', viewName: 'mine' }, +]; + +/** What the mocked server holds, per metadata type: published rows and `_drafts` headers. */ +const server = vi.hoisted(() => ({ + published: new Map>>(), + drafts: new Map>>(), +})); + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string) => server.published.get(type) ?? []), + listDrafts: vi.fn(async (opts?: { type?: string }) => server.drafts.get(opts?.type ?? '') ?? []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + const eff = (server.published.get(type) ?? []).find((row) => row.name === name) ?? null; + return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); + +// The `view` resource's list filter (the aggregated container is no item) is +// registered here, as the console registers it. +import '../../services/builtinComponents'; +import { InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview, type MetadataPreviewProps } from '../metadata-admin/preview-registry'; + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +/** A canvas that says what it was opened on, and whether it may write. */ +function StubPreview({ type, name, onPatch }: MetadataPreviewProps) { + return ( +
+ {`${type}:${name}`} +
+ ); +} +for (const type of ['page', 'dashboard', 'report', 'view']) registerMetadataPreview(type, StubPreview); + +const page = (name: string, label: string, extra: Record = {}) => ({ name, label, type: 'app', ...extra }); + +function seedWithApp() { + server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]); + server.published.set('page', [page('home', 'Home'), page('lead_detail', 'Lead Detail', { type: 'record', object: 'crm_lead' })]); + server.published.set('dashboard', [ + { name: 'ops', label: 'Ops' }, + { name: 'sales_overview', label: 'Sales Overview' }, + ]); + server.published.set('report', [{ name: 'pipeline', label: 'Pipeline' }]); + server.published.set('view', [ + { name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, + { name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, + // The container the server keeps beside the expanded items: no item of its own. + { name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } }, + ]); + server.drafts.set('page', [{ name: 'welcome', label: 'Welcome', packageId: PKG }]); +} + +function seedNoApp() { + server.published.set('dashboard', [{ name: 'service_overview', label: 'Service Overview' }]); + server.published.set('page', [page('case_detail', 'Case Detail', { type: 'record', object: 'crm_case' })]); +} + +beforeEach(() => { + server.published.clear(); + server.drafts.clear(); + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); +}); + +afterEach(cleanup); + +function renderPillar(props: { readOnly?: boolean } = {}) { + render( + + + , + ); +} + +/** The sections under the tree, once the package's items are read. */ +async function offNav(): Promise { + return screen.findByTestId('if-off-nav', undefined, { timeout: 8000 }); +} + +/** Every rail row's `type · name`, in rail order. */ +function railTitles(scope: HTMLElement): string[] { + return within(scope) + .getAllByRole('button') + .map((b) => b.getAttribute('title') ?? '') + .filter((title) => title.includes(' · ')); +} + +describe('objectstack#26889 — Interfaces lists what no navigation entry names', () => { + it('lists the views, pages, dashboards and reports no entry opens, one section per type', async () => { + seedWithApp(); + renderPillar(); + const sections = await offNav(); + expect(within(sections).getByTestId('if-off-nav-page')).toBeInTheDocument(); + expect(railTitles(sections)).toEqual([ + 'page · lead_detail', + 'page · welcome', + 'dashboard · sales_overview', + 'report · pipeline', + 'view · crm_lead.hot', + ]); + // A draft-only page reads as the label its `_drafts` row carries. + expect(within(sections).getByRole('button', { name: 'Welcome' })).toHaveAttribute('title', 'page · welcome'); + // Each type was read for this package, published and draft. + for (const type of ['view', 'page', 'dashboard', 'report']) { + expect(mockClient.list).toHaveBeenCalledWith(type, { packageId: PKG }); + expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type }); + } + }); + + it('an item a nav entry opens is listed once, in the tree, and never again', async () => { + seedWithApp(); + renderPillar(); + const sections = await offNav(); + const rail = sections.closest('nav') as HTMLElement; + const titles = railTitles(rail); + expect(titles.filter((t) => t === 'page · home')).toHaveLength(1); + expect(titles.filter((t) => t === 'dashboard · ops')).toHaveLength(1); + // The view the `My leads` entry names (`viewName: 'mine'`) is that entry's row. + expect(titles).not.toContain('view · crm_lead.mine'); + expect(within(sections).queryByText('Home')).toBeNull(); + expect(within(sections).queryByText('Mine')).toBeNull(); + // The aggregated container is not listed as a view. + expect(titles).not.toContain('view · crm_lead'); + }); + + it('a view, a record page and a dashboard no entry names each open in the canvas', async () => { + seedWithApp(); + renderPillar(); + const sections = await offNav(); + for (const [label, opened] of [ + ['Hot Leads', 'view:crm_lead.hot'], + ['Lead Detail', 'page:lead_detail'], + ['Sales Overview', 'dashboard:sales_overview'], + ] as const) { + fireEvent.click(within(sections).getByRole('button', { name: label })); + expect(await screen.findByText(opened, undefined, { timeout: 8000 })).toBeInTheDocument(); + expect(screen.getByTestId('if-breadcrumb')).toHaveTextContent(label); + expect(within(sections).getByRole('button', { name: label }).className).toMatch(/bg-/); + } + }); + + it('a package with no app keeps its notice and lists its dashboard and page, which open', async () => { + seedNoApp(); + renderPillar(); + const sections = await offNav(); + expect(screen.getByText('This package has no app yet.')).toBeInTheDocument(); + expect(screen.getByText('This package has no app yet')).toBeInTheDocument(); + expect(railTitles(sections)).toEqual(['page · case_detail', 'dashboard · service_overview']); + + fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' })); + expect(await screen.findByText('dashboard:service_overview', undefined, { timeout: 8000 })).toBeInTheDocument(); + // The canvas shows the item now, not the empty state; the rail keeps its notice. + expect(screen.queryByText('This package has no app yet')).toBeNull(); + expect(screen.getByText('This package has no app yet.')).toBeInTheDocument(); + + fireEvent.click(within(sections).getByRole('button', { name: 'Case Detail' })); + expect(await screen.findByText('page:case_detail', undefined, { timeout: 8000 })).toBeInTheDocument(); + }); + + it('a read-only package lists them too, and opens them read-only', async () => { + seedNoApp(); + renderPillar({ readOnly: true }); + const sections = await offNav(); + fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' })); + const canvas = await screen.findByTestId('stub-canvas', undefined, { timeout: 8000 }); + expect(canvas).toHaveTextContent('dashboard:service_overview'); + expect(canvas).toHaveAttribute('data-writable', 'no'); + expect(mockClient.save).not.toHaveBeenCalled(); + }); + + it('CONTROL: a writable package opens them writable', async () => { + seedNoApp(); + renderPillar(); + const sections = await offNav(); + fireEvent.click(within(sections).getByRole('button', { name: 'Service Overview' })); + expect(await screen.findByTestId('stub-canvas', undefined, { timeout: 8000 })).toHaveAttribute('data-writable', 'yes'); + }); + + it('CONTROL: a package whose every item an entry opens draws no sections', async () => { + server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]); + server.published.set('page', [page('home', 'Home')]); + server.published.set('dashboard', [{ name: 'ops', label: 'Ops' }]); + renderPillar(); + await screen.findByRole('button', { name: 'Home menu Page' }, { timeout: 8000 }); + await vi.waitFor(() => expect(mockClient.listDrafts).toHaveBeenCalledWith({ packageId: PKG, type: 'view' })); + // Every read settled, and what it answered rendered. + await act(async () => { + await Promise.all(mockClient.listDrafts.mock.results.map((r) => r.value)); + await new Promise((r) => setTimeout(r, 50)); + }); + expect(screen.queryByTestId('if-off-nav')).toBeNull(); + // CONTROL of the control: the same package with one more dashboard lists it. + cleanup(); + server.published.set('dashboard', [{ name: 'ops', label: 'Ops' }, { name: 'extra', label: 'Extra' }]); + renderPillar(); + expect(railTitles(await offNav())).toEqual(['dashboard · extra']); + }); +}); 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 2ed269061bd..7e3159f8000 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -196,6 +196,7 @@ import { listViewColumnIndexOf, listViewColumnSelection, listViewIsKanban, + listViewTargetOf, ObjectListViewInspector, useObjectListViewDraft, type DraftOnlyObjects, @@ -250,7 +251,7 @@ import { type StudioRefusal, } from './metadataError.js'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; -import { loadPackageLessSurfaces, loadPackageSurfaces } from './packageSurfaces.js'; +import { loadPackageLessSurfaces, loadPackageSurfaces, type PackageSurface } from './packageSurfaces.js'; import { STUDIO_ORG_SCOPE_PILLAR, isOrgScopeDraft, @@ -2551,6 +2552,118 @@ function NavTree({ ); } +/** + * objectstack#26889 — the item types the Interfaces pillar lists under the + * navigation tree, in the order of their sections: the three a nav entry can + * open by name, in the "+" menu's order, then views, which a package ships by + * the dozen and so sit last. + */ +const OFF_NAV_TYPES = ['page', 'dashboard', 'report', 'view'] as const; +type OffNavType = (typeof OFF_NAV_TYPES)[number]; + +/** + * The `type:name` of every item a nav entry in `nodes` opens, at any depth — + * what the sections under the tree leave out, so no item is listed twice. The + * binding is the tree's own (`resolveSurface`): a page / dashboard / report + * entry opens the item it names, and an `object` entry with a `viewName` opens + * that view, under the name its view item carries (`.`, the + * spelling the entry's list-view panel edits, `listViewTargetOf`). A plain + * `object` entry opens its object, so the object's views stay listed. + */ +function navOpenedItems(nodes: readonly NavNode[], locale: string): Set { + const opened = new Set(); + const walk = (list: readonly NavNode[]) => { + for (const node of list) { + if (Array.isArray(node.children) && node.children.length) { + walk(node.children); + continue; + } + const surface = resolveSurface(node, locale); + if (!surface) continue; + opened.add(`${surface.type}:${surface.name}`); + if (surface.type === 'object' && typeof node.viewName === 'string' && node.viewName) { + opened.add(`view:${listViewTargetOf(surface.name, node.viewName, {}).viewId}`); + } + } + }; + walk(nodes); + return opened; +} + +/** + * objectstack#26889 — the package's pages, dashboards, reports and views that + * no nav entry opens (`navOpenedItems`), one collapsible section per type, + * under the navigation tree. A package with no app lists everything it ships + * here, under its "no app yet" notice. A row opens its item in the pillar's + * canvas and inspector as a tree row does, so a read-only package shows it + * read-only. Listing writes nothing: no nav entry is added for a row. + */ +function OffNavSections({ + sections, + active, + onPick, +}: { + sections: ReadonlyArray<{ type: OffNavType; items: ReadonlyArray }>; + active: Surface | null; + onPick: (s: Surface) => void; +}): React.ReactElement | null { + const locale = useMetadataLocale(); + const shown = sections.filter((s) => s.items.length > 0); + if (shown.length === 0) return null; + return ( +
+

{t('engine.studio.if.offNav.heading', locale)}

+ {shown.map((s) => ( + + ))} +
+ ); +} + +/** One type's section: its rows, open until the author closes it. */ +function OffNavSection({ + type, + items, + active, + onPick, +}: { + type: OffNavType; + items: ReadonlyArray; + active: Surface | null; + onPick: (s: Surface) => void; +}): React.ReactElement { + const locale = useMetadataLocale(); + const [open, setOpen] = React.useState(true); + const Icon = navIcon(type); + return ( + setOpen((v) => !v)} + > + {items.map((item) => { + const surface: Surface = { type: item.type, name: item.name, label: item.label }; + return ( + onPick(surface)} + active={!!active && isSameSurface(active, surface)} + indent + // The label is what the row says; the machine name rides on the + // tooltip with the type, as on a tree row (objectui#11862). + title={`${item.type} · ${item.name}`} + icon={} + > + {item.label || item.name} + + ); + })} + + ); +} + /** Interfaces pillar — real App nav · live canvas · inspector. */ /** * StudioNavItemInspector — right-panel editor for the selected nav item while @@ -3615,6 +3728,35 @@ export function InterfacesPillar({ }; }, [client, packageId, publishNonce, draftNonce, metadataRefreshNonce]); + // objectstack#26889 — the package's pages, dashboards, reports and views, + // published ∪ draft, each narrowed by its type's own list filter, as + // Setup › Metadata lists them: the sections under the nav tree list the ones + // no entry opens (`OffNavSections`). Re-read when the package's metadata + // moves, as the app is. Stamped with the package read, so a package switch + // never shows another package's items. A type whose read fails lists + // nothing, as the object read above leaves its picker empty. + const [packageItems, setPackageItems] = React.useState<{ + packageId: string; + byType: Partial>; + } | null>(null); + React.useEffect(() => { + let cancelled = false; + (async () => { + const lists = await Promise.all( + OFF_NAV_TYPES.map((type) => + loadPackageSurfaces(client, type, packageId, locale, getMetadataResource(type)?.listFilter).catch( + () => [] as PackageSurface[], + ), + ), + ); + if (cancelled) return; + setPackageItems({ packageId, byType: Object.fromEntries(OFF_NAV_TYPES.map((type, i) => [type, lists[i]])) }); + })(); + return () => { + cancelled = true; + }; + }, [client, packageId, locale, publishNonce, draftNonce, metadataRefreshNonce]); + // 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 // shows the current package's app — never another package's. `list()` sees @@ -4720,7 +4862,9 @@ export function InterfacesPillar({ : 'rounded-lg border bg-background p-4', )} > - {appStatus === 'missing' && !error ? ( + {/* objectstack#26889 — a package with no app shows its empty state + until a row of the rail's sections opens one of its items. */} + {appStatus === 'missing' && !error && !current ? (
@@ -5027,6 +5171,27 @@ export function InterfacesPillar({ ) : null; + // A rail row opens its item: a tree entry or a row of the sections under it. + const pickSurface = (s: Surface) => { + // objectstack#26705 — another leaf's load replaces the buffer the held + // widget lives in; an entry for the open leaf keeps it. + if (leafKeyOf(s) !== leafKey && !confirmDropUnsavedWork(leafHeld !== null, locale)) return; + setCurrent(s); + if (isMobile) setRailOpen(false); + }; + // objectstack#26889 — what the sections under the tree list: the package's + // items no entry of the nav buffer opens, an unsent nav edit included. None + // while the app is still resolving, so an item its entry will claim is never + // shown and then taken away. + const navOpened = navOpenedItems(navTree, locale); + const offNavSections = OFF_NAV_TYPES.map((type) => ({ + type, + items: + appStatus !== 'loading' && packageItems?.packageId === packageId + ? (packageItems.byType[type] ?? []).filter((item) => !navOpened.has(`${type}:${item.name}`)) + : [], + })); + return (
{leafConflictDialog} @@ -5240,19 +5405,12 @@ export function InterfacesPillar({ : t('engine.studio.if.noNavItems', locale)}

) : ( - { - // objectstack#26705 — another leaf's load replaces the buffer - // the held widget lives in; an entry for the open leaf keeps it. - if (leafKeyOf(s) !== leafKey && !confirmDropUnsavedWork(leafHeld !== null, locale)) return; - setCurrent(s); - if (isMobile) setRailOpen(false); - }} - /> + )} + {/* objectstack#26889 — under the tree (or the no-app notice), the + items no entry opens. Not while nav editing: the rail is the + nav editor then. */} + {!editNav && }
diff --git a/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts b/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts index f0ea40e3771..17ad2f81065 100644 --- a/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts +++ b/ui/packages/app-shell/src/views/studio-design/packageSurfaces.ts @@ -60,12 +60,19 @@ export interface PackageSurface { * `listDrafts` failures are tolerated (an older server without the drafts * endpoint, or a transient error) — the rail still renders the published set * rather than erroring out, matching the sibling pillars' `.catch(() => [])`. + * + * `keep` narrows the PUBLISHED rows, which carry the item's body: the type's + * own list filter (a `MetadataResourceConfig.listFilter`), so a rail lists + * what Setup › Metadata lists — `view`'s leaves out the aggregated container + * the server keeps beside its expanded items (objectstack#26889). A draft + * header carries no body, so no filter reads it. */ export async function loadPackageSurfaces( client: PackageSurfaceClient, type: string, packageId: string, locale?: string, + keep?: (item: Record) => boolean, ): Promise { const [published, draftHeaders] = await Promise.all([ client.list(type, { packageId }) as Promise> | null | undefined>, @@ -73,7 +80,7 @@ export async function loadPackageSurfaces( .listDrafts({ packageId, type }) .catch(() => [] as DraftRow[]), ]); - return mergeSurfaces(type, published, draftHeaders, locale); + return mergeSurfaces(type, keep ? (published || []).filter(keep) : published, draftHeaders, locale); } /** diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 6225fdbb499..d11b6682d4e 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2171,6 +2171,12 @@ const STUDIO_EN: Record = { 'engine.studio.if.kindDesc.separator': 'A divider, no label, opens nothing', 'engine.studio.if.addMenuTitle': 'Add to navigation', 'engine.studio.if.emptyGroup': 'No items in this package', + // objectstack#26889 — the rail's sections under the nav tree: the items no entry opens. + 'engine.studio.if.offNav.heading': 'Not in navigation', + 'engine.studio.if.offNav.page': 'Pages', + 'engine.studio.if.offNav.dashboard': 'Dashboards', + 'engine.studio.if.offNav.report': 'Reports', + 'engine.studio.if.offNav.view': 'Views', 'engine.studio.if.openInData': 'Open in Data', // objectui#12725 — the status line above the canvas, and the item's ⋯ menu. 'engine.studio.if.status.design': 'Design', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 3ed1dcf2575..ad2f9fe9e7f 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1956,6 +1956,12 @@ const STUDIO_ZH: Record = { 'engine.studio.if.kindDesc.separator': '一条分隔线,无标签,不打开任何内容', 'engine.studio.if.addMenuTitle': '添加到导航', 'engine.studio.if.emptyGroup': '这个软件包里没有条目', + // objectstack#26889 — the rail's sections under the nav tree: the items no entry opens. + 'engine.studio.if.offNav.heading': '不在导航中', + 'engine.studio.if.offNav.page': '页面', + 'engine.studio.if.offNav.dashboard': '仪表板', + 'engine.studio.if.offNav.report': '报表', + 'engine.studio.if.offNav.view': '视图', 'engine.studio.if.openInData': '在「数据」中打开', // objectui#12725 — 画布上方的状态行,以及条目的 ⋯ 菜单。 'engine.studio.if.status.design': '设计态', From 3bad20b19e129a1c9b44f673a59b7d1cfdb9320e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:56:43 +0000 Subject: [PATCH 2/4] test(studio): pin the active off-nav row by the rail's active token Claude-Session: https://claude.ai/code/session_01JQwxbZJXVERQKCsD3gtpru Co-authored-by: Claude --- .changeset/26889-interfaces-off-nav-items.md | 2 +- .../InterfacesPillar.offNavItems-26889.test.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/26889-interfaces-off-nav-items.md b/.changeset/26889-interfaces-off-nav-items.md index 69ab91abecf..b94acef6cd4 100644 --- a/.changeset/26889-interfaces-off-nav-items.md +++ b/.changeset/26889-interfaces-off-nav-items.md @@ -8,4 +8,4 @@ Clause-②: no Metadata-protocol: none - Studio › Interfaces used to list only what the app's navigation names, so a list view off the menu, a record page, or any item of a package with no app could not be opened in Studio at all. Under the navigation tree the rail now has one section per type ("Not in navigation": Pages, Dashboards, Reports, Views) listing the package's items no entry opens, published and draft, and each row opens its item in the same canvas and inspector, read-only on an installed package. A package with no app keeps its "no app yet" notice and lists what it ships under it. An item a navigation entry opens keeps its row in the tree and is not listed again. -- The `studio` namespace gains the six `engine.studio.if.offNav.*` strings, in `en` and `zh`. No export changes. +- The `studio` namespace gains the five `engine.studio.if.offNav.*` strings, in `en` and `zh`. No export changes. diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx index 8fdf9ae8132..1ea12bdc6a7 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx @@ -75,6 +75,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f import '../../services/builtinComponents'; import { InterfacesPillar } from './StudioDesignSurface'; import { registerMetadataPreview, type MetadataPreviewProps } from '../metadata-admin/preview-registry'; +import { STUDIO_RAIL_ROW_ACTIVE } from './studio-tokens'; vi.stubGlobal( 'fetch', @@ -194,7 +195,9 @@ describe('objectstack#26889 — Interfaces lists what no navigation entry names' fireEvent.click(within(sections).getByRole('button', { name: label })); expect(await screen.findByText(opened, undefined, { timeout: 8000 })).toBeInTheDocument(); expect(screen.getByTestId('if-breadcrumb')).toHaveTextContent(label); - expect(within(sections).getByRole('button', { name: label }).className).toMatch(/bg-/); + // The row it was opened from is the active one, and only it. + const active = within(sections).getAllByRole('button').filter((b) => b.className.includes(STUDIO_RAIL_ROW_ACTIVE)); + expect(active.map((b) => b.textContent)).toEqual([label]); } }); From d9302876c48993af1d0a8ce900b85360bfd2942e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 23:13:49 +0000 Subject: [PATCH 3/4] test(studio): the create dialogs' collision mocks answer a package-scoped list per package The two tests that refuse an identifier another package holds served that package's item from the pillar's own scoped list too, which the server never does; the Interfaces rail now reads that list, so the mock keeps scope. Claude-Session: https://claude.ai/code/session_01JQwxbZJXVERQKCsD3gtpru Co-authored-by: Claude --- ...oDesignSurface.interfacesCreate-11823.test.tsx | 14 +++++++++++--- ...ignSurface.interfacesCreatePage-11823.test.tsx | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreate-11823.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreate-11823.test.tsx index 08cf0067bf0..608fb443ec3 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreate-11823.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreate-11823.test.tsx @@ -59,6 +59,14 @@ const DATASETS = [ }, ]; +/** A package other than the pillar's: its items answer the unscoped list only. */ +const OTHER_PKG = 'com.other.app'; + +/** A package-scoped list keeps the rows of that package, as the server's does (objectstack#26889). */ +function inScope(rows: Array>, opts: { packageId?: string }) { + return opts.packageId ? rows.filter((r) => (r._packageId ?? opts.packageId) === opts.packageId) : rows; +} + let apps: Array>; let published: Record>>; let draftHeaders: Array<{ type: string; name: string; packageId: string }>; @@ -71,10 +79,10 @@ const mockClient = { draftRows[`${type}:${name}`] = body; return { success: true, version: 'v1', state: 'draft' }; }), - list: vi.fn(async (type: string) => { + list: vi.fn(async (type: string, opts: { packageId?: string } = {}) => { if (type === 'app') return apps; if (type === 'dataset') return DATASETS; - return published[type] ?? []; + return inScope(published[type] ?? [], opts); }), listDrafts: vi.fn(async (opts: { type?: string } = {}) => draftHeaders.filter((h) => !opts.type || h.type === opts.type), @@ -290,7 +298,7 @@ describe('the Interfaces pillar creates a dashboard and a report (objectui#11823 }); it('an identifier a dashboard in another package holds is refused in the dialog, and nothing is written', async () => { - published.dashboard = [HOME_DASH, { name: 'sales_overview', label: 'Their sales' }]; + published.dashboard = [HOME_DASH, { name: 'sales_overview', label: 'Their sales', _packageId: OTHER_PKG }]; mountPillar(); await ready(); const dialog = await openCreate('New dashboard'); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx index 3e6ad299bde..7f6d8464c63 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx @@ -43,6 +43,15 @@ const LANDING = { name: 'landing', label: 'Landing', type: 'app', kind: 'html', const NAV = [{ id: 'nav_landing', type: 'page', label: 'Landing', pageName: 'landing' }]; + +/** A package other than the pillar's: its items answer the unscoped list only. */ +const OTHER_PKG = 'com.other.app'; + +/** A package-scoped list keeps the rows of that package, as the server's does (objectstack#26889). */ +function inScope(rows: Array>, opts: { packageId?: string }) { + return opts.packageId ? rows.filter((r) => (r._packageId ?? opts.packageId) === opts.packageId) : rows; +} + let apps: Array>; let published: Record>>; let draftHeaders: Array<{ type: string; name: string; packageId: string }>; @@ -53,7 +62,9 @@ const mockClient = { draftRows[`${type}:${name}`] = body; return { success: true, version: 'v1', state: 'draft' }; }), - list: vi.fn(async (type: string) => (type === 'app' ? apps : (published[type] ?? []))), + list: vi.fn(async (type: string, opts: { packageId?: string } = {}) => + type === 'app' ? apps : inScope(published[type] ?? [], opts), + ), listDrafts: vi.fn(async (opts: { type?: string } = {}) => draftHeaders.filter((h) => !opts.type || h.type === opts.type), ), @@ -268,7 +279,7 @@ describe('the Interfaces pillar creates a page and opens it on source plus live }); it('an identifier a page in another package holds is refused in the dialog, and nothing is written', async () => { - published.page = [LANDING, { name: 'team_handbook', label: 'Their handbook', type: 'app' }]; + published.page = [LANDING, { name: 'team_handbook', label: 'Their handbook', type: 'app', _packageId: OTHER_PKG }]; mountPillar(); await ready(); const dialog = await openNewPage(); From e6d81a62447020362e261049e6dff5e53f3f8381 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:24:06 +0000 Subject: [PATCH 4/4] fix(studio): key the off-nav item set through a helper, never a view-prefixed template The view-cache guard (viewCacheInvalidation.guard) refuses any app-shell file that interpolates a view-prefixed template string, the adapter cache key's shape. The set of items nav entries open is not a cache key; its keys now go through itemKeyOf. Claude-Session: https://claude.ai/code/session_01JQwxbZJXVERQKCsD3gtpru Co-authored-by: Claude --- .../src/views/studio-design/StudioDesignSurface.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 7e3159f8000..1207614341c 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -2561,6 +2561,9 @@ function NavTree({ const OFF_NAV_TYPES = ['page', 'dashboard', 'report', 'view'] as const; type OffNavType = (typeof OFF_NAV_TYPES)[number]; +/** An item's identity in {@link navOpenedItems}' set. Not a cache key. */ +const itemKeyOf = (type: string, name: string): string => `${type}:${name}`; + /** * The `type:name` of every item a nav entry in `nodes` opens, at any depth — * what the sections under the tree leave out, so no item is listed twice. The @@ -2580,9 +2583,9 @@ function navOpenedItems(nodes: readonly NavNode[], locale: string): Set } const surface = resolveSurface(node, locale); if (!surface) continue; - opened.add(`${surface.type}:${surface.name}`); + opened.add(itemKeyOf(surface.type, surface.name)); if (surface.type === 'object' && typeof node.viewName === 'string' && node.viewName) { - opened.add(`view:${listViewTargetOf(surface.name, node.viewName, {}).viewId}`); + opened.add(itemKeyOf('view', listViewTargetOf(surface.name, node.viewName, {}).viewId)); } } }; @@ -5188,7 +5191,7 @@ export function InterfacesPillar({ type, items: appStatus !== 'loading' && packageItems?.packageId === packageId - ? (packageItems.byType[type] ?? []).filter((item) => !navOpened.has(`${type}:${item.name}`)) + ? (packageItems.byType[type] ?? []).filter((item) => !navOpened.has(itemKeyOf(type, item.name))) : [], }));