diff --git a/portals/api-control-plane/src/extensions.tsx b/portals/api-control-plane/src/extensions.tsx index 0ed969124b..f5b6e12a77 100644 --- a/portals/api-control-plane/src/extensions.tsx +++ b/portals/api-control-plane/src/extensions.tsx @@ -16,36 +16,43 @@ * under the License. */ -import { createContext, useContext, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; import type { ConsoleScope } from './scope/ConsoleScopeProvider'; import type { NavigationLevel } from './navigation/navigationTypes'; +import type { CloudHostPort } from './hostPort'; +import { + SlotEntriesProvider, + useSlotEntries, + type SlotEntry, +} from './slots'; /** - * A host-injected feature: a route plus its sidebar entry. `routePath` is - * relative to the same route group the built-in nav items live in (e.g. - * `"billing"`, not `"/organizations/:orgHandle/billing"`), and `level` - * decides which sidebar section it's grouped under — mirrors - * `NavigationDefinition` so it can be merged straight into the existing - * nav pipeline in `navigation/useNavigationItems.ts`. + * A host-injected feature. `routePath` is relative to the same route group + * the built-in pages live in (e.g. `"billing"` or `"settings/environments"`, + * never an absolute `/organizations/...` path), and `scope` decides the URL + * shape (organization/project/api) the same way the built-in pages' own + * `level` does. + * + * `slot` is the named extension point this entry attaches to (see + * `slots/index.tsx`) — e.g. `"sidebar.project"` for a top-level project nav + * item, or `"settings.project.tabs"` to appear as a Settings sub-nav tab. + * New slot names can be introduced by core without changing this type. + * + * `render` receives the small, portable `CloudHostPort` (org/project handle, + * navigate, notify) instead of a pre-built element, so the same feature + * component can be reused by another host app without depending on this + * portal's own hooks — see `hostPort.tsx`. */ -export type ApiControlPlaneExtension = { - id: string; +export type ApiControlPlaneExtension = SlotEntry & { routePath: string; - element: ReactNode; + render: (port: CloudHostPort) => ReactNode; label: string; icon?: ReactNode; - level: NavigationLevel; - /** Sidebar section heading. Defaults to the level's own section (e.g. "Organization"). */ - group?: string; - order: number; + scope: NavigationLevel; isVisible?: (scope: ConsoleScope) => boolean; }; -const ExtensionsContext = createContext( - [] -); - export function ExtensionsProvider({ extensions, children, @@ -54,31 +61,29 @@ export function ExtensionsProvider({ children: ReactNode; }) { return ( - - {children} - + {children} ); } export function useExtensions(): readonly ApiControlPlaneExtension[] { - return useContext(ExtensionsContext); + return useSlotEntries(); } /** - * Prefixes an extension's `routePath` with the URL shape for its `level` + * Prefixes an extension's `routePath` with the URL shape for its `scope` * (organization/project/api), so both `AppRoutes` (route patterns, `orgHandle` * etc. as `:param` placeholders) and the nav pipeline (concrete scope values) * build the same URL shape from one place. */ export function buildScopedExtensionPath( - level: NavigationLevel, + scope: NavigationLevel, routeSuffix: string, params: { orgHandle: string; projectHandler?: string; apiHandler?: string } ): string { - if (level === 'organization') { + if (scope === 'organization') { return `/organizations/${params.orgHandle}/${routeSuffix}`; } - if (level === 'project') { + if (scope === 'project') { return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/${routeSuffix}`; } return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/apis/${params.apiHandler}/${routeSuffix}`; diff --git a/portals/api-control-plane/src/features/settings/GeneralSettingsPage.tsx b/portals/api-control-plane/src/features/settings/GeneralSettingsPage.tsx new file mode 100644 index 0000000000..6e122d1c56 --- /dev/null +++ b/portals/api-control-plane/src/features/settings/GeneralSettingsPage.tsx @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Card, CardContent, Typography } from '@wso2/oxygen-ui'; + +import { useConsoleScope } from '../../scope/ConsoleScopeProvider'; + +/** + * Shared "General" tab content for both the organization- and project-level + * Settings pages — the scope name in the copy is the only thing that + * differs, so one component covers both rather than two near-duplicates. + */ +export function GeneralSettingsPage() { + const { organization, project, params } = useConsoleScope(); + const scopeName = params.projectHandler + ? project?.name || params.projectHandler + : organization?.name || params.orgHandle; + + return ( + + + + Minimal settings overview for {scopeName}. Advanced organization + admin settings, governance, marketplace, and developer portal + configuration are intentionally excluded from the MVP replacement + app. + + + + ); +} diff --git a/portals/api-control-plane/src/features/settings/SettingsLayout.tsx b/portals/api-control-plane/src/features/settings/SettingsLayout.tsx new file mode 100644 index 0000000000..62a20389d3 --- /dev/null +++ b/portals/api-control-plane/src/features/settings/SettingsLayout.tsx @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Settings layout. A persistent left sub-nav (built-in tabs plus any +// cloud-injected `settingsTab` extension, see `useSettingsTabs`) + a vertical +// divider; the active tab renders in the right pane via . + +import { + Box, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + PageTitle, + Stack, +} from '@wso2/oxygen-ui'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; + +import { useConsoleScope } from '../../scope/ConsoleScopeProvider'; +import { routes } from '../../routes/paths'; +import { useSettingsTabs } from '../../navigation/useSettingsTabs'; +import type { NavigationLevel } from '../../navigation/navigationTypes'; + +export type SettingsLayoutProps = { + /** Which Settings page this is — organization- or project-scoped. */ + scope: Extract; +}; + +export function SettingsLayout({ scope }: SettingsLayoutProps) { + const navigate = useNavigate(); + const location = useLocation(); + const { params } = useConsoleScope(); + const tabs = useSettingsTabs(scope); + + const selectedId = tabs.find((tab) => + location.pathname.endsWith(`/settings/${tab.path}`) + )?.id; + + const goToTab = (path: string) => { + if (!params.orgHandle) return; + if (scope === 'project') { + if (!params.projectHandler) return; + navigate(routes.settingsTab(path, params.orgHandle, params.projectHandler)); + } else { + navigate(routes.orgSettingsTab(path, params.orgHandle)); + } + }; + + return ( + + + + + Settings + + + {tabs.map((tab) => ( + goToTab(tab.path)} + sx={{ borderRadius: 1, mb: 0.5, border: 1, borderColor: 'divider' }} + > + {tab.icon} + + + ))} + + + + + + + + + + + ); +} diff --git a/portals/api-control-plane/src/features/settings/SettingsPage.tsx b/portals/api-control-plane/src/features/settings/SettingsPage.tsx deleted file mode 100644 index 7bf121d030..0000000000 --- a/portals/api-control-plane/src/features/settings/SettingsPage.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { Card, CardContent, PageContent, PageTitle, Typography } from '@wso2/oxygen-ui'; -import { useParams } from 'react-router-dom'; - -export function SettingsPage() { - const { projectHandler } = useParams(); - - return ( - - - Settings - Minimal settings overview for {projectHandler}. - - - - - Advanced organization admin settings, governance, marketplace, and - developer portal configuration are intentionally excluded from the - MVP replacement app. - - - - - ); -} diff --git a/portals/api-control-plane/src/hostPort.tsx b/portals/api-control-plane/src/hostPort.tsx new file mode 100644 index 0000000000..297aac0b74 --- /dev/null +++ b/portals/api-control-plane/src/hostPort.tsx @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * The "Port": the small set of host capabilities an extension's `render` + * function receives, so a feature component never imports this portal's + * own hooks (`useConsoleScope`, `useNotifications`) directly — that's what + * would make it impossible to reuse the same component in another host. + * + * This is a real React context, but — unlike `slots/index.tsx` — it is NOT + * meant to be imported across the api-platform/apim-saas repo boundary. + * apim-saas's host file (e.g. `hosts/api-control-plane.tsx`) hand-mirrors + * this exact `CloudHostPort` type (same convention as `ApiControlPlaneExtension`) + * and receives a value of that shape as a plain function argument via + * `extension.render(port)` — never by importing `PortContext`/`usePort` + * itself. `PortProvider` is built and mounted once, here in core, from real + * hooks; only the small type crosses the boundary, never the context object. + */ + +import { createContext, useContext, type ReactNode } from 'react'; + +export type NotifySeverity = 'success' | 'info' | 'warning' | 'error'; + +export type CloudHostPort = { + orgHandle: string; + projectHandle?: string; + navigate: (path: string) => void; + notify: (message: string, severity?: NotifySeverity) => void; +}; + +const PortContext = createContext(null); + +export function PortProvider({ + value, + children, +}: { + value: CloudHostPort; + children: ReactNode; +}) { + return {children}; +} + +export function usePort(): CloudHostPort { + const port = useContext(PortContext); + if (!port) { + throw new Error('usePort must be used within a PortProvider'); + } + return port; +} diff --git a/portals/api-control-plane/src/layouts/AppLayout.tsx b/portals/api-control-plane/src/layouts/AppLayout.tsx index 9ae307a555..e390a560af 100644 --- a/portals/api-control-plane/src/layouts/AppLayout.tsx +++ b/portals/api-control-plane/src/layouts/AppLayout.tsx @@ -29,9 +29,11 @@ import { Suspense } from 'react'; import { Outlet, useNavigate } from 'react-router-dom'; import { LoadingState } from '../components/StateViews'; +import { useNotifications } from '../components/Notifications'; import { runtimeConfig } from '../config/runtime'; import { routes } from '../routes/paths'; import { useConsoleScope } from '../scope/ConsoleScopeProvider'; +import { PortProvider, type CloudHostPort } from '../hostPort'; import { AppHeader } from './AppHeader'; import { APP_FOOTER_ID } from './appLayoutConstants'; import { AppSidebar } from './AppSidebar'; @@ -39,6 +41,18 @@ import { AppSidebar } from './AppSidebar'; export default function AppLayout() { const navigate = useNavigate(); const { organization, project, component, params } = useConsoleScope(); + const { notify } = useNotifications(); + + // Built once per render from this portal's own hooks, then handed down as + // a plain value to every extension's `render(port)` — see `hostPort.tsx` + // for why this crosses the api-platform/apim-saas seam as a value, not a + // shared context object. + const port: CloudHostPort = { + orgHandle: params.orgHandle ?? '', + projectHandle: params.projectHandler, + navigate, + notify, + }; const crumbs: BreadcrumbItem[] = []; if (params.orgHandle) { @@ -76,59 +90,61 @@ export default function AppLayout() { ); return ( - - - - + + + + + - - - + + + - - - {breadcrumbItems.length > 1 && ( - - )} - }> - - - - + + + {breadcrumbItems.length > 1 && ( + + )} + }> + + + + - - {/* id is an anchor for measuring the footer height so sticky action - bars (develop tabs' SaveBar) can offset above it — see SaveBar. */} - -
- - © {new Date().getFullYear()} WSO2 LLC. - - {runtimeConfig.environmentName} - - Terms of Use - - - Privacy Policy - -
-
-
+ + {/* id is an anchor for measuring the footer height so sticky action + bars (develop tabs' SaveBar) can offset above it — see SaveBar. */} + +
+ + © {new Date().getFullYear()} WSO2 LLC. + + {runtimeConfig.environmentName} + + Terms of Use + + + Privacy Policy + +
+
+
- - - - - - - - Notifications - - - - - - -
+ + + + + + + + Notifications + + + + + + +
+ ); } diff --git a/portals/api-control-plane/src/layouts/AppSidebar.tsx b/portals/api-control-plane/src/layouts/AppSidebar.tsx index 49837f0263..b66ea1dc72 100644 --- a/portals/api-control-plane/src/layouts/AppSidebar.tsx +++ b/portals/api-control-plane/src/layouts/AppSidebar.tsx @@ -19,12 +19,26 @@ import { Sidebar, useAppShell } from '@wso2/oxygen-ui'; import { Link } from 'react-router-dom'; -import { useNavigationGroups } from '../navigation/useNavigationItems'; +import { + useNavigationGroups, + useSidebarFooterGroups, +} from '../navigation/useNavigationItems'; +import type { NavigationGroup } from '../navigation/navigationTypes'; + +function renderItems(items: NavigationGroup['items']) { + return items.map((item) => ( + }> + {item.icon} + {item.label} + + )); +} export function AppSidebar() { const groups = useNavigationGroups(); + const footerGroups = useSidebarFooterGroups(); const { state } = useAppShell(); - const activeItem = groups + const activeItem = [...groups, ...footerGroups] .flatMap((group) => group.items) .find((item) => item.isActive)?.id; @@ -34,19 +48,17 @@ export function AppSidebar() { {groups.map((group) => ( {group.label} - {group.items.map((item) => ( - } - > - {item.icon} - {item.label} - - ))} + {renderItems(group.items)} ))} + {footerGroups.length > 0 && ( + + + {footerGroups.flatMap((group) => renderItems(group.items))} + + + )} ); } diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.test.ts b/portals/api-control-plane/src/navigation/navigationRegistry.test.ts index b7c4ea8cd8..fffafe9016 100644 --- a/portals/api-control-plane/src/navigation/navigationRegistry.test.ts +++ b/portals/api-control-plane/src/navigation/navigationRegistry.test.ts @@ -44,13 +44,29 @@ describe('navigation match predicates', () => { expect(match('/projects/orders/home')).toBe(false); }); - it('anchors settings to the end of the path', () => { + it('stays active on a settings tab but not two levels deep', () => { const match = matcherFor('settings'); expect(match(`${PROJECT}/settings`)).toBe(true); - expect(match(`${PROJECT}/settings/advanced`)).toBe(false); + expect(match(`${PROJECT}/settings/general`)).toBe(true); + expect(match(`${PROJECT}/settings/general/extra`)).toBe(false); expect(match(`${ORG}/projects/settings/home`)).toBe(false); }); + it('matches org-level settings without colliding with project settings', () => { + const match = matcherFor('org-settings'); + expect(match(`${ORG}/settings`)).toBe(true); + expect(match(`${ORG}/settings/general`)).toBe(true); + // A project's own /settings must not also light up org-settings. + expect(match(`${PROJECT}/settings`)).toBe(false); + }); + + it('hides org-settings once a project is selected', () => { + const item = navigationRegistry.find((entry) => entry.id === 'org-settings'); + if (!item?.isVisible) throw new Error('org-settings has no isVisible predicate'); + expect(item.isVisible({ isProjectScope: false } as never)).toBe(true); + expect(item.isVisible({ isProjectScope: true } as never)).toBe(false); + }); + it('matches runtime logs only at the runtimelogs path', () => { const match = matcherFor('runtime-logs'); expect(match(`${PROJECT}/observe/runtimelogs`)).toBe(true); diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.tsx b/portals/api-control-plane/src/navigation/navigationRegistry.tsx index 7b728cddd6..d108c651da 100644 --- a/portals/api-control-plane/src/navigation/navigationRegistry.tsx +++ b/portals/api-control-plane/src/navigation/navigationRegistry.tsx @@ -60,6 +60,21 @@ export const navigationRegistry: NavigationDefinition[] = [ params.orgHandle ? routes.gateways(params.orgHandle) : undefined, match: (pathname) => /\/organizations\/[^/]+\/gateways(\/[^/]+)?$/.test(pathname), }, + { + id: 'org-settings', + label: 'Settings', + level: 'organization', + order: 40, + icon: , + pinned: true, + // Only while not inside a project — the project-level `settings` entry + // takes over once a project is selected, so there's always exactly one + // "Settings" link pinned to the sidebar bottom, never two at once. + isVisible: (scope) => !scope.isProjectScope, + to: ({ params }) => + params.orgHandle ? routes.orgSettings(params.orgHandle) : undefined, + match: (pathname) => /\/organizations\/[^/]+\/settings(\/[^/]+)?$/.test(pathname), + }, { id: 'project-home', label: 'Project Home', @@ -104,11 +119,15 @@ export const navigationRegistry: NavigationDefinition[] = [ level: 'project', order: 130, icon: , + pinned: true, to: ({ params }) => params.orgHandle && params.projectHandler ? routes.settings(params.orgHandle, params.projectHandler) : undefined, - match: (pathname) => /\/settings$/.test(pathname), + // Also active on a settings tab (e.g. /settings/general), but not deeper. + // The `/projects/[^/]+/` prefix is required so a project literally + // handled "settings" (e.g. `/projects/settings/home`) can't false-match. + match: (pathname) => /\/projects\/[^/]+\/settings(\/[^/]+)?$/.test(pathname), }, { id: 'api-overview', diff --git a/portals/api-control-plane/src/navigation/navigationTypes.ts b/portals/api-control-plane/src/navigation/navigationTypes.ts index 9a57b5e293..e0512e1a01 100644 --- a/portals/api-control-plane/src/navigation/navigationTypes.ts +++ b/portals/api-control-plane/src/navigation/navigationTypes.ts @@ -33,6 +33,12 @@ export type NavigationDefinition = { level: NavigationLevel; match?: (pathname: string) => boolean; order: number; + /** + * Pins this item to the sidebar's fixed bottom section (`Sidebar.Footer`) + * instead of the scrolling main nav list. Used for Settings, which should + * stay visible at the bottom regardless of how long the nav list gets. + */ + pinned?: boolean; to: (scope: ConsoleScope) => string | undefined; }; @@ -42,6 +48,7 @@ export type NavigationItem = { id: string; isActive: boolean; label: string; + pinned?: boolean; to: string; }; diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx b/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx new file mode 100644 index 0000000000..be4be6449e --- /dev/null +++ b/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, it } from 'vitest'; + +import { ExtensionsProvider, type ApiControlPlaneExtension } from '../extensions'; +import { makeConsoleScope } from '../test/mockScope'; +import { renderWithProviders, screen } from '../test/utils'; +import { useNavigationItems } from './useNavigationItems'; + +// A sidebar extension whose routePath ("environments") is also the tail +// segment of an unrelated settings-tab route — the exact collision +// CodeRabbit flagged: the old `pathname.indexOf(routeSegment)` matcher found +// "/environments" as a substring of ".../settings/environments" too, even +// though that route belongs to a completely different extension/feature. +const sidebarExtension: ApiControlPlaneExtension = { + id: 'environments-sidebar', + routePath: 'environments', + render: () =>
Sidebar Environments
, + label: 'Environments', + scope: 'project', + slot: 'sidebar.project', + order: 50, +}; + +function Probe() { + const items = useNavigationItems(); + const item = items.find((entry) => entry.id === 'environments-sidebar'); + return ( +
+ {item ? (item.isActive ? 'active' : 'inactive') : 'missing'} +
+ ); +} + +describe('useNavigationItems sidebar extension matching', () => { + const scope = makeConsoleScope(); + + it('is not active on an unrelated route that merely ends with the same segment name', () => { + renderWithProviders( + + + , + { + scope, + route: + '/organizations/api-platform-demo/projects/retail-apis/settings/environments', + } + ); + + expect(screen.getByTestId('result')).toHaveTextContent('inactive'); + }); + + it('is active at its own real destination', () => { + renderWithProviders( + + + , + { + scope, + route: '/organizations/api-platform-demo/projects/retail-apis/environments', + } + ); + + expect(screen.getByTestId('result')).toHaveTextContent('active'); + }); +}); diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.ts b/portals/api-control-plane/src/navigation/useNavigationItems.ts index 31bf338ba0..d047bb2403 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.ts +++ b/portals/api-control-plane/src/navigation/useNavigationItems.ts @@ -51,47 +51,45 @@ export const useNavigationItems = (): NavigationItem[] => { return useMemo(() => { // Host-injected extensions are converted to the same NavigationDefinition // shape the built-in registry uses, so they run through one filter/sort - // pipeline instead of a parallel "Cloud category" implementation. - const extensionDefinitions: NavigationDefinition[] = extensions.map( - (extension) => { + // pipeline instead of a parallel "Cloud category" implementation. Only + // extensions registered against a `sidebar.*` slot get a top-level + // sidebar entry here — a `settings.*.tabs` extension instead renders + // inside the Settings page's own sub-nav (see `useSettingsTabs`). + const extensionDefinitions: NavigationDefinition[] = extensions + .filter((extension) => extension.slot.startsWith('sidebar.')) + .map((extension) => { const isDescendantRoute = extension.routePath.endsWith('/*'); const routeSuffix = extension.routePath.replace(/\/\*$/, ''); - const routeSegment = `/${routeSuffix}`; + // Computed once per render from the current scope (not a raw + // substring search) so `match` can't be fooled by an unrelated route + // that merely happens to contain this segment name elsewhere — e.g. + // a `settings/` tab route shouldn't activate a sidebar + // extension whose own destination is `/` at a different depth. + const { orgHandle, projectHandler, apiHandler } = scope.params; + const destination = + orgHandle && + !(extension.scope === 'project' && !projectHandler) && + !(extension.scope === 'api' && (!projectHandler || !apiHandler)) + ? buildScopedExtensionPath(extension.scope, routeSuffix, { + apiHandler, + orgHandle, + projectHandler, + }) + : undefined; return { - group: extension.group, icon: extension.icon, id: extension.id, isVisible: extension.isVisible, label: extension.label, - level: extension.level, - match: (pathname) => { - const index = pathname.indexOf(routeSegment); - if (index === -1) return false; - const charAfter = pathname[index + routeSegment.length]; - // Match only a complete path segment: nothing after it, or (for - // a `/*` route) a further `/` continuing into a descendant path. - return charAfter === undefined || (isDescendantRoute && charAfter === '/'); - }, + level: extension.scope, + match: (pathname) => + destination !== undefined && + (pathname === destination || + (isDescendantRoute && pathname.startsWith(`${destination}/`))), order: extension.order, - to: (navScope) => { - const { orgHandle, projectHandler, apiHandler } = navScope.params; - if (!orgHandle) return undefined; - if (extension.level === 'project' && !projectHandler) return undefined; - if ( - extension.level === 'api' && - (!projectHandler || !apiHandler) - ) { - return undefined; - } - return buildScopedExtensionPath(extension.level, routeSuffix, { - apiHandler, - orgHandle, - projectHandler, - }); - }, + to: () => destination, }; - } - ); + }); const combinedRegistry = [...navigationRegistry, ...extensionDefinitions]; return combinedRegistry @@ -109,6 +107,7 @@ export const useNavigationItems = (): NavigationItem[] => { ? definition.match(location.pathname) : location.pathname === to, label: definition.label, + pinned: definition.pinned, to, }; }) @@ -123,28 +122,40 @@ export const useNavigationItems = (): NavigationItem[] => { }, [location.pathname, scope, extensions]); }; +const groupByLabel = (items: NavigationItem[]): NavigationGroup[] => { + const groups: NavigationGroup[] = []; + const byLabel = new Map(); + + for (const item of items) { + let group = byLabel.get(item.group); + if (!group) { + group = { label: item.group, items: [] }; + byLabel.set(item.group, group); + groups.push(group); + } + group.items.push(item); + } + + return groups; +}; + /** * Same items as `useNavigationItems`, bucketed into ordered sidebar sections by * their `group`. Group order follows first appearance in the (order-sorted) - * item list, so Organization → Project → Api falls out naturally. + * item list, so Organization → Project → Api falls out naturally. Excludes + * `pinned` items — those render separately, see `useSidebarFooterGroups`. */ export const useNavigationGroups = (): NavigationGroup[] => { const items = useNavigationItems(); + return useMemo(() => groupByLabel(items.filter((item) => !item.pinned)), [items]); +}; - return useMemo(() => { - const groups: NavigationGroup[] = []; - const byLabel = new Map(); - - for (const item of items) { - let group = byLabel.get(item.group); - if (!group) { - group = { label: item.group, items: [] }; - byLabel.set(item.group, group); - groups.push(group); - } - group.items.push(item); - } - - return groups; - }, [items]); +/** + * `pinned` items (e.g. Settings), grouped the same way as + * `useNavigationGroups` but meant for a sidebar's fixed bottom section + * (`Sidebar.Footer`) rather than the scrolling main nav. + */ +export const useSidebarFooterGroups = (): NavigationGroup[] => { + const items = useNavigationItems(); + return useMemo(() => groupByLabel(items.filter((item) => item.pinned)), [items]); }; diff --git a/portals/api-control-plane/src/navigation/useSettingsTabs.tsx b/portals/api-control-plane/src/navigation/useSettingsTabs.tsx new file mode 100644 index 0000000000..f8072fafd4 --- /dev/null +++ b/portals/api-control-plane/src/navigation/useSettingsTabs.tsx @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ReactNode } from 'react'; +import { Settings as SettingsIcon } from '@wso2/oxygen-ui-icons-react'; + +import { useConsoleScope } from '../scope/ConsoleScopeProvider'; +import { useExtensions } from '../extensions'; +import { useIsHidden } from '../slots'; +import type { NavigationLevel } from './navigationTypes'; + +export type SettingsTab = { + id: string; + label: string; + icon: ReactNode; + /** Path segment relative to `/settings/`, e.g. `"general"`. */ + path: string; + order: number; +}; + +const BUILT_IN_GENERAL_TAB: SettingsTab = { + id: 'general', + label: 'General', + icon: , + path: 'general', + order: 0, +}; + +/** + * The sub-nav tabs rendered inside the Settings page for a given scope: the + * built-in "General" tab (unless suppressed via `Hideable`) plus any + * cloud-injected extension registered against the matching + * `settings..tabs` slot, sorted by order. Mirrors + * `useNavigationItems`'s registry-plus-extensions merge, one level deeper + * (inside Settings rather than the main sidebar). + */ +export const useSettingsTabs = (scope: NavigationLevel): SettingsTab[] => { + const consoleScope = useConsoleScope(); + const extensions = useExtensions(); + const generalTabHidden = useIsHidden(`settings.${scope}.tabs.general`); + + const extensionTabs: SettingsTab[] = extensions + // Require `scope` to agree with the slot name too — a type-valid but + // inconsistent descriptor (e.g. `slot: 'settings.organization.tabs'` + // with `scope: 'project'`) must not render here with the wrong scope's + // Port (see `AppRoutes.tsx`'s equivalent guard for its route). + .filter( + (extension) => + extension.slot === `settings.${scope}.tabs` && extension.scope === scope + ) + .filter((extension) => extension.isVisible?.(consoleScope) ?? true) + .map((extension) => ({ + id: extension.id, + label: extension.label, + icon: extension.icon ?? , + path: extension.routePath.replace(/^settings\//, ''), + order: extension.order, + })); + + const builtInTabs = generalTabHidden ? [] : [BUILT_IN_GENERAL_TAB]; + + return [...builtInTabs, ...extensionTabs].sort( + (left, right) => left.order - right.order + ); +}; diff --git a/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx b/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx new file mode 100644 index 0000000000..ba9752d870 --- /dev/null +++ b/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AppRoutes } from './AppRoutes'; +import { authStatePresets } from '../test/mockAuthState'; +import { renderWithProviders, screen } from '../test/utils'; + +// Covers the org-level Settings page (mirrors ai-workspace, which mounts the +// same Settings feature at both org and project scope) and the sidebar's +// pinned-to-bottom Settings link, which must show exactly one link at a +// time — organization-level while browsing the org, project-level once a +// project is selected — never both at once. +describe('Org-level Settings', () => { + beforeEach(() => vi.stubEnv('VITE_USE_MOCK_API', 'true')); + afterEach(() => vi.unstubAllEnvs()); + + it('redirects /settings to /settings/general at the org level', async () => { + renderWithProviders(, { + route: '/organizations/api-platform-demo/settings', + authState: authStatePresets.authenticated(), + }); + + expect( + await screen.findByText(/Minimal settings overview for API Platform Demo/) + ).toBeInTheDocument(); + }); + + it('shows one org-scoped pinned Settings link while browsing the org', async () => { + renderWithProviders(, { + route: '/organizations/api-platform-demo/home', + authState: authStatePresets.authenticated(), + }); + + const settingsLinks = await screen.findAllByRole('link', { name: /settings/i }); + expect(settingsLinks).toHaveLength(1); + expect(settingsLinks[0]).toHaveAttribute( + 'href', + '/organizations/api-platform-demo/settings' + ); + }); + + it('shows one project-scoped pinned Settings link inside a project, not both', async () => { + renderWithProviders(, { + route: '/organizations/api-platform-demo/projects/retail-apis/home', + authState: authStatePresets.authenticated(), + }); + + // The org-level Settings link must be hidden here — never both at once. + const settingsLinks = await screen.findAllByRole('link', { name: /settings/i }); + expect(settingsLinks).toHaveLength(1); + expect(settingsLinks[0]).toHaveAttribute( + 'href', + '/organizations/api-platform-demo/projects/retail-apis/settings' + ); + }); +}); diff --git a/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx b/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx new file mode 100644 index 0000000000..3c93d3c3c9 --- /dev/null +++ b/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ExtensionsProvider, type ApiControlPlaneExtension } from '../extensions'; +import { AppRoutes } from './AppRoutes'; +import { authStatePresets } from '../test/mockAuthState'; +import { renderWithProviders, screen } from '../test/utils'; + +// Covers the `settings..tabs` slot: a host-injected extension +// registered against that slot should render inside the Settings page's own +// sub-nav (nested under /settings) rather than as a top-level sidebar/route +// entry. +describe('AppRoutes settingsTab extensions', () => { + beforeEach(() => vi.stubEnv('VITE_USE_MOCK_API', 'true')); + afterEach(() => vi.unstubAllEnvs()); + + const mockExtension: ApiControlPlaneExtension = { + id: 'environments', + routePath: 'settings/environments', + render: () =>
Mock Environments page
, + label: 'Environments', + scope: 'project', + slot: 'settings.project.tabs', + order: 10, + }; + + it('lists the extension as a Settings sub-nav tab and does not add a top-level sidebar entry', async () => { + renderWithProviders( + + + , + { + route: '/organizations/api-platform-demo/projects/retail-apis/settings', + authState: authStatePresets.authenticated(), + } + ); + + // Settings sub-nav shows both the built-in "General" tab and the + // extension-contributed "Environments" tab. + expect(await screen.findByText('General')).toBeInTheDocument(); + expect(await screen.findByText('Environments')).toBeInTheDocument(); + + // It must not also appear as its own top-level sidebar entry (there is + // only ever one "Environments" text node on the page: the settings tab). + expect(screen.getAllByText('Environments')).toHaveLength(1); + }); + + it('renders the extension element when its settings tab route is visited', async () => { + renderWithProviders( + + + , + { + route: '/organizations/api-platform-demo/projects/retail-apis/settings/environments', + authState: authStatePresets.authenticated(), + } + ); + + expect(await screen.findByText('Mock Environments page')).toBeInTheDocument(); + }); + + it('does not register a conflicting descriptor whose slot and scope disagree', async () => { + // Type-valid but internally inconsistent: the slot says "project" while + // `scope` says "organization". Neither `useSettingsTabs` nor + // `settingsTabRoutesFor` may accept this — it must not render in EITHER + // settings page, rather than rendering in the wrong one with a mismatched + // Port (e.g. missing `projectHandle`). + const conflictingExtension: ApiControlPlaneExtension = { + ...mockExtension, + id: 'conflicting', + label: 'Conflicting', + slot: 'settings.project.tabs', + scope: 'organization', + }; + + renderWithProviders( + + + , + { + route: '/organizations/api-platform-demo/projects/retail-apis/settings', + authState: authStatePresets.authenticated(), + } + ); + + expect(await screen.findByText('General')).toBeInTheDocument(); + expect(screen.queryByText('Conflicting')).not.toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index cffdcd5bab..14ac63d83b 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -17,7 +17,7 @@ */ import { lazy } from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Navigate, Route, Routes } from 'react-router-dom'; import { AuthCallbackPage } from '../features/auth/AuthCallbackPage'; import { LoginPage } from '../features/auth/LoginPage'; @@ -34,9 +34,16 @@ import { buildScopedExtensionPath, type ApiControlPlaneExtension, } from '../extensions'; +import { usePort } from '../hostPort'; import { ProtectedRoute } from './ProtectedRoute'; import { routes } from './paths'; +/** Resolves the real `CloudHostPort` and hands it to the extension's `render`. */ +function ExtensionRoute({ extension }: { extension: ApiControlPlaneExtension }) { + const port = usePort(); + return <>{extension.render(port)}; +} + // Code-split the authenticated feature pages so they are not pulled into the // initial (login) bundle. const OrganizationHomePage = lazy(() => @@ -98,9 +105,14 @@ const RuntimeLogsPage = lazy(() => default: m.RuntimeLogsPage, })) ); -const SettingsPage = lazy(() => - import('../features/settings/SettingsPage').then((m) => ({ - default: m.SettingsPage, +const SettingsLayout = lazy(() => + import('../features/settings/SettingsLayout').then((m) => ({ + default: m.SettingsLayout, + })) +); +const GeneralSettingsPage = lazy(() => + import('../features/settings/GeneralSettingsPage').then((m) => ({ + default: m.GeneralSettingsPage, })) ); @@ -109,18 +121,40 @@ export type AppRoutesProps = { }; export function AppRoutes({ extensions = [] }: AppRoutesProps) { - const extensionRoutes = extensions.map((extension) => ( + const topLevelExtensions = extensions.filter((ext) => + ext.slot.startsWith('sidebar.') + ); + + const extensionRoutes = topLevelExtensions.map((extension) => ( } /> )); + // Extensions registered against a `settings..tabs` slot render + // nested under the matching (org- or project-level) Settings layout + // instead of as a sibling top-level route — the path is relative to + // `/settings/`. Also requires `ext.scope === scope`: a type-valid but + // inconsistent descriptor (slot says one scope, `scope` field says + // another) must not register a route here with the wrong scope's Port — + // see `useSettingsTabs`'s matching guard for the sub-nav tab list itself. + const settingsTabRoutesFor = (scope: 'organization' | 'project') => + extensions + .filter((ext) => ext.slot === `settings.${scope}.tabs` && ext.scope === scope) + .map((extension) => ( + } + /> + )); + return ( } /> @@ -140,6 +174,11 @@ export function AppRoutes({ extensions = [] }: AppRoutesProps) { } /> } /> } /> + }> + } /> + } /> + {settingsTabRoutesFor('organization')} + } /> } /> } /> @@ -152,7 +191,11 @@ export function AppRoutes({ extensions = [] }: AppRoutesProps) { } /> } /> } /> - } /> + }> + } /> + } /> + {settingsTabRoutesFor('project')} + {extensionRoutes} } /> diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index 313aad8df6..a2ab9ce208 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -70,4 +70,12 @@ export const routes = { `/organizations/${orgHandle}/projects/${projectHandler}/observe/runtimelogs`, settings: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => `/organizations/${orgHandle}/projects/${projectHandler}/settings`, + settingsTab: ( + tab: string, + orgHandle = ':orgHandle', + projectHandler = ':projectHandler' + ) => `/organizations/${orgHandle}/projects/${projectHandler}/settings/${tab}`, + orgSettings: (orgHandle = ':orgHandle') => `/organizations/${orgHandle}/settings`, + orgSettingsTab: (tab: string, orgHandle = ':orgHandle') => + `/organizations/${orgHandle}/settings/${tab}`, }; diff --git a/portals/api-control-plane/src/slots/index.test.tsx b/portals/api-control-plane/src/slots/index.test.tsx new file mode 100644 index 0000000000..30347dfa19 --- /dev/null +++ b/portals/api-control-plane/src/slots/index.test.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +import { + Hideable, + HiddenRegionsProvider, + SlotEntriesProvider, + useSlot, + type SlotEntry, +} from './index'; + +type TestEntry = SlotEntry & { label: string }; + +function SlotList({ name }: { name: string }) { + const entries = useSlot(name); + return ( +
    + {entries.map((entry) => ( +
  • {entry.label}
  • + ))} +
+ ); +} + +describe('useSlot', () => { + const entries: TestEntry[] = [ + { id: 'b', slot: 'sidebar.project', order: 2, label: 'Second' }, + { id: 'a', slot: 'sidebar.project', order: 1, label: 'First' }, + { id: 'c', slot: 'settings.project.tabs', order: 0, label: 'Other slot' }, + ]; + + it('returns only entries for the exact slot name, sorted by order', () => { + render( + + + + ); + + const items = screen.getAllByRole('listitem').map((li) => li.textContent); + expect(items).toEqual(['First', 'Second']); + expect(screen.queryByText('Other slot')).not.toBeInTheDocument(); + }); + + it('returns nothing when no provider is mounted', () => { + render(); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + }); +}); + +describe('Hideable', () => { + it('renders children when the region is not hidden', () => { + render( + + ); + expect(screen.getByText('General tab')).toBeInTheDocument(); + }); + + it('suppresses children when the region is named in the hidden set', () => { + render( + + ); + expect(screen.queryByText('General tab')).not.toBeInTheDocument(); + }); + + it('renders children when no HiddenRegionsProvider is mounted at all', () => { + render( + + General tab + + ); + expect(screen.getByText('General tab')).toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/slots/index.tsx b/portals/api-control-plane/src/slots/index.tsx new file mode 100644 index 0000000000..33da3d02d2 --- /dev/null +++ b/portals/api-control-plane/src/slots/index.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * Slot & Hideable — the two primitives every host-injected extension point + * in this app is built from. Deliberately generic: no dependency on this + * portal's own scope/auth/routing types, so this file is meant to be copied + * verbatim into any other host app that wants the same seam (see the + * `apip-cloud-extensions` skill). + * + * - Slot: a named, additive extension point. A host exposes a slot name + * (e.g. "sidebar.project", "settings.organization.tabs"); anything can + * register an entry against that name without the host knowing what it + * is. New slot names cost nothing to introduce later — no enum to extend. + * - Hideable: a named, suppressible region wrapping *built-in* UI, so an + * extension can eventually replace (not just add to) something the host + * ships by default. Orthogonal to Slot: Slot adds, Hideable suppresses; + * using both at the same conceptual position is how you override. + */ + +import { createContext, useContext, useMemo, type ReactNode } from 'react'; + +/** The minimum shape any slot-registered entry must have. */ +export type SlotEntry = { + id: string; + /** Which named extension point this entry attaches to. */ + slot: string; + /** Merge-sort key among other entries in the same slot. */ + order: number; +}; + +const SlotEntriesContext = createContext([]); + +export function SlotEntriesProvider({ + entries, + children, +}: { + entries: readonly SlotEntry[]; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** Every registered entry, unfiltered — for consumers that group by more than one slot name (e.g. a prefix match). */ +export function useSlotEntries(): readonly T[] { + return useContext(SlotEntriesContext) as readonly T[]; +} + +/** Entries registered for exactly `slotName`, sorted by `order`. */ +export function useSlot(slotName: string): T[] { + const entries = useSlotEntries(); + return useMemo( + () => + entries + .filter((entry) => entry.slot === slotName) + .sort((a, b) => a.order - b.order), + [entries, slotName] + ); +} + +const HiddenRegionsContext = createContext>(new Set()); + +export function HiddenRegionsProvider({ + hidden, + children, +}: { + hidden: ReadonlySet | readonly string[]; + children: ReactNode; +}) { + const set = useMemo( + () => (hidden instanceof Set ? hidden : new Set(hidden)), + [hidden] + ); + return ( + + {children} + + ); +} + +/** Whether `name` has been suppressed by a `HiddenRegionsProvider` up the tree. */ +export function useIsHidden(name: string): boolean { + return useContext(HiddenRegionsContext).has(name); +} + +/** Renders `children` unless `name` is currently hidden. */ +export function Hideable({ + name, + children, +}: { + name: string; + children: ReactNode; +}) { + return useIsHidden(name) ? null : <>{children}; +}