From 36954c6295bea7332a829b9f8ca32e8be38e2457 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 04:37:39 +0000 Subject: [PATCH] [maintainer] refactor(app-shell): move the Interfaces nav rail out of StudioDesignSurface.tsx (slice 3) Move-only (ruling #24586 A): the Interfaces pillar's navigation rail (NavTree, NavTreeGroup, ContributedNavRows, OffNavSections, the two rail contexts, navKindName, navOpenedItems, KIND_ICON / navIcon) moves byte for byte into views/studio-design/InterfacesNavRail.tsx. Only imports and eleven `export`s change. The two by-path readers gain the new module, and two test comments now name PackageSwitcher.tsx, where slice 2 moved the text they cite. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzedfGwVcebpN5t3ETE --- ...29988-studio-interfaces-nav-rail-module.md | 10 + ...eryRedirectsFollowDeclaration-7373.test.ts | 1 + .../PackagesPage.confirmFlows-11784.test.tsx | 2 +- .../views/studio-design/InterfacesNavRail.tsx | 489 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 478 +---------------- .../packages-io.inflightShare-11797.test.tsx | 2 +- .../spec-symbol-ledger-app-shell-7265.test.ts | 1 + 7 files changed, 518 insertions(+), 465 deletions(-) create mode 100644 .changeset/29988-studio-interfaces-nav-rail-module.md create mode 100644 ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx diff --git a/.changeset/29988-studio-interfaces-nav-rail-module.md b/.changeset/29988-studio-interfaces-nav-rail-module.md new file mode 100644 index 00000000000..5100c5f70b4 --- /dev/null +++ b/.changeset/29988-studio-interfaces-nav-rail-module.md @@ -0,0 +1,10 @@ +--- +'@object-ui/app-shell': patch +--- + +internal: move-only — the Studio Interfaces pillar's navigation rail moves out of `StudioDesignSurface.tsx` into its own module (#29988, slice 3) + +Clause-②: no +Metadata-protocol: none + +No behaviour change. The rail Studio's Interfaces pillar draws for an app's navigation (the nav tree and its groups, the rows a customization package contributes, the off-nav sections for pages, dashboards, reports and views, the two rail contexts, the kind names and kind icons) now lives in `views/studio-design/InterfacesNavRail.tsx`. The moved code is byte-identical apart from its imports and eleven `export`s; `StudioDesignSurface.tsx` imports it from there. The package's public exports are unchanged. diff --git a/ui/packages/app-shell/src/__tests__/homeRecoveryRedirectsFollowDeclaration-7373.test.ts b/ui/packages/app-shell/src/__tests__/homeRecoveryRedirectsFollowDeclaration-7373.test.ts index a0568c2ffbd..fe6ff175ef5 100644 --- a/ui/packages/app-shell/src/__tests__/homeRecoveryRedirectsFollowDeclaration-7373.test.ts +++ b/ui/packages/app-shell/src/__tests__/homeRecoveryRedirectsFollowDeclaration-7373.test.ts @@ -139,6 +139,7 @@ const RECOVERY_SITES: ReadonlyArray<{ alsoReads: [ 'packages/app-shell/src/views/studio-design/StudioNavItemInspector.tsx', 'packages/app-shell/src/views/studio-design/PackageSwitcher.tsx', + 'packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx', ], site: 'Studio: deleted-package eviction + header Home', expression: /const homePath = useHomePath\(\)/, diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx index 21d077545fd..2218f0cae99 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.confirmFlows-11784.test.tsx @@ -392,7 +392,7 @@ describe('PackageDetailSheet — no native dialog on these three paths (objectui describe('Console Packages page — a delete there stays on the page (objectui#11784)', () => { /** * Studio's return to its landing after a delete is the Studio HOST's - * (`PackageSwitcher`'s eviction in `StudioDesignSurface.tsx`), not the + * (`PackageSwitcher`'s eviction in `PackageSwitcher.tsx`), not the * sheet's. The same sheet serves this console page, whose `onChanged` only * reloads the list; `/studio` is not a destination here. */ diff --git a/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx new file mode 100644 index 00000000000..6ef59ee71a6 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx @@ -0,0 +1,489 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The Interfaces pillar's navigation rail: the app's nav tree, its contributed + * rows and off-nav sections, the two rail contexts and the kind icons, moved + * out of `StudioDesignSurface.tsx` unchanged by objectstack#29988 (slice 3 of + * the move-only split). + */ + +import * as React from 'react'; +import { StudioRailGroup, StudioRailRow } from './StudioRail.js'; +import { Badge, Separator } from '@object-ui/components'; +import { + FileText, + Database, + LayoutDashboard, + BarChart3, + Table2, + Folder, + Compass, + Link as LinkIcon, + Puzzle, + BookOpen, + MousePointer2, + Minus, + type LucideIcon, +} from 'lucide-react'; +import { listViewTargetOf } from './ObjectListViewInspector.js'; +import type { PackageSurface } from './packageSurfaces.js'; +import { resolveSurface, isSameSurface, type NavNode, type Surface } from './navSurface.js'; +import { t, tFormat, translateMetadataType, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { inMenuOrder, navPayloadOf } from '../metadata-admin/previews/AppNavCanvas.js'; +import { navEntryLabelText } from '../metadata-admin/previews/navItemLabel.js'; +import { useNavTargetLabel } from '../../hooks/useNavTargetLabel.js'; +import { isNavEntryType } from '../metadata-admin/inspectors/nav-target.js'; +import type { NavTargetLabelResolver } from '@object-ui/layout'; +import { getIcon } from '../../utils/getIcon.js'; +import { + ContributedRailRow, + RailCustomizationMark, + contributedItemTitle, + useOpenContributor, + type RailCustomizations, +} from './StudioRailCustomizations.js'; +import type { NavContributionPlacement, TopLevelContributedNav } from './contributionRows.js'; + +const KIND_ICON: Record = { + group: Folder, + page: FileText, + object: Database, + dashboard: LayoutDashboard, + report: BarChart3, + view: Table2, + action: MousePointer2, + // objectui#11790 — the types the nav editor can now add. A url / component / + // doc row opens no design surface (`resolveSurface`), so it renders disabled, + // under its own glyph rather than the generic compass. + url: LinkIcon, + component: Puzzle, + doc: BookOpen, + separator: Minus, +}; +export const navIcon = (type?: string): LucideIcon => KIND_ICON[type ?? ''] ?? Compass; + +/** + * The kind tag a nav entry carries at the right of its rail row, and the kind + * an entry is named by in the header chip and the inspector's eyebrow + * (objectui#12455). A type the console also names as a metadata type (object, + * page, dashboard, report) is translated by that one helper, so the rail, the + * breadcrumb and the canvas caption say the same word; an `object` entry bound + * to a named view reads "View"; the types that open no design surface (url, + * action, component, doc) and the two structural ones are named from the + * Studio's own table. `''` for an entry with no type the spec declares. + */ +export function navKindName(node: Pick, locale: string): string { + const type = node.type ?? ''; + if (type === 'object' && typeof node.viewName === 'string' && node.viewName) { + return t('engine.studio.if.kind.view', locale); + } + if (type === 'object' || type === 'page' || type === 'dashboard' || type === 'report') { + return translateMetadataType(type, locale); + } + if (isNavEntryType(type)) return t(`engine.studio.if.kind.${type}`, locale); + return ''; +} + +/** + * Whether `nodes` hold, at any depth, the entry that opens `active` — so a + * group the sidebar would collapse opens when the open leaf is inside it. + */ +function navTreeHoldsActive( + nodes: NavNode[] | undefined, + active: Surface | null, + locale: string, + targetLabel: NavTargetLabelResolver, +): boolean { + if (!active || !nodes) return false; + return nodes.some((node) => { + if (Array.isArray(node.children) && node.children.length) { + return navTreeHoldsActive(node.children, active, locale, targetLabel); + } + const surface = resolveSurface(node, locale, targetLabel); + return !!surface && isSameSurface(active, surface); + }); +} + +/** + * One group header and its children (objectui#12455). Open or closed as the + * running app's sidebar opens it (`NavigationRenderer` in `@object-ui/layout`): + * always open when the open leaf is inside it, else the spec's `expanded` when + * the author set it, else open — however many children it holds + * (objectui#13336). Collapsible by its + * header, as the sidebar's is. A group with nothing in it says so in its place + * instead of drawing a bare header. + */ +function NavTreeGroup({ + node, + labelText, + active, + onPick, + objectIcons, + depth, +}: { + node: NavNode; + labelText: string; + active: Surface | null; + onPick: (s: Surface) => void; + objectIcons?: Record; + depth: number; +}): React.ReactElement { + const locale = useMetadataLocale(); + const targetLabel = useNavTargetLabel(); + const children = node.children ?? []; + // objectstack#29786 — a declared group (`type: 'group'`) also holds the + // entries other packages contribute to it; a group with only those is no + // longer empty. + const placement = React.useContext(InterfacesNavContributionsContext); + const contributed = node.type === 'group' && typeof node.id === 'string' ? (placement.byGroup.get(node.id) ?? []) : []; + const holdsActive = navTreeHoldsActive(children, active, locale, targetLabel); + const [open, setOpen] = React.useState(() => { + if (holdsActive) return true; + if (typeof node.expanded === 'boolean') return node.expanded; + return true; + }); + // A leaf opened from elsewhere (a deep link, a create) inside a closed + // group opens the group, as the sidebar does for the route it is on. + // Adjusted while rendering, on the change, so no effect re-renders it. + const [heldActive, setHeldActive] = React.useState(holdsActive); + if (holdsActive !== heldActive) { + setHeldActive(holdsActive); + if (holdsActive) setOpen(true); + } + return ( + setOpen((v) => !v)}> + {children.length === 0 && contributed.length === 0 ? ( +

+ {t('engine.studio.if.emptyGroup', locale)} +

+ ) : ( + <> + {children.length > 0 && ( + + )} + {/* objectstack#29786 — what other packages add to this group, after + the group's own entries, as the served navigation appends them. */} + ({ entry, undeclaredGroup: null }))} indent /> + + )} +
+ ); +} + +/** + * Recursive App-navigation tree: groups, separators and typed leaves. + * + * objectui#12455 (epic objectui#12446, decision 5) — the rail shows the app's + * navigation as the shipped sidebar renders it: the same order, the same + * groups (collapsible, opened as the sidebar opens them, see `NavTreeGroup`), + * the same icons. Each leaf is one 32 px row: its icon, its label, and its + * kind as a tag at the right (`navKindName`). The internal `type · name` pair + * lives on the row's tooltip (objectui#7254), which is also its accessible + * description; the visible text is what an author reads. + * + * objectui#11791 — the rail draws a `separator` and a leaf's `badge` / + * `badgeVariant` with the decisions the running app's sidebar makes + * (`NavigationRenderer` in `@object-ui/layout`, which `UnifiedSidebar` mounts), + * so the design surface agrees with the app it designs. Mirrored, not + * imported: the sidebar draws both inline in its row renderer, and its rows + * are `SidebarMenu` items this rail does not use. The pin that holds the two + * together renders both: the `StudioDesignSurface.railSeparatorBadge-11791` + * block of `StudioDesignSurface.railLabels.test.tsx`. + */ +/** + * objectstack#28572 — the Interfaces rail's customization marks, read by its + * tree rows and its off-nav rows alike (`railCustomizationsOf`); `null` on a + * package with nothing to mark. + */ +export const InterfacesRailMarksContext = React.createContext(null); + +/** + * objectstack#29786 — where the open app's contributed nav entries go + * (`placeNavContributions`), read by the tree's groups and its top level. + * Empty on a package with none. + */ +export const InterfacesNavContributionsContext = React.createContext({ byGroup: new Map(), top: [] }); + +/** + * objectstack#29786 — contributed nav entries as rail rows: greyed, with the + * contributing package, and opening that package's Interfaces pillar. Not + * nav entries of this app: never in its edit buffer, never dragged, renamed + * or deleted here. + */ +export function ContributedNavRows({ + rows, + indent = false, +}: { + rows: readonly TopLevelContributedNav[]; + indent?: boolean; +}): React.ReactElement | null { + const locale = useMetadataLocale(); + const openContributor = useOpenContributor(); + if (rows.length === 0) return null; + return ( + <> + {rows.map(({ entry, undeclaredGroup }) => ( + } + title={contributedItemTitle(entry, locale, undeclaredGroup)} + onOpen={() => openContributor(`/studio/${encodeURIComponent(entry.packageId)}/interfaces`)} + locale={locale} + /> + ))} + + ); +} + +export function NavTree({ + nodes, + active, + onPick, + objectIcons, + depth = 0, +}: { + nodes: NavNode[]; + active: Surface | null; + onPick: (s: Surface) => void; + /** object name → its metadata icon, so object nav items show their own glyph. */ + objectIcons?: Record; + /** Nesting depth: a row under a group header is indented one step. */ + depth?: number; +}): React.ReactElement { + const locale = useMetadataLocale(); + // What a label-less row inherits is asked of the console's own resolver, so + // the rail names it as the console's sidebar does (objectui#11196). + const targetLabel = useNavTargetLabel(); + const railMarks = React.useContext(InterfacesRailMarksContext); + // objectstack#26979 — a level is listed in the order the running menu draws + // it, through the canvas's `inMenuOrder` (the menu's `byNavOrder` rule). `i` + // stays the entry's position in the authored array, which the canvas numbers + // an untitled entry by too. + const ordered = inMenuOrder(nodes); + return ( + <> + {ordered.map(([node, i]) => { + // objectui#11158 — the spec types a nav item's `label` as `I18nLabel`, + // so a locale map rendered raw threw ("Objects are not valid as a + // React child") and took the whole rail down. Every read below goes + // through the family's one helper, in the designer locale. + // objectui#11196 — an ABSENT label shows the text the entry inherits: + // the runtime's own rule, never a second copy of it. + // objectui#11791 — a `separator` is a rule between rows, not an entry. + // The sidebar's decision, mirrored: a decorative `Separator` + // (`role="none"`), so it is no row to click, no stop for the keyboard, + // and nothing a screen reader announces. The spec's separator carries + // `type`, `id` and `order` only, so there is no label or badge to draw. + if (node.type === 'separator') { + return ; + } + const labelText = navEntryLabelText(node, locale, targetLabel); + if (node.type === 'group' || (Array.isArray(node.children) && node.children.length)) { + return ( + + ); + } + // The same resolver names the Surface a click opens, so the caption, + // the breadcrumb and the copilot chip read what this row reads. + const surface = resolveSurface(node, locale, targetLabel); + // objectui#11862 — an entry that names no target yet ("Add nav item" + // left unbound, which the editor keeps showing in its place) inherits + // only its minted `id`. It reads as the editor's untitled wording + // instead, never `nav_item_N`. "No target" is the save's own rule + // (`navPayloadOf` leaves exactly these entries out), and an authored + // label still renders verbatim. + const untitled = node.label === undefined && navPayloadOf([node]).length === 0; + // objectui#7254 — a nav item with no declared label used to render an + // EMPTY row. It shows what it inherits (objectui#11196): its target's + // current label, else the target's internal name, which is the + // fallback #7254 chose. `surface?.name` stays for a label that is + // present but resolves to nothing (an empty map). + const rowText = untitled ? tFormat('engine.appNav.item', locale, { n: i + 1 }) : labelText || surface?.name || ''; + // Icon precedence: the nav item's own `icon`, then an object surface's + // own metadata icon, then the type-generic fallback. + const objIcon = surface?.type === 'object' ? objectIcons?.[surface.name] : undefined; + const Icon: React.ElementType = node.icon ? getIcon(node.icon) : objIcon ? getIcon(objIcon) : navIcon(node.type); + // objectui#11774 — the ENTRY is active, not every entry of its target: + // compared by nav id when both carry one (see `isSameSurface`). + const isActive = !!surface && !!active && isSameSurface(active, surface); + const kind = navKindName(node, locale); + return ( + surface && onPick(surface)} + disabled={!surface} + active={isActive} + indent={depth > 0} + // objectui#11862 — the label is what the row says; the machine + // name is the secondary information, on the tooltip with the type. + title={surface ? `${surface.type} · ${surface.name}` : rowText || undefined} + className="disabled:opacity-40" + icon={} + trailing={ + <> + {/* objectui#11791 — the spec's `badge` / `badgeVariant`, drawn + when the sidebar row draws them (a present `badge`, a count + `0` included) with the same `Badge` and the same variant: an + absent `badgeVariant` is `Badge`'s own default, which is the + sidebar's. On every leaf, a disabled one too; never on a + group heading, where the sidebar draws none. Before the kind + tag, so the pill sits beside the text it qualifies. */} + {node.badge != null && ( + + {node.badge} + + )} + {kind && ( + + {kind} + + )} + {surface && } + + } + > + {rowText} + + ); + })} + + ); +} + +/** + * 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. + */ +export const OFF_NAV_TYPES = ['page', 'dashboard', 'report', 'view'] as const; +export type OffNavType = (typeof OFF_NAV_TYPES)[number]; + +/** An item's identity in {@link navOpenedItems}' set. Not a cache key. */ +export 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. + */ +export 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. + */ +export function OffNavSections({ + sections, + active, + onPick, + onlyCustomized = false, +}: { + sections: ReadonlyArray<{ type: OffNavType; items: ReadonlyArray }>; + active: Surface | null; + onPick: (s: Surface) => void; + /** objectstack#28572 — "only customizations" is on: the sections list the marked items, and head nothing. */ + onlyCustomized?: boolean; +}): React.ReactElement | null { + const locale = useMetadataLocale(); + const shown = sections.filter((s) => s.items.length > 0); + if (shown.length === 0) return null; + return ( +
+ {!onlyCustomized && ( +

{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 railMarks = React.useContext(InterfacesRailMarksContext); + const [open, setOpen] = React.useState(true); + const Icon = navIcon(type); + return ( + setOpen((v) => !v)} + > + {items.map((item) => { + // objectstack#28572 — an item the pair adds opens at that pair. + const surface: Surface = { + type: item.type, + name: item.name, + label: item.label, + ...(item.addedIn ? { addedIn: item.addedIn } : {}), + }; + 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={} + trailing={} + > + {item.label || item.name} + + ); + })} + + ); +} 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 561d98c212e..ebaf86a592d 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -96,6 +96,19 @@ import { import { StudioRefusalStrip } from './StudioRefusalStrip.js'; import { StudioNavItemInspector, StudioPicker } from './StudioNavItemInspector.js'; import { PACKAGE_LIST_TOAST_ID, PackageSwitcher, STUDIO_LANDING_PATH } from './PackageSwitcher.js'; +import { + ContributedNavRows, + InterfacesNavContributionsContext, + InterfacesRailMarksContext, + NavTree, + OFF_NAV_TYPES, + OffNavSections, + itemKeyOf, + navIcon, + navKindName, + navOpenedItems, + type OffNavType, +} from './InterfacesNavRail.js'; import { GridFieldAuthoringProvider, cn, @@ -109,8 +122,6 @@ import { DropdownMenuSeparator, DropdownMenuRadioGroup, DropdownMenuRadioItem, - Badge, - Separator, Sheet, SheetContent, SheetHeader, @@ -125,16 +136,8 @@ import { ListView } from '@object-ui/plugin-list'; import { ObjectForm } from '@object-ui/plugin-form'; import { Ban, - FileText, Database, LayoutDashboard, - BarChart3, - Table2, - Folder, - Compass, - Link as LinkIcon, - Puzzle, - BookOpen, Workflow, SlidersHorizontal, MousePointer2, @@ -159,7 +162,6 @@ import { PanelRightClose, PanelRightOpen, Play, - Minus, Zap, Clock, MousePointerClick, @@ -195,7 +197,6 @@ import { listViewColumnSelection, listViewHeldEdit, listViewIsKanban, - listViewTargetOf, ObjectListViewInspector, useObjectListViewDraft, type DraftOnlyObjects, @@ -280,7 +281,7 @@ import { useWalledCatalogReadOnly } from '../metadata-admin/catalog-scope.js'; import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; import { StudioMetadataArea } from './StudioMetadataArea.js'; import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; -import { resolveSurface, findSurfaceInTree, isSameSurface, type NavNode, type Surface } from './navSurface.js'; +import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js'; import { servedNavTextOf, displayNavTree, displayAppLabel, type ServedNavText } from './servedNavText.js'; import { ServedWidgetTextContext, servedWidgetTextOf } from './servedDashboardText.js'; import { useSurfaceDeepLink, resolveSurfaceDeepLink, type SurfaceTarget } from './useSurfaceDeepLink.js'; @@ -293,7 +294,7 @@ import { fieldsForNodeType, localizeFlowFields, type FlowConfigField } from '../ import { ObjectPicker } from '../metadata-admin/inspectors/ObjectPicker.js'; import { useDisplayLocale } from '@object-ui/i18n'; import { SuggestedBindingsPanel } from '../../components/SuggestedBindingsPanel.js'; -import { AppNavCanvas, inMenuOrder, navPayloadOf } from '../metadata-admin/previews/AppNavCanvas.js'; +import { AppNavCanvas, navPayloadOf } from '../metadata-admin/previews/AppNavCanvas.js'; import { FlowRegistrationContext, FlowRuntimeContext, @@ -320,11 +321,9 @@ import { import { DraftTargetLabels, findByName, useNavTargetLabel } from '../../hooks/useNavTargetLabel.js'; import { NAV_ENTRY_TYPES, - isNavEntryType, } from '../metadata-admin/inspectors/nav-target.js'; import type { NavigationItemType } from '@object-ui/types'; import { errorCodeIs } from '@object-ui/types'; -import type { NavTargetLabelResolver } from '@object-ui/layout'; import { readFields, writeFields, @@ -378,7 +377,6 @@ import { useOpenContributor, useRailCustomizationsOnly, withPairItems, - type RailCustomizations, } from './StudioRailCustomizations.js'; import { baseContributionsOf, @@ -387,8 +385,6 @@ import { placesAnyNav, withContributedFlowGroups, type ContributedOnObject, - type NavContributionPlacement, - type TopLevelContributedNav, } from './contributionRows.js'; import { StudioCustomizeButton, @@ -660,24 +656,6 @@ const PILLAR_FOR_SURFACE_TYPE: Readonly> = { permission: 'access', }; -const KIND_ICON: Record = { - group: Folder, - page: FileText, - object: Database, - dashboard: LayoutDashboard, - report: BarChart3, - view: Table2, - action: MousePointer2, - // objectui#11790 — the types the nav editor can now add. A url / component / - // doc row opens no design surface (`resolveSurface`), so it renders disabled, - // under its own glyph rather than the generic compass. - url: LinkIcon, - component: Puzzle, - doc: BookOpen, - separator: Minus, -}; -const navIcon = (type?: string): LucideIcon => KIND_ICON[type ?? ''] ?? Compass; - // objectstack#29148 — the strip moved to its own module; re-exported for its pins. export { StudioRefusalStrip }; @@ -2246,432 +2224,6 @@ function StudioNameLabel({ label, name }: { label: string; name?: string }): Rea ); } -/** - * The kind tag a nav entry carries at the right of its rail row, and the kind - * an entry is named by in the header chip and the inspector's eyebrow - * (objectui#12455). A type the console also names as a metadata type (object, - * page, dashboard, report) is translated by that one helper, so the rail, the - * breadcrumb and the canvas caption say the same word; an `object` entry bound - * to a named view reads "View"; the types that open no design surface (url, - * action, component, doc) and the two structural ones are named from the - * Studio's own table. `''` for an entry with no type the spec declares. - */ -function navKindName(node: Pick, locale: string): string { - const type = node.type ?? ''; - if (type === 'object' && typeof node.viewName === 'string' && node.viewName) { - return t('engine.studio.if.kind.view', locale); - } - if (type === 'object' || type === 'page' || type === 'dashboard' || type === 'report') { - return translateMetadataType(type, locale); - } - if (isNavEntryType(type)) return t(`engine.studio.if.kind.${type}`, locale); - return ''; -} - -/** - * Whether `nodes` hold, at any depth, the entry that opens `active` — so a - * group the sidebar would collapse opens when the open leaf is inside it. - */ -function navTreeHoldsActive( - nodes: NavNode[] | undefined, - active: Surface | null, - locale: string, - targetLabel: NavTargetLabelResolver, -): boolean { - if (!active || !nodes) return false; - return nodes.some((node) => { - if (Array.isArray(node.children) && node.children.length) { - return navTreeHoldsActive(node.children, active, locale, targetLabel); - } - const surface = resolveSurface(node, locale, targetLabel); - return !!surface && isSameSurface(active, surface); - }); -} - -/** - * One group header and its children (objectui#12455). Open or closed as the - * running app's sidebar opens it (`NavigationRenderer` in `@object-ui/layout`): - * always open when the open leaf is inside it, else the spec's `expanded` when - * the author set it, else open — however many children it holds - * (objectui#13336). Collapsible by its - * header, as the sidebar's is. A group with nothing in it says so in its place - * instead of drawing a bare header. - */ -function NavTreeGroup({ - node, - labelText, - active, - onPick, - objectIcons, - depth, -}: { - node: NavNode; - labelText: string; - active: Surface | null; - onPick: (s: Surface) => void; - objectIcons?: Record; - depth: number; -}): React.ReactElement { - const locale = useMetadataLocale(); - const targetLabel = useNavTargetLabel(); - const children = node.children ?? []; - // objectstack#29786 — a declared group (`type: 'group'`) also holds the - // entries other packages contribute to it; a group with only those is no - // longer empty. - const placement = React.useContext(InterfacesNavContributionsContext); - const contributed = node.type === 'group' && typeof node.id === 'string' ? (placement.byGroup.get(node.id) ?? []) : []; - const holdsActive = navTreeHoldsActive(children, active, locale, targetLabel); - const [open, setOpen] = React.useState(() => { - if (holdsActive) return true; - if (typeof node.expanded === 'boolean') return node.expanded; - return true; - }); - // A leaf opened from elsewhere (a deep link, a create) inside a closed - // group opens the group, as the sidebar does for the route it is on. - // Adjusted while rendering, on the change, so no effect re-renders it. - const [heldActive, setHeldActive] = React.useState(holdsActive); - if (holdsActive !== heldActive) { - setHeldActive(holdsActive); - if (holdsActive) setOpen(true); - } - return ( - setOpen((v) => !v)}> - {children.length === 0 && contributed.length === 0 ? ( -

- {t('engine.studio.if.emptyGroup', locale)} -

- ) : ( - <> - {children.length > 0 && ( - - )} - {/* objectstack#29786 — what other packages add to this group, after - the group's own entries, as the served navigation appends them. */} - ({ entry, undeclaredGroup: null }))} indent /> - - )} -
- ); -} - -/** - * Recursive App-navigation tree: groups, separators and typed leaves. - * - * objectui#12455 (epic objectui#12446, decision 5) — the rail shows the app's - * navigation as the shipped sidebar renders it: the same order, the same - * groups (collapsible, opened as the sidebar opens them, see `NavTreeGroup`), - * the same icons. Each leaf is one 32 px row: its icon, its label, and its - * kind as a tag at the right (`navKindName`). The internal `type · name` pair - * lives on the row's tooltip (objectui#7254), which is also its accessible - * description; the visible text is what an author reads. - * - * objectui#11791 — the rail draws a `separator` and a leaf's `badge` / - * `badgeVariant` with the decisions the running app's sidebar makes - * (`NavigationRenderer` in `@object-ui/layout`, which `UnifiedSidebar` mounts), - * so the design surface agrees with the app it designs. Mirrored, not - * imported: the sidebar draws both inline in its row renderer, and its rows - * are `SidebarMenu` items this rail does not use. The pin that holds the two - * together renders both: the `StudioDesignSurface.railSeparatorBadge-11791` - * block of `StudioDesignSurface.railLabels.test.tsx`. - */ -/** - * objectstack#28572 — the Interfaces rail's customization marks, read by its - * tree rows and its off-nav rows alike (`railCustomizationsOf`); `null` on a - * package with nothing to mark. - */ -const InterfacesRailMarksContext = React.createContext(null); - -/** - * objectstack#29786 — where the open app's contributed nav entries go - * (`placeNavContributions`), read by the tree's groups and its top level. - * Empty on a package with none. - */ -const InterfacesNavContributionsContext = React.createContext({ byGroup: new Map(), top: [] }); - -/** - * objectstack#29786 — contributed nav entries as rail rows: greyed, with the - * contributing package, and opening that package's Interfaces pillar. Not - * nav entries of this app: never in its edit buffer, never dragged, renamed - * or deleted here. - */ -function ContributedNavRows({ - rows, - indent = false, -}: { - rows: readonly TopLevelContributedNav[]; - indent?: boolean; -}): React.ReactElement | null { - const locale = useMetadataLocale(); - const openContributor = useOpenContributor(); - if (rows.length === 0) return null; - return ( - <> - {rows.map(({ entry, undeclaredGroup }) => ( - } - title={contributedItemTitle(entry, locale, undeclaredGroup)} - onOpen={() => openContributor(`/studio/${encodeURIComponent(entry.packageId)}/interfaces`)} - locale={locale} - /> - ))} - - ); -} - -function NavTree({ - nodes, - active, - onPick, - objectIcons, - depth = 0, -}: { - nodes: NavNode[]; - active: Surface | null; - onPick: (s: Surface) => void; - /** object name → its metadata icon, so object nav items show their own glyph. */ - objectIcons?: Record; - /** Nesting depth: a row under a group header is indented one step. */ - depth?: number; -}): React.ReactElement { - const locale = useMetadataLocale(); - // What a label-less row inherits is asked of the console's own resolver, so - // the rail names it as the console's sidebar does (objectui#11196). - const targetLabel = useNavTargetLabel(); - const railMarks = React.useContext(InterfacesRailMarksContext); - // objectstack#26979 — a level is listed in the order the running menu draws - // it, through the canvas's `inMenuOrder` (the menu's `byNavOrder` rule). `i` - // stays the entry's position in the authored array, which the canvas numbers - // an untitled entry by too. - const ordered = inMenuOrder(nodes); - return ( - <> - {ordered.map(([node, i]) => { - // objectui#11158 — the spec types a nav item's `label` as `I18nLabel`, - // so a locale map rendered raw threw ("Objects are not valid as a - // React child") and took the whole rail down. Every read below goes - // through the family's one helper, in the designer locale. - // objectui#11196 — an ABSENT label shows the text the entry inherits: - // the runtime's own rule, never a second copy of it. - // objectui#11791 — a `separator` is a rule between rows, not an entry. - // The sidebar's decision, mirrored: a decorative `Separator` - // (`role="none"`), so it is no row to click, no stop for the keyboard, - // and nothing a screen reader announces. The spec's separator carries - // `type`, `id` and `order` only, so there is no label or badge to draw. - if (node.type === 'separator') { - return ; - } - const labelText = navEntryLabelText(node, locale, targetLabel); - if (node.type === 'group' || (Array.isArray(node.children) && node.children.length)) { - return ( - - ); - } - // The same resolver names the Surface a click opens, so the caption, - // the breadcrumb and the copilot chip read what this row reads. - const surface = resolveSurface(node, locale, targetLabel); - // objectui#11862 — an entry that names no target yet ("Add nav item" - // left unbound, which the editor keeps showing in its place) inherits - // only its minted `id`. It reads as the editor's untitled wording - // instead, never `nav_item_N`. "No target" is the save's own rule - // (`navPayloadOf` leaves exactly these entries out), and an authored - // label still renders verbatim. - const untitled = node.label === undefined && navPayloadOf([node]).length === 0; - // objectui#7254 — a nav item with no declared label used to render an - // EMPTY row. It shows what it inherits (objectui#11196): its target's - // current label, else the target's internal name, which is the - // fallback #7254 chose. `surface?.name` stays for a label that is - // present but resolves to nothing (an empty map). - const rowText = untitled ? tFormat('engine.appNav.item', locale, { n: i + 1 }) : labelText || surface?.name || ''; - // Icon precedence: the nav item's own `icon`, then an object surface's - // own metadata icon, then the type-generic fallback. - const objIcon = surface?.type === 'object' ? objectIcons?.[surface.name] : undefined; - const Icon: React.ElementType = node.icon ? getIcon(node.icon) : objIcon ? getIcon(objIcon) : navIcon(node.type); - // objectui#11774 — the ENTRY is active, not every entry of its target: - // compared by nav id when both carry one (see `isSameSurface`). - const isActive = !!surface && !!active && isSameSurface(active, surface); - const kind = navKindName(node, locale); - return ( - surface && onPick(surface)} - disabled={!surface} - active={isActive} - indent={depth > 0} - // objectui#11862 — the label is what the row says; the machine - // name is the secondary information, on the tooltip with the type. - title={surface ? `${surface.type} · ${surface.name}` : rowText || undefined} - className="disabled:opacity-40" - icon={} - trailing={ - <> - {/* objectui#11791 — the spec's `badge` / `badgeVariant`, drawn - when the sidebar row draws them (a present `badge`, a count - `0` included) with the same `Badge` and the same variant: an - absent `badgeVariant` is `Badge`'s own default, which is the - sidebar's. On every leaf, a disabled one too; never on a - group heading, where the sidebar draws none. Before the kind - tag, so the pill sits beside the text it qualifies. */} - {node.badge != null && ( - - {node.badge} - - )} - {kind && ( - - {kind} - - )} - {surface && } - - } - > - {rowText} - - ); - })} - - ); -} - -/** - * 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, - onlyCustomized = false, -}: { - sections: ReadonlyArray<{ type: OffNavType; items: ReadonlyArray }>; - active: Surface | null; - onPick: (s: Surface) => void; - /** objectstack#28572 — "only customizations" is on: the sections list the marked items, and head nothing. */ - onlyCustomized?: boolean; -}): React.ReactElement | null { - const locale = useMetadataLocale(); - const shown = sections.filter((s) => s.items.length > 0); - if (shown.length === 0) return null; - return ( -
- {!onlyCustomized && ( -

{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 railMarks = React.useContext(InterfacesRailMarksContext); - const [open, setOpen] = React.useState(true); - const Icon = navIcon(type); - return ( - setOpen((v) => !v)} - > - {items.map((item) => { - // objectstack#28572 — an item the pair adds opens at that pair. - const surface: Surface = { - type: item.type, - name: item.name, - label: item.label, - ...(item.addedIn ? { addedIn: item.addedIn } : {}), - }; - 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={} - trailing={} - > - {item.label || item.name} - - ); - })} - - ); -} - // objectstack#29988 — the nav item inspector moved to its own module; re-exported for its pins. export { StudioNavItemInspector }; diff --git a/ui/packages/app-shell/src/views/studio-design/packages-io.inflightShare-11797.test.tsx b/ui/packages/app-shell/src/views/studio-design/packages-io.inflightShare-11797.test.tsx index a4baffd17db..259efc7e7d1 100644 --- a/ui/packages/app-shell/src/views/studio-design/packages-io.inflightShare-11797.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/packages-io.inflightShare-11797.test.tsx @@ -5,7 +5,7 @@ * * Opening a package mounts three readers of `GET /api/v1/packages` at once — * the switcher list, the writability courtesy gate and the namespace lookup - * (the `PACKAGE_LIST_TOAST_ID` doc in `StudioDesignSurface.tsx` names all + * (the `PACKAGE_LIST_TOAST_ID` doc in `PackageSwitcher.tsx` names all * three) — and each one sent its own request. Counted on the wire here: the * stubbed `fetch` holds every request open until the test answers it, so * "at once" means the first request is genuinely pending when the next call diff --git a/ui/scripts/__tests__/spec-symbol-ledger-app-shell-7265.test.ts b/ui/scripts/__tests__/spec-symbol-ledger-app-shell-7265.test.ts index 1f4fc96496e..1c1f380bb5f 100644 --- a/ui/scripts/__tests__/spec-symbol-ledger-app-shell-7265.test.ts +++ b/ui/scripts/__tests__/spec-symbol-ledger-app-shell-7265.test.ts @@ -66,6 +66,7 @@ const SITES = [ 'packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx', 'packages/app-shell/src/views/studio-design/StudioNavItemInspector.tsx', 'packages/app-shell/src/views/studio-design/PackageSwitcher.tsx', + 'packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx', 'packages/app-shell/src/hooks/useTrackRouteAsRecent.ts', 'packages/app-shell/src/utils/deriveRelatedLists.ts', 'packages/app-shell/src/views/metadata-admin/datasource/DatasourceResourcePage.tsx',