Skip to content
Merged
19 changes: 19 additions & 0 deletions .changeset/28561-studio-all-metadata-area.md
Original file line number Diff line number Diff line change
@@ -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/<package>/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.
174 changes: 174 additions & 0 deletions ui/apps/console/src/components/StudioRoute.metadataArea-28561.test.tsx
Original file line number Diff line number Diff line change
@@ -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/<package>/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<Response> = 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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
BuilderLanding: () => <div data-testid="barrel-builder" />,
StudioDesignSurface: () => <div data-testid="barrel-builder" />,
StudioMetadataArea: () => <div data-testid="barrel-builder" />,
ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
RedirectWithSplash: () => <div data-testid="redirect" />,
LoadingFallback: () => <div data-testid="loading" />,
LoadingScreen: () => <div data-testid="error-screen" />,
getProductName: () => 'ObjectOS',
useHomePath: () => '/home',
}));

function SurfaceMarker({ area }: { area?: string }) {
const { packageId, tab } = useParams();
return (
<div data-testid="studio-pillar-builder" data-package={packageId} data-tab={tab ?? ''} data-area={area ?? ''} />
);
}

vi.mock('./studioBuilder', () => ({
BuilderLanding: () => <div data-testid="studio-front-door" />,
StudioDesignSurface: SurfaceMarker,
StudioMetadataArea: ({ packageId }: { packageId: string | null }) => (
<div data-testid="studio-metadata-area" data-package={String(packageId)} />
),
}));

import { STUDIO_ENTRY_CAPABILITY } from './studioEntry';
import { studioRoutes } from './StudioRoute';

function LocationProbe() {
const { pathname, search } = useLocation();
return <div data-testid="pathname">{`${pathname}${search}`}</div>;
}

function renderStudio(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<LocationProbe />
<Routes>
{studioRoutes}
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
</Routes>
</MemoryRouter>,
);
}

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);
},
);
});
76 changes: 66 additions & 10 deletions ui/apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -205,6 +211,19 @@ export function StudioBuilderLoading() {
* (objectui#11912), and `⌘K` opens it.
*/
function StudioLanding() {
return (
<StudioConsoleFrame>
<BuilderLanding />
</StudioConsoleFrame>
);
}

/**
* 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 (
<CommandPaletteProvider>
<div className="flex min-h-screen flex-col bg-background text-foreground">
Expand All @@ -217,16 +236,29 @@ function StudioLanding() {
</header>
<CommandPalette scope="studio" />
<div className="min-h-0 flex-1 overflow-auto">
{/* The frame above stays painted while the landing's chunk loads. */}
<Suspense fallback={<StudioBuilderLoading />}>
<BuilderLanding />
</Suspense>
{/* The frame above stays painted while the body's chunk loads. */}
<Suspense fallback={<StudioBuilderLoading />}>{children}</Suspense>
</div>
</div>
</CommandPaletteProvider>
);
}

/**
* 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 (
<StudioConsoleFrame>
<StudioMetadataArea packageId={null} />
</StudioConsoleFrame>
);
}

/**
* 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
Expand All @@ -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(() => {
Expand All @@ -259,7 +291,7 @@ function StudioPackageBuilder() {
}, [packageId]);
return (
<Suspense fallback={<LoadingFallback />}>
<StudioDesignSurface />
<StudioDesignSurface area={area} />
</Suspense>
);
}
Expand All @@ -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
Expand All @@ -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 = (
<Route path="/studio" element={<StudioRoute />}>
Expand All @@ -293,7 +337,19 @@ export const studioRoutes = (
path={STUDIO_ORG_SCOPE_SEGMENT}
element={<Navigate to={STUDIO_ORG_SCOPE_PILLAR} replace />}
/>
<Route
path={`${STUDIO_ALL_PACKAGES_SEGMENT}/${STUDIO_METADATA_AREA}/*`}
element={<StudioAllPackagesMetadata />}
/>
<Route
path={`${STUDIO_ALL_PACKAGES_SEGMENT}/*`}
element={<Navigate to={studioMetadataAreaPath(STUDIO_ALL_PACKAGES_SEGMENT)} replace />}
/>
<Route path=":packageId" element={<Navigate to="data" replace />} />
<Route
path={`:packageId/${STUDIO_METADATA_AREA}/*`}
element={<StudioPackageBuilder area={STUDIO_METADATA_AREA} />}
/>
<Route path=":packageId/:tab" element={<StudioPackageBuilder />} />
</Route>
);
7 changes: 4 additions & 3 deletions ui/apps/console/src/components/studioBuilder.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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';
10 changes: 10 additions & 0 deletions ui/packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package>/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
Expand Down
Loading
Loading