diff --git a/.changeset/26889-interfaces-off-nav-items.md b/.changeset/26889-interfaces-off-nav-items.md new file mode 100644 index 00000000000..b94acef6cd4 --- /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 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 new file mode 100644 index 00000000000..1ea12bdc6a7 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavItems-26889.test.tsx @@ -0,0 +1,260 @@ +// 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'; +import { STUDIO_RAIL_ROW_ACTIVE } from './studio-tokens'; + +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); + // 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]); + } + }); + + 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.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(); 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 635d52c65fc..e0ded53d967 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -198,6 +198,7 @@ import { listViewColumnIndexOf, listViewColumnSelection, listViewIsKanban, + listViewTargetOf, ObjectListViewInspector, useObjectListViewDraft, type DraftOnlyObjects, @@ -255,7 +256,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, @@ -2838,6 +2839,121 @@ 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]; + +/** 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 + * 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(itemKeyOf(surface.type, surface.name)); + if (surface.type === 'object' && typeof node.viewName === 'string' && node.viewName) { + opened.add(itemKeyOf('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} + + ); + })} + + ); +} + /** The keys an `object` entry's landings own besides its `objectName` (`OBJECT_MODE_FIELDS`). */ const OBJECT_LANDING_KEYS = ['viewName', 'recordId', 'recordMode', 'filters'] as const; @@ -4227,6 +4343,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 @@ -5357,7 +5502,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 ? (
@@ -5666,6 +5813,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(itemKeyOf(type, item.name))) + : [], + })); + return (
{leafConflictDialog} @@ -5879,19 +6047,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 10474aaa79d..ea72a308e48 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2228,6 +2228,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 1695abff4ae..c6d57a98baf 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2003,6 +2003,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': '设计态',