From 50d583ef442a1773535809986e6c70e6d29f8f1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 20:32:15 +0000 Subject: [PATCH 1/5] feat(setup): Setup's metadata pages open Studio's All metadata area; the catalog's Active switch moves to each item's page ADR-0146 D1: Setup carries no metadata editor and no metadata catalog. Every /apps//metadata route and its legacy aliases (the shell's component/metadata/*, the console's system/objects, system/metadata and system/{roles,positions,permissions}) open the same page in Studio's All metadata area in one hop, with ?package, ?scope and ?review kept and the leg named by the query. The datasource stays in Setup. A bookmark of the retired Packaged automation page opens the package's Automations pillar. The four catalog nav entries, sys-objects and the Manage Objects card leave Setup and Home; their labels are tombstoned. The catalog's Active switch (M-1 ruling A) is offered on the permission set, position and declared sharing rule item pages in the area. Studio links into no Setup metadata page: the landing's overlay link is gone, the read-only link and the chat's review hop open the area. The live specs aim at Studio. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRKMRpKnN9FkAvSWGr --- .../28578-setup-metadata-into-studio.md | 22 ++ .../src/apps/translations/en.ts | 4 - .../apps/translations/es-ES.source-hashes.ts | 4 - .../src/apps/translations/es-ES.ts | 4 - .../apps/translations/ja-JP.source-hashes.ts | 4 - .../src/apps/translations/ja-JP.ts | 4 - .../setup-nav-dead-key-tombstone.test.ts | 18 + .../apps/translations/zh-CN.source-hashes.ts | 4 - .../src/apps/translations/zh-CN.ts | 4 - ...le-org-assignment-nav-contribution.test.ts | 25 +- .../plugin-security/src/security-plugin.ts | 32 +- .../plugin-sharing/src/sharing-plugin.ts | 17 +- ui/apps/console/src/AppContent.tsx | 55 +-- .../AppContent.legacyRedirects.test.tsx | 125 ++++--- .../AppContent.systemHubRoutes.test.tsx | 44 +-- ...declared-sharing-rules-reach-13091.spec.ts | 4 +- ui/e2e/live/studio-editor.spec.ts | 8 +- ui/e2e/live/studio-object-designer.spec.ts | 12 +- .../app-shell/src/console/AppContent.tsx | 113 ++++--- .../AppContent.bootRedirectCoverage.test.tsx | 5 +- ...tent.emptyNavigationLanding-12079.test.tsx | 12 +- .../AppContent.noAppComponentRoutes.test.tsx | 46 ++- .../AppContent.pseudoRouteSegments.test.tsx | 77 +++-- ...tent.setupItemRoutesVerdict-12706.test.tsx | 10 +- ...Content.setupMetadataStudio-28578.test.tsx | 241 +++++++++++++ .../src/console/home/QuickActions.tsx | 23 +- .../QuickActions.settingsTarget.test.tsx | 27 +- ui/packages/app-shell/src/index.ts | 4 + .../app-shell/src/layout/UnifiedSidebar.tsx | 15 +- .../__tests__/systemNavObjectsHop.test.tsx | 188 ++-------- .../systemNavSettingsTarget.test.tsx | 22 +- ...atalogActiveSwitch.itemPage-28578.test.tsx | 196 +++++++++++ .../metadata-admin/CatalogActiveSwitch.tsx | 320 ++++++++++++++++++ .../metadata-admin/PermissionMatrixEditor.tsx | 4 + .../views/metadata-admin/ResourceEditPage.tsx | 18 +- .../views/metadata-admin/ResourceListPage.tsx | 149 +------- .../metadataAreaHost.gates-28561.test.tsx | 13 + ...nding.environmentOverlayCopy-12171.test.ts | 7 +- ...ilderLanding.readonlyRoutes-11808.test.tsx | 33 +- .../BuilderLanding.setupExit-12513.test.tsx | 32 +- .../views/studio-design/BuilderLanding.tsx | 45 +-- .../studio-design/StudioDesignSurface.tsx | 12 +- .../StudioReadOnlyLink.overlay-27356.test.tsx | 10 +- .../noSetupMetadataLink-28578.test.ts | 81 +++++ .../setupMetadataRedirect.test.ts | 119 +++++++ .../studio-design/setupMetadataRedirect.ts | 75 ++++ ui/packages/i18n/src/namespaces/studio/en.ts | 3 - ui/packages/i18n/src/namespaces/studio/zh.ts | 2 - 48 files changed, 1562 insertions(+), 730 deletions(-) create mode 100644 .changeset/28578-setup-metadata-into-studio.md create mode 100644 ui/packages/app-shell/src/console/__tests__/AppContent.setupMetadataStudio-28578.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.itemPage-28578.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/noSetupMetadataLink-28578.test.ts create mode 100644 ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.test.ts create mode 100644 ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.ts diff --git a/.changeset/28578-setup-metadata-into-studio.md b/.changeset/28578-setup-metadata-into-studio.md new file mode 100644 index 00000000000..46b0f6b64a1 --- /dev/null +++ b/.changeset/28578-setup-metadata-into-studio.md @@ -0,0 +1,22 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +'@object-ui/i18n': patch +'@objectstack/platform-objects': patch +'@objectstack/plugin-security': patch +'@objectstack/plugin-sharing': patch +--- + +feat(setup): Setup's metadata pages open Studio's All metadata area, the catalog's Active switch moves to each item's page, and Studio stops linking into Setup + +Clause-②: no +Metadata-protocol: none + +Setup no longer has a metadata editor or a metadata catalog (ADR-0146 D1). Studio's "All metadata" area serves those pages now. + +- **Setup's metadata URLs redirect.** Each `/apps//metadata/...` route opens the same page in Studio's "All metadata" area: the directory, `_diagnostics`, a type's list, an item, its history and its create page. The legacy aliases redirect too: `component/metadata/{directory,resource}` in the shell, and `system/objects`, `system/metadata` and `system/{roles,positions,permissions}` in the console. `?package=`, `?scope=environment` and `?review=1` carry over, and each URL lands in one hop on the leg its query names. +- **The datasource stays in Setup.** Its routes render Setup's pages as before. It is runtime configuration, not metadata authoring, and the area sends datasource URLs back to Setup. +- **A bookmark of the retired Setup › Packaged automation page lands in Studio.** `/apps//component/automation/packaged?package=` opens that package's Automations pillar, where a packaged flow's switch and New similar are, with the query kept. With no package named, it opens the area's cross-package list of flows. +- **The catalog entries leave Setup.** Positions, Capabilities, Permission Sets and Declared Sharing Rules leave Setup's navigation, and their labels are retired in all four locales. Home loses the "Manage Objects" quick action, and the sidebar loses its Objects entry. +- **The Active switch moves to the item's page.** A permission set, a position or a declared sharing rule is now switched on and off on its own page in the area. It is the same switch the catalog list had: switching off asks first and names how many users lose the access, and switching on writes at once. The area's type lists keep their switch column. **An admin with `setup.access` but no `studio.access` now switches these items in Studio.** Grant `studio.access` to anyone who managed the catalog from Setup. +- **Studio stops linking into Setup.** The landing page's environment-overlay link is gone. The read-only item link and the chat's "Review changes" now open the item in Studio's area. diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index df366a5ccaf..cdaddf1efbd 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -95,11 +95,7 @@ export const en: TranslationData = { nav_permission_set_assignment: { label: 'Permission Set Assignment' }, // Access Control - nav_positions: { label: 'Positions' }, - nav_capabilities: { label: 'Capabilities' }, - nav_permission_sets: { label: 'Permission Sets' }, nav_sharing_rules: { label: 'Sharing Rules' }, - nav_declared_sharing_rules: { label: 'Declared Sharing Rules' }, nav_record_shares: { label: 'Record Shares' }, nav_queues: { label: 'Queues' }, nav_queue_members: { label: 'Queue Members' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 1308ad20f54..b1a34935c5f 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -92,11 +92,7 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546', 'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8', 'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298', - 'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0', - 'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f', - 'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a', 'apps.setup.navigation.nav_sharing_rules.label': 'ceaa66fa5f1df4ad', - 'apps.setup.navigation.nav_declared_sharing_rules.label': 'a3371ec9975d5b0f', 'apps.setup.navigation.nav_record_shares.label': 'e261d340ceb25b9a', 'apps.setup.navigation.nav_sso_providers.label': '7b06b6a6abf53ba4', 'apps.setup.navigation.nav_api_keys.label': '8cda9851248b0490', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 476960c444e..4e2f74aece4 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -62,11 +62,7 @@ export const esES: TranslationData = { nav_position_holders: { label: 'Titulares de posiciones' }, nav_permission_set_assignment: { label: 'Asignación de conjuntos de permisos' }, - nav_positions: { label: 'Posiciones' }, - nav_capabilities: { label: 'Capacidades' }, - nav_permission_sets: { label: 'Conjuntos de Permisos' }, nav_sharing_rules: { label: 'Reglas de Compartición' }, - nav_declared_sharing_rules: { label: 'Reglas de Compartición Declaradas' }, nav_record_shares: { label: 'Registros Compartidos' }, nav_queues: { label: 'Colas' }, nav_queue_members: { label: 'Miembros de Colas' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index fae64b60a41..b0ada86f820 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -92,11 +92,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546', 'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8', 'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298', - 'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0', - 'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f', - 'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a', 'apps.setup.navigation.nav_sharing_rules.label': 'ceaa66fa5f1df4ad', - 'apps.setup.navigation.nav_declared_sharing_rules.label': 'a3371ec9975d5b0f', 'apps.setup.navigation.nav_record_shares.label': 'e261d340ceb25b9a', 'apps.setup.navigation.nav_sso_providers.label': '7b06b6a6abf53ba4', 'apps.setup.navigation.nav_api_keys.label': '8cda9851248b0490', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index eab694df2a6..89a8f775e7c 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -62,11 +62,7 @@ export const jaJP: TranslationData = { nav_position_holders: { label: 'ポジション保持者' }, nav_permission_set_assignment: { label: '権限セットの割り当て' }, - nav_positions: { label: 'ポジション' }, - nav_capabilities: { label: 'ケイパビリティ' }, - nav_permission_sets: { label: '権限セット' }, nav_sharing_rules: { label: '共有ルール' }, - nav_declared_sharing_rules: { label: '宣言済みの共有ルール' }, nav_record_shares: { label: 'レコード共有' }, nav_queues: { label: 'キュー' }, nav_queue_members: { label: 'キューメンバー' }, diff --git a/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts b/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts index f83b37e0109..4268b8fd726 100644 --- a/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts +++ b/packages/platform-objects/src/apps/translations/setup-nav-dead-key-tombstone.test.ts @@ -57,6 +57,20 @@ // entry id (example apps, docs, test fixtures) — so // for that one the grep is no evidence, and the // assertions below are the whole claim. +// nav_positions the Setup metadata catalog (ADR-0146 D1, M-1 +// nav_capabilities ruling A): Setup carries no metadata catalog. +// nav_permission_sets The four entries opened the registry types' +// nav_declared_sharing_ environment-scope lists, which Studio's "All +// rules metadata" area serves now, with the on/off switch +// on each item's page; Setup's URLs redirect there. +// The first three were plugin-security's, the last +// plugin-sharing's; both depend on this package, so +// the declaring side is pinned in their own tests. +// Measured differently: the grep answers zero for +// `nav_capabilities` and `nav_declared_sharing_rules` +// but not for the other two, which fixtures and an +// ADR still spell, so for those the assertions below +// are the whole claim. // nav_packaged_automation Setup › Packaged automation (ADR-0146 D11): its // flows half moved into Studio (D4), and its actions // half is its own entry, `nav_packaged_actions`. @@ -100,10 +114,14 @@ const DEAD_SETUP_NAV_IDS = [ 'nav_accounts', 'nav_approval_processes', 'nav_audit_log_browser', + 'nav_capabilities', + 'nav_declared_sharing_rules', 'nav_device_codes', 'nav_jwks', 'nav_metadata', 'nav_packaged_automation', + 'nav_permission_sets', + 'nav_positions', 'nav_user_preferences', 'nav_verifications', ] as const; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 5d2b9051c98..ec80aaf76b4 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -92,11 +92,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_invitations.label': '4ebb9fd13de7a546', 'apps.setup.navigation.nav_position_holders.label': '627cacb50fd1b0d8', 'apps.setup.navigation.nav_permission_set_assignment.label': 'fcf7d1b69ee9c298', - 'apps.setup.navigation.nav_positions.label': '517a2743f949b5f0', - 'apps.setup.navigation.nav_capabilities.label': '0b59005e10add41f', - 'apps.setup.navigation.nav_permission_sets.label': '4addfe2b8b28b78a', 'apps.setup.navigation.nav_sharing_rules.label': 'ceaa66fa5f1df4ad', - 'apps.setup.navigation.nav_declared_sharing_rules.label': 'a3371ec9975d5b0f', 'apps.setup.navigation.nav_record_shares.label': 'e261d340ceb25b9a', 'apps.setup.navigation.nav_sso_providers.label': '7b06b6a6abf53ba4', 'apps.setup.navigation.nav_api_keys.label': '8cda9851248b0490', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 0c31b58ebf3..ca51232285d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -65,11 +65,7 @@ export const zhCN: TranslationData = { nav_position_holders: { label: '岗位持有人' }, nav_permission_set_assignment: { label: '权限集分配' }, - nav_positions: { label: '岗位' }, - nav_capabilities: { label: '能力' }, - nav_permission_sets: { label: '权限集' }, nav_sharing_rules: { label: '共享规则' }, - nav_declared_sharing_rules: { label: '已声明的共享规则' }, nav_record_shares: { label: '记录共享' }, nav_queues: { label: '队列' }, nav_queue_members: { label: '队列成员' }, diff --git a/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts b/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts index eb5e8fba1a7..5c010488167 100644 --- a/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts +++ b/packages/plugins/plugin-security/src/people-org-assignment-nav-contribution.test.ts @@ -114,21 +114,22 @@ describe('SecurityPlugin — Setup People & Organization assignment entries (ADR } }); - it('carries the same gate as the Positions / Permission Sets entries: none of its own', async () => { + it('carries no gate of its own, and the catalog entries are gone (ADR-0146 D1)', async () => { const manifest = await registeredManifest(); - const items = (manifest.navigationContributions as Contribution[]).flatMap((c) => c.items); + const contributions = manifest.navigationContributions as Contribution[]; + const items = contributions.flatMap((c) => c.items); const byId = new Map(items.map((i) => [i.id, i])); - const positions = byId.get('nav_positions'); - const permissionSets = byId.get('nav_permission_sets'); - // Control: the catalog entries really are in this manifest, so the - // comparison below is against a real gate, not two undefined lookups. - expect(positions).toBeDefined(); - expect(permissionSets).toBeDefined(); + // Setup carries no metadata catalog: positions, capabilities and permission + // sets are Studio's "All metadata" area's, so this plugin contributes no + // entry into Access Control. + for (const id of ['nav_positions', 'nav_capabilities', 'nav_permission_sets']) { + expect(byId.has(id), id).toBe(false); + } + expect(contributions.filter((c) => c.group === 'group_access_control')).toEqual([]); for (const id of [POSITION_HOLDERS.id, PERMISSION_SET_ASSIGNMENT.id]) { const entry = byId.get(id); expect(entry, id).toBeDefined(); - expect(gateOf(entry!), id).toEqual(gateOf(positions!)); - expect(gateOf(entry!), id).toEqual(gateOf(permissionSets!)); + expect(Object.values(gateOf(entry!)).filter((v) => v !== undefined), id).toEqual([]); } }); @@ -149,13 +150,11 @@ describe('SecurityPlugin — Setup People & Organization assignment entries (ADR expect(all.filter((i) => i.componentRef === PERMISSION_SET_ASSIGNMENT.componentRef)).toHaveLength(1); }); - it('without this plugin, neither entry appears — hidden exactly where the catalog entries are', async () => { + it('without this plugin, neither entry appears', async () => { const nav = await renderedSetup(false); const ids = nav.flatMap((n) => n.children ?? []).map((i) => i.id); // Control: the fold still serves platform-objects' entries. expect(ids).toContain('nav_users'); - expect(ids).not.toContain('nav_positions'); - expect(ids).not.toContain('nav_permission_sets'); expect(ids).not.toContain(POSITION_HOLDERS.id); expect(ids).not.toContain(PERMISSION_SET_ASSIGNMENT.id); }); diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 4024fcc2133..1f088f768d2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1629,32 +1629,20 @@ export class SecurityPlugin implements Plugin { // can resolve them by name when SecurityPlugin middleware queries // `metadata.list('permissions')`. permissions: this.bootstrapPermissionSets, - // ADR-0029 D7 — contribute the RBAC entries into the Setup app's - // `group_access_control` slot. This plugin owns the security catalog, so - // it ships its menu too; when the plugin is absent the entries don't - // appear. [ADR-0131 D3/D7] Each entry opens Setup's list of the registry - // type in its environment scope — the catalog has no object behind it. + // [ADR-0146 D1] No catalog entries: Setup carries no metadata catalog. + // Positions, capabilities and permission sets are listed and switched + // on and off in Studio's "All metadata" area (`/studio/~all/metadata`), + // and an old Setup link to them redirects there. Setup keeps the people + // pages below (D7). navigationContributions: [ - { - app: 'setup', - group: 'group_access_control', - priority: 100, - items: [ - { id: 'nav_positions', type: 'url', label: 'Positions', url: '/apps/setup/metadata/position?scope=environment', icon: 'shield-check' }, - { id: 'nav_capabilities', type: 'url', label: 'Capabilities', url: '/apps/setup/metadata/capability?scope=environment', icon: 'badge-check' }, - { id: 'nav_permission_sets', type: 'url', label: 'Permission Sets', url: '/apps/setup/metadata/permission?scope=environment', icon: 'lock' }, - ], - }, { // [ADR-0146 D7] The two ASSIGNMENT pages — who holds a position, and // which users carry a permission set. They sit with the people they - // assign (People & Organization), not with the catalog above. The - // console binds each `componentRef` to its page (app-shell - // `builtinComponents`), and the pages read and write this plugin's - // assignment rows, so the entries live and die with this plugin like - // the catalog entries. Gate: none of their own, exactly like - // Positions / Permission Sets — the Setup app's `setup.access` and the - // ungated group anchor decide who sees them (ADR-0146 D7: the scope of + // assign (People & Organization). The console binds each + // `componentRef` to its page (app-shell `builtinComponents`), and the + // pages read and write this plugin's assignment rows, so the entries + // live and die with this plugin. Gate: none of their own — the Setup + // app's `setup.access` and the ungated group anchor decide who sees them (ADR-0146 D7: the scope of // `setup.access` and the delegated-administrator gate), and the writes // the pages make keep their own server-side gates (the ADR-0090 D12 // delegated-admin gate among them). Priority 200 keeps them after diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index a06e4d333f7..a7beeef2083 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -653,15 +653,15 @@ export class SharingServicePlugin implements Plugin { SysAssignmentCursor, ], // ADR-0029 D7 — contribute the sharing entries into the Setup app's - // `group_access_control` slot (priority 200 so they sit after plugin- - // security's Roles / Permission Sets). This plugin owns these objects (K2). + // `group_access_control` slot (priority 200). This plugin owns these + // objects (K2). // - // [ADR-0131 D6, regime C] A sharing rule has two sources, and Setup lists - // each where it lives: `nav_sharing_rules` opens the organization's own - // rules (`sys_sharing_rule` rows), `nav_declared_sharing_rules` the rules - // packages declare — registry items with no object behind them, so the - // entry is a `url` to Setup's list of the registry type in its environment - // scope, as plugin-security's `nav_permission_sets` is. + // [ADR-0131 D6, regime C] A sharing rule has two sources: `nav_sharing_rules` + // opens the organization's own rules (`sys_sharing_rule` rows). The rules + // packages declare are registry items with no object behind them, and + // [ADR-0146 D1] Setup carries no metadata catalog: they are listed and + // switched on and off in Studio's "All metadata" area, so no entry here + // opens them. navigationContributions: [ { app: 'setup', @@ -669,7 +669,6 @@ export class SharingServicePlugin implements Plugin { priority: 200, items: [ { id: 'nav_sharing_rules', type: 'object', label: 'Sharing Rules', objectName: 'sys_sharing_rule', icon: 'share-2', requiresObject: 'sys_sharing_rule', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_declared_sharing_rules', type: 'url', label: 'Declared Sharing Rules', url: '/apps/setup/metadata/sharing_rule?scope=environment', icon: 'package', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_record_shares', type: 'object', label: 'Record Shares', objectName: 'sys_record_share', viewName: 'all_shares', icon: 'link', requiresObject: 'sys_record_share', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_queues', type: 'object', label: 'Queues', objectName: 'sys_queue', icon: 'inbox', requiresObject: 'sys_queue', requiredPermissions: ['manage_platform_settings'] }, { id: 'nav_queue_members', type: 'object', label: 'Queue Members', objectName: 'sys_queue_member', icon: 'users', requiresObject: 'sys_queue_member', requiredPermissions: ['manage_platform_settings'] }, diff --git a/ui/apps/console/src/AppContent.tsx b/ui/apps/console/src/AppContent.tsx index be884c6ab79..5a9130e306a 100644 --- a/ui/apps/console/src/AppContent.tsx +++ b/ui/apps/console/src/AppContent.tsx @@ -11,7 +11,7 @@ import { lazy, Suspense, useMemo, type ReactNode } from 'react'; import { Route, useParams, useLocation, Navigate } from 'react-router-dom'; -import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LauncherAppsProvider, LoadingScreen, useAdapter } from '@object-ui/app-shell'; +import { DefaultAppContent, ENVIRONMENT_SCOPE_QUERY, LauncherAppsProvider, LoadingScreen, setupMetadataStudioPath, useAdapter } from '@object-ui/app-shell'; import { AdapterCtx } from '@object-ui/react'; import { MePermissionsProvider, ServerReadScope } from '@object-ui/permissions'; import { createAuthenticatedFetch } from '@object-ui/auth'; @@ -46,9 +46,9 @@ const DocsLayout = lazy(() => import('./pages/DocsLayout')); /** * Forwards legacy `system/objects[/:objectName]` URLs to the metadata-admin - * engine, preserving the active-app prefix. The engine routes are the - * REST-style `…/metadata/object/:name` (edit) and `…/metadata/object` (list), - * declared by `DefaultAppContent` in `@object-ui/app-shell`. + * engine: the REST-style `object/:name` (edit) and `object` (list) pages, which + * Studio's "All metadata" area serves since Setup carries no metadata editor + * (objectstack ADR-0146 D1), so the active-app prefix is not kept. * * NOT `…/component/metadata/resource/:name?type=object` (objectui#3639). * That spelling pre-dates the REST-style nesting and is no longer a page at @@ -62,17 +62,18 @@ const DocsLayout = lazy(() => import('./pages/DocsLayout')); function ObjectRedirect() { const { objectName } = useParams<{ objectName?: string }>(); const location = useLocation(); - const prefix = location.pathname.replace(/\/(system\/)?objects(\/.*)?$/, ''); - const target = objectName - ? `${prefix}/metadata/object/${objectName}` - : `${prefix}/metadata/object`; - return ; + // objectstack ADR-0146 D1 — straight to Studio's "All metadata" area, the + // page Setup's `…/metadata/object` route opens there. + const target = setupMetadataStudioPath(objectName ? `object/${objectName}` : 'object', location.search, location.hash); + return ; } /** * Forwards legacy `system/metadata[/:metadataType[/:itemName]]` URLs to the - * metadata-admin engine's own REST-style routes: `…/metadata` (directory), - * `…/metadata/:type` (one type's list), `…/metadata/:type/:name` (one item). + * metadata-admin engine's own REST-style routes: the directory, `:type` (one + * type's list), `:type/:name` (one item). Since objectstack ADR-0146 D1 they + * open Studio's "All metadata" area in one hop; a datasource keeps Setup's + * `…/metadata/datasource…` route, which D1 leaves in Setup. * The legacy page-based editor was removed once the server's `/api/v1/meta` * endpoint started emitting JSON Schema per type, letting the engine render * every type generically. @@ -92,13 +93,15 @@ function MetadataRedirect() { // `…/system/metadata/:type`, and the engine routes live at the app root // (`…/metadata/…`), not under `system/`. const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, ''); - const base = `${prefix}/metadata`; - const target = !metadataType - ? base + const rest = !metadataType + ? '' : itemName - ? `${base}/${encodeURIComponent(metadataType)}/${itemName}` - : `${base}/${encodeURIComponent(metadataType)}`; - return ; + ? `${encodeURIComponent(metadataType)}/${itemName}` + : encodeURIComponent(metadataType); + // objectstack ADR-0146 D1 — straight to Studio's "All metadata" area, query + // kept; a datasource stays on Setup's own route. + const target = setupMetadataStudioPath(rest, location.search, location.hash); + return ; } /** @@ -154,9 +157,10 @@ function SystemObjectRedirect({ objectName }: { objectName: string }) { /** * Forwards the retired `system/{roles,positions,permissions}` console pages - * onto the Setup CATALOG (objectui#7611): the metadata-admin list of the - * registry type, in its environment scope (`…/metadata/?scope=environment`, - * `catalog-scope.ts` in `@object-ui/app-shell`). + * onto the CATALOG (objectui#7611): the metadata-admin list of the registry + * type, in its environment scope (`?scope=environment`, `catalog-scope.ts` + * in `@object-ui/app-shell`), which Studio's "All metadata" area serves since + * Setup carries no metadata catalog (objectstack ADR-0146 D1). * * ADR-0131 D3 gives positions and permission sets one home, the environment * registry, and D7 says Setup lists that registry; the ruling that unblocked @@ -164,13 +168,14 @@ function SystemObjectRedirect({ objectName }: { objectName: string }) { * re-routed. The words map as they always did — `roles` was ADR-0090 D3's old * name for positions, and objectui#3655 decision A bound `permissions` to the * permission-set surface (layer 2), not the capability definitions (layer 1). - * Same prefix handling, and the same treatment of `location.search` / - * `location.hash`, as {@link SystemObjectRedirect}. + * The app prefix is not kept: the area is outside every app. As with + * {@link SystemObjectRedirect}, `location.search` / `location.hash` are not + * forwarded. */ function SystemCatalogRedirect({ type }: { type: 'position' | 'permission' }) { - const location = useLocation(); - const prefix = location.pathname.replace(/\/system\/[^/]+\/?$/, ''); - return ; + // objectstack ADR-0146 D1 — the catalog is Studio's, in the "All metadata" + // area's cross-package leg. + return ; } /** diff --git a/ui/apps/console/src/__tests__/AppContent.legacyRedirects.test.tsx b/ui/apps/console/src/__tests__/AppContent.legacyRedirects.test.tsx index 8e54df2181a..24b1fec4efc 100644 --- a/ui/apps/console/src/__tests__/AppContent.legacyRedirects.test.tsx +++ b/ui/apps/console/src/__tests__/AppContent.legacyRedirects.test.tsx @@ -33,6 +33,17 @@ * annotated the copy "keep identical to the original" — a copy is exactly what * this file must not become. * + * ## Since objectstack ADR-0146 D1 (N-28) + * + * Setup carries no metadata editor and no metadata catalog, and app-shell's + * canonical `…/metadata/*` routes themselves redirect into Studio's "All + * metadata" area. Aiming at them would be the same two-hop shape this file was + * written against, one level up, so the host now aims at Studio directly: + * `/studio//metadata/…`, the query kept (`?package=`, `?scope=`, + * `?review=`). A datasource is the exception — it stays in Setup — and still + * lands on the canonical route. The canonical probes are kept, so a host that + * drifted back onto them renders them and fails. + * * ## Scope * * This measures the HOST's rewrite only. What app-shell then does with the @@ -55,7 +66,7 @@ import { systemRoutes } from '../AppContent'; */ function ChainRecorder({ sink }: { sink: string[] }) { const location = useLocation(); - const here = `${location.pathname}${location.search}`; + const here = `${location.pathname}${location.search}${location.hash}`; if (sink[sink.length - 1] !== here) sink.push(here); return null; } @@ -87,6 +98,8 @@ function renderAt(url: string): string[] { } /> } /> + {/* The Studio app the host mounts beside `/apps/*` (objectstack ADR-0146 D1). */} + } /> } /> , @@ -101,80 +114,66 @@ function expectNoAliasAnywhere(chain: string[]) { expect(screen.queryByTestId('alias-directory')).not.toBeInTheDocument(); } -describe('console host legacy redirects → canonical metadata routes (objectui#3639)', () => { - it('system/metadata/:type reaches metadata/:type in ONE hop', () => { - const chain = renderAt('/apps/x/system/metadata/object'); - - // The whole point: two entries, i.e. a single ``. Before the fix - // this was three, with the alias in the middle. - expect(chain).toEqual(['/apps/x/system/metadata/object', '/apps/x/metadata/object']); - expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); - expectNoAliasAnywhere(chain); - }); - - it('system/metadata/:type/:name reaches metadata/:type/:name in ONE hop', () => { - const chain = renderAt('/apps/x/system/metadata/object/account'); - - expect(chain).toEqual([ - '/apps/x/system/metadata/object/account', - '/apps/x/metadata/object/account', - ]); - const probe = screen.getByTestId('canonical-item'); - expect(probe).toHaveTextContent('"type":"object"'); - expect(probe).toHaveTextContent('"name":"account"'); - expectNoAliasAnywhere(chain); - }); - - it('bare system/metadata reaches the metadata directory in ONE hop', () => { - // The typeless arm used to aim at `component/metadata/directory`, the - // second alias — a different route from the resource one, same detour. - const chain = renderAt('/apps/x/system/metadata'); - - expect(chain).toEqual(['/apps/x/system/metadata', '/apps/x/metadata']); - expect(screen.getByTestId('canonical-directory')).toBeInTheDocument(); +describe('console host legacy redirects → Studio\'s All metadata area, in ONE hop (objectui#3639, ADR-0146 D1)', () => { + /** One ``, straight to Studio: no stop on Setup's canonical route or on the alias. */ + function expectStudioInOneHop(chain: string[], from: string, to: string) { + expect(chain).toEqual([from, to]); + expect(screen.getByTestId('studio')).toBeInTheDocument(); + expect(screen.queryByTestId('canonical-list')).not.toBeInTheDocument(); + expect(screen.queryByTestId('canonical-item')).not.toBeInTheDocument(); + expect(screen.queryByTestId('canonical-directory')).not.toBeInTheDocument(); expectNoAliasAnywhere(chain); + } + + it.each([ + ['/apps/x/system/metadata/object', '/studio/~all/metadata/object'], + ['/apps/x/system/metadata/object/account', '/studio/~all/metadata/object/account'], + ['/apps/x/system/metadata', '/studio/~all/metadata'], + ['/apps/x/system/objects/account', '/studio/~all/metadata/object/account'], + ['/apps/x/system/objects', '/studio/~all/metadata/object'], + ['/apps/my-app/system/metadata/permission/admin', '/studio/~all/metadata/permission/admin'], + ])('%s → %s', (from, to) => { + expectStudioInOneHop(renderAt(from), from, to); }); - it('system/objects/:objectName reaches metadata/object/:name in ONE hop', () => { - const chain = renderAt('/apps/x/system/objects/account'); - - expect(chain).toEqual(['/apps/x/system/objects/account', '/apps/x/metadata/object/account']); - const probe = screen.getByTestId('canonical-item'); - expect(probe).toHaveTextContent('"type":"object"'); - expect(probe).toHaveTextContent('"name":"account"'); - expectNoAliasAnywhere(chain); + it.each([ + ['/apps/x/system/metadata/object?package=crm', '/studio/crm/metadata/object?package=crm'], + ['/apps/x/system/metadata/position?scope=environment', '/studio/~all/metadata/position?scope=environment'], + ['/apps/x/system/metadata/object/account?review=1', '/studio/~all/metadata/object/account?review=1'], + ['/apps/x/system/objects/account?package=crm#fields', '/studio/crm/metadata/object/account?package=crm#fields'], + ['/apps/x/system/metadata/object?keep=me#frag', '/studio/~all/metadata/object?keep=me#frag'], + ])('the query and hash are carried, onto the leg the query names: %s', (from, to) => { + // The old chain arrived with a bare path (objectui#3639 measured it, and + // kept it). The area gives `?package=`, `?scope=` and `?review=` their + // Setup meaning, so they are kept now, and the leg is chosen here, so the + // area adds no second hop. + expectStudioInOneHop(renderAt(from), from, to); }); - it('bare system/objects reaches the object list in ONE hop', () => { - const chain = renderAt('/apps/x/system/objects'); - - expect(chain).toEqual(['/apps/x/system/objects', '/apps/x/metadata/object']); - expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); - expectNoAliasAnywhere(chain); + it.each([ + ['/apps/x/system/positions', '/studio/~all/metadata/position?scope=environment'], + ['/apps/x/system/roles', '/studio/~all/metadata/position?scope=environment'], + ['/apps/x/system/permissions', '/studio/~all/metadata/permission?scope=environment'], + ])('the retired catalog page %s opens the catalog in Studio', (from, to) => { + expectStudioInOneHop(renderAt(from), from, to); }); - it('the app prefix is preserved, not just stripped to the root', () => { - // `prefix` is cut with a regex off `location.pathname`; a mistake there is - // invisible in the single-segment cases above. + it('a datasource stays in Setup: the canonical route, the app prefix preserved', () => { + // ADR-0146 D1 places the datasource in Setup, and the area sends it back + // there, so the host must not send it to Studio. const chain = renderAt('/apps/my-app/system/metadata/datasource'); - expect(chain).toEqual([ - '/apps/my-app/system/metadata/datasource', - '/apps/my-app/metadata/datasource', - ]); + expect(chain).toEqual(['/apps/my-app/system/metadata/datasource', '/apps/my-app/metadata/datasource']); + expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"datasource"'); + expect(screen.queryByTestId('studio')).not.toBeInTheDocument(); expectNoAliasAnywhere(chain); }); - it('MEASUREMENT: the endpoint is byte-identical to what the old two-hop produced', () => { - // Equivalence, not improvement: `LegacyMetadataRedirect` dropped - // `location.search` on its resource arm and the host never forwarded the - // incoming query either, so the old chain also arrived with a bare path. - // Preserving query/hash would be a real behaviour change and is NOT part of - // this issue — pinned here so a future reader can see the omission was - // measured rather than overlooked. - const chain = renderAt('/apps/x/system/metadata/object?keep=me#frag'); + it('a datasource item stays in Setup too', () => { + const chain = renderAt('/apps/x/system/metadata/datasource/main'); - expect(chain[chain.length - 1]).toBe('/apps/x/metadata/object'); - expect(chain).toHaveLength(2); + expect(chain).toEqual(['/apps/x/system/metadata/datasource/main', '/apps/x/metadata/datasource/main']); + expect(screen.getByTestId('canonical-item')).toHaveTextContent('"name":"main"'); + expectNoAliasAnywhere(chain); }); }); diff --git a/ui/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx b/ui/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx index 045fa1d4999..502037fa1c4 100644 --- a/ui/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx +++ b/ui/apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx @@ -252,6 +252,8 @@ function renderConsoleAt(initialUrl: string) { path="/apps/:appName/*" element={} /> + {/* The Studio app the host mounts beside `/apps/*` (objectstack ADR-0146 D1). */} + studio} /> landing} /> } /> @@ -338,7 +340,7 @@ describe('system-hub entries reach the framework system objects (objectui#3655)' }); }); -describe('the catalog entries reach the Setup catalog (objectui#7611)', () => { +describe('the catalog entries reach the catalog, in Studio (objectui#7611, ADR-0146 D1)', () => { /** * `roles`, `positions` and `permissions` name the security CATALOG, which * ADR-0131 D3 moves into the environment registry, so they land on the @@ -346,42 +348,40 @@ describe('the catalog entries reach the Setup catalog (objectui#7611)', () => { * and `positions` still converge on ONE surface (ADR-0090 D3 renamed * `sys_role` -> `sys_position`), and `permissions` still means the * permission-set surface (objectui#3655 decision A). + * + * Setup carries no metadata catalog since objectstack ADR-0146 D1 (N-28): + * the list is Studio's "All metadata" area's, on its cross-package leg, and + * the URL goes there directly rather than through Setup's own redirect. */ it.each([ - ['/apps/setup/system/roles', '/apps/setup/metadata/position?scope=environment', 'position'], - ['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment', 'position'], - ['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment', 'permission'], - ])('%s reaches %s in ONE hop', async (url, target, type) => { + ['/apps/setup/system/roles', '/studio/~all/metadata/position?scope=environment'], + ['/apps/setup/system/positions', '/studio/~all/metadata/position?scope=environment'], + ['/apps/setup/system/permissions', '/studio/~all/metadata/permission?scope=environment'], + // Outside every app, so the app prefix is not part of the target. + ['/apps/my-app/system/permissions', '/studio/~all/metadata/permission?scope=environment'], + ])('%s reaches %s in ONE hop', async (url, target) => { renderConsoleAt(url); - expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent(type); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); expect(chain).toEqual([url, target]); - // Never the row page the URL used to land on. + // Never the row page the URL used to land on, nor Setup's catalog list. expect(screen.queryByTestId('object-view')).not.toBeInTheDocument(); - }); - - it('preserves the app prefix', () => { - renderConsoleAt('/apps/my-app/system/permissions'); - - expect(chain).toEqual([ - '/apps/my-app/system/permissions', - '/apps/my-app/metadata/permission?scope=environment', - ]); + expect(screen.queryByTestId('metadata-resource-list-page')).not.toBeInTheDocument(); }); /** - * The zero-app branch declares the metadata routes too, so the catalog is - * reachable on a deployment with no app — unlike the object pages the three - * URLs used to land on, which end at the "No Apps Configured" guard there. + * The zero-app branch declares the routes too, so the catalog is reachable + * on a deployment with no app — unlike the object pages the three URLs used + * to land on, which end at the "No Apps Configured" guard there. */ it.each([ - ['/apps/setup/system/positions', '/apps/setup/metadata/position?scope=environment'], - ['/apps/setup/system/permissions', '/apps/setup/metadata/permission?scope=environment'], + ['/apps/setup/system/positions', '/studio/~all/metadata/position?scope=environment'], + ['/apps/setup/system/permissions', '/studio/~all/metadata/permission?scope=environment'], ])('with no apps, %s still reaches the catalog', async (url, target) => { metadataApps = []; renderConsoleAt(url); - expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument(); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); expect(chain).toEqual([url, target]); }); }); diff --git a/ui/e2e/live/declared-sharing-rules-reach-13091.spec.ts b/ui/e2e/live/declared-sharing-rules-reach-13091.spec.ts index c2a114c5562..5d5b27acfed 100644 --- a/ui/e2e/live/declared-sharing-rules-reach-13091.spec.ts +++ b/ui/e2e/live/declared-sharing-rules-reach-13091.spec.ts @@ -16,7 +16,9 @@ import { test, expect, type Locator } from '@playwright/test'; * The unit pins for the same page are in * `ResourceListPage.declaredSharingRules-12982.test.tsx`. */ -const LIST = '/apps/setup/metadata/sharing_rule?scope=environment'; +// Studio's "All metadata" area, cross-package leg: the catalog's home since +// Setup carries none (objectstack ADR-0146 D1). +const LIST = '/studio/~all/metadata/sharing_rule?scope=environment'; async function boxOf(locator: Locator) { const box = await locator.boundingBox(); diff --git a/ui/e2e/live/studio-editor.spec.ts b/ui/e2e/live/studio-editor.spec.ts index 1723497aac5..8d4df2a4a74 100644 --- a/ui/e2e/live/studio-editor.spec.ts +++ b/ui/e2e/live/studio-editor.spec.ts @@ -3,7 +3,9 @@ import { test, expect, type APIRequestContext } from '@playwright/test'; /** * Live e2e for the Studio metadata page editor — guards the "Add block works * and blocks are configurable" contract the SDUI runtime depends on. Drives the - * real editor at /apps/:app/metadata/page/:name against the running stack. + * real editor at /studio/~all/metadata/page/:name (Studio's "All metadata" + * area; Setup carries no metadata editor, objectstack ADR-0146 D1) against the + * running stack. * * The page under edit is one the test authors in the environment: a copy of the * showcase's `showcase_project_workspace` (its regions and blocks) under a fresh @@ -53,7 +55,7 @@ async function removePage(request: APIRequestContext, name: string) { test('the page editor loads with its config sections', async ({ page, request }) => { const { name, regions } = await authorEditablePage(request); try { - await page.goto(`/apps/showcase_app/metadata/page/${name}`); + await page.goto(`/studio/~all/metadata/page/${name}`); await expect(page.getByTestId('metadata-edit-page')).toBeVisible(); // The environment's own page: no lock stands between the test and the editor. await expect(page.getByText(/is locked and cannot be edited/i)).toHaveCount(0); @@ -72,7 +74,7 @@ test('the page editor loads with its config sections', async ({ page, request }) test('Add block opens a block-type picker with configurable block kinds, and the added block is stored', async ({ page, request }) => { const { name, firstRegionBlocks } = await authorEditablePage(request); try { - await page.goto(`/apps/showcase_app/metadata/page/${name}`); + await page.goto(`/studio/~all/metadata/page/${name}`); await expect(page.getByTestId('metadata-edit-page')).toBeVisible(); await page.getByRole('button', { name: /Add block/i }).first().click(); diff --git a/ui/e2e/live/studio-object-designer.spec.ts b/ui/e2e/live/studio-object-designer.spec.ts index d19eef2bd46..8e7470a4114 100644 --- a/ui/e2e/live/studio-object-designer.spec.ts +++ b/ui/e2e/live/studio-object-designer.spec.ts @@ -19,12 +19,12 @@ import { test, expect, type APIRequestContext, type Page } from '@playwright/tes * 5/5 run of the enlarged allowlist); the readings are on that card's PR. */ -// The object designer is reached through the showcase's own app: a stock -// showcase boot does not serve the Studio app (`com.objectstack.studio`; the -// served apps are what `GET /api/v1/meta/app` lists), and the metadata routes -// resolve under any served app. +// The object designer is reached through Studio's "All metadata" area, on the +// package's own leg (`/studio//metadata/...`): Setup carries no +// metadata editor (objectstack ADR-0146 D1), and its old URLs redirect here. const PKG = 'com.example.showcase'; -const NEW_OBJ = `/apps/showcase_app/metadata/object/new?package=${PKG}`; +const newObjectIn = (pkg: string) => `/studio/${pkg}/metadata/object/new?package=${pkg}`; +const NEW_OBJ = newObjectIn(PKG); const API = process.env.LIVE_API_URL || 'http://localhost:3000'; /** @@ -75,7 +75,7 @@ test('F4: adding a picklist + an empty option row does not trip spec validation, const pkgId = await authorWritablePackage(request, stamp); const objName = `e2e_asset${stamp}`; try { - await page.goto(`/apps/showcase_app/metadata/object/new?package=${pkgId}`); + await page.goto(newObjectIn(pkgId)); await page.getByTestId('object-name-input').fill(objName); await addField(page, 'Picklist'); // Give the picklist its option source first: a select with no options is a diff --git a/ui/packages/app-shell/src/console/AppContent.tsx b/ui/packages/app-shell/src/console/AppContent.tsx index 6cb2274d3e1..b3b9d20ef12 100644 --- a/ui/packages/app-shell/src/console/AppContent.tsx +++ b/ui/packages/app-shell/src/console/AppContent.tsx @@ -70,6 +70,7 @@ import { resolveSetupAppPath } from './ConsoleShell.js'; import { APP_ROUTE_METADATA_READS, collectRoutePaths, metadataReadsForAppRoute, type LazyMetadataType } from './appRouteMetadataReads.js'; import { enterAppBundle, readAppObjectScope } from './appObjectScope.js'; import { preferLocal } from '../utils/preferLocal.js'; +import { packagedAutomationStudioPath, setupMetadataRest, setupMetadataStudioPath } from '../views/studio-design/setupMetadataRedirect.js'; import { SkeletonDashboard, SkeletonDetail, SkeletonGrid } from '../skeletons/index.js'; // Route-based code splitting — lazy-load less-frequently-used routes. @@ -136,14 +137,13 @@ const RecordFormPage = routeChunk.lazy(() => import('../views/RecordFormPage.js' const ComponentNavView = routeChunk.lazy(() => import('../views/ComponentNavView.js').then(m => m.ComponentNavView)); const ObjectDataPage = routeChunk.lazy(() => import('../views/ObjectDataPage.js').then(m => m.ObjectDataPage)); -// Metadata admin — mounted under /apps/:app/metadata. Lives at the top -// level so URLs read like a normal nested resource (RFC-style) instead of -// piggy-backing on the legacy ComponentRegistry fan-out. -const MetadataDirectoryPage = routeChunk.lazy(() => import('../views/metadata-admin/index.js').then(m => m.MetadataDirectoryPage)); +// Metadata admin — mounted under /apps/:app/metadata for the datasource +// alone (objectstack ADR-0146 D1): every other type's URL there opens Studio's +// "All metadata" area (`SetupMetadataRoute`), so these rows are not preloaded +// in `APP_ROUTE_CHUNKS`: most arrivals render a redirect, not the page. const MetadataResourceListPage = routeChunk.lazy(() => import('../views/metadata-admin/index.js').then(m => m.MetadataResourceListPage)); const MetadataResourceEditPage = routeChunk.lazy(() => import('../views/metadata-admin/index.js').then(m => m.MetadataResourceEditPage)); const MetadataResourceHistoryPage = routeChunk.lazy(() => import('../views/metadata-admin/index.js').then(m => m.MetadataResourceHistoryPage)); -const MetadataDiagnosticsPage = routeChunk.lazy(() => import('../views/metadata-admin/index.js').then(m => m.MetadataDiagnosticsPage)); // App authoring + dashboard editor pages — sourced from // @object-ui/plugin-designer so third-party hosts can opt out by not @@ -163,12 +163,6 @@ const MarketplacePackagePage = routeChunk.lazy(() => import('./marketplace/Marke * them). A path not listed renders an eagerly bundled element. */ const APP_ROUTE_CHUNKS: ReadonlyArray<{ path: string; chunk: { preload: () => Promise } }> = [ - { path: 'metadata', chunk: MetadataDirectoryPage }, - { path: 'metadata/_diagnostics', chunk: MetadataDiagnosticsPage }, - { path: 'metadata/:type', chunk: MetadataResourceListPage }, - { path: 'metadata/:type/new', chunk: MetadataResourceEditPage }, - { path: 'metadata/:type/:name', chunk: MetadataResourceEditPage }, - { path: 'metadata/:type/:name/history', chunk: MetadataResourceHistoryPage }, { path: ':objectName/new', chunk: RecordFormPage }, { path: ':objectName/data', chunk: ObjectDataPage }, { path: ':objectName/record/:recordId', chunk: RecordDetailView }, @@ -226,9 +220,10 @@ interface AppContentProps { /** * Bridges the global chat's "Review N change(s)" affordance (ADR-0033 Phase B) * to the metadata designer. The chat publishes a review target on `assistantBus`; - * this navigator — which lives inside the app router and knows the app base — - * routes to `/apps/:appName/metadata/:type/:name?review=1`, where the designer - * reloads the pending draft and opens its review/diff. + * this navigator routes to the item's page in Studio's "All metadata" area with + * `?review=1` (objectstack ADR-0146 D1: Setup carries no metadata editor), + * where the designer reloads the pending draft and opens its review/diff. A + * datasource, which stays in Setup, opens Setup's page the same way. */ function DraftReviewNavigator({ appName }: { appName: string | undefined }) { const { reviewSeq, reviewTarget } = useAssistant(); @@ -238,9 +233,8 @@ function DraftReviewNavigator({ appName }: { appName: string | undefined }) { if (reviewSeq === lastSeq.current || !reviewTarget || !appName) return; lastSeq.current = reviewSeq; const { type, name } = reviewTarget; - navigate( - `/apps/${appName}/metadata/${encodeURIComponent(type)}/${encodeURIComponent(name)}?review=1`, - ); + const item = `${encodeURIComponent(type)}/${encodeURIComponent(name)}`; + navigate(setupMetadataStudioPath(item, 'review=1') ?? `/apps/${appName}/metadata/${item}?review=1`); }, [reviewSeq, reviewTarget, appName, navigate]); return null; } @@ -1397,12 +1391,15 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = } /> {/* Studio / metadata designer — reachable with no active app so a fresh env can review + publish its first (AI-authored) drafts. */} - } /> - } /> - } /> - } /> - } /> - } /> + {/* objectstack ADR-0146 D1 — Setup carries no metadata catalog: + every route but the datasource's opens Studio's area + (`SetupMetadataRoute`). */} + } /> + } /> + } /> + } /> + } /> + } /> {/* #3610 — the legacy metadata aliases, mirrored from the with-app branch below. They are NOT a second copy of the page: both render `LegacyMetadataRedirect`, which forwards onto the canonical @@ -1511,13 +1508,16 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = path="metadata/package/*" element={} /> + {/* objectstack ADR-0146 D1 — Setup carries no metadata + catalog: every route but the datasource's opens Studio's + area (`SetupMetadataRoute`). */} - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> @@ -1549,6 +1549,11 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps = {/* objectui#13020: the app's report index, linked from the sidebar. */} } /> } /> + {/* objectstack ADR-0146 D11 — the retired Setup › Packaged + automation page (`automation:packaged`): a bookmark opens + Studio's Automations pillar. The static path outranks the + `:ns/:name` route below it. */} + } /> } /> {/* Legacy: old metadata routes built before the REST-style nesting landed. Redirect to the new /metadata/:type/... shape. */} @@ -1925,25 +1930,53 @@ function looksLikeRecordId(segment: string | undefined): boolean { return segment.length >= 6; } +/** + * objectstack ADR-0146 D1 (N-28) — a Setup metadata route. Setup carries no + * metadata editor and no metadata catalog, so every route but the datasource's + * opens Studio's "All metadata" area on the same page, query kept + * (`setupMetadataStudioPath`). The datasource stays in Setup (D1): its URLs + * render `children`, the metadata-admin page they always rendered. + */ +function SetupMetadataRoute({ children }: { children?: ReactNode }) { + const location = useLocation(); + const rest = setupMetadataRest(location.pathname) ?? ''; + const target = setupMetadataStudioPath(rest, location.search, location.hash); + if (target === null) return <>{children ?? }; + return ; +} + +/** + * A bookmark of the retired Setup › Packaged automation page (objectstack + * ADR-0146 D11) opens Studio's Automations pillar, where a packaged flow's + * switch and New similar are, the query kept (`packagedAutomationStudioPath`). + */ +function PackagedAutomationRedirect() { + const location = useLocation(); + return ; +} + /** * Translates pre-refactor metadata admin URLs * (`/apps/:app/component/metadata/resource/:name?type=:type`, - * `/apps/:app/component/metadata/directory`) into the new REST-style - * shape (`/apps/:app/metadata/:type/:name`). Keeps bookmarks and any - * still-unmigrated link producers working. + * `/apps/:app/component/metadata/directory`). A datasource URL lands on the + * REST-style shape Setup still serves (`/apps/:app/metadata/datasource/:name`); + * every other one opens Studio's "All metadata" area in one hop, the query it + * carried kept (objectstack ADR-0146 D1, `setupMetadataStudioPath`). */ function LegacyMetadataRedirect({ mode }: { mode: 'directory' | 'resource' }) { const location = useLocation(); const appBase = location.pathname.replace(/\/component\/metadata\/.*$/, ''); - if (mode === 'directory') { - return ; - } const sp = new URLSearchParams(location.search); - const type = sp.get('type') ?? ''; - const tail = location.pathname.match(/\/component\/metadata\/resource(\/.*)?$/)?.[1] ?? ''; - const target = type - ? `${appBase}/metadata/${encodeURIComponent(type)}${tail}${location.hash}` - : `${appBase}/metadata${location.hash}`; + const type = mode === 'resource' ? (sp.get('type') ?? '') : ''; + sp.delete('type'); + const tail = mode === 'resource' + ? (location.pathname.match(/\/component\/metadata\/resource(\/.*)?$/)?.[1] ?? '').replace(/^\/+/, '') + : ''; + const rest = type ? [encodeURIComponent(type), tail].filter(Boolean).join('/') : ''; + const target = setupMetadataStudioPath(rest, mode === 'directory' ? location.search : sp.toString(), location.hash); + if (target === null) { + return ; + } return ; } diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx index 3426082ebfa..8ad7f1a5e52 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.bootRedirectCoverage.test.tsx @@ -217,7 +217,10 @@ describe('boot-gate coverage — AppContent control arm (out-of-shape redirects) */ it('LegacyMetadataRedirect fires under a painted layout — no splash needed, none added', async () => { metadataApps = [{ name: 'crm', _packageId: 'com.example.crm' }]; - const all = await commitsAt('/apps/crm/component/metadata/directory'); + // The datasource alias: since objectstack ADR-0146 D1 (N-28) every other + // alias leaves the console for Studio, but the datasource's stays in + // Setup, so its rewrite is still one under the painted layout. + const all = await commitsAt('/apps/crm/component/metadata/resource?type=datasource'); expect(all.length, 'nothing rendered').toBeGreaterThan(0); expect( diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.emptyNavigationLanding-12079.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.emptyNavigationLanding-12079.test.tsx index 6ec820247f8..c171be81905 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.emptyNavigationLanding-12079.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.emptyNavigationLanding-12079.test.tsx @@ -134,6 +134,10 @@ function renderConsoleAt(initialUrl: string) { } /> + {/* The Studio app the host mounts beside `/apps/*`: an app's + `metadata` route opens its "All metadata" area (objectstack + ADR-0146 D1, N-28). */} + } /> , ); @@ -242,9 +246,11 @@ describe('AppContent — an app with empty navigation lands on an empty state (o ]; renderConsoleAt('/apps/studio'); - // `metadata:directory` resolves to the app's `metadata` route. - expect(await screen.findByTestId('metadata-directory-page')).toBeInTheDocument(); - expect(screen.getByTestId('pathname').textContent).toBe('/apps/studio/metadata'); + // `metadata:directory` resolves to the app's `metadata` route, which since + // objectstack ADR-0146 D1 (N-28) opens Studio's "All metadata" area: the + // landing reached the entry, not the empty state. + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + expect(screen.getByTestId('pathname').textContent).toBe('/studio/~all/metadata'); expect(screen.queryByTestId('app-no-access-empty-state')).not.toBeInTheDocument(); }); }); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.noAppComponentRoutes.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.noAppComponentRoutes.test.tsx index 2fbd050bb80..307aa5acf9a 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.noAppComponentRoutes.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.noAppComponentRoutes.test.tsx @@ -192,6 +192,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ })); import { AppContent } from '../AppContent'; +import { setupMetadataStudioPath } from '../../views/studio-design/setupMetadataRedirect'; /** * Reports the live URL so a redirect chain is visible as a URL, not just a @@ -210,7 +211,8 @@ function LocationProbe({ sink }: { sink: string[] }) { /** * VERBATIM transcription of `apps/console/src/AppContent.tsx`'s * `MetadataRedirect` (the `system/metadata/*` legs of its `systemRoutes` - * fragment), as it stands after objectui#3658 landed in `7883c0250`. Keep the + * fragment), as it stands after objectstack ADR-0146 D1 (N-28) sent it + * straight to Studio's "All metadata" area, a datasource excepted. Keep the * regex and the target construction identical to the original — this stub is * what makes the `sys-objects` hop measurable from inside app-shell. * @@ -233,13 +235,13 @@ function MetadataRedirectStub() { const { metadataType, itemName } = useParams<{ metadataType?: string; itemName?: string }>(); const location = useLocation(); const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, ''); - const base = `${prefix}/metadata`; - const target = !metadataType - ? base + const rest = !metadataType + ? '' : itemName - ? `${base}/${encodeURIComponent(metadataType)}/${itemName}` - : `${base}/${encodeURIComponent(metadataType)}`; - return ; + ? `${encodeURIComponent(metadataType)}/${itemName}` + : encodeURIComponent(metadataType); + const target = setupMetadataStudioPath(rest, location.search, location.hash); + return ; } /** @@ -273,6 +275,9 @@ function renderConsoleAt(initialUrl: string) { } /> + {/* The Studio app the host mounts beside `/apps/*`: Setup's retired + metadata URLs land here (objectstack ADR-0146 D1, N-28). */} + studio} /> landing} /> home} /> } /> @@ -335,16 +340,17 @@ describe('AppContent — zero-app component/metadata destinations (objectui#3610 // "Manage Objects" card name the canonical route — so what is pinned here // is the arrival path the host still declares for bookmarks and external // links. + // + // objectstack ADR-0146 D1 (N-28) moved the endpoint into Studio's "All + // metadata" area, and the host now aims there directly: still one hop. const chain = renderConsoleAt('/apps/setup/system/metadata/object'); - const page = await screen.findByTestId('metadata-resource-list-page'); - expect(page).toBeInTheDocument(); - expect(page).toHaveTextContent('object'); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); // Two entries, i.e. a single ``. The alias is not merely absent // from the end of the chain — it is absent from the chain. - expect(chain).toEqual(['/apps/setup/system/metadata/object', '/apps/setup/metadata/object']); + expect(chain).toEqual(['/apps/setup/system/metadata/object', '/studio/~all/metadata/object']); expect(chain.some((entry) => entry.includes('component/metadata'))).toBe(false); - expect(pathname()).toBe('/apps/setup/metadata/object'); + expect(pathname()).toBe('/studio/~all/metadata/object'); expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument(); }); @@ -353,12 +359,13 @@ describe('AppContent — zero-app component/metadata destinations (objectui#3610 // arm. That arm used to aim at `component/metadata/directory` — the second // alias, and the second blank screen without it — and since objectui#3658 // aims at the canonical directory route directly. + // Since objectstack ADR-0146 D1 (N-28) the directory is Studio's. const chain = renderConsoleAt('/apps/setup/system/metadata'); - expect(await screen.findByTestId('metadata-directory-page')).toBeInTheDocument(); - expect(chain).toEqual(['/apps/setup/system/metadata', '/apps/setup/metadata']); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + expect(chain).toEqual(['/apps/setup/system/metadata', '/studio/~all/metadata']); expect(chain.some((entry) => entry.includes('component/metadata'))).toBe(false); - expect(pathname()).toBe('/apps/setup/metadata'); + expect(pathname()).toBe('/studio/~all/metadata'); }); it('the legacy directory alias still resolves when entered directly', async () => { @@ -375,11 +382,14 @@ describe('AppContent — zero-app component/metadata destinations (objectui#3610 // it until #3660 re-pointed the card, and objectui#3743 retired the card // wall), so the alias is live input, not a museum piece — entered directly // here, exactly as `sys-datasources` above enters the resource alias. + // + // Since objectstack ADR-0146 D1 (N-28) it lands in Studio's "All metadata" + // area, still in one hop: this branch's alias aims there directly. const chain = renderConsoleAt('/apps/setup/component/metadata/directory'); - expect(await screen.findByTestId('metadata-directory-page')).toBeInTheDocument(); - expect(chain).toEqual(['/apps/setup/component/metadata/directory', '/apps/setup/metadata']); - expect(pathname()).toBe('/apps/setup/metadata'); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + expect(chain).toEqual(['/apps/setup/component/metadata/directory', '/studio/~all/metadata']); + expect(pathname()).toBe('/studio/~all/metadata'); expect(screen.queryByTestId('create-first-app-btn')).not.toBeInTheDocument(); expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument(); }); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.pseudoRouteSegments.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.pseudoRouteSegments.test.tsx index dfd6a79c106..7d0cbcfabc8 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.pseudoRouteSegments.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.pseudoRouteSegments.test.tsx @@ -254,6 +254,7 @@ vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ })); import { AppContent } from '../AppContent'; +import { setupMetadataStudioPath } from '../../views/studio-design/setupMetadataRedirect'; /** * Reports the live URL so a redirect chain is visible as a URL, not just a @@ -272,8 +273,9 @@ function LocationProbe({ sink }: { sink: string[] }) { /** * VERBATIM transcription of `apps/console/src/AppContent.tsx`'s * `MetadataRedirect` — the `system/metadata/*` legs of `systemRoutes` — as it - * stands after objectui#3658 landed in `7883c0250`. Keep the regex and the - * target construction identical to the original. + * stands after objectstack ADR-0146 D1 (N-28) sent it straight to Studio's + * "All metadata" area, a datasource excepted. Keep the regex and the target + * construction identical to the original. * * "Identical to the original" is a claim this file has to keep earning, and * between `7883c0250` and objectui#3669 it was false. The host was re-pointed @@ -300,13 +302,13 @@ function MetadataRedirectStub() { const { metadataType, itemName } = useParams<{ metadataType?: string; itemName?: string }>(); const location = useLocation(); const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, ''); - const base = `${prefix}/metadata`; - const target = !metadataType - ? base + const rest = !metadataType + ? '' : itemName - ? `${base}/${encodeURIComponent(metadataType)}/${itemName}` - : `${base}/${encodeURIComponent(metadataType)}`; - return ; + ? `${encodeURIComponent(metadataType)}/${itemName}` + : encodeURIComponent(metadataType); + const target = setupMetadataStudioPath(rest, location.search, location.hash); + return ; } /** @@ -345,6 +347,9 @@ function renderConsoleAt(initialUrl: string) { path="/apps/:appName/*" element={} /> + {/* The Studio app the host mounts beside `/apps/*`: Setup's retired + metadata URLs land here (objectstack ADR-0146 D1, N-28). */} + studio} /> landing} /> home} /> } /> @@ -374,9 +379,11 @@ describe('AppContent pseudo-routes — live input surface stays special (objectu ['/apps/ghost/system', 'system-hub-page', '/apps/ghost/system'], ['/apps/ghost/system/settings', 'system-settings-page', '/apps/ghost/system/settings'], ['/apps/ghost/system/audit-log', 'system-audit-log-page', '/apps/ghost/system/audit-log'], - ['/apps/ghost/metadata', 'metadata-directory-page', '/apps/ghost/metadata'], - ['/apps/ghost/metadata/_diagnostics', 'metadata-diagnostics-page', '/apps/ghost/metadata/_diagnostics'], - ['/apps/ghost/metadata/object/sys_user', 'metadata-resource-edit-page', '/apps/ghost/metadata/object/sys_user'], + // Since objectstack ADR-0146 D1 (N-28) every other metadata route opens + // Studio (the `it.each` below), so the datasource's — the routes that + // stay — carry this branch's coverage. + ['/apps/ghost/metadata/datasource', 'metadata-resource-list-page', '/apps/ghost/metadata/datasource'], + ['/apps/ghost/metadata/datasource/main', 'metadata-resource-edit-page', '/apps/ghost/metadata/datasource/main'], // The legacy aliases redirect onto the canonical `metadata/:type` route // (declared in this same branch) — the pathname pins the hop's direction. // These two ENTER the alias directly, so they are unaffected by the host @@ -384,7 +391,6 @@ describe('AppContent pseudo-routes — live input surface stays special (objectu // branch's `component/metadata/*` declarations covered now that nothing // passes through them on the way somewhere else. ['/apps/ghost/component/metadata/resource?type=datasource', 'metadata-resource-list-page', '/apps/ghost/metadata/datasource'], - ['/apps/ghost/component/metadata/directory', 'metadata-directory-page', '/apps/ghost/metadata'], // The host rewrite (`system/metadata/:type`) is its own `it` below — it is // the one URL here whose hop COUNT is part of the claim, and a shared // `it.each` body can only pin the endpoint. @@ -401,7 +407,23 @@ describe('AppContent pseudo-routes — live input surface stays special (objectu expect(screen.queryByTestId('app-not-available-retry')).not.toBeInTheDocument(); }); - it('unresolved app segment: the host rewrite reaches canonical metadata/:type in ONE hop', async () => { + it.each([ + ['/apps/ghost/metadata', '/studio/~all/metadata'], + ['/apps/ghost/metadata/_diagnostics', '/studio/~all/metadata/_diagnostics'], + ['/apps/ghost/metadata/object/sys_user', '/studio/~all/metadata/object/sys_user'], + ['/apps/ghost/component/metadata/directory', '/studio/~all/metadata'], + ])('unresolved app segment: the retired Setup metadata URL %s still takes the metadata branch, into Studio', async (url, studio) => { + // The metadata flag must still fire for these (the branch that redirects + // them is the pseudo-route's), so they reach Studio and never "App not + // available" (objectstack ADR-0146 D1, N-28). + const chain = renderConsoleAt(url); + + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + expect(chain).toEqual([url, studio]); + expect(screen.queryByTestId('app-not-available-retry')).not.toBeInTheDocument(); + }); + + it('unresolved app segment: the host rewrite reaches Studio in ONE hop', async () => { // Lifted out of the table above so the hop COUNT can be asserted, not just // the endpoint. The host's `MetadataRedirect` rewrites this URL onto the // canonical `metadata/:type` that the shell declares itself: two route @@ -412,17 +434,18 @@ describe('AppContent pseudo-routes — live input surface stays special (objectu // the middle stop, and this case went on measuring it anyway, because the // stub above had not been re-synced and the endpoint is byte-identical // either way. objectui#3669 is what that cost to notice. + // + // objectstack ADR-0146 D1 (N-28) moved the endpoint: the host now sends + // the URL straight to Studio's "All metadata" area, still one hop — no + // stop on the shell's own `metadata/:type`, which would redirect again. const chain = renderConsoleAt('/apps/ghost/system/metadata/object'); - expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument(); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); // Two entries, i.e. a single ``. The alias is not merely absent // from the end of the chain — it is absent from the chain. - expect(chain).toEqual(['/apps/ghost/system/metadata/object', '/apps/ghost/metadata/object']); + expect(chain).toEqual(['/apps/ghost/system/metadata/object', '/studio/~all/metadata/object']); expect(chain.some((entry) => entry.includes('component/metadata'))).toBe(false); - expect(pathname()).toBe('/apps/ghost/metadata/object'); - // Same two guarantees the table's body makes: the DEFAULT app's shell, and - // no "App not available". - expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm'); + expect(pathname()).toBe('/studio/~all/metadata/object'); expect(screen.queryByTestId('app-not-available-retry')).not.toBeInTheDocument(); }); @@ -442,23 +465,22 @@ describe('AppContent pseudo-routes — live input surface stays special (objectu expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm'); }); - it('the /apps/setup family is unaffected: the host rewrite still lands in ONE hop', async () => { + it('the /apps/setup family is unaffected: the host rewrite still lands in ONE hop (in Studio)', async () => { // The `setup` twin of the case above, lifted out of the table for the same // reason. `isSetupRoute` decides this URL rather than `isMetadataRoute`, // but the chain through the host stub is the same one, and it was stale in // the same way. const chain = renderConsoleAt('/apps/setup/system/metadata/object'); - expect(await screen.findByTestId('metadata-resource-list-page')).toBeInTheDocument(); - expect(chain).toEqual(['/apps/setup/system/metadata/object', '/apps/setup/metadata/object']); + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + expect(chain).toEqual(['/apps/setup/system/metadata/object', '/studio/~all/metadata/object']); expect(chain.some((entry) => entry.includes('component/metadata'))).toBe(false); - expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm'); }); /** A matched app resolves on its own — the flags change nothing here. */ it.each([ ['/apps/crm/system/marketplace', 'marketplace-page'], - ['/apps/crm/metadata/object', 'metadata-resource-list-page'], + ['/apps/crm/metadata/datasource', 'metadata-resource-list-page'], ['/apps/sales/system/settings', 'system-settings-page'], ])('a matched app segment renders its own shell: %s', async (url, testid) => { renderConsoleAt(url); @@ -479,8 +501,11 @@ describe('AppContent pseudo-routes — zero-app deployment (objectui#3638)', () it.each([ ['/apps/setup/system', 'system-hub-page'], ['/apps/setup/system/marketplace', 'marketplace-page'], - ['/apps/setup/metadata', 'metadata-directory-page'], - ['/apps/setup/metadata/object', 'metadata-resource-list-page'], + // Retired into Studio (objectstack ADR-0146 D1, N-28): still the + // pseudo-route branch, which is what redirects them. + ['/apps/setup/metadata', 'studio-probe'], + ['/apps/setup/metadata/object', 'studio-probe'], + ['/apps/setup/metadata/datasource', 'metadata-resource-list-page'], ['/apps/setup/component/metadata/resource?type=datasource', 'metadata-resource-list-page'], ['/apps/setup/create-app', 'create-app-page'], ])('with zero apps %s still reaches the pseudo-route branch', async (url, testid) => { diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx index 37f2dd8d053..8035754aae6 100644 --- a/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.setupItemRoutesVerdict-12706.test.tsx @@ -311,9 +311,11 @@ describe('AppContent — Setup\'s item routes wait on the app\'s access verdict it('CONTROL — an admitted caller gets the page, and nothing is probed', async () => { metadataApps = [CRM_APP, SETUP_APP]; - renderConsoleAt('/apps/setup/metadata/permission/crm_sales_user?scope=environment'); + // A datasource item: since objectstack ADR-0146 D1 (N-28) the datasource's + // are the item pages Setup still draws (every other one opens Studio). + renderConsoleAt('/apps/setup/metadata/datasource/main'); - expect(await screen.findByTestId('metadata-resource-edit-page')).toHaveTextContent('permission/crm_sales_user'); + expect(await screen.findByTestId('metadata-resource-edit-page')).toHaveTextContent('datasource/main'); expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'setup'); expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); expect(metaItemRequests).toEqual([]); @@ -323,9 +325,9 @@ describe('AppContent — Setup\'s item routes wait on the app\'s access verdict // A 404 is not a refusal; the pseudo-route keeps its default-app fallback. byName = {}; - renderConsoleAt('/apps/setup/metadata/permission'); + renderConsoleAt('/apps/setup/metadata/datasource'); - expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent('permission'); + expect(await screen.findByTestId('metadata-resource-list-page')).toHaveTextContent('datasource'); expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'crm'); expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument(); }); diff --git a/ui/packages/app-shell/src/console/__tests__/AppContent.setupMetadataStudio-28578.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppContent.setupMetadataStudio-28578.test.tsx new file mode 100644 index 00000000000..ffac8640029 --- /dev/null +++ b/ui/packages/app-shell/src/console/__tests__/AppContent.setupMetadataStudio-28578.test.tsx @@ -0,0 +1,241 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every Setup metadata route and alias opens Studio's "All metadata" area, the + * datasource excepted (objectstack ADR-0146 D1, D11 "Retire"; N-28). + * + * The table below is the whole input surface `AppContent` declares for + * metadata, on both of its branches (an active app, and a zero-app + * deployment): + * + * canonical metadata, metadata/_diagnostics, metadata/:type, + * metadata/:type/new, metadata/:type/:name, + * metadata/:type/:name/history + * aliases component/metadata/directory, + * component/metadata/resource/*?type=:type + * + * Each row asserts the whole redirect chain, so "lands in at most two hops" + * is read off the chain, not inferred from the endpoint: the canonical routes + * and both aliases reach Studio in ONE hop (two entries). `?package`, `?scope` + * and `?review` are carried, and the leg is the one the query names, so the + * area adds no second hop of its own (pinned on the helper in + * `views/studio-design/setupMetadataRedirect.test.ts`). + * + * The datasource rows are the converse: the area sends every datasource URL + * back to Setup's Datasources page, so a datasource must stay put here, on the + * metadata-admin page it always rendered, or the two redirects would loop. + * + * One more retired Setup URL redirects here (ADR-0146 D11): a bookmark of + * Setup › Packaged automation (`component/automation/packaged`) opens Studio's + * Automations pillar for the package its `?package=` names, or the area's + * cross-package flow list when it names none. + * + * The host's own aliases (`system/objects`, `system/metadata`, + * `system/{roles,positions,permissions}`) are `apps/console`'s, pinned in + * `apps/console/src/__tests__/AppContent.legacyRedirects.test.tsx`. + * + * Harness: the same stubs as `AppContent.pseudoRouteSegments.test.tsx` + * (pages and chrome stubbed, routing real), plus a `/studio/*` probe standing + * in for the Studio app the host mounts beside `/apps/*`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; + +vi.mock('../../views/metadata-admin', () => ({ + MetadataDirectoryPage: () =>
, + MetadataResourceListPage: () => { + const { type } = useParams<{ type?: string }>(); + return
{type}
; + }, + MetadataResourceEditPage: ({ createMode }: { createMode?: boolean }) => { + const { type, name } = useParams<{ type?: string; name?: string }>(); + return
{`${type}/${name ?? ''}`}
; + }, + MetadataResourceHistoryPage: () =>
, + MetadataDiagnosticsPage: () =>
, +})); + +vi.mock('../marketplace/MarketplacePage', () => ({ MarketplacePage: () =>
})); +vi.mock('../marketplace/MarketplacePackagePage', () => ({ + MarketplacePackagePage: () =>
, +})); +vi.mock('../../layout/ConsoleLayout', () => ({ + ConsoleLayout: ({ activeAppName, children }: { activeAppName?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), +})); +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); +vi.mock('../../views/ObjectView', () => ({ + ObjectView: () => { + const { objectName } = useParams<{ objectName?: string }>(); + return
{objectName}
; + }, +})); +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), + useObjectLabel: () => ({ objectLabel: ({ label }: { label?: string }) => label }), +})); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: null, getAuthConfig: async () => ({ features: {} }), activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), +})); +const dataSourceStub = { onConnectionStateChange: () => () => {}, getConnectionState: () => 'connected' }; +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => dataSourceStub, +})); + +const APPS = [ + { name: 'crm', label: 'CRM', isDefault: true, navigation: [] }, + { name: 'sales', label: 'Sales', navigation: [] }, +]; +let metadataApps: unknown[] = APPS; +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadata: () => ({ + apps: metadataApps, + objects: [], + loading: false, + ensureType: undefined, + error: null, + refresh: async () => {}, + }), +})); + +import { AppContent } from '../AppContent'; + +function LocationProbe({ sink }: { sink: string[] }) { + const location = useLocation(); + const here = `${location.pathname}${location.search}${location.hash}`; + if (sink[sink.length - 1] !== here) sink.push(here); + return null; +} + +function renderAt(initialUrl: string) { + const chain: string[] = []; + render( + + + + } /> + } /> + } /> + } /> + + , + ); + return chain; +} + +beforeEach(() => { + metadataApps = APPS; +}); + +/** [Setup URL, the Studio URL it lands on in one hop] */ +const TO_STUDIO: Array<[string, string]> = [ + // canonical routes + ['/metadata', '/studio/~all/metadata'], + ['/metadata/_diagnostics', '/studio/~all/metadata/_diagnostics'], + ['/metadata/object', '/studio/~all/metadata/object'], + ['/metadata/object/new', '/studio/~all/metadata/object/new'], + ['/metadata/object/account', '/studio/~all/metadata/object/account'], + ['/metadata/permission/admin/history', '/studio/~all/metadata/permission/admin/history'], + // the query is carried, and names the leg + ['/metadata/object/account?package=crm', '/studio/crm/metadata/object/account?package=crm'], + ['/metadata/object?package=all', '/studio/~all/metadata/object?package=all'], + ['/metadata/position?scope=environment', '/studio/~all/metadata/position?scope=environment'], + ['/metadata/sharing_rule?scope=environment&package=crm', '/studio/~all/metadata/sharing_rule?scope=environment'], + ['/metadata/object/account?review=1', '/studio/~all/metadata/object/account?review=1'], + ['/metadata/object/account?package=crm&review=1', '/studio/crm/metadata/object/account?package=crm&review=1'], + ['/metadata/object/account#fields', '/studio/~all/metadata/object/account#fields'], + // the pre-refactor aliases, straight to Studio (not via the canonical route) + ['/component/metadata/directory', '/studio/~all/metadata'], + ['/component/metadata/resource?type=object', '/studio/~all/metadata/object'], + ['/component/metadata/resource/account?type=object', '/studio/~all/metadata/object/account'], + ['/component/metadata/resource/account?type=object&package=crm', '/studio/crm/metadata/object/account?package=crm'], + ['/component/metadata/resource?type=position&scope=environment', '/studio/~all/metadata/position?scope=environment'], +]; + +/** [Setup URL, the page it keeps rendering, where it settles] */ +const STAYS: Array<[string, string, string]> = [ + ['/metadata/datasource', 'metadata-resource-list-page', '/metadata/datasource'], + ['/metadata/datasource?package=crm', 'metadata-resource-list-page', '/metadata/datasource?package=crm'], + ['/metadata/datasource/new', 'metadata-resource-edit-page', '/metadata/datasource/new'], + ['/metadata/datasource/main', 'metadata-resource-edit-page', '/metadata/datasource/main'], + ['/metadata/datasource/main/history', 'metadata-resource-history-page', '/metadata/datasource/main/history'], + ['/component/metadata/resource?type=datasource', 'metadata-resource-list-page', '/metadata/datasource'], + ['/component/metadata/resource/main?type=datasource', 'metadata-resource-edit-page', '/metadata/datasource/main'], +]; + +describe.each([ + ['an active app (setup)', '/apps/setup', APPS], + ['an active app (crm)', '/apps/crm', APPS], + ['a zero-app deployment', '/apps/setup', []], +] as const)('Setup metadata routes open Studio — %s (ADR-0146 D1)', (_label, base, apps) => { + it.each(TO_STUDIO)(`${base}%s → %s, in one hop`, async (from, to) => { + metadataApps = [...apps]; + const chain = renderAt(`${base}${from}`); + + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + // Two entries: one ``, no stop on Setup's canonical route. + expect(chain).toEqual([`${base}${from}`, to]); + expect(screen.queryByTestId('metadata-resource-list-page')).not.toBeInTheDocument(); + expect(screen.queryByTestId('metadata-resource-edit-page')).not.toBeInTheDocument(); + }); + + it.each(STAYS)(`${base}%s stays in Setup on %s`, async (from, testid, settles) => { + metadataApps = [...apps]; + const chain = renderAt(`${base}${from}`); + + expect(await screen.findByTestId(testid)).toBeInTheDocument(); + expect(chain[chain.length - 1]).toBe(`${base}${settles}`); + expect(chain.length).toBeLessThanOrEqual(2); + expect(chain.some((entry) => entry.startsWith('/studio'))).toBe(false); + expect(screen.queryByTestId('studio-probe')).not.toBeInTheDocument(); + }); +}); + +describe('a bookmark of the retired Setup › Packaged automation page opens Studio (ADR-0146 D11)', () => { + it.each([ + ['/apps/setup/component/automation/packaged?package=crm', '/studio/crm/automations?package=crm'], + ['/apps/crm/component/automation/packaged?package=crm', '/studio/crm/automations?package=crm'], + ['/apps/setup/component/automation/packaged', '/studio/~all/metadata/flow'], + ])('%s → %s, in one hop', async (from, to) => { + const chain = renderAt(from); + + expect(await screen.findByTestId('studio-probe')).toBeInTheDocument(); + expect(chain).toEqual([from, to]); + }); + + it('control: another component ref still resolves through the component route, not Studio', async () => { + const chain = renderAt('/apps/setup/component/automation/packaged-actions'); + await screen.findByTestId('console-layout'); + expect(chain).toEqual(['/apps/setup/component/automation/packaged-actions']); + expect(screen.queryByTestId('studio-probe')).not.toBeInTheDocument(); + }); +}); + +describe('the datasource pages keep their own shape', () => { + it('a datasource create page renders in create mode', async () => { + renderAt('/apps/setup/metadata/datasource/new'); + expect(await screen.findByTestId('metadata-resource-edit-page')).toHaveAttribute('data-create', 'yes'); + }); + + it('metadata/package/* still opens the developer packages page, not Studio', async () => { + const chain = renderAt('/apps/setup/metadata/package/acme'); + await screen.findByTestId('console-layout'); + expect(chain[1]).toBe('/apps/setup/component/developer/packages'); + }); +}); diff --git a/ui/packages/app-shell/src/console/home/QuickActions.tsx b/ui/packages/app-shell/src/console/home/QuickActions.tsx index 2641b5056cf..c15b0db63b5 100644 --- a/ui/packages/app-shell/src/console/home/QuickActions.tsx +++ b/ui/packages/app-shell/src/console/home/QuickActions.tsx @@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom'; import { useObjectTranslation } from '@object-ui/i18n'; import { Card, CardContent } from '@object-ui/components'; -import { Settings, Database, ArrowUpRight } from 'lucide-react'; +import { Settings, ArrowUpRight } from 'lucide-react'; import { cn } from '@object-ui/components'; interface QuickAction { @@ -29,25 +29,10 @@ export function QuickActions() { const { t } = useObjectTranslation(); // Manual "Create App" deprecated — the AI-first builder (Build with AI on - // /home) is the path to a new application. + // /home) is the path to a new application. No "Manage Objects" card either + // (objectstack ADR-0146 D1): objects are designed in Studio, which Home + // opens from its own cover. const actions: QuickAction[] = [ - { - id: 'manage-objects', - label: t('home.quickActions.manageObjects', { defaultValue: 'Manage Objects' }), - description: t('home.quickActions.manageObjectsDesc', { defaultValue: 'Configure data models' }), - icon: Database, - // #3739 — the metadata-admin engine's CANONICAL route, not the legacy - // `…/system/metadata/object` alias this card used to carry. That alias is - // not a page: `apps/console`'s host fragment serves it with - // `MetadataRedirect`, a bare `` onto the URL below, so every - // click paid a redundant hop plus a re-render. Same defect and same - // remedy as #3660's `sys-datasources` entry in both sidebars. The alias - // routes stay for bookmarks and external links. - href: '/apps/setup/metadata/object', - iconBg: 'bg-gradient-to-br from-violet-500/15 to-purple-500/10 ring-violet-500/20', - iconText: 'text-violet-600 dark:text-violet-400', - hoverBorder: 'hover:border-violet-500/40', - }, { id: 'system-settings', label: t('home.quickActions.systemSettings', { defaultValue: 'System Settings' }), diff --git a/ui/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx b/ui/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx index 63734b78d5f..ce9498d085c 100644 --- a/ui/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx +++ b/ui/packages/app-shell/src/console/home/__tests__/QuickActions.settingsTarget.test.tsx @@ -79,9 +79,6 @@ function renderQuickActions() { const SYSTEM_HUB = '/apps/setup/system'; -/** Where the metadata-admin engine really serves the object list (objectui#3739). */ -const OBJECTS_TARGET = '/apps/setup/metadata/object'; - describe('QuickActions system-settings card (objectui#3611, dormant)', () => { it('DORMANCY PRECONDITION: nothing renders this component, so the fix is a guard, not a user-visible change', async () => { // Recorded as an assertion rather than prose so it goes red the day the @@ -121,23 +118,15 @@ describe('QuickActions system-settings card (objectui#3611, dormant)', () => { expect(screen.getByTestId('landing')).toHaveTextContent(SYSTEM_HUB); }); - it('the Manage Objects card targets the canonical metadata route, not the system alias (objectui#3739)', async () => { - // Replaces #3611's "the sibling is unchanged" anchor. That assertion was - // true of the URL and wrong about it: `…/system/metadata/object` is an alias - // the host only forwards, so pinning it froze the extra hop in place. - const user = userEvent.setup(); + it('carries no Manage Objects card: Setup carries no metadata catalog (objectstack ADR-0146 D1)', () => { + // objectui#3739 pointed the card at Setup's canonical `metadata/object` + // route. N-28 retired that route into Studio's "All metadata" area, and the + // card with it: objects are designed in Studio. renderQuickActions(); - await user.click(screen.getByTestId('quick-action-manage-objects')); - - expect(screen.getByTestId('landing')).toHaveTextContent(OBJECTS_TARGET); - // `toHaveTextContent` matches on substring, and neither spelling contains - // the other (`/apps/setup/system/metadata/object` has the extra segment in - // the MIDDLE), so the assertion above already separates them. This second - // one adds nothing to the logic and is kept for the diff: a revert then - // fails naming the alias, instead of reporting two similar-looking paths. - expect(screen.getByTestId('landing')).not.toHaveTextContent( - `${SYSTEM_HUB}/metadata/object`, - ); + expect(screen.queryByTestId('quick-action-manage-objects')).not.toBeInTheDocument(); + for (const link of screen.queryAllByRole('link')) { + expect(link.getAttribute('href') ?? '').not.toContain('/metadata/object'); + } }); }); diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index b44f0afa6a6..807515b78b8 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -528,6 +528,10 @@ export { studioMetadataAreaPath, } from './views/studio-design/metadataArea.js'; export { StudioMetadataArea } from './views/studio-design/StudioMetadataArea.js'; +// objectstack ADR-0146 D1 (N-28) — where a Setup metadata URL lands in that +// area (the datasource's stays in Setup). A host whose legacy aliases forward +// onto Setup's metadata routes lands them in one hop through this. +export { setupMetadataStudioPath } from './views/studio-design/setupMetadataRedirect.js'; // Setup › Packaged actions (ADR-0126 §7.4) — on/off for the actions installed // packages ship. Reached through the `automation:packaged-actions` component diff --git a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx index 999280656da..4f5b9ad1dc4 100644 --- a/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx +++ b/ui/packages/app-shell/src/layout/UnifiedSidebar.tsx @@ -377,20 +377,15 @@ export function UnifiedSidebar({ activeAppName, railOverlay = false }: UnifiedSi // active app, `extraRoutes` once one exists), so an app-bearing deployment // now reaches the hub here instead of whatever app `/apps/setup` fell back // to. Every sibling below already spelled `/apps/setup/system/...` — the - // two metadata-admin entries excepted, and only since: they name the - // engine's canonical `/apps/setup/metadata/:type` routes (#3660, #3739), - // because for THOSE two the `system/...` spelling is a redirect rather - // than a page. + // datasource entry excepted: it names the engine's canonical + // `/apps/setup/metadata/datasource` route (#3660), because for it the + // `system/...` spelling is a redirect rather than a page. There is no + // Object Manager entry: objects are Studio's (objectstack ADR-0146 D1), + // and Setup keeps the datasource alone of the metadata types. const adminItems: NavigationItem[] = [ { id: 'sys-settings', label: t('layout.systemNav.systemSettings', { defaultValue: 'System Settings' }), type: 'url' as const, url: '/apps/setup/system', icon: 'settings' }, { id: 'sys-apps', label: t('layout.systemNav.applications', { defaultValue: 'Applications' }), type: 'url' as const, url: '/apps/setup/system/apps', icon: 'layout-grid' }, { id: 'sys-marketplace', label: t('layout.systemNav.appMarketplace', { defaultValue: 'App Marketplace' }), type: 'url' as const, url: '/apps/setup/system/marketplace', icon: 'store' }, - // #3739 — canonical `…/metadata/object`, not the legacy - // `…/system/metadata/object` alias. The alias is served by - // `apps/console`'s `MetadataRedirect`, a bare `` onto this - // very URL, so pointing here removes a hop without moving the landing - // page. The alias routes themselves are untouched. - { id: 'sys-objects', label: t('layout.systemNav.objectManager', { defaultValue: 'Object Manager' }), type: 'url' as const, url: '/apps/setup/metadata/object', icon: 'database' }, // #3660 — canonical `…/metadata/datasource`, not the legacy // `…/component/metadata/resource?type=datasource` alias. The alias // renders `LegacyMetadataRedirect`, a bare `` onto this very diff --git a/ui/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx b/ui/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx index 6a2903617a6..919de550f48 100644 --- a/ui/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/systemNavObjectsHop.test.tsx @@ -1,76 +1,27 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The `sys-objects` entry `UnifiedSidebar` carries arrives at the canonical - * metadata-admin route with ZERO redirects (objectui#3739). + * The `/home` Administration cluster carries no `sys-objects` entry (objectstack + * ADR-0146 D1, N-28). * - * ## What was wrong + * objectui#3739 pinned that this entry ("Object Manager") reached Setup's + * canonical `/apps/setup/metadata/object` route with zero redirects, after + * objectui#3660 did the same for `sys-datasources` one line below it. N-28 + * retired Setup's metadata catalog into Studio's "All metadata" area, and that + * route now redirects there, so the entry would leave Home for Setup only to be + * forwarded on into Studio. The entry is removed. Objects are designed in + * Studio. * - * `AppSidebar.systemFallbackNavigation` and `UnifiedSidebar.homeNavigation` each - * held their own literal for this entry, and both spelled it - * `/apps/setup/system/metadata/object`. That is not a page: `apps/console`'s host - * fragment declares `system/metadata/:metadataType` with `MetadataRedirect` as - * its element, which immediately ``s onto - * `/apps/setup/metadata/object`. Every click therefore paid a redundant hop plus - * a re-render to reach a URL the nav item could name itself. (`AppSidebar` and - * its copy were removed later, by objectui#5817.) - * - * This is the same defect objectui#3660 fixed for `sys-datasources` — declared on - * the line immediately BELOW this entry in both arrays — and it was missed there, - * because the two entries reach their aliases through different route tables: - * `sys-datasources` went through app-shell's own `component/metadata/resource` - * alias, `sys-objects` through the host's `system/metadata/:type` rewrite. Same - * cost, different producer of the hop, so the grep that found one did not find - * the other. The third producer of this URL, `console/home/QuickActions`, is - * pinned in `console/home/__tests__/QuickActions.settingsTarget.test.tsx`. - * - * ## What this file measures, and how it differs from the sibling pins - * - * `systemNavSettingsTarget.test.tsx` asserts the URL each entry CARRIES — a - * string equality on an `href`. This file asks the next question: what that URL - * costs to resolve. `ChainRecorder` records every distinct location the router - * settles on, so a direct arrival is a one-entry chain and an alias arrival is - * two. Endpoint-only assertions cannot tell those apart — both finish at - * `/apps/setup/metadata/object`, which is exactly why the detour survived #3660 - * and #3611 (the latter even pinned the alias spelling as its consistency - * anchor). `systemNavDatasourcesHop.test.tsx` is this file's twin for the entry - * one line below; the two are kept separate because the alias route being - * mirrored, and the file it is transcribed from, differ. - * - * The href is READ OUT of a real `UnifiedSidebar` render rather than typed in - * here, so the producer under test is the component's own literal. - * - * ## The alias mirror below, and what rests on its fidelity - * - * `AliasMetadataRedirect` mirrors `MetadataRedirect` from - * `apps/console/src/AppContent.tsx` — transcribed, not imported, because - * `apps/console` is a different Vitest project (the same reason, and the same - * transcription, as `console/__tests__/AppContent.noAppComponentRoutes.test.tsx` - * and `AppContent.pseudoRouteSegments.test.tsx`; keep the regex and the target - * construction identical to the original). - * - * Nothing this file ASSERTS depends on that mirror being faithful: the green - * expectation is `chain` equals `[canonical URL]`, which holds iff the sidebar's - * URL matches a canonical route directly. The mirror only shapes what the FAILURE - * looks like — with it, restoring the old literal produces a two-entry chain whose - * first entry is the alias, i.e. the extra hop printed literally in the diff - * rather than merely implied. The real host route's own behaviour is pinned - * against the real route table in `apps/console`'s - * `__tests__/AppContent.legacyRedirects.test.tsx`, and end-to-end through - * app-shell's route tree in `console/__tests__/AppContent.noAppComponentRoutes.test.tsx`. - * - * ## Scope - * - * Where the sidebar AIMS. The alias routes are untouched and stay reachable for - * bookmarks and external links; nothing here asks for their removal, and the - * CONTROL case below pins that they still work. + * The inverse pin is read off a REAL `UnifiedSidebar` render, the same way the + * original pin read the entry's href. `sys-datasources` stays: the datasource is + * Setup's (D1), and `systemNavDatasourcesHop.test.tsx` still pins its route. */ import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; // --------------------------------------------------------------------------- // Mocks — providers and console-only chrome, matching the sibling sidebar @@ -155,107 +106,24 @@ vi.mock('../LocalizedSidebarTrigger', () => ({ import { SidebarProvider } from '@object-ui/components'; import { UnifiedSidebar } from '../UnifiedSidebar'; -/** Where the metadata-admin engine really serves the object list. */ -const CANONICAL = '/apps/setup/metadata/object'; - -/** The legacy spelling both entries used to carry — an alias, not a page. */ -const ALIAS = '/apps/setup/system/metadata/object'; - -/** - * Records every distinct location the router settles on: one entry means the URL - * matched a real route on arrival, two means it was forwarded once. - */ -function ChainRecorder({ sink }: { sink: string[] }) { - const location = useLocation(); - const here = `${location.pathname}${location.search}`; - if (sink[sink.length - 1] !== here) sink.push(here); - return null; -} - -/** Terminal probe: reports which route matched and with which params. */ -function Probe({ id }: { id: string }) { - const params = useParams(); - return
{JSON.stringify(params)}
; -} - -/** - * Mirror of `MetadataRedirect` from `apps/console/src/AppContent.tsx` — the - * `system/metadata/*` legs of its `systemRoutes` fragment. Present so a restored - * alias literal shows up as a real extra hop rather than as a dead end; see this - * file's header on what does and does not rest on its fidelity. - */ -function AliasMetadataRedirect() { - const { metadataType, itemName } = useParams<{ metadataType?: string; itemName?: string }>(); - const location = useLocation(); - const prefix = location.pathname.replace(/\/(system\/)?metadata(\/.*)?$/, ''); - const base = `${prefix}/metadata`; - const target = !metadataType - ? base - : itemName - ? `${base}/${encodeURIComponent(metadataType)}/${itemName}` - : `${base}/${encodeURIComponent(metadataType)}`; - return ; -} - -/** Renders a sidebar, reads the entry's href, then unmounts it. */ -function objectsHrefFrom(ui: React.ReactElement, at: string): string { - const view = render( - - {ui} - , - ); - const href = screen.getByRole('link', { name: 'Object Manager' }).getAttribute('href'); - view.unmount(); - expect(href).toBeTruthy(); - return href as string; -} - -/** Drops the emitted URL into a router that knows both spellings. */ -function chainFor(url: string): string[] { - const chain: string[] = []; - render( - - - - - } /> - } /> - } /> - } /> - - } /> - - , - ); - return chain; -} - beforeEach(() => { localStorage.clear(); }); -describe('sidebar sys-objects reaches the canonical route directly (objectui#3739)', () => { - it('UnifiedSidebar: the /home Administration cluster arrives with NO redirect', () => { - const href = objectsHrefFrom(, '/home'); - - expect(href).toBe(CANONICAL); - - const chain = chainFor(href); - - expect(chain).toEqual([CANONICAL]); - expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); - expect(chain.some((entry) => entry.includes('system/metadata'))).toBe(false); - }); - - it('CONTROL: the alias still resolves, and doing so costs the hop the entries used to pay', () => { - // The alias is deliberately KEPT (bookmarks, external links), so its - // continued reachability is part of the contract, not collateral. This also - // proves the assertions above are not vacuous: the probe table really - // does forward this spelling, so a sidebar that still emitted it would be - // measured at two entries rather than silently falling to `unmatched`. - const chain = chainFor(ALIAS); - - expect(chain).toEqual([ALIAS, CANONICAL]); - expect(screen.getByTestId('canonical-list')).toHaveTextContent('"type":"object"'); +describe('the /home Administration cluster carries no Object Manager entry (ADR-0146 D1)', () => { + it('UnifiedSidebar: no `sys-objects` link, and no link into Setup\'s metadata catalog', () => { + render( + + + + + , + ); + + const hrefs = screen.queryAllByRole('link').map((link) => link.getAttribute('href') ?? ''); + // Control: the cluster really rendered, with its kept datasource entry. + expect(hrefs).toContain('/apps/setup/metadata/datasource'); + expect(screen.queryByRole('link', { name: 'Object Manager' })).toBeNull(); + expect(hrefs.filter((href) => /\/metadata\/(?!datasource(?:$|[/?#]))/.test(href))).toEqual([]); }); }); diff --git a/ui/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx b/ui/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx index 8db27c51b40..94eaaeb154b 100644 --- a/ui/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/systemNavSettingsTarget.test.tsx @@ -61,7 +61,7 @@ * `AppSidebar` test this file carried then gained the matching assertion at the * same time; that test left with the component (objectui#5817). * - * ## The `Object Manager` entry (objectui#3739) + * ## The `Object Manager` entry (objectui#3739; removed by objectstack ADR-0146 D1) * * #3660 re-pointed `sys-datasources` and stopped there; `sys-objects`, the entry * declared on the line immediately ABOVE it in both sidebars, kept spelling @@ -198,26 +198,20 @@ const SYSTEM_HUB = '/apps/setup/system'; */ const DATASOURCES_TARGET = '/apps/setup/metadata/datasource'; -/** - * `Object Manager` names the same engine's canonical route (objectui#3739). This - * entry used to read `${SYSTEM_HUB}/metadata/object` — an alias served by - * `apps/console`'s `MetadataRedirect`, a bare `` onto the URL below. - * Replaced here rather than kept alongside, for the reason spelled out in this - * file's header: the repo pins the fix, not both the bug and the fix. - */ -const OBJECTS_TARGET = '/apps/setup/metadata/object'; - /** * Every entry of the `/home` Administration cluster, in declaration order. * Asserted whole rather than by sample: the defect was that the group's * children were never visited at all, so "some of them render" is not the - * property worth pinning — "all nine, at the URLs they declare" is. + * property worth pinning — "all of them, at the URLs they declare" is. + * + * Eight since objectstack ADR-0146 D1 (N-28): `Object Manager` named Setup's + * object catalog route (objectui#3739), which now redirects into Studio's "All + * metadata" area, so the entry left the cluster. Its absence is pinned below. */ const ADMINISTRATION_ENTRIES: ReadonlyArray = [ ['System Settings', SYSTEM_HUB], ['Applications', `${SYSTEM_HUB}/apps`], ['App Marketplace', `${SYSTEM_HUB}/marketplace`], - ['Object Manager', OBJECTS_TARGET], ['Datasources', DATASOURCES_TARGET], ['Users', `${SYSTEM_HUB}/users`], ['Organizations', `${SYSTEM_HUB}/organizations`], @@ -241,7 +235,7 @@ beforeEach(() => { }); describe('sidebar system-settings target (objectui#3590)', () => { - it('UnifiedSidebar: /home renders the Administration cluster as a GROUP, with all nine entries reachable (objectui#3609)', () => { + it('UnifiedSidebar: /home renders the Administration cluster as a GROUP, with all eight entries reachable (objectui#3609)', () => { // Replaces the #3590 MEASUREMENT pin. Its two halves invert exactly: // before → `Administration` IS a link, href `/home`; children absent. // after → `Administration` is a disclosure, not a link; children present. @@ -262,6 +256,8 @@ describe('sidebar system-settings target (objectui#3590)', () => { for (const [label, href] of ADMINISTRATION_ENTRIES) { expect(screen.getByRole('link', { name: label })).toHaveAttribute('href', href); } + // ADR-0146 D1 (N-28) — the object catalog entry is gone. + expect(screen.queryByRole('link', { name: 'Object Manager' })).not.toBeInTheDocument(); // The head entry, called out because #3590 is what corrected it: it must // not have regressed to the bare setup URL that re-renders the empty state. diff --git a/ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.itemPage-28578.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.itemPage-28578.test.tsx new file mode 100644 index 00000000000..79c0ec47c4a --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.itemPage-28578.test.tsx @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * N-28 (M-1 ruling A, comment 6554738464) — the catalog's Active switch on an + * item's own page in Studio's "All metadata" area. + * + * Setup's catalog list, which carried the switch, retired (objectstack + * ADR-0146 D1), so the switch moved with the item. `CatalogItemActiveSwitch` + * is the one switch the area's item pages mount: the position and declared + * sharing rule pages through `ResourceEditPage`'s area sections, the + * permission set through its own editor. It is the list's switch, for one + * item, so the list's contract holds (objectui#13338): off asks first with the + * holder count, Cancel writes nothing, on writes at once, a toast confirms. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +// `audienceAnchorsOf` dynamic-imports this specifier; paid in the import phase. +import '@objectstack/spec/identity'; + +const POSITIONS: Record[] = [ + { name: 'sales_manager', label: 'Sales Manager', permissionSets: ['sales_rep'] }, +]; + +vi.mock('./i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataLocale: () => 'en-US', +})); + +let canAuthor = true; +vi.mock('../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => canAuthor })); +vi.mock('../../hooks/useTenancyPosture', async (importOriginal) => ({ + ...(await importOriginal>()), + useTenancyPosture: () => 'single', +})); + +const toasts = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('sonner', () => ({ toast: toasts })); + +let data: Record[]> = {}; +const adapter = { + find: vi.fn(async (object: string, query: { $filter?: Record } = {}) => { + const filter = query.$filter ?? {}; + return { + data: (data[object] ?? []).filter((r) => + Object.entries(filter).every(([k, v]) => + v && typeof v === 'object' && Array.isArray((v as { $in?: unknown[] }).$in) + ? (v as { $in: unknown[] }).$in.includes(r[k]) + : r[k] === v, + ), + ), + }; + }), +}; +const metadataStore = { ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])) }; +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => adapter, + useMetadata: () => metadataStore, +})); + +let ledgerRows: Record[] = []; +const posts: Array<{ url: string; body?: string }> = []; + +import { CatalogItemActiveSwitch } from './CatalogActiveSwitch'; +import { t, tFormat } from './i18n'; + +beforeEach(() => { + canAuthor = true; + data = { + sys_user_permission_set: [ + { id: 'g1', permission_set: 'sales_rep', user_id: 'u1' }, + { id: 'g2', permission_set: 'sales_rep', user_id: 'u2' }, + ], + sys_user_position: [ + { id: 'p1', position: 'sales_manager', user_id: 'u2' }, + { id: 'p2', position: 'sales_manager', user_id: 'u3' }, + ], + }; + ledgerRows = [{ metadata_type: 'sharing_rule', name: 'open_accounts', active: false }]; + posts.length = 0; + adapter.find.mockClear(); + Object.values(toasts).forEach((f) => f.mockClear()); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + if (method === 'GET' && String(url).startsWith('/api/v1/data/sys_metadata_activation')) { + return json(200, { object: 'sys_metadata_activation', records: ledgerRows, hasMore: false }); + } + if (method === 'POST' && String(url).startsWith('/api/v1/security/_activation/')) { + posts.push({ url: String(url), body: init?.body as string | undefined }); + const enabled = JSON.parse(String(init?.body ?? '{}')).enabled; + return json(200, { success: true, data: { enabled } }); + } + return json(404, { success: false, error: { message: `unexpected ${method} ${url}` } }); + }), + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function switchOf(name: string, checked: 'true' | 'false') { + const sw = await screen.findByTestId(`catalog-active-${name}`); + await waitFor(() => expect(sw).toHaveAttribute('aria-checked', checked)); + return sw; +} + +describe.each([ + { type: 'permission', name: 'sales_rep', label: 'Sales Rep', holders: 3 }, + { type: 'position', name: 'sales_manager', label: 'Sales Manager', holders: 2 }, +])('the $type item page: switching off asks first', ({ type, name, label, holders }) => { + it('names the holders, writes nothing until confirmed, then writes and toasts', async () => { + render(); + expect(screen.getByTestId('catalog-item-active')).toHaveTextContent(t('engine.catalog.col.active', 'en-US')); + fireEvent.click(await switchOf(name, 'true')); + + const confirm = await screen.findByTestId('catalog-deactivate-confirm'); + await waitFor(() => expect(confirm).not.toBeDisabled()); + expect(screen.getByTestId('catalog-deactivate-impact')).toHaveTextContent( + tFormat('engine.catalog.deactivate.holders', 'en-US', { count: holders }), + ); + expect(posts).toEqual([]); + + fireEvent.click(confirm); + await switchOf(name, 'false'); + expect(posts).toEqual([{ url: `/api/v1/security/_activation/${type}/${name}`, body: '{"enabled":false}' }]); + expect(toasts.success).toHaveBeenCalledWith(tFormat('engine.catalog.active.switchedOff', 'en-US', { name: label })); + }); + + it('Cancel leaves it on and writes nothing', async () => { + render(); + const sw = await switchOf(name, 'true'); + fireEvent.click(sw); + fireEvent.click(await screen.findByTestId('catalog-deactivate-cancel')); + await waitFor(() => expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull()); + expect(sw).toHaveAttribute('aria-checked', 'true'); + expect(posts).toEqual([]); + }); +}); + +describe('the declared sharing rule item page', () => { + it('reads the ledger, and switches on at once with no holder read', async () => { + render(); + const sw = await switchOf('open_accounts', 'false'); + fireEvent.click(sw); + await switchOf('open_accounts', 'true'); + expect(screen.queryByTestId('catalog-deactivate-dialog')).toBeNull(); + expect(posts).toEqual([{ url: '/api/v1/security/_activation/sharing_rule/open_accounts', body: '{"enabled":true}' }]); + expect(adapter.find).not.toHaveBeenCalled(); + }); +}); + +describe('a viewer who cannot author metadata', () => { + it('sees the state and gets no click', async () => { + canAuthor = false; + render(); + const sw = await switchOf('sales_manager', 'true'); + expect(sw).toBeDisabled(); + expect(sw).toHaveAttribute('aria-label', t('engine.catalog.status.active', 'en-US')); + }); +}); + +describe('a refused ledger read', () => { + it('is said in plain words, never as a switch', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ success: false, error: { message: 'no' } }), { status: 403 })), + ); + render(); + expect(await screen.findByTestId('catalog-active-unreadable-sales_manager')).toHaveTextContent( + t('engine.catalog.active.notVisible', 'en-US'), + ); + expect(screen.queryByTestId('catalog-active-sales_manager')).toBeNull(); + }); +}); + +describe('where the area item pages mount it (source pin)', () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + it('the position and sharing rule area sections, and the permission set editor', () => { + const editPage = readFileSync(path.join(here, 'ResourceEditPage.tsx'), 'utf8'); + const areaSections = editPage.slice(editPage.indexOf('const AREA_ENVIRONMENT_SECTIONS')); + expect(areaSections).toMatch(/position: \[catalogActiveSection\('position'\)/); + expect(areaSections).toMatch(/sharing_rule: \[catalogActiveSection\('sharing_rule'\)\]/); + const matrix = readFileSync(path.join(here, 'PermissionMatrixEditor.tsx'), 'utf8'); + expect(matrix).toContain(''); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.tsx b/ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.tsx new file mode 100644 index 00000000000..a4db53d5f56 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/CatalogActiveSwitch.tsx @@ -0,0 +1,320 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ACTIVE switch of a catalog item — a position's, a permission set's or a + * declared sharing rule's row in the activation ledger (`catalog-activation.ts` + * reads and writes it; objectui#7611, objectstack ADR-0131 D3/D6) — drawn in + * the two places it is offered: + * + * - the environment-scope LIST, one switch per row (`ResourceListPage`); + * - the item's own page in Studio's "All metadata" area + * ({@link CatalogItemActiveSwitch}): objectstack ADR-0146 D1 retires + * Setup's catalog, and the M-1 ruling (A) moves the switch with the item + * into Studio, the way D4 put the flow switch on the flow's item. The door, + * its gate and its readings are unchanged; only the surface moved. + * + * Both draw the same cell and ask the same confirm before a switch-off, so the + * two cannot say different things about one item. + */ + +import * as React from 'react'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Switch, + buttonVariants, +} from '@object-ui/components'; +import { useAdapter, useMetadata } from '@object-ui/react'; +import { + audienceAnchorsOf, + isCatalogItemActive, + readCatalogActivation, + writeCatalogActivation, + type CatalogActivation, +} from './catalog-activation.js'; +import { countsCatalogHolders, readCatalogHolderCount, type CatalogHolderCount } from './catalog-holders.js'; +import { useWalledCatalogReadOnly } from './catalog-scope.js'; +import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; +import { t, tFormat, useMetadataLocale } from './i18n.js'; + +/** A switch-off waiting on its confirm: the item, and its holder count once read. */ +export interface PendingDeactivation { + name: string; + label: string; + holders: CatalogHolderCount | { error: string } | null; +} + +/** + * objectui#13338 — the confirm a permission set's or a position's switch asks + * before it switches the item OFF, naming how many users lose the access. The + * count is the server's, read when the confirm opens; while it is read the + * confirm says so and its Deactivate waits. A count that could not be read is + * said as such (never as "0 users"), and the admin may still go ahead: the + * confirm informs, it does not gate. Cancel leaves the item on. + */ +export function DeactivateConfirmDialog({ + pending, + locale, + onCancel, + onConfirm, +}: { + pending: PendingDeactivation | null; + locale: string; + onCancel: () => void; + onConfirm: (name: string) => void; +}) { + const holders = pending?.holders ?? null; + let impact: string; + if (holders === null) impact = t('engine.catalog.deactivate.counting', locale); + else if ('error' in holders) impact = tFormat('engine.catalog.deactivate.countFailed', locale, { message: holders.error }); + else if (holders.everyone) impact = t('engine.catalog.deactivate.everyone', locale); + else if (holders.truncated) impact = tFormat('engine.catalog.deactivate.holdersAtLeast', locale, { count: holders.count }); + else if (holders.count === 1) impact = t('engine.catalog.deactivate.holdersOne', locale); + else impact = tFormat('engine.catalog.deactivate.holders', locale, { count: holders.count }); + return ( + { + if (!open) onCancel(); + }} + > + + + + {tFormat('engine.catalog.deactivate.title', locale, { name: pending?.label ?? '' })} + + {impact} + + + {t('engine.cancel', locale)} + { + if (pending) onConfirm(pending.name); + }} + > + {t('engine.catalog.deactivate.confirm', locale)} + + + + + ); +} + +/** The activation ledger's states for a type, and the names its door refuses. */ +export interface CatalogLedger { + states: CatalogActivation; + anchors: ReadonlySet; +} + +/** + * objectui#7611 — one item's active switch in the environment scope, showing + * the activation ledger's state (see `catalog-activation.ts`). Its non-switch + * states are each said, never rendered as a guessed "on": the state is still + * loading, or the read failed (said in words, with the server's reason). An audience anchor shows its state on a + * disabled switch with the reason, because the door refuses to switch it. + */ +export function CatalogActiveCell({ + itemName, + ledger, + refusal, + canSwitch, + busy, + locale, + onToggle, +}: { + itemName: string; + ledger: CatalogLedger | 'error' | null; + /** Why the read failed: the server's refusal, as `actionErrorDetail` read it. */ + refusal: string | null; + canSwitch: boolean; + busy: boolean; + locale: string; + onToggle: (itemName: string, enabled: boolean) => void; +}) { + if (ledger === null) return …; + if (ledger === 'error') { + // objectui#13091 — said in plain words, never a bare "?"; the server's + // own sentence for why rides in the title. + const why = [t('engine.catalog.active.unknown', locale), refusal].filter(Boolean).join(' '); + return ( + + {t('engine.catalog.active.notVisible', locale)} + + ); + } + const enabled = isCatalogItemActive(ledger.states, itemName); + const anchor = ledger.anchors.has(itemName); + // A switch the caller cannot use states the state, and offers no click; the + // page's read-only reason says why. + const label = anchor + ? t('engine.catalog.active.anchor', locale) + : canSwitch + ? t(enabled ? 'engine.catalog.active.on' : 'engine.catalog.active.off', locale) + : t(enabled ? 'engine.catalog.status.active' : 'engine.catalog.status.inactive', locale); + return ( + + onToggle(itemName, enabled)} + aria-label={label} + data-testid={`catalog-active-${itemName}`} + /> + + ); +} + +/** + * objectstack ADR-0146 D1 (M-1 ruling A) — an item's active switch on its own + * page in Studio's "All metadata" area, in the environment scope: the switch + * Setup's catalog list carried, for this one item. The same reads, the same + * confirm before a switch-off (with the holder count) and the same door as + * the list's; a caller who may not author metadata, or whom the deployment's + * wall refuses the catalog, sees the state and no live switch. + */ +export function CatalogItemActiveSwitch({ + type, + name, + label, +}: { + type: string; + name: string; + /** The item's label for the confirm and the toast; the name when it has none. */ + label?: string; +}): React.ReactElement { + const locale = useMetadataLocale(); + const canAuthor = useCanAuthorMetadata(); + const walled = useWalledCatalogReadOnly(type); + const adapter = useAdapter(); + const metadataStore = useMetadata(); + // Read through a ref when the confirm opens: the store's context value is + // rebuilt whenever any cached type settles (ui AGENTS.md #10). + const metadataStoreRef = React.useRef(metadataStore); + React.useEffect(() => { + metadataStoreRef.current = metadataStore; + }); + const [ledger, setLedger] = React.useState(null); + const [refusal, setRefusal] = React.useState(null); + const [busy, setBusy] = React.useState(false); + const [switchError, setSwitchError] = React.useState(null); + const [pendingOff, setPendingOff] = React.useState(null); + const shownLabel = label || name; + + React.useEffect(() => { + const controller = new AbortController(); + setLedger(null); + setRefusal(null); + Promise.all([readCatalogActivation(type, controller.signal), audienceAnchorsOf(type)]) + .then(([states, anchors]) => { + if (!controller.signal.aborted) setLedger({ states, anchors }); + }) + .catch((err: unknown) => { + if (controller.signal.aborted) return; + setLedger('error'); + setRefusal(err instanceof Error ? err.message : String(err)); + }); + return () => controller.abort(); + }, [type, name]); + + const write = React.useCallback( + async (enabled: boolean) => { + setBusy(true); + setSwitchError(null); + try { + const now = await writeCatalogActivation(type, name, !enabled); + setLedger((prev) => { + if (!prev || prev === 'error') return prev; + const states = new Map(prev.states); + states.set(name, now); + return { ...prev, states }; + }); + if (countsCatalogHolders(type)) { + toast.success( + tFormat(now ? 'engine.catalog.active.switchedOn' : 'engine.catalog.active.switchedOff', locale, { + name: shownLabel, + }), + ); + } + } catch (err: any) { + setSwitchError(err?.message ?? String(err)); + } finally { + setBusy(false); + } + }, + [type, name, locale, shownLabel], + ); + + // The holder-count read of the confirm now open; a newer open (or a close) + // makes an older answer stale. + const holderReadRef = React.useRef(0); + const toggle = React.useCallback( + (_itemName: string, enabled: boolean) => { + if (!enabled || !countsCatalogHolders(type)) { + void write(enabled); + return; + } + const read = ++holderReadRef.current; + setPendingOff({ name, label: shownLabel, holders: null }); + readCatalogHolderCount(adapter, metadataStoreRef.current, type, name) + .then((holders) => { + if (holderReadRef.current === read) setPendingOff((prev) => (prev ? { ...prev, holders } : prev)); + }) + .catch((err: unknown) => { + if (holderReadRef.current !== read) return; + const error = err instanceof Error ? err.message : String(err); + setPendingOff((prev) => (prev ? { ...prev, holders: { error } } : prev)); + }); + }, + [type, name, shownLabel, adapter, write], + ); + const closeConfirm = React.useCallback(() => { + holderReadRef.current++; + setPendingOff(null); + }, []); + + return ( +
+ {t('engine.catalog.col.active', locale)} + + {switchError && ( +
+ {tFormat('engine.catalog.active.failed', locale, { message: switchError })} +
+ )} + { + closeConfirm(); + void write(true); + }} + /> +
+ ); +} 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 456ef231163..fedc892f24a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PermissionMatrixEditor.tsx @@ -122,6 +122,7 @@ import { useRegisterUnsavedEdits } from '../../chrome/unsavedEdits.js'; import { AssignedUsersSection } from './AssignedUsersSection.js'; import { SetupAssignmentsLink } from './SetupAssignmentsLink.js'; import { useMetadataAreaHost } from './metadataAreaHost.js'; +import { CatalogItemActiveSwitch } from './CatalogActiveSwitch.js'; import { isEnvironmentScope, useWalledCatalogReadOnly } from './catalog-scope.js'; import { useCanAuthorMetadata, useCanReadAuthoringTrail } from '../../hooks/useCanAuthorMetadata.js'; import { errorCodeIs } from '@object-ui/types'; @@ -2477,6 +2478,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, assignment page, opened on this set. */} {showAssignments && areaHost && (
+ {/* N-28 (M-1 ruling A) — the set's active switch moved with + the item from Setup's catalog list into Studio. */} +
)} 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 29a9d54485e..78cc16207fb 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -161,6 +161,7 @@ import { environmentScopeSuffix, isEnvironmentScope, useWalledCatalogReadOnly } import { PositionHoldersSection } from './PositionHoldersSection.js'; import { PositionPermissionSetsSection } from './PositionPermissionSetsSection.js'; import { PositionHoldersSetupLink } from './SetupAssignmentsLink.js'; +import { CatalogItemActiveSwitch } from './CatalogActiveSwitch.js'; import { useMetadataAreaHost } from './metadataAreaHost.js'; import { declarationOnlyAuthoredObject } from './sharing-rule-catalog.js'; import { useRegisterUnsavedEdits } from '../../chrome/unsavedEdits.js'; @@ -185,14 +186,29 @@ const ENVIRONMENT_SECTIONS: Readonly { + function CatalogActiveSection({ name, draft }: EnvironmentSectionProps) { + const label = typeof draft.label === 'string' ? draft.label : undefined; + return ; + } + return CatalogActiveSection; +} + /** * objectstack ADR-0146 D1/D7 (#28561) — the same sections under Studio's "All * metadata" area, which draws no assignment: who holds a position is Setup's * Position holders page, linked in its place. What each set grants stays, a * read of the definition. + * + * N-28 (M-1 ruling A) — and the item's active switch, first: Setup's catalog + * list, which carried it, retired, and the switch moved with the item into + * Studio. A position and a declared sharing rule draw this page; a permission + * set draws its own editor, which mounts the same switch. */ const AREA_ENVIRONMENT_SECTIONS: Readonly>>> = { - position: [PositionHoldersSetupLink, PositionPermissionSetsSection], + position: [catalogActiveSection('position'), PositionHoldersSetupLink, PositionPermissionSetsSection], + sharing_rule: [catalogActiveSection('sharing_rule')], }; /** 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 781606b6483..fce5d4d225b 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -27,18 +27,6 @@ import { } from '@object-ui/components'; import { Input } from '@object-ui/components'; import { Badge } from '@object-ui/components'; -import { Switch } from '@object-ui/components'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - buttonVariants, -} from '@object-ui/components'; import { toast } from 'sonner'; import { Select, @@ -87,9 +75,9 @@ import { isCatalogItemActive, readCatalogActivation, writeCatalogActivation, - type CatalogActivation, } from './catalog-activation.js'; -import { countsCatalogHolders, readCatalogHolderCount, type CatalogHolderCount } from './catalog-holders.js'; +import { countsCatalogHolders, readCatalogHolderCount } from './catalog-holders.js'; +import { CatalogActiveCell, DeactivateConfirmDialog, type CatalogLedger, type PendingDeactivation } from './CatalogActiveSwitch.js'; import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; import { declarationOnlyAuthoredObject } from './sharing-rule-catalog.js'; import { resolveAffordance, type SchemaLike } from '@object-ui/core'; @@ -240,11 +228,7 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string // naming how many users lose the access: the holder count, read from the // server when the confirm opens (`catalog-holders.ts`). Switching on asks // nothing. `holders` is `null` while the count is read. - const [pendingOff, setPendingOff] = React.useState<{ - name: string; - label: string; - holders: CatalogHolderCount | { error: string } | null; - } | null>(null); + const [pendingOff, setPendingOff] = React.useState(null); const adapter = useAdapter(); const locale = useMetadataLocale(); // objectui#12982 — a declaration-only catalog (declared sharing rules): the @@ -1278,133 +1262,6 @@ const INVALID_ROW_TINT = '[--os-row-tint:color-mix(in_oklab,var(--color-destruct const WARN_ROW_TINT = '[--os-row-tint:color-mix(in_oklab,var(--color-amber-500)_5%,transparent)]'; const HOVER_ROW_TINT = 'hover:[--os-row-tint:color-mix(in_oklab,var(--color-accent)_50%,transparent)]'; -/** - * objectui#13338 — the confirm a permission set's or a position's switch asks - * before it switches the item OFF, naming how many users lose the access. The - * count is the server's, read when the confirm opens; while it is read the - * confirm says so and its Deactivate waits. A count that could not be read is - * said as such (never as "0 users"), and the admin may still go ahead: the - * confirm informs, it does not gate. Cancel leaves the item on. - */ -function DeactivateConfirmDialog({ - pending, - locale, - onCancel, - onConfirm, -}: { - pending: { name: string; label: string; holders: CatalogHolderCount | { error: string } | null } | null; - locale: string; - onCancel: () => void; - onConfirm: (name: string) => void; -}) { - const holders = pending?.holders ?? null; - let impact: string; - if (holders === null) impact = t('engine.catalog.deactivate.counting', locale); - else if ('error' in holders) impact = tFormat('engine.catalog.deactivate.countFailed', locale, { message: holders.error }); - else if (holders.everyone) impact = t('engine.catalog.deactivate.everyone', locale); - else if (holders.truncated) impact = tFormat('engine.catalog.deactivate.holdersAtLeast', locale, { count: holders.count }); - else if (holders.count === 1) impact = t('engine.catalog.deactivate.holdersOne', locale); - else impact = tFormat('engine.catalog.deactivate.holders', locale, { count: holders.count }); - return ( - { - if (!open) onCancel(); - }} - > - - - - {tFormat('engine.catalog.deactivate.title', locale, { name: pending?.label ?? '' })} - - {impact} - - - {t('engine.cancel', locale)} - { - if (pending) onConfirm(pending.name); - }} - > - {t('engine.catalog.deactivate.confirm', locale)} - - - - - ); -} - -/** The activation ledger's states for a type, and the names its door refuses. */ -interface CatalogLedger { - states: CatalogActivation; - anchors: ReadonlySet; -} - -/** - * objectui#7611 — one item's active switch in the environment scope, showing - * the activation ledger's state (see `catalog-activation.ts`). Its non-switch - * states are each said, never rendered as a guessed "on": the state is still - * loading, or the read failed (said in words, with the server's reason). An audience anchor shows its state on a - * disabled switch with the reason, because the door refuses to switch it. - */ -function CatalogActiveCell({ - itemName, - ledger, - refusal, - canSwitch, - busy, - locale, - onToggle, -}: { - itemName: string; - ledger: CatalogLedger | 'error' | null; - /** Why the read failed: the server's refusal, as `actionErrorDetail` read it. */ - refusal: string | null; - canSwitch: boolean; - busy: boolean; - locale: string; - onToggle: (itemName: string, enabled: boolean) => void; -}) { - if (ledger === null) return …; - if (ledger === 'error') { - // objectui#13091 — said in plain words, never a bare "?"; the server's - // own sentence for why rides in the title. - const why = [t('engine.catalog.active.unknown', locale), refusal].filter(Boolean).join(' '); - return ( - - {t('engine.catalog.active.notVisible', locale)} - - ); - } - const enabled = isCatalogItemActive(ledger.states, itemName); - const anchor = ledger.anchors.has(itemName); - // A switch the caller cannot use states the state, and offers no click; the - // page's read-only reason says why. - const label = anchor - ? t('engine.catalog.active.anchor', locale) - : canSwitch - ? t(enabled ? 'engine.catalog.active.on' : 'engine.catalog.active.off', locale) - : t(enabled ? 'engine.catalog.status.active' : 'engine.catalog.status.inactive', locale); - return ( - - onToggle(itemName, enabled)} - aria-label={label} - data-testid={`catalog-active-${itemName}`} - /> - - ); -} - /** * The whole row opens its item (objectstack#25333), the way a record list's * row does. The first cell keeps its link, which is what a keyboard, a screen 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 index c09a978e92f..7a93e395335 100644 --- 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 @@ -77,6 +77,16 @@ vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({ useCanAuthorMetadata: () => true, useCanReadAuthoringTrail: () => true, })); +// N-28 — the area's permission-set page mounts the catalog Active switch, +// which reads the activation ledger; its behaviour is +// `CatalogActiveSwitch.itemPage-28578.test.tsx`'s subject, so a marker stands +// in for it here and no read leaves the file. +vi.mock('./CatalogActiveSwitch', async (importOriginal) => ({ + ...(await importOriginal>()), + CatalogItemActiveSwitch: ({ type, name }: { type: string; name: string }) => ( +
{`${type}/${name}`}
+ ), +})); vi.mock('./AssignedUsersSection', () => ({ AssignedUsersSection: () =>
, })); @@ -178,6 +188,7 @@ describe('the permission-set editor under the area', () => { await openSet(null, '?scope=environment'); expect(screen.getByTestId('perm-assignments')).toBeInTheDocument(); expect(screen.queryByTestId('setup-assignments-link-permission')).toBeNull(); + expect(screen.queryByTestId('catalog-item-active-stub')).toBeNull(); }); it('the cross-package leg links Setup\'s assignment page, opened on the set, and draws no holders', async () => { @@ -186,6 +197,8 @@ describe('the permission-set editor under the area', () => { 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'); + // N-28 — and the set's Active switch, which left Setup's catalog list. + expect(screen.getByTestId('catalog-item-active-stub')).toHaveTextContent('permission/sales_rep'); }); it('a read-only package: no Save', async () => { diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts index a7d426b7aed..3292381218a 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.environmentOverlayCopy-12171.test.ts @@ -49,13 +49,14 @@ const readTables: (root: string) => Map> = readDesig const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); /** - * The keys that name the overlay axis: the three `BuilderLanding` renders, and - * the hint `PermissionMatrixEditor` shows on a set a code package ships. + * The keys that name the overlay axis: the two `BuilderLanding` renders, and + * the hint `PermissionMatrixEditor` shows on a set a code package ships. The + * third landing string, the overlay link's tooltip, retired with the link: the + * landing no longer links into Setup (objectstack ADR-0146 D1, N-28). */ const KEYS = [ 'engine.studio.landing.description', 'engine.studio.landing.readonlyHint', - 'engine.studio.landing.overlayTitle', 'perm.readOnly.artifact.hint', ] as const; diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.readonlyRoutes-11808.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.readonlyRoutes-11808.test.tsx index bddc8eaca09..6f8048e485f 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.readonlyRoutes-11808.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.readonlyRoutes-11808.test.tsx @@ -8,10 +8,13 @@ * is customized by org overlay where the item's metadata type declares * `allowEnvironmentOverlay` (ADR-0005). Measured before this card: the read-only card * was one button that opened the package for browsing, with no route forward. + * The overlay link this card added, into Setup's metadata directory, retired + * with that directory (objectstack ADR-0146 D1, N-28): an item is customized in + * Studio now. * * The routes are read off the real `BuilderLanding` under a real router: the - * overlay link lands on the package's metadata directory, and the marketplace - * link appears only when the runtime serves a marketplace — read through the + * card opens the package for browsing, and the marketplace link appears only + * when the runtime serves a marketplace — read through the * REAL `initRuntimeConfig()` over a stubbed `GET /api/v1/runtime/config`, the * way `HomePage.marketplaceDisabled.test.tsx` drives the same flag. */ @@ -71,7 +74,6 @@ function renderLanding() { } /> } /> - } /> } /> , @@ -86,22 +88,21 @@ function cardOf(name: string): HTMLElement { } describe('Studio landing: a read-only package points at the routes it has (objectui#11808)', () => { - it('links the read-only card to the package’s metadata directory, the org-overlay route', async () => { + it('the read-only card carries no link into Setup’s metadata directory (ADR-0146 D1)', async () => { + // objectui#11808 linked the card to `/apps/setup/metadata?package=…`, the + // overlay route. N-28 retired Setup's metadata directory into Studio's "All + // metadata" area, so the card opens the package itself (below) and links + // nowhere into Setup's metadata pages. await bootRuntime({ marketplace: true }); packages.list = [READ_ONLY]; renderLanding(); await screen.findByText('Showcase'); expect(screen.getByTestId('studio-landing-readonly-hint')).toBeInTheDocument(); - const overlay = within(cardOf('Showcase')).getByTestId('studio-landing-overlay'); - expect(overlay).toHaveAttribute('href', '/apps/setup/metadata?package=com.example.showcase'); - expect(overlay).toHaveAccessibleName(t('engine.studio.landing.overlay', 'en-US')); - - fireEvent.click(overlay); - await waitFor(() => - expect(screen.getByTestId('location')).toHaveTextContent('/apps/setup/metadata?package=com.example.showcase'), - ); - expect(screen.getByTestId('metadata-directory')).toBeInTheDocument(); + expect(within(cardOf('Showcase')).queryByTestId('studio-landing-overlay')).not.toBeInTheDocument(); + for (const link of within(cardOf('Showcase')).queryAllByRole('link')) { + expect(link.getAttribute('href') ?? '').not.toMatch(/^\/apps\/setup\/metadata/); + } }); it('still opens the read-only package for browsing from the card itself', async () => { @@ -122,8 +123,6 @@ describe('Studio landing: a read-only package points at the routes it has (objec const dupTitle = t('engine.studio.landing.dupTitle', 'en-US'); expect(within(cardOf('Acme')).getByTitle(dupTitle)).toBeInTheDocument(); expect(within(cardOf('Showcase')).queryByTitle(dupTitle)).not.toBeInTheDocument(); - // The overlay route is the read-only card's, not the writable base's. - expect(within(cardOf('Acme')).queryByTestId('studio-landing-overlay')).not.toBeInTheDocument(); }); it('links the marketplace when the runtime serves one', async () => { @@ -138,14 +137,14 @@ describe('Studio landing: a read-only package points at the routes it has (objec expect(screen.getByTestId('marketplace')).toBeInTheDocument(); }); - it('offers no marketplace link on a runtime without one, and keeps the overlay route', async () => { + it('offers no marketplace link on a runtime without one', async () => { await bootRuntime({ marketplace: false }); packages.list = [READ_ONLY]; renderLanding(); await screen.findByText('Showcase'); expect(screen.queryByTestId('studio-landing-marketplace')).not.toBeInTheDocument(); - expect(within(cardOf('Showcase')).getByTestId('studio-landing-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('studio-landing-readonly-hint')).toBeInTheDocument(); }); it('renders no read-only section at all when every package is writable', async () => { diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx index 4aea65f7b30..c2128f750cf 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.setupExit-12513.test.tsx @@ -1,15 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * objectui#12513 — the installed card's link leaves Studio for Setup, and says - * so: it reads "Open in Setup" and carries an outward arrow, while its route - * (the package's metadata directory, objectui#11808) and its testid stay. + * objectui#12513 gave the installed card's link into Setup an "Open in Setup" + * label and an outward arrow, so it said that it left Studio. objectstack + * ADR-0146 D1 (N-28) retired the page it opened: Setup's metadata directory + * redirects into Studio's "All metadata" area, so the link would have left + * Studio only to come straight back. The card now carries no Setup exit, and + * the Packaged automation route Studio built (`packagedAutomationPath`) went + * with that page (D11). */ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen, within } from '@testing-library/react'; +import { cleanup, render, screen } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; const packages = vi.hoisted(() => ({ list: [] as Array> })); @@ -48,19 +52,21 @@ function renderLanding() { ); } -describe('Studio landing: the installed card’s Setup exit says where it goes (objectui#12513)', () => { - it('reads "Open in Setup" with an outward arrow, on the same route', async () => { +describe('Studio landing: the installed card has no Setup exit (objectui#12513, ADR-0146 D1)', () => { + it('a read-only package card links nowhere into Setup', async () => { packages.list = [READ_ONLY]; renderLanding(); await screen.findByText('Showcase'); - const link = screen.getByTestId('studio-landing-overlay'); - expect(link).toHaveAccessibleName('Open in Setup'); - expect(link).toHaveAttribute('href', '/apps/setup/metadata?package=com.example.showcase'); - expect(link.querySelector('svg.lucide-arrow-up-right')).not.toBeNull(); - // The overlay explanation stays on the link's tooltip. - expect(link).toHaveAttribute('title', expect.stringContaining('environment overlay')); - expect(within(link).queryByText('Customize with an overlay')).toBeNull(); + expect(screen.queryByTestId('studio-landing-overlay')).toBeNull(); + expect(screen.queryByText('Open in Setup')).toBeNull(); + for (const link of screen.queryAllByRole('link')) { + expect(link.getAttribute('href') ?? '').not.toMatch(/^\/apps\/setup\/metadata/); + } + }); + + it('no longer builds the overlay route into Setup’s metadata directory', () => { + expect('overlayPath' in landing).toBe(false); }); it('no longer builds the Packaged automation route (ADR-0146 D11)', () => { 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 a09e086d4d6..a2679eb1b6e 100644 --- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -22,7 +22,7 @@ import * as React from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowUpRight, Boxes, Building2, Clock, Hammer, Library, Lock, Plus, Loader2, Copy, Store } from 'lucide-react'; +import { 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'; @@ -38,21 +38,13 @@ import { STUDIO_ALL_PACKAGES_SEGMENT, studioMetadataAreaPath } from './metadataA import { STUDIO_NEW_PARAM, STUDIO_NEW_PACKAGE_VALUE } from './studioNewPackage.js'; /** - * objectui#11808 — the two places a read-only package's card sends the author. - * Both are routes the console already serves, spelled the way its own entries - * spell them under the `setup` app: the Setup sidebar's metadata entries - * (`/apps/setup/metadata/:type`) and Home's "Start with a template". - * - * The overlay route is the package's metadata directory, scoped by its - * `package` query parameter. It marks, per metadata type, whether the type - * accepts an environment overlay — the answer this page cannot give, because - * `allowEnvironmentOverlay` belongs to the TYPE (ADR-0005), not to the package or the - * item, and `PkgEntry` does not say which types a package ships. The Studio's - * read-only link (objectui#12453) lands on the same route through `overlayPath`. + * objectui#11808 — where a read-only package's card sends the author beyond + * the package itself: Home's "Start with a template" (the marketplace), when + * this runtime serves one. The card's own button opens the package in Studio, + * where Customize and the "All metadata" area are; Studio links into no Setup + * metadata page (objectstack ADR-0146 D1, Setup carries none). */ const MARKETPLACE_PATH = '/apps/setup/system/marketplace'; -export const overlayPath = (packageId: string) => - `/apps/setup/metadata?package=${encodeURIComponent(packageId)}`; /** objectui#11863 — how many recent packages the landing lists: one row of cards. */ const RECENT_PACKAGES_SHOWN = 3; @@ -456,13 +448,13 @@ export function BuilderLanding(): React.ReactElement { {/* objectui#11808 — a read-only package is not a dead end. ADR-0070 D2: * code and installed packages take no new items, and D4's Duplicate - * clones a writable base, not them. They are customized by environment overlay - * where the item's metadata type declares `allowEnvironmentOverlay` (ADR-0005), - * so each card links to the package's metadata directory, which says - * per type whether an overlay is accepted (see `overlayPath`). The - * marketplace, which Home's "Start with a template" opens, is offered - * only when this runtime serves one (`isMarketplaceEnabled`, - * objectui#5504): elsewhere that route does not exist. */} + * clones a writable base, not them. Each card opens the package in + * Studio, where it is customized (objectstack ADR-0146: Customize, and + * the "All metadata" area's per-type overlays); the card links into no + * Setup page. The marketplace, which Home's "Start with a template" + * opens, is offered only when this runtime serves one + * (`isMarketplaceEnabled`, objectui#5504): elsewhere that route does not + * exist. */} {readonly.length > 0 && ( <>

@@ -504,17 +496,6 @@ export function BuilderLanding(): React.ReactElement {
{renderCustomizationLine(p.id)} - - {/* objectui#12513 — the link leaves Studio for Setup: its - name says where it goes, the arrow that it goes out. */} - {t('engine.studio.landing.overlay', locale)} -

))} 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 95f69a03d78..73bb43337f3 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -283,7 +283,7 @@ import { isStudioOrgScope, studioOrgScopePath, } from './studioScope.js'; -import { STUDIO_METADATA_AREA, studioMetadataAreaPath, studioPillarItemPath } from './metadataArea.js'; +import { STUDIO_ALL_PACKAGES_SEGMENT, STUDIO_METADATA_AREA, studioMetadataAreaPath, studioPillarItemPath } from './metadataArea.js'; import { PermissionSetCloneDialog } from '../metadata-admin/PermissionSetCloneDialog.js'; import type { PermissionSetCloned } from '../metadata-admin/permission-set-clone.js'; import { useWalledCatalogReadOnly } from '../metadata-admin/catalog-scope.js'; @@ -873,9 +873,11 @@ const PACKAGE_LIST_TOAST_ID = 'studio-package-list'; * * It offers an environment overlay only where one exists: when the open * item's metadata type declares `allowEnvironmentOverlay` in the served - * registry (ADR-0005), it is a link to that item's Setup page, where the - * overlay is edited. Any other item, no open item, or a registry not yet - * read draws a plain "Read-only" mark that links nowhere. + * registry (ADR-0005), it is a link to that item's page in Studio's "All + * metadata" area, where the overlay is edited: the page Setup's item route + * drew, which ADR-0146 D1 moved into Studio, on its cross-package leg. Any + * other item, no open item, or a registry not yet read draws a plain + * "Read-only" mark that links nowhere. * * objectstack#28562 (ADR-0146 D2) — nor does a view, dashboard or report * (`isStudioCustomizeType`): Studio customizes those itself, into the base's @@ -923,7 +925,7 @@ export function StudioReadOnlyLink({ return overlayable && item ? ( } - to={`/apps/setup/metadata/${encodeURIComponent(item.type)}/${encodeURIComponent(item.name)}`} + to={`${studioMetadataAreaPath(STUDIO_ALL_PACKAGES_SEGMENT)}/${encodeURIComponent(item.type)}/${encodeURIComponent(item.name)}`} title={title} data-testid="studio-readonly-link" data-folded={folded || undefined} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx index 39dfc54dacb..b76a181b6ed 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioReadOnlyLink.overlay-27356.test.tsx @@ -4,7 +4,9 @@ * #27356 row 13 (hotcrm#2320 Studio S2) — the read-only mark in a Studio item * header offers an environment overlay only where one exists: an open item * whose type the served registry marks `allowEnvironmentOverlay` gets - * "Read-only · Customize with an overlay", linking to that item's Setup page. + * "Read-only · Customize with an overlay", linking to that item's page — + * Setup's once, the item's page in Studio's "All metadata" area since + * objectstack ADR-0146 D1 (N-28). * Every other case (a type that takes no overlay, no open item, a registry not * read) gets a plain "Read-only" that links nowhere. * @@ -58,12 +60,14 @@ afterEach(() => { }); describe('the read-only mark offers an overlay only for a type that takes one (#27356 row 13)', () => { - it('an open overlay-able item Studio does not customize: the overlay link, landing on that item’s Setup page', () => { + it('an open overlay-able item Studio does not customize: the overlay link, landing on that item’s page in the All metadata area', () => { registry.entries = SERVED; const mark = renderMark({ type: 'email_template', name: 'welcome' }); expect(mark.tagName).toBe('A'); expect(mark).toHaveTextContent(LINK); - expect(mark).toHaveAttribute('href', '/apps/setup/metadata/email_template/welcome'); + // Not Setup's page: Setup's metadata routes redirect into Studio's "All + // metadata" area (objectstack ADR-0146 D1, N-28), so the link goes there. + expect(mark).toHaveAttribute('href', '/studio/~all/metadata/email_template/welcome'); expect(mark).toHaveAttribute('title', 'hint'); }); diff --git a/ui/packages/app-shell/src/views/studio-design/noSetupMetadataLink-28578.test.ts b/ui/packages/app-shell/src/views/studio-design/noSetupMetadataLink-28578.test.ts new file mode 100644 index 00000000000..a2ee9e33169 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/noSetupMetadataLink-28578.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Studio links into no Setup metadata page (objectstack ADR-0146 D1; N-28). + * + * Setup carries no metadata editor and no metadata catalog: its + * `/apps//metadata/...` routes redirect into Studio's "All metadata" area + * (`setupMetadataRedirect.ts`). A Studio file that still linked there would + * send the user out of Studio and straight back. This pin reads every + * non-test source under `views/studio-design/`, comments masked, and refuses + * any `/apps//metadata` spelling. (The retired Setup › Packaged + * automation page has its own pin, `noRetiredSetupLink-28578.test.ts`.) + * + * ⛔ The datasource is exempt: it stays in Setup (D1), and the area's + * `datasource/*` route sends it to Setup's Datasources page + * (`SETUP_DATASOURCES_PATH`). + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../../scripts/js-comment-mask.mjs'; + +const mask: (source: string) => string = maskComments; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry !== '__tests__') out.push(...sourceFiles(full)); + } else if (/\.tsx?$/.test(full) && !/\.test\.tsx?$/.test(full)) { + out.push(full); + } + } + return out; +} + +/** + * A Setup metadata URL, written out or composed: `/apps/setup/metadata`, + * `/apps/${app}/metadata`, `apps/setup/component/metadata/…` — but not the + * datasource's. + */ +const SETUP_METADATA_LINK = + /\/?apps\/(?:[\w-]+|\$\{[^}]*\})\/(?:component\/)?metadata(?![\w-])(?!\/datasource(?![\w-]))/g; + +function setupLinks(source: string): string[] { + return [...mask(source).matchAll(SETUP_METADATA_LINK)].map((m) => m[0]); +} + +describe('Studio links into no Setup metadata page (ADR-0146 D1)', () => { + it('the matcher sees each spelling and spares the datasource', () => { + expect(setupLinks("const a = '/apps/setup/metadata/object';")).toEqual(['/apps/setup/metadata']); + expect(setupLinks('const b = `/apps/${appName}/metadata/${type}`;')).toEqual(['/apps/${appName}/metadata']); + expect(setupLinks("navigate('/apps/setup/metadata')")).toEqual(['/apps/setup/metadata']); + expect(setupLinks("const c = '/apps/setup/component/metadata/resource?type=object';")).toHaveLength(1); + expect(setupLinks("const d = '/apps/setup/metadata/datasource';")).toEqual([]); + expect(setupLinks("const e = '/apps/setup/metadata/datasource/main';")).toEqual([]); + // Not a Setup metadata URL at all. + expect(setupLinks("const f = '/studio/~all/metadata/object';")).toEqual([]); + expect(setupLinks("const g = '/apps/setup/metadata_import';")).toEqual([]); + // A comment is prose, not a link. + expect(setupLinks('// opens /apps/setup/metadata/object\nconst h = 1;')).toEqual([]); + }); + + it('no Studio source links into /apps//metadata', () => { + const files = sourceFiles(here); + // Control: the walk really reads the folder. + expect(files.some((file) => file.endsWith('StudioDesignSurface.tsx'))).toBe(true); + expect(files.some((file) => file.endsWith('BuilderLanding.tsx'))).toBe(true); + + const offenders = files.flatMap((file) => + setupLinks(readFileSync(file, 'utf8')).map((link) => `${path.basename(file)}: ${link}`), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.test.ts b/ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.test.ts new file mode 100644 index 00000000000..5a484308ead --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a Setup metadata URL lands (objectstack ADR-0146 D1, N-28): Studio's + * "All metadata" area, on the leg its query names, in one computation; the + * datasource stays in Setup. The route-level table, through the real + * `AppContent`, is `console/__tests__/AppContent.setupMetadataStudio-28578.test.tsx`. + */ + +import { describe, it, expect } from 'vitest'; +import { + SETUP_KEPT_METADATA_TYPE, + packagedAutomationStudioPath, + setupMetadataRest, + setupMetadataStudioPath, +} from './setupMetadataRedirect.js'; +import { areaLocationRedirect } from './metadataArea.js'; + +describe('setupMetadataStudioPath', () => { + it.each([ + // [rest, search, expected] + ['', '', '/studio/~all/metadata'], + ['_diagnostics', '', '/studio/~all/metadata/_diagnostics'], + ['object', '', '/studio/~all/metadata/object'], + ['object/account', '', '/studio/~all/metadata/object/account'], + ['object/new', '', '/studio/~all/metadata/object/new'], + ['permission/admin/history', '', '/studio/~all/metadata/permission/admin/history'], + // `?package=` names the leg, and is kept. + ['object/account', '?package=crm', '/studio/crm/metadata/object/account?package=crm'], + ['object', 'package=crm', '/studio/crm/metadata/object?package=crm'], + ['object', '?package=all', '/studio/~all/metadata/object?package=all'], + // `?scope=environment` is the cross-package leg; it binds no package. + ['position', '?scope=environment', '/studio/~all/metadata/position?scope=environment'], + ['position', '?scope=environment&package=crm', '/studio/~all/metadata/position?scope=environment'], + // `?review=1` is kept, alone or beside a package. + ['object/account', '?review=1', '/studio/~all/metadata/object/account?review=1'], + ['object/account', '?package=crm&review=1', '/studio/crm/metadata/object/account?package=crm&review=1'], + // A trailing or doubled slash does not leak into the target. + ['object/', '', '/studio/~all/metadata/object'], + ])('%s %s → %s', (rest, search, expected) => { + expect(setupMetadataStudioPath(rest, search)).toBe(expected); + }); + + it('keeps the hash', () => { + expect(setupMetadataStudioPath('object/account', '?package=crm', '#fields')).toBe( + '/studio/crm/metadata/object/account?package=crm#fields', + ); + expect(setupMetadataStudioPath('object', '', '#x')).toBe('/studio/~all/metadata/object#x'); + }); + + it.each([ + ['datasource'], + ['datasource/main'], + ['datasource/new'], + ['datasource/main/history'], + ])('a datasource URL stays in Setup: %s', (rest) => { + expect(SETUP_KEPT_METADATA_TYPE).toBe('datasource'); + expect(setupMetadataStudioPath(rest, '?package=crm')).toBeNull(); + }); + + it('a type that merely starts with "datasource" is not the datasource', () => { + expect(setupMetadataStudioPath('datasource_mapping', '')).toBe('/studio/~all/metadata/datasource_mapping'); + }); + + it('lands where the area keeps it: one hop, never a second', () => { + // The area re-roots a URL whose query names another leg. Every target + // above is already on its leg, so the area adds no second redirect. + for (const [rest, search] of [ + ['object/account', ''], + ['object/account', '?package=crm'], + ['position', '?scope=environment&package=crm'], + ['object', '?package=all&review=1'], + ] as const) { + const target = new URL(setupMetadataStudioPath(rest, search)!, 'http://x'); + const [, , leg, , ...tail] = target.pathname.split('/'); + expect(areaLocationRedirect(decodeURIComponent(leg), tail.join('/'), target.search)).toBeNull(); + } + }); +}); + +describe('setupMetadataRest', () => { + it.each([ + ['/apps/setup/metadata', ''], + ['/apps/setup/metadata/', ''], + ['/apps/setup/metadata/object', 'object'], + ['/apps/crm/metadata/object/account', 'object/account'], + ['/apps/setup/metadata/permission/admin/history', 'permission/admin/history'], + ['/apps/metadata/metadata/object', 'object'], + ])('%s → %s', (pathname, rest) => { + expect(setupMetadataRest(pathname)).toBe(rest); + }); + + it.each([['/apps/setup/system'], ['/apps/setup/metadata_import'], ['/studio/~all/metadata/object']])( + '%s is not a metadata route', + (pathname) => { + expect(setupMetadataRest(pathname)).toBeNull(); + }, + ); +}); + +describe('packagedAutomationStudioPath (ADR-0146 D11)', () => { + it.each([ + // [search, hash, expected] + ['?package=crm', '', '/studio/crm/automations?package=crm'], + ['package=com.example.showcase&q=alert', '', '/studio/com.example.showcase/automations?package=com.example.showcase&q=alert'], + ['?package=crm', '#flows', '/studio/crm/automations?package=crm#flows'], + // No package named: the page listed every package's flows. + ['', '', '/studio/~all/metadata/flow'], + ['?q=alert', '', '/studio/~all/metadata/flow?q=alert'], + ['?package=all', '', '/studio/~all/metadata/flow?package=all'], + ])('%s %s → %s', (search, hash, expected) => { + expect(packagedAutomationStudioPath(search, hash)).toBe(expected); + }); + + it('the cross-package landing is already on its leg: the area adds no second hop', () => { + const target = new URL(packagedAutomationStudioPath('?package=all'), 'http://x'); + expect(areaLocationRedirect('~all', 'flow', target.search)).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.ts b/ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.ts new file mode 100644 index 00000000000..1d78acc0cc1 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/setupMetadataRedirect.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Where a Setup metadata URL lands now that Setup carries no metadata editor + * and no metadata catalog (objectstack ADR-0146 D1, D11 "Retire"; N-28). + * + * Every `/apps//metadata/...` URL, and every legacy alias that chained + * into one, opens Studio's "All metadata" area (`metadataArea.ts`) on the same + * page: the area mounts the metadata-admin pages under the same route shapes, + * so the path below `metadata` carries over as it is. The query carries over + * too, because `?package=`, `?scope=environment` and `?review=1` keep their + * Setup meaning there. The URL is put on the leg its query names here, with + * {@link areaLocationRedirect}, so an old link lands in one hop. + * + * ⛔ Not the datasource. D1 places it in Setup (runtime configuration, not + * metadata authoring), and the area sends every datasource URL back to Setup's + * Datasources page, so redirecting it here would loop. + */ + +import { STUDIO_ALL_PACKAGES_SEGMENT, areaLocationRedirect, studioMetadataAreaPath } from './metadataArea.js'; + +/** The one metadata type whose Setup pages stay in Setup (ADR-0146 D1). */ +export const SETUP_KEPT_METADATA_TYPE = 'datasource'; + +/** + * The Studio URL for a Setup metadata URL, or `null` when the URL stays in + * Setup (a datasource page). + * + * `rest` is the path below `…/metadata` as the URL spells it (encoded), `''` + * at the root: `object`, `object/account`, `permission/admin/history`, + * `_diagnostics`. `search` is the URL's query, with or without its `?`, and + * `hash` its fragment, with its `#`. + */ +export function setupMetadataStudioPath(rest: string, search: string, hash = ''): string | null { + const segments = rest.split('/').filter(Boolean); + if (segments[0] === SETUP_KEPT_METADATA_TYPE) return null; + const tail = segments.join('/'); + const onLeg = areaLocationRedirect(STUDIO_ALL_PACKAGES_SEGMENT, tail, search); + if (onLeg) return `${onLeg}${hash}`; + const query = new URLSearchParams(search).toString(); + return `${studioMetadataAreaPath(STUDIO_ALL_PACKAGES_SEGMENT)}${tail ? `/${tail}` : ''}${query ? `?${query}` : ''}${hash}`; +} + +/** + * The part of an app path below its `metadata` segment, or `null` when the + * path is not one of the metadata routes: `/apps/setup/metadata/object/account` + * → `object/account`, `/apps/crm/metadata` → `''`. The app segment is the first + * one after `/apps`, so an app named `metadata` still reads right. + */ +export function setupMetadataRest(pathname: string): string | null { + const match = /^\/apps\/[^/]+\/metadata(?:\/(.*))?$/.exec(pathname); + if (!match) return null; + return (match[1] ?? '').replace(/\/+$/, ''); +} + +/** + * Where a bookmark of the retired Setup › Packaged automation page lands + * (`/apps//component/automation/packaged`, ADR-0146 D11): Studio's + * Automations pillar, where a packaged flow's switch and its New similar are + * (D4). + * + * The pillar is a package's, so `?package=` names which one, and is kept + * with the rest of the query (`?package=all`, the cross-package spelling the + * area reads, names none). With no package named (the page listed every + * package's flows), the URL opens the cross-package list of flows in the "All + * metadata" area, which lists the same flows and opens each one. + */ +export function packagedAutomationStudioPath(search: string, hash = ''): string { + const params = new URLSearchParams(search); + const query = params.toString(); + const tail = `${query ? `?${query}` : ''}${hash}`; + const pkg = params.get('package'); + if (pkg && pkg !== 'all') return `/studio/${encodeURIComponent(pkg)}/automations${tail}`; + return `${studioMetadataAreaPath(STUDIO_ALL_PACKAGES_SEGMENT)}/flow${tail}`; +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index e707b47672a..8fb62691919 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3232,9 +3232,6 @@ const STUDIO_EN: Record = { // objectui#11808 — the routes a read-only package does have (ADR-0070 D2, // ADR-0005): an environment overlay, and the marketplace where the runtime has one. 'engine.studio.landing.readonlyHint': 'Code and installed packages are read-only, so they are not edited or duplicated here. Customize one with an environment overlay, for the metadata types that accept one.', - // objectui#12513 — the link leaves Studio for Setup, and says so. - 'engine.studio.landing.overlay': 'Open in Setup', - 'engine.studio.landing.overlayTitle': 'Open this package’s metadata: each type shows whether it accepts an environment overlay', 'engine.studio.landing.marketplace': 'Or install a template app from the marketplace', // ADR-0146 D3 — a read-only base's card says what this deployment changed on // it, from the server's per-package customization read. diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 5c6e6610e62..8b4f579ed4e 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2864,8 +2864,6 @@ const STUDIO_ZH: Record = { 'engine.studio.landing.installedHeading': '已安装(只读 · 可浏览)', // objectui#11808 — the routes a read-only package does have. 'engine.studio.landing.readonlyHint': '源码与已安装的软件包为只读,不能在此编辑或复制。对于接受环境覆盖的元数据类型,可用环境覆盖(overlay)进行定制。', - 'engine.studio.landing.overlay': '在设置中打开', - 'engine.studio.landing.overlayTitle': '打开该软件包的元数据:每种类型都会标明是否接受环境覆盖', 'engine.studio.landing.marketplace': '或从应用市场安装一个模板应用', 'engine.studio.landing.customizations.customized': '已定制 {customized} 项', 'engine.studio.landing.customizations.separator': ',', From d1def38df6f8c75f3fc911f4c7dfa7fcf54846c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 21:46:12 +0000 Subject: [PATCH 2/5] =?UTF-8?q?docs(adr):=20re-point=20ADR-0126=20=C2=A77.?= =?UTF-8?q?4's=20Setup=20nav=20anchor=20to=20a=20surviving=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The permission-set catalog entry it named leaves Setup's navigation in this change, so the symbol anchor no longer resolved. It now anchors the permission-set assignment entry, which stays in Setup. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRKMRpKnN9FkAvSWGr --- docs/adr/0126-packaged-metadata-customization-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0126-packaged-metadata-customization-model.md b/docs/adr/0126-packaged-metadata-customization-model.md index 783e8d77d87..66af9ded52e 100644 --- a/docs/adr/0126-packaged-metadata-customization-model.md +++ b/docs/adr/0126-packaged-metadata-customization-model.md @@ -405,7 +405,7 @@ callers first, or don't), and preserves "packaged code calls what it names". - **Leaning D2 — a Setup page for packaged automation** (final call rides this ADR's merge): packaged flows with their activation state and the enable/disable and clone actions, - contributed the way `nav_permission_sets` is (`packages/plugins/plugin-security/src/security-plugin.ts#nav_permission_sets`). The maintainer + contributed the way Setup's permission-set pages are (`packages/plugins/plugin-security/src/security-plugin.ts#nav_permission_set_assignment`). The maintainer corrected the record here: automation UI is **Studio-only today** (`packages/platform-objects/src/apps/studio.app.ts`), so this page is new work, and the Setup permission-set page is the precedent shape. Studio keeps the editing; Setup gets the operational state. Minimum honest content per packaged flow: From 5397876f1e350f7ac48d8fe3a624234e73d33b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 22:02:05 +0000 Subject: [PATCH 3/5] test(e2e): open the object designer on an authored package in F2 and F3 Studio's "All metadata" area offers a create page on a writable package only: a read-only package's `:type/new` lands on the type's list. The showcase is provided by code, so F2 and F3 found the object list and no designer. They now author a package first, as F4 does, and delete it afterwards. The assertions are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRKMRpKnN9FkAvSWGr --- ui/e2e/live/studio-object-designer.spec.ts | 53 ++++++++++++++-------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/ui/e2e/live/studio-object-designer.spec.ts b/ui/e2e/live/studio-object-designer.spec.ts index 8e7470a4114..2e95b199ff1 100644 --- a/ui/e2e/live/studio-object-designer.spec.ts +++ b/ui/e2e/live/studio-object-designer.spec.ts @@ -22,14 +22,17 @@ import { test, expect, type APIRequestContext, type Page } from '@playwright/tes // The object designer is reached through Studio's "All metadata" area, on the // package's own leg (`/studio//metadata/...`): Setup carries no // metadata editor (objectstack ADR-0146 D1), and its old URLs redirect here. +// The area opens a create page on a WRITABLE package only: the showcase is +// provided by code, so its create URL lands on the type's list, and every +// test that opens the designer authors a package of its own first. const PKG = 'com.example.showcase'; const newObjectIn = (pkg: string) => `/studio/${pkg}/metadata/object/new?package=${pkg}`; -const NEW_OBJ = newObjectIn(PKG); const API = process.env.LIVE_API_URL || 'http://localhost:3000'; /** - * A package the environment authors, for a test that SAVES: the showcase is - * provided by code, so a save into it is refused (`WRITABLE_PACKAGE_REQUIRED`). + * A package the environment authors, for a test that opens the designer: the + * showcase is provided by code, so the area offers no create page on it, and a + * save into it is refused (`WRITABLE_PACKAGE_REQUIRED`). */ async function authorWritablePackage(request: APIRequestContext, stamp: number): Promise { const id = `com.e2e.designer${stamp}`; @@ -49,25 +52,35 @@ async function addField(page: Page, typeName: string) { await page.getByRole('button', { name: typeName, exact: true }).first().click(); } -test('F2: object Name accepts a typed underscore (no per-keystroke trim)', async ({ page }) => { - await page.goto(NEW_OBJ); - const name = page.getByTestId('object-name-input'); - await expect(name).toBeVisible(); - await name.click(); - // MUST type char-by-char: .fill() would set the value in one shot and hide - // the per-keystroke bug. Pre-fix this yields "repairticket". - await name.pressSequentially('repair_ticket'); - await expect(name).toHaveValue('repair_ticket'); +test('F2: object Name accepts a typed underscore (no per-keystroke trim)', async ({ page, request }) => { + const pkgId = await authorWritablePackage(request, Date.now()); + try { + await page.goto(newObjectIn(pkgId)); + const name = page.getByTestId('object-name-input'); + await expect(name).toBeVisible(); + await name.click(); + // MUST type char-by-char: .fill() would set the value in one shot and hide + // the per-keystroke bug. Pre-fix this yields "repairticket". + await name.pressSequentially('repair_ticket'); + await expect(name).toHaveValue('repair_ticket'); + } finally { + await request.delete(`${API}/api/v1/packages/${pkgId}`); + } }); -test('F3: a field API name derives from its label on blur', async ({ page }) => { - await page.goto(NEW_OBJ); - await page.getByTestId('object-name-input').fill('asset'); - await addField(page, 'Text'); - const label = page.getByTestId('field-label-input'); - await label.fill('Asset Code'); - await label.blur(); - await expect(page.getByTestId('field-apiname-input')).toHaveValue('asset_code'); +test('F3: a field API name derives from its label on blur', async ({ page, request }) => { + const pkgId = await authorWritablePackage(request, Date.now()); + try { + await page.goto(newObjectIn(pkgId)); + await page.getByTestId('object-name-input').fill('asset'); + await addField(page, 'Text'); + const label = page.getByTestId('field-label-input'); + await label.fill('Asset Code'); + await label.blur(); + await expect(page.getByTestId('field-apiname-input')).toHaveValue('asset_code'); + } finally { + await request.delete(`${API}/api/v1/packages/${pkgId}`); + } }); test('F4: adding a picklist + an empty option row does not trip spec validation, and is not stored', async ({ page, request }) => { From d4b2406222bf39db17877269f8301248ace00987 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 22:26:46 +0000 Subject: [PATCH 4/5] fix(console): give the Packaged automation redirect its metadata-reads row The bookmark redirect route `component/automation/packaged` is a new `` in the app branch, and every such route declares what it reads. A redirect reads nothing: the Studio page it lands on loads its own. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRKMRpKnN9FkAvSWGr --- ui/packages/app-shell/src/console/appRouteMetadataReads.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/packages/app-shell/src/console/appRouteMetadataReads.ts b/ui/packages/app-shell/src/console/appRouteMetadataReads.ts index df99c5d97c8..be99b3c6c7d 100644 --- a/ui/packages/app-shell/src/console/appRouteMetadataReads.ts +++ b/ui/packages/app-shell/src/console/appRouteMetadataReads.ts @@ -69,6 +69,8 @@ export const APP_ROUTE_METADATA_READS: ReadonlyArray<{ path: string; reads: read { path: 'page/:pageName', reads: ['page', 'object'] }, // ComponentNavView renders whatever component the registry holds. { path: 'component/:ns/:name/*', reads: ALL_LAZY_METADATA_TYPES }, + // A redirect into Studio (objectstack#28578): the retired Packaged automation page. + { path: 'component/automation/packaged', reads: NONE }, { path: 'component/metadata/directory', reads: NONE }, { path: 'component/metadata/resource/*', reads: NONE }, { path: 'design/dashboard/:dashboardName', reads: ['dashboard'] }, From 31ecbcd31b0f6af7ee89836c761940865944178f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:18:36 +0000 Subject: [PATCH 5/5] fix(setup): keep ADR-0126's dated wording, and point Studio's refusal at Permission Set Assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR-0126 §7.4 keeps the words the record was written with. The retired nav id is spelled in plain quotes, the anchor drops to the file level, and a dated note says the entry is retired under N-28 and §7.4's surface moved per ADR-0146 D4. - The Studio access refusal names Setup → Permission Set Assignment in all ten packs and in the call site's default. The Permission Sets entry it named is retired, and its URL redirects into the Studio the reader cannot open. - The security plugin's comment no longer says capabilities are switched on and off: only a position and a permission set carry the switch. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jRKMRpKnN9FkAvSWGr --- .../0126-packaged-metadata-customization-model.md | 2 +- .../plugins/plugin-security/src/security-plugin.ts | 9 +++++---- ui/apps/console/src/components/StudioRoute.test.tsx | 2 +- ui/apps/console/src/components/StudioRoute.tsx | 2 +- .../src/__tests__/studioAccessGrantor-27521.test.ts | 13 +++++++------ ui/packages/i18n/src/locales/ar.ts | 2 +- ui/packages/i18n/src/locales/de.ts | 2 +- ui/packages/i18n/src/locales/en.ts | 2 +- ui/packages/i18n/src/locales/es.ts | 2 +- ui/packages/i18n/src/locales/fr.ts | 2 +- ui/packages/i18n/src/locales/ja.ts | 2 +- ui/packages/i18n/src/locales/ko.ts | 2 +- ui/packages/i18n/src/locales/pt.ts | 2 +- ui/packages/i18n/src/locales/ru.ts | 2 +- ui/packages/i18n/src/locales/zh.ts | 2 +- 15 files changed, 25 insertions(+), 23 deletions(-) diff --git a/docs/adr/0126-packaged-metadata-customization-model.md b/docs/adr/0126-packaged-metadata-customization-model.md index 66af9ded52e..ca376339b58 100644 --- a/docs/adr/0126-packaged-metadata-customization-model.md +++ b/docs/adr/0126-packaged-metadata-customization-model.md @@ -405,7 +405,7 @@ callers first, or don't), and preserves "packaged code calls what it names". - **Leaning D2 — a Setup page for packaged automation** (final call rides this ADR's merge): packaged flows with their activation state and the enable/disable and clone actions, - contributed the way Setup's permission-set pages are (`packages/plugins/plugin-security/src/security-plugin.ts#nav_permission_set_assignment`). The maintainer + contributed the way "nav_permission_sets" is (`packages/plugins/plugin-security/src/security-plugin.ts`; 2026-11-02 note: that entry is retired under N-28, #28578, and §7.4's surface moved per ADR-0146 D4). The maintainer corrected the record here: automation UI is **Studio-only today** (`packages/platform-objects/src/apps/studio.app.ts`), so this page is new work, and the Setup permission-set page is the precedent shape. Studio keeps the editing; Setup gets the operational state. Minimum honest content per packaged flow: diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1f088f768d2..9c016b7559b 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1630,10 +1630,11 @@ export class SecurityPlugin implements Plugin { // `metadata.list('permissions')`. permissions: this.bootstrapPermissionSets, // [ADR-0146 D1] No catalog entries: Setup carries no metadata catalog. - // Positions, capabilities and permission sets are listed and switched - // on and off in Studio's "All metadata" area (`/studio/~all/metadata`), - // and an old Setup link to them redirects there. Setup keeps the people - // pages below (D7). + // Positions, capabilities and permission sets are listed in Studio's + // "All metadata" area (`/studio/~all/metadata`), where a position or a + // permission set is switched on and off on its own page (a capability + // has no switch), and an old Setup link to them redirects there. Setup + // keeps the people pages below (D7). navigationContributions: [ { // [ADR-0146 D7] The two ASSIGNMENT pages — who holds a position, and diff --git a/ui/apps/console/src/components/StudioRoute.test.tsx b/ui/apps/console/src/components/StudioRoute.test.tsx index dd5cfbcf304..e4aeba4ea5a 100644 --- a/ui/apps/console/src/components/StudioRoute.test.tsx +++ b/ui/apps/console/src/components/StudioRoute.test.tsx @@ -152,7 +152,7 @@ const pathname = () => screen.getByTestId('pathname').textContent; * answers. That the packs answer in the caller's language is the sibling * file's pin. */ -const NOTICE = 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.'; +const NOTICE = 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Set Assignment.'; const WAY_HOME = 'Back to home'; /** diff --git a/ui/apps/console/src/components/StudioRoute.tsx b/ui/apps/console/src/components/StudioRoute.tsx index 0074bfe6795..ea6d7583099 100644 --- a/ui/apps/console/src/components/StudioRoute.tsx +++ b/ui/apps/console/src/components/StudioRoute.tsx @@ -104,7 +104,7 @@ function StudioAccessRequired({ home }: { home: string }) {
{t('console.studio.accessRequired', { - defaultValue: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.', + defaultValue: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Set Assignment.', })}
diff --git a/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts b/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts index ac524e07575..f6cf802c970 100644 --- a/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts +++ b/ui/packages/i18n/src/__tests__/studioAccessGrantor-27521.test.ts @@ -4,8 +4,9 @@ * * A member who opened a Studio URL read "You need Studio access to open * Studio." and nothing about where that access comes from. The `en` and `zh` - * sentences now name an administrator and Setup's Permission Sets; the other - * packs keep their sentence until a translator adds the clause. + * sentences now name an administrator and the Setup page where a set is + * assigned: Permission Set Assignment, since Setup's Permission Sets catalog + * moved into Studio (objectstack#28578). */ import { describe, it, expect } from 'vitest'; import { builtInLocales } from '../locales'; @@ -14,14 +15,14 @@ const sentence = (lang: 'en' | 'zh') => (builtInLocales[lang] as unknown as { console: { studio: { accessRequired: string } } }).console.studio.accessRequired; describe('the Studio access refusal names who grants it (objectstack#27521)', () => { - it('en: an administrator, in Setup → Permission Sets', () => { + it('en: an administrator, in Setup → Permission Set Assignment', () => { expect(sentence('en')).toMatch(/^You need Studio access to open Studio\./u); expect(sentence('en')).toMatch(/administrator/u); - expect(sentence('en')).toMatch(/Permission Sets/u); + expect(sentence('en')).toMatch(/Setup → Permission Set Assignment/u); }); - it('zh: 管理员,在「设置 → 权限集」', () => { + it('zh: 管理员,在「设置 → 权限集分配」', () => { expect(sentence('zh')).toMatch(/管理员/u); - expect(sentence('zh')).toMatch(/权限集/u); + expect(sentence('zh')).toMatch(/设置 → 权限集分配/u); }); }); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index f316981667b..1fa9705c183 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1992,7 +1992,7 @@ const ar = { console: { studio: { title: "Studio", - accessRequired: "تحتاج إلى صلاحية الوصول إلى Studio لفتح Studio. يمكن للمسؤول منحها بمجموعة أذونات في الإعداد → مجموعات الأذونات.", + accessRequired: "تحتاج إلى صلاحية الوصول إلى Studio لفتح Studio. يمكن للمسؤول منحها بمجموعة أذونات في الإعداد → إسناد مجموعات الأذونات.", }, saveAdvisoryTitle: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", saveAdvisoryTitle_zero: "تم الحفظ — أنتج فحص التأليف {{count}} ملاحظة إرشادية", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index cac8ac88fcf..cefe6d4df66 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1785,7 +1785,7 @@ const de = { console: { studio: { title: "Studio", - accessRequired: "Sie benötigen Studio-Zugriff, um Studio zu öffnen. Ein Administrator kann ihn unter Einrichtung → Berechtigungssätze mit einem Berechtigungssatz erteilen.", + accessRequired: "Sie benötigen Studio-Zugriff, um Studio zu öffnen. Ein Administrator kann ihn unter Einrichtung → Berechtigungssatz-Zuweisung mit einem Berechtigungssatz erteilen.", }, saveAdvisoryTitle: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweis(e)", saveAdvisoryTitle_one: "Gespeichert — die Autorenprüfung ergab {{count}} Hinweis", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 12d8440bee8..551afff5b49 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2179,7 +2179,7 @@ const en = { title: 'Studio', // What a principal without `studio.access` reads at the Studio URL they // opened (objectui#12035), in place of the silent redirect home. - accessRequired: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.', + accessRequired: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Set Assignment.', }, saveAdvisoryTitle: 'Saved — the authoring check raised {{count}} advisory finding(s)', saveAdvisoryTitle_one: 'Saved — the authoring check raised {{count}} advisory finding', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index d9722ae222e..a637f891461 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1839,7 +1839,7 @@ const es = { console: { studio: { title: "Studio", - accessRequired: "Necesita acceso a Studio para abrir Studio. Un administrador puede concedérselo con un conjunto de permisos en Configuración → Conjuntos de permisos.", + accessRequired: "Necesita acceso a Studio para abrir Studio. Un administrador puede concedérselo con un conjunto de permisos en Configuración → Asignación de conjuntos de permisos.", }, saveAdvisoryTitle: "Guardado: la comprobación de creación generó {{count}} recomendación(es)", saveAdvisoryTitle_one: "Guardado: la comprobación de creación generó {{count}} recomendación", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 51fb0927382..382d188932d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1835,7 +1835,7 @@ const fr = { console: { studio: { title: "Studio", - accessRequired: "Vous avez besoin de l'accès à Studio pour ouvrir Studio. Un administrateur peut vous l'accorder avec un ensemble d'autorisations dans Configuration → Ensembles d'autorisations.", + accessRequired: "Vous avez besoin de l'accès à Studio pour ouvrir Studio. Un administrateur peut vous l'accorder avec un ensemble d'autorisations dans Configuration → Attribution des ensembles d’autorisations.", }, saveAdvisoryTitle: "Enregistré — le contrôle de création a signalé {{count}} recommandation(s)", saveAdvisoryTitle_one: "Enregistré — le contrôle de création a signalé {{count}} recommandation", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 316e967ca35..f3fd3683466 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1785,7 +1785,7 @@ const ja = { console: { studio: { title: "Studio", - accessRequired: "Studio を開くには Studio へのアクセス権限が必要です。管理者が「設定 → 権限セット」で権限セットを使って付与できます。", + accessRequired: "Studio を開くには Studio へのアクセス権限が必要です。管理者が「設定 → 権限セットの割り当て」で権限セットを使って付与できます。", }, saveAdvisoryTitle: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", saveAdvisoryTitle_one: "保存しました — 編集チェックで {{count}} 件の推奨事項が見つかりました", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 939dfcd101d..4db171bd637 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1785,7 +1785,7 @@ const ko = { console: { studio: { title: "Studio", - accessRequired: "Studio를 열려면 Studio 접근 권한이 필요합니다. 관리자가 '설정 → 권한 세트'에서 권한 세트로 부여할 수 있습니다.", + accessRequired: "Studio를 열려면 Studio 접근 권한이 필요합니다. 관리자가 '설정 → 권한 집합 할당'에서 권한 세트로 부여할 수 있습니다.", }, saveAdvisoryTitle: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", saveAdvisoryTitle_one: "저장되었습니다 — 작성 검사에서 {{count}}건의 권장 사항이 발견되었습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index ab413756959..cd3d0fa2d5d 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1834,7 +1834,7 @@ const pt = { console: { studio: { title: "Studio", - accessRequired: "Você precisa de acesso ao Studio para abrir o Studio. Um administrador pode concedê-lo com um conjunto de permissões em Configuração → Conjuntos de permissões.", + accessRequired: "Você precisa de acesso ao Studio para abrir o Studio. Um administrador pode concedê-lo com um conjunto de permissões em Configuração → Atribuição de conjuntos de permissões.", }, saveAdvisoryTitle: "Salvo — a verificação de criação gerou {{count}} recomendação(ões)", saveAdvisoryTitle_one: "Salvo — a verificação de criação gerou {{count}} recomendação", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index a16138c6d8e..5db89b49968 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1893,7 +1893,7 @@ const ru = { console: { studio: { title: "Studio", - accessRequired: "Чтобы открыть Studio, нужен доступ к Studio. Администратор может выдать его набором разрешений в разделе «Настройка → Наборы разрешений».", + accessRequired: "Чтобы открыть Studio, нужен доступ к Studio. Администратор может выдать его набором разрешений в разделе «Настройка → Назначение наборов разрешений».", }, saveAdvisoryTitle: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", saveAdvisoryTitle_one: "Сохранено — проверка авторинга выдала рекомендаций: {{count}}", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index ea656bd95f4..c49f9a6933c 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1848,7 +1848,7 @@ const zh = { console: { studio: { title: 'Studio', - accessRequired: '你需要 Studio 访问权限才能打开 Studio。管理员可以在“设置 → 权限集”中通过权限集授予。', + accessRequired: '你需要 Studio 访问权限才能打开 Studio。管理员可以在“设置 → 权限集分配”中通过权限集授予。', }, saveAdvisoryTitle: '已保存 — 编辑检查提出了 {{count}} 条建议', saveAdvisoryTitle_one: '已保存 — 编辑检查提出了 {{count}} 条建议',