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. 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); + }, + ); +}); 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..fcc299f3729 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, locale]); 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.gates-28561.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.gates-28561.test.tsx new file mode 100644 index 00000000000..c09a978e92f --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.gates-28561.test.tsx @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — what the metadata-admin item pages do under Studio's + * "All metadata" area (ADR-0146 D1, D7), each against the same page with no + * area around it, which is Setup's rendering and must not move. + * + * - On a read-only package the generic item page is read-only: no edit, save, + * reset or delete, and the package is the reason it names. A `translation` + * shipped by a code package is the case that matters: the type accepts an + * environment overlay, so Setup offers the overlay save, and the server + * would accept it. On a writable package the page offers what Setup offers. + * - The routed history page offers no rollback on a read-only package. + * - The permission-set editor is read-only on a read-only package, and in + * the environment scope it links Setup's assignment page instead of + * drawing the holders. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost'; + +const TRANSLATION_BODY = { name: 'ui_en', locale: 'en', messages: { hello: 'Hello' } }; + +/** A translation a code package ships, with an environment overlay on it. */ +const TRANSLATION_LAYERED = { + code: { ...TRANSLATION_BODY, _packageId: 'com.vendor.hr', _provenance: 'package' }, + overlay: { ...TRANSLATION_BODY, messages: { hello: 'Hi' } }, + overlayScope: 'org', + effective: { ...TRANSLATION_BODY, messages: { hello: 'Hi' } }, + provenance: 'package', + packageId: 'com.vendor.hr', + editable: true, + deletable: true, +}; + +const PERMISSION_SET = { name: 'sales_rep', label: 'Sales Rep', objects: { crm_case: { allowRead: true } }, fields: {} }; + +const client = { + list: vi.fn(async (type: string) => + type === 'object' ? [{ item: { name: 'crm_case' } }] : [], + ), + listDrafts: vi.fn(async () => []), + get: vi.fn(async (type: string) => (type === 'object' ? { fields: [] } : null)), + getDraft: vi.fn(async () => null), + references: vi.fn(async () => []), + history: vi.fn(async () => ({ events: [{ seq: 2, op: 'update', version: 2 }, { seq: 1, op: 'create', version: 1 }] })), + layered: vi.fn(async (type: string) => + type === 'permission' + ? { effective: PERMISSION_SET, code: null, overlay: null, overlayScope: null } + : TRANSLATION_LAYERED, + ), + save: vi.fn(async () => ({})), +}; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => client, + useMetadataTypes: () => ({ + loading: false, + error: null, + entries: [ + { type: 'translation', label: 'Translation', domain: 'ui', allowEnvironmentOverlay: true, allowRuntimeCreate: true }, + { type: 'permission', label: 'Permission', domain: 'security', allowEnvironmentOverlay: false, allowRuntimeCreate: true }, + ], + }), +})); +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); +vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useCanAuthorMetadata: () => true, + useCanReadAuthoringTrail: () => true, +})); +vi.mock('./AssignedUsersSection', () => ({ + AssignedUsersSection: () =>
, +})); +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + CapabilityMultiSelectField: () =>
, + parseCapabilityNames: (v: unknown) => (typeof v === 'string' ? JSON.parse(v) : []), +})); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { MetadataResourceHistoryPage } from '../item-tools/ResourceHistoryPage'; +import { PermissionMatrixEditPage } from './PermissionMatrixEditor'; +import { t } from './i18n'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const READ_ONLY_HOST: MetadataAreaHost = { packageId: 'com.vendor.hr', readOnly: true, basePath: '/studio/com.vendor.hr/metadata' }; +const WRITABLE_HOST: MetadataAreaHost = { packageId: 'com.example.crm', readOnly: false, basePath: '/studio/com.example.crm/metadata' }; + +function underHost(host: MetadataAreaHost | null, url: string, node: React.ReactNode) { + const tree = {node}; + return render(host ? {tree} : tree); +} + +async function openTranslation(host: MetadataAreaHost | null) { + const pkg = host?.packageId ?? 'com.vendor.hr'; + const base = host?.basePath ?? '/apps/setup/metadata'; + underHost(host, `${base}/translation/ui_en?package=${pkg}`, ); + await waitFor(() => expect(client.layered).toHaveBeenCalled()); +} + +const editButtons = () => screen.queryAllByRole('button', { name: /^Edit$/ }); + +describe('the generic item page under the area (objectstack#28561)', () => { + it('Setup (no area): the code-shipped translation offers its overlay edit and reset — the control', async () => { + await openTranslation(null); + expect((await screen.findAllByRole('button', { name: /^Edit$/ })).length).toBeGreaterThan(0); + expect(screen.getByTestId('reset-or-delete-button')).toBeInTheDocument(); + expect(screen.queryByTestId('package-readonly-banner')).toBeNull(); + }); + + it('a writable package: the same doors as Setup', async () => { + await openTranslation(WRITABLE_HOST); + expect((await screen.findAllByRole('button', { name: /^Edit$/ })).length).toBeGreaterThan(0); + expect(screen.getByTestId('reset-or-delete-button')).toBeInTheDocument(); + expect(screen.queryByTestId('package-readonly-banner')).toBeNull(); + }); + + it('a read-only package: read-only, no overlay save, no reset or delete, and the package named as the reason', async () => { + await openTranslation(READ_ONLY_HOST); + const banner = await screen.findByTestId('package-readonly-banner'); + expect(banner).toHaveTextContent(t('engine.studio.pkg.readonlyHint', 'en-US')); + expect(editButtons()).toHaveLength(0); + expect(screen.queryByRole('button', { name: /save/i })).toBeNull(); + expect(screen.queryByTestId('reset-or-delete-button')).toBeNull(); + // The type-level banner would name the wrong reason (a registry flag). + expect(screen.queryByTestId('readonly-banner')).toBeNull(); + // Layers, references, history and audit stay: the read tools are not writes. + expect(screen.getByTestId('page-shell-header-meta')).toBeInTheDocument(); + }); + + it('the header badge reads the package gate under a read-only package', async () => { + await openTranslation(READ_ONLY_HOST); + await screen.findByTestId('package-readonly-banner'); + const badge = screen.getByText('read-only'); + expect(badge).toHaveAttribute('title', t('engine.studio.pkg.readonlyHint', 'en-US')); + }); +}); + +describe('the routed history page under the area', () => { + const rollbacks = () => screen.queryAllByTitle('Rollback to this version'); + + it('Setup and a writable package offer rollback', async () => { + underHost(null, '/apps/setup/metadata/translation/ui_en/history', ); + await waitFor(() => expect(rollbacks().length).toBe(2)); + cleanup(); + underHost(WRITABLE_HOST, `${WRITABLE_HOST.basePath}/translation/ui_en/history`, ); + await waitFor(() => expect(rollbacks().length).toBe(2)); + }); + + it('a read-only package offers none, and still draws the timeline', async () => { + underHost(READ_ONLY_HOST, `${READ_ONLY_HOST.basePath}/translation/ui_en/history`, ); + await waitFor(() => expect(screen.getAllByRole('listitem')).toHaveLength(2)); + expect(rollbacks()).toHaveLength(0); + }); +}); + +describe('the permission-set editor under the area', () => { + async function openSet(host: MetadataAreaHost | null, search: string) { + const base = host?.basePath ?? '/apps/setup/metadata'; + underHost(host, `${base}/permission/sales_rep${search}`, ); + await screen.findByText('crm_case'); + } + + it('Setup\'s environment scope draws the holders — the control', async () => { + await openSet(null, '?scope=environment'); + expect(screen.getByTestId('perm-assignments')).toBeInTheDocument(); + expect(screen.queryByTestId('setup-assignments-link-permission')).toBeNull(); + }); + + it('the cross-package leg links Setup\'s assignment page, opened on the set, and draws no holders', async () => { + await openSet({ packageId: null, readOnly: false, basePath: '/studio/~all/metadata' }, '?scope=environment'); + expect(screen.queryByTestId('perm-assignments')).toBeNull(); + expect(screen.queryByTestId('assigned-users-stub')).toBeNull(); + const link = screen.getByTestId('setup-assignments-link-permission').querySelector('a'); + expect(link).toHaveAttribute('href', '/apps/setup/component/security/permission-set-assignment?set=sales_rep'); + }); + + it('a read-only package: no Save', async () => { + await openSet(WRITABLE_HOST, ''); + expect(screen.getByRole('button', { name: /^Save$/ })).toBeInTheDocument(); + cleanup(); + await openSet(READ_ONLY_HOST, ''); + expect(screen.queryByRole('button', { name: /^Save$/ })).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.scope-28561.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.scope-28561.test.tsx new file mode 100644 index 00000000000..190f23a201e --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.scope-28561.test.tsx @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — the directory and the list under Studio's "All metadata" + * area (ADR-0146 D1), each against the same page with no area around it, + * which is Setup's rendering and must not move. + * + * - A package's area is scoped to that package: the pages neither pick nor + * repair a `?package=` of their own, and a read-only package offers no + * create. + * - The cross-package leg lists the environment catalog, each type opened in + * the environment scope, and its permission list is the registry's whole + * list, as Setup's catalog is. + * - The datasource tile opens Setup's Datasources page. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +// The switch column waits on `audienceAnchorsOf`'s dynamic import of this exact +// specifier (see `ResourceListPage.environmentScope-7611.test.tsx`). +import '@objectstack/spec/identity'; +import { MetadataAreaHostContext, type MetadataAreaHost } from './metadataAreaHost'; + +const PROJECT = 'com.example.crm'; +const OTHER = 'com.example.hr'; +const PLATFORM = 'com.objectstack.plugin-security'; + +const PERMISSION_SETS = [ + { name: 'admin_full_access', label: 'Administrator', _packageId: PLATFORM, _provenance: 'package' }, + { name: 'crm_rep', label: 'CRM Rep', _packageId: PROJECT, _provenance: 'package' }, + { name: 'hr_rep', label: 'HR Rep', _packageId: OTHER, _provenance: 'package' }, + { name: 'env_authored_set', label: 'Environment Set' }, +]; + +const CLIENT = { + list: async (type: string) => + type === 'package' + ? [ + { manifest: { id: OTHER, scope: 'project', name: 'HR' } }, + { manifest: { id: PROJECT, scope: 'project', name: 'CRM' } }, + ] + : type === 'permission' + ? PERMISSION_SETS + : [], +}; + +const entry = (type: string, domain: string, write = true) => ({ + type, + label: type, + domain, + allowEnvironmentOverlay: false, + allowRuntimeCreate: write, +}); +const ENTRIES = [ + entry('object', 'data'), + entry('flow', 'automation'), + entry('translation', 'ui'), + entry('datasource', 'system'), + entry('permission', 'security'), + entry('position', 'security'), + entry('sharing_rule', 'security'), + entry('capability', 'security', false), +]; + +vi.mock('./useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => CLIENT, + useMetadataTypes: () => ({ loading: false, error: null, entries: ENTRIES }), + // Each package contributes every type; the cross-package sweep sees them all. + useGlobalDiagnostics: () => ({ + loading: false, + error: null, + summary: { entries: [], total: 0, scannedTypes: 0, scannedItems: 0 }, + byType: {}, + warnByType: {}, + countsByType: {}, + lockedByType: {}, + packagesByType: Object.fromEntries(ENTRIES.map((e) => [e.type, [OTHER, PROJECT]])), + allPackages: [OTHER, PROJECT], + reload: () => {}, + }), +})); +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); +vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useCanAuthorMetadata: () => true, +})); + +import { MetadataDirectoryPage } from './DirectoryPage'; +import { MetadataResourceListPage } from './ResourceListPage'; +import { t } from './i18n'; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + if (String(url).startsWith('/api/v1/data/sys_metadata_activation')) { + return json(200, { object: 'sys_metadata_activation', records: [], hasMore: false }); + } + return json(404, { success: false }); + }), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const crmArea = (readOnly = false): MetadataAreaHost => ({ + packageId: PROJECT, + readOnly, + basePath: `/studio/${PROJECT}/metadata`, +}); +const ALL_LEG: MetadataAreaHost = { packageId: null, readOnly: false, basePath: '/studio/~all/metadata' }; + +function Where() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +/** Mounted on its route, so the page's relative links resolve as they do in the app. */ +function mount(host: MetadataAreaHost | null, url: string, node: React.ReactNode) { + const pathname = url.split('?')[0]; + const tree = ( + + + + + + + ); + return render(host ? {tree} : tree); +} + +/** The directory tile for one type, by the machine name it prints. */ +const tile = async (type: string) => + (await screen.findByText(type, { selector: 'code' })).closest('a') as HTMLAnchorElement; +const where = () => screen.getByTestId('where').textContent; + +describe('the directory under the area (objectstack#28561)', () => { + it('Setup (no area) repairs `?package=` to the first project package — the control', async () => { + mount(null, `/apps/setup/metadata?package=${OTHER}`, ); + await tile('flow'); + // A valid `?package=` is kept; the tiles carry it, the datasource tile included. + expect(where()).toBe(`/apps/setup/metadata?package=${OTHER}`); + expect((await tile('datasource')).getAttribute('href')).toBe(`/apps/setup/metadata/datasource?package=${OTHER}`); + cleanup(); + mount(null, '/apps/setup/metadata', ); + await tile('flow'); + await waitFor(() => expect(where()).toBe(`/apps/setup/metadata?package=${PROJECT}`)); + }); + + it('a package\'s area is that package\'s: no repair, every tile scoped to it', async () => { + mount(crmArea(), `/studio/${PROJECT}/metadata?package=${PROJECT}`, ); + expect((await tile('flow')).getAttribute('href')).toBe(`/studio/${PROJECT}/metadata/flow?package=${PROJECT}`); + expect(where()).toBe(`/studio/${PROJECT}/metadata?package=${PROJECT}`); + // The environment catalog types are a package's too, where it ships them. + expect(await tile('permission')).toBeInTheDocument(); + }); + + it('a package Setup\'s scope would not keep (a kernel package) stays the area\'s package', async () => { + const kernel: MetadataAreaHost = { packageId: PLATFORM, readOnly: true, basePath: `/studio/${PLATFORM}/metadata` }; + mount(kernel, `/studio/${PLATFORM}/metadata?package=${PLATFORM}`, ); + // Setup would repair this URL onto a project package; the area keeps its own. + expect(await screen.findByText(t('engine.directory.noMatches', 'en-US'))).toBeInTheDocument(); + expect(where()).toBe(`/studio/${PLATFORM}/metadata?package=${PLATFORM}`); + }); + + it('the datasource tile opens Setup\'s Datasources page', async () => { + mount(crmArea(), `/studio/${PROJECT}/metadata?package=${PROJECT}`, ); + expect((await tile('datasource')).getAttribute('href')).toBe('/apps/setup/metadata/datasource'); + }); + + it('the cross-package leg lists the environment catalog, each in the environment scope', async () => { + mount(ALL_LEG, '/studio/~all/metadata', ); + for (const type of ['position', 'capability', 'permission', 'sharing_rule']) { + expect((await tile(type)).getAttribute('href')).toBe(`/studio/~all/metadata/${type}?scope=environment`); + } + for (const type of ['object', 'flow', 'translation', 'datasource']) { + expect(screen.queryByText(type, { selector: 'code' })).toBeNull(); + } + expect(where()).toBe('/studio/~all/metadata'); + }); +}); + +describe('the list under the area', () => { + const rowLink = (container: HTMLElement, name: string) => container.querySelector(`a[href*="/${name}"]`); + const createButton = () => screen.queryByRole('button', { name: new RegExp(t('engine.list.create', 'en-US')) }); + + it('a package\'s area lists that package\'s rows, with no repair, and offers create', async () => { + const { container } = mount(crmArea(), `/studio/${PROJECT}/metadata/permission?package=${PROJECT}`, ); + await waitFor(() => expect(rowLink(container, 'crm_rep')).not.toBeNull()); + expect(rowLink(container, 'hr_rep')).toBeNull(); + expect(rowLink(container, 'admin_full_access')).toBeNull(); + expect(rowLink(container, 'crm_rep')?.getAttribute('href')).toBe( + `/studio/${PROJECT}/metadata/permission/crm_rep?package=${PROJECT}`, + ); + expect(where()).toBe(`/studio/${PROJECT}/metadata/permission?package=${PROJECT}`); + expect(createButton()).toBeInTheDocument(); + }); + + it('a package Setup\'s scope would not keep (a kernel package) lists its own rows, unrepaired', async () => { + const kernel: MetadataAreaHost = { packageId: PLATFORM, readOnly: true, basePath: `/studio/${PLATFORM}/metadata` }; + const { container } = mount(kernel, `/studio/${PLATFORM}/metadata/permission?package=${PLATFORM}`, ); + await waitFor(() => expect(rowLink(container, 'admin_full_access')).not.toBeNull()); + expect(rowLink(container, 'crm_rep')).toBeNull(); + expect(where()).toBe(`/studio/${PLATFORM}/metadata/permission?package=${PLATFORM}`); + expect(createButton()).toBeNull(); + }); + + it('a read-only package offers no create', async () => { + const { container } = mount(crmArea(true), `/studio/${PROJECT}/metadata/permission?package=${PROJECT}`, ); + await waitFor(() => expect(rowLink(container, 'crm_rep')).not.toBeNull()); + expect(createButton()).toBeNull(); + }); + + it('the cross-package leg: the environment-scope permission list is the registry\'s whole list', async () => { + const { container } = mount(ALL_LEG, '/studio/~all/metadata/permission?scope=environment', ); + for (const name of ['admin_full_access', 'crm_rep', 'hr_rep', 'env_authored_set']) { + await waitFor(() => expect(rowLink(container, name)).not.toBeNull()); + expect(rowLink(container, name)?.getAttribute('href')).toBe(`/studio/~all/metadata/permission/${name}?scope=environment`); + } + expect(where()).toBe('/studio/~all/metadata/permission?scope=environment'); + }); +}); 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/InterfacesPillar.offNavDeepLink-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx new file mode 100644 index 00000000000..443ad1124e9 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx @@ -0,0 +1,172 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — Studio's "All metadata" area opens a page, dashboard, + * report or view in the Interfaces pillar by URL (`?surface=:`, + * ADR-0146 D1: "a pillar type opened from the directory opens its pillar"). + * + * The pillar resolved a `?surface=` deep link through its nav tree alone, so + * an item no nav entry opens (objectstack#26889's off-nav items) fell back to + * the first leaf: the link landed in the right pillar on the wrong item. These + * cases pin that it now opens the item from the package's own lists, with or + * without an app, and still falls back to the first leaf for a name the + * package does not hold. The harness is objectstack#26889's. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; + +const PKG = 'com.acme.crm'; + +const NAV = [ + { id: 'nav_home', type: 'page', label: 'Home menu', pageName: 'home' }, + { id: 'nav_ops', type: 'dashboard', label: 'Ops board', dashboardName: 'ops' }, + { id: 'nav_my_leads', type: 'object', label: 'My leads', objectName: 'crm_lead', viewName: 'mine' }, +]; + +/** What the mocked server holds, per metadata type: published rows and `_drafts` headers. */ +const server = vi.hoisted(() => ({ + published: new Map>>(), + drafts: new Map>>(), +})); + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string) => server.published.get(type) ?? []), + listDrafts: vi.fn(async (opts?: { type?: string }) => server.drafts.get(opts?.type ?? '') ?? []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + const eff = (server.published.get(type) ?? []).find((row) => row.name === name) ?? null; + return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); + +// The `view` resource's list filter (the aggregated container is no item) is +// registered here, as the console registers it. +import '../../services/builtinComponents'; +import { InterfacesPillar } from './StudioDesignSurface'; +import { registerMetadataPreview, type MetadataPreviewProps } from '../metadata-admin/preview-registry'; + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })), +); + +/** A canvas that says what it was opened on, and whether it may write. */ +function StubPreview({ type, name, onPatch }: MetadataPreviewProps) { + return ( +
+ {`${type}:${name}`} +
+ ); +} +for (const type of ['page', 'dashboard', 'report', 'view']) registerMetadataPreview(type, StubPreview); + +const page = (name: string, label: string, extra: Record = {}) => ({ name, label, type: 'app', ...extra }); + +function seedWithApp() { + server.published.set('app', [{ name: 'crm_app', label: 'CRM', navigation: NAV }]); + server.published.set('page', [page('home', 'Home'), page('lead_detail', 'Lead Detail', { type: 'record', object: 'crm_lead' })]); + server.published.set('dashboard', [ + { name: 'ops', label: 'Ops' }, + { name: 'sales_overview', label: 'Sales Overview' }, + ]); + server.published.set('report', [{ name: 'pipeline', label: 'Pipeline' }]); + server.published.set('view', [ + { name: 'crm_lead.mine', label: 'Mine', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, + { name: 'crm_lead.hot', label: 'Hot Leads', object: 'crm_lead', viewKind: 'list', config: { type: 'grid', columns: ['name'] } }, + // The container the server keeps beside the expanded items: no item of its own. + { name: 'crm_lead', object: 'crm_lead', listViews: { hot: { columns: ['name'] } } }, + ]); + server.drafts.set('page', [{ name: 'welcome', label: 'Welcome', packageId: PKG }]); +} + +function seedNoApp() { + server.published.set('dashboard', [{ name: 'service_overview', label: 'Service Overview' }]); + server.published.set('page', [page('case_detail', 'Case Detail', { type: 'record', object: 'crm_case' })]); +} + +beforeEach(() => { + server.published.clear(); + server.drafts.clear(); + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); +}); + +afterEach(cleanup); + +function Where() { + const { search } = useLocation(); + return
{search}
; +} + +function renderPillar(surface: string) { + render( + + + + , + ); +} + +const surfaceParam = () => new URLSearchParams(screen.getByTestId('where').textContent ?? '').get('surface'); + +const canvas = () => screen.findByTestId('stub-canvas', undefined, { timeout: 8000 }); + +describe('objectstack#28561 — Interfaces opens an off-nav item by `?surface=`', () => { + it('a view no nav entry opens: the view, not the first leaf', async () => { + seedWithApp(); + renderPillar('view:crm_lead.hot'); + expect(await canvas()).toHaveTextContent('view:crm_lead.hot'); + expect(surfaceParam()).toBe('view:crm_lead.hot'); + }); + + it('a report and a record page no entry opens', async () => { + seedWithApp(); + renderPillar('report:pipeline'); + expect(await canvas()).toHaveTextContent('report:pipeline'); + cleanup(); + renderPillar('page:lead_detail'); + expect(await canvas()).toHaveTextContent('page:lead_detail'); + }); + + it('a package with no app opens the dashboard the link names', async () => { + seedNoApp(); + renderPillar('dashboard:service_overview'); + expect(await canvas()).toHaveTextContent('dashboard:service_overview'); + expect(screen.queryByText('This package has no app yet')).toBeNull(); + }); + + it('CONTROL: an item a nav entry opens still opens through its entry', async () => { + seedWithApp(); + renderPillar('dashboard:ops'); + expect(await canvas()).toHaveTextContent('dashboard:ops'); + }); + + it('CONTROL: a name the package does not hold falls back to the first leaf', async () => { + seedWithApp(); + renderPillar('view:crm_lead.gone'); + expect(await canvas()).toHaveTextContent('page:home'); + await waitFor(() => expect(surfaceParam()).toBe('page:home')); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessSegment-12452.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessSegment-12452.test.tsx index 73b72ba3aa0..398b8808edb 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessSegment-12452.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessSegment-12452.test.tsx @@ -218,9 +218,10 @@ describe('Studio pillar nav: Access is the fourth segment, and More is gone (obj await screen.findByTestId('studio-shell-header'); const links = within(pillarNav()).getAllByRole('link'); - expect(links.map((a) => a.textContent)).toEqual([DATA, AUTOMATIONS, INTERFACES, ACCESS]); + // objectstack#28561 — the "All metadata" area follows the four pillars. + expect(links.map((a) => a.textContent)).toEqual([DATA, AUTOMATIONS, INTERFACES, ACCESS, t('engine.studio.pillar.metadata', 'en')]); expect(links.map((a) => a.getAttribute('href'))).toEqual( - ['data', 'automations', 'interfaces', 'access'].map((p) => `/studio/${PKG}/${p}`), + ['data', 'automations', 'interfaces', 'access', 'metadata'].map((p) => `/studio/${PKG}/${p}`), ); // No More trigger, under its old test id or any other name. expect(within(pillarNav()).queryAllByRole('button')).toEqual([]); 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.notFound-26708.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.notFound-26708.test.tsx index 2432143adac..d310d352bf0 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.notFound-26708.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.notFound-26708.test.tsx @@ -147,7 +147,8 @@ describe('Studio: a package id no package has renders "package not found" (objec expect(within(header).getByTestId('studio-pkg-chip')).toHaveAttribute('data-pkg-list-state', 'loaded'), ); expect(screen.queryByTestId('studio-package-not-found')).toBeNull(); - expect(within(screen.getByTestId('studio-pillar-nav')).getAllByRole('link')).toHaveLength(4); + // The four pillars and, objectstack#28561, the "All metadata" area. + expect(within(screen.getByTestId('studio-pillar-nav')).getAllByRole('link')).toHaveLength(5); }); it('CONTROL — a kernel package the switcher hides is installed, never "not found"', async () => { @@ -169,7 +170,7 @@ describe('Studio: a package id no package has renders "package not found" (objec .toHaveAttribute('data-pkg-list-state', 'failed'), ); expect(screen.queryByTestId('studio-package-not-found')).toBeNull(); - expect(within(screen.getByTestId('studio-pillar-nav')).getAllByRole('link')).toHaveLength(4); + expect(within(screen.getByTestId('studio-pillar-nav')).getAllByRole('link')).toHaveLength(5); }); }); @@ -186,6 +187,7 @@ describe('Studio: a pillar segment no pillar has renders a not-found state (obje `/studio/${PACKAGE_ID}/automations`, `/studio/${PACKAGE_ID}/interfaces`, `/studio/${PACKAGE_ID}/access`, + `/studio/${PACKAGE_ID}/metadata`, ]); for (const link of links) expect(link).not.toHaveAttribute('aria-current'); }); 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'); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx index fd84c7c69ff..14ed6b84a71 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.rightSlot-12725.test.tsx @@ -322,12 +322,17 @@ describe('Studio right slot: properties OR the copilot (objectui#12725)', () => it('the pillar names are visible from 1024 px (lg), icons below', async () => { await openDashboard(); const links = within(screen.getByTestId('studio-pillar-nav')).getAllByRole('link'); - expect(links).toHaveLength(4); - for (const link of links) { + // The four pillars, then (objectstack#28561) the "All metadata" area. + expect(links).toHaveLength(5); + for (const link of links.slice(0, 4)) { const name = link.querySelector('span') as HTMLElement; expect(name).toHaveClass('sr-only', 'lg:not-sr-only'); expect(name).not.toHaveClass('xl:not-sr-only'); } + // The area is named from 1280 px: the 1024 px row holds the four names. + const area = links[4].querySelector('span') as HTMLElement; + expect(area).toHaveClass('sr-only', 'xl:not-sr-only'); + expect(area).not.toHaveClass('lg:not-sr-only'); }); }); 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..ec2be499d9f 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 @@ -2554,6 +2580,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React } /> + ) : tab === STUDIO_METADATA_AREA ? ( + // objectstack#28561 — keyed by package like the pillars: every + // page under it holds that package's scope and gate. + ) : !pillarKnown ? ( ( + initialSurface && (OFF_NAV_TYPES as readonly string[]).includes(initialSurface.type) ? 'pending' : 'done', + ); + const offNavFallbackRef = React.useRef(null); // objectui#8219 — lift the open leaf's label to the dock (see the prop doc). // Keyed on the primitives, and the callback read through a ref, so neither a // re-created Surface object nor a non-memoized callback refires the report. @@ -4518,6 +4558,20 @@ export function InterfacesPillar({ }; }, [client, packageId, locale, publishNonce, draftNonce, metadataRefreshNonce]); + // objectstack#28561 — the off-nav deep link (see `offNavDeepLinkRef`), once + // both reads it waits on have answered: the app (or its absence), and this + // package's off-nav lists. + React.useEffect(() => { + if (offNavDeepLinkRef.current !== 'pending' || !initialSurface) return; + if (appStatus === 'loading' || packageItems?.packageId !== packageId) return; + offNavDeepLinkRef.current = 'done'; + const item = (packageItems.byType[initialSurface.type as OffNavType] ?? []).find( + (i) => i.name === initialSurface.name, + ); + const target: Surface | null = item ? { type: item.type, name: item.name, label: item.label } : offNavFallbackRef.current; + setCurrent((cur) => cur ?? target); + }, [appStatus, packageItems, packageId, initialSurface]); + // Resolve THIS package's App → load its navigation tree. The query is scoped // to the package (`list('app', { packageId })`) so a design surface only ever // shows the current package's app — never another package's. `list()` sees @@ -4637,6 +4691,12 @@ export function InterfacesPillar({ // Its `?nav=` entry id, when that entry still exists, picks the entry // among several that open one target (objectui#11774). const deepLinked = initialSurface ? findSurfaceInTree(tree, initialSurface, locale, resolveTarget) : null; + // objectstack#28561 — an off-nav deep link no entry opens waits for the + // package's own lists (the effect after the package-items read). + if (!deepLinked && offNavDeepLinkRef.current === 'pending') { + offNavFallbackRef.current = firstLeaf; + return; + } setCurrent((cur) => cur ?? deepLinked ?? firstLeaf); } catch (e) { if (!cancelled) { diff --git a/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx new file mode 100644 index 00000000000..c75018e538d --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.routes-28561.test.tsx @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — Studio's "All metadata" area mounts the metadata-admin + * pages (ADR-0146 D1) for a writable package, a read-only package and the + * cross-package leg, and opens a pillar type in its pillar. + * + * The pages themselves are stubs here: each prints which page it is and the + * host the area handed it, so these cases pin the AREA (its routes, its legs, + * its gate). What each page does with the host is pinned beside the pages + * (`metadataAreaHost.gates-28561.test.tsx`). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import * as React from 'react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation, useSearchParams } from 'react-router-dom'; +import { useMetadataAreaHost } from '../metadata-admin/metadataAreaHost'; + +function PageStub({ page }: { page: string }) { + const host = useMetadataAreaHost(); + return ( +
+ {page} +
+ ); +} + +/** The item page stub drops `?review=1` once open, as the real one does after opening its review sheet. */ +function EditStub({ createMode }: { createMode?: boolean }) { + const [search, setSearch] = useSearchParams(); + React.useEffect(() => { + if (search.get('review') === '1') { + const next = new URLSearchParams(search); + next.delete('review'); + setSearch(next, { replace: true }); + } + }, [search, setSearch]); + return ; +} + +vi.mock('../metadata-admin/index.js', () => ({ + MetadataDirectoryPage: () => , + MetadataDiagnosticsPage: () => , + MetadataResourceListPage: () => , + MetadataResourceEditPage: ({ createMode }: { createMode?: boolean }) => , + MetadataResourceHistoryPage: () => , +})); + +/** `GET /api/v1/packages` as the listed manifests read it: one writable, one read-only, one kernel. */ +let listed: Map }>; +let listFails = false; +vi.mock('./packages-io.js', async (importOriginal) => ({ + ...(await importOriginal>()), + fetchListedPackageManifests: vi.fn(async () => { + if (listFails) throw new Error('HTTP 503'); + return listed; + }), +})); + +import { StudioMetadataArea } from './StudioMetadataArea'; + +const WRITABLE = 'com.example.crm'; +const READ_ONLY = 'com.vendor.hr'; +const KERNEL = 'com.objectstack.plugin-security'; + +beforeEach(() => { + listFails = false; + listed = new Map([ + [WRITABLE, { id: WRITABLE, name: 'CRM', writable: true, manifest: {} }], + [READ_ONLY, { id: READ_ONLY, name: 'HR', writable: false, manifest: { scope: 'installed' } }], + // A kernel package: hidden from the switcher, read-only all the same. + [KERNEL, { id: KERNEL, name: 'Security', writable: false, manifest: { scope: 'system' } }], + ]); +}); +afterEach(cleanup); + +function Where() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +function PackageArea() { + const { pathname } = useLocation(); + const packageId = decodeURIComponent(pathname.split('/')[2] ?? ''); + return ; +} + +function mount(url: string) { + return render( + + + + } /> + } /> + } /> + } /> + + , + ); +} + +const where = () => screen.getByTestId('where').textContent ?? ''; +async function page() { + const el = await screen.findByTestId('area-page'); + return { page: el.getAttribute('data-page'), host: JSON.parse(el.getAttribute('data-host') ?? 'null') }; +} + +describe('the area mounts the metadata pages (objectstack#28561)', () => { + it('a writable package: the directory, scoped to the package, writes ungated', async () => { + mount(`/studio/${WRITABLE}/metadata`); + expect(await page()).toEqual({ + page: 'directory', + host: { packageId: WRITABLE, readOnly: false, basePath: `/studio/${WRITABLE}/metadata` }, + }); + // The URL carries the package, which the item page binds its saves to. + expect(where()).toBe(`/studio/${WRITABLE}/metadata?package=${WRITABLE}`); + }); + + it('a read-only package: the same pages under the read-only gate', async () => { + mount(`/studio/${READ_ONLY}/metadata/translation?package=${READ_ONLY}`); + expect(await page()).toEqual({ + page: 'list', + host: { packageId: READ_ONLY, readOnly: true, basePath: `/studio/${READ_ONLY}/metadata` }, + }); + }); + + it('a kernel package, which the switcher hides, is read-only too', async () => { + mount(`/studio/${KERNEL}/metadata/translation/t1?package=${KERNEL}`); + expect((await page()).host.readOnly).toBe(true); + }); + + it('a package the list cannot answer for stays ungated, as the pillars do', async () => { + listFails = true; + mount(`/studio/${WRITABLE}/metadata?package=${WRITABLE}`); + expect((await page()).host.readOnly).toBe(false); + }); + + it('the cross-package leg: no package, and the environment-scope permission list', async () => { + mount('/studio/~all/metadata/permission?scope=environment'); + expect(await page()).toEqual({ + page: 'list', + host: { packageId: null, readOnly: false, basePath: '/studio/~all/metadata' }, + }); + expect(where()).toBe('/studio/~all/metadata/permission?scope=environment'); + }); + + it('the environment scope opened under a package moves to the cross-package leg', async () => { + mount(`/studio/${WRITABLE}/metadata/permission/admin_full_access?scope=environment&package=${WRITABLE}`); + expect((await page()).page).toBe('item'); + expect(where()).toBe('/studio/~all/metadata/permission/admin_full_access?scope=environment'); + }); + + it('diagnostics and history are the area\'s routes too', async () => { + mount(`/studio/${WRITABLE}/metadata/_diagnostics?package=${WRITABLE}`); + expect((await page()).page).toBe('diagnostics'); + cleanup(); + mount(`/studio/${WRITABLE}/metadata/flow/f1/history?package=${WRITABLE}`); + expect((await page()).page).toBe('history'); + }); + + it('create: a writable package opens it, a read-only package lands on the list', async () => { + mount(`/studio/${WRITABLE}/metadata/translation/new?package=${WRITABLE}`); + expect((await page()).page).toBe('create'); + cleanup(); + mount(`/studio/${READ_ONLY}/metadata/translation/new?package=${READ_ONLY}`); + expect((await page()).page).toBe('list'); + expect(where()).toBe(`/studio/${READ_ONLY}/metadata/translation?package=${READ_ONLY}`); + }); + + it('a datasource URL opens Setup\'s Datasources page', async () => { + mount(`/studio/${WRITABLE}/metadata/datasource?package=${WRITABLE}`); + expect(await screen.findByTestId('setup-datasources')).toBeInTheDocument(); + }); +}); + +describe('a pillar type opened from the area opens its pillar', () => { + for (const [type, pillar] of [ + ['object', 'data'], + ['flow', 'automations'], + ['view', 'interfaces'], + ['permission', 'access'], + ] as const) { + it(`${type} → ${pillar}, with \`?surface=\``, async () => { + mount(`/studio/${WRITABLE}/metadata/${type}/item_1?package=${WRITABLE}`); + await screen.findByTestId('pillar'); + const url = new URL(where(), 'http://x'); + expect(url.pathname).toBe(`/studio/${WRITABLE}/${pillar}`); + expect(url.searchParams.get('surface')).toBe(`${type}:item_1`); + }); + } + + it('a translation opens the generic page', async () => { + mount(`/studio/${WRITABLE}/metadata/translation/ui_en?package=${WRITABLE}`); + expect((await page()).page).toBe('item'); + }); + + it('`?review=1` opens the generic page and its review, and the page stays once the review is open', async () => { + mount(`/studio/${WRITABLE}/metadata/flow/f1?package=${WRITABLE}&review=1`); + expect((await page()).page).toBe('item'); + // The stub dropped `?review=1`, as the item page does: the item stays. + await waitFor(() => expect(where()).toBe(`/studio/${WRITABLE}/metadata/flow/f1?package=${WRITABLE}`)); + expect(screen.queryByTestId('pillar')).toBeNull(); + expect((await page()).page).toBe('item'); + }); + + it('the cross-package leg opens a pillar type on the generic page: it names no package', async () => { + mount('/studio/~all/metadata/permission/admin_full_access?scope=environment'); + expect((await page()).page).toBe('item'); + expect(screen.queryByTestId('pillar')).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.tsx b/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.tsx new file mode 100644 index 00000000000..9cbf2eed67a --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioMetadataArea.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Studio's "All metadata" area (objectstack ADR-0146 D1, #28561): the type + * directory, the lists, the generic item page, history and diagnostics, per + * package beside the four pillars and across packages for the environment + * catalog. + * + * The pages are the metadata-admin pages Setup mounts today, MOUNTED here, not + * copied, under the same route shapes (`metadataArea.ts` names the two legs): + * + * the type directory + * /_diagnostics diagnostics + * /:type a type's list + * /:type/new create (a writable package only) + * /:type/:name the generic item page, or the item's pillar + * /:type/:name/history the item's history + * + * Mirroring Setup's shapes keeps every relative link the pages emit pointing + * where it points there. What the area adds, it hands the pages through + * {@link MetadataAreaHostContext}: the package, its read-only state and the + * root. "No new write path": a writable package's pages save exactly as Setup's + * do with `?package=`, through the same components and doors. + * + * Assignments are Setup's (D7): the pages read the host and link the Setup + * assignment pages instead of drawing them. The datasource is Setup's runtime + * configuration: its URLs here open Setup's Datasources page. + */ + +import * as React from 'react'; +import { Navigate, Route, Routes, useLocation, useParams, useSearchParams } from 'react-router-dom'; +import { + MetadataAreaHostContext, + SETUP_DATASOURCES_PATH, + useMetadataAreaHost, + type MetadataAreaHost, +} from '../metadata-admin/metadataAreaHost.js'; +import { t, useMetadataLocale } from '../metadata-admin/i18n.js'; +import { fetchListedPackageManifests } from './packages-io.js'; +import { + STUDIO_ALL_PACKAGES_SEGMENT, + areaItemPillarPath, + areaLocationRedirect, + studioMetadataAreaPath, +} from './metadataArea.js'; + +/* + * The pages load with the area, through the specifier `AppContent` loads them + * by (`../metadata-admin/index.js`), so Studio and Setup share their one chunk + * and the Studio builder's own chunk does not grow by them. + */ +const MetadataDirectoryPage = React.lazy(() => + import('../metadata-admin/index.js').then((m) => ({ default: m.MetadataDirectoryPage })), +); +const MetadataDiagnosticsPage = React.lazy(() => + import('../metadata-admin/index.js').then((m) => ({ default: m.MetadataDiagnosticsPage })), +); +const MetadataResourceListPage = React.lazy(() => + import('../metadata-admin/index.js').then((m) => ({ default: m.MetadataResourceListPage })), +); +const MetadataResourceEditPage = React.lazy(() => + import('../metadata-admin/index.js').then((m) => ({ default: m.MetadataResourceEditPage })), +); +const MetadataResourceHistoryPage = React.lazy(() => + import('../metadata-admin/index.js').then((m) => ({ default: m.MetadataResourceHistoryPage })), +); + +/** + * Is this package writable, as `GET /api/v1/packages` says? `undefined` while + * asking, `null` when the list could not say (the read failed, or it lists no + * such id). + * + * Read from the listed manifests, which keep the kernel packages the + * switcher's entries hide: the surface's own courtesy gate reads the switcher's + * entries, so a kernel package reads as "unknown" there, and the generic item + * page would then offer its overlay save on a package nobody may write. The + * verdict rule is the one `parsePackages` applies. One request either way: the + * readers of that endpoint share it (`packages-io.ts`). + */ +function useListedPackageWritable(packageId: string | null): boolean | null | undefined { + const [answer, setAnswer] = React.useState<{ id: string; writable: boolean | null } | null>(null); + React.useEffect(() => { + if (packageId === null) return; + let cancelled = false; + fetchListedPackageManifests().then( + (manifests) => { + if (!cancelled) setAnswer({ id: packageId, writable: manifests.get(packageId)?.writable ?? null }); + }, + () => { + // The surface reports the failure once (its package-list toast). + if (!cancelled) setAnswer({ id: packageId, writable: null }); + }, + ); + return () => { + cancelled = true; + }; + }, [packageId]); + if (packageId === null) return null; + return answer?.id === packageId ? answer.writable : undefined; +} + +function AreaLoading({ locale }: { locale: string }): React.ReactElement { + return ( +
+ {t('engine.studio.loading', locale)} +
+ ); +} + +/** + * `/:type/:name`: a pillar type opens its pillar, every other type the + * generic item page (`areaItemPillarPath` lists when). + * + * Decided once per item, when it opens (the item is the key below): the item + * page itself rewrites the query it opened with (it drops `?review=1` once the + * review sheet is open), and that must not send an open review on to the + * pillar. + */ +function AreaItem(): React.ReactElement { + const { type = '', name = '' } = useParams<{ type: string; name: string }>(); + // A type never holds a `/`, so this names one item. + return ; +} + +function AreaItemOpen({ type, name }: { type: string; name: string }): React.ReactElement { + const [search] = useSearchParams(); + const host = useMetadataAreaHost(); + const [pillarPath] = React.useState(() => + areaItemPillarPath({ packageId: host?.packageId ?? null, type, name, search }), + ); + if (pillarPath) return ; + return ; +} + +/** `/:type/new`: a read-only package takes no new item, so its create URL lands on the list. */ +function AreaCreate(): React.ReactElement { + const { type = '' } = useParams<{ type: string }>(); + const { search } = useLocation(); + const host = useMetadataAreaHost(); + if (host?.readOnly) { + return ; + } + return ; +} + +export interface StudioMetadataAreaProps { + /** The package the area is open on, or `null` for the cross-package leg. */ + packageId: string | null; +} + +export function StudioMetadataArea({ packageId }: StudioMetadataAreaProps): React.ReactElement { + const { pathname, search } = useLocation(); + const locale = useMetadataLocale(); + const segment = packageId ?? STUDIO_ALL_PACKAGES_SEGMENT; + const basePath = studioMetadataAreaPath(segment); + // The courtesy gate the pillars apply, read for the area from the listed + // manifests (see the hook). Unknown stays ungated, as on the pillars: the + // server's write gate is the authority, and a failed probe must not lock an + // author out of a writable package. + const writable = useListedPackageWritable(packageId); + const host = React.useMemo( + () => ({ packageId, readOnly: packageId !== null && writable === false, basePath }), + [packageId, writable, basePath], + ); + + // The query names the leg (`areaLocationRedirect`). Settled before any page + // mounts, so no page reads a scope the URL is about to change. + const rest = pathname.startsWith(basePath) ? pathname.slice(basePath.length).replace(/^\/+/, '') : ''; + const redirect = areaLocationRedirect(segment, rest, search); + if (redirect) return ; + // A page must not offer a write before the package's state is known. + if (packageId !== null && writable === undefined) return ; + + return ( + +
+ }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + +
+
+ ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts b/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts new file mode 100644 index 00000000000..4e695a553de --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/metadataArea.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28561 — the addresses of Studio's "All metadata" area + * (ADR-0146 D1): which leg a URL belongs on, and which pillar an item opens in. + * + * The query keeps its Setup meaning (`?package=`, `?scope=environment`, + * `?review=1`) so that N-28's redirects from Setup's metadata routes lose no + * deep link; each rule below is pinned with the case that would break it. + */ + +import { describe, it, expect } from 'vitest'; +import { + AREA_PILLAR_FOR_TYPE, + STUDIO_ALL_PACKAGES_SEGMENT, + areaItemPillarPath, + areaLocationRedirect, + isStudioAllPackagesScope, + studioMetadataAreaPath, +} from './metadataArea'; +import { STUDIO_ORG_SCOPE_SEGMENT } from './studioScope'; +import { parseSurfaceParam } from '../metadata-admin/nav-selection'; + +const PKG = 'com.example.crm'; + +describe('the area legs (objectstack#28561)', () => { + it('names a package leg and a reserved cross-package leg that no package id can spell', () => { + expect(studioMetadataAreaPath(PKG)).toBe(`/studio/${PKG}/metadata`); + expect(studioMetadataAreaPath(STUDIO_ALL_PACKAGES_SEGMENT)).toBe('/studio/~all/metadata'); + expect(isStudioAllPackagesScope('~all')).toBe(true); + // A package may be called `metadata`; the leg is not `/studio/metadata`. + expect(isStudioAllPackagesScope('metadata')).toBe(false); + // And it is not the package-less flows scope. + expect(STUDIO_ALL_PACKAGES_SEGMENT).not.toBe(STUDIO_ORG_SCOPE_SEGMENT); + }); +}); + +describe('areaLocationRedirect — the query names the leg', () => { + it('a package leg without `?package=` gets its own, so the item page binds its saves to it', () => { + expect(areaLocationRedirect(PKG, '', '')).toBe(`/studio/${PKG}/metadata?package=${encodeURIComponent(PKG)}`); + expect(areaLocationRedirect(PKG, 'translation/ui_en', '?review=1')).toBe( + `/studio/${PKG}/metadata/translation/ui_en?review=1&package=${encodeURIComponent(PKG)}`, + ); + }); + + it('a package leg whose `?package=` is its own stays where it is', () => { + expect(areaLocationRedirect(PKG, 'flow', `?package=${PKG}`)).toBeNull(); + expect(areaLocationRedirect(PKG, 'flow/f1/history', `?package=${PKG}&tab=x`)).toBeNull(); + }); + + it('a `?package=` naming another package re-roots onto that package, keeping the rest', () => { + expect(areaLocationRedirect(PKG, 'view/v1', '?package=com.example.hr&review=1')).toBe( + '/studio/com.example.hr/metadata/view/v1?package=com.example.hr&review=1', + ); + // From the cross-package leg too: a Setup URL that names a package lands in that package. + expect(areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, 'flow', '?package=com.example.hr')).toBe( + '/studio/com.example.hr/metadata/flow?package=com.example.hr', + ); + }); + + it('`?scope=environment` is the cross-package leg, and the scope binds no package', () => { + expect(areaLocationRedirect(PKG, 'permission', `?scope=environment&package=${PKG}`)).toBe( + '/studio/~all/metadata/permission?scope=environment', + ); + expect(areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, 'position/p1', '?scope=environment')).toBeNull(); + expect(areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, 'permission', '?scope=environment&package=x')).toBe( + '/studio/~all/metadata/permission?scope=environment', + ); + }); + + it('`?package=all` (the list\'s every-package scope) is the cross-package leg, kept', () => { + expect(areaLocationRedirect(PKG, 'flow', '?package=all')).toBe('/studio/~all/metadata/flow?package=all'); + expect(areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, 'flow', '?package=all')).toBeNull(); + }); + + it('the cross-package leg with no package keeps the URL as it is', () => { + expect(areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, '', '')).toBeNull(); + expect(areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, 'flow/f1', '?review=1')).toBeNull(); + }); +}); + +describe('areaItemPillarPath — a pillar type opens its pillar', () => { + const search = (q = '') => new URLSearchParams(q); + const open = (type: string, q = '', packageId: string | null = PKG) => + areaItemPillarPath({ packageId, type, name: 'n_1', search: search(q) }); + + it('object, flow, the four Interfaces types and permission open their pillar with `?surface=`', () => { + const expected: Record = { + object: 'data', + flow: 'automations', + page: 'interfaces', + dashboard: 'interfaces', + report: 'interfaces', + view: 'interfaces', + permission: 'access', + }; + expect(AREA_PILLAR_FOR_TYPE).toEqual(expected); + for (const [type, pillar] of Object.entries(expected)) { + const path = open(type); + expect(path, type).not.toBeNull(); + const url = new URL(path!, 'http://x'); + expect(url.pathname).toBe(`/studio/${PKG}/${pillar}`); + expect(parseSurfaceParam(url.searchParams.get('surface'))).toEqual({ type, name: 'n_1' }); + } + }); + + it('every other type opens the generic item page', () => { + for (const type of ['translation', 'email_template', 'agent', 'position', 'sharing_rule', 'app']) { + expect(open(type), type).toBeNull(); + } + }); + + it('the cross-package leg, the environment scope and `?review=1` open the generic page', () => { + expect(open('flow', '', null)).toBeNull(); + expect(open('permission', '?scope=environment')).toBeNull(); + expect(open('flow', '?review=1')).toBeNull(); + // Control: the same item without them opens the pillar. + expect(open('flow', '?review=0')).not.toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/metadataArea.ts b/ui/packages/app-shell/src/views/studio-design/metadataArea.ts new file mode 100644 index 00000000000..f60ea6cf4c6 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/metadataArea.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The addresses of Studio's "All metadata" area (objectstack ADR-0146 D1, + * #28561): its routes, the one reserved segment it adds, and the pillar each + * pillar type opens in. Pure, so the routing rules are pinned without a render. + * + * ## Two legs + * + * - Per package: `/studio//metadata/...`, beside the four pillars of + * that package. The metadata-admin pages, mounted unchanged, scoped to it. + * - Across packages: `/studio/~all/metadata/...`. The environment catalog + * (positions, capabilities, permission sets, sharing rules), whose items + * have one name per deployment and may belong to no package (ADR-0131 D3), + * served in the environment scope (`?scope=environment`), as Setup's catalog + * serves it today. + * + * ## Why `~all` and not `~org` + * + * `~org` (`studioScope.ts`) is the scope of the flows that belong to NO + * package; this leg is the registry of EVERY package plus the environment's + * own items. Two different reads, so two names: folding this leg into `~org` + * would make the package-less scope mean "all packages" too, and its header, + * switcher entry and drafts filter (`isOrgScopeDraft`) are the flows' alone. + * `~` is outside every package-id alphabet (`studioScope.ts` says why), so + * `~all` cannot name a package either. `metadata` could: it is a legal package + * id, which is why the cross-package leg is not a bare `/studio/metadata`. + * + * ## The query keeps its meaning + * + * `?package=`, `?scope=environment` and `?review=1` mean what they mean on + * Setup's metadata routes, so a redirect from those routes (N-28) loses no deep + * link. {@link areaLocationRedirect} puts each URL on the leg its query names. + */ + +import { DESIGNER_SURFACE_PARAM, formatSurfaceParam } from '../metadata-admin/nav-selection.js'; +import { CATALOG_SCOPE_PARAM, ENVIRONMENT_SCOPE } from '../metadata-admin/catalog-scope.js'; + +/** The area's route segment, beside the pillars' (`/studio//metadata`). */ +export const STUDIO_METADATA_AREA = 'metadata'; + +/** The reserved `/studio/` value for the area's cross-package leg. */ +export const STUDIO_ALL_PACKAGES_SEGMENT = '~all'; + +/** Whether a `/studio/:packageId` route segment names the cross-package leg. */ +export function isStudioAllPackagesScope(segment: string | null | undefined): boolean { + return segment === STUDIO_ALL_PACKAGES_SEGMENT; +} + +/** The area's root for a package, or for the cross-package leg with {@link STUDIO_ALL_PACKAGES_SEGMENT}. */ +export function studioMetadataAreaPath(segment: string): string { + return `/studio/${encodeURIComponent(segment)}/${STUDIO_METADATA_AREA}`; +} + +/** The pillar keys a pillar type opens in (`StudioDesignSurface`'s `PILLARS`). */ +export type StudioPillarKey = 'data' | 'automations' | 'interfaces' | 'access'; + +/** + * ADR-0146 D1 — "A pillar type opened from the directory opens its pillar". + * The types each pillar designs; every other type opens the area's generic + * item page. + * + * Its own table, not `StudioDesignSurface`'s `PILLAR_FOR_SURFACE_TYPE`: that + * one routes the live in-surface request channel, which the Interfaces pillar + * answers through its nav tree alone, so it names no Interfaces type. Here the + * four Interfaces types open the pillar by URL, where the pillar resolves an + * item no nav entry opens through its off-nav lists. + */ +export const AREA_PILLAR_FOR_TYPE: Readonly> = { + object: 'data', + flow: 'automations', + page: 'interfaces', + dashboard: 'interfaces', + report: 'interfaces', + view: 'interfaces', + permission: 'access', +}; + +/** A pillar of a package, opened on one item through its `?surface=` deep link. */ +export function studioPillarItemPath( + packageId: string, + pillar: StudioPillarKey, + item: { type: string; name: string }, +): string { + return `/studio/${encodeURIComponent(packageId)}/${pillar}?${DESIGNER_SURFACE_PARAM}=${encodeURIComponent(formatSurfaceParam(item))}`; +} + +/** + * The pillar an item of the area opens in, as a path, or `null` when the + * area's generic item page is where it opens: + * - a type no pillar designs; + * - the cross-package leg, which names no package to open a pillar of; + * - the environment scope, whose catalog item is the environment's, by name; + * - `?review=1`, which opens the draft review on the generic page, as it does + * on Setup's (the console's draft-review links name that page). + */ +export function areaItemPillarPath(input: { + packageId: string | null; + type: string; + name: string; + search: URLSearchParams; +}): string | null { + const pillar = AREA_PILLAR_FOR_TYPE[input.type]; + if (!pillar || !input.packageId) return null; + if (input.search.get(CATALOG_SCOPE_PARAM) === ENVIRONMENT_SCOPE) return null; + if (input.search.get('review') === '1') return null; + return studioPillarItemPath(input.packageId, pillar, { type: input.type, name: input.name }); +} + +/** `?package=all` is the metadata list's "every package" scope, not a package (`ResourceEditPage`'s `'all'` fold). */ +const ALL_PACKAGES_VALUE = 'all'; + +/** + * Where an area URL belongs, or `null` when it is already there. The query + * decides the leg, so each parameter keeps its Setup meaning: + * + * - `?scope=environment` is the cross-package leg. The environment scope + * lists every package's items and binds no package, so `?package=` is + * dropped there; + * - `?package=` is that package's leg, so the route and the scope never + * disagree, and a link that names another package re-roots onto it; + * - `?package=all` is the cross-package leg, keeping the parameter; + * - the per-package leg with no `?package=` gets its own, because the item + * page binds its saves to `?package=` (`readActivePackageBinding`): without + * it a save in the area would leave the package; + * - the cross-package leg with no `?package=` stays as it is, the pages + * keeping their Setup meaning for a URL that names no package. + * + * `rest` is the path below the area's root as the URL spells it (encoded), + * `''` at the root; `search` is the URL's query, with or without its `?`. + */ +export function areaLocationRedirect(segment: string, rest: string, search: string): string | null { + const params = new URLSearchParams(search); + const allLeg = isStudioAllPackagesScope(segment); + const tail = rest ? `/${rest}` : ''; + const at = (leg: string) => { + const qs = params.toString(); + return `${studioMetadataAreaPath(leg)}${tail}${qs ? `?${qs}` : ''}`; + }; + + if (params.get(CATALOG_SCOPE_PARAM) === ENVIRONMENT_SCOPE) { + if (allLeg && !params.has('package')) return null; + params.delete('package'); + return at(STUDIO_ALL_PACKAGES_SEGMENT); + } + + const pkg = params.get('package'); + if (!pkg) { + if (allLeg) return null; + params.set('package', segment); + return at(segment); + } + if (pkg === ALL_PACKAGES_VALUE) return allLeg ? null : at(STUDIO_ALL_PACKAGES_SEGMENT); + return pkg === segment ? null : at(pkg); +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index e72cc677d74..107ca364ef5 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3118,7 +3118,7 @@ const STUDIO_EN: Record = { // objectstack#26708 — the builder's not-found states: a package id the // package list does not hold, and a pillar segment Studio has no pillar for. 'engine.studio.notFound.package': 'Package not found. No package with the id "{id}" is installed here, so there is nothing to build in.', - 'engine.studio.notFound.pillar': 'This page does not exist. Studio has no "{pillar}" section; pick Data, Automations, Interfaces or Access above.', + 'engine.studio.notFound.pillar': 'This page does not exist. Studio has no "{pillar}" section; pick Data, Automations, Interfaces, Access or All metadata above.', 'engine.studio.aiCopilot': 'AI copilot', // objectui#12725 — the Studio right slot: properties OR the copilot. 'engine.studio.slot.label': 'Right panel', @@ -3133,6 +3133,8 @@ const STUDIO_EN: Record = { 'engine.studio.pillar.automations': 'Automations', 'engine.studio.pillar.interfaces': 'Interfaces', 'engine.studio.pillar.access': 'Access', + // objectstack#28561 — the "All metadata" area beside the pillars (ADR-0146 D1). + 'engine.studio.pillar.metadata': 'All metadata', // Header · package-level publish + app bridge 'engine.studio.changes': 'Changes', 'engine.studio.publishTitle': 'Review and publish every pending draft in this package together', @@ -3191,6 +3193,15 @@ const STUDIO_EN: Record = { 'engine.studio.org.none': 'No flows outside a package yet. Clone a packaged flow in Setup › Packaged automation to edit the copy here.', // objectui#12513 — the empty block's way to that Setup list: a link, since this scope creates nothing. 'engine.studio.org.openSetup': 'Open in Setup', + // objectstack#28561 — the "All metadata" area's cross-package leg: the + // environment catalog, entered from the landing. + 'engine.studio.landing.envCatalog.name': 'Environment catalog', + 'engine.studio.landing.envCatalog.description': 'Positions, capabilities, permission sets and sharing rules, across every package.', + // objectstack#28561 — assignments are Setup's (ADR-0146 D7): the area links + // the Setup page in place of the section. + 'engine.studio.area.assignments.position': 'The people who hold this position are assigned in Setup.', + 'engine.studio.area.assignments.permission': 'The people this permission set is assigned to are managed in Setup.', + 'engine.studio.area.assignments.open': 'Open in Setup', 'engine.studio.publishedAllFlows': 'Published {count} flows', 'engine.studio.publishedAllFlowsOne': 'Published flow "{label}"', 'engine.studio.designer.search': 'Search…', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 12143029805..0ed4168f605 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2764,7 +2764,7 @@ const STUDIO_ZH: Record = { 'engine.studio.landingHome': 'Studio 首页', 'engine.studio.desktopHint': 'Studio 专为桌面屏幕设计。', 'engine.studio.notFound.package': '未找到包。本部署没有安装 ID 为“{id}”的包,因此无处可构建。', - 'engine.studio.notFound.pillar': '页面不存在。Studio 没有“{pillar}”这一栏,请在上方选择数据、自动化、界面或权限。', + 'engine.studio.notFound.pillar': '页面不存在。Studio 没有“{pillar}”这一栏,请在上方选择数据、自动化、界面、权限或全部元数据。', 'engine.studio.aiCopilot': 'AI 副驾', // objectui#12725 — Studio 右栏:属性或 AI 副驾二选一。 'engine.studio.slot.label': '右栏', @@ -2779,6 +2779,7 @@ const STUDIO_ZH: Record = { 'engine.studio.pillar.automations': '自动化', 'engine.studio.pillar.interfaces': '界面', 'engine.studio.pillar.access': '权限', + 'engine.studio.pillar.metadata': '全部元数据', // Header · package-level publish + app bridge 'engine.studio.changes': '变更', 'engine.studio.publishTitle': '检查并一并发布本软件包的全部待发布草稿', @@ -2832,6 +2833,11 @@ const STUDIO_ZH: Record = { 'engine.studio.org.description': '本组织自有、不属于任何软件包的流程,例如软件包流程的克隆副本。可直接编辑。', 'engine.studio.org.none': '还没有不属于软件包的流程。在 设置 › 打包自动化 中克隆一个软件包流程,即可在这里编辑副本。', 'engine.studio.org.openSetup': '在设置中打开', + 'engine.studio.landing.envCatalog.name': '环境目录', + 'engine.studio.landing.envCatalog.description': '岗位、能力、权限集与共享规则,覆盖所有软件包。', + 'engine.studio.area.assignments.position': '此岗位的任职人员在设置中分配。', + 'engine.studio.area.assignments.permission': '此权限集分配给哪些人,在设置中管理。', + 'engine.studio.area.assignments.open': '在设置中打开', 'engine.studio.publishedAllFlows': '已发布 {count} 个流程', 'engine.studio.publishedAllFlowsOne': '已发布流程「{label}」', 'engine.studio.designer.search': '搜索…',