Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 31 additions & 26 deletions portals/api-control-plane/src/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly ApiControlPlaneExtension[]>(
[]
);

export function ExtensionsProvider({
extensions,
children,
Expand All @@ -54,31 +61,29 @@ export function ExtensionsProvider({
children: ReactNode;
}) {
return (
<ExtensionsContext.Provider value={extensions}>
{children}
</ExtensionsContext.Provider>
<SlotEntriesProvider entries={extensions}>{children}</SlotEntriesProvider>
);
}

export function useExtensions(): readonly ApiControlPlaneExtension[] {
return useContext(ExtensionsContext);
return useSlotEntries<ApiControlPlaneExtension>();
}

/**
* 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}`;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Card variant="outlined">
<CardContent>
<Typography>
Minimal settings overview for {scopeName}. Advanced organization
admin settings, governance, marketplace, and developer portal
configuration are intentionally excluded from the MVP replacement
app.
</Typography>
</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -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 <Outlet />.

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<NavigationLevel, 'organization' | 'project'>;
};

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 (
<Box sx={{ display: 'flex', alignItems: 'stretch', minHeight: '100%', width: '100%' }}>
<Box sx={{ width: { xs: 200, md: 280 }, flexShrink: 0, p: 3 }}>
<Stack spacing={2}>
<PageTitle>
<PageTitle.Header>Settings</PageTitle.Header>
</PageTitle>
<List dense disablePadding>
{tabs.map((tab) => (
<ListItemButton
key={tab.id}
selected={tab.id === selectedId}
onClick={() => goToTab(tab.path)}
sx={{ borderRadius: 1, mb: 0.5, border: 1, borderColor: 'divider' }}
>
<ListItemIcon sx={{ minWidth: 32 }}>{tab.icon}</ListItemIcon>
<ListItemText primary={tab.label} slotProps={{ primary: { noWrap: true } }} />
</ListItemButton>
))}
</List>
</Stack>
</Box>

<Divider orientation="vertical" flexItem />

<Box sx={{ flex: 1, minWidth: 0, p: 3 }}>
<Outlet />
</Box>
</Box>
);
}

This file was deleted.

62 changes: 62 additions & 0 deletions portals/api-control-plane/src/hostPort.tsx
Original file line number Diff line number Diff line change
@@ -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<CloudHostPort | null>(null);

export function PortProvider({
value,
children,
}: {
value: CloudHostPort;
children: ReactNode;
}) {
return <PortContext.Provider value={value}>{children}</PortContext.Provider>;
}

export function usePort(): CloudHostPort {
const port = useContext(PortContext);
if (!port) {
throw new Error('usePort must be used within a PortProvider');
}
return port;
}
Loading
Loading