From 1ca43ada00e3b86897c1b7761d840a677d5cd84c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:22:31 +0000 Subject: [PATCH 1/7] feat(studio): an All metadata area in the builder, per package and across packages Mounts the metadata-admin directory, lists, generic item page, history and diagnostics in Studio at /studio//metadata and /studio/~all/metadata. Pillar types open their pillar; a read-only package's item page offers no write. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSErqBgsh8YbjzzqZ9 --- .../console/src/components/StudioRoute.tsx | 76 ++++++- .../console/src/components/studioBuilder.ts | 7 +- ui/packages/app-shell/src/index.ts | 10 + .../views/item-tools/ResourceHistoryPage.tsx | 15 +- .../views/metadata-admin/DirectoryPage.tsx | 41 +++- .../src/views/metadata-admin/PageShell.tsx | 11 +- .../metadata-admin/PermissionMatrixEditor.tsx | 20 +- .../src/views/metadata-admin/QuickFind.tsx | 17 +- .../views/metadata-admin/ResourceEditPage.tsx | 42 +++- .../views/metadata-admin/ResourceListPage.tsx | 25 ++- .../metadata-admin/SetupAssignmentsLink.tsx | 57 ++++++ .../views/metadata-admin/metadataAreaHost.ts | 72 +++++++ .../views/studio-design/BuilderLanding.tsx | 22 +- .../studio-design/StudioDesignSurface.tsx | 64 +++++- .../studio-design/StudioMetadataArea.tsx | 191 ++++++++++++++++++ .../src/views/studio-design/metadataArea.ts | 155 ++++++++++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 13 +- ui/packages/i18n/src/namespaces/studio/zh.ts | 8 +- 18 files changed, 796 insertions(+), 50 deletions(-) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/SetupAssignmentsLink.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.ts create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/metadataArea.ts diff --git a/ui/apps/console/src/components/StudioRoute.tsx b/ui/apps/console/src/components/StudioRoute.tsx index ce27969accc..0074bfe6795 100644 --- a/ui/apps/console/src/components/StudioRoute.tsx +++ b/ui/apps/console/src/components/StudioRoute.tsx @@ -44,8 +44,11 @@ import { CommandPaletteProvider, LoadingFallback, LoadingScreen, + STUDIO_ALL_PACKAGES_SEGMENT, + STUDIO_METADATA_AREA, STUDIO_ORG_SCOPE_PILLAR, STUDIO_ORG_SCOPE_SEGMENT, + studioMetadataAreaPath, useHomePath, useRecentItems, } from '@object-ui/app-shell'; @@ -74,6 +77,9 @@ const StudioDesignSurface = lazy(() => const BuilderLanding = lazy(() => import('./studioBuilder').then((m) => ({ default: m.BuilderLanding })), ); +const StudioMetadataArea = lazy(() => + import('./studioBuilder').then((m) => ({ default: m.StudioMetadataArea })), +); /** * What a principal without `studio.access` sees, at the Studio URL they opened @@ -205,6 +211,19 @@ export function StudioBuilderLoading() { * (objectui#11912), and `⌘K` opens it. */ function StudioLanding() { + return ( + + + + ); +} + +/** + * The console frame `/studio` screens without a package draw (objectui#11863): + * the console's own top bar in its `studio` variant and the command palette in + * its `studio` scope, around a body that loads with its route. + */ +function StudioConsoleFrame({ children }: { children: ReactNode }) { return (
@@ -217,16 +236,29 @@ function StudioLanding() {
- {/* The frame above stays painted while the landing's chunk loads. */} - }> - - + {/* The frame above stays painted while the body's chunk loads. */} + }>{children}
); } +/** + * objectstack#28561 — the "All metadata" area's cross-package leg + * (`/studio/~all/metadata/*`, ADR-0146 D1): the environment catalog, served + * with no package. No package means no pillars, no package switcher and no + * package publish, so it is not the pillar builder: it is the area alone, in + * the frame the landing draws, which it is entered from. + */ +function StudioAllPackagesMetadata() { + return ( + + + + ); +} + /** * A package's pillar builder, which records the visit as a recent `package` * entry (objectui#11863), so the landing can list the packages an author was @@ -241,7 +273,7 @@ function StudioLanding() { * and the provider writes nothing. The package-less scope (`~org`) is not a * package and records nothing. */ -function StudioPackageBuilder() { +function StudioPackageBuilder({ area }: { area?: typeof STUDIO_METADATA_AREA }) { const { packageId } = useParams(); const { addRecentItem } = useRecentItems(); useEffect(() => { @@ -259,7 +291,7 @@ function StudioPackageBuilder() { }, [packageId]); return ( }> - + ); } @@ -273,10 +305,13 @@ function StudioPackageBuilder() { * REAL tree instead of a transcription of it — a transcription would be free to * agree with itself while `App.tsx` quietly mounted the builder ungated. * - * `/studio` front door (pick / create a writable package) - * `/studio/~org` the package-less scope lands on its one pillar - * `/studio/:packageId` a package lands on its Data pillar - * `/studio/:packageId/:tab` the pillar builder (a recent-package visit) + * `/studio` front door (pick / create a writable package) + * `/studio/~org` the package-less scope lands on its one pillar + * `/studio/~all/metadata/*` the "All metadata" area across packages + * `/studio/~all/*` lands on that area + * `/studio/:packageId` a package lands on its Data pillar + * `/studio/:packageId/metadata/*` a package's "All metadata" area, in the builder + * `/studio/:packageId/:tab` the pillar builder (a recent-package visit) * * `~org` is the reserved segment for the organization's own, package-less * flows (objectui#11553; `studioScope.ts` in app-shell says why `~` can never @@ -285,6 +320,15 @@ function StudioPackageBuilder() { * package" — so only its bare leg needs a route of its own: a static segment * outranks `:packageId`, and the generic leg would send it to a Data pillar the * scope does not have. + * + * objectstack#28561 — the "All metadata" area (ADR-0146 D1) has routes of its + * own because its pages nest below it (`StudioMetadataArea` declares them), and + * `:packageId/:tab` matches one segment: an area URL such as + * `/studio/crm/metadata/flow` would match no route at all. The static + * `metadata` segment outranks `:tab`. `~all` is the area's reserved + * cross-package segment (`metadataArea.ts` in app-shell says why it is not + * `~org`); every `~all` URL outside the area lands on it, so none reaches the + * pillar builder as a package id. */ export const studioRoutes = ( }> @@ -293,7 +337,19 @@ export const studioRoutes = ( path={STUDIO_ORG_SCOPE_SEGMENT} element={} /> + } + /> + } + /> } /> + } + /> } /> ); diff --git a/ui/apps/console/src/components/studioBuilder.ts b/ui/apps/console/src/components/studioBuilder.ts index dd84a6b7f7f..a1a4c2d6559 100644 --- a/ui/apps/console/src/components/studioBuilder.ts +++ b/ui/apps/console/src/components/studioBuilder.ts @@ -1,8 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The Studio builder's two screens, as the ONE module the console loads them - * through (objectui#11798). + * The Studio builder's screens, as the ONE module the console loads them + * through (objectui#11798): the landing, the pillar builder, and the "All + * metadata" area's cross-package leg (objectstack#28561). * * `StudioRoute.tsx` (the `/studio` subtree) and `registerStudioComponents.tsx` * (the `studio:builder` registry entry) both reach these components through @@ -29,4 +30,4 @@ * one, and `pnpm check:eager-closure` weighs the bytes that would come back. */ -export { BuilderLanding, StudioDesignSurface } from '@object-ui/app-shell'; +export { BuilderLanding, StudioDesignSurface, StudioMetadataArea } from '@object-ui/app-shell'; diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index ca3a959942e..cf93749a981 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -518,6 +518,16 @@ export { STUDIO_ORG_SCOPE_PILLAR, studioOrgScopePath, } from './views/studio-design/studioScope.js'; +// Studio's "All metadata" area (objectstack ADR-0146 D1): per package beside +// the pillars, at `/studio//metadata/*`, and across packages at the +// reserved `/studio/~all/metadata/*`. A host that declares the `/studio` routes +// reads the segments from here, and mounts the cross-package leg's area. +export { + STUDIO_METADATA_AREA, + STUDIO_ALL_PACKAGES_SEGMENT, + studioMetadataAreaPath, +} from './views/studio-design/metadataArea.js'; +export { StudioMetadataArea } from './views/studio-design/StudioMetadataArea.js'; // Setup › Packaged automation (ADR-0126 §7.4) — on/off + clone for the flows // installed packages ship. Reached through the `automation:packaged` component diff --git a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx index 08cb648e631..4155b148f5b 100644 --- a/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx +++ b/ui/packages/app-shell/src/views/item-tools/ResourceHistoryPage.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import { useDisplayLocale } from '@object-ui/i18n'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ArrowLeft, RefreshCw, Loader2, RotateCcw } from 'lucide-react'; import { Button } from '@object-ui/components'; import { Badge } from '@object-ui/components'; @@ -35,6 +35,7 @@ import { import { t, tFormat, translateConsoleValue, useMetadataLocale } from '../metadata-admin/i18n.js'; import { useHistoryActorNames, type HistoryActorName } from './historyActors.js'; import { PageShell } from '../metadata-admin/PageShell.js'; +import { useMetadataAreaHost } from '../metadata-admin/metadataAreaHost.js'; import { useMetadataClient, useMetadataTypes, @@ -89,6 +90,14 @@ export function MetadataResourceHistoryPage({ const [refreshKey, setRefreshKey] = React.useState(0); const [eventCount, setEventCount] = React.useState(0); + // objectstack ADR-0146 D1 (#28561) — under Studio's "All metadata" area a + // read-only package offers no rollback, and "Back to item" names the area's + // item page with the query it came with (its `?package=` and scope). + const areaHost = useMetadataAreaHost(); + const { search } = useLocation(); + const backToItem = areaHost + ? `${areaHost.basePath}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${search}` + : `../${encodeURIComponent(name)}`; return ( navigate(`../${encodeURIComponent(name)}`)} + onClick={() => navigate(backToItem)} > Back to item @@ -125,7 +134,7 @@ export function MetadataResourceHistoryPage({ onCount={setEventCount} client={client} locale={locale} - onRollback={() => setRefreshKey((k) => k + 1)} + onRollback={areaHost?.readOnly ? undefined : () => setRefreshKey((k) => k + 1)} /> diff --git a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx index cf6f8660712..232799bd9c6 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/DirectoryPage.tsx @@ -39,6 +39,8 @@ import { } from './i18n.js'; import { buildPackageScopeOptions } from './package-scope.js'; import { diagnosticsHeaderTone, type DiagnosticsHeaderTone } from './diagnostics-tone.js'; +import { ENVIRONMENT_AREA_TYPES, SETUP_DATASOURCES_PATH, useMetadataAreaHost } from './metadataAreaHost.js'; +import { ENVIRONMENT_SCOPE_QUERY } from './catalog-scope.js'; /** * The "View all issues" link's look per sweep tone (objectui#12768). The @@ -99,6 +101,10 @@ export function MetadataDirectoryPage() { const [domainFilter, setDomainFilter] = React.useState('all'); const [writableOnly, setWritableOnly] = React.useState(false); const [searchParams, setSearchParams] = useSearchParams(); + // objectstack ADR-0146 D1 (#28561) — under Studio's "All metadata" area the + // package is the area's, not one this page picks and repairs; the + // cross-package leg (`packageId: null`) lists the environment catalog. + const host = useMetadataAreaHost(); // Studio is scoped to a single *project* package at a time (the sidebar // `active_package` selector owns the scope via `?package=`). Load the @@ -128,15 +134,18 @@ export function MetadataDirectoryPage() { // package set. `null` while packages are still loading (fail closed). const urlPackage = searchParams.get('package'); const activePackage = React.useMemo(() => { + if (host) return host.packageId; if (!projectPackages) return null; if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return urlPackage; return projectPackages[0]?.id ?? null; - }, [projectPackages, urlPackage]); + }, [host, projectPackages, urlPackage]); // Repair `?package=` so the sidebar selector and deep-links agree on the // active scope. Runs once packages resolve and the URL holds no valid // project package. React.useEffect(() => { + // The area's package is its route's: nothing to repair. + if (host) return; if (!projectPackages || projectPackages.length === 0) return; if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return; const next = new URLSearchParams(searchParams); @@ -167,10 +176,12 @@ export function MetadataDirectoryPage() { () => entries.filter((e) => { if (HIDDEN_TYPES.has(e.type)) return false; + // The area's cross-package leg: the environment catalog, every package's. + if (host && host.packageId === null) return ENVIRONMENT_AREA_TYPES.includes(e.type); if (!activePackage) return false; return (packagesByType[e.type] ?? []).includes(activePackage); }), - [entries, activePackage, packagesByType], + [host, entries, activePackage, packagesByType], ); // Counts per domain for the filter chip bar. @@ -213,7 +224,8 @@ export function MetadataDirectoryPage() { }); }, [filtered]); - if (loading || projectPackages === null || (activePackage && diagLoading)) { + // The area never waits on the project packages: its package is given. + if (loading || (!host && projectPackages === null) || ((host || activePackage) && diagLoading)) { return
{t('engine.directory.loading', locale)}
; } if (error) { @@ -297,7 +309,7 @@ export function MetadataDirectoryPage() { {/* Body */}
- {projectPackages.length === 0 && ( + {!host && projectPackages?.length === 0 && ( No project packages installed @@ -306,7 +318,7 @@ export function MetadataDirectoryPage() { )} - {projectPackages.length > 0 && filtered.length === 0 && ( + {(host || (projectPackages?.length ?? 0) > 0) && filtered.length === 0 && ( {t('engine.directory.noMatches', locale)} @@ -337,6 +349,10 @@ export function MetadataDirectoryPage() { lockedCount={lockedByType[e.type] ?? 0} itemCount={totalCount} packageFilter={activePackage ?? undefined} + environmentScope={!!host && host.packageId === null} + // ADR-0146 D1 — under the area, the datasource tile + // opens Setup's Datasources page. + href={host && e.type === 'datasource' ? SETUP_DATASOURCES_PATH : undefined} /> ); })} @@ -391,6 +407,8 @@ function TypeTile({ itemCount = 0, lockedCount = 0, packageFilter, + environmentScope = false, + href: hrefOverride, }: { entry: RichMetadataTypeEntry; locale?: string; @@ -401,12 +419,19 @@ function TypeTile({ lockedCount?: number; /** When set, navigate into the list page pre-filtered to this package. */ packageFilter?: string; + /** Open the list in the environment scope (the Studio area's cross-package leg). */ + environmentScope?: boolean; + /** Where the tile leads instead of the type's list (the Studio area's datasource tile). */ + href?: string; }) { // Prefer the locale-table translation; fall back to server's `label` (typically English). const label = translateMetadataType(entry.type, locale, entry.label); - const href = packageFilter - ? `./${encodeURIComponent(entry.type)}?package=${encodeURIComponent(packageFilter)}` - : `./${encodeURIComponent(entry.type)}`; + const href = hrefOverride + ?? (environmentScope + ? `./${encodeURIComponent(entry.type)}?${ENVIRONMENT_SCOPE_QUERY}` + : packageFilter + ? `./${encodeURIComponent(entry.type)}?package=${encodeURIComponent(packageFilter)}` + : `./${encodeURIComponent(entry.type)}`); return ( { + if (host) return host.basePath; const idx = pathname.indexOf('/metadata'); return idx >= 0 ? pathname.slice(0, idx + '/metadata'.length) : '/metadata'; - }, [pathname]); + }, [host, pathname]); // objectui#7611 — an editor opened from the Setup catalog returns to the // Setup catalog, not to the package-scoped Studio list of the same type. const [search] = useSearchParams(); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx index 90f842a5e47..456ef231163 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -120,6 +120,8 @@ import { CelPredicateField } from './CelPredicateField.js'; import type { CelLintIssue } from './celAuthoring.js'; import { useRegisterUnsavedEdits } from '../../chrome/unsavedEdits.js'; import { AssignedUsersSection } from './AssignedUsersSection.js'; +import { SetupAssignmentsLink } from './SetupAssignmentsLink.js'; +import { useMetadataAreaHost } from './metadataAreaHost.js'; import { isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js'; import { useCanAuthorMetadata, useCanReadAuthoringTrail } from '../../hooks/useCanAuthorMetadata.js'; import { errorCodeIs } from '@object-ui/types'; @@ -574,7 +576,13 @@ export interface PermissionMatrixEditPageProps { /* Component */ /* ────────────────────────────────────────────────────────────────── */ -export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, publishNonce, onOpenOwd, onDirtyChange, readOnly = false, embedded = false, studio }: PermissionMatrixEditPageProps) { +export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, publishNonce, onOpenOwd, onDirtyChange, readOnly: readOnlyProp = false, embedded = false, studio }: PermissionMatrixEditPageProps) { + // objectstack ADR-0146 D1 (#28561) — Studio's "All metadata" area mounts this + // editor through the routed item page, which hands it no `readOnly`: a + // read-only package's area is the host gate here, as a pillar's prop is. + // Outside the area there is no host and nothing changes. + const areaHost = useMetadataAreaHost(); + const readOnly = readOnlyProp || !!areaHost?.readOnly; const navigate = useNavigate(); const client = useMetadataClient(); // The editor reads no record: the capability picker lists the capability @@ -2464,7 +2472,15 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, objectstack#26885 — in the matrix's own scroll, after the grid: a strip pinned beneath it took up to 35vh off the grid's height, so a laptop showed about five object rows and a phone none. */} - {showAssignments && ( + {/* objectstack ADR-0146 D1/D7 (#28561) — Studio's "All metadata" + area draws no assignment: it links Setup's Permission set + assignment page, opened on this set. */} + {showAssignments && areaHost && ( +
+ +
+ )} + {showAssignments && !areaHost && (
diff --git a/ui/packages/app-shell/src/views/metadata-admin/QuickFind.tsx b/ui/packages/app-shell/src/views/metadata-admin/QuickFind.tsx index a0d9441f0e3..306fa40b307 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/QuickFind.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/QuickFind.tsx @@ -38,6 +38,7 @@ import { type RichMetadataTypeEntry, } from './useMetadata.js'; import { useMetadataLocale, t as tr, translateMetadataType } from './i18n.js'; +import { useMetadataAreaHost } from './metadataAreaHost.js'; type ItemResult = { kind: 'item'; @@ -49,6 +50,8 @@ type ItemResult = { lock?: string; /** Reason shown in the lock icon tooltip. */ lockReason?: string; + /** The served owning package (`_packageId`), when the item names one. */ + packageId?: string; }; type TypeResult = { @@ -67,6 +70,10 @@ export function MetadataQuickFind({ appSlug }: MetadataQuickFindProps = {}) { const navigate = useNavigate(); const client = useMetadataClient(); const { entries: typeEntries } = useMetadataTypes(client); + // objectstack ADR-0146 D1 (#28561) — under Studio's "All metadata" area the + // palette opens the area's pages, and a package's area finds that package's + // items only. + const host = useMetadataAreaHost(); const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); @@ -115,6 +122,7 @@ export function MetadataQuickFind({ appSlug }: MetadataQuickFindProps = {}) { description: item?.description, lock: typeof item?._lock === 'string' ? item._lock : undefined, lockReason: typeof item?._lockReason === 'string' ? item._lockReason : undefined, + packageId: typeof item?._packageId === 'string' ? item._packageId : undefined, }); } } catch { @@ -150,6 +158,7 @@ export function MetadataQuickFind({ appSlug }: MetadataQuickFindProps = {}) { .slice(0, 8) .map((entry) => ({ kind: 'type', entry })); const itemHits: ItemResult[] = (items ?? []) + .filter((i) => !host?.packageId || i.packageId === host.packageId) .filter( (i) => i.name.toLowerCase().includes(q) || @@ -159,7 +168,7 @@ export function MetadataQuickFind({ appSlug }: MetadataQuickFindProps = {}) { ) .slice(0, 40); return [...typeHits, ...itemHits]; - }, [query, typeEntries, items]); + }, [query, typeEntries, items, host?.packageId]); React.useEffect(() => { setActiveIdx(0); @@ -167,12 +176,12 @@ export function MetadataQuickFind({ appSlug }: MetadataQuickFindProps = {}) { function navigateTo(r: Result) { setOpen(false); - const base = appSlug ? `/apps/${appSlug}` : '..'; + const base = host ? host.basePath : `${appSlug ? `/apps/${appSlug}` : '..'}/metadata`; if (r.kind === 'type') { - navigate(`${base}/metadata/${encodeURIComponent(r.entry.type)}`); + navigate(`${base}/${encodeURIComponent(r.entry.type)}`); } else { navigate( - `${base}/metadata/${encodeURIComponent(r.type)}/${encodeURIComponent(r.name)}`, + `${base}/${encodeURIComponent(r.type)}/${encodeURIComponent(r.name)}`, ); } } diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 5f0dd044ffd..29a9d54485e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -160,6 +160,8 @@ import { useCanAuthorMetadata, useCanReadAuthoringTrail } from '../../hooks/useC import { environmentScopeSuffix, isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js'; import { PositionHoldersSection } from './PositionHoldersSection.js'; import { PositionPermissionSetsSection } from './PositionPermissionSetsSection.js'; +import { PositionHoldersSetupLink } from './SetupAssignmentsLink.js'; +import { useMetadataAreaHost } from './metadataAreaHost.js'; import { declarationOnlyAuthoredObject } from './sharing-rule-catalog.js'; import { useRegisterUnsavedEdits } from '../../chrome/unsavedEdits.js'; import { RefusalMessage } from '../../components/RefusalMessage.js'; @@ -183,6 +185,16 @@ const ENVIRONMENT_SECTIONS: Readonly>>> = { + position: [PositionHoldersSetupLink, PositionPermissionSetsSection], +}; + /** * ADR-0010 §3.6 lock state -> the lock banner's headline sentence. * @@ -1699,7 +1711,14 @@ function MetadataResourceEditPageImpl({ const canAuthor = useCanAuthorMetadata(); const walled = useWalledCatalogReadOnly(type); const walledDeciding = walled && itemWritable; - const canWrite = itemWritable && canAuthor && !walled; + // objectstack ADR-0146 D1 (#28561) — under Studio's "All metadata" area, a + // read-only package withholds every write this page offers: save, source + // save, publish, discard, reset, delete and rollback all read `canWrite`. + // The overlay save included, which the server would accept on a code + // package's item of an overlay-allowed type. Outside the area, no change. + const areaHost = useMetadataAreaHost(); + const hostReadOnly = !!areaHost?.readOnly; + const canWrite = itemWritable && canAuthor && !walled && !hostReadOnly; // Auto-enable design mode for designer-capable types. We do this once // per (type,name) navigation so the user lands in the productive // state instead of having to click "Edit". An item this caller cannot @@ -2308,7 +2327,8 @@ function MetadataResourceEditPageImpl({ const readOnly = !canWrite && !createMode; const envScope = isEnvironmentScope(searchParams); const scopeSuffix = environmentScopeSuffix(searchParams); - const environmentSections = envScope && !createMode && !embedded ? ENVIRONMENT_SECTIONS[type] : undefined; + const environmentSections = + envScope && !createMode && !embedded ? (areaHost ? AREA_ENVIRONMENT_SECTIONS : ENVIRONMENT_SECTIONS)[type] : undefined; // #2272 — designer deep-link: `?sel=nav:` selects the nav item with // that spec `id` (stable across reorders, unlike the positional selection @@ -2447,7 +2467,8 @@ function MetadataResourceEditPageImpl({ const showArtifactLockedBanner = // objectui#7611 — and only for a caller who could author the new item // (objectui#12183: under a wall, the operator). - readOnly && isArtifactItem && !!entry?.allowRuntimeCreate && canAuthor && !walled && !declarationOnly; + // Never in a read-only package's area: that package takes no new item. + readOnly && isArtifactItem && !!entry?.allowRuntimeCreate && canAuthor && !walled && !declarationOnly && !hostReadOnly; // Preview tab — opt-in via `registerMetadataPreview()`. Hidden in // create mode (nothing to preview yet) and inside the embedded @@ -2934,7 +2955,18 @@ function MetadataResourceEditPageImpl({ {t(`engine.catalog.declaredOnly.hint.${type}`, locale)}
)} - {readOnly && !isLocked && !canAuthor && !walledDeciding && !declarationOnly && ( + {/* objectstack ADR-0146 D1 (#28561) — in a read-only package's + area the package is the reason, so it is the one named. */} + {hostReadOnly && !createMode && ( +
+ + {t('engine.studio.pkg.readonlyHint', locale)} +
+ )} + {readOnly && !hostReadOnly && !isLocked && !canAuthor && !walledDeciding && !declarationOnly && (
{t('engine.edit.capabilityReadOnly', locale)}
)} - {readOnly && !isLocked && canAuthor && !walledDeciding && !declarationOnly && ( + {readOnly && !hostReadOnly && !isLocked && canAuthor && !walledDeciding && !declarationOnly && (
{showArtifactLockedBanner ? ( diff --git a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 1abb0737508..2f4976e4589 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -72,6 +72,7 @@ import { import { t, tFormat, translateMetadataType, useMetadataLocale } from './i18n.js'; import { buildPackageScopeOptions } from './package-scope.js'; import { ENVIRONMENT_SCOPE_QUERY, isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js'; +import { useMetadataAreaHost } from './metadataAreaHost.js'; import { audienceAnchorsOf, hasCatalogActivation, @@ -190,6 +191,10 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // the description. Read off the route's `:appName`; the same page anywhere // else (the directory, Studio's "Open in Setup" target) keeps its shell. const inSetup = useInSetupApp(); + // objectstack ADR-0146 D1 (#28561) — under Studio's "All metadata" area the + // package scope is the area's, never picked or repaired here, and a + // read-only package offers no create. + const host = useMetadataAreaHost(); // objectstack#27356 — under Setup the header names the page as the sidebar // entry that opens it does ("Positions"); the type's label otherwise. const { app } = useExpressionContext(); @@ -306,6 +311,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // package set. `null` while packages are still loading (fail closed). const urlPackage = searchParams.get('package'); const activePackage = React.useMemo(() => { + if (host?.packageId) return host.packageId; if (!projectPackages) return null; if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return urlPackage; if (projectPackages.length === 0) return null; @@ -328,7 +334,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string if (n > bestN) { best = p.id; bestN = n; } } return best ?? projectPackages[0]?.id ?? null; - }, [projectPackages, urlPackage, items, appPackage]); + }, [host, projectPackages, urlPackage, items, appPackage]); // Repair `?package=` so the sidebar selector, deep-links and create/edit // navigation all agree on the active scope. Runs once packages resolve @@ -336,6 +342,8 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string React.useEffect(() => { // The environment scope lists every package's items: no package to repair. if (envScope) return; + // The area's package is its route's. + if (host?.packageId) return; if (!projectPackages || projectPackages.length === 0) return; if (urlPackage && projectPackages.some((p) => p.id === urlPackage)) return; // If the current app's package is known we can repair immediately; otherwise @@ -369,6 +377,11 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string navigate(`./new?${ENVIRONMENT_SCOPE_QUERY}`); return; } + // The Studio area creates into its own package. + if (host?.packageId) { + navigate(`./new${pkgSuffix}`); + return; + } const bases = projectPackages ?? []; if (projectPackages !== null && bases.length === 0) { setShowCreateBase(true); @@ -379,7 +392,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string return; } navigate(`./new${pkgSuffix}`); - }, [projectPackages, activePackage, pkgSuffix, navigate, envScope]); + }, [projectPackages, activePackage, pkgSuffix, navigate, envScope, host?.packageId]); // objectui#7611 — the activation state the switch column shows. Re-read on // the list's own refresh, never on an unrelated render. The anchors arrive @@ -599,14 +612,14 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string const typeWritable = !!(entry?.allowEnvironmentOverlay || entry?.allowRuntimeCreate); // A declaration-only catalog is never authored here: its items are created // on the authored object's page. - const canCreate = typeWritable && !walled && (!envScope || canAuthor) && !authoredObject; + const canCreate = typeWritable && !walled && (!envScope || canAuthor) && !authoredObject && !host?.readOnly; // The wall is the gate to NAME only where the type would otherwise admit a // write: a type with no runtime write channel is read-only for the operator // too, and its page says nothing about the wall. const walledDeciding = walled && typeWritable; // Packages load only for the package scope; the environment scope never // waits on them. - const packagesPending = !envScope && projectPackages === null; + const packagesPending = !envScope && !host?.packageId && projectPackages === null; // Localise default column labels — registered columns keep their // hand-authored labels (consumers may want bespoke wording). @@ -784,7 +797,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string {error}
)} - {!loading && !error && !envScope && projectPackages !== null && projectPackages.length === 0 && ( + {!loading && !error && !envScope && !host?.packageId && projectPackages !== null && projectPackages.length === 0 && ( No project packages installed @@ -793,7 +806,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string )} - {!loading && !error && !packagesPending && (envScope || (projectPackages?.length ?? 0) > 0) && filtered.length === 0 && ( + {!loading && !error && !packagesPending && (envScope || !!host?.packageId || (projectPackages?.length ?? 0) > 0) && filtered.length === 0 && ( {scopedItems.length === 0 diff --git a/ui/packages/app-shell/src/views/metadata-admin/SetupAssignmentsLink.tsx b/ui/packages/app-shell/src/views/metadata-admin/SetupAssignmentsLink.tsx new file mode 100644 index 00000000000..c89eb1c09f4 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/SetupAssignmentsLink.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a catalog item's assignments are, drawn in Studio's "All metadata" area + * in place of the assignment section itself (objectstack ADR-0146 D1 and D7, + * #28561). + * + * Assigning people is Setup's work: D7 gives position holders and + * permission-set assignment their own Setup pages (N-27), and D1 keeps Studio + * the developer's surface. So the area does not draw `PositionHoldersSection` + * or `AssignedUsersSection` under a definition, as Setup's catalog does; it + * names the Setup page that does, opened on this item. + */ + +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowUpRight, UserCheck } from 'lucide-react'; +import { Button } from '@object-ui/components'; +import { permissionSetAssignmentPagePath, positionHoldersPagePath } from '../setup/peopleAssignments.js'; +import { t, useMetadataLocale } from './i18n.js'; + +export type SetupAssignmentsKind = 'position' | 'permission'; + +export function SetupAssignmentsLink({ + kind, + name, +}: { + /** `position` names the Position holders page, `permission` the Permission set assignment page. */ + kind: SetupAssignmentsKind; + /** The item the Setup page opens on. */ + name: string; +}): React.ReactElement { + const locale = useMetadataLocale(); + const href = kind === 'position' ? positionHoldersPagePath(name) : permissionSetAssignmentPagePath(name); + return ( +
+
+ + {t(`engine.studio.area.assignments.${kind}`, locale)} +
+ +
+ ); +} + +/** The position editor's environment section under the area: the link, not the holders list. */ +export function PositionHoldersSetupLink({ name }: { name: string }): React.ReactElement { + return ; +} diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.ts b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.ts new file mode 100644 index 00000000000..95de3a6dabd --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Studio's "All metadata" area, as the metadata-admin pages see it + * (objectstack ADR-0146 D1, #28561). + * + * D1 makes Studio the home of every metadata type: beside the four pillars, an + * "All metadata" area holds what these pages hold today — the type directory, + * the lists, the generic item page, history and diagnostics — per package and + * across packages. The area MOUNTS these pages; it does not copy them. Setup + * keeps mounting them too, unchanged, until N-28 redirects its routes. + * + * What differs under the area is a small set of facts the route alone cannot + * give a page, so the area provides them here and a page reads them with + * {@link useMetadataAreaHost}: + * + * - which package is open. A page that would otherwise pick and repair its own + * `?package=` scope (the directory, the list) stays on the area's package; + * - whether that package is read-only. Every write the pages offer is then + * withheld: rollback, source save, reset, delete, the overlay save and the + * create doors. The server stays the authority; this is the courtesy gate + * the Studio pillars already apply (`StudioDesignSurface`'s `readOnly`), + * which the generic page needs most of all, because the server ACCEPTS an + * overlay save on a code package's item of an overlay-allowed type; + * - where the area is mounted, so a link that today climbs to `…/metadata` + * by reading the pathname reaches the area's root instead. + * + * ⛔ No provider, no change: with no area around it, every page renders exactly + * as it does in Setup today (`null` below). A page reads the host only to add + * the area's gate or scope, never to take one of Setup's away. + */ + +import * as React from 'react'; +import { SETUP_CATALOG_TYPES } from './catalog-scope.js'; + +export interface MetadataAreaHost { + /** + * The package the area is open on, or `null` in the cross-package leg, which + * serves the environment catalog (`?scope=environment`) with no package. + */ + packageId: string | null; + /** + * The open package is read-only (a code or installed package). Always + * `false` in the cross-package leg, whose writes are the environment scope's + * own gates (`catalog-scope.ts`), exactly as in Setup. + */ + readOnly: boolean; + /** The area's root route, `/studio//metadata`, without a trailing slash. */ + basePath: string; +} + +export const MetadataAreaHostContext = React.createContext(null); + +/** The area this page is mounted in, or `null` outside one (Setup). */ +export function useMetadataAreaHost(): MetadataAreaHost | null { + return React.useContext(MetadataAreaHostContext); +} + +/** + * The types the cross-package leg's directory lists: the environment catalog, + * each opened in the environment scope. The Setup catalog types with a switch, + * plus `capability`, the catalog with none (`SETUP_CATALOG_TYPES` says why it + * is absent there). + */ +export const ENVIRONMENT_AREA_TYPES: readonly string[] = [...SETUP_CATALOG_TYPES, 'capability']; + +/** + * Setup's Datasources page, the route the Setup sidebar names. A datasource is + * runtime configuration, Setup's to administer (ADR-0146 D1), so the area's + * datasource tile and its datasource URLs open this page. + */ +export const SETUP_DATASOURCES_PATH = '/apps/setup/metadata/datasource'; diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx index 332846e337c..503dca6c221 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowUpRight, Boxes, Building2, Clock, Hammer, Lock, Plus, Loader2, Copy, Store } from 'lucide-react'; +import { ArrowUpRight, Boxes, Building2, Clock, Hammer, Library, Lock, Plus, Loader2, Copy, Store } from 'lucide-react'; import { toast } from 'sonner'; import { useRecentItems, type RecentNamedItem } from '../../context/RecentItemsProvider.js'; import { useRecentItemLabel } from '../../hooks/useRecentItemLabel.js'; @@ -30,6 +30,7 @@ import { isMarketplaceEnabled } from '../../runtime-config.js'; import { fetchPackages, duplicatePackage, isSpecPackageId, type PkgEntry } from './packages-io.js'; import { PackageIdInput } from './PackageIdInput.js'; import { studioOrgScopePath } from './studioScope.js'; +import { STUDIO_ALL_PACKAGES_SEGMENT, studioMetadataAreaPath } from './metadataArea.js'; import { STUDIO_NEW_PARAM, STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js'; import { componentRefToUrlSegments } from '../../services/componentRegistry.js'; @@ -334,6 +335,25 @@ export function BuilderLanding(): React.ReactElement { + {/* objectstack#28561 — the "All metadata" area's cross-package leg + * (ADR-0146 D1): the environment catalog, whose items have one name + * per deployment and may belong to no package (ADR-0131 D3). */} +
{/* objectui#11808 — a read-only package is not a dead end. ADR-0070 D2: 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 05b1c07fa63..a508967b67f 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -178,6 +178,7 @@ import { ArrowUpRight, FileQuestion, PackageX, + Library, type LucideIcon, } from 'lucide-react'; import { @@ -282,6 +283,8 @@ import { isStudioOrgScope, studioOrgScopePath, } from './studioScope.js'; +import { STUDIO_METADATA_AREA, studioMetadataAreaPath } from './metadataArea.js'; +import { StudioMetadataArea } from './StudioMetadataArea.js'; import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; import { useHomePath } from '../../hooks/useHomePath.js'; import { resolveSurface, findSurfaceInTree, isSameSurface, type NavNode, type Surface } from './navSurface.js'; @@ -439,6 +442,18 @@ const PILLARS: ReadonlyArray<{ key: string; label: string; Icon: LucideIcon }> = { key: 'interfaces', label: 'Interfaces', Icon: LayoutDashboard }, { key: 'access', label: 'Access', Icon: Shield }, ]; +/** + * objectstack ADR-0146 D1 (#28561) — the "All metadata" area joins the four + * pillars in the header: a package's type directory, lists, generic item page, + * history and diagnostics (`StudioMetadataArea`). Not a pillar: it designs no + * type of its own, and its URLs are the area's (`/studio//metadata/…`, + * `metadataArea.ts`), so `PILLARS` and the not-found check do not hold it. + */ +const METADATA_AREA_TAB: { key: string; label: string; Icon: LucideIcon } = { + key: STUDIO_METADATA_AREA, + label: 'All metadata', + Icon: Library, +}; /** * objectui#11357 — the body the Interfaces pillar saves for its open leaf. A * page goes through the `page` registration's `fromDraft`, the serialiser @@ -1512,9 +1527,15 @@ function PackageSwitcher({ export interface StudioDesignSurfaceProps { /** Open-core slot — the cloud edition injects its AI copilot panel here. */ aiSlot?: React.ReactNode; + /** + * objectstack#28561 — the surface is mounted on the "All metadata" area's + * route (`/studio/:packageId/metadata/*`), which has no `:tab` segment: the + * header marks the area and the body renders it. + */ + area?: typeof STUDIO_METADATA_AREA; } -export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React.ReactElement { +export function StudioDesignSurface({ aiSlot, area }: StudioDesignSurfaceProps): React.ReactElement { const params = useParams<{ packageId?: string; tab?: string }>(); // The route segment: a package id, or the package-less scope's reserved // segment (objectui#11553, `studioScope.ts`). URLs are built from it. @@ -1523,7 +1544,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // publish below keys on this value, so none of them can be handed the // reserved segment as though it named a package. const packageId: string | null = isStudioOrgScope(scopeSegment) ? null : scopeSegment; - const tab = params.tab ?? 'interfaces'; + const tab = area ?? params.tab ?? 'interfaces'; // objectstack#26708 — a segment no pillar has is a not-found state, never // the Interfaces pillar it used to fall through to. const pillarKnown = PILLARS.some((p) => p.key === tab); @@ -2363,10 +2384,11 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React From 94bb73af140342e1f8ff1feb0b1d47b666bb00da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:50:02 +0000 Subject: [PATCH 5/7] test(studio): the builder links and mounts the All metadata area The real surface and area on the console's two builder routes: the header links the area beside the pillars and marks it current on its route, a kernel package opens it read-only, a missing package has none, and the package-less scope offers no area link. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSErqBgsh8YbjzzqZ9 --- ...oDesignSurface.metadataArea-28561.test.tsx | 187 ++++++++++++++++++ ...ignSurface.packageLessFlows-11553.test.tsx | 2 + 2 files changed, 189 insertions(+) create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx new file mode 100644 index 00000000000..8a3e3740f14 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.metadataArea-28561.test.tsx @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — the "All metadata" area in the builder (ADR-0146 D1): + * the header links it beside the four pillars, and the builder's body is the + * area on its route (`/studio//metadata/*`), which the console mounts + * as ``. + * + * The surface and the area are real here, and so is the one package-list read + * both make (`GET /api/v1/packages`). The metadata-admin pages are stubs that + * print which page they are and the host the area handed them: what the pages + * do with the host is pinned beside them (`metadataAreaHost.*-28561.test.tsx`). + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { useMetadataAreaHost } from '../metadata-admin/metadataAreaHost'; + +const PACKAGE_ID = 'app.r34d'; +const KERNEL_ID = 'com.objectstack.platform'; +const PHANTOM = 'com.example.nosuch'; + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), dismiss: vi.fn() }, + Toaster: () => null, +})); + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async (_t: string, name: string) => ({ effective: { name } })), + getDraft: vi.fn(async () => null), + get: vi.fn(async () => undefined), + save: vi.fn(async () => ({})), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ loading: false, error: null, entries: [] }), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => ({}) }; +}); + +vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null })); +vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null })); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +function PageStub({ page }: { page: string }) { + const host = useMetadataAreaHost(); + return
; +} +vi.mock('../metadata-admin/index.js', () => ({ + MetadataDirectoryPage: () => , + MetadataDiagnosticsPage: () => , + MetadataResourceListPage: () => , + MetadataResourceEditPage: () => , + MetadataResourceHistoryPage: () => , +})); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { t } from '../metadata-admin/i18n'; + +const answer = (body: unknown) => ({ ok: true, status: 200, json: async () => body }); + +/** The package list as the server serves it: one writable app package and one kernel package. */ +function servePackages() { + const fetchMock = vi.fn(async (input: unknown) => { + if (String(input) !== '/api/v1/packages') return answer([]); + return answer({ + success: true, + data: { + packages: [ + { manifest: { id: PACKAGE_ID, name: 'Field Ops' }, writable: true }, + { manifest: { id: KERNEL_ID, name: 'Platform', scope: 'system' }, writable: false }, + ], + }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function Where() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +/** The console's two builder routes, as `StudioRoute.tsx` declares them. */ +function renderAt(path: string) { + render( + + + + } /> + } /> + } /> + + , + ); +} + +const where = () => screen.getByTestId('where').textContent; +const nav = () => within(screen.getByTestId('studio-pillar-nav')); +async function areaPage() { + const el = await screen.findByTestId('area-page'); + return { page: el.getAttribute('data-page'), host: JSON.parse(el.getAttribute('data-host') ?? 'null') }; +} + +beforeEach(() => { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('Studio: the "All metadata" area in the builder (objectstack#28561)', () => { + it('a pillar links the area beside the four pillars, not current', async () => { + servePackages(); + renderAt(`/studio/${PACKAGE_ID}/data`); + const link = await waitFor(() => nav().getByRole('link', { name: t('engine.studio.pillar.metadata', 'en') })); + expect(link).toHaveAttribute('href', `/studio/${PACKAGE_ID}/metadata`); + expect(link).not.toHaveAttribute('aria-current'); + expect(nav().getByRole('link', { current: 'page' })).toHaveAttribute('href', `/studio/${PACKAGE_ID}/data`); + }); + + it('the area\'s route: the area is current, no pillar is, and its body is the package\'s directory', async () => { + const fetchMock = servePackages(); + renderAt(`/studio/${PACKAGE_ID}/metadata`); + expect(await areaPage()).toEqual({ + page: 'directory', + host: { packageId: PACKAGE_ID, readOnly: false, basePath: `/studio/${PACKAGE_ID}/metadata` }, + }); + expect(where()).toBe(`/studio/${PACKAGE_ID}/metadata?package=${PACKAGE_ID}`); + const current = nav().getByRole('link', { current: 'page' }); + expect(current).toHaveAttribute('href', `/studio/${PACKAGE_ID}/metadata`); + // The builder's header stays: the package chip, its pillars, the area. + expect(nav().getAllByRole('link')).toHaveLength(5); + expect(screen.queryByTestId('studio-pillar-not-found')).toBeNull(); + // The surface and the area share the one package-list read. + expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/packages')).toHaveLength(1); + }); + + it('the pages below the area\'s root are the area\'s too', async () => { + servePackages(); + renderAt(`/studio/${PACKAGE_ID}/metadata/translation?package=${PACKAGE_ID}`); + expect((await areaPage()).page).toBe('list'); + expect(nav().getByRole('link', { current: 'page' })).toHaveAttribute('href', `/studio/${PACKAGE_ID}/metadata`); + }); + + it('a kernel package the switcher hides opens its area read-only', async () => { + servePackages(); + renderAt(`/studio/${KERNEL_ID}/metadata/translation/t1?package=${KERNEL_ID}`); + expect(await areaPage()).toEqual({ + page: 'item', + host: { packageId: KERNEL_ID, readOnly: true, basePath: `/studio/${KERNEL_ID}/metadata` }, + }); + }); + + it('a package that is not there has no area either', async () => { + servePackages(); + renderAt(`/studio/${PHANTOM}/metadata`); + expect(await screen.findByTestId('studio-package-not-found')).toBeInTheDocument(); + expect(nav().queryAllByRole('link')).toHaveLength(0); + expect(screen.queryByTestId('studio-metadata-area')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx index 95d277ff703..446bf7174fc 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx @@ -269,6 +269,8 @@ describe('the package-less scope lists a package-less flow and opens it editable expect(screen.queryByRole('link', { name: /Interfaces/ })).not.toBeInTheDocument(); // objectui#12452 — Access is a package pillar segment now, not a More entry. expect(screen.queryByRole('link', { name: /Access/ })).not.toBeInTheDocument(); + // objectstack#28561 — nor the "All metadata" area: a package's, beside its pillars. + expect(screen.queryByRole('link', { name: /All metadata/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Create app/ })).not.toBeInTheDocument(); expect(chatDock.mounts).toBe(0); expect(screen.getByTestId('studio-pkg-chip')).toHaveTextContent('Organization flows'); From f170c68b5eaaa53ff0c22ab4aa8f7489af9d3c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:52:22 +0000 Subject: [PATCH 6/7] test(console): the Studio routes reach the All metadata area on both legs On the real studioRoutes: a package's area is the pillar builder with its area open, the cross-package leg is the area alone in the landing's frame, every other ~all URL lands on it, and the entry gate refuses both legs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSErqBgsh8YbjzzqZ9 --- .../StudioRoute.metadataArea-28561.test.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 ui/apps/console/src/components/StudioRoute.metadataArea-28561.test.tsx diff --git a/ui/apps/console/src/components/StudioRoute.metadataArea-28561.test.tsx b/ui/apps/console/src/components/StudioRoute.metadataArea-28561.test.tsx new file mode 100644 index 00000000000..9fd9f5d872b --- /dev/null +++ b/ui/apps/console/src/components/StudioRoute.metadataArea-28561.test.tsx @@ -0,0 +1,174 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — the "All metadata" area's routes in the `/studio` + * subtree (ADR-0146 D1), on the REAL `studioRoutes` the console mounts. + * + * - A package's area (`/studio//metadata/*`) is the pillar builder + * with its area open: the surface draws the header and mounts the area, so + * the route hands it `area="metadata"`. `:packageId/:tab` matches one + * segment, so without this route an area URL such as + * `/studio/crm/metadata/flow` matched nothing. + * - The cross-package leg (`/studio/~all/metadata/*`) is the area alone, with + * no package, in the frame the landing draws. Every other `~all` URL lands + * on it, so none reaches the pillar builder as a package id. + * - The entry gate decides first, as for every Studio URL. + * + * The builder module is mocked with markers (as in + * `StudioRoute.lazyBuilder-11798.test.tsx`); the gate, the frame and the + * router are real. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, useLocation, useParams } from 'react-router-dom'; + +let auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } as unknown }; + +let permissionsFetch: (input: unknown, init?: unknown) => Promise = async () => + jsonResponse({ authenticated: true, systemPermissions: [] }); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + } as unknown as Response; +} + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, + createAuthenticatedFetch: () => (input: unknown, init?: unknown) => permissionsFetch(input, init), +})); + +// `AuthGuard` reads `useAuth` through the auth package's own module graph; the +// seam `StudioRoute.test.tsx` documents. +vi.mock('../../../../packages/auth/src/useAuth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +// The real barrel's surface is inherited (`check:vi-mock-inherit`); the route +// constants (`STUDIO_ALL_PACKAGES_SEGMENT`, `STUDIO_METADATA_AREA`, +// `studioMetadataAreaPath`) are the real ones. The barrel's own builder +// components are overridden with a marker no case looks for: the route reaches +// them only through `./studioBuilder`. +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + BuilderLanding: () =>
, + StudioDesignSurface: () =>
, + StudioMetadataArea: () =>
, + ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}, + RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}, + RedirectWithSplash: () =>
, + LoadingFallback: () =>
, + LoadingScreen: () =>
, + getProductName: () => 'ObjectOS', + useHomePath: () => '/home', +})); + +function SurfaceMarker({ area }: { area?: string }) { + const { packageId, tab } = useParams(); + return ( +
+ ); +} + +vi.mock('./studioBuilder', () => ({ + BuilderLanding: () =>
, + StudioDesignSurface: SurfaceMarker, + StudioMetadataArea: ({ packageId }: { packageId: string | null }) => ( +
+ ), +})); + +import { STUDIO_ENTRY_CAPABILITY } from './studioEntry'; +import { studioRoutes } from './StudioRoute'; + +function LocationProbe() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +function renderStudio(url: string) { + return render( + + + + {studioRoutes} + home
} /> + + , + ); +} + +const answer = (systemPermissions: string[]) => async () => + jsonResponse({ authenticated: true, userId: 'u1', systemPermissions, objects: {}, fields: {} }); +const location = () => screen.getByTestId('pathname').textContent; + +beforeEach(() => { + auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } }; + permissionsFetch = answer([STUDIO_ENTRY_CAPABILITY]); + // The frame mounts the real `AppHeader`, whose feeds answer empty here. + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] }))); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('/studio — the "All metadata" area\'s routes (objectstack#28561)', () => { + it('a package\'s area is the pillar builder with its area open, the rest of the path its own', async () => { + renderStudio('/studio/com.example.crm/metadata/flow/f1/history?package=com.example.crm'); + const surface = await screen.findByTestId('studio-pillar-builder'); + expect(surface).toHaveAttribute('data-package', 'com.example.crm'); + expect(surface).toHaveAttribute('data-area', 'metadata'); + expect(location()).toBe('/studio/com.example.crm/metadata/flow/f1/history?package=com.example.crm'); + expect(screen.queryByTestId('barrel-builder')).toBeNull(); + }); + + it('CONTROL — a pillar is the pillar builder with no area', async () => { + renderStudio('/studio/com.example.crm/data'); + const surface = await screen.findByTestId('studio-pillar-builder'); + expect(surface).toHaveAttribute('data-tab', 'data'); + expect(surface).toHaveAttribute('data-area', ''); + }); + + it('the cross-package leg is the area alone, with no package, in the landing\'s frame', async () => { + renderStudio('/studio/~all/metadata/permission?scope=environment'); + const area = await screen.findByTestId('studio-metadata-area'); + expect(area).toHaveAttribute('data-package', 'null'); + expect(screen.queryByTestId('studio-pillar-builder')).toBeNull(); + // The console's own top bar frames it; its brand links home. + expect(screen.getByRole('link', { name: 'ObjectOS home' })).toHaveAttribute('href', '/home'); + expect(location()).toBe('/studio/~all/metadata/permission?scope=environment'); + }); + + it.each(['/studio/~all', '/studio/~all/data', '/studio/~all/flow/f1'])( + '%s lands on the cross-package area, never on the builder as a package id', + async (url) => { + renderStudio(url); + expect(await screen.findByTestId('studio-metadata-area')).toBeInTheDocument(); + expect(location()).toBe('/studio/~all/metadata'); + expect(screen.queryByTestId('studio-pillar-builder')).toBeNull(); + }, + ); + + it.each(['/studio/~all/metadata', '/studio/com.example.crm/metadata'])( + 'a principal the gate refuses gets neither leg: %s', + async (url) => { + permissionsFetch = answer(['setup.access']); + renderStudio(url); + expect(await screen.findByTestId('studio-access-required')).toBeInTheDocument(); + expect(screen.queryByTestId('studio-metadata-area')).toBeNull(); + expect(screen.queryByTestId('studio-pillar-builder')).toBeNull(); + // The refusal is said where the caller asked. + expect(location()).toBe(url); + }, + ); +}); From ea13af0c9e9f121d53156d2c809a19bf791cacd6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:23:13 +0000 Subject: [PATCH 7/7] chore(changeset): the Studio All metadata area Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSErqBgsh8YbjzzqZ9 --- .changeset/28561-studio-all-metadata-area.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/28561-studio-all-metadata-area.md diff --git a/.changeset/28561-studio-all-metadata-area.md b/.changeset/28561-studio-all-metadata-area.md new file mode 100644 index 00000000000..cfa35f7be4d --- /dev/null +++ b/.changeset/28561-studio-all-metadata-area.md @@ -0,0 +1,19 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +feat(studio): an "All metadata" area in the builder, per package and across packages; a pillar type opened there opens its pillar + +Clause-②: no +Metadata-protocol: none + +Studio owns all metadata (ADR-0146 D1), and until now only Setup listed every type. The builder gains an "All metadata" area beside its four pillars. It is the metadata-admin pages Setup mounts (the type directory, the lists, the generic item page, history and diagnostics), mounted, not copied. + +- **Per package.** `/studio//metadata/…` is the open package's area, with the same route shapes Setup has below `/apps/setup/metadata`. The header links it beside the pillars, and names it from 1280 px. The URL keeps `?package=` for the area's package, which the item page binds its saves to, so a writable package saves exactly as Setup does with `?package=`. There is no new write path. +- **Across packages.** `/studio/~all/metadata/…` serves the environment catalog (position, capability, permission, sharing_rule) with `?scope=environment` and no package. The landing's "Not in a package" row opens it. `~all` is a reserved segment, so no package id spells it, and it is not the package-less flows scope (`~org`). +- **Query kept.** `?package=`, `?scope=environment` and `?review=1` mean what they mean in Setup. A `?package=` naming another package re-roots onto that package. `?scope=environment` and `?package=all` move to the cross-package leg. `?review=1` opens the generic item page and its review. +- **Pillar types open their pillar.** An item opened from the area opens its pillar with `?surface=`: object → Data, flow → Automations, page, dashboard, report and view → Interfaces, permission → Access. Every other type opens the generic item page. Interfaces now opens a page, dashboard, report or view that no nav entry opens by its `?surface=` link, instead of the first nav leaf. +- **Read-only package.** On a package `GET /api/v1/packages` lists as not writable (a kernel package the switcher hides included), the item page is read-only and says the package is why. Layers, references, history, audit and source stay. No rollback, source save, reset, delete, create or "Customize with an overlay" save is offered. A package the list cannot answer for stays ungated, as on the pillars. +- **Setup's parts stay Setup's.** In the area, a position's holders and a permission set's assignments are a link to their Setup page and are not drawn there. The datasource tile and any datasource URL open Setup's Datasources page. Setup's own pages are unchanged.