diff --git a/portals/api-control-plane/.claude/skills/apicp-ui/SKILL.md b/portals/api-control-plane/.claude/skills/apicp-ui/SKILL.md
new file mode 100644
index 0000000000..7080d5e66f
--- /dev/null
+++ b/portals/api-control-plane/.claude/skills/apicp-ui/SKILL.md
@@ -0,0 +1,400 @@
+---
+name: apicp-ui
+description: Build or change UI in portals/api-control-plane — pages, components, forms, dialogs, listings, app shell/navigation, theming, and MUI→Oxygen migration. Covers the Oxygen UI (@wso2/oxygen-ui) component/theming API plus this app's own rules for data access (hooks only), i18n (react-intl), routing/scope gating, and tests. Use for any .tsx/.ts work under portals/api-control-plane/src, and whenever asked how a page, listing, form, sidebar item, or theme override should be built here.
+---
+
+# UI development — api-control-plane
+
+One skill for all UI work in `portals/api-control-plane`. It replaces the four generated
+`oxygen-*` skills and `.claude/oxygen-ui/*.md`: everything load-bearing from those is condensed
+below, and the full vendor reference is still on disk (see *Where the truth lives*).
+
+## Where the truth lives
+
+Verify before you invent. In priority order:
+
+| Question | Source |
+| --- | --- |
+| Does this component/prop/sub-component exist? | `node_modules/@wso2/oxygen-ui/dist/**/*.d.ts` — the compiled types, ground truth |
+| Full Oxygen component/pattern/theming reference | `node_modules/@wso2/oxygen-ui/.ai/{components,patterns,theming,migration}.md` (shipped with the package; identical to the docs previously copied into `.claude/oxygen-ui/`) |
+| Which icon names exist | `node_modules/@wso2/oxygen-ui-icons-react/dist/index.d.ts` → 8 brand icons + `export * from "lucide-react"` |
+| Data-access layer rules | `src/api/README.md` |
+| i18n rules | `src/i18n/README.md` |
+| Test conventions | `src/test/README.md` |
+| Layer bans, i18n lint rules | `eslint.config.js` — the restrictions are documented inline there |
+| Online / upstream | (`packages/oxygen-ui/.ai/*.md`, `src/components/**`). No hosted docs site exists as of v0.13.1 — fetch the repo, not a guessed URL. |
+
+Vendor docs lag the package. Two known errors in them — **do not copy**:
+- `` → this app is on `@mui/material` v9 Grid v2: ``, no `item`.
+- `HomeIcon`/`TrashIcon` naming → this codebase uses bare lucide names (`Home`, `Trash2`, `Plus`). Both aliases resolve; stay with the bare form.
+- They also omit components that do exist (`AppBreadcrumbs`, `PageTitle.Actions`, `PageTitle.BackButton`, `ColorSchemeSVG`).
+
+`npx @wso2/oxygen-ui init --claude` / `update --claude` regenerates `.claude/oxygen-ui/` and the
+`oxygen-*` skills. If someone runs it, delete the regenerated files again — this skill is the
+project's single entry point, and `node_modules/@wso2/oxygen-ui/.ai/` already carries the reference.
+
+## Non-negotiables
+
+1. **Every source file starts with the Apache-2.0 header** (`Copyright (c) 2026, WSO2 LLC.` block — copy it from any neighbouring file).
+2. **All components come from `@wso2/oxygen-ui`**, never `@mui/material`. All icons from `@wso2/oxygen-ui-icons-react`, never `lucide-react`/`@mui/icons-material`.
+3. **Backend access goes through a hook in `src/api/resources/`** — nothing else. ESLint blocks endpoints, queries, query keys, transport, generated types and legacy clients.
+4. **No user-facing literal strings.** `` for JSX, `intl.formatMessage` for string props. IDs are `apiControlPlane...`.
+5. **No colour, radius, blur, shadow or border literals.** Theme tokens (`bgcolor: 'background.paper'`, `color: 'text.secondary'`, `spacing` numbers) or a recipe from `src/theme/receipes.ts`. Local `sx` is for layout only.
+6. **Scoped pages wrap their body in `ScopeGate`** (see *Routing, scope and navigation*).
+7. **Never “resolve” a rule from `.claude/rules/*` with a `// TODO`.** Those rules (XSS/output encoding, dependency management, error handling) apply to this portal's `.ts`/`.tsx` too.
+
+Verification loop, in order: `npm run typecheck` → `npm run lint` → `npm run test` → `npm run i18n`
+(commit `src/i18n/messages/`, never `src/i18n/compiled/`).
+
+## Project map
+
+```
+src/
+ api/ resources//{endpoints,queries,hooks}.ts + core/ (http, scope, spec, queryKeys)
+ components/ app-wide: StateViews (Loading/Empty/Error), ConfirmDialog, Notifications,
+ ErrorBoundary, AppLoader, ComingSoon, cards/*, common/*
+ contexts/auth/ AuthProvider + AuthStateContext
+ hooks/ cross-cutting hooks (ProductActivation)
+ i18n/ I18nProvider, useLocale, useFormatters, formats.ts, messages/ (source catalogs)
+ navigation/ navigationRegistry.tsx (the sidebar), navigationTypes, useNavigationItems
+ pages/
+ auth/ LoginPage, AuthCallbackPage
+ appShell/ AppLayout, AppHeader, AppSidebar, *QuickSelector
+ appShellPages// the actual pages (+ components/, utils/ per feature)
+ routes/ paths.ts (route builders), AppRoutes.tsx, ProtectedRoute
+ scope/ ConsoleScopeProvider/Context, ScopeGate, consoleRouteParams
+ slots/ Slot + Hideable extension primitives
+ theme/ receipes.ts (shared style recipes)
+ test/ renderWithProviders, MSW server + toolkit, mock scope/auth
+ extensions.tsx extension registration; hostPort.tsx the value handed to extensions
+```
+
+Provider order (`App.tsx`, outermost first): `I18nProvider` → `OxygenUIThemeProvider` →
+`NotificationProvider` → `AppQueryProvider` → `ApiClientProvider` → `ErrorBoundary` →
+`BrowserRouter` → `AuthProvider` → `ExtensionsProvider` → `AppRoutes`. Inside a protected route:
+`ConsoleScopeProvider` → `AppLayout`. Don't add a provider without a reason that names its position.
+
+---
+
+## Oxygen UI essentials
+
+### Imports
+
+```tsx
+import { Box, Button, Card, PageTitle, Stack, Typography } from '@wso2/oxygen-ui';
+import { Plus, Search, Trash2 } from '@wso2/oxygen-ui-icons-react'; // lucide names, size={18}
+import { DataGrid, DatePickers, TreeView, AdapterDateFns } from '@wso2/oxygen-ui'; // MUI X namespaces
+```
+
+`@wso2/oxygen-ui` re-exports **all** of `@mui/material` plus Oxygen's own components, `styled`,
+`alpha`, `useTheme`, `Theme`. MUI X ships as namespaces: ``,
+``, ``. Charts live in
+`@wso2/oxygen-ui-charts-react`, which is **not installed here** — adding it is a dependency change
+(`.claude/rules/js-dependency-management.md`).
+
+### Oxygen's own components (beyond MUI)
+
+| Component | Sub-components / notes |
+| --- | --- |
+| `AppShell` | `.Navbar` `.Sidebar` `.Main` `.Footer` `.NotificationPanel`; props `initialCollapsed`, `collapseOnSelectOnMobile`. Wired once in `AppLayout` — pages never touch it. |
+| `Header` | `.Toggle` `.Brand` `.BrandLogo` `.BrandTitle` `.Switchers` `.Actions` `.Spacer`; `minimal` hides switchers |
+| `Sidebar` | `.Nav` `.Category` `.CategoryLabel` `.Item` `.ItemIcon` `.ItemLabel` `.ItemBadge` `.Footer` `.User*`; props `collapsed`, `activeItem`, `expandedMenus`, `onSelect`, `onToggleExpand`. An `Item` with nested children toggles instead of navigating. |
+| `Footer` | `.Copyright` `.Version` `.Divider` (left) · `.Link` (right) |
+| `UserMenu` | `.Trigger` `.Header` `.Item` `.Logout` `.Divider` |
+| `NotificationPanel` | `.Header{,Icon,Title,Badge,Close}` `.Tabs` `.Actions` `.List` `.Item{,Avatar,Title,Message,Timestamp,Action}` `.EmptyState` |
+| `PageTitle` | `.Header` `.SubHeader` `.Avatar` `.Link` `.Actions` `.BackButton` — the standard page heading |
+| `PageContent` | padding/max-width wrapper; `fullWidth`. Already applied by `AppLayout`, so a page starts at its own content. |
+| `AppBreadcrumbs` | `items: BreadcrumbItem[]`; rendered by `AppLayout` from route scope |
+| `ListingTable` | `.Provider` `.Container` `.Toolbar` `.Head` `.Body` `.Footer` `.Row` `.Cell` `.SortLabel` `.RowActions` `.CellIcon` `.EmptyState` `.DensityControl`; `variant='table'\|'card'`, `density`, `striped`, `bordered`. Prefer over raw MUI `Table`. |
+| `Form` | `.Section` `.Header` `.Subheader` `.Body` `.Stack` `.ElementWrapper` `.Wizard` `.CardButton` `.Card{Header,Content,Actions,Media}` |
+| `ComplexSelect` | `.MenuItem` + `.MenuItem.Icon` / `.MenuItem.Text` — icon+text options inside a `Select` |
+| `SearchBar`, `SearchBarWithAdvancedFilter`, `StatCard`, `CodeBlock`, `ColorSchemeToggle`, `ColorSchemeImage`, `ColorSchemeSVG`, `ThemeSwitcher`, `NotificationBanner`, `ParticleBackground`, `Layout` (`.Navbar` `.Sidebar` `.Content` `.Header`) | single-purpose; check the `.d.ts` for props |
+
+Hooks: `useTheme`, `useThemeSwitcher`, `useThemeContent`, `useAppShell`, `useNotifications`
+(Oxygen's own — not this app's `src/components/Notifications`), `useHeader`, `useSidebar`,
+`useNotificationPanel`, `useListingTable` / `useListingTableRequired`.
+Utils: `formatRelativeTime`, `pxToRem`, `alpha`.
+
+### Theming
+
+`OxygenUIThemeProvider` is mounted once, directly in `App.tsx`, with a one-entry registry
+(`AcrylicOrangeTheme`) declared at module scope in that same file — module scope because the
+provider keys a theme-resolving effect on the array's identity. Don't call the provider anywhere
+else, and don't wrap it in an app-level provider of your own.
+
+It needs nothing else from us: it already renders `` internally,
+and with no `emotionCache`/`nonce` prop it falls back to `` — so
+app styles override Oxygen styles without a hand-rolled Emotion cache. Under a CSP, pass the
+provider's own `nonce` prop rather than building a cache.
+
+Three tiers, in order of preference:
+
+1. **Theme** — global decisions owned by Oxygen (`theme.palette.*`, `theme.typography.*`, `theme.border.*`, `theme.zIndex.*`, `theme.oxygen.*` for blur/gradient/glass/syntax).
+2. **Recipes** — `src/theme/receipes.ts`: `hairline(theme)`, `glassSurfaceSx(theme)`, `interactiveCardSx`, `stickyBottomBarSx(theme)`, `overlayBarShadow`. A repeated multi-property treatment goes here, once.
+3. **Local `sx`** — layout only: flex, gap, grid, min/max sizing.
+
+```tsx
+ ({ ...glassSurfaceSx(theme), display: 'flex', gap: 2 })} />
+```
+
+Dark mode: the theme is CSS-variable based (`--oxygen-*`, `data-color-scheme` attribute), so token
+usage adapts for free. Read the mode with `useTheme().palette.mode` only when behaviour (not colour)
+must branch.
+
+---
+
+## Page patterns
+
+### Page skeleton
+
+```tsx
+export function ThingListPage() {
+ // Gate the whole body, not just the JSX: out of scope the query stays disabled
+ // and `isPending` never clears, so a loading branch would hang forever.
+ return (
+
+
+
+ );
+}
+
+function ThingList() {
+ const intl = useIntl();
+ const thingsQuery = useThings();
+ const { notify } = useNotifications();
+
+ // `isPending`, not `isLoading` — a disabled query has isLoading=false with no
+ // data, which would flash the empty state.
+ if (thingsQuery.isPending) return ;
+ if (thingsQuery.error) return ;
+
+ const things = thingsQuery.data?.list ?? [];
+
+ return (
+ <>
+
+
+
+
+ } variant="contained" onClick={create}>
+
+
+
+
+
+ {things.length === 0 ? (
+
+ ) : (
+ {/* toolbar row, then grid/list */}
+ )}
+ >
+ );
+}
+```
+
+State views come from `src/components/StateViews` — `LoadingState({label, fullScreen})`,
+`EmptyState({title, description, actionLabel, onAction})`, `ErrorState({title, message})`.
+Never hand-roll a spinner or an error `Alert`.
+
+Reference implementations to copy from: `apis/ApiListPage.tsx` (list + search + grid/list toggle +
+delete), `apis/ApiDetailPage.tsx` (tabbed detail), `gateways/GatewayCreatePage.tsx` (create flow),
+`apis/overview/ApiKeysPanel.tsx` (`ListingTable`), `projects/NewProjectDialog.tsx` (dialog form).
+
+### Feedback and confirmation
+
+```tsx
+const { notify } = useNotifications(); // src/components/Notifications
+notify('Deleted "Orders API".', 'success'); // 'success' | 'info' | 'warning' | 'error'
+```
+
+Mutation errors already surface globally (the QueryClient's `onMutationError` notifies), so a
+per-call `onError` is for *better* copy, never for making the error visible at all.
+
+Destructive actions use `ConfirmDialog` (`src/components/ConfirmDialog`), with `destructive` and —
+for irreversible deletes — `confirmPhrase` + `confirmInputLabel` (type-the-name):
+
+```tsx
+ setToDelete(null)}
+ onConfirm={confirmDelete}
+/>
+```
+
+### Forms
+
+Controlled state + `error`/`helperText`; group with `Form.Section` / `Form.Stack`; multi-step with
+`Form.Wizard` (or `Stepper` where a wizard is overkill). Labels and helper text go through
+`intl.formatMessage`. Field errors from the API arrive as `ApiError.fieldErrors` — map them onto the
+matching field instead of dumping the message into a banner.
+
+```tsx
+
+
+
+
+ setName(event.target.value)}
+ required
+ value={name}
+ />
+
+
+```
+
+### Accessibility and small conventions
+
+- A `Select` needs `labelId` pointing at its `FormLabel id` — a bare `FormLabel` leaves the combobox with no accessible name (and nothing for tests to query).
+- Icon-only buttons need `aria-label` (translated).
+- Icon size is explicit: ``.
+- Newer files sort JSX props and `sx` keys alphabetically; follow the file you're editing.
+- Truncate rather than widen: `minWidth: 0` + `overflow: 'hidden'` + `noWrap`.
+
+---
+
+## Data access (`src/api`)
+
+Full rules in `src/api/README.md`. What a UI author must know:
+
+- Import **only** hooks: `useThings()`, `useThing(id)`, `useCreateThing()`, `useUpdateThing()`, `useDeleteThing()`. Endpoints/queries/keys/http/generated types are ESLint-blocked from the UI.
+- Hooks resolve org/project from route scope via `useApiScope()`; pass overrides only for a genuine cross-scope read.
+- Scoped queries are `enabled`-gated, so **branch on `isPending`**, not `isLoading`.
+- Errors are always `ApiError` — branch on `error.code`, never HTTP status; `fieldErrors`, `details`, `trackingId` are available. Never render `error.message` as translated copy.
+- Lists: read `pagination.total`, never `list.length`.
+- Adding a backend call = three files (`*.endpoints.ts`, `*.queries.ts`, `*.hooks.ts`) copied from `resources/restApis/`, plus `npm run api:codegen` if the spec changed. Never hand-write a request/response type — derive from the `operationId` with `ResponseOf`/`BodyOf`/`QueryOf`/`PathOf`.
+- A legacy layer (`client.ts`, `mvpApi.ts`, `*/*Client.ts`) still serves some pages. Don't extend it; port the page.
+
+Route scope for rendering (not fetching) comes from `useConsoleScope()`:
+`{ params, activeScope, organization(s), project(s), component, capabilities, isOrganizationScope, isProjectScope, isApiScope, isLoading, projectsError }`.
+
+---
+
+## i18n
+
+Full rules in `src/i18n/README.md`. The short form:
+
+```tsx
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; // direct import, always
+
+const messages = defineMessages({ // module scope — extraction is static
+ title: { id: 'apiControlPlane.pages.apis.ApiListPage.title', defaultMessage: 'APIs' },
+ nameLabel: {
+ id: 'apiControlPlane.pages.apis.ApiListPage.nameLabel',
+ defaultMessage: 'Name',
+ description: 'Label for the API name field. Noun, not a command.',
+ },
+});
+```
+
+- ID = `apiControlPlane...`; `` mirrors the `src/` path (`/`→`.`, filename dropped). Renaming an ID discards its translations; `defaultMessage` may change freely.
+- One sentence = one message. Never concatenate; use ICU placeholders/plurals/rich-text tags.
+- Never translate backend or user data — pass it through as a *value*.
+- Dates/numbers via `useFormatters()` (`shortDate`, `dateTime`, `relativeTime`) or ``/`` — never `toLocaleString()` or a module-scope `Intl.*`.
+- After changing strings: `npm run i18n`, commit `src/i18n/messages/`. `npm run lint:i18n` lists remaining hardcoded JSX (the rule is `warn`, so `npm run lint --quiet` skips it). Check layout growth with `?lang=en-XA`.
+
+---
+
+## Routing, scope and navigation
+
+Three tiers of scope live in the URL: `/organizations/:orgHandle`,
+`.../projects/:projectHandler`, `.../apis/:apiHandler`. A page that needs deeper scope than the
+current URL still mounts — at a **scope-less alias** where `select-scope` (`SELECT_SCOPE_SEGMENT`)
+replaces the missing segments — and `ScopeGate` renders a picker until the handles are filled in.
+That is why every sidebar item stays clickable at every scope.
+
+Adding a page, end to end:
+
+1. **`src/routes/paths.ts`** — add a builder using `projectPath`/`apiPath` so the alias is generated for you:
+ ```ts
+ thingDetail: (orgHandle = ':orgHandle', projectHandler: ScopeHandle = ':projectHandler') =>
+ projectPath(orgHandle, projectHandler, 'things/detail'),
+ ```
+2. **`src/routes/AppRoutes.tsx`** — `lazy()`-import the page and register every path it answers on:
+ ```tsx
+ {scopedRoutes(projectScopedPaths(routes.thingDetail), )}
+ ```
+ (`apiScopedPaths` for API-level pages; a single `` only when the page has no alias.)
+3. **The page** wraps its body in `ScopeGate` with `requires` + `to={routes.thingDetail}`.
+4. **`src/navigation/navigationRegistry.tsx`** — add the sidebar entry. Build `to`/`match` from the *same* builder via the helpers, never by hand: `orgLevelTo`, `apiLevelTo`, `matchRoutes`, `submenu([...])` for a parent with children, `adaptive([...tiers])` for one item that degrades across scopes, `apiCapability(...)` for capability gating (which only applies once an API is in scope).
+
+Never hand-write a path string or a `match` regex: `routes.*` is the single source, and
+`paths.test.ts` / `navigationRegistry.test.ts` guard the pairing.
+
+Extension points: `Slot` / `Hideable` (`src/slots/`) — a named additive slot plus a suppressible
+region for built-in UI; extensions receive a plain `CloudHostPort` value (`src/hostPort.tsx`), not a
+shared context. Keep `src/slots/index.tsx` free of portal-specific types; it is copied verbatim into
+other hosts.
+
+Two slot families exist today, both declared in `src/extensions.tsx`: `sidebar.` for a
+top-level nav item (routed by `AppRoutes`, merged into the sidebar by `useNavigationItems`) and
+`settings..tabs` for a Settings sub-nav tab (listed by `useSettingsTabs`, routed nested under
+`SettingsLayout`). An entry's `slot` and `level` must agree — a descriptor claiming
+`settings.project.tabs` with `level: 'organization'` is dropped by both, rather than rendered
+against the wrong scope's Port. Filter with `isSidebarExtension`/`settingsTabExtensions`; never
+re-spell a slot name as a literal at the use site.
+
+---
+
+## Tests
+
+Full conventions in `src/test/README.md`.
+
+- Colocate as `*.test.ts` / `*.test.tsx`. Always render through `renderWithProviders` (never wrap providers by hand):
+ ```tsx
+ const { user } = renderWithProviders(, {
+ route: '/organizations/api-platform-demo/projects/retail-apis/apis',
+ scope: makeConsoleScope(),
+ authState: authStatePresets.authenticated(),
+ });
+ ```
+- Mock at the network boundary with MSW, never a client or a hook. The server ships **no default handlers** and fails unhandled requests, so each test declares its endpoints via `collection` / `resource` / `accepts` / `noContent` / `failure` / `recorder` and spec-typed fixtures (`aRestApi`, `manyRestApis`). Build URLs with `apiUrl('/things')`; call `resetHttpClient()` in `beforeEach`.
+- `renderWithProviders` supplies `IntlProvider` with empty messages, so assert on the English `defaultMessage`.
+- Prefer `userEvent`; query by role/label; await with `findBy*`. To prove *no* request fired, await a short timeout then assert `requests.count() === 0` — `waitFor` proves nothing there.
+- Hook tests go one per *shape*, not per resource; endpoint tests one per resource.
+
+---
+
+## Migrating existing MUI code
+
+| From | To |
+| --- | --- |
+| `@mui/material`, `@mui/material/styles` (`styled`, `alpha`, `useTheme`) | `@wso2/oxygen-ui` |
+| `@mui/icons-material`, `lucide-react` | `@wso2/oxygen-ui-icons-react` (bare lucide names, explicit `size`) |
+| `@mui/x-data-grid` / `x-date-pickers` / `x-tree-view` | `DataGrid.*` / `DatePickers.*` / `TreeView.*` from `@wso2/oxygen-ui` |
+| `ThemeProvider` + `createTheme`, `CssBaseline`, a custom Emotion cache | nothing — `OxygenUIThemeProvider` in `App.tsx` already owns all three |
+| `AppBar`/`Toolbar`/`Drawer` layout | `AppShell` + `Header` + `Sidebar` (already in `AppLayout`) |
+| `Table`/`TableHead`/`TableRow`/`TableCell` | `ListingTable.*` |
+| `` | `` |
+| `useColorScheme()` | ``, or `useTheme().palette.mode` |
+| hardcoded colours/spacing | theme tokens, or a recipe in `src/theme/receipes.ts` |
+| hardcoded JSX strings | `FormattedMessage` / `intl.formatMessage` (add to `defineMessages`) |
+| direct client/axios calls | a hook from `src/api/resources/` |
+
+## Review checklist
+
+- Apache header present; imports only from `@wso2/oxygen-ui` + `@wso2/oxygen-ui-icons-react`.
+- No user-facing literal; every new message has an ID in the house format (and a `description` where a translator could misread it).
+- Data via hooks; `isPending` used for the loading branch; `ApiError.code` for branching; `pagination.total` for counts.
+- Scoped page wrapped in `ScopeGate`; `to` is the page's own `routes.*` builder; route registered for the alias paths too; sidebar `to`/`match` derived from the same builder.
+- Colours/spacing/borders via tokens or recipes; local `sx` limited to layout.
+- Loading/empty/error use `StateViews`; destructive actions use `ConfirmDialog`; feedback via `useNotifications`.
+- `Select` has `labelId`; icon-only buttons have a translated `aria-label`.
+- `npm run typecheck && npm run lint && npm run test` clean; `npm run i18n` run and `src/i18n/messages/` committed (never `src/i18n/compiled/`).
diff --git a/portals/api-control-plane/.eslintignore b/portals/api-control-plane/.eslintignore
new file mode 100644
index 0000000000..67dc1a0320
--- /dev/null
+++ b/portals/api-control-plane/.eslintignore
@@ -0,0 +1,4 @@
+/public/**
+/node_modules/**
+/coverage/**
+# Pleae comment the reason if you are ignoring a file/directory here
diff --git a/portals/api-control-plane/.prettierignore b/portals/api-control-plane/.prettierignore
new file mode 100644
index 0000000000..aa4535301a
--- /dev/null
+++ b/portals/api-control-plane/.prettierignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+build/
+coverage/
+public/
+# Pleae comment the reason if you are ignoring a file/directory here
diff --git a/portals/api-control-plane/.prettierrc.json b/portals/api-control-plane/.prettierrc.json
new file mode 100644
index 0000000000..a96131f74d
--- /dev/null
+++ b/portals/api-control-plane/.prettierrc.json
@@ -0,0 +1,13 @@
+{
+ "printWidth": 100,
+ "tabWidth": 2,
+ "useTabs": false,
+ "semi": true,
+ "singleQuote": true,
+ "jsxSingleQuote": false,
+ "trailingComma": "all",
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/portals/api-control-plane/CLAUDE.md b/portals/api-control-plane/CLAUDE.md
new file mode 100644
index 0000000000..08ad157043
--- /dev/null
+++ b/portals/api-control-plane/CLAUDE.md
@@ -0,0 +1,13 @@
+# Project Guidelines
+
+## UI development
+
+All UI work in this portal — pages, components, forms, listings, navigation, theming, Oxygen UI
+component/theming API, and MUI→Oxygen migration — follows the `apicp-ui` skill
+([.claude/skills/apicp-ui/SKILL.md](.claude/skills/apicp-ui/SKILL.md)). Invoke it with `/apicp-ui`,
+or read it directly before touching anything under `src/`.
+
+The full upstream Oxygen UI reference ships with the package at
+`node_modules/@wso2/oxygen-ui/.ai/{components,patterns,theming,migration}.md`; the compiled types in
+`node_modules/@wso2/oxygen-ui/dist/**/*.d.ts` are the ground truth when the docs and the package
+disagree.
diff --git a/portals/api-control-plane/eslint.config.js b/portals/api-control-plane/eslint.config.js
index 8a107633eb..e22b7165bd 100644
--- a/portals/api-control-plane/eslint.config.js
+++ b/portals/api-control-plane/eslint.config.js
@@ -3,6 +3,7 @@ import globals from 'globals';
import formatjs from 'eslint-plugin-formatjs';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
+import eslintConfigPrettier from "eslint-config-prettier";
export default [
js.configs.recommended,
@@ -242,4 +243,5 @@ export default [
'src/api/generated/**',
],
},
+ eslintConfigPrettier,
];
diff --git a/portals/api-control-plane/package-lock.json b/portals/api-control-plane/package-lock.json
index b8e5b3c40f..0c6e94cbd4 100644
--- a/portals/api-control-plane/package-lock.json
+++ b/portals/api-control-plane/package-lock.json
@@ -9,19 +9,24 @@
"version": "0.1.0",
"dependencies": {
"@babel/runtime-corejs3": "7.29.7",
+ "@emotion/react": "11.14.0",
+ "@emotion/styled": "11.14.1",
+ "@mui/material": "9.3.1",
+ "@mui/x-data-grid": "9.11.0",
+ "@mui/x-date-pickers": "9.11.0",
"@tanstack/react-query": "5.101.0",
- "@wso2/oxygen-ui": "0.11.0",
- "@wso2/oxygen-ui-icons-react": "0.11.0",
+ "@wso2/oxygen-ui": "0.13.1",
+ "@wso2/oxygen-ui-icons-react": "0.13.1",
"axios": "1.19.0",
"js-yaml": "4.1.0",
"react": "19.2.3",
"react-dom": "19.2.3",
- "react-intl": "^10.1.20",
+ "react-intl": "10.1.20",
"react-router-dom": "7.9.4"
},
"devDependencies": {
"@eslint/js": "9.26.0",
- "@formatjs/cli": "^6.16.18",
+ "@formatjs/cli": "6.16.18",
"@openapitools/openapi-generator-cli": "2.5.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.3",
@@ -34,14 +39,16 @@
"@vitejs/plugin-basic-ssl": "1.1.0",
"@vitejs/plugin-react": "4.2.1",
"@vitest/coverage-v8": "2.1.8",
- "babel-plugin-formatjs": "^11.3.16",
+ "babel-plugin-formatjs": "11.3.16",
"eslint": "9.26.0",
- "eslint-plugin-formatjs": "^6.4.0",
+ "eslint-config-prettier": "10.1.8",
+ "eslint-plugin-formatjs": "6.4.0",
"eslint-plugin-react-hooks": "5.2.0",
"globals": "15.15.0",
"jsdom": "25.0.1",
"msw": "2.6.8",
"openapi-typescript": "7.13.0",
+ "prettier": "3.9.6",
"typescript": "5.8.3",
"typescript-eslint": "8.32.1",
"vite": "5.0.11",
@@ -354,6 +361,9 @@
},
"node_modules/@base-ui-components/utils": {
"version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@base-ui-components/utils/-/utils-0.1.2.tgz",
+ "integrity": "sha512-aEitDGpMsYO2qnSpYOwZNykn9Rzn2ioyEVk2fyDRH7t+TIHVKpp9CeV7SPTq43M9mMSDxQ+7UeZJVkrj2dCVIQ==",
+ "deprecated": "Package was renamed to @base-ui/utils",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -372,6 +382,28 @@
}
}
},
+ "node_modules/@base-ui/utils": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.2.tgz",
+ "integrity": "sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "@floating-ui/utils": "^0.2.12",
+ "reselect": "^5.2.0",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^17 || ^18 || ^19",
+ "react": "^17 || ^18 || ^19",
+ "react-dom": "^17 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@bcoe/v8-coverage": {
"version": "0.2.3",
"dev": true,
@@ -531,6 +563,8 @@
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
+ "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
@@ -548,10 +582,14 @@
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/@emotion/cache": {
"version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
+ "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0",
@@ -563,10 +601,14 @@
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
+ "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0"
@@ -574,10 +616,14 @@
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/react": {
"version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
+ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
@@ -600,6 +646,8 @@
},
"node_modules/@emotion/serialize": {
"version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
+ "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.2",
@@ -611,10 +659,14 @@
},
"node_modules/@emotion/sheet": {
"version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
+ "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT"
},
"node_modules/@emotion/styled": {
"version": "11.14.1",
+ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
+ "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
@@ -636,10 +688,14 @@
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
+ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT"
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
+ "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
@@ -647,10 +703,14 @@
},
"node_modules/@emotion/utils": {
"version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
+ "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
+ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
@@ -1194,10 +1254,14 @@
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
+ "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -1349,6 +1413,36 @@
"win32"
]
},
+ "node_modules/@formatjs/ecma402-abstract": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz",
+ "integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "3.1.0",
+ "@formatjs/intl-localematcher": "0.8.1",
+ "decimal.js": "^10.6.0",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/fast-memoize": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz",
+ "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/ecma402-abstract/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/@formatjs/fast-memoize": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz",
@@ -1372,20 +1466,48 @@
"license": "MIT"
},
"node_modules/@formatjs/intl": {
- "version": "4.1.19",
- "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-4.1.19.tgz",
- "integrity": "sha512-bZMROATl+rzfL8wsDZ53i8XWHx5e6rVr8QlGT5GDvHImEUro4WE9pxUd2dobzmI3CtD+zkt/wkEdqcb+3CK8Fg==",
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-4.1.18.tgz",
+ "integrity": "sha512-d+AFsn+luyMAUTQImPJEK76bhEvzFKRG/1PCf1Q6sCH3ojHyHgJJkfvUFElpbDkihtm4uedK7/zoG24EL285nw==",
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.7",
- "@formatjs/icu-messageformat-parser": "3.5.17",
- "intl-messageformat": "11.2.14"
+ "@formatjs/icu-messageformat-parser": "3.5.16",
+ "intl-messageformat": "11.2.13"
+ }
+ },
+ "node_modules/@formatjs/intl-localematcher": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz",
+ "integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/fast-memoize": "3.1.0",
+ "tslib": "^2.8.1"
}
},
+ "node_modules/@formatjs/intl-localematcher/node_modules/@formatjs/fast-memoize": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz",
+ "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@formatjs/intl-localematcher/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/@formatjs/intl/node_modules/@formatjs/icu-messageformat-parser": {
- "version": "3.5.17",
- "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.17.tgz",
- "integrity": "sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==",
+ "version": "3.5.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz",
+ "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.11"
@@ -1811,7 +1933,9 @@
}
},
"node_modules/@mui/core-downloads-tracker": {
- "version": "7.3.11",
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.3.1.tgz",
+ "integrity": "sha512-IAyAFNQbT7hysJ9HXphiOmWJF7G1OglzHanqCgvQgH9LA2ydxtmaTBDbcBqw6euZesyShiwvpvbnYO1GY1AyXQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -1819,20 +1943,22 @@
}
},
"node_modules/@mui/material": {
- "version": "7.3.4",
+ "version": "9.3.1",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.3.1.tgz",
+ "integrity": "sha512-NahAEGIXqS1K0bA4th1jeFxBguS59NOcLbMA0vU+fSaPWKjtwGBGGHeTwlc9PSmzMjOZKceeFWESB8fVHr31hA==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.4",
- "@mui/core-downloads-tracker": "^7.3.4",
- "@mui/system": "^7.3.3",
- "@mui/types": "^7.4.7",
- "@mui/utils": "^7.3.3",
+ "@babel/runtime": "^7.29.7",
+ "@mui/core-downloads-tracker": "^9.3.1",
+ "@mui/system": "^9.3.0",
+ "@mui/types": "^9.3.0",
+ "@mui/utils": "^9.3.0",
"@popperjs/core": "^2.11.8",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
- "csstype": "^3.1.3",
+ "csstype": "^3.2.3",
"prop-types": "^15.8.1",
- "react-is": "^19.1.1",
+ "react-is": "^19.2.8",
"react-transition-group": "^4.4.5"
},
"engines": {
@@ -1845,7 +1971,7 @@
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
- "@mui/material-pigment-css": "^7.3.3",
+ "@mui/material-pigment-css": "^9.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -1865,40 +1991,14 @@
}
}
},
- "node_modules/@mui/material/node_modules/@mui/utils": {
- "version": "7.3.11",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/types": "^7.4.12",
- "@types/prop-types": "^15.7.15",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-is": "^19.2.3"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@mui/private-theming": {
- "version": "7.3.11",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.3.0.tgz",
+ "integrity": "sha512-ERvqk5pejf9aRnQcDILSWGtFmsEMiVxlQ4+xsVCjsEvmK0fV9BiVP/cQwAF5dwyDFbve4lTrlcgeFEecVzTNiA==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/utils": "^7.3.11",
+ "@babel/runtime": "^7.29.7",
+ "@mui/utils": "^9.3.0",
"prop-types": "^15.8.1"
},
"engines": {
@@ -1918,39 +2018,13 @@
}
}
},
- "node_modules/@mui/private-theming/node_modules/@mui/utils": {
- "version": "7.3.11",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/types": "^7.4.12",
- "@types/prop-types": "^15.7.15",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-is": "^19.2.3"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@mui/styled-engine": {
- "version": "7.3.10",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.3.0.tgz",
+ "integrity": "sha512-x9+KYxhjoHYZ4nioxdKnvQWdw0RScbhoZfQ4tv3Db742683U3wlYSp6zV6Us78dMFnKnyTrPTkQKyutp14gnKA==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6",
+ "@babel/runtime": "^7.29.7",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
@@ -1979,14 +2053,16 @@
}
},
"node_modules/@mui/system": {
- "version": "7.3.11",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.3.0.tgz",
+ "integrity": "sha512-0l4LqHJxZj65xSrioniGsxm7VNoGXonPo203oZjhBUvIDPeBqRTb7Mqc45Qxs6sO6WGR7WE/9cJ6lb4lkvHkjg==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/private-theming": "^7.3.11",
- "@mui/styled-engine": "^7.3.10",
- "@mui/types": "^7.4.12",
- "@mui/utils": "^7.3.11",
+ "@babel/runtime": "^7.29.7",
+ "@mui/private-theming": "^9.3.0",
+ "@mui/styled-engine": "^9.3.0",
+ "@mui/types": "^9.3.0",
+ "@mui/utils": "^9.3.0",
"clsx": "^2.1.1",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
@@ -2016,39 +2092,13 @@
}
}
},
- "node_modules/@mui/system/node_modules/@mui/utils": {
- "version": "7.3.11",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/types": "^7.4.12",
- "@types/prop-types": "^15.7.15",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-is": "^19.2.3"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@mui/types": {
- "version": "7.4.12",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.3.0.tgz",
+ "integrity": "sha512-2JSxyfpEFWNUB2vKs/T1BvkfyNisMHWph8bLMj8T0uHwmLl/0qfAwQkfwMT6kxLXN9uIum9AEbECXU8er3amIg==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6"
+ "@babel/runtime": "^7.29.7"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -2060,15 +2110,17 @@
}
},
"node_modules/@mui/utils": {
- "version": "9.0.0",
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.3.0.tgz",
+ "integrity": "sha512-2HZdHwWJ6eB+7lVGSOHsByGw8jeRulT4g0NZ608Wb8Q57DE2jbNqrWPFuJsvkQQiBiTmlpvQL3i+/62zsiPrkw==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.29.2",
- "@mui/types": "^9.0.0",
+ "@babel/runtime": "^7.29.7",
+ "@mui/types": "^9.3.0",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
- "react-is": "^19.2.4"
+ "react-is": "^19.2.8"
},
"engines": {
"node": ">=14.0.0"
@@ -2087,29 +2139,17 @@
}
}
},
- "node_modules/@mui/utils/node_modules/@mui/types": {
- "version": "9.1.1",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.29.2"
- },
- "peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
- }
- },
"node_modules/@mui/x-data-grid": {
- "version": "8.16.0",
+ "version": "9.11.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-9.11.0.tgz",
+ "integrity": "sha512-p/KHQSMOl+gV2lfkUXUSeNLnfXiQ3pUqJQ9oHLvNPdvauw87apx0o9iPiohCyw7WOeaqtosNLWCsbBkCy1YQpQ==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.4",
- "@mui/utils": "^7.3.3",
- "@mui/x-internals": "8.16.0",
- "@mui/x-virtualizer": "0.2.6",
+ "@babel/runtime": "^7.29.7",
+ "@base-ui/utils": "^0.3.1",
+ "@mui/utils": "^9.3.0",
+ "@mui/x-internals": "^9.11.0",
+ "@mui/x-virtualizer": "0.6.3",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"use-sync-external-store": "^1.6.0"
@@ -2124,8 +2164,8 @@
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
- "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
- "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "@mui/material": "^7.3.0 || ^9.0.0",
+ "@mui/system": "^7.3.0 || ^9.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
@@ -2138,16 +2178,17 @@
}
}
},
- "node_modules/@mui/x-data-grid/node_modules/@mui/utils": {
- "version": "7.3.11",
+ "node_modules/@mui/x-data-grid/node_modules/@mui/x-internals": {
+ "version": "9.11.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.11.0.tgz",
+ "integrity": "sha512-JjKe9k1+gVWNPwMTZLNSc92eVmoZqP5Xq3Ui6mJkstotUrbWIZC0o9+4AfTR1lYqWwnKmqm+VtLLVCa7MEBXWQ==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/types": "^7.4.12",
- "@types/prop-types": "^15.7.15",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-is": "^19.2.3"
+ "@babel/runtime": "^7.29.7",
+ "@base-ui/utils": "^0.3.1",
+ "@mui/utils": "^9.3.0",
+ "reselect": "^5.2.0",
+ "use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
@@ -2157,22 +2198,41 @@
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@mui/x-data-grid/node_modules/@mui/x-virtualizer": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.6.3.tgz",
+ "integrity": "sha512-12EEt7/qG7/6YGmzjcqDRzDkYnTSELoQxOrTf30XLWxhIdkYn1uygZzSPG+fg811ZzbF301HodEVY05GMLCQmg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.7",
+ "@base-ui/utils": "^0.3.1",
+ "@mui/utils": "^9.3.0",
+ "@mui/x-internals": "^9.11.0"
},
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@mui/x-date-pickers": {
- "version": "8.16.0",
+ "version": "9.11.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-9.11.0.tgz",
+ "integrity": "sha512-3vLmkn1wG+hNBaGobnk/9R8APCRhOiAXBTC78m7wZ2OWZ2NoMqi+O09HwTGlIgK6mScZZCeCQTSj5phNLrK1hw==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.4",
- "@mui/utils": "^7.3.3",
- "@mui/x-internals": "8.16.0",
+ "@babel/runtime": "^7.29.7",
+ "@mui/utils": "^9.3.0",
+ "@mui/x-internals": "^9.11.0",
"@types/react-transition-group": "^4.4.12",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
@@ -2188,8 +2248,8 @@
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
- "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
- "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "@mui/material": "^7.3.0 || ^9.0.0",
+ "@mui/system": "^7.3.0 || ^9.0.0",
"date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
"date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
"dayjs": "^1.10.7",
@@ -2230,16 +2290,17 @@
}
}
},
- "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": {
- "version": "7.3.11",
+ "node_modules/@mui/x-date-pickers/node_modules/@mui/x-internals": {
+ "version": "9.11.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.11.0.tgz",
+ "integrity": "sha512-JjKe9k1+gVWNPwMTZLNSc92eVmoZqP5Xq3Ui6mJkstotUrbWIZC0o9+4AfTR1lYqWwnKmqm+VtLLVCa7MEBXWQ==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/types": "^7.4.12",
- "@types/prop-types": "^15.7.15",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-is": "^19.2.3"
+ "@babel/runtime": "^7.29.7",
+ "@base-ui/utils": "^0.3.1",
+ "@mui/utils": "^9.3.0",
+ "reselect": "^5.2.0",
+ "use-sync-external-store": "^1.6.0"
},
"engines": {
"node": ">=14.0.0"
@@ -2249,17 +2310,13 @@
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@types/react": {
- "optional": true
- }
}
},
"node_modules/@mui/x-internals": {
"version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.16.0.tgz",
+ "integrity": "sha512-JR53WOFqmQYQzurOpB0H91K7/9uMcte1ooxHxTLGB+97PgB+rKY6siRWvUALGS56XyPV+1a2ALI33hd2E7+Rgg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
@@ -2278,8 +2335,27 @@
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@mui/x-internals/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@mui/x-internals/node_modules/@mui/utils": {
"version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
@@ -2306,18 +2382,15 @@
}
}
},
- "node_modules/@mui/x-tree-view": {
- "version": "8.14.0",
+ "node_modules/@mui/x-virtualizer": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.2.6.tgz",
+ "integrity": "sha512-t45EHhD9kStSwIYMkqYYQIFbZNVQws9LRANktf0e/+j+MxsRTFk41r0rgiazMSOSugJlCuSh/H8xUUuMCZdtow==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
- "@base-ui-components/utils": "0.1.2",
"@mui/utils": "^7.3.3",
- "@mui/x-internals": "8.14.0",
- "@types/react-transition-group": "^4.4.12",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-transition-group": "^4.4.5"
+ "@mui/x-internals": "8.16.0"
},
"engines": {
"node": ">=14.0.0"
@@ -2327,43 +2400,20 @@
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "@emotion/react": "^11.9.0",
- "@emotion/styled": "^11.8.1",
- "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
- "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
- },
- "peerDependenciesMeta": {
- "@emotion/react": {
- "optional": true
- },
- "@emotion/styled": {
- "optional": true
- }
}
},
- "node_modules/@mui/x-tree-view/node_modules/@mui/utils": {
- "version": "7.3.11",
+ "node_modules/@mui/x-virtualizer/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
"license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.28.6",
- "@mui/types": "^7.4.12",
- "@types/prop-types": "^15.7.15",
- "clsx": "^2.1.1",
- "prop-types": "^15.8.1",
- "react-is": "^19.2.3"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
+ "@babel/runtime": "^7.28.6"
},
"peerDependencies": {
- "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -2371,48 +2421,10 @@
}
}
},
- "node_modules/@mui/x-tree-view/node_modules/@mui/x-internals": {
- "version": "8.14.0",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.4",
- "@mui/utils": "^7.3.3",
- "reselect": "^5.1.1",
- "use-sync-external-store": "^1.6.0"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
- "node_modules/@mui/x-virtualizer": {
- "version": "0.2.6",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.4",
- "@mui/utils": "^7.3.3",
- "@mui/x-internals": "8.16.0"
- },
- "engines": {
- "node": ">=14.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/mui-org"
- },
- "peerDependencies": {
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/@mui/x-virtualizer/node_modules/@mui/utils": {
"version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
@@ -2640,6 +2652,8 @@
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -3294,16 +3308,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/estree-jsx": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
- "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "*"
- }
- },
"node_modules/@types/js-yaml": {
"version": "4.0.3",
"dev": true,
@@ -3324,6 +3328,8 @@
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
+ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/picomatch": {
@@ -3335,6 +3341,8 @@
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": {
@@ -3354,6 +3362,8 @@
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
+ "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
@@ -3610,190 +3620,832 @@
"vite": "^4.2.0 || ^5.0.0"
}
},
- "node_modules/@vitest/coverage-v8": {
- "version": "2.1.8",
- "dev": true,
+ "node_modules/@vitest/coverage-v8": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^0.2.3",
+ "debug": "^4.3.7",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.12",
+ "magicast": "^0.3.5",
+ "std-env": "^3.8.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "2.1.8",
+ "vitest": "2.1.8"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.8",
+ "@vitest/utils": "2.1.8",
+ "chai": "^5.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "2.1.8",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.12"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "2.1.9",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "2.1.8",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.8",
+ "magic-string": "^0.30.12",
+ "pathe": "^1.1.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^3.0.2"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.8",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
+ "version": "2.1.8",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@wso2/oxygen-ui": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/@wso2/oxygen-ui/-/oxygen-ui-0.13.1.tgz",
+ "integrity": "sha512-naLAZdIBBYS76Rx9cncLquyLZHrXxMym9CXO+W+CZn5YWnYQWxsE+0IIW03Ap/eF0JNX+d/AJVlENA/+WE9WoQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@emotion/cache": "11.14.0",
+ "@emotion/react": "11.14.0",
+ "@emotion/styled": "11.14.1",
+ "@fontsource-variable/inter": "5.2.8",
+ "@mui/material": "7.3.4",
+ "@mui/utils": "9.0.0",
+ "@mui/x-data-grid": "8.16.0",
+ "@mui/x-date-pickers": "8.16.0",
+ "@mui/x-tree-view": "8.14.0",
+ "date-fns": "4.1.0",
+ "prismjs": "^1.30.0"
+ },
+ "bin": {
+ "oxygen-ui": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "@wso2/oxygen-ui-icons-react": ">=0.1.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3"
+ }
+ },
+ "node_modules/@wso2/oxygen-ui-icons-react": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/@wso2/oxygen-ui-icons-react/-/oxygen-ui-icons-react-0.13.1.tgz",
+ "integrity": "sha512-F4TbWA/u9Ov1sSUhA7Ze7TREKuqAG0vDaeQvjeLGTuU2gNYFkQgKvvLDztAtIwxsyiN39Jb1KWf6q/RCncsq1Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "lucide-react": "1.16.0"
+ },
+ "peerDependencies": {
+ "react": "19.2.3"
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/core-downloads-tracker": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz",
+ "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/material": {
+ "version": "7.3.4",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
+ "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/core-downloads-tracker": "^7.3.4",
+ "@mui/system": "^7.3.3",
+ "@mui/types": "^7.4.7",
+ "@mui/utils": "^7.3.3",
+ "@popperjs/core": "^2.11.8",
+ "@types/react-transition-group": "^4.4.12",
+ "clsx": "^2.1.1",
+ "csstype": "^3.1.3",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.1.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@mui/material-pigment-css": "^7.3.3",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "@mui/material-pigment-css": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/material/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/material/node_modules/@mui/utils": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@mui/types": "^7.4.12",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/private-theming": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz",
+ "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@mui/utils": "^7.3.11",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/private-theming/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/private-theming/node_modules/@mui/utils": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@mui/types": "^7.4.12",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/styled-engine": {
+ "version": "7.3.10",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz",
+ "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/sheet": "^1.4.0",
+ "csstype": "^3.2.3",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.4.1",
+ "@emotion/styled": "^11.3.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/system": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz",
+ "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@mui/private-theming": "^7.3.11",
+ "@mui/styled-engine": "^7.3.10",
+ "@mui/types": "^7.4.12",
+ "@mui/utils": "^7.3.11",
+ "clsx": "^2.1.1",
+ "csstype": "^3.2.3",
+ "prop-types": "^15.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.5.0",
+ "@emotion/styled": "^11.3.0",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/system/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/system/node_modules/@mui/utils": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@mui/types": "^7.4.12",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/utils": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz",
+ "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "@mui/types": "^9.0.0",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-data-grid": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.16.0.tgz",
+ "integrity": "sha512-yJ+v+E1yI1HxrEUdOfgrUTCxobAFvotGggU6cy6MnM7c7/TPPg9d5mDzjzxb0imOCJ6WyiM/vtd5WKbY/5sUNw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/utils": "^7.3.3",
+ "@mui/x-internals": "8.16.0",
+ "@mui/x-virtualizer": "0.2.6",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.9.0",
+ "@emotion/styled": "^11.8.1",
+ "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-data-grid/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-data-grid/node_modules/@mui/utils": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "@mui/types": "^7.4.12",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-date-pickers": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.16.0.tgz",
+ "integrity": "sha512-zvUoO9ImWiKRaOWvQVbB1vCa6aUQIX5GM0tJ+nAyNNIVV0VqpXz3CvkRR6ovBBFzIcChc7FXlqrMKcJ//EhePQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "@mui/utils": "^7.3.3",
+ "@mui/x-internals": "8.16.0",
+ "@types/react-transition-group": "^4.4.12",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@emotion/react": "^11.9.0",
+ "@emotion/styled": "^11.8.1",
+ "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0",
+ "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0",
+ "dayjs": "^1.10.7",
+ "luxon": "^3.0.2",
+ "moment": "^2.29.4",
+ "moment-hijri": "^2.1.2 || ^3.0.0",
+ "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/react": {
+ "optional": true
+ },
+ "@emotion/styled": {
+ "optional": true
+ },
+ "date-fns": {
+ "optional": true
+ },
+ "date-fns-jalali": {
+ "optional": true
+ },
+ "dayjs": {
+ "optional": true
+ },
+ "luxon": {
+ "optional": true
+ },
+ "moment": {
+ "optional": true
+ },
+ "moment-hijri": {
+ "optional": true
+ },
+ "moment-jalaali": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-date-pickers/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6"
+ },
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-date-pickers/node_modules/@mui/utils": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"license": "MIT",
"dependencies": {
- "@ampproject/remapping": "^2.3.0",
- "@bcoe/v8-coverage": "^0.2.3",
- "debug": "^4.3.7",
- "istanbul-lib-coverage": "^3.2.2",
- "istanbul-lib-report": "^3.0.1",
- "istanbul-lib-source-maps": "^5.0.6",
- "istanbul-reports": "^3.1.7",
- "magic-string": "^0.30.12",
- "magicast": "^0.3.5",
- "std-env": "^3.8.0",
- "test-exclude": "^7.0.1",
- "tinyrainbow": "^1.2.0"
+ "@babel/runtime": "^7.28.6",
+ "@mui/types": "^7.4.12",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "@vitest/browser": "2.1.8",
- "vitest": "2.1.8"
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
- "@vitest/browser": {
+ "@types/react": {
"optional": true
}
}
},
- "node_modules/@vitest/expect": {
- "version": "2.1.8",
- "dev": true,
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-tree-view": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-8.14.0.tgz",
+ "integrity": "sha512-LyB48R6ANSY/1nP84Qw1AEdEAjsMM6iCxPtCpUqO1GVJSFNP3Jy2xZuPkTO9Ilsd2Ud1p2sjy7AtQVpMRsDTyA==",
"license": "MIT",
"dependencies": {
- "@vitest/spy": "2.1.8",
- "@vitest/utils": "2.1.8",
- "chai": "^5.1.2",
- "tinyrainbow": "^1.2.0"
+ "@babel/runtime": "^7.28.4",
+ "@base-ui-components/utils": "0.1.2",
+ "@mui/utils": "^7.3.3",
+ "@mui/x-internals": "8.14.0",
+ "@types/react-transition-group": "^4.4.12",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/mocker": {
- "version": "2.1.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "2.1.8",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.12"
+ "engines": {
+ "node": ">=14.0.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^5.0.0"
+ "@emotion/react": "^11.9.0",
+ "@emotion/styled": "^11.8.1",
+ "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
- "msw": {
+ "@emotion/react": {
"optional": true
},
- "vite": {
+ "@emotion/styled": {
"optional": true
}
}
},
- "node_modules/@vitest/pretty-format": {
- "version": "2.1.9",
- "dev": true,
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-tree-view/node_modules/@mui/types": {
+ "version": "7.4.12",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
+ "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^1.2.0"
+ "@babel/runtime": "^7.28.6"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/runner": {
- "version": "2.1.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/utils": "2.1.8",
- "pathe": "^1.1.2"
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@vitest/snapshot": {
- "version": "2.1.8",
- "dev": true,
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-tree-view/node_modules/@mui/utils": {
+ "version": "7.3.11",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
+ "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "2.1.8",
- "magic-string": "^0.30.12",
- "pathe": "^1.1.2"
+ "@babel/runtime": "^7.28.6",
+ "@mui/types": "^7.4.12",
+ "@types/prop-types": "^15.7.15",
+ "clsx": "^2.1.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^19.2.3"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
- "version": "2.1.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyrainbow": "^1.2.0"
+ "engines": {
+ "node": ">=14.0.0"
},
"funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/spy": {
- "version": "2.1.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tinyspy": "^3.0.2"
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@vitest/utils": {
- "version": "2.1.8",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/pretty-format": "2.1.8",
- "loupe": "^3.1.2",
- "tinyrainbow": "^1.2.0"
+ "peerDependencies": {
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
- "funding": {
- "url": "https://opencollective.com/vitest"
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
- "version": "2.1.8",
- "dev": true,
+ "node_modules/@wso2/oxygen-ui/node_modules/@mui/x-tree-view/node_modules/@mui/x-internals": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.14.0.tgz",
+ "integrity": "sha512-esYyl61nuuFXiN631TWuPh2tqdoyTdBI/4UXgwH3rytF8jiWvy6prPBPRHEH1nvW3fgw9FoBI48FlOO+yEI8xg==",
"license": "MIT",
"dependencies": {
- "tinyrainbow": "^1.2.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
- "node_modules/@wso2/oxygen-ui": {
- "version": "0.11.0",
- "license": "Apache-2.0",
- "dependencies": {
- "@emotion/react": "11.14.0",
- "@emotion/styled": "11.14.1",
- "@fontsource-variable/inter": "5.2.8",
- "@mui/material": "7.3.4",
- "@mui/utils": "9.0.0",
- "@mui/x-data-grid": "8.16.0",
- "@mui/x-date-pickers": "8.16.0",
- "@mui/x-tree-view": "8.14.0",
- "date-fns": "4.1.0",
- "prismjs": "^1.30.0"
+ "@babel/runtime": "^7.28.4",
+ "@mui/utils": "^7.3.3",
+ "reselect": "^5.1.1",
+ "use-sync-external-store": "^1.6.0"
},
- "bin": {
- "oxygen-ui": "bin/cli.js"
+ "engines": {
+ "node": ">=14.0.0"
},
- "peerDependencies": {
- "@wso2/oxygen-ui-icons-react": ">=0.1.0",
- "react": "19.2.3",
- "react-dom": "19.2.3"
- }
- },
- "node_modules/@wso2/oxygen-ui-icons-react": {
- "version": "0.11.0",
- "license": "Apache-2.0",
- "dependencies": {
- "lucide-react": "1.16.0"
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
- "react": "19.2.3"
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/accepts": {
@@ -4006,6 +4658,8 @@
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
@@ -4373,6 +5027,8 @@
},
"node_modules/clsx": {
"version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -4577,6 +5233,8 @@
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"dependencies": {
"@types/parse-json": "^4.0.0",
@@ -4642,6 +5300,8 @@
},
"node_modules/date-fns": {
"version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -4740,6 +5400,8 @@
},
"node_modules/dom-helpers": {
"version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
@@ -4807,6 +5469,8 @@
},
"node_modules/error-ex": {
"version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
+ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
@@ -4975,53 +5639,82 @@
}
}
},
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
"node_modules/eslint-plugin-formatjs": {
- "version": "6.5.0",
- "resolved": "https://registry.npmjs.org/eslint-plugin-formatjs/-/eslint-plugin-formatjs-6.5.0.tgz",
- "integrity": "sha512-0vO54Z55gcFkky7EzUk6LlDHz8d0KgF7CEz5kPl024wAGO9XPQJhbDfZb7qt2isKH+muTPo9qbmZaAt4/7XXpA==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-formatjs/-/eslint-plugin-formatjs-6.4.0.tgz",
+ "integrity": "sha512-IfSchytfKn5PhEmsd79/c44e5ZoXbiteD9/AmBgNNtyMmJ+wBWby3jotF3pSZoz3PHoggmhdEh3K3wxYuYqQng==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@formatjs/icu-messageformat-parser": "3.5.17",
- "@formatjs/ts-transformer": "4.4.19",
- "@types/estree-jsx": "1",
- "@types/picomatch": "4",
- "@unicode/unicode-17.0.0": "1",
- "magic-string": "1",
- "picomatch": "2 || 3 || 4"
+ "@formatjs/icu-messageformat-parser": "3.5.1",
+ "@formatjs/ts-transformer": "4.4.0",
+ "@types/picomatch": "^4.0.0",
+ "@unicode/unicode-17.0.0": "^1.6.16",
+ "magic-string": "^0.30.0",
+ "picomatch": "2 || 3 || 4",
+ "tslib": "^2.8.1"
},
"peerDependencies": {
"eslint": "9 || 10"
}
},
"node_modules/eslint-plugin-formatjs/node_modules/@formatjs/icu-messageformat-parser": {
- "version": "3.5.17",
- "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.17.tgz",
- "integrity": "sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==",
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz",
+ "integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@formatjs/icu-skeleton-parser": "2.1.11"
+ "@formatjs/ecma402-abstract": "3.1.1",
+ "@formatjs/icu-skeleton-parser": "2.1.1",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/eslint-plugin-formatjs/node_modules/@formatjs/icu-skeleton-parser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz",
+ "integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@formatjs/ecma402-abstract": "3.1.1",
+ "tslib": "^2.8.1"
}
},
"node_modules/eslint-plugin-formatjs/node_modules/@formatjs/ts-transformer": {
- "version": "4.4.19",
- "resolved": "https://registry.npmjs.org/@formatjs/ts-transformer/-/ts-transformer-4.4.19.tgz",
- "integrity": "sha512-TWxL1btx29333yEQc1/eJsU9SUjDJBruzkrR4TVXBLgPaCWbhxg7haOztGXTLgLx+X3ONat7F0kRzcCHksyJyA==",
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@formatjs/ts-transformer/-/ts-transformer-4.4.0.tgz",
+ "integrity": "sha512-lFDp9Rbpxk5Dt8O1/I9VG5btqKbOkjT4snSa73HO1YTJ9KGeXPKA7aWVgHFXJVVq0KluhbZiCoPJVHC4ZREgxw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@formatjs/icu-messageformat-parser": "3.5.17",
- "@types/babel__core": "7",
- "@types/node": "22 || 24",
- "json-stable-stringify": "1",
- "typescript": "^5.6 || 6 || 7"
+ "@formatjs/icu-messageformat-parser": "3.5.1",
+ "@types/node": "^22.19.5",
+ "json-stable-stringify": "^1.3.0",
+ "tslib": "^2.8.1",
+ "typescript": "^5.6.0"
},
"engines": {
"node": ">= 20.12.0"
},
"peerDependencies": {
- "ts-jest": "29"
+ "ts-jest": "^29"
},
"peerDependenciesMeta": {
"ts-jest": {
@@ -5030,29 +5723,26 @@
}
},
"node_modules/eslint-plugin-formatjs/node_modules/@types/node": {
- "version": "24.13.3",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
- "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "undici-types": "~7.18.0"
+ "undici-types": "~6.21.0"
}
},
- "node_modules/eslint-plugin-formatjs/node_modules/magic-string": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.0.tgz",
- "integrity": "sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==",
+ "node_modules/eslint-plugin-formatjs/node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
+ "license": "0BSD"
},
"node_modules/eslint-plugin-formatjs/node_modules/undici-types": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
- "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -5409,6 +6099,8 @@
},
"node_modules/find-root": {
"version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
"node_modules/find-up": {
@@ -5739,6 +6431,8 @@
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
@@ -5746,6 +6440,8 @@
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hono": {
@@ -5937,19 +6633,19 @@
}
},
"node_modules/intl-messageformat": {
- "version": "11.2.14",
- "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.14.tgz",
- "integrity": "sha512-9f2VD1HFuxUvMw0RxsaP8WmMns6JRTnsNB/zghTFrp11ZktiXWwVDeZBPQchBKEmo+Gx/ZhxI7Qht7YglFD4PA==",
+ "version": "11.2.13",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.13.tgz",
+ "integrity": "sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==",
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.7",
- "@formatjs/icu-messageformat-parser": "3.5.17"
+ "@formatjs/icu-messageformat-parser": "3.5.16"
}
},
"node_modules/intl-messageformat/node_modules/@formatjs/icu-messageformat-parser": {
- "version": "3.5.17",
- "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.17.tgz",
- "integrity": "sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==",
+ "version": "3.5.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz",
+ "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.11"
@@ -5973,10 +6669,14 @@
},
"node_modules/is-arrayish": {
"version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-core-module": {
"version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
@@ -6215,6 +6915,8 @@
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema-traverse": {
@@ -6306,6 +7008,8 @@
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/locate-path": {
@@ -6349,6 +7053,8 @@
},
"node_modules/loose-envify": {
"version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -6372,6 +7078,8 @@
},
"node_modules/lucide-react": {
"version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
+ "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -6965,6 +7673,8 @@
},
"node_modules/parse-json": {
"version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
@@ -7024,6 +7734,8 @@
},
"node_modules/path-parse": {
"version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-scurry": {
@@ -7053,6 +7765,8 @@
},
"node_modules/path-type": {
"version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"engines": {
"node": ">=8"
@@ -7137,6 +7851,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"dev": true,
@@ -7168,6 +7898,8 @@
},
"node_modules/prismjs": {
"version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
+ "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -7175,6 +7907,8 @@
},
"node_modules/prop-types": {
"version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -7184,6 +7918,8 @@
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
@@ -7307,14 +8043,14 @@
}
},
"node_modules/react-intl": {
- "version": "10.1.22",
- "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-10.1.22.tgz",
- "integrity": "sha512-6PxlTKapYpv/Kl+yYEH06J3T5T7gUVx6STjrWJedeITOAkGEn6sBvb6r0qtjg2pK7PZsom7gXKUFKwNykLoLSQ==",
+ "version": "10.1.20",
+ "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-10.1.20.tgz",
+ "integrity": "sha512-dvD9F7CqjlB/cIWakFzScGVPDmhWk34cFWY0etWuVNc07Zmc56OKjQwqu9q74evbttSto5wFXchZtvdRXy/Tkg==",
"license": "BSD-3-Clause",
"dependencies": {
- "@formatjs/icu-messageformat-parser": "3.5.17",
- "@formatjs/intl": "4.1.19",
- "intl-messageformat": "11.2.14"
+ "@formatjs/icu-messageformat-parser": "3.5.16",
+ "@formatjs/intl": "4.1.18",
+ "intl-messageformat": "11.2.13"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
@@ -7322,9 +8058,9 @@
}
},
"node_modules/react-intl/node_modules/@formatjs/icu-messageformat-parser": {
- "version": "3.5.17",
- "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.17.tgz",
- "integrity": "sha512-cN9jhVqT7u0K9tix43fhjoUwL0nazyW6zsNIXs2QdPADr+nurPfYyssUiMqcSCGlPcCiqnYVxgSn7zBSuI+5Bg==",
+ "version": "3.5.16",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz",
+ "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==",
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.11"
@@ -7332,6 +8068,8 @@
},
"node_modules/react-is": {
"version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
+ "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
@@ -7389,6 +8127,8 @@
},
"node_modules/react-transition-group": {
"version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
@@ -7454,10 +8194,14 @@
},
"node_modules/reselect": {
"version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
+ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7845,6 +8589,8 @@
},
"node_modules/source-map": {
"version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -7967,6 +8713,8 @@
},
"node_modules/stylis": {
"version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supports-color": {
@@ -7982,6 +8730,8 @@
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -8376,6 +9126,8 @@
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -8730,6 +9482,8 @@
},
"node_modules/yaml": {
"version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"engines": {
"node": ">= 6"
diff --git a/portals/api-control-plane/package.json b/portals/api-control-plane/package.json
index e1f2c6cd62..23a87f43ab 100644
--- a/portals/api-control-plane/package.json
+++ b/portals/api-control-plane/package.json
@@ -34,23 +34,30 @@
"i18n:pseudo": "formatjs compile --ast --pseudo-locale en-XA src/i18n/messages/en.json --out-file src/i18n/compiled/en-XA.json",
"i18n:build": "npm run i18n:compile && npm run i18n:pseudo",
"i18n": "npm run i18n:extract && npm run i18n:build",
- "i18n:check": "npm run i18n && git diff --exit-code src/i18n/messages"
+ "i18n:check": "npm run i18n && git diff --exit-code src/i18n/messages",
+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
+ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
"@babel/runtime-corejs3": "7.29.7",
+ "@emotion/react": "11.14.0",
+ "@emotion/styled": "11.14.1",
+ "@mui/material": "9.3.1",
+ "@mui/x-data-grid": "9.11.0",
+ "@mui/x-date-pickers": "9.11.0",
"@tanstack/react-query": "5.101.0",
- "@wso2/oxygen-ui": "0.11.0",
- "@wso2/oxygen-ui-icons-react": "0.11.0",
+ "@wso2/oxygen-ui": "0.13.1",
+ "@wso2/oxygen-ui-icons-react": "0.13.1",
"axios": "1.19.0",
"js-yaml": "4.1.0",
"react": "19.2.3",
"react-dom": "19.2.3",
- "react-intl": "^10.1.20",
+ "react-intl": "10.1.20",
"react-router-dom": "7.9.4"
},
"devDependencies": {
"@eslint/js": "9.26.0",
- "@formatjs/cli": "^6.16.18",
+ "@formatjs/cli": "6.16.18",
"@openapitools/openapi-generator-cli": "2.5.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.3",
@@ -63,14 +70,16 @@
"@vitejs/plugin-basic-ssl": "1.1.0",
"@vitejs/plugin-react": "4.2.1",
"@vitest/coverage-v8": "2.1.8",
- "babel-plugin-formatjs": "^11.3.16",
+ "babel-plugin-formatjs": "11.3.16",
"eslint": "9.26.0",
- "eslint-plugin-formatjs": "^6.4.0",
+ "eslint-config-prettier": "10.1.8",
+ "eslint-plugin-formatjs": "6.4.0",
"eslint-plugin-react-hooks": "5.2.0",
"globals": "15.15.0",
"jsdom": "25.0.1",
"msw": "2.6.8",
"openapi-typescript": "7.13.0",
+ "prettier": "3.9.6",
"typescript": "5.8.3",
"typescript-eslint": "8.32.1",
"vite": "5.0.11",
diff --git a/portals/api-control-plane/src/App.smoke.test.tsx b/portals/api-control-plane/src/App.smoke.test.tsx
index 8eff14c1b3..27dd0c322d 100644
--- a/portals/api-control-plane/src/App.smoke.test.tsx
+++ b/portals/api-control-plane/src/App.smoke.test.tsx
@@ -19,7 +19,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AppRoutes } from './routes/AppRoutes';
+import { anOrganization, aProject, collection, resource } from './test/msw';
import { authStatePresets } from './test/mockAuthState';
+import { server } from './test/server';
import { renderWithProviders, screen } from './test/utils';
// Smoke test: render the REAL route tree (ProtectedRoute → ConsoleScopeProvider
@@ -27,10 +29,26 @@ import { renderWithProviders, screen } from './test/utils';
// mode so the scope queries resolve from fixtures with no network. This is a
// thin guardrail that catches provider-wiring regressions, not deep assertions.
describe('App smoke (mock mode, authenticated)', () => {
- beforeEach(() => vi.stubEnv('VITE_USE_MOCK_API', 'true'));
+ // The scope and page hooks always go to the real transport —
+ // `VITE_USE_MOCK_API` only governs the legacy client — so the organization
+ // endpoints the shell resolves its scope from are stubbed at the network
+ // layer for every test here.
+ const org = anOrganization({
+ id: 'api-platform-demo',
+ displayName: 'API Platform Demo',
+ });
+
+ beforeEach(() => {
+ vi.stubEnv('VITE_USE_MOCK_API', 'true');
+ server.use(
+ collection('/organizations', [org]),
+ resource('/organizations/:organizationId', org)
+ );
+ });
afterEach(() => vi.unstubAllEnvs());
it('renders the org home through the full app shell', async () => {
+ server.use(collection('/projects', []));
renderWithProviders(, {
route: '/organizations/api-platform-demo/home',
authState: authStatePresets.authenticated(),
@@ -45,6 +63,12 @@ describe('App smoke (mock mode, authenticated)', () => {
});
it('navigates to the projects list and renders project cards', async () => {
+ server.use(
+ collection('/projects', [
+ aProject({ id: 'retail', displayName: 'Retail APIs' }),
+ ]),
+ collection('/rest-apis', [])
+ );
renderWithProviders(, {
route: '/organizations/api-platform-demo/projects',
authState: authStatePresets.authenticated(),
diff --git a/portals/api-control-plane/src/App.tsx b/portals/api-control-plane/src/App.tsx
index 03b5c0cafa..27a4035ff0 100644
--- a/portals/api-control-plane/src/App.tsx
+++ b/portals/api-control-plane/src/App.tsx
@@ -18,16 +18,16 @@
import { type ReactNode, useState } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
-import { OxygenUIThemeProvider, OxygenTheme } from '@wso2/oxygen-ui';
+import { AcrylicOrangeTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui';
import { BrowserRouter } from 'react-router-dom';
import { ApiClientProvider } from './api/ApiClientProvider';
import { createQueryClient } from './api/core/queryClient';
-import { ErrorBoundary } from './components/ErrorBoundary';
+import { ErrorBoundary } from './components/errors/ErrorBoundary';
import { NotificationProvider, useNotifications } from './components/Notifications';
import { runtimeConfig } from './config/runtime';
-import { AuthProvider } from './features/auth/AuthProvider';
-import { ProductActivation } from './features/billing/ProductActivation';
+import { AuthProvider } from './contexts/auth/AuthProvider';
+import { ProductActivation } from './hooks/ProductActivation';
import { AppRoutes } from './routes/AppRoutes';
import {
ExtensionsProvider,
@@ -35,6 +35,21 @@ import {
} from './extensions';
import { I18nProvider } from './i18n';
+/**
+ * The themes `OxygenUIThemeProvider` can switch between — currently the one
+ * library-defined `AcrylicOrangeTheme`.
+ *
+ * Module scope, not an inline `themes={[...]}` literal on the provider below:
+ * the provider keys a theme-resolving effect on this array's identity, so a
+ * fresh literal each render would re-resolve the registry every time.
+ */
+const themeRegistry = [
+ { key: 'acrylicOrange', label: 'Acrylic Orange', theme: AcrylicOrangeTheme },
+];
+
+/** Selected on first load, before any stored preference takes over. */
+const INITIAL_THEME = themeRegistry[0].key;
+
/**
* Builds the app's QueryClient with the notification handler already attached,
* which is why it lives below `NotificationProvider` rather than at module
@@ -69,7 +84,7 @@ export default function App({ extensions = [] }: AppProps) {
return (
-
+
diff --git a/portals/api-control-plane/src/api/core/http.test.ts b/portals/api-control-plane/src/api/core/http.test.ts
index 368e563ae6..d44a3acd53 100644
--- a/portals/api-control-plane/src/api/core/http.test.ts
+++ b/portals/api-control-plane/src/api/core/http.test.ts
@@ -24,7 +24,7 @@ import {
} from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { CSRF_HEADER, CSRF_HEADER_VALUE } from '../../features/auth/authConstants';
+import { CSRF_HEADER, CSRF_HEADER_VALUE } from '../../contexts/auth/authConstants';
import { server } from '../../test/server';
import { ApiError, ClientErrorCode, ErrorCode } from './errors';
import {
diff --git a/portals/api-control-plane/src/api/core/http.ts b/portals/api-control-plane/src/api/core/http.ts
index f9fc71327d..48749dd115 100644
--- a/portals/api-control-plane/src/api/core/http.ts
+++ b/portals/api-control-plane/src/api/core/http.ts
@@ -28,7 +28,7 @@ import { runtimeConfig } from '../../config/runtime';
import {
CSRF_HEADER,
CSRF_HEADER_VALUE,
-} from '../../features/auth/authConstants';
+} from '../../contexts/auth/authConstants';
import { ApiErrorKind, platformErrorFromBody, platformErrorFromTransport } from './errors';
import { notifySessionExpired } from './sessionEvents';
diff --git a/portals/api-control-plane/src/api/mocks/data.ts b/portals/api-control-plane/src/api/mocks/data.ts
index 8e681eff29..0a749a3b56 100644
--- a/portals/api-control-plane/src/api/mocks/data.ts
+++ b/portals/api-control-plane/src/api/mocks/data.ts
@@ -22,49 +22,33 @@ import type {
Deployment,
Environment,
Gateway,
- Organization,
- Project,
} from '../../types/domain';
+import type { Organization } from "../../api/resources/organizations";
+import type { Project } from "../../api/resources/projects";
+
export const organizations: Organization[] = [
{
id: 'org-1',
- uuid: '783c6c4d-8b9b-4190-b70a-e717ab1ee739',
- name: 'API Platform Demo',
- handle: 'api-platform-demo',
- description: 'Demo organization for API Platform development',
- status: 'ACTIVE',
+ displayName: 'API Platform Demo',
+ region: 'us-east-1',
},
];
export const projects: Project[] = [
{
id: 'project-1',
- orgId: 'org-1',
- name: 'Retail APIs',
- handler: 'retail-apis',
+ organizationId: 'org-1',
+ displayName: 'Retail APIs',
description: 'Core APIs for retail services',
- region: 'us-east-1',
- version: '1.0.0',
- createdDate: '2026-05-20T08:00:00.000Z',
updatedAt: '2026-06-01T08:00:00.000Z',
- type: 'MULTI_REPO',
- gitProvider: 'github',
- repository: 'api-platform-demo/retail-apis',
},
{
id: 'project-2',
- orgId: 'org-1',
- name: 'Internal Tools',
- handler: 'internal-tools',
+ organizationId: 'org-1',
+ displayName : 'Internal Tools',
description: 'Operations and internal integration services',
- region: 'us-east-1',
- version: '1.0.0',
- createdDate: '2026-05-28T08:00:00.000Z',
updatedAt: '2026-06-10T08:00:00.000Z',
- type: 'MONO_REPO',
- gitProvider: 'github',
- repository: 'api-platform-demo/internal-tools',
},
];
diff --git a/portals/api-control-plane/src/api/mocks/handlers.ts b/portals/api-control-plane/src/api/mocks/handlers.ts
index a774a76efa..736a5e59d7 100644
--- a/portals/api-control-plane/src/api/mocks/handlers.ts
+++ b/portals/api-control-plane/src/api/mocks/handlers.ts
@@ -52,12 +52,12 @@ export const handlers = [
graphql.query('OxygenProjects', ({ variables }) => {
const projectVariables = variables as ProjectVariables;
const organization = organizations.find(
- (item) => item.handle === projectVariables.orgHandle
+ (item) => item.id === projectVariables.orgHandle
);
return HttpResponse.json({
data: {
projects: projects.filter(
- (project) => project.orgId === organization?.id
+ (project) => project.organizationId === organization?.id
),
},
});
diff --git a/portals/api-control-plane/src/api/organizations/organizationClient.ts b/portals/api-control-plane/src/api/organizations/organizationClient.ts
index dcb9c0c396..fa5fbb1e26 100644
--- a/portals/api-control-plane/src/api/organizations/organizationClient.ts
+++ b/portals/api-control-plane/src/api/organizations/organizationClient.ts
@@ -17,7 +17,7 @@
*/
import { runtimeConfig } from '../../config/runtime';
-import type { AuthUser } from '../../features/auth/authTypes';
+import type { AuthUser } from '../../contexts/auth/authTypes';
import type { Organization } from '../../types/domain';
import { toOrganization } from '../adapters';
import { getJson } from '../client';
diff --git a/portals/api-control-plane/src/api/platform/platformClient.test.ts b/portals/api-control-plane/src/api/platform/platformClient.test.ts
index e91bef3768..7b71b25a7e 100644
--- a/portals/api-control-plane/src/api/platform/platformClient.test.ts
+++ b/portals/api-control-plane/src/api/platform/platformClient.test.ts
@@ -19,7 +19,7 @@
import { http, HttpResponse } from 'msw';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { CSRF_HEADER, CSRF_HEADER_VALUE } from '../../features/auth/authConstants';
+import { CSRF_HEADER, CSRF_HEADER_VALUE } from '../../contexts/auth/authConstants';
import { server } from '../../test/server';
const BASE = 'http://platform.test';
diff --git a/portals/api-control-plane/src/api/platform/platformClient.ts b/portals/api-control-plane/src/api/platform/platformClient.ts
index 9bee2df51b..fe73384a36 100644
--- a/portals/api-control-plane/src/api/platform/platformClient.ts
+++ b/portals/api-control-plane/src/api/platform/platformClient.ts
@@ -17,7 +17,7 @@
*/
import { runtimeConfig } from '../../config/runtime';
-import { CSRF_HEADER, CSRF_HEADER_VALUE } from '../../features/auth/authConstants';
+import { CSRF_HEADER, CSRF_HEADER_VALUE } from '../../contexts/auth/authConstants';
import { ApiError, type ApiErrorCode } from '../types/errors';
/** True when read flows should go to platform-api REST (via BML). */
diff --git a/portals/api-control-plane/src/api/projects/projectClient.ts b/portals/api-control-plane/src/api/projects/projectClient.ts
index 4f1856e308..39fe3166d0 100644
--- a/portals/api-control-plane/src/api/projects/projectClient.ts
+++ b/portals/api-control-plane/src/api/projects/projectClient.ts
@@ -48,9 +48,9 @@ const findOrganization = async (orgHandle: string) => {
export async function listProjects(orgHandle: string): Promise {
if (useMockApi()) {
await delay();
- const org = organizations.find((item) => item.handle === orgHandle);
+ const org = organizations.find((item) => item.id === orgHandle);
return projects
- .filter((project) => project.orgId === org?.id)
+ .filter((project) => project.organizationId === org?.id)
.map(toProject);
}
@@ -141,8 +141,8 @@ export async function createProject(
if (useMockApi()) {
await delay();
- const org = organizations.find((item) => item.handle === orgHandle);
- if (projects.some((p) => p.orgId === org?.id && p.name === name)) {
+ const org = organizations.find((item) => item.id === orgHandle);
+ if (projects.some((p) => p.organizationId === org?.id && p.displayName === name)) {
throw new ApiError(
'Project already exists in organization',
'CONFLICT',
@@ -160,7 +160,11 @@ export async function createProject(
createdDate: now,
updatedAt: now,
};
- projects.push(project);
+ projects.push({
+ ...project,
+ organizationId: org?.id || '',
+ displayName: name,
+ });
return toProject(project);
}
@@ -194,8 +198,8 @@ export async function deleteProject(
): Promise {
if (useMockApi()) {
await delay();
- const org = organizations.find((item) => item.handle === orgHandle);
- const orgProjects = projects.filter((p) => p.orgId === org?.id);
+ const org = organizations.find((item) => item.id === orgHandle);
+ const orgProjects = projects.filter((p) => p.organizationId === org?.id);
// Mirror the platform-api delete guards (last project / has APIs) so the
// mock surfaces the same blocking errors the real backend returns.
if (orgProjects.length <= 1) {
diff --git a/portals/api-control-plane/src/api/resources/apiKeys/index.ts b/portals/api-control-plane/src/api/resources/apiKeys/index.ts
new file mode 100644
index 0000000000..e1bbfc9bc6
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/apiKeys/index.ts
@@ -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.
+ */
+
+/**
+ * Public surface of the API keys resource module.
+ * Import from `api/resources/apiKeys` only; deeper imports skip scope binding and gating.
+ * @see ./apiKeys.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ ApiKeyArtifactType,
+ CreateApiKeyBody,
+ CreateApiKeyResponse,
+ ListMyApiKeysQuery,
+ UpdateApiKeyBody,
+ UpdateApiKeyResponse,
+ UserApiKeyListResponse,
+} from './apiKeys.endpoints';
+
+export type { ApiKeyListFilters } from './apiKeys.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useCreateApiKey,
+ useMyApiKeys,
+ useRevokeApiKey,
+ useUpdateApiKey,
+} from './apiKeys.hooks';
diff --git a/portals/api-control-plane/src/api/resources/applications/index.ts b/portals/api-control-plane/src/api/resources/applications/index.ts
new file mode 100644
index 0000000000..41262becde
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/applications/index.ts
@@ -0,0 +1,61 @@
+/*
+ * 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.
+ */
+
+/**
+ * Public surface of the REST APIs resource module.
+ * Import from `api/resources/restApis` only; deeper imports skip scope binding and gating.
+ * @see ./restApis.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ AddApplicationApiKeysBody,
+ AddApplicationAssociationsBody,
+ Application,
+ ApplicationAssociationListResponse,
+ ApplicationListResponse,
+ CreateApplicationBody,
+ ListApplicationApiKeysQuery,
+ ListApplicationAssociationsQuery,
+ ListApplicationsQuery,
+ ListAssociationApiKeysQuery,
+ MappedApiKeyListResponse,
+ RemoveApplicationApiKeyQuery,
+ UpdateApplicationBody,
+} from './applications.endpoints';
+
+export type { ApplicationListFilters } from './applications.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useAddApplicationApiKeys,
+ useAddApplicationAssociations,
+ useApplication,
+ useApplicationApiKeys,
+ useApplicationAssociations,
+ useApplicationOptions,
+ useApplications,
+ useAssociationApiKeys,
+ useCreateApplication,
+ useDeleteApplication,
+ useRemoveApplicationApiKey,
+ useRemoveApplicationAssociation,
+ useUpdateApplication,
+} from './applications.hooks';
diff --git a/portals/api-control-plane/src/api/resources/gatewayCustomPolicies/index.ts b/portals/api-control-plane/src/api/resources/gatewayCustomPolicies/index.ts
new file mode 100644
index 0000000000..90443169c4
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/gatewayCustomPolicies/index.ts
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+/**
+ * Public surface of the gateway custom policies resource module.
+ * Import from `api/resources/gatewayCustomPolicies` only; deeper imports skip scope binding and gating.
+ * @see ./gatewayCustomPolicies.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CustomPolicy,
+ CustomPolicyListResponse,
+ ListCustomPoliciesQuery,
+ SyncCustomPolicyQuery,
+} from './gatewayCustomPolicies.endpoints';
+
+export type { CustomPolicyListFilters } from './gatewayCustomPolicies.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useDeleteGatewayCustomPolicy,
+ useGatewayCustomPolicies,
+ useGatewayCustomPolicy,
+ useGatewayCustomPolicyOptions,
+ useSyncCustomPolicy,
+} from './gatewayCustomPolicies.hooks';
diff --git a/portals/api-control-plane/src/api/resources/gateways/index.ts b/portals/api-control-plane/src/api/resources/gateways/index.ts
new file mode 100644
index 0000000000..0cbdef6542
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/gateways/index.ts
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+/**
+ * Public surface of the gateways resource module.
+ * Import from `api/resources/gateways` only; deeper imports skip scope binding and gating.
+ * @see ./gateways.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CreateGatewayBody,
+ Gateway,
+ GatewayListResponse,
+ GatewayManifest,
+ GatewayTokenListResponse,
+ ListGatewaysQuery,
+ ListGatewayTokensQuery,
+ TokenRotationResponse,
+ UpdateGatewayBody,
+} from './gateways.endpoints';
diff --git a/portals/api-control-plane/src/api/resources/organizations/index.ts b/portals/api-control-plane/src/api/resources/organizations/index.ts
new file mode 100644
index 0000000000..96bc6c5a83
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/organizations/index.ts
@@ -0,0 +1,43 @@
+/*
+ * 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.
+ */
+
+/**
+ * Public surface of the organizations resource module.
+ * Import from `api/resources/organizations` only; deeper imports skip scope binding and gating.
+ * @see ./organizations.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ ListOrganizationsQuery,
+ Organization,
+ OrganizationListResponse,
+ RegisterOrganizationBody,
+} from './organizations.endpoints';
+
+export type { OrganizationListFilters } from './organizations.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useOrganization,
+ useOrganizationOptions,
+ useOrganizations,
+ useRegisterOrganization,
+} from './organizations.hooks';
diff --git a/portals/api-control-plane/src/api/resources/projects/index.ts b/portals/api-control-plane/src/api/resources/projects/index.ts
new file mode 100644
index 0000000000..062ec4b7b4
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/projects/index.ts
@@ -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.
+ */
+
+/**
+ * Public surface of the projects resource module.
+ * Import from `api/resources/projects` only; deeper imports skip scope binding and gating.
+ * @see ./projects.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CreateProjectBody,
+ ListProjectsQuery,
+ Project,
+ ProjectListResponse,
+ UpdateProjectBody,
+} from './projects.endpoints';
+
+export type { ProjectListFilters } from './projects.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useCreateProject,
+ useDeleteProject,
+ useProject,
+ useProjectOptions,
+ useProjects,
+ useUpdateProject,
+} from './projects.hooks';
diff --git a/portals/api-control-plane/src/api/resources/projects/projects.hooks.ts b/portals/api-control-plane/src/api/resources/projects/projects.hooks.ts
index 5f98b365a3..97b74e556e 100644
--- a/portals/api-control-plane/src/api/resources/projects/projects.hooks.ts
+++ b/portals/api-control-plane/src/api/resources/projects/projects.hooks.ts
@@ -16,7 +16,12 @@
* under the License.
*/
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query';
import type { ApiError } from '../../core/errors';
import { requireOrgScope, useApiScope } from '../../core/scope';
@@ -54,6 +59,12 @@ export type ProjectListFilters = ListProjectsQuery;
* The query does not run until the organization is known, so this never fires a
* request the server would reject — and, critically, never writes into a cache
* entry keyed by an empty scope.
+ *
+ * `keepPreviousData` is the one pagination-specific exception the query client
+ * deliberately leaves to individual list queries: paging or searching changes
+ * the key, and without it the grid would unmount into a loading state on every
+ * page change. The previous page stays on screen (flagged by
+ * `isPlaceholderData`) until the next one arrives.
*/
export const useProjects = (
filters: ProjectListFilters = {},
@@ -64,6 +75,7 @@ export const useProjects = (
return useQuery({
...projectQueries.list(org!, filters),
enabled: Boolean(org),
+ placeholderData: keepPreviousData,
});
};
diff --git a/portals/api-control-plane/src/api/resources/restApis/index.ts b/portals/api-control-plane/src/api/resources/restApis/index.ts
new file mode 100644
index 0000000000..1775435775
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/restApis/index.ts
@@ -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.
+ */
+
+/**
+ * Public surface of the REST APIs resource module.
+ * Import from `api/resources/restApis` only; deeper imports skip scope binding and gating.
+ * @see ./restApis.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CreateRestApiBody,
+ ListRestApisQuery,
+ RestApi,
+ RestApiListResponse,
+ UpdateRestApiBody,
+} from './restApis.endpoints';
+
+export type { RestApiListFilters } from './restApis.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useCreateRestApi,
+ useDeleteRestApi,
+ useRestApi,
+ useRestApiOptions,
+ useRestApis,
+ useUpdateRestApi,
+} from './restApis.hooks';
diff --git a/portals/api-control-plane/src/api/resources/secrets/index.ts b/portals/api-control-plane/src/api/resources/secrets/index.ts
new file mode 100644
index 0000000000..659f6c4c31
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/secrets/index.ts
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+
+/**
+ * Public surface of the secrets resource module.
+ * Import from `api/resources/secrets` only; deeper imports skip scope binding and gating.
+ * @see ./secrets.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CreateSecretBody,
+ ListSecretsQuery,
+ RotateSecretBody,
+ SecretListResponse,
+ SecretResponse,
+ SecretSummary,
+} from './secrets.endpoints';
+
+export type { SecretListFilters } from './secrets.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useCreateSecret,
+ useDeleteSecret,
+ useRotateSecret,
+ useSecret,
+ useSecretOptions,
+ useSecrets,
+} from './secrets.hooks';
+
+// ─── Error predicates ───────────────────────────────────────────────────────
+
+/** Narrows a delete failure to "the secret is still referenced". */
+export { isSecretInUse } from './secrets.hooks';
diff --git a/portals/api-control-plane/src/api/resources/subscriptionPlans/index.ts b/portals/api-control-plane/src/api/resources/subscriptionPlans/index.ts
new file mode 100644
index 0000000000..cf9a40a114
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/subscriptionPlans/index.ts
@@ -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.
+ */
+
+/**
+ * Public surface of the subscription plans resource module.
+ * Import from `api/resources/subscriptionPlans` only; deeper imports skip scope binding and gating.
+ * @see ./subscriptionPlans.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CreateSubscriptionPlanBody,
+ ListSubscriptionPlansQuery,
+ SubscriptionPlan,
+ SubscriptionPlanListResponse,
+ UpdateSubscriptionPlanBody,
+} from './subscriptionPlans.endpoints';
+
+export type { SubscriptionPlanListFilters } from './subscriptionPlans.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useCreateSubscriptionPlan,
+ useDeleteSubscriptionPlan,
+ useSubscriptionPlan,
+ useSubscriptionPlanOptions,
+ useSubscriptionPlans,
+ useUpdateSubscriptionPlan,
+} from './subscriptionPlans.hooks';
diff --git a/portals/api-control-plane/src/api/resources/subscriptions/index.ts b/portals/api-control-plane/src/api/resources/subscriptions/index.ts
new file mode 100644
index 0000000000..d0fa6e4472
--- /dev/null
+++ b/portals/api-control-plane/src/api/resources/subscriptions/index.ts
@@ -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.
+ */
+
+/**
+ * Public surface of the REST APIs resource module.
+ * Import from `api/resources/restApis` only; deeper imports skip scope binding and gating.
+ * @see ./restApis.hooks.ts for the hook contract.
+ */
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export type {
+ CreateSubscriptionBody,
+ ListSubscriptionsQuery,
+ SubscriberQuery,
+ Subscription,
+ SubscriptionListResponse,
+ UpdateSubscriptionBody,
+} from './subscriptions.endpoints';
+
+export type { SubscriptionListFilters } from './subscriptions.hooks';
+
+// ─── Hooks ──────────────────────────────────────────────────────────────────
+
+export {
+ useCreateSubscription,
+ useDeleteSubscription,
+ useSubscription,
+ useSubscriptions,
+ useUpdateSubscription,
+} from './subscriptions.hooks';
diff --git a/portals/api-control-plane/src/components/ComingSoon.tsx b/portals/api-control-plane/src/components/ComingSoon.tsx
new file mode 100644
index 0000000000..429e8e1e1b
--- /dev/null
+++ b/portals/api-control-plane/src/components/ComingSoon.tsx
@@ -0,0 +1,140 @@
+/*
+ * 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 { Box, Button, ColorSchemeSVG, Stack, Typography } from '@wso2/oxygen-ui';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+/**
+ * The roadworks barricade, drawn through Oxygen's `ColorSchemeSVG` so its fills
+ * and strokes resolve to theme colours rather than hard-coded hex — the console
+ * has a light/dark toggle, and a fixed-colour illustration would go muddy in
+ * dark mode.
+ */
+function BarricadeIllustration() {
+ return (
+
+ {/* Soft blob behind the barricade. */}
+
+
+ {/* Two angled boards, each with its own diagonal stripes. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Legs and the ground line they stand on. */}
+
+
+
+
+
+ {/* Warning lamps, one at each end. */}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export type ComingSoonProps = {
+ /**
+ * The feature's own name, interpolated into the message — pass a
+ * `` so the page keeps owning its own translation.
+ */
+ feature: ReactNode;
+ /** An extra line under the first, e.g. where to go in the meantime. */
+ detail?: ReactNode;
+ /** Somewhere to go instead. Omitted when there is no alternative yet. */
+ action?: { label: ReactNode; to: string };
+};
+
+/**
+ * Placeholder for a page whose feature is not built yet.
+ *
+ * Distinct from `EmptyState` in `StateViews`: that one says "nothing here yet,
+ * add something", which invites an action the user *can* take. This says "not
+ * here yet at all", so it never implies the page will fill up on its own.
+ */
+export function ComingSoon({ action, detail, feature }: ComingSoonProps) {
+ return (
+
+
+
+
+
+
+
+
+
+ {detail && (
+
+ {detail}
+
+ )}
+ {action && (
+
+ )}
+
+
+ );
+}
diff --git a/portals/api-control-plane/src/components/ConfirmDialog.tsx b/portals/api-control-plane/src/components/ConfirmDialog.tsx
index 17f56676a4..671eb8a035 100644
--- a/portals/api-control-plane/src/components/ConfirmDialog.tsx
+++ b/portals/api-control-plane/src/components/ConfirmDialog.tsx
@@ -16,15 +16,17 @@
* under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useId, useState, type FormEvent } from 'react';
import {
+ Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
- TextField,
+ Form,
+ OutlinedInput,
} from '@wso2/oxygen-ui';
export type ConfirmDialogProps = {
@@ -62,6 +64,10 @@ export function ConfirmDialog({
onCancel,
}: ConfirmDialogProps) {
const [typed, setTyped] = useState('');
+ // Generated rather than a constant: this dialog is rendered by several pages,
+ // and two mounted at once would otherwise share one id, pointing both labels
+ // at whichever input the DOM saw first.
+ const fieldId = useId();
// Reset the typed phrase whenever the dialog opens/closes.
useEffect(() => {
@@ -71,40 +77,56 @@ export function ConfirmDialog({
const matched = !confirmPhrase || typed === confirmPhrase;
const canConfirm = matched && !loading;
+ // A real `form` element, so Enter in the phrase field confirms the way a form
+ // is expected to — no key handler duplicating the browser's own behaviour.
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ if (canConfirm) onConfirm();
+ };
+
return (
);
}
diff --git a/portals/api-control-plane/src/components/ErrorBoundary.tsx b/portals/api-control-plane/src/components/ErrorBoundary.tsx
deleted file mode 100644
index 4277e2243d..0000000000
--- a/portals/api-control-plane/src/components/ErrorBoundary.tsx
+++ /dev/null
@@ -1,85 +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 { Box, Button, Typography } from '@wso2/oxygen-ui';
-import { Component, type ErrorInfo, type ReactNode } from 'react';
-
-type ErrorBoundaryProps = {
- children: ReactNode;
- fallback?: (error: Error, reset: () => void) => ReactNode;
-};
-
-type ErrorBoundaryState = {
- error?: Error;
-};
-
-/**
- * Top-level error boundary. Without this, a render-time throw anywhere in the
- * routed tree (for example `useConsoleScope` used outside its provider) unmounts
- * the whole app and leaves a blank screen.
- */
-export class ErrorBoundary extends Component<
- ErrorBoundaryProps,
- ErrorBoundaryState
-> {
- state: ErrorBoundaryState = {};
-
- static getDerivedStateFromError(error: Error): ErrorBoundaryState {
- return { error };
- }
-
- componentDidCatch(error: Error, info: ErrorInfo) {
- // eslint-disable-next-line no-console
- console.error('Unhandled error in oxygen-console', error, info);
- }
-
- reset = () => {
- this.setState({ error: undefined });
- };
-
- render() {
- const { error } = this.state;
- const { children, fallback } = this.props;
-
- if (!error) return children;
- if (fallback) return fallback(error, this.reset);
-
- return (
-
- Something went wrong
-
- {error.message || 'An unexpected error occurred.'}
-
-
-
- );
- }
-}
diff --git a/portals/api-control-plane/src/components/cards/ApiCard.tsx b/portals/api-control-plane/src/components/cards/ApiCard.tsx
deleted file mode 100644
index a4942ce22a..0000000000
--- a/portals/api-control-plane/src/components/cards/ApiCard.tsx
+++ /dev/null
@@ -1,259 +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 { useState } from 'react';
-import {
- alpha,
- Avatar,
- Box,
- IconButton,
- ListItemIcon,
- ListItemText,
- Menu,
- MenuItem,
- Stack,
- Typography,
-} from '@wso2/oxygen-ui';
-import {
- Boxes,
- Clock,
- MoreVertical,
- Trash2,
-} from '@wso2/oxygen-ui-icons-react';
-
-import type { Api, ApiStatus } from '../../types/domain';
-import { relativeTime } from '../../utils/relativeTime';
-import { COMPONENT_KIND_LABEL, componentStatusColor } from './apiDisplay';
-import { EnvStatusChips } from './EnvStatusChips';
-import { KindIconTile } from './KindIconTile';
-
-const STATUS_LABEL: Record = {
- ACTIVE: 'Active',
- PENDING: 'Pending',
- FAILED: 'Failed',
- DRAFT: 'Draft',
-};
-
-/** Shared square-chip styling from the gateway card. */
-const chipSx = {
- alignItems: 'center',
- bgcolor: 'action.hover',
- border: '1px solid',
- borderColor: 'divider',
- borderRadius: 1,
- color: 'text.secondary',
- display: 'inline-flex',
- fontSize: 12,
- fontWeight: 500,
- gap: 0.75,
- px: 1.25,
- py: 0.5,
-} as const;
-
-type ApiCardProps = {
- component: Api;
- onOpen: (component: Api) => void;
- onDelete?: (component: Api) => void;
-};
-
-/** API card styled to match the gateway card family (GatewaysPage). */
-export function ApiCard({ component, onOpen, onDelete }: ApiCardProps) {
- const updated = component.updatedAt || component.createdAt;
- const [menuAnchor, setMenuAnchor] = useState(null);
-
- const statusColor = componentStatusColor(component.status);
- const statusMain =
- statusColor === 'default' ? 'text.disabled' : `${statusColor}.main`;
-
- const closeMenu = (event?: React.MouseEvent) => {
- event?.stopPropagation();
- setMenuAnchor(null);
- };
-
- return (
- onOpen(component)}
- sx={{
- bgcolor: 'background.paper',
- border: '1px solid',
- borderColor: 'divider',
- borderRadius: 2,
- cursor: 'pointer',
- display: 'flex',
- flexDirection: 'column',
- height: '100%',
- p: 2.5,
- transition: 'border-color .2s, box-shadow .2s, transform .2s',
- '&:hover': {
- borderColor: 'primary.main',
- boxShadow: 3,
- transform: 'translateY(-2px)',
- },
- }}
- >
- {/* Header: kind tile, name + handler, actions menu */}
-
-
-
-
- {component.displayName}
-
-
- {component.handler}
-
-
- {onDelete && (
- <>
- {
- event.stopPropagation();
- setMenuAnchor(event.currentTarget);
- }}
- size="small"
- sx={{ alignSelf: 'flex-start', mr: -0.5, mt: -0.5 }}
- >
-
-
-
- >
- )}
-
-
- {/* Two-line clamped description with reserved space so cards align */}
-
- {component.description || ''}
-
-
- {/* Chips: tinted kind chip, version, per-environment dots */}
-
- alpha(theme.palette.primary.main, 0.14),
- borderColor: (theme) => alpha(theme.palette.primary.main, 0.3),
- color: 'primary.main',
- fontWeight: 600,
- }}
- >
-
- {COMPONENT_KIND_LABEL[component.kind]}
-
- {component.version && v{component.version}}
-
-
-
- {/* Footer: status dot, owner, updated time */}
-
-
-
-
- {STATUS_LABEL[component.status]}
-
-
-
- {component.owner && (
-
-
- {component.owner.charAt(0).toUpperCase()}
-
-
- {component.owner}
-
-
- )}
- {updated && (
-
-
-
- {relativeTime(updated)}
-
-
- )}
-
-
-
- );
-}
diff --git a/portals/api-control-plane/src/components/cards/ProjectCard.tsx b/portals/api-control-plane/src/components/cards/ProjectCard.tsx
index 67c9bace63..c1278a8997 100644
--- a/portals/api-control-plane/src/components/cards/ProjectCard.tsx
+++ b/portals/api-control-plane/src/components/cards/ProjectCard.tsx
@@ -18,8 +18,9 @@
import { useState, type MouseEvent } from 'react';
import {
+ alpha,
Box,
- Button,
+ Card,
Chip,
IconButton,
ListItemIcon,
@@ -31,21 +32,22 @@ import {
Typography,
} from '@wso2/oxygen-ui';
import {
- ArrowRight,
Boxes,
Clock,
- GitBranch,
+ Layers,
MoreVertical,
Rocket,
Settings,
Trash2,
} from '@wso2/oxygen-ui-icons-react';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
-import { useApis } from '../../api/hooks/useMvpQueries';
+import { useRestApis } from '../../api/resources/restApis/restApis.hooks';
+import type { Project } from '../../api/resources/projects';
import { routes } from '../../routes/paths';
-import type { Project } from '../../types/domain';
import { relativeTime } from '../../utils/relativeTime';
+import {interactiveCardSx } from '../../theme';
type ProjectCardProps = {
project: Project;
@@ -54,17 +56,70 @@ type ProjectCardProps = {
onDelete?: (project: Project) => void;
};
-// Decorative brand accents (read on both light and dark surfaces): WSO2 orange
-// for regular projects, a cyan tone for the default project.
-const ORANGE_ACCENT = 'linear-gradient(90deg, #F47B20, #EF4223)';
-const CYAN_ACCENT = 'linear-gradient(90deg, #3AA0D6, #5CD1FF)';
+/**
+ * Tint strength of the metadata strip, per color scheme.
+ *
+ * Two factors rather than one because the strip is tinted with opposite ink in
+ * each scheme — black over a light card, white over a dark one — and the two
+ * need different strengths to read as the same depth of recess. They are applied
+ * to `common.black`/`common.white`, which are identical in both schemes, so the
+ * value is never taken from the wrong scheme's palette.
+ */
+const METADATA_TINT = { dark: 0.08, light: 0.06 } as const;
-const repoTypeLabel = (type: Project['type']) =>
- type === 'MONO_REPO'
- ? 'Mono repo'
- : type === 'MULTI_REPO'
- ? 'Multi repo'
- : undefined;
+const messages = defineMessages({
+ actionsLabel: {
+ id: 'project.card.actionsLabel',
+ defaultMessage: 'Project actions',
+ description: 'Accessible label for the button opening the card overflow menu.',
+ },
+ apiCount: {
+ id: 'project.card.apiCount',
+ defaultMessage: '{count, plural, one {# API} other {# APIs}}',
+ },
+ apiCountLoading: {
+ id: 'project.card.apiCountLoading',
+ defaultMessage: '… APIs',
+ description: 'Placeholder shown while the API count is still loading.',
+ },
+ defaultBadge: {
+ id: 'project.card.defaultBadge',
+ defaultMessage: 'DEFAULT',
+ description: 'Badge marking the organization’s default project.',
+ },
+ delete: {
+ id: 'project.card.delete',
+ defaultMessage: 'Delete',
+ },
+ deployedCount: {
+ id: 'project.card.deployedCount',
+ defaultMessage: '{count} deployed',
+ },
+ deployedCountLoading: {
+ id: 'project.card.deployedCountLoading',
+ defaultMessage: '… deployed',
+ description: 'Placeholder shown while the deployed count is still loading.',
+ },
+ fallbackDescription: {
+ id: 'project.card.fallbackDescription',
+ defaultMessage: 'Project workspace',
+ description: 'Shown in place of a description when the project has none.',
+ },
+ neverUpdated: {
+ id: 'project.card.neverUpdated',
+ defaultMessage: 'Not updated yet',
+ },
+ settingsLabel: {
+ id: 'project.card.settingsLabel',
+ defaultMessage: 'Project settings',
+ },
+ updatedAt: {
+ id: 'project.card.updatedAt',
+ defaultMessage: 'Updated {relative}',
+ description:
+ 'Footer timestamp; {relative} is a phrase such as "3 hours ago".',
+ },
+});
export function ProjectCard({
project,
@@ -72,6 +127,7 @@ export function ProjectCard({
onOpen,
onDelete,
}: ProjectCardProps) {
+ const intl = useIntl();
const stopCardClick = (event: MouseEvent) => event.stopPropagation();
const [menuAnchor, setMenuAnchor] = useState(null);
@@ -80,71 +136,55 @@ export function ProjectCard({
setMenuAnchor(null);
};
const isDefault =
- project.handler === 'default' || project.name.toLowerCase() === 'default';
- const accent = isDefault ? CYAN_ACCENT : ORANGE_ACCENT;
- const iconTint = isDefault ? 'rgba(92,209,255,0.14)' : 'rgba(255,115,0,0.14)';
- const iconColor = isDefault ? '#3AA0D6' : '#FF7300';
- const repoLabel = repoTypeLabel(project.type);
+ project.id === 'default' || project.displayName.toLowerCase() === 'default';
- const apisQuery = useApis(orgHandle, project.handler);
- const apiCount = apisQuery.data?.length;
- const deployedCount = apisQuery.data?.filter(
- (api) => api.status === 'ACTIVE'
+ // Scoped to this card's project rather than the route's, so the counts belong
+ // to the card the user is looking at and not the project they are currently in.
+ const apisQuery = useRestApis({}, { projectId: project.id });
+ const apiCount = apisQuery.data?.pagination?.total ?? apisQuery.data?.count;
+ const deployedCount = apisQuery.data?.list?.filter(
+ (api) => api.lifeCycleStatus === 'PUBLISHED'
).length;
return (
- onOpen(project)}
- sx={{
- bgcolor: 'background.paper',
- border: '1px solid',
- borderColor: 'divider',
- borderRadius: 1,
- cursor: 'pointer',
+ sx={() => ({
+ ...interactiveCardSx,
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
- transition:
- 'transform .18s ease, border-color .18s ease, box-shadow .18s ease',
- '&:hover': {
- borderColor: 'primary.main',
- boxShadow: 4,
- transform: 'translateY(-3px)',
- },
- }}
+ })}
>
- {/* accent strip */}
-
-
+ ({
alignItems: 'center',
- bgcolor: iconTint,
- border: '1px solid',
- borderColor: 'divider',
- borderRadius: 1,
- color: iconColor,
+ bgcolor: `primary.light`,
+ color: `primary.contrastText`,
+ borderRadius: 2,
display: 'flex',
flex: 'none',
height: 46,
justifyContent: 'center',
width: 46,
- }}
+ })}
>
-
+
- {project.name}
+ {project.displayName}
{isDefault && (
- {project.description || 'Project workspace'}
+ {project.description || (
+
+ )}
@@ -166,17 +208,24 @@ export function ProjectCard({
({
+ alignItems: 'center',
+ bgcolor: alpha(theme.palette.common.black, METADATA_TINT.light),
+ borderRadius: 1,
+ color: 'text.secondary',
+ mt: 2.25,
+ px: 1.75,
+ py: 1.25,
+ }),
+ // Emitted under the dark color-scheme selector, so it follows the
+ // theme the user is actually on. Must come last in the array —
+ // `applyStyles` returns a nested selector, not a flat value.
+ (theme) =>
+ theme.applyStyles('dark', {
+ bgcolor: alpha(theme.palette.common.white, METADATA_TINT.dark),
+ }),
+ ]}
>
- {apisQuery.isLoading
- ? '… APIs'
- : `${apiCount ?? 0} ${apiCount === 1 ? 'API' : 'APIs'}`}
+ {apisQuery.isLoading ? (
+
+ ) : (
+
+ )}
- {apisQuery.isLoading
- ? '… deployed'
- : `${deployedCount ?? 0} deployed`}
+ {apisQuery.isLoading ? (
+
+ ) : (
+
+ )}
- {repoLabel && (
-
-
-
- {repoLabel}
-
-
- )}
@@ -219,9 +270,6 @@ export function ProjectCard({
- {project.updatedAt
- ? `Updated ${relativeTime(project.updatedAt)}`
- : 'Not updated yet'}
+ {project.updatedAt ? (
+
+ ) : (
+
+ )}
-
+
{onDelete && (
<>
-
+ {
event.stopPropagation();
setMenuAnchor(event.currentTarget);
@@ -276,24 +329,14 @@ export function ProjectCard({
- Delete
+
+
+
>
)}
- }
- onClick={(event) => {
- stopCardClick(event);
- onOpen(project);
- }}
- size="small"
- sx={{ borderRadius: 5 }}
- variant="outlined"
- >
- Open
-
-
+
);
}
diff --git a/portals/api-control-plane/src/components/cards/ProjectsGrid.tsx b/portals/api-control-plane/src/components/cards/ProjectsGrid.tsx
deleted file mode 100644
index 52af5baea9..0000000000
--- a/portals/api-control-plane/src/components/cards/ProjectsGrid.tsx
+++ /dev/null
@@ -1,81 +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 { useState } from 'react';
-import { Box, TablePagination } from '@wso2/oxygen-ui';
-
-import type { Project } from '../../types/domain';
-import { ProjectCard } from './ProjectCard';
-
-type ProjectsGridProps = {
- projects: Project[];
- orgHandle: string;
- onOpen: (project: Project) => void;
- onDelete?: (project: Project) => void;
-};
-
-export function ProjectsGrid({
- projects,
- orgHandle,
- onOpen,
- onDelete,
-}: ProjectsGridProps) {
- const [page, setPage] = useState(0);
- const [rowsPerPage, setRowsPerPage] = useState(12);
- const paged = projects.slice(
- page * rowsPerPage,
- page * rowsPerPage + rowsPerPage
- );
-
- return (
- <>
-
- {paged.map((project) => (
-
- ))}
-
- {projects.length > rowsPerPage && (
- setPage(nextPage)}
- onRowsPerPageChange={(event) => {
- setRowsPerPage(parseInt(event.target.value, 10));
- setPage(0);
- }}
- page={page}
- rowsPerPage={rowsPerPage}
- rowsPerPageOptions={[12, 24, 48]}
- sx={{ mt: 2 }}
- />
- )}
- >
- );
-}
diff --git a/portals/api-control-plane/src/components/cards/SummaryCardSection.tsx b/portals/api-control-plane/src/components/cards/SummaryCardSection.tsx
index 7ab745bf2a..d6a17b74e5 100644
--- a/portals/api-control-plane/src/components/cards/SummaryCardSection.tsx
+++ b/portals/api-control-plane/src/components/cards/SummaryCardSection.tsx
@@ -31,6 +31,7 @@ import {
Typography,
} from '@wso2/oxygen-ui';
import { Plus } from '@wso2/oxygen-ui-icons-react';
+import { FormattedMessage } from 'react-intl';
export type SummaryRow = {
id: string;
@@ -103,7 +104,10 @@ export function SummaryCardSection({
)}
{onSeeMore && totalCount > visible.length && (
)}
@@ -142,13 +146,21 @@ export function SummaryCardSection({
action={
onRetry ? (
) : undefined
}
severity="error"
>
- {error.message || 'Unable to load.'}
+ {error.message ||
+
+ }
) : isEmpty ? (
{item.icon ||
item.avatarText ||
- item.title.charAt(0).toUpperCase()}
+ item?.title.charAt(0).toUpperCase()}
diff --git a/portals/api-control-plane/src/components/common/SearchableComplexSelect.tsx b/portals/api-control-plane/src/components/common/SearchableComplexSelect.tsx
new file mode 100644
index 0000000000..e7a4f86dc4
--- /dev/null
+++ b/portals/api-control-plane/src/components/common/SearchableComplexSelect.tsx
@@ -0,0 +1,282 @@
+/*
+ * 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 React, { ReactNode, useEffect, useMemo, useState } from 'react';
+import {
+ Box,
+ ComplexSelect,
+ InputAdornment,
+ ListSubheader,
+ SxProps,
+ TextField,
+ Theme,
+} from '@wso2/oxygen-ui';
+import { Search } from '@wso2/oxygen-ui-icons-react';
+import { useIntl } from 'react-intl';
+
+const DEFAULT_MENU_MAX_HEIGHT = 320;
+const DEFAULT_MENU_MAX_WIDTH = 320;
+
+export type SearchableComplexSelectOption = {
+ id: string;
+ name: string;
+ description?: string;
+};
+
+type Props = {
+ value: string;
+ selectedOption: T | null;
+ options: T[];
+ label: string;
+ onChange: (value: string) => void | Promise;
+ renderOptionContent: (option: T) => ReactNode;
+ disabled?: boolean;
+ loading?: boolean;
+ error?: unknown;
+ emptyMessage: string;
+ errorMessage?: string;
+ noResultsMessage?: string;
+ loadingMessage?: string;
+ searchPlaceholder?: string;
+ menuMaxHeight?: number;
+ menuMaxWidth?: number;
+ sx?: SxProps;
+ getSearchText?: (option: T) => string;
+ openOnFieldClick?: boolean;
+ onFieldClick?: () => void;
+ fieldClickAriaLabel?: string;
+ dropdownClickAriaLabel?: string;
+};
+
+export default function SearchableComplexSelect<
+ T extends SearchableComplexSelectOption,
+>({
+ value,
+ selectedOption,
+ options,
+ label,
+ onChange,
+ renderOptionContent,
+ disabled = false,
+ loading = false,
+ error,
+ emptyMessage,
+ errorMessage = 'Failed to load items',
+ noResultsMessage = 'No matching results',
+ loadingMessage = 'Loading...',
+ searchPlaceholder = 'Search',
+ menuMaxHeight = DEFAULT_MENU_MAX_HEIGHT,
+ menuMaxWidth = DEFAULT_MENU_MAX_WIDTH,
+ sx,
+ getSearchText,
+ openOnFieldClick = true,
+ onFieldClick,
+ fieldClickAriaLabel,
+ dropdownClickAriaLabel,
+}: Props) {
+ const intl = useIntl();
+ const [isOpen, setIsOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const filteredOptions = useMemo(() => {
+ const query = searchQuery.trim().toLowerCase();
+
+ if (!query) {
+ return options;
+ }
+
+ return options.filter((option) => {
+ const haystack = getSearchText
+ ? getSearchText(option)
+ : [option.name, option.description, option.id].filter(Boolean).join(' ');
+
+ return haystack.toLowerCase().includes(query);
+ });
+ }, [getSearchText, options, searchQuery]);
+
+ const handleClose = () => {
+ setIsOpen(false);
+ setSearchQuery('');
+ };
+
+ useEffect(() => {
+ if (!disabled) {
+ return;
+ }
+
+ setIsOpen(false);
+ setSearchQuery('');
+ }, [disabled]);
+
+ const canSearch = !loading && options.length > 0;
+ const effectiveValue = loading ? '__loading__' : value;
+ const isSelectedOptionFilteredOut =
+ Boolean(value) &&
+ Boolean(selectedOption) &&
+ !filteredOptions.some((option) => option.id === value);
+ const usesSplitTrigger = !disabled && (!openOnFieldClick || Boolean(onFieldClick));
+
+ const selectNode = (
+ onChange(event.target.value as string)}
+ onOpen={openOnFieldClick ? () => setIsOpen(true) : undefined}
+ onClose={handleClose}
+ open={isOpen}
+ size="small"
+ sx={sx}
+ label={label}
+ disabled={disabled}
+ MenuProps={{
+ autoFocus: false,
+ disableAutoFocusItem: true,
+ slotProps: {
+ paper: {
+ sx: {
+ maxHeight: menuMaxHeight,
+ // Keeps the menu from stretching to the widest option, so option
+ // text can ellipsize instead of pushing the paper off-screen.
+ maxWidth: menuMaxWidth,
+ },
+ },
+ },
+ }}
+ >
+ {isSelectedOptionFilteredOut && selectedOption ? (
+
+ {renderOptionContent(selectedOption)}
+
+ ) : null}
+
+ {canSearch ? (
+
+ setSearchQuery(event.target.value)}
+ onKeyDown={(event) => event.stopPropagation()}
+ slotProps={{
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ },
+ }}
+ />
+
+ ) : null}
+
+ {loading ? (
+
+
+
+ ) : options.length === 0 ? (
+
+
+
+ ) : filteredOptions.length === 0 ? (
+
+
+
+ ) : (
+ filteredOptions.map((option) => (
+
+ {renderOptionContent(option)}
+
+ ))
+ )}
+
+ );
+
+ if (!usesSplitTrigger) {
+ return selectNode;
+ }
+
+ return (
+
+ {selectNode}
+ onFieldClick?.()}
+ sx={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 40,
+ bottom: 0,
+ border: 0,
+ p: 0,
+ m: 0,
+ bgcolor: 'transparent',
+ cursor: onFieldClick ? 'pointer' : 'default',
+ borderRadius: 1,
+ zIndex: 1,
+ }}
+ />
+ setIsOpen(true)}
+ sx={{
+ position: 'absolute',
+ top: 0,
+ right: 0,
+ width: 40,
+ bottom: 0,
+ border: 0,
+ p: 0,
+ m: 0,
+ bgcolor: 'transparent',
+ cursor: 'pointer',
+ borderRadius: 1,
+ zIndex: 1,
+ }}
+ />
+
+ );
+}
diff --git a/portals/api-control-plane/src/components/errors/ErrorBoundary.test.tsx b/portals/api-control-plane/src/components/errors/ErrorBoundary.test.tsx
new file mode 100644
index 0000000000..c82fa0f44d
--- /dev/null
+++ b/portals/api-control-plane/src/components/errors/ErrorBoundary.test.tsx
@@ -0,0 +1,226 @@
+/*
+ * 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 { Link, Route, Routes, useLocation } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { renderWithProviders, screen } from '../../test/utils';
+import { ErrorBoundary } from './ErrorBoundary';
+import { PageErrorFallback } from './ErrorFallback';
+
+/**
+ * React logs every caught error to `console.error` in addition to handing it to
+ * the boundary, so a passing test would still print two stack traces per case.
+ */
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+});
+
+function Boom({ message = 'render exploded' }: { message?: string }): never {
+ throw new Error(message);
+}
+
+describe('ErrorBoundary', () => {
+ it('renders children while nothing throws', () => {
+ renderWithProviders(
+
+
page body
+
+ );
+
+ expect(screen.getByText('page body')).toBeInTheDocument();
+ });
+
+ it('shows the app fallback instead of unmounting when a child throws', () => {
+ renderWithProviders(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Go to console home' })
+ ).toBeInTheDocument();
+ });
+
+ it('keeps the raw message off the screen in a production build', () => {
+ vi.stubEnv('DEV', false);
+
+ renderWithProviders(
+
+
+
+ );
+
+ // Sterile copy only — the real message goes to the console, which the
+ // `beforeEach` spy above is capturing.
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Cannot read properties of undefined')
+ ).not.toBeInTheDocument();
+ expect(console.error).toHaveBeenCalled();
+ });
+
+ it('shows the raw message as a developer aid in a dev build', () => {
+ vi.stubEnv('DEV', true);
+
+ renderWithProviders(
+
+
+
+ );
+
+ expect(
+ screen.getByText('Cannot read properties of undefined')
+ ).toBeInTheDocument();
+ });
+
+ it('recovers through the fallback reset without reloading', async () => {
+ // Held outside the component: the boundary unmounts its children when it
+ // catches, so component state would be back to "throwing" on reset.
+ let throwing = true;
+
+ function Flaky() {
+ if (throwing) throw new Error('transient');
+ return
+
+ );
+
+ expect(screen.getByText('next page body')).toBeInTheDocument();
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
+ });
+
+ it('offers a reload, not a retry, for a stale code-split chunk', () => {
+ renderWithProviders(
+ (
+
+ )}
+ >
+
+
+ );
+
+ expect(
+ screen.getByText('A newer version of the console is available')
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Reload' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Try again' })
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe('page-level containment', () => {
+ /**
+ * The behaviour the boundary exists for: a throw in the routed page must not
+ * take the surrounding shell with it. Modelled on `AppLayout`'s structure
+ * (persistent chrome as siblings of the guarded outlet) rather than mounting
+ * `AppLayout` itself, which would drag in the whole scope/query stack and
+ * test Oxygen's `AppShell` more than this boundary.
+ */
+ function Shell({ children }: { children: ReactNode }) {
+ // `useLocation`, not `window.location` — the pathname the boundary resets
+ // on has to be the router's, which is what `AppLayout` passes.
+ const routerLocation = useLocation();
+
+ return (
+ <>
+
+ (
+
+ )}
+ resetKeys={[routerLocation.pathname]}
+ >
+ {children}
+
+
+ >
+ );
+ }
+
+ it('leaves the surrounding chrome mounted and navigable', async () => {
+ const { user } = renderWithProviders(
+
+
+ } />
+ gateways page
} />
+
+ ,
+ { route: '/apis' }
+ );
+
+ expect(
+ screen.getByText('This page could not be displayed')
+ ).toBeInTheDocument();
+ // The whole point: chrome survives, so there is a way out of the failure.
+ expect(screen.getByRole('link', { name: 'Gateways' })).toBeInTheDocument();
+ expect(screen.getByText('console footer')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('link', { name: 'Gateways' }));
+
+ expect(await screen.findByText('gateways page')).toBeInTheDocument();
+ expect(
+ screen.queryByText('This page could not be displayed')
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/portals/api-control-plane/src/components/errors/ErrorBoundary.tsx b/portals/api-control-plane/src/components/errors/ErrorBoundary.tsx
new file mode 100644
index 0000000000..c424d19cc9
--- /dev/null
+++ b/portals/api-control-plane/src/components/errors/ErrorBoundary.tsx
@@ -0,0 +1,113 @@
+/*
+ * 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 { Component, type ErrorInfo, type ReactNode } from 'react';
+
+import { AppErrorFallback } from './ErrorFallback';
+
+type ErrorBoundaryProps = {
+ children: ReactNode;
+ fallback?: (error: Error, reset: () => void) => ReactNode;
+ /**
+ * Values that, when any of them changes, clear a caught error and re-render
+ * `children`.
+ *
+ * The page-level boundary passes the current pathname: without it the
+ * fallback stays latched after the user navigates away, so the shell's
+ * sidebar would appear to be broken too.
+ */
+ resetKeys?: readonly unknown[];
+};
+
+type ErrorBoundaryState = {
+ error?: Error;
+ /** The `resetKeys` this state was derived against; compared, never rendered. */
+ resetKeys: readonly unknown[];
+};
+
+const EMPTY_RESET_KEYS: readonly unknown[] = [];
+
+const sameResetKeys = (a: readonly unknown[], b: readonly unknown[]) =>
+ a.length === b.length && a.every((value, index) => Object.is(value, b[index]));
+
+/**
+ * Catches a render-time throw in its subtree and renders a fallback in its
+ * place, so the failure is contained to that subtree rather than unmounting
+ * everything above it.
+ *
+ * Mounted at two levels, and the difference matters:
+ *
+ * - Around the routed page in `AppLayout`, which is what keeps a page fault
+ * from taking the header, sidebar and footer down with it. This is the one
+ * that catches almost everything in practice, including a `lazy()` chunk that
+ * fails to load after a deploy.
+ * - At the top of `App`, above `BrowserRouter`, as the last resort for anything
+ * that throws outside the routed page (the auth provider, the scope provider,
+ * the shell itself).
+ *
+ * It does not catch errors thrown from event handlers, timers, or unawaited
+ * promises, React boundaries never do. Those still need handling at the call
+ * site.
+ */
+export class ErrorBoundary extends Component<
+ ErrorBoundaryProps,
+ ErrorBoundaryState
+> {
+ state: ErrorBoundaryState = { resetKeys: EMPTY_RESET_KEYS };
+
+ static getDerivedStateFromError(error: Error): Partial {
+ return { error };
+ }
+
+ /**
+ * Drops a caught error once `resetKeys` change.
+ *
+ * Done here rather than in an effect because a boundary showing its fallback
+ * renders no children, so a child effect can never run to clear it — the
+ * decision has to happen during the render that follows the key change.
+ */
+ static getDerivedStateFromProps(
+ props: ErrorBoundaryProps,
+ state: ErrorBoundaryState
+ ): Partial | null {
+ const keys = props.resetKeys ?? EMPTY_RESET_KEYS;
+ if (sameResetKeys(keys, state.resetKeys)) return null;
+ return { error: undefined, resetKeys: keys };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ // Kept in every build: the fallback shows the user sterile copy, so the
+ // console is the only place the real message and component stack survive.
+ // eslint-disable-next-line no-console
+ console.error('Unhandled error in oxygen-console', error, info);
+ }
+
+ reset = () => {
+ this.setState({ error: undefined });
+ };
+
+ render() {
+ const { error } = this.state;
+ const { children, fallback } = this.props;
+
+ if (!error) return children;
+ if (fallback) return fallback(error, this.reset);
+
+ return ;
+ }
+}
diff --git a/portals/api-control-plane/src/components/errors/ErrorFallback.tsx b/portals/api-control-plane/src/components/errors/ErrorFallback.tsx
new file mode 100644
index 0000000000..4af9e88e2b
--- /dev/null
+++ b/portals/api-control-plane/src/components/errors/ErrorFallback.tsx
@@ -0,0 +1,351 @@
+/*
+ * 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 {
+ Box,
+ Button,
+ Sidebar,
+ Stack,
+ Tooltip,
+ Typography,
+} from '@wso2/oxygen-ui';
+import { House, RefreshCw, RotateCcw, TriangleAlert } from '@wso2/oxygen-ui-icons-react';
+import type { ReactNode } from 'react';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+import { useNavigate } from 'react-router-dom';
+
+import { runtimeConfig } from '../../config/runtime';
+import { isChunkLoadError } from '../../utils/errors/errorClassification';
+
+const messages = defineMessages({
+ appBody: {
+ id: 'apiControlPlane.components.ErrorFallback.app.body',
+ defaultMessage:
+ 'The console ran into an unexpected problem and could not continue.',
+ },
+ appTitle: {
+ id: 'apiControlPlane.components.ErrorFallback.app.title',
+ defaultMessage: 'Something went wrong',
+ },
+ navigationUnavailable: {
+ id: 'apiControlPlane.components.ErrorFallback.chrome.navigationUnavailable',
+ defaultMessage:
+ 'Navigation is unavailable. Reload the page to restore it.',
+ description:
+ 'Tooltip on the marker left in place of the sidebar when it fails to render.',
+ },
+ goHome: {
+ id: 'apiControlPlane.components.ErrorFallback.action.goHome',
+ defaultMessage: 'Go to console home',
+ description:
+ 'Leaves the failing page for the console landing page. Escapes a page that fails every time it is opened.',
+ },
+ pageBody: {
+ id: 'apiControlPlane.components.ErrorFallback.page.body',
+ defaultMessage:
+ 'Something went wrong while displaying this page. The rest of the console is still available.',
+ },
+ pageTitle: {
+ id: 'apiControlPlane.components.ErrorFallback.page.title',
+ defaultMessage: 'This page could not be displayed',
+ },
+ reload: {
+ id: 'apiControlPlane.components.ErrorFallback.action.reload',
+ defaultMessage: 'Reload',
+ description: 'Reloads the browser tab. Verb, not a noun.',
+ },
+ staleBody: {
+ id: 'apiControlPlane.components.ErrorFallback.stale.body',
+ defaultMessage:
+ 'This page could not be loaded because the console has been updated since you opened it. Reload to get the latest version.',
+ description:
+ 'Shown when a code-split chunk 404s, which happens to an open tab after a deployment.',
+ },
+ staleTitle: {
+ id: 'apiControlPlane.components.ErrorFallback.stale.title',
+ defaultMessage: 'A newer version of the console is available',
+ },
+ switchersUnavailable: {
+ id: 'apiControlPlane.components.ErrorFallback.chrome.switchersUnavailable',
+ defaultMessage:
+ 'Organization, project and API switchers are unavailable. Reload the page to restore them.',
+ description:
+ 'Tooltip on the marker left in place of the header switchers when they fail to render.',
+ },
+ tryAgain: {
+ id: 'apiControlPlane.components.ErrorFallback.action.tryAgain',
+ defaultMessage: 'Try again',
+ description:
+ 're-renders the failed area without reloading the browser tab.',
+ },
+});
+
+/** What every fallback below renders, differing only in copy and actions. */
+function ErrorFallbackLayout({
+ actions,
+ body,
+ error,
+ title,
+}: {
+ actions: ReactNode;
+ body: ReactNode;
+ error: Error;
+ title: ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+ {body}
+
+ {/*
+ The raw message is a developer artefact ("Cannot read properties of
+ undefined"), so it is shown only in a dev build. `componentDidCatch`
+ logs it to the console in every build, which is where support should
+ read it from.
+ */}
+ {import.meta.env.DEV && error.message && (
+
+ {error.message}
+
+ )}
+
+
+ {actions}
+
+
+ );
+}
+
+export type ErrorFallbackProps = {
+ error: Error;
+ /**
+ * Clears the boundary's error state and re-renders its children. Recovers a
+ * transient fault without discarding the router, the query cache, or any
+ * state held above the boundary — which is what a full reload costs.
+ */
+ reset: () => void;
+};
+
+/**
+ * Fallback for the boundary around the routed page, inside `AppLayout`.
+ *
+ * Deliberately renders only where the page would: the header, sidebar,
+ * breadcrumbs and footer stay mounted, so the user navigates away from a broken
+ * page instead of being left with a reload button that reproduces the same
+ * failure. That is also why "go home" is a real route navigation here rather
+ * than a document load.
+ */
+export function PageErrorFallback({ error, reset }: ErrorFallbackProps) {
+ const navigate = useNavigate();
+
+ if (isChunkLoadError(error)) {
+ return (
+ window.location.reload()}
+ startIcon={}
+ variant="contained"
+ >
+
+
+ }
+ body={}
+ error={error}
+ title={}
+ />
+ );
+ }
+
+ return (
+
+ }
+ variant="contained"
+ >
+
+
+
+ >
+ }
+ body={}
+ error={error}
+ title={}
+ />
+ );
+}
+
+/**
+ * Fallback for the outermost boundary, which sits above `BrowserRouter`.
+ *
+ * Reached only when something outside the routed page throws, so there is no
+ * router to navigate with — "go home" has to be a document load. It points at
+ * the app's base path rather than reloading the current URL on purpose: when
+ * the failure is deterministic for this route, reloading it reproduces the
+ * error forever, and this is the one action that escapes that loop.
+ */
+export function AppErrorFallback({ error, reset }: ErrorFallbackProps) {
+ const stale = isChunkLoadError(error);
+
+ return (
+
+ {stale ? (
+
+ ) : (
+ }
+ variant="contained"
+ >
+
+
+ )}
+
+ >
+ }
+ body={
+
+ }
+ error={error}
+ title={
+
+ }
+ />
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* Persistent chrome */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * The marker a failed piece of persistent chrome leaves behind.
+ *
+ * A header switcher or the sidebar has no room for the full-page treatment
+ * above, and no useful action of its own — the surrounding chrome is still
+ * working, so the recovery is "carry on, or reload". What it must not do is
+ * disappear silently: a console shipped with a missing org switcher and no
+ * visible trace is a bug nobody reports. Hence a small, deliberately
+ * unobtrusive marker with the explanation in a tooltip.
+ *
+ * `role="status"` rather than `alert`: this is a degraded region the user may
+ * never need, not something demanding immediate attention.
+ */
+function ChromeErrorMarker({ description }: { description: string }) {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * Fallback for the boundary around the header's scope switchers.
+ *
+ * Scoped to the switchers alone so the rest of the header survives — the brand,
+ * the colour-scheme toggle, the notification bell, and above all the user menu
+ * with **logout** in it. Losing the ability to sign out because an org lookup
+ * returned an unexpected shape is a far worse outcome than losing a switcher.
+ */
+export function HeaderSwitchersErrorFallback() {
+ const intl = useIntl();
+ return (
+
+ );
+}
+
+/**
+ * Fallback for the boundary around the sidebar.
+ *
+ * Renders a real but empty `Sidebar` rather than nothing, so the rail keeps its
+ * width and the shell's grid does not reflow around a missing column. It reads
+ * `collapsed`/`width` from `AppShellContext` on its own, so the fallback rail
+ * matches whatever the user had before the failure.
+ *
+ * No synthesised nav items: building a fallback menu would re-run the same
+ * scope-dependent logic that just threw. The header's switchers and brand are
+ * the way out from here.
+ */
+export function SidebarErrorFallback() {
+ const intl = useIntl();
+ return (
+
+
+
+
+
+ );
+}
diff --git a/portals/api-control-plane/src/features/auth/AuthProvider.test.tsx b/portals/api-control-plane/src/contexts/auth/AuthProvider.test.tsx
similarity index 100%
rename from portals/api-control-plane/src/features/auth/AuthProvider.test.tsx
rename to portals/api-control-plane/src/contexts/auth/AuthProvider.test.tsx
diff --git a/portals/api-control-plane/src/features/auth/AuthProvider.tsx b/portals/api-control-plane/src/contexts/auth/AuthProvider.tsx
similarity index 100%
rename from portals/api-control-plane/src/features/auth/AuthProvider.tsx
rename to portals/api-control-plane/src/contexts/auth/AuthProvider.tsx
diff --git a/portals/api-control-plane/src/features/auth/AuthStateContext.ts b/portals/api-control-plane/src/contexts/auth/AuthStateContext.ts
similarity index 100%
rename from portals/api-control-plane/src/features/auth/AuthStateContext.ts
rename to portals/api-control-plane/src/contexts/auth/AuthStateContext.ts
diff --git a/portals/api-control-plane/src/features/auth/authConstants.ts b/portals/api-control-plane/src/contexts/auth/authConstants.ts
similarity index 100%
rename from portals/api-control-plane/src/features/auth/authConstants.ts
rename to portals/api-control-plane/src/contexts/auth/authConstants.ts
diff --git a/portals/api-control-plane/src/features/auth/authTypes.ts b/portals/api-control-plane/src/contexts/auth/authTypes.ts
similarity index 100%
rename from portals/api-control-plane/src/features/auth/authTypes.ts
rename to portals/api-control-plane/src/contexts/auth/authTypes.ts
diff --git a/portals/api-control-plane/src/extensions.tsx b/portals/api-control-plane/src/extensions.tsx
index f5b6e12a77..eea3b5d40b 100644
--- a/portals/api-control-plane/src/extensions.tsx
+++ b/portals/api-control-plane/src/extensions.tsx
@@ -18,26 +18,22 @@
import type { ReactNode } from 'react';
+import { apiPath, projectPath, type ScopeHandle } from './routes/paths';
import type { ConsoleScope } from './scope/ConsoleScopeProvider';
import type { NavigationLevel } from './navigation/navigationTypes';
import type { CloudHostPort } from './hostPort';
-import {
- SlotEntriesProvider,
- useSlotEntries,
- type SlotEntry,
-} from './slots';
+import { SlotEntriesProvider, useSlotEntries, type SlotEntry } from './slots';
/**
- * 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.
+ * 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 `level` 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.
+ * 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
@@ -49,10 +45,47 @@ export type ApiControlPlaneExtension = SlotEntry & {
render: (port: CloudHostPort) => ReactNode;
label: string;
icon?: ReactNode;
- scope: NavigationLevel;
+ level: NavigationLevel;
+ /** Sidebar section heading. Defaults to the level's own section (e.g. "Organization"). */
+ group?: string;
isVisible?: (scope: ConsoleScope) => boolean;
};
+/**
+ * Slot names core knows about. Both live here rather than being spelled out at
+ * each use site, so the sidebar route builder and the nav pipeline (and the
+ * Settings tab list and its routes) can never drift apart on a string literal.
+ */
+const SIDEBAR_SLOT_PREFIX = 'sidebar.';
+
+/** The slot a Settings sub-nav tab for `level` attaches to. */
+export const settingsTabSlot = (level: NavigationLevel): string =>
+ `settings.${level}.tabs`;
+
+/** Whether this entry is a top-level sidebar item rather than a nested one. */
+export const isSidebarExtension = (
+ extension: ApiControlPlaneExtension
+): boolean => extension.slot.startsWith(SIDEBAR_SLOT_PREFIX);
+
+/**
+ * Entries for `settingsTabSlot(level)`, sorted by `order`.
+ *
+ * `slot` and `level` must agree: a type-valid but inconsistent descriptor
+ * (`slot: 'settings.organization.tabs'` with `level: 'project'`) would
+ * otherwise render against the wrong scope's Port, so it is dropped here and
+ * in the matching route pass rather than half-honoured.
+ */
+export const settingsTabExtensions = (
+ extensions: readonly ApiControlPlaneExtension[],
+ level: NavigationLevel
+): ApiControlPlaneExtension[] =>
+ extensions
+ .filter(
+ (extension) =>
+ extension.slot === settingsTabSlot(level) && extension.level === level
+ )
+ .sort((left, right) => left.order - right.order);
+
export function ExtensionsProvider({
extensions,
children,
@@ -70,21 +103,62 @@ export function useExtensions(): readonly ApiControlPlaneExtension[] {
}
/**
- * Prefixes an extension's `routePath` with the URL shape for its `scope`
+ * Prefixes an extension's `routePath` with the URL shape for its `level`
* (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.
+ *
+ * A `null` project/API handle drops that scope's segments, exactly as it does
+ * for the built-in `routes.*` builders — see `ScopeHandle` in `routes/paths.ts`.
+ * That is what lets an extension page be reached from a shallower scope and
+ * render `ScopeGate` to ask for the rest.
*/
export function buildScopedExtensionPath(
- scope: NavigationLevel,
+ level: NavigationLevel,
routeSuffix: string,
- params: { orgHandle: string; projectHandler?: string; apiHandler?: string }
+ params: {
+ orgHandle: string;
+ projectHandler?: ScopeHandle;
+ apiHandler?: ScopeHandle;
+ }
): string {
- if (scope === 'organization') {
+ if (level === 'organization') {
return `/organizations/${params.orgHandle}/${routeSuffix}`;
}
- if (scope === 'project') {
- return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/${routeSuffix}`;
+ if (level === 'project') {
+ return projectPath(params.orgHandle, params.projectHandler ?? null, routeSuffix);
+ }
+ return apiPath(
+ params.orgHandle,
+ params.projectHandler ?? null,
+ params.apiHandler ?? null,
+ routeSuffix
+ );
+}
+
+/**
+ * Every route pattern an extension page answers on: its fully-scoped path plus
+ * the scope-less aliases for whichever scopes its level requires. Mirrors
+ * `projectScopedPaths`/`apiScopedPaths` for the built-in pages.
+ */
+export function extensionScopedPaths(
+ level: NavigationLevel,
+ routeSuffix: string
+): string[] {
+ const build = (projectHandler: ScopeHandle, apiHandler: ScopeHandle) =>
+ buildScopedExtensionPath(level, routeSuffix, {
+ apiHandler,
+ orgHandle: ':orgHandle',
+ projectHandler,
+ });
+
+ if (level === 'organization') return [build(null, null)];
+ if (level === 'project') {
+ return [build(':projectHandler', null), build(null, null)];
}
- return `/organizations/${params.orgHandle}/projects/${params.projectHandler}/apis/${params.apiHandler}/${routeSuffix}`;
+ return [
+ build(':projectHandler', ':apiHandler'),
+ build(':projectHandler', null),
+ build(null, null),
+ ];
}
diff --git a/portals/api-control-plane/src/features/apis/ApiCreatePage.test.tsx b/portals/api-control-plane/src/features/apis/ApiCreatePage.test.tsx
deleted file mode 100644
index 0f21a35159..0000000000
--- a/portals/api-control-plane/src/features/apis/ApiCreatePage.test.tsx
+++ /dev/null
@@ -1,100 +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 { Route, Routes } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { renderWithProviders, screen, waitFor } from '../../test/utils';
-
-// Capture the create mutation so we can assert the submitted input.
-const { mutateAsync } = vi.hoisted(() => ({ mutateAsync: vi.fn() }));
-vi.mock('../../api/hooks/useMvpQueries', () => ({
- useCreateApi: () => ({ mutateAsync, isPending: false, error: null }),
-}));
-
-import { ApiCreatePage } from './ApiCreatePage';
-
-const ROUTE = '/organizations/acme/projects/retail/apis/new';
-
-function renderPage() {
- return renderWithProviders(
-
- }
- />
- ,
- { route: ROUTE }
- );
-}
-
-describe('ApiCreatePage', () => {
- beforeEach(() => vi.clearAllMocks());
-
- it('renders the type + method selection with only HTTP/start methods enabled', () => {
- renderPage();
- expect(screen.getAllByText('HTTP').length).toBeGreaterThan(0);
- expect(screen.getByText('Import API Contract')).toBeInTheDocument();
- expect(screen.getByText('Start from Scratch')).toBeInTheDocument();
- // GraphQL/WebSocket/etc are "Soon"; GenAI is "Coming soon".
- expect(screen.getAllByText('Soon').length).toBeGreaterThan(0);
- expect(screen.getByText('Coming soon')).toBeInTheDocument();
- });
-
- it('start-from-scratch goes to details and disables Create until valid', async () => {
- const { user } = renderPage();
- await user.click(screen.getByText('Start from Scratch'));
-
- // Details phase rendered.
- expect(screen.getByText('Create an API Proxy')).toBeInTheDocument();
- const createButton = screen.getByRole('button', { name: 'Create' });
- expect(createButton).toBeDisabled();
-
- // Display name only → still disabled (scratch requires a backend URL).
- await user.type(screen.getByLabelText(/Display name/), 'Pizza Shack API');
- expect(createButton).toBeDisabled();
- });
-
- it('submits the built CreateApiInput once details are valid', async () => {
- mutateAsync.mockResolvedValue({ handler: 'pizza-shack-api' });
- const { user } = renderPage();
- await user.click(screen.getByText('Start from Scratch'));
-
- await user.type(screen.getByLabelText(/Display name/), 'Pizza Shack API');
- await user.type(
- screen.getByLabelText(/Target URL/),
- 'https://backend.example.com'
- );
-
- const createButton = screen.getByRole('button', { name: 'Create' });
- await waitFor(() => expect(createButton).toBeEnabled());
- await user.click(createButton);
-
- await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
- expect(mutateAsync).toHaveBeenCalledWith(
- expect.objectContaining({
- kind: 'API_PROXY',
- displayName: 'Pizza Shack API',
- name: 'pizza-shack-api',
- version: '1.0.0',
- prodUrl: 'https://backend.example.com',
- source: { mode: 'scratch' },
- })
- );
- });
-});
diff --git a/portals/api-control-plane/src/features/apis/ApiDetailPage.test.tsx b/portals/api-control-plane/src/features/apis/ApiDetailPage.test.tsx
deleted file mode 100644
index 117dc9e259..0000000000
--- a/portals/api-control-plane/src/features/apis/ApiDetailPage.test.tsx
+++ /dev/null
@@ -1,101 +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 type { UseQueryResult } from '@tanstack/react-query';
-import { Route, Routes } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { useApiDetail } from '../../api/hooks/useMvpQueries';
-import { renderWithProviders, screen } from '../../test/utils';
-import type { ApiDetail } from '../../types/domain';
-import { ApiDetailPage } from './ApiDetailPage';
-
-vi.mock('../../api/hooks/useMvpQueries', async (importActual) => ({
- ...(await importActual()),
- useApiDetail: vi.fn(),
-}));
-
-const ROUTE = '/organizations/acme/projects/retail/apis/orders-api';
-
-const detail: ApiDetail = {
- id: 'api-1',
- projectId: 'proj-1',
- name: 'orders-api',
- displayName: 'Orders API',
- handler: 'orders-api',
- kind: 'API_PROXY',
- status: 'ACTIVE',
- operations: [],
- policies: [],
- endpoints: {},
-};
-
-const queryResult = (overrides: Partial>) =>
- overrides as UseQueryResult;
-
-function renderPage() {
- return renderWithProviders(
-
- API list marker}
- />
- }
- />
- ,
- { route: ROUTE }
- );
-}
-
-describe('ApiDetailPage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it('shows the loading state', () => {
- vi.mocked(useApiDetail).mockReturnValue(queryResult({ isLoading: true }));
- renderPage();
- expect(screen.getByText('Loading API')).toBeInTheDocument();
- });
-
- it('shows the error state', () => {
- vi.mocked(useApiDetail).mockReturnValue(
- queryResult({ isLoading: false, error: new Error('x') })
- );
- renderPage();
- expect(screen.getByText('API not found')).toBeInTheDocument();
- });
-
- it('renders the API title without the header action buttons', () => {
- vi.mocked(useApiDetail).mockReturnValue(
- queryResult({ isLoading: false, data: detail })
- );
- renderPage();
-
- expect(screen.getByText('Orders API')).toBeInTheDocument();
- // The Delete/Deploy/Test/Manage header pallet has been removed; progress
- // navigation now lives in the Overview tab's progress banner. Delete has no
- // banner equivalent, so its absence proves the header pallet is gone.
- expect(
- screen.queryByRole('button', { name: 'Delete' })
- ).not.toBeInTheDocument();
- expect(screen.getByText('Track your progress here')).toBeInTheDocument();
- });
-});
diff --git a/portals/api-control-plane/src/features/apis/ApiDetailPage.tsx b/portals/api-control-plane/src/features/apis/ApiDetailPage.tsx
deleted file mode 100644
index ee8e571d3d..0000000000
--- a/portals/api-control-plane/src/features/apis/ApiDetailPage.tsx
+++ /dev/null
@@ -1,67 +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 { useState } from 'react';
-import { PageContent, PageTitle, Tab, Tabs } from '@wso2/oxygen-ui';
-
-import { useApiDetail } from '../../api/hooks/useMvpQueries';
-import { ErrorState, LoadingState } from '../../components/StateViews';
-import { DocumentsTab } from './develop/DocumentsTab';
-import { PolicyTab } from './develop/PolicyTab';
-import { RoutingTab } from './develop/RoutingTab';
-import { OverviewTab } from './overview/OverviewTab';
-
-export function ApiDetailPage() {
- const detailQuery = useApiDetail();
- const [tab, setTab] = useState(0);
-
- if (detailQuery.isLoading) return ;
- if (detailQuery.error || !detailQuery.data) {
- return ;
- }
-
- const detail = detailQuery.data;
-
- // Develop tab set mirrors the product: Overview, then Policy/Routing/Documents.
- const tabs = ['Overview', 'Policy', 'Routing', 'Documents'] as const;
- const active = tabs[tab] ?? 'Overview';
-
- return (
-
-
- {detail.displayName}
- {detail.description}
-
-
- setTab(value)}
- sx={{ mb: 3 }}
- value={tab}
- >
- {tabs.map((label) => (
-
- ))}
-
-
- {active === 'Overview' && }
- {active === 'Policy' && }
- {active === 'Routing' && }
- {active === 'Documents' && }
-
- );
-}
diff --git a/portals/api-control-plane/src/features/apis/ApiListPage.test.tsx b/portals/api-control-plane/src/features/apis/ApiListPage.test.tsx
deleted file mode 100644
index 22e5d7139b..0000000000
--- a/portals/api-control-plane/src/features/apis/ApiListPage.test.tsx
+++ /dev/null
@@ -1,118 +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 type { UseQueryResult } from '@tanstack/react-query';
-import { Route, Routes } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { components as mockComponents } from '../../api/mocks/data';
-import { renderWithProviders, screen } from '../../test/utils';
-import type { Api } from '../../types/domain';
-
-vi.mock('../../api/hooks/useMvpQueries', async (importActual) => ({
- ...(await importActual()),
- useApis: vi.fn(),
- useDeleteApi: vi.fn(),
-}));
-
-import { useApis, useDeleteApi } from '../../api/hooks/useMvpQueries';
-import { ApiListPage } from './ApiListPage';
-
-const ROUTE = '/organizations/acme/projects/retail/apis';
-
-const queryResult = (overrides: Partial>) =>
- overrides as UseQueryResult;
-
-function renderPage() {
- return renderWithProviders(
-
- }
- />
- ,
- { route: ROUTE }
- );
-}
-
-describe('ApiListPage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- // Most tests don't exercise delete; provide a default mutation.
- vi.mocked(useDeleteApi).mockReturnValue({
- mutate: vi.fn(),
- isPending: false,
- } as unknown as ReturnType);
- });
-
- it('shows the loading state', () => {
- vi.mocked(useApis).mockReturnValue(queryResult({ isLoading: true }));
- renderPage();
- expect(screen.getByText('Loading APIs')).toBeInTheDocument();
- });
-
- it('shows the error state', () => {
- vi.mocked(useApis).mockReturnValue(
- queryResult({ isLoading: false, error: new Error('x') })
- );
- renderPage();
- expect(screen.getByText('Unable to load APIs')).toBeInTheDocument();
- });
-
- it('shows the empty state', () => {
- vi.mocked(useApis).mockReturnValue(
- queryResult({ isLoading: false, data: [] })
- );
- renderPage();
- expect(screen.getByText('No APIs yet')).toBeInTheDocument();
- });
-
- it('renders the API Proxies section and filters by search', async () => {
- vi.mocked(useApis).mockReturnValue(
- queryResult({ isLoading: false, data: mockComponents })
- );
- const { user } = renderPage();
-
- expect(screen.getByText('API Proxies')).toBeInTheDocument();
- expect(screen.getByText('Orders API')).toBeInTheDocument();
-
- await user.type(screen.getByPlaceholderText('Search APIs'), 'orders');
- expect(screen.getByText('Orders API')).toBeInTheDocument();
-
- await user.clear(screen.getByPlaceholderText('Search APIs'));
- await user.type(screen.getByPlaceholderText('Search APIs'), 'nomatch');
- expect(screen.queryByText('Orders API')).not.toBeInTheDocument();
- expect(screen.getByText('No matching APIs')).toBeInTheDocument();
- });
-
- it('switches between grid and list views', async () => {
- vi.mocked(useApis).mockReturnValue(
- queryResult({ isLoading: false, data: mockComponents })
- );
- const { user } = renderPage();
-
- expect(screen.queryByTestId('api-list-view')).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'List view' }));
- expect(screen.getAllByTestId('api-list-view').length).toBeGreaterThan(0);
- // Rows still open the API and show status.
- expect(screen.getByText('Orders API')).toBeInTheDocument();
-
- await user.click(screen.getByRole('button', { name: 'Grid view' }));
- expect(screen.queryByTestId('api-list-view')).not.toBeInTheDocument();
- });
-});
diff --git a/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx b/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx
deleted file mode 100644
index cde5bb3f0a..0000000000
--- a/portals/api-control-plane/src/features/apis/overview/OverviewTab.test.tsx
+++ /dev/null
@@ -1,218 +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 type { UseQueryResult } from '@tanstack/react-query';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import {
- useApiKeys,
- useCreateApiKey,
- useGatewayDeployments,
- useGateways,
- useRevokeApiKey,
-} from '../../../api/hooks/useMvpQueries';
-import { renderWithProviders, screen } from '../../../test/utils';
-import type {
- ApiDetail,
- ApiKeySummary,
- Gateway,
- GatewayDeployment,
-} from '../../../types/domain';
-import { OverviewTab } from './OverviewTab';
-
-vi.mock('../../../api/hooks/useMvpQueries', async (importActual) => ({
- ...(await importActual()),
- useGateways: vi.fn(),
- useGatewayDeployments: vi.fn(),
- useApiKeys: vi.fn(),
- useCreateApiKey: vi.fn(),
- useRevokeApiKey: vi.fn(),
-}));
-
-const detail: ApiDetail = {
- id: 'api-1',
- projectId: 'proj-1',
- name: 'orders-api',
- displayName: 'Orders API',
- handler: 'orders-api',
- kind: 'API_PROXY',
- status: 'ACTIVE',
- context: '/orders',
- operations: [
- { method: 'GET', path: '/items', description: 'List order items' },
- { method: 'POST', path: '/items' },
- ],
- policies: [],
- endpoints: {},
-};
-
-const gateway: Gateway = {
- id: 'gw-1',
- name: 'prod-gw',
- displayName: 'Production Gateway',
- vhost: 'mg.acme.dev',
- functionalityType: 'regular',
- mode: 'self-hosted',
- isActive: true,
-};
-
-const deployed: GatewayDeployment = {
- id: 'dep-1',
- name: 'v1.0-prod',
- gatewayId: 'gw-1',
- status: 'DEPLOYED',
- createdAt: '2026-07-01T08:00:00Z',
-};
-
-const query = (overrides: Partial>) =>
- overrides as UseQueryResult;
-
-const mutation = unknown>(mutate = vi.fn()) =>
- ({ mutate, isPending: false }) as unknown as ReturnType;
-
-describe('OverviewTab', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(useGateways).mockReturnValue(
- query({ isLoading: false, data: [gateway] })
- );
- vi.mocked(useGatewayDeployments).mockReturnValue(
- query({ isLoading: false, data: [deployed] })
- );
- vi.mocked(useApiKeys).mockReturnValue(
- query({
- isLoading: false,
- data: [
- {
- name: 'prod-key',
- maskedApiKey: 'abcd****xy',
- expiresAt: '2026-10-01T00:00:00Z',
- },
- ],
- })
- );
- vi.mocked(useCreateApiKey).mockReturnValue(
- mutation()
- );
- vi.mocked(useRevokeApiKey).mockReturnValue(
- mutation()
- );
- });
-
- it('renders the resources list with method chips', () => {
- renderWithProviders();
- expect(screen.getByText('Resources')).toBeInTheDocument();
- expect(screen.getByText('GET')).toBeInTheDocument();
- expect(screen.getByText('List order items')).toBeInTheDocument();
- expect(screen.getAllByText('/items')).toHaveLength(2);
- });
-
- it('renders the progress banner with the life-cycle steps', () => {
- renderWithProviders();
- expect(screen.getByText('Track your progress here')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Deploy' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Test' })).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: 'Publish to Devportal' })
- ).toBeInTheDocument();
- });
-
- it('advances the progress chart as the API is deployed', () => {
- // Deployed on a gateway but still a draft → Create + Deploy done: 2/4 (50%).
- renderWithProviders(
-
- );
- expect(screen.getByText('2 of 4 completed')).toBeInTheDocument();
- expect(screen.getByRole('progressbar')).toHaveAttribute(
- 'aria-valuenow',
- '50'
- );
- });
-
- it('completes every step once the API is published', () => {
- // status ACTIVE (PUBLISHED) + deployed → all four steps done: 4/4 (100%).
- renderWithProviders();
- expect(screen.getByText('4 of 4 completed')).toBeInTheDocument();
- expect(screen.getByRole('progressbar')).toHaveAttribute(
- 'aria-valuenow',
- '100'
- );
- });
-
- it('shows only Create complete when the API is not deployed', () => {
- vi.mocked(useGatewayDeployments).mockReturnValue(
- query({ isLoading: false, data: [] })
- );
- renderWithProviders(
-
- );
- expect(screen.getByText('1 of 4 completed')).toBeInTheDocument();
- expect(screen.getByRole('progressbar')).toHaveAttribute(
- 'aria-valuenow',
- '25'
- );
- });
-
- it('builds the invoke URL from the deployed gateway vhost and context', () => {
- renderWithProviders();
- expect(screen.getByText('Invoke URL')).toBeInTheDocument();
- expect(
- screen.getByDisplayValue('https://mg.acme.dev/orders')
- ).toBeInTheDocument();
- });
-
- it('hides the right column when the API is not deployed anywhere', () => {
- vi.mocked(useGatewayDeployments).mockReturnValue(
- query({ isLoading: false, data: [] })
- );
- renderWithProviders();
- expect(screen.queryByText('Invoke URL')).not.toBeInTheDocument();
- expect(screen.queryByText('API Keys')).not.toBeInTheDocument();
- });
-
- it('lists API keys and adds a new one through the dialog', async () => {
- const mutate = vi.fn(
- (_variables: unknown, options?: { onSuccess?: () => void }) =>
- options?.onSuccess?.()
- );
- vi.mocked(useCreateApiKey).mockReturnValue(
- mutation(mutate)
- );
- const { user } = renderWithProviders();
-
- expect(screen.getByText('prod-key')).toBeInTheDocument();
- expect(screen.getByText('abcd****xy')).toBeInTheDocument();
-
- await user.click(screen.getByRole('button', { name: 'Add API Key' }));
- await user.type(
- screen.getByPlaceholderText('Ex: Production Key'),
- 'Staging Key'
- );
- const dialogFields = screen.getAllByRole('textbox');
- await user.type(dialogFields[dialogFields.length - 1], 'secret-value');
- await user.click(screen.getByRole('button', { name: 'Add' }));
-
- expect(mutate).toHaveBeenCalledWith(
- {
- api: detail,
- input: { displayName: 'Staging Key', apiKey: 'secret-value' },
- },
- expect.any(Object)
- );
- });
-});
diff --git a/portals/api-control-plane/src/features/deploy/DeployPage.test.tsx b/portals/api-control-plane/src/features/deploy/DeployPage.test.tsx
deleted file mode 100644
index 5f4e6c9e30..0000000000
--- a/portals/api-control-plane/src/features/deploy/DeployPage.test.tsx
+++ /dev/null
@@ -1,232 +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 type { UseQueryResult } from '@tanstack/react-query';
-import { Route, Routes } from 'react-router-dom';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import {
- useApi,
- useDeleteGatewayDeployment,
- useDeployApi,
- useGatewayDeployments,
- useGateways,
- useRestoreGatewayDeployment,
- useUndeployGatewayDeployment,
-} from '../../api/hooks/useMvpQueries';
-import { renderWithProviders, screen } from '../../test/utils';
-import type { Api, Gateway, GatewayDeployment } from '../../types/domain';
-import { DeployPage } from './DeployPage';
-
-vi.mock('../../api/hooks/useMvpQueries', async (importActual) => ({
- ...(await importActual()),
- useApi: vi.fn(),
- useGateways: vi.fn(),
- useGatewayDeployments: vi.fn(),
- useDeployApi: vi.fn(),
- useUndeployGatewayDeployment: vi.fn(),
- useRestoreGatewayDeployment: vi.fn(),
- useDeleteGatewayDeployment: vi.fn(),
-}));
-
-const ROUTE = '/organizations/acme/projects/retail/apis/orders-api/deploy';
-
-const api: Api = {
- id: 'api-1',
- projectId: 'proj-1',
- name: 'orders-api',
- displayName: 'Orders API',
- handler: 'orders-api',
- kind: 'API_PROXY',
- status: 'ACTIVE',
- version: '1.0.0',
-};
-
-const gateway: Gateway = {
- id: 'gw-1',
- name: 'prod-gw',
- displayName: 'Production Gateway',
- vhost: 'mg.acme.dev',
- functionalityType: 'regular',
- mode: 'self-hosted',
- isActive: true,
-};
-
-const deployed: GatewayDeployment = {
- id: 'dep-1',
- name: 'v1.0-prod',
- gatewayId: 'gw-1',
- status: 'DEPLOYED',
- createdAt: '2026-07-01T08:00:00Z',
- updatedAt: '2026-07-01T08:05:00Z',
-};
-
-const query = (overrides: Partial>) =>
- ({
- refetch: vi.fn(),
- isFetching: false,
- ...overrides,
- }) as unknown as UseQueryResult;
-
-// Minimal mutation-result stub, cast to the specific hook's return type.
-const mutation = unknown>(mutate = vi.fn()) =>
- ({ mutate, isPending: false }) as unknown as ReturnType;
-
-function renderPage() {
- return renderWithProviders(
-
- }
- />
- ,
- { route: ROUTE }
- );
-}
-
-describe('DeployPage', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(useApi).mockReturnValue(query({ isLoading: false, data: api }));
- vi.mocked(useGateways).mockReturnValue(
- query({ isLoading: false, data: [gateway] })
- );
- vi.mocked(useGatewayDeployments).mockReturnValue(
- query({ isLoading: false, data: [deployed] })
- );
- vi.mocked(useDeployApi).mockReturnValue(mutation());
- vi.mocked(useUndeployGatewayDeployment).mockReturnValue(
- mutation()
- );
- vi.mocked(useRestoreGatewayDeployment).mockReturnValue(
- mutation()
- );
- vi.mocked(useDeleteGatewayDeployment).mockReturnValue(
- mutation()
- );
- });
-
- it('shows the loading state', () => {
- vi.mocked(useApi).mockReturnValue(query({ isLoading: true }));
- renderPage();
- expect(screen.getByText('Loading deploy state')).toBeInTheDocument();
- });
-
- it('prompts to add a gateway when none exist', () => {
- vi.mocked(useGateways).mockReturnValue(
- query({ isLoading: false, data: [] })
- );
- vi.mocked(useGatewayDeployments).mockReturnValue(
- query({ isLoading: false, data: [] })
- );
- renderPage();
- expect(screen.getByText('No gateway added yet')).toBeInTheDocument();
- expect(
- screen.getByRole('button', { name: 'Add Gateway' })
- ).toBeInTheDocument();
- });
-
- it('renders the gateway card expanded with status and history', () => {
- renderPage();
- // Header: gateway name, connection chip, current deployment chip.
- expect(screen.getByText('Production Gateway')).toBeInTheDocument();
- expect(screen.getAllByText('Active').length).toBeGreaterThan(0);
- expect(screen.getByText('Current Deployment:')).toBeInTheDocument();
- // First gateway is expanded: status panel + history are visible.
- expect(screen.getByText('Deployment Status')).toBeInTheDocument();
- expect(screen.getByText('API Deployment History')).toBeInTheDocument();
- expect(screen.getByText('Latest')).toBeInTheDocument();
- });
-
- it('deploys with an auto-generated {gateway}_{date}_{n} name', async () => {
- const mutate = vi.fn();
- vi.mocked(useDeployApi).mockReturnValue(
- mutation(mutate)
- );
- const { user } = renderPage();
-
- await user.click(screen.getByRole('button', { name: 'Deploy' }));
-
- expect(mutate).toHaveBeenCalledWith(
- {
- api,
- input: {
- name: expect.stringMatching(/^prod-gw_\d{4}-\d{2}-\d{2}_1$/),
- gatewayId: 'gw-1',
- base: 'current',
- },
- },
- expect.any(Object)
- );
- });
-
- it('stops the active deployment from the status panel', async () => {
- const mutate = vi.fn();
- vi.mocked(useUndeployGatewayDeployment).mockReturnValue(
- mutation(mutate)
- );
- const { user } = renderPage();
-
- await user.click(screen.getByRole('button', { name: 'Stop' }));
-
- expect(mutate).toHaveBeenCalledWith(
- { api, deployment: deployed },
- expect.any(Object)
- );
- });
-
- it('redeploys a suspended deployment', async () => {
- const suspended: GatewayDeployment = { ...deployed, status: 'UNDEPLOYED' };
- vi.mocked(useGatewayDeployments).mockReturnValue(
- query({ isLoading: false, data: [suspended] })
- );
- const mutate = vi.fn();
- vi.mocked(useRestoreGatewayDeployment).mockReturnValue(
- mutation(mutate)
- );
- const { user } = renderPage();
-
- expect(screen.getByText('Suspended')).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Redeploy' }));
-
- expect(mutate).toHaveBeenCalledWith(
- { api, deployment: suspended },
- expect.any(Object)
- );
- });
-
- it('filters gateways by search', async () => {
- const second: Gateway = {
- ...gateway,
- id: 'gw-2',
- name: 'edge-gw',
- displayName: 'Edge Gateway',
- isActive: false,
- };
- vi.mocked(useGateways).mockReturnValue(
- query({ isLoading: false, data: [gateway, second] })
- );
- const { user } = renderPage();
-
- expect(screen.getByText('Edge Gateway')).toBeInTheDocument();
- await user.type(screen.getByPlaceholderText('Search gateways'), 'prod');
-
- expect(screen.queryByText('Edge Gateway')).not.toBeInTheDocument();
- expect(screen.getByText('Production Gateway')).toBeInTheDocument();
- });
-});
diff --git a/portals/api-control-plane/src/features/manage/ManagePage.tsx b/portals/api-control-plane/src/features/manage/ManagePage.tsx
deleted file mode 100644
index 7104c9d343..0000000000
--- a/portals/api-control-plane/src/features/manage/ManagePage.tsx
+++ /dev/null
@@ -1,48 +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 { Button, Card, CardContent, Stack, TextField, PageContent, PageTitle } from '@wso2/oxygen-ui';
-
-import { useApi } from '../../api/hooks/useMvpQueries';
-import { ErrorState, LoadingState } from '../../components/StateViews';
-
-export function ManagePage() {
- const apiQuery = useApi();
-
- if (apiQuery.isLoading) return ;
- if (!apiQuery.data) return ;
-
- return (
-
-
- Manage {apiQuery.data.displayName}
- Core editable metadata only for MVP.
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/portals/api-control-plane/src/features/projects/NewProjectDialog.tsx b/portals/api-control-plane/src/features/projects/NewProjectDialog.tsx
deleted file mode 100644
index e58072df01..0000000000
--- a/portals/api-control-plane/src/features/projects/NewProjectDialog.tsx
+++ /dev/null
@@ -1,148 +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 {
- Button,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- Stack,
- TextField,
-} from '@wso2/oxygen-ui';
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import { useCreateProject } from '../../api/hooks/useMvpQueries';
-import { useNotifications } from '../../components/Notifications';
-import { routes } from '../../routes/paths';
-
-const NAME_MAX = 120;
-
-export type NewProjectDialogProps = {
- open: boolean;
- orgHandle: string;
- onClose: () => void;
-};
-
-/**
- * Create a project (platform-api `POST /api/v1/projects`). The backend persists
- * only name + description — the organization comes from the bearer token — so
- * the form is intentionally minimal. On success it navigates to the new
- * project's home.
- */
-export function NewProjectDialog({
- open,
- orgHandle,
- onClose,
-}: NewProjectDialogProps) {
- const navigate = useNavigate();
- const { notify } = useNotifications();
- const mutation = useCreateProject(orgHandle);
- const [name, setName] = useState('');
- const [description, setDescription] = useState('');
-
- // Reset fields each time the dialog opens.
- useEffect(() => {
- if (open) {
- setName('');
- setDescription('');
- }
- }, [open]);
-
- const trimmedName = name.trim();
- const canSubmit = trimmedName.length > 0 && !mutation.isPending;
-
- const handleSubmit = async () => {
- if (!canSubmit) return;
- try {
- const project = await mutation.mutateAsync({
- name: trimmedName,
- description: description.trim() || undefined,
- });
- notify('Project created', 'success');
- onClose();
- navigate(routes.projectHome(orgHandle, project.handler));
- } catch (error) {
- notify(
- error instanceof Error ? error.message : 'Failed to create project',
- 'error'
- );
- }
- };
-
- return (
-
- );
-}
diff --git a/portals/api-control-plane/src/features/projects/ProjectListPage.test.tsx b/portals/api-control-plane/src/features/projects/ProjectListPage.test.tsx
deleted file mode 100644
index c1cff68293..0000000000
--- a/portals/api-control-plane/src/features/projects/ProjectListPage.test.tsx
+++ /dev/null
@@ -1,121 +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 { Route, Routes } from 'react-router-dom';
-import type { UseQueryResult } from '@tanstack/react-query';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { projects as mockProjects } from '../../api/mocks/data';
-import type { ApiClient } from '../../api/ApiClientProvider';
-import { renderWithProviders, screen, waitFor, within } from '../../test/utils';
-import { makeConsoleScope } from '../../test/mockScope';
-import type { Project } from '../../types/domain';
-
-vi.mock('../../api/hooks/useMvpQueries', async (importActual) => ({
- ...(await importActual()),
- useProjects: vi.fn(),
-}));
-
-import { useProjects } from '../../api/hooks/useMvpQueries';
-import { ProjectListPage } from './ProjectListPage';
-
-const ORG = 'api-platform-demo';
-
-// Minimal cast helper for the bits of the query result the page reads.
-const queryResult = (overrides: Partial>) =>
- overrides as UseQueryResult;
-
-function renderPage(apiClient?: Partial) {
- return renderWithProviders(
-
- }
- />
- ,
- {
- route: `/organizations/${ORG}/projects`,
- scope: makeConsoleScope(),
- apiClient,
- }
- );
-}
-
-describe('ProjectListPage', () => {
- beforeEach(() => vi.clearAllMocks());
-
- it('shows the loading state', () => {
- vi.mocked(useProjects).mockReturnValue(queryResult({ isLoading: true }));
- renderPage();
- expect(screen.getByText('Loading projects')).toBeInTheDocument();
- });
-
- it('shows an error state with the message', () => {
- vi.mocked(useProjects).mockReturnValue(
- queryResult({ isLoading: false, error: new Error('boom') })
- );
- renderPage();
- expect(screen.getByText(/Unable to load projects\. boom/)).toBeInTheDocument();
- });
-
- it('shows the empty state when there are no projects', () => {
- vi.mocked(useProjects).mockReturnValue(
- queryResult({ isLoading: false, data: [] })
- );
- renderPage();
- expect(screen.getByText('No projects found')).toBeInTheDocument();
- });
-
- it('renders the projects and filters by search', async () => {
- vi.mocked(useProjects).mockReturnValue(
- queryResult({ isLoading: false, data: mockProjects })
- );
- const { user } = renderPage();
-
- expect(screen.getByText('Retail APIs')).toBeInTheDocument();
- expect(screen.getByText('Internal Tools')).toBeInTheDocument();
-
- await user.type(screen.getByPlaceholderText('Search projects'), 'internal');
-
- expect(screen.queryByText('Retail APIs')).not.toBeInTheDocument();
- expect(screen.getByText('Internal Tools')).toBeInTheDocument();
- });
-
- it('deletes a project after type-to-confirm', async () => {
- vi.mocked(useProjects).mockReturnValue(
- queryResult({ isLoading: false, data: mockProjects })
- );
- const deleteProject = vi.fn().mockResolvedValue(undefined);
- const { user } = renderPage({ deleteProject });
-
- // Open the actions menu on the first card (Retail APIs) and choose Delete.
- await user.click(screen.getAllByLabelText('Project actions')[0]);
- await user.click(screen.getByRole('menuitem', { name: /Delete/ }));
-
- // Type-to-confirm guards the irreversible delete.
- const dialog = screen.getByRole('dialog');
- const confirmButton = within(dialog).getByRole('button', { name: 'Delete' });
- expect(confirmButton).toBeDisabled();
-
- await user.type(within(dialog).getByRole('textbox'), 'Retail APIs');
- await user.click(confirmButton);
-
- await waitFor(() => expect(deleteProject).toHaveBeenCalledTimes(1));
- expect(deleteProject.mock.calls[0][1]).toMatchObject({ name: 'Retail APIs' });
- });
-});
diff --git a/portals/api-control-plane/src/features/projects/ProjectListPage.tsx b/portals/api-control-plane/src/features/projects/ProjectListPage.tsx
deleted file mode 100644
index 68adf63aed..0000000000
--- a/portals/api-control-plane/src/features/projects/ProjectListPage.tsx
+++ /dev/null
@@ -1,192 +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 {
- Box,
- Button,
- InputAdornment,
- PageContent,
- PageTitle,
- Stack,
- TextField,
- Typography,
-} from '@wso2/oxygen-ui';
-import { Plus, Search } from '@wso2/oxygen-ui-icons-react';
-import { useMemo, useState } from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
-
-import { useDeleteProject, useProjects } from '../../api/hooks/useMvpQueries';
-import { ProjectsGrid } from '../../components/cards/ProjectsGrid';
-import { ConfirmDialog } from '../../components/ConfirmDialog';
-import { useNotifications } from '../../components/Notifications';
-import { EmptyState, ErrorState, LoadingState } from '../../components/StateViews';
-import { routes } from '../../routes/paths';
-import { useConsoleScope } from '../../scope/ConsoleScopeProvider';
-import type { Project } from '../../types/domain';
-import { NewProjectDialog } from './NewProjectDialog';
-
-export function ProjectListPage() {
- const { orgHandle = '' } = useParams();
- const navigate = useNavigate();
- const { organization } = useConsoleScope();
- const { notify } = useNotifications();
- const [search, setSearch] = useState('');
- const [createOpen, setCreateOpen] = useState(false);
- const [toDelete, setToDelete] = useState(null);
- const projectsQuery = useProjects();
- const deleteProjectMutation = useDeleteProject();
- const projects = projectsQuery.data || [];
-
- const confirmDelete = () => {
- if (!toDelete) return;
- deleteProjectMutation.mutate(toDelete, {
- onSuccess: () => {
- notify(`Deleted "${toDelete.name}".`, 'success');
- setToDelete(null);
- },
- onError: (error) =>
- notify(
- error instanceof Error ? error.message : 'Delete failed',
- 'error'
- ),
- });
- };
-
- const filteredProjects = useMemo(() => {
- const term = search.trim().toLowerCase();
- if (!term) return projects;
- return projects.filter((project) =>
- [project.name, project.handler, project.region, project.description]
- .filter(Boolean)
- .some((field) => field!.toLowerCase().includes(term))
- );
- }, [projects, search]);
-
- const openProject = (project: Project) =>
- navigate(routes.projectHome(orgHandle, project.handler));
-
- if (projectsQuery.isLoading) return ;
- if (projectsQuery.error) {
- return (
-
- );
- }
-
- return (
-
-
- Projects
-
- {organization?.name
- ? `Project workspaces in ${organization.name}.`
- : 'Select a project to manage APIs.'}
-
-
-
-
-
-
- {projects.length === 0 ? (
- setCreateOpen(true)}
- title="No projects found"
- description="Create a project to organize and manage your APIs."
- />
- ) : (
-
-
-
- {filteredProjects.length} project
- {filteredProjects.length === 1 ? '' : 's'}
-
- setSearch(event.target.value)}
- placeholder="Search projects"
- size="small"
- slotProps={{
- input: {
- startAdornment: (
-
-
-
- ),
- },
- }}
- sx={{ minWidth: 260 }}
- value={search}
- />
-
- {filteredProjects.length === 0 ? (
-
- ) : (
-
- )}
-
- )}
-
- setCreateOpen(false)}
- open={createOpen}
- orgHandle={orgHandle}
- />
-
- setToDelete(null)}
- onConfirm={confirmDelete}
- open={toDelete !== null}
- title="Delete project"
- />
-
- );
-}
diff --git a/portals/api-control-plane/src/features/settings/SettingsLayout.tsx b/portals/api-control-plane/src/features/settings/SettingsLayout.tsx
deleted file mode 100644
index 62a20389d3..0000000000
--- a/portals/api-control-plane/src/features/settings/SettingsLayout.tsx
+++ /dev/null
@@ -1,95 +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.
- */
-
-// 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/billing/ProductActivation.tsx b/portals/api-control-plane/src/hooks/ProductActivation.tsx
similarity index 95%
rename from portals/api-control-plane/src/features/billing/ProductActivation.tsx
rename to portals/api-control-plane/src/hooks/ProductActivation.tsx
index 384f065def..fa0b7e4344 100644
--- a/portals/api-control-plane/src/features/billing/ProductActivation.tsx
+++ b/portals/api-control-plane/src/hooks/ProductActivation.tsx
@@ -18,8 +18,8 @@
import { useEffect, useRef } from 'react';
-import { runtimeConfig } from '../../config/runtime';
-import { useAuth } from '../auth/AuthProvider';
+import { runtimeConfig } from '../config/runtime';
+import { useAuth } from '../contexts/auth/AuthProvider';
// Product code this console activates on first login. Must match the billing
// product whose subscription drives APIP gateway provisioning.
diff --git a/portals/api-control-plane/src/hooks/useFooterHeight.ts b/portals/api-control-plane/src/hooks/useFooterHeight.ts
new file mode 100644
index 0000000000..e3f543dde3
--- /dev/null
+++ b/portals/api-control-plane/src/hooks/useFooterHeight.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { useEffect, useState } from 'react';
+
+import { APP_FOOTER_ID } from '../pages/appShell/appLayoutConstants';
+
+/**
+ * Measures the app footer's height.
+ *
+ * The footer sits at the bottom of the same scroll area a sticky bottom bar
+ * (`stickyBottomBarSx`) sticks to, so `bottom: 0` would leave the bar underneath
+ * it. Every such bar offsets by this value instead of a hardcoded one, because
+ * the footer's height depends on the theme's spacing and on whether its links
+ * wrap on a narrow viewport.
+ *
+ * Returns 0 when the footer is absent — a page rendered outside the app shell,
+ * or a test — which degrades to `bottom: 0` rather than throwing.
+ */
+export function useFooterHeight(): number {
+ const [height, setHeight] = useState(0);
+
+ useEffect(() => {
+ const element = document.getElementById(APP_FOOTER_ID);
+ if (!element) return;
+
+ const update = () => setHeight(element.offsetHeight);
+ update();
+
+ // The footer reflows on viewport resize (its links wrap), so a one-off
+ // measurement would leave the bar overlapping it at that width.
+ const observer = new ResizeObserver(update);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ return height;
+}
diff --git a/portals/api-control-plane/src/i18n/messages/en.json b/portals/api-control-plane/src/i18n/messages/en.json
index 0967ef424b..84c5ae99a3 100644
--- a/portals/api-control-plane/src/i18n/messages/en.json
+++ b/portals/api-control-plane/src/i18n/messages/en.json
@@ -1 +1,917 @@
-{}
+{
+ "api.create.ApiCreationSteps.step.apiType": {
+ "defaultMessage": "API type",
+ "description": "Progress step where the user picks REST, GraphQL and so on. Noun, not a command."
+ },
+ "api.create.ApiCreationSteps.step.configure": {
+ "defaultMessage": "Configure",
+ "description": "Progress step where the user fills in the API name, version and context."
+ },
+ "api.create.ApiCreationSteps.step.source": {
+ "defaultMessage": "Source",
+ "description": "Progress step where the user supplies a contract or a backend endpoint."
+ },
+ "api.create.ApiTypeSelector.badge.comingSoon": {
+ "defaultMessage": "Coming soon",
+ "description": "Badge on an API type that is planned but not released yet."
+ },
+ "api.create.ApiTypeSelector.badge.selected": {
+ "defaultMessage": "Selected",
+ "description": "Accessible label for the check mark on the chosen card."
+ },
+ "api.create.ApiTypeSelector.groupLabel": {
+ "defaultMessage": "API type",
+ "description": "Accessible name for the group of API type cards. Noun, not a command."
+ },
+ "api.create.ApiTypeSelector.subtitle": {
+ "defaultMessage": "This decides how the gateway exposes your backend. Only REST is available today.",
+ "description": "Supporting line under the API type selector heading."
+ },
+ "api.create.ApiTypeSelector.title": {
+ "defaultMessage": "What kind of API are you exposing?",
+ "description": "Heading above the grid of API type cards."
+ },
+ "api.create.ApiTypeSelector.tooltip.comingSoon": {
+ "defaultMessage": "Not available yet.",
+ "description": "Tooltip explaining why an unreleased API type card cannot be clicked."
+ },
+ "api.create.apiResourcesPreview.empty.body": {
+ "defaultMessage": "Import a contract to explore its endpoints"
+ },
+ "api.create.apiResourcesPreview.empty.title": {
+ "defaultMessage": "Resources will show here"
+ },
+ "api.create.apiResourcesPreview.source": {
+ "defaultMessage": "Source",
+ "description": "Toggle that swaps the rendered resources for the definition’s own text."
+ },
+ "api.create.apiResourcesPreview.title": {
+ "defaultMessage": "API resources"
+ },
+ "api.create.apiType.graphQl.description": {
+ "defaultMessage": "Serve a GraphQL schema through the gateway."
+ },
+ "api.create.apiType.graphQl.title": {
+ "defaultMessage": "GraphQL API"
+ },
+ "api.create.apiType.grpc.description": {
+ "defaultMessage": "Expose high-performance gRPC services."
+ },
+ "api.create.apiType.grpc.title": {
+ "defaultMessage": "gRPC API"
+ },
+ "api.create.apiType.rest.description": {
+ "defaultMessage": "Expose REST and other HTTP backends."
+ },
+ "api.create.apiType.rest.title": {
+ "defaultMessage": "REST API"
+ },
+ "api.create.apiType.webSocket.description": {
+ "defaultMessage": "Stream data over long-lived WebSocket connections."
+ },
+ "api.create.apiType.webSocket.title": {
+ "defaultMessage": "WebSocket API"
+ },
+ "api.create.apiType.webSub.description": {
+ "defaultMessage": "Deliver events to subscribers over WebSub."
+ },
+ "api.create.apiType.webSub.title": {
+ "defaultMessage": "WebSub API"
+ },
+ "api.create.approach.contract.description": {
+ "defaultMessage": "Upload or link an OpenAPI, GraphQL or gRPC definition."
+ },
+ "api.create.approach.contract.title": {
+ "defaultMessage": "Import API Contract"
+ },
+ "api.create.approach.endpoint.description": {
+ "defaultMessage": "Point the gateway at a backend URL and refine later."
+ },
+ "api.create.approach.endpoint.title": {
+ "defaultMessage": "Start with Endpoint"
+ },
+ "api.create.approach.genAi.description": {
+ "defaultMessage": "Describe the API in plain language and let AI draft it."
+ },
+ "api.create.approach.genAi.title": {
+ "defaultMessage": "Generate with AI"
+ },
+ "api.create.approach.scratch.description": {
+ "defaultMessage": "Create an empty API and add resources by hand."
+ },
+ "api.create.approach.scratch.title": {
+ "defaultMessage": "Start from Scratch"
+ },
+ "api.create.approachSelector.apiTypeSection.description": {
+ "defaultMessage": "Types that {proxyType} does not carry stay listed, greyed out.",
+ "description": "Explains why unavailable API types are still visible on the cards below."
+ },
+ "api.create.approachSelector.apiTypeSection.title": {
+ "defaultMessage": "Pick an API type"
+ },
+ "api.create.approachSelector.approach.continue": {
+ "defaultMessage": "Continue"
+ },
+ "api.create.approachSelector.approachSection.description": {
+ "defaultMessage": "Choosing one of these opens the next step."
+ },
+ "api.create.approachSelector.approachSection.title": {
+ "defaultMessage": "How do you want to start?"
+ },
+ "api.create.approachSelector.badge.comingSoon": {
+ "defaultMessage": "Coming soon",
+ "description": "Badge on an option that is planned but not released yet."
+ },
+ "api.create.approachSelector.badge.selected": {
+ "defaultMessage": "Selected",
+ "description": "Accessible label for the check mark on the chosen card."
+ },
+ "api.create.approachSelector.badge.unavailable": {
+ "defaultMessage": "Unavailable",
+ "description": "Badge on an option the currently selected proxy type does not offer."
+ },
+ "api.create.approachSelector.proxyTypeSection.description": {
+ "defaultMessage": "This decides which API types and starting points are available below."
+ },
+ "api.create.approachSelector.proxyTypeSection.title": {
+ "defaultMessage": "Pick a proxy type"
+ },
+ "api.create.approachSelector.subtitle": {
+ "defaultMessage": "Choose what you are exposing and how you want to start. Everything here can be changed after the API is created."
+ },
+ "api.create.approachSelector.title": {
+ "defaultMessage": "Create New"
+ },
+ "api.create.approachSelector.tooltip.comingSoon": {
+ "defaultMessage": "Not available yet."
+ },
+ "api.create.approachSelector.tooltip.unsupportedForProxyType": {
+ "defaultMessage": "Not available for {proxyType}."
+ },
+ "api.create.defineApi.action.back": {
+ "defaultMessage": "Back",
+ "description": "Returns to the previous step of the API creation wizard."
+ },
+ "api.create.defineApi.action.next": {
+ "defaultMessage": "Next"
+ },
+ "api.create.defineApi.contract.description": {
+ "defaultMessage": "Import from a URL, file, or SwaggerHub."
+ },
+ "api.create.defineApi.contract.title": {
+ "defaultMessage": "Start with a contract"
+ },
+ "api.create.defineApi.scratch.description": {
+ "defaultMessage": "Start blank and chat with AI to build it."
+ },
+ "api.create.defineApi.scratch.title": {
+ "defaultMessage": "Design from scratch"
+ },
+ "api.create.designWithAi.detail": {
+ "defaultMessage": "The skeleton on the right is a starting point — carry on and edit its operations by hand."
+ },
+ "api.create.designWithAi.feature": {
+ "defaultMessage": "Designing an API by chatting with AI"
+ },
+ "api.create.designWithAi.title": {
+ "defaultMessage": "Design with AI"
+ },
+ "api.create.fromContract.action.fetch": {
+ "defaultMessage": "Fetch Contract",
+ "description": "Reads the contract from the chosen source and previews its resources, without leaving the step."
+ },
+ "api.create.fromContract.action.sampleUrl": {
+ "defaultMessage": "Try with Sample URL",
+ "description": "Fills the field with a ready-made example to try the import with."
+ },
+ "api.create.fromContract.gitHub.authorize": {
+ "defaultMessage": "Authorize With GitHub"
+ },
+ "api.create.fromContract.gitHub.connectTitle": {
+ "defaultMessage": "Connect Your Repository"
+ },
+ "api.create.fromContract.gitHub.invalid": {
+ "defaultMessage": "Enter a valid GitHub repository URL."
+ },
+ "api.create.fromContract.gitHub.label": {
+ "defaultMessage": "Public Repository URL"
+ },
+ "api.create.fromContract.gitHub.or": {
+ "defaultMessage": "OR",
+ "description": "Separates the two ways of reaching a repository: a public URL, or authorizing with GitHub."
+ },
+ "api.create.fromContract.gitHub.placeholder": {
+ "defaultMessage": "https://github.com/org/repo"
+ },
+ "api.create.fromContract.gitHub.required": {
+ "defaultMessage": "The repository URL cannot be empty"
+ },
+ "api.create.fromContract.source.file": {
+ "defaultMessage": "Upload"
+ },
+ "api.create.fromContract.source.gitHub": {
+ "defaultMessage": "GitHub"
+ },
+ "api.create.fromContract.source.label": {
+ "defaultMessage": "Import the contract from",
+ "description": "Label over the picker that chooses where the API contract is read from."
+ },
+ "api.create.fromContract.source.swaggerHub": {
+ "defaultMessage": "SwaggerHub"
+ },
+ "api.create.fromContract.source.url": {
+ "defaultMessage": "URL"
+ },
+ "api.create.fromContract.spec.oversized": {
+ "defaultMessage": "That file is too large to validate in the browser."
+ },
+ "api.create.fromContract.spec.unreachable": {
+ "defaultMessage": "That contract could not be downloaded. Check the URL, and that the host allows cross-origin requests."
+ },
+ "api.create.fromContract.spec.unreadable": {
+ "defaultMessage": "That contract could not be read as YAML or JSON."
+ },
+ "api.create.fromContract.spec.unsupportedSource": {
+ "defaultMessage": "Importing from this source is not available yet."
+ },
+ "api.create.fromContract.swaggerHub.apiLabel": {
+ "defaultMessage": "API"
+ },
+ "api.create.fromContract.swaggerHub.apiPlaceholder": {
+ "defaultMessage": "Select an API"
+ },
+ "api.create.fromContract.swaggerHub.authorized": {
+ "defaultMessage": "Authorized",
+ "description": "SwaggerHub access mode that reads private definitions. Not released yet."
+ },
+ "api.create.fromContract.swaggerHub.authorizedHint": {
+ "defaultMessage": "Not available yet."
+ },
+ "api.create.fromContract.swaggerHub.lookupFailed": {
+ "defaultMessage": "SwaggerHub could not be reached. Try again."
+ },
+ "api.create.fromContract.swaggerHub.notFound": {
+ "defaultMessage": "No SwaggerHub organization with public APIs under that name."
+ },
+ "api.create.fromContract.swaggerHub.organizationLabel": {
+ "defaultMessage": "SwaggerHub Organization"
+ },
+ "api.create.fromContract.swaggerHub.organizationPlaceholder": {
+ "defaultMessage": "Enter SwaggerHub Organization here"
+ },
+ "api.create.fromContract.swaggerHub.organizationRequired": {
+ "defaultMessage": "The SwaggerHub organization cannot be empty"
+ },
+ "api.create.fromContract.swaggerHub.partialListing": {
+ "defaultMessage": "Showing {shown} of {total} APIs in this organization.",
+ "description": "The registry pages long listings; only the first page is offered."
+ },
+ "api.create.fromContract.swaggerHub.public": {
+ "defaultMessage": "Public",
+ "description": "SwaggerHub access mode that reads publicly listed definitions."
+ },
+ "api.create.fromContract.swaggerHub.refresh": {
+ "defaultMessage": "Refresh SwaggerHub organizations",
+ "description": "Accessible name for the reload button on the organization field."
+ },
+ "api.create.fromContract.swaggerHub.searching": {
+ "defaultMessage": "Looking up organization…"
+ },
+ "api.create.fromContract.swaggerHub.versionLabel": {
+ "defaultMessage": "Version"
+ },
+ "api.create.fromContract.swaggerHub.versionPlaceholder": {
+ "defaultMessage": "Select a version"
+ },
+ "api.create.fromContract.title": {
+ "defaultMessage": "Create API Proxy from Contract"
+ },
+ "api.create.fromContract.upload.accepted": {
+ "defaultMessage": "Accepted file types: {extensions}"
+ },
+ "api.create.fromContract.upload.action": {
+ "defaultMessage": "Upload"
+ },
+ "api.create.fromContract.upload.hint": {
+ "defaultMessage": "Drag & Drop your files or click to select files"
+ },
+ "api.create.fromContract.upload.remove": {
+ "defaultMessage": "Remove {fileName}",
+ "description": "Accessible name for the button that discards the chosen file."
+ },
+ "api.create.fromContract.upload.required": {
+ "defaultMessage": "Select an API contract file to continue"
+ },
+ "api.create.fromContract.upload.title": {
+ "defaultMessage": "Upload API Contract"
+ },
+ "api.create.fromContract.upload.unsupported": {
+ "defaultMessage": "That file type is not supported. Accepted types: {extensions}"
+ },
+ "api.create.fromContract.url.invalid": {
+ "defaultMessage": "Enter a valid HTTP or HTTPS URL."
+ },
+ "api.create.fromContract.url.label": {
+ "defaultMessage": "URL for API Contract"
+ },
+ "api.create.fromContract.url.placeholder": {
+ "defaultMessage": "Enter URL for API Contract here"
+ },
+ "api.create.fromContract.url.required": {
+ "defaultMessage": "The URL for the API contract cannot be empty"
+ },
+ "api.create.fromEndpoint.action.back": {
+ "defaultMessage": "Back",
+ "description": "Returns to the previous step of the API creation wizard."
+ },
+ "api.create.fromEndpoint.action.clear": {
+ "defaultMessage": "Clear {label}",
+ "description": "Accessible name for the button that empties a URL field. {label} is that field’s own label."
+ },
+ "api.create.fromEndpoint.action.next": {
+ "defaultMessage": "Next"
+ },
+ "api.create.fromEndpoint.apiType.label": {
+ "defaultMessage": "Select API Type"
+ },
+ "api.create.fromEndpoint.error.invalidUrl": {
+ "defaultMessage": "Enter a valid HTTP or HTTPS URL."
+ },
+ "api.create.fromEndpoint.error.productionEmpty": {
+ "defaultMessage": "The Production Backend URL cannot be empty"
+ },
+ "api.create.fromEndpoint.production.helper": {
+ "defaultMessage": "The backend the gateway routes production traffic to."
+ },
+ "api.create.fromEndpoint.production.label": {
+ "defaultMessage": "Production Backend URL"
+ },
+ "api.create.fromEndpoint.production.placeholder": {
+ "defaultMessage": "Enter the production backend URL"
+ },
+ "api.create.fromEndpoint.sandbox.helper": {
+ "defaultMessage": "A non-production backend used for testing this API."
+ },
+ "api.create.fromEndpoint.sandbox.label": {
+ "defaultMessage": "Sandbox Backend URL (optional)"
+ },
+ "api.create.fromEndpoint.sandbox.placeholder": {
+ "defaultMessage": "Enter the sandbox backend URL"
+ },
+ "api.create.fromEndpoint.status.validMark": {
+ "defaultMessage": "Valid URL",
+ "description": "Accessible label for the check mark shown inside a URL field that passed validation."
+ },
+ "api.create.fromEndpoint.subtitle": {
+ "defaultMessage": "Point the gateway at your backend. Everything else is filled in on the next step."
+ },
+ "api.create.fromEndpoint.title": {
+ "defaultMessage": "Create API Proxy from Endpoint"
+ },
+ "api.create.generalForm.action.back": {
+ "defaultMessage": "Back"
+ },
+ "api.create.generalForm.action.create": {
+ "defaultMessage": "Create"
+ },
+ "api.create.generalForm.advanced": {
+ "defaultMessage": "Advanced",
+ "description": "Label on the disclosure holding the optional settings."
+ },
+ "api.create.generalForm.backendAuth.description": {
+ "defaultMessage": "Credentials the gateway uses to call the backend."
+ },
+ "api.create.generalForm.backendAuth.label": {
+ "defaultMessage": "Backend authentication"
+ },
+ "api.create.generalForm.backendAuth.option.apiKey": {
+ "defaultMessage": "API key"
+ },
+ "api.create.generalForm.backendAuth.option.basic": {
+ "defaultMessage": "Basic authentication"
+ },
+ "api.create.generalForm.backendAuth.option.bearer": {
+ "defaultMessage": "Bearer token"
+ },
+ "api.create.generalForm.backendAuth.option.none": {
+ "defaultMessage": "None",
+ "description": "Backend authentication option meaning no credentials are sent."
+ },
+ "api.create.generalForm.basePath.helper": {
+ "defaultMessage": "Routing base path for this API. Defaults from the identifier and version."
+ },
+ "api.create.generalForm.basePath.label": {
+ "defaultMessage": "Base Path"
+ },
+ "api.create.generalForm.description.label": {
+ "defaultMessage": "Description"
+ },
+ "api.create.generalForm.displayName.label": {
+ "defaultMessage": "Display name"
+ },
+ "api.create.generalForm.identifier.helper": {
+ "defaultMessage": "URL-friendly. Auto-generated from the display name."
+ },
+ "api.create.generalForm.identifier.label": {
+ "defaultMessage": "Identifier"
+ },
+ "api.create.generalForm.proxyType.description": {
+ "defaultMessage": "Expose your service as an HTTP API proxy."
+ },
+ "api.create.generalForm.proxyType.title": {
+ "defaultMessage": "HTTP"
+ },
+ "api.create.generalForm.sandboxUrl.label": {
+ "defaultMessage": "Sandbox URL (optional)"
+ },
+ "api.create.generalForm.section.backendEndpoint": {
+ "defaultMessage": "Backend endpoint"
+ },
+ "api.create.generalForm.section.basicInformation": {
+ "defaultMessage": "Basic information"
+ },
+ "api.create.generalForm.subtitle": {
+ "defaultMessage": "Provide the details to configure and expose your API proxy."
+ },
+ "api.create.generalForm.targetUrl.helper": {
+ "defaultMessage": "The backend the gateway routes to."
+ },
+ "api.create.generalForm.targetUrl.label": {
+ "defaultMessage": "Target URL"
+ },
+ "api.create.generalForm.title": {
+ "defaultMessage": "Create an API Proxy"
+ },
+ "api.create.generalForm.transport.http": {
+ "defaultMessage": "HTTP"
+ },
+ "api.create.generalForm.transport.https": {
+ "defaultMessage": "HTTPS"
+ },
+ "api.create.generalForm.transport.label": {
+ "defaultMessage": "Transport"
+ },
+ "api.create.generalForm.version.helper": {
+ "defaultMessage": "e.g. 1.0.0"
+ },
+ "api.create.generalForm.version.label": {
+ "defaultMessage": "Version"
+ },
+ "api.create.proxyType.apiProxy.description": {
+ "defaultMessage": "Expose an existing HTTP backend through the gateway."
+ },
+ "api.create.proxyType.apiProxy.title": {
+ "defaultMessage": "API Proxy"
+ },
+ "api.create.proxyType.eventProxy.description": {
+ "defaultMessage": "Publish and subscribe to event streams over WebSub."
+ },
+ "api.create.proxyType.eventProxy.title": {
+ "defaultMessage": "Event Proxy"
+ },
+ "api.create.proxyType.mcpProxy.description": {
+ "defaultMessage": "Proxy an existing MCP server, or generate one from an API."
+ },
+ "api.create.proxyType.mcpProxy.title": {
+ "defaultMessage": "MCP Proxy"
+ },
+ "apiCard.delete": {
+ "defaultMessage": "Delete {apiName}",
+ "description": "Accessible label for deleting an API from API card"
+ },
+ "apiCard.updated": {
+ "defaultMessage": "Updated {relative}",
+ "description": "Card footer timestamp; {relative} is a phrase such as \"3 hours ago\"."
+ },
+ "apiControlPlane.navigation.useSettingsTabs.generalTab": {
+ "defaultMessage": "General",
+ "description": "Label for the built-in first tab of the Settings page. A noun naming the section, not a command."
+ },
+ "apiControlPlane.pages.appShell.APIQuickSelector.apis": {
+ "defaultMessage": "APIs"
+ },
+ "apiControlPlane.pages.appShell.APIQuickSelector.failed.to.load.apis": {
+ "defaultMessage": "Failed to load APIs"
+ },
+ "apiControlPlane.pages.appShell.APIQuickSelector.loading": {
+ "defaultMessage": "Loading..."
+ },
+ "apiControlPlane.pages.appShell.APIQuickSelector.no.apis.available": {
+ "defaultMessage": "No APIs available"
+ },
+ "apiControlPlane.pages.appShell.ProjectQuickSelector.failed.to.load.projects": {
+ "defaultMessage": "Failed to load projects"
+ },
+ "apiControlPlane.pages.appShell.ProjectQuickSelector.loading": {
+ "defaultMessage": "Loading..."
+ },
+ "apiControlPlane.pages.appShell.ProjectQuickSelector.no.projects.available": {
+ "defaultMessage": "No projects available"
+ },
+ "apiControlPlane.pages.appShell.ProjectQuickSelector.projects": {
+ "defaultMessage": "Projects"
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.context.label": {
+ "defaultMessage": "Context",
+ "description": "Label for the API base path shown in the API detail header, e.g. \"/orders\"."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.deployToGateway": {
+ "defaultMessage": "Deploy to Gateway",
+ "description": "Button on the API overview header that opens the API's deployment page."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.gatewayManaged": {
+ "defaultMessage": "Gateway-managed",
+ "description": "Chip marking an API that was discovered from a gateway and cannot be edited here."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.gatewayManagedHint": {
+ "defaultMessage": "Discovered from a data-plane gateway, so it is read-only in this console.",
+ "description": "Tooltip explaining the gateway-managed chip."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.lastUpdated.label": {
+ "defaultMessage": "Last updated",
+ "description": "Label for when the API definition last changed, shown in the API detail header."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.ApiDetailPage.transports.label": {
+ "defaultMessage": "Transports",
+ "description": "Label for the protocols an API is exposed over (HTTP, HTTPS)."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.develop.DocumentsPage.subtitle": {
+ "defaultMessage": "Documentation for {apiName}",
+ "description": "Sub-header under the section name; {apiName} is the API display name."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.develop.DocumentsPage.title": {
+ "defaultMessage": "Documents"
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.develop.PoliciesPage.subtitle": {
+ "defaultMessage": "Policies attached to {apiName}",
+ "description": "Sub-header under the section name; {apiName} is the API display name."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.develop.PoliciesPage.title": {
+ "defaultMessage": "Policies"
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.develop.RoutingPage.subtitle": {
+ "defaultMessage": "Routing for {apiName}",
+ "description": "Sub-header under the section name; {apiName} is the API display name."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.apis.develop.RoutingPage.title": {
+ "defaultMessage": "Routing"
+ },
+ "apiControlPlane.pages.appShell.appShellPages.projects.ExploreMoreCard.explore.more": {
+ "defaultMessage": "Explore More"
+ },
+ "apiControlPlane.pages.appShell.appShellPages.settings.GeneralSettingsPage.mvpScope": {
+ "defaultMessage": "Advanced organization admin settings, governance, marketplace, and developer portal configuration are intentionally excluded from the MVP replacement app.",
+ "description": "Body copy explaining which settings areas the MVP deliberately leaves out."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.settings.SettingsLayout.subtitle": {
+ "defaultMessage": "Minimal settings overview for {subject}.",
+ "description": "Sub-heading of the Settings page. {subject} is the display name of the organization or project being configured — never translated."
+ },
+ "apiControlPlane.pages.appShell.appShellPages.settings.SettingsLayout.title": {
+ "defaultMessage": "Settings",
+ "description": "Heading of the Settings page."
+ },
+ "apiListPage.createApiButton": {
+ "defaultMessage": "Create API",
+ "description": "Button label for creating a new API"
+ },
+ "apiListPage.subHeader": {
+ "defaultMessage": "REST APIs in this project.",
+ "description": "Sub header for the API list page"
+ },
+ "apiListPage.title": {
+ "defaultMessage": "APIs",
+ "description": "Page title for the API list page"
+ },
+ "appLayout.footer.privacyPolicy": {
+ "defaultMessage": "Privacy Policy",
+ "description": "Footer link to the Privacy Policy page"
+ },
+ "appLayout.footer.termsOfUse": {
+ "defaultMessage": "Terms of Use",
+ "description": "Footer link to the Terms of Use page"
+ },
+ "appLayout.notificationPanel.headerTitle": {
+ "defaultMessage": "Notifications",
+ "description": "Header title for the notification panel"
+ },
+ "appShell.adminPage.feature": {
+ "defaultMessage": "Administration for this API"
+ },
+ "appShell.alertsPage.feature": {
+ "defaultMessage": "Observability alerts for this API"
+ },
+ "appShell.apiChatPage.feature": {
+ "defaultMessage": "Testing this API through chat"
+ },
+ "appShell.apiConsolePage.feature": {
+ "defaultMessage": "An interactive API console"
+ },
+ "appShell.compliancePage.feature": {
+ "defaultMessage": "Compliance reporting for this API"
+ },
+ "appShell.deployPage.header": {
+ "defaultMessage": "Deploy {apiName}"
+ },
+ "appShell.deployPage.noGatewaysMatchSearch": {
+ "defaultMessage": "No gateways match your search"
+ },
+ "appShell.deployPage.subHeader": {
+ "defaultMessage": "Deploy the current working copy to a gateway, and manage existing deployments."
+ },
+ "appShell.header.api.aria": {
+ "defaultMessage": "APIs"
+ },
+ "appShell.header.api.empty": {
+ "defaultMessage": "No APIs found"
+ },
+ "appShell.header.api.goToProjectLevel": {
+ "defaultMessage": "Go to project level"
+ },
+ "appShell.header.api.label": {
+ "defaultMessage": "APIs"
+ },
+ "appShell.header.api.noResults": {
+ "defaultMessage": "No matching APIs"
+ },
+ "appShell.header.api.placeholder": {
+ "defaultMessage": "Search APIs..."
+ },
+ "appShell.header.api.search": {
+ "defaultMessage": "Search"
+ },
+ "appShell.header.api.select": {
+ "defaultMessage": "Select API"
+ },
+ "appShell.header.notifications": {
+ "defaultMessage": "Notifications"
+ },
+ "appShell.header.org.aria": {
+ "defaultMessage": "Organizations"
+ },
+ "appShell.header.org.empty": {
+ "defaultMessage": "No organizations found"
+ },
+ "appShell.header.org.label": {
+ "defaultMessage": "Organizations"
+ },
+ "appShell.header.org.noResults": {
+ "defaultMessage": "No matching organizations"
+ },
+ "appShell.header.org.placeholder": {
+ "defaultMessage": "Search organizations..."
+ },
+ "appShell.header.project.aria": {
+ "defaultMessage": "Projects"
+ },
+ "appShell.header.project.empty": {
+ "defaultMessage": "No projects found"
+ },
+ "appShell.header.project.goToOrganization": {
+ "defaultMessage": "Go to organization level"
+ },
+ "appShell.header.project.label": {
+ "defaultMessage": "Projects"
+ },
+ "appShell.header.project.noResults": {
+ "defaultMessage": "No matching projects"
+ },
+ "appShell.header.project.placeholder": {
+ "defaultMessage": "Search projects..."
+ },
+ "appShell.header.project.select": {
+ "defaultMessage": "Select Project"
+ },
+ "appShell.header.title": {
+ "defaultMessage": "API Platform"
+ },
+ "appShell.insightsPage.feature": {
+ "defaultMessage": "API insights"
+ },
+ "appShell.lifeCyclePage.feature": {
+ "defaultMessage": "Lifecycle management for this API"
+ },
+ "appShell.metricsPage.feature": {
+ "defaultMessage": "Observability metrics for this API"
+ },
+ "appShell.monetizePage.feature": {
+ "defaultMessage": "Monetization for this API"
+ },
+ "appShell.projectHomePage.noAdditionalMetadata": {
+ "defaultMessage": "No additional project metadata available."
+ },
+ "appShell.projectHomePage.projectDetails": {
+ "defaultMessage": "Project details"
+ },
+ "appShell.projectHomePage.viewAllAPIs": {
+ "defaultMessage": "View all APIs ({count})"
+ },
+ "appShell.runtimeLogsPage.header": {
+ "defaultMessage": "Observability"
+ },
+ "appShell.runtimeLogsPage.subHeader": {
+ "defaultMessage": "Runtime logs for {apiHandler}."
+ },
+ "appShell.testPage.header": {
+ "defaultMessage": "Test {apiName}"
+ },
+ "appShell.testPage.subHeader": {
+ "defaultMessage": "Use the following curl command to test the API."
+ },
+ "components.comingSoon.body": {
+ "defaultMessage": "{feature} will be available soon."
+ },
+ "components.comingSoon.title": {
+ "defaultMessage": "Coming Soon"
+ },
+ "components.common.searchableComplexSelect.dropdownClickAriaLabel": {
+ "defaultMessage": "Open {label}"
+ },
+ "gateways.filter.all": {
+ "defaultMessage": "All"
+ },
+ "gateways.filter.managed": {
+ "defaultMessage": "Managed"
+ },
+ "gateways.filter.self": {
+ "defaultMessage": "Self-hosted"
+ },
+ "gateways.provisionButton": {
+ "defaultMessage": "Provision gateway"
+ },
+ "gateways.subtitle": {
+ "defaultMessage": "Provision and manage self-hosted or WSO2-managed gateways to expose your APIs to clients."
+ },
+ "gateways.title": {
+ "defaultMessage": "Gateways"
+ },
+ "project.card.actionsLabel": {
+ "defaultMessage": "Project actions",
+ "description": "Accessible label for the button opening the card overflow menu."
+ },
+ "project.card.apiCount": {
+ "defaultMessage": "{count, plural, one {# API} other {# APIs}}"
+ },
+ "project.card.apiCountLoading": {
+ "defaultMessage": "… APIs",
+ "description": "Placeholder shown while the API count is still loading."
+ },
+ "project.card.defaultBadge": {
+ "defaultMessage": "DEFAULT",
+ "description": "Badge marking the organization’s default project."
+ },
+ "project.card.delete": {
+ "defaultMessage": "Delete"
+ },
+ "project.card.deployedCount": {
+ "defaultMessage": "{count} deployed"
+ },
+ "project.card.deployedCountLoading": {
+ "defaultMessage": "… deployed",
+ "description": "Placeholder shown while the deployed count is still loading."
+ },
+ "project.card.fallbackDescription": {
+ "defaultMessage": "Project workspace",
+ "description": "Shown in place of a description when the project has none."
+ },
+ "project.card.neverUpdated": {
+ "defaultMessage": "Not updated yet"
+ },
+ "project.card.settingsLabel": {
+ "defaultMessage": "Project settings"
+ },
+ "project.card.updatedAt": {
+ "defaultMessage": "Updated {relative}",
+ "description": "Footer timestamp; {relative} is a phrase such as \"3 hours ago\"."
+ },
+ "project.list.count": {
+ "defaultMessage": "{count, plural, one {# project} other {# projects}}"
+ },
+ "project.list.createProjectButton": {
+ "defaultMessage": "Create Project"
+ },
+ "project.list.delete.confirmInputLabel": {
+ "defaultMessage": "Type \"{name}\" to confirm",
+ "description": "Label for the type-to-confirm field guarding an irreversible delete."
+ },
+ "project.list.delete.confirmLabel": {
+ "defaultMessage": "Delete"
+ },
+ "project.list.delete.failed": {
+ "defaultMessage": "Delete failed",
+ "description": "Fallback toast when the server gives no reason for a failure."
+ },
+ "project.list.delete.message": {
+ "defaultMessage": "This permanently deletes the project \"{name}\" and its configuration. A project that still has APIs cannot be deleted. This action is irreversible."
+ },
+ "project.list.delete.succeeded": {
+ "defaultMessage": "Deleted \"{name}\"."
+ },
+ "project.list.delete.title": {
+ "defaultMessage": "Delete project"
+ },
+ "project.list.empty.action": {
+ "defaultMessage": "Create project"
+ },
+ "project.list.empty.description": {
+ "defaultMessage": "Create a project to organize and manage your APIs."
+ },
+ "project.list.empty.title": {
+ "defaultMessage": "No projects found"
+ },
+ "project.list.error.message": {
+ "defaultMessage": "Unable to load projects. {reason}"
+ },
+ "project.list.loading": {
+ "defaultMessage": "Loading projects"
+ },
+ "project.list.noMatches.description": {
+ "defaultMessage": "Try a different project name or handle."
+ },
+ "project.list.noMatches.title": {
+ "defaultMessage": "No matching projects"
+ },
+ "project.list.rowsPerPage": {
+ "defaultMessage": "Projects per page"
+ },
+ "project.list.searchPlaceholder": {
+ "defaultMessage": "Search projects"
+ },
+ "project.list.sort.nameAscending": {
+ "defaultMessage": "Name (A–Z)",
+ "description": "Sort option: alphabetical by project name, ascending."
+ },
+ "project.list.sort.nameDescending": {
+ "defaultMessage": "Name (Z–A)",
+ "description": "Sort option: alphabetical by project name, descending."
+ },
+ "project.list.sort.newest": {
+ "defaultMessage": "Newest first",
+ "description": "Sort option: by creation date, most recent project first."
+ },
+ "project.list.sort.oldest": {
+ "defaultMessage": "Oldest first",
+ "description": "Sort option: by creation date, earliest project first."
+ },
+ "project.list.sortLabel": {
+ "defaultMessage": "Sort by",
+ "description": "Label for the control choosing the project list order."
+ },
+ "project.list.subHeader.default": {
+ "defaultMessage": "Select a project to manage APIs."
+ },
+ "project.list.subHeader.withOrganization": {
+ "defaultMessage": "Project workspaces in {organizationName}."
+ },
+ "project.list.title": {
+ "defaultMessage": "Projects"
+ },
+ "scopeGate.api": {
+ "defaultMessage": "API"
+ },
+ "scopeGate.apiSelect.empty": {
+ "defaultMessage": "No APIs available"
+ },
+ "scopeGate.goToApiLevel": {
+ "defaultMessage": "Go to API Level"
+ },
+ "scopeGate.goToProjectLevel": {
+ "defaultMessage": "Go to Project Level"
+ },
+ "scopeGate.goToProjects": {
+ "defaultMessage": "Go to Projects"
+ },
+ "scopeGate.loadingApis": {
+ "defaultMessage": "Loading APIs..."
+ },
+ "scopeGate.loadingProjects": {
+ "defaultMessage": "Loading projects..."
+ },
+ "scopeGate.noApis": {
+ "defaultMessage": "This project has no APIs yet. Create an API to continue."
+ },
+ "scopeGate.noProjects": {
+ "defaultMessage": "You have no projects yet. Create a project to continue."
+ },
+ "scopeGate.project": {
+ "defaultMessage": "Project"
+ },
+ "scopeGate.projectSelect.empty": {
+ "defaultMessage": "No projects available"
+ },
+ "scopeGate.selectProject": {
+ "defaultMessage": "Select a project to switch to project level and continue."
+ },
+ "scopeGate.selectProjectAndApi": {
+ "defaultMessage": "Select a project and an API to switch to API level and continue."
+ },
+ "summary.card.error": {
+ "defaultMessage": "Unable to load."
+ },
+ "summary.card.retry": {
+ "defaultMessage": "Retry"
+ },
+ "summary.card.see.more": {
+ "defaultMessage": "See more"
+ }
+}
diff --git a/portals/api-control-plane/src/layouts/AppHeader.tsx b/portals/api-control-plane/src/layouts/AppHeader.tsx
deleted file mode 100644
index 3d6bb7ceab..0000000000
--- a/portals/api-control-plane/src/layouts/AppHeader.tsx
+++ /dev/null
@@ -1,151 +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 {
- Badge,
- ColorSchemeToggle,
- ComplexSelect,
- Header,
- IconButton,
- Tooltip,
- UserMenu,
- useAppShell,
-} from '@wso2/oxygen-ui';
-import { Bell, Boxes, Building2, LogOut, WSO2 } from '@wso2/oxygen-ui-icons-react';
-import { useNavigate } from 'react-router-dom';
-
-import { useAuth } from '../features/auth/AuthProvider';
-import { routes } from '../routes/paths';
-import { useConsoleScope } from '../scope/ConsoleScopeProvider';
-
-export function AppHeader() {
- const navigate = useNavigate();
- const { actions } = useAppShell();
- const { organization, organizations, params, project, projects } =
- useConsoleScope();
- const auth = useAuth();
-
- const userName = auth.user?.name || 'User';
- const userEmail = auth.user?.email || '';
-
- const changeOrganization = (orgHandle: string) => {
- if (!orgHandle || orgHandle === params.orgHandle) return;
- navigate(routes.organizationHome(orgHandle));
- };
-
- const changeProject = (projectHandler: string) => {
- if (!params.orgHandle || !projectHandler) return;
- navigate(routes.projectHome(params.orgHandle, projectHandler));
- };
-
- // organizations may not be loaded yet on first paint; keep the current org
- // selectable so the switcher never renders an out-of-range value.
- const orgOptions: { handle: string; name: string }[] =
- organizations.length > 0
- ? organizations
- : params.orgHandle
- ? [{ handle: params.orgHandle, name: organization?.name || params.orgHandle }]
- : [];
- const projectOptions: { handler: string; name: string }[] =
- projects.length > 0 ? projects : project ? [project] : [];
-
- return (
-
-
-
-
-
-
- API Platform
-
-
- {params.orgHandle && (
-
- changeOrganization(String(event.target.value))}
- sx={{ minWidth: 220 }}
- >
- {orgOptions.map((item) => (
-
-
-
-
-
-
- ))}
-
-
- {params.projectHandler && (
- changeProject(String(event.target.value))}
- sx={{ minWidth: 220 }}
- >
- {projectOptions.map((item) => (
-
-
-
-
-
-
- ))}
-
- )}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } onClick={auth.logout} />
-
-
-
- );
-}
diff --git a/portals/api-control-plane/src/layouts/AppLayout.tsx b/portals/api-control-plane/src/layouts/AppLayout.tsx
deleted file mode 100644
index e390a560af..0000000000
--- a/portals/api-control-plane/src/layouts/AppLayout.tsx
+++ /dev/null
@@ -1,150 +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 {
- AppBreadcrumbs,
- AppShell,
- Box,
- Footer,
- NotificationPanel,
-} from '@wso2/oxygen-ui';
-import type { BreadcrumbItem } from '@wso2/oxygen-ui';
-import { Bell } from '@wso2/oxygen-ui-icons-react';
-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';
-
-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) {
- crumbs.push({
- key: 'org',
- label: organization?.name || params.orgHandle,
- onClick: () => navigate(routes.organizationHome(params.orgHandle!)),
- });
- }
- if (params.orgHandle && params.projectHandler) {
- crumbs.push({
- key: 'project',
- label: project?.name || params.projectHandler,
- onClick: () =>
- navigate(routes.projectHome(params.orgHandle!, params.projectHandler!)),
- });
- }
- if (params.orgHandle && params.projectHandler && params.apiHandler) {
- crumbs.push({
- key: 'api',
- label: component?.displayName || params.apiHandler,
- onClick: () =>
- navigate(
- routes.api(
- params.orgHandle!,
- params.projectHandler!,
- params.apiHandler!
- )
- ),
- });
- }
- // The final crumb is the current page — render it as plain text (no nav).
- const breadcrumbItems = crumbs.map((crumb, index) =>
- index === crumbs.length - 1 ? { ...crumb, onClick: undefined } : crumb
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {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. */}
-
-
-
-
-
-
-
-
-
-
-
-
- Notifications
-
-
-
-
-
-
-
-
- );
-}
diff --git a/portals/api-control-plane/src/layouts/AppSidebar.tsx b/portals/api-control-plane/src/layouts/AppSidebar.tsx
deleted file mode 100644
index b66ea1dc72..0000000000
--- a/portals/api-control-plane/src/layouts/AppSidebar.tsx
+++ /dev/null
@@ -1,64 +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 { Sidebar, useAppShell } from '@wso2/oxygen-ui';
-import { Link } from 'react-router-dom';
-
-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, ...footerGroups]
- .flatMap((group) => group.items)
- .find((item) => item.isActive)?.id;
-
- return (
-
-
- {groups.map((group) => (
-
- {group.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 fffafe9016..65400341c9 100644
--- a/portals/api-control-plane/src/navigation/navigationRegistry.test.ts
+++ b/portals/api-control-plane/src/navigation/navigationRegistry.test.ts
@@ -18,73 +18,377 @@
import { describe, expect, it } from 'vitest';
+import { organizations, projects } from '../api/mocks/data';
+import { routes } from '../routes/paths';
+import { makeConsoleScope } from '../test/mockScope';
import { navigationRegistry } from './navigationRegistry';
-const matcherFor = (id: string) => {
+const definitionFor = (id: string) => {
const item = navigationRegistry.find((entry) => entry.id === id);
- if (!item?.match) throw new Error(`No match predicate for ${id}`);
- return item.match;
+ if (!item) throw new Error(`No navigation item ${id}`);
+ return item;
+};
+
+const matcherFor = (id: string) => {
+ const { match } = definitionFor(id);
+ if (!match) throw new Error(`No match predicate for ${id}`);
+ return match;
};
-const ORG = '/organizations/acme';
-const PROJECT = `${ORG}/projects/orders`;
+const ORG = `/organizations/${organizations[0].id}`;
+const PROJECT = `${ORG}/projects/${projects[0].id}`;
const API = `${PROJECT}/apis/api-1`;
+const SELECT = `${ORG}/select-scope`;
-describe('navigation match predicates', () => {
- it('matches home only at the org home path', () => {
- const match = matcherFor('organization-home');
- expect(match(`${ORG}/home`)).toBe(true);
- expect(match(`${PROJECT}/home`)).toBe(false);
+/** Scope as the shell sees it at each depth, for exercising `to`. */
+const atOrg = () =>
+ makeConsoleScope({
+ isApiScope: false,
+ isProjectScope: false,
+ params: { orgHandle: organizations[0].id },
+ project: undefined,
+ });
+const atProject = () => makeConsoleScope();
+const atApi = () =>
+ makeConsoleScope({
+ isApiScope: true,
+ params: {
+ apiHandler: 'api-1',
+ orgHandle: organizations[0].id,
+ projectHandler: projects[0].id,
+ },
});
- it('requires the org prefix for project home', () => {
- const match = matcherFor('project-home');
- expect(match(`${PROJECT}/home`)).toBe(true);
- // A bare /projects/x/home without the org segment must not match.
- expect(match('/projects/orders/home')).toBe(false);
+/*
+ * The sidebar has one item per *concern*, not per scope: Overview is the summary
+ * of wherever you are, and the API-level items follow the API you're in. These
+ * tests cover the two halves that makes possible — `to` picking the right page
+ * for the current scope, and `match` keeping the item highlighted on every page
+ * it can reach.
+ */
+describe('Overview adapts to the deepest scope', () => {
+ it('links to the organization home when nothing deeper is selected', () => {
+ expect(definitionFor('overview').to(atOrg())).toBe(`${ORG}/home`);
});
- 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/general`)).toBe(true);
- expect(match(`${PROJECT}/settings/general/extra`)).toBe(false);
- expect(match(`${ORG}/projects/settings/home`)).toBe(false);
+ it('links to the project home once a project is selected', () => {
+ expect(definitionFor('overview').to(atProject())).toBe(`${PROJECT}/home`);
});
- 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('links to the API overview once an API is open', () => {
+ expect(definitionFor('overview').to(atApi())).toBe(API);
});
- 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);
+ // Opening a project or an API navigates into a deeper tier of this same item,
+ // so Overview has to stay lit rather than handing off to another item.
+ it.each([
+ ['organization home', `${ORG}/home`],
+ ['project home', `${PROJECT}/home`],
+ ['api overview', API],
+ ])('stays active on the %s page', (_name, pathname) => {
+ expect(matcherFor('overview')(pathname)).toBe(true);
});
- it('matches runtime logs only at the runtimelogs path', () => {
- const match = matcherFor('runtime-logs');
- expect(match(`${PROJECT}/observe/runtimelogs`)).toBe(true);
- expect(match(`${PROJECT}/observe/runtimelogs/x`)).toBe(false);
+ it('does not light up for pages belonging to other items', () => {
+ const match = matcherFor('overview');
+ expect(match(`${ORG}/projects`)).toBe(false);
+ expect(match(`${ORG}/gateways`)).toBe(false);
+ expect(match(`${API}/deploy`)).toBe(false);
+ expect(match(`${PROJECT}/apis`)).toBe(false);
+ });
+
+ // Its shallowest tier is the organization, which the shell always has — so
+ // unlike the API-level items it never needs a scope picker to fall back on.
+ it('never needs a scope-less alias', () => {
+ for (const scope of [atOrg(), atProject(), atApi()]) {
+ expect(definitionFor('overview').to(scope)).not.toContain('select-scope');
+ }
+ });
+});
+
+describe('scope visibility', () => {
+ it('hides Projects inside a project, where Overview already covers it', () => {
+ const { isVisible } = definitionFor('projects');
+ expect(isVisible?.(atOrg())).toBe(true);
+ expect(isVisible?.(atProject())).toBe(false);
+ expect(isVisible?.(atApi())).toBe(false);
});
- it.each(['deploy', 'test', 'manage'])(
- 'anchors the %s api tab',
+ // Capability gating applies only once an API is loaded: with none, every
+ // capability reads false, which would hide these items in exactly the state
+ // where they are the way in.
+ it.each(['develop', 'test', 'deploy', 'manage'])(
+ 'keeps %s visible out of API scope and lets the capability decide within it',
(id) => {
+ const { isVisible } = definitionFor(id);
+ expect(isVisible?.(atOrg())).toBe(true);
+ expect(isVisible?.(atProject())).toBe(true);
+ expect(isVisible?.(atApi())).toBe(false); // no component loaded -> unsupported
+ }
+ );
+
+ // `hasUsageInsights`/`hasRuntimeLogs` are false for API_PROXY, the dominant
+ // kind here, so gating on them would hide these on the APIs they are for.
+ it.each(['insights', 'observability', 'admin'])(
+ '%s is not capability-gated',
+ (id) => {
+ expect(definitionFor(id).isVisible).toBeUndefined();
+ }
+ );
+});
+
+describe('API-level items', () => {
+ // Items that are pages in their own right, as opposed to submenu parents.
+ const LEAF_ITEMS: [string, string][] = [
+ ['deploy', 'deploy'],
+ ['admin', 'admin'],
+ ];
+
+ it.each(LEAF_ITEMS)('%s anchors to its own scoped path', (id, suffix) => {
+ const match = matcherFor(id);
+ expect(match(`${API}/${suffix}`)).toBe(true);
+ expect(match(`${API}/${suffix}/extra`)).toBe(false);
+ expect(match(`/${suffix}`)).toBe(false);
+ });
+
+ // Clicked from a shallower scope, these link to the page's scope-less alias so
+ // its `ScopeGate` can ask for what's missing — and stay highlighted there.
+ it.each(LEAF_ITEMS)('%s links to, and matches, its aliases', (id, suffix) => {
+ const { match, to } = definitionFor(id);
+ expect(to(atOrg())).toBe(`${SELECT}/${suffix}`);
+ expect(to(atProject())).toBe(`${PROJECT}/select-scope/${suffix}`);
+ expect(to(atApi())).toBe(`${API}/${suffix}`);
+ expect(match?.(`${SELECT}/${suffix}`)).toBe(true);
+ expect(match?.(`${PROJECT}/select-scope/${suffix}`)).toBe(true);
+ });
+
+ // Dropping `apis/` as a pair is what keeps an API whose handle matches
+ // a page suffix reachable: `.../apis/deploy` is that API, not the Deploy page.
+ it('leaves an API handled like a page suffix reachable', () => {
+ expect(matcherFor('overview')(`${PROJECT}/apis/deploy`)).toBe(true);
+ expect(matcherFor('deploy')(`${PROJECT}/apis/deploy`)).toBe(false);
+ });
+});
+
+/*
+ * Test, Observability and Manage have no page of their own: in API scope they
+ * open a submenu, and outside it they lead to the first child's `ScopeGate`. The
+ * split of responsibilities that makes that work is what these tests pin —
+ * the parent owns the scope-less aliases, each child owns its scoped path, and
+ * the two never claim active at the same time.
+ */
+describe('submenu parents', () => {
+ const SUBMENUS: [string, string, [string, string][]][] = [
+ [
+ 'develop',
+ 'develop',
+ [
+ ['develop-policies', 'policies'],
+ ['develop-routing', 'routing'],
+ ['develop-documents', 'documents'],
+ ],
+ ],
+ [
+ 'test',
+ 'test',
+ [
+ ['test-console', 'console'],
+ ['test-curl', 'curl'],
+ ['test-chat', 'chat'],
+ ],
+ ],
+ [
+ 'observability',
+ 'observability',
+ [
+ ['observability-alerts', 'alerts'],
+ ['observability-metrics', 'metrics'],
+ ['observability-logs', 'logs'],
+ ],
+ ],
+ [
+ 'insights',
+ 'insights',
+ [
+ ['insights-api', 'api'],
+ ['insights-compliance', 'compliance'],
+ ],
+ ],
+ [
+ 'manage',
+ 'manage',
+ [
+ ['manage-monetize', 'monetize'],
+ ['manage-lifecycle', 'lifecycle'],
+ ],
+ ],
+ ];
+
+ it.each(SUBMENUS)('%s lists its children in order', (id, _base, children) => {
+ expect(definitionFor(id).children?.map((child) => child.id)).toEqual(
+ children.map(([childId]) => childId)
+ );
+ });
+
+ it.each(SUBMENUS)('%s only offers them in API scope', (id) => {
+ expect(definitionFor(id).requires).toBe('api');
+ });
+
+ // The parent is a link only until scope resolves; in API scope the sidebar
+ // drops the link and a click expands instead, so this target stops being used.
+ it.each(SUBMENUS)(
+ '%s links to its first child while out of scope',
+ (id, base, children) => {
+ const [, firstSuffix] = children[0];
+ const { to } = definitionFor(id);
+ expect(to(atOrg())).toBe(`${SELECT}/${base}/${firstSuffix}`);
+ expect(to(atProject())).toBe(
+ `${PROJECT}/select-scope/${base}/${firstSuffix}`
+ );
+ }
+ );
+
+ // Highlighted while the ScopeGate is asking, and only then: once scope
+ // resolves the child takes over, which is also what Oxygen expects — it leaves
+ // an expanded parent unhighlighted and marks the active child instead.
+ it.each(SUBMENUS)(
+ '%s matches every child alias and no scoped page',
+ (id, base, children) => {
const match = matcherFor(id);
- expect(match(`${API}/${id}`)).toBe(true);
- expect(match(`${API}/${id}/extra`)).toBe(false);
+ for (const [, suffix] of children) {
+ expect(match(`${SELECT}/${base}/${suffix}`)).toBe(true);
+ expect(match(`${PROJECT}/select-scope/${base}/${suffix}`)).toBe(true);
+ expect(match(`${API}/${base}/${suffix}`)).toBe(false);
+ }
}
);
- it('matches api overview but not its sub-tabs', () => {
- const match = matcherFor('api-overview');
- expect(match(API)).toBe(true);
- expect(match(`${API}/deploy`)).toBe(false);
+ it.each(SUBMENUS)('%s children own their scoped path alone', (id, base, children) => {
+ const parent = definitionFor(id);
+ for (const [childId, suffix] of children) {
+ const child = parent.children?.find((entry) => entry.id === childId);
+ expect(child, `${id} has no child ${childId}`).toBeDefined();
+ expect(child?.to(atApi())).toBe(`${API}/${base}/${suffix}`);
+ expect(child?.match?.(`${API}/${base}/${suffix}`)).toBe(true);
+ // The parent covers the aliases; a child claiming them too would light
+ // both rows up at once on the scope-gate page.
+ expect(child?.match?.(`${SELECT}/${base}/${suffix}`)).toBe(false);
+ expect(child?.match?.(`${API}/${base}/${suffix}/extra`)).toBe(false);
+ }
+ });
+
+ it('keeps children out of the top-level list', () => {
+ const topLevel = navigationRegistry.map((item) => item.id);
+ for (const [, , children] of SUBMENUS) {
+ for (const [childId] of children) {
+ expect(topLevel).not.toContain(childId);
+ }
+ }
+ });
+
+ it('gives every item and sub-item a unique id', () => {
+ const ids = navigationRegistry.flatMap((item) => [
+ item.id,
+ ...(item.children ?? []).map((child) => child.id),
+ ]);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
+
+describe('organization-level items', () => {
+ it('anchors Projects to the list, and to a project it links into', () => {
+ const match = matcherFor('projects');
+ expect(match(`${ORG}/projects`)).toBe(true);
+ // Clicking a card navigates to the project home; the item stays lit until
+ // `isVisible` drops it at project scope.
+ expect(match(`${PROJECT}/home`)).toBe(true);
+ expect(match('/projects')).toBe(false);
+ });
+
+ it('covers the gateway list, create and detail pages', () => {
+ const match = matcherFor('gateways');
+ expect(match(`${ORG}/gateways`)).toBe(true);
+ expect(match(`${ORG}/gateways/new`)).toBe(true);
+ expect(match(`${ORG}/gateways/gw-1`)).toBe(true);
+ expect(match(`${ORG}/gateways/gw-1/extra`)).toBe(false);
+ });
+
+ /*
+ * Settings needs no scope of its own, so it never gates — but it does follow you
+ * down one level: the organization's settings while browsing the org, that
+ * project's once you are inside one. One pinned link at a time, never both,
+ * which is what `AppRoutes.orgSettings.test.tsx` exercises end to end.
+ */
+ it('follows the scope down to the project, and no further', () => {
+ const { to } = definitionFor('settings');
+ expect(to(atOrg())).toBe(`${ORG}/settings`);
+ expect(to(atProject())).toBe(`${PROJECT}/settings`);
+ // No API tier: an API has no settings page of its own, so inside one the item
+ // stays on the project's.
+ expect(to(atApi())).toBe(`${PROJECT}/settings`);
+ });
+
+ it('lights Settings up at either of its entry points', () => {
+ const match = matcherFor('settings');
+ expect(match(`${ORG}/settings`)).toBe(true);
+ expect(match(`${PROJECT}/settings`)).toBe(true);
+ expect(match(`${API}/settings`)).toBe(false);
+ });
+});
+
+describe('sidebar structure', () => {
+ it('carries no section headings, only divider clusters', () => {
+ // Clusters separate; they are never rendered as text (see
+ // NavigationDefinition.group), so they are keys, not labels.
+ expect(new Set(navigationRegistry.map((item) => item.group))).toEqual(
+ new Set(['place', 'api', 'global'])
+ );
+ });
+
+ it('orders items so clusters come out contiguous', () => {
+ const byOrder = [...navigationRegistry].sort((a, b) => a.order - b.order);
+ const clusterRun = byOrder
+ .map((item) => item.group)
+ .filter((group, index, all) => group !== all[index - 1]);
+
+ expect(clusterRun).toEqual(['place', 'api', 'global']);
+ });
+
+ it('leaves `level` to extensions, which need it for path building', () => {
+ expect(navigationRegistry.every((item) => item.level === undefined)).toBe(
+ true
+ );
+ });
+
+ it('gives every item a route builder and a matcher', () => {
+ for (const item of navigationRegistry) {
+ expect(item.match, `${item.id} has no match`).toBeTypeOf('function');
+ expect(item.to(atApi()), `${item.id} has no target`).toBeTruthy();
+ }
+ });
+});
+
+describe('every page routes.* builds is anchored, not a bare suffix', () => {
+ it('requires the org prefix everywhere', () => {
+ for (const item of navigationRegistry) {
+ const target = item.to(atApi());
+ expect(target?.startsWith('/organizations/')).toBe(true);
+ // A matcher must not fire on the same path without its org/project prefix.
+ expect(item.match?.(target!.replace(ORG, ''))).toBe(false);
+ }
+ });
+
+ // The create page sits in the `:apiHandler` slot (`.../apis/new`), so
+ // Overview's API tier claims it — which reads correctly in the sidebar, since
+ // creating an API belongs to the API area and lands on its overview. Worth
+ // pinning: it is a consequence of the URL shape, not a decision, and the same
+ // overlap makes every API-level item treat `new` as a handle.
+ it('leaves the new-api page under Overview', () => {
+ const newApi = routes.newApi(organizations[0].id, projects[0].id);
+ const owners = navigationRegistry
+ .filter((item) => item.match?.(newApi))
+ .map((item) => item.id);
+
+ expect(owners).toEqual(['overview']);
});
});
diff --git a/portals/api-control-plane/src/navigation/navigationRegistry.tsx b/portals/api-control-plane/src/navigation/navigationRegistry.tsx
index d108c651da..b958b14a7d 100644
--- a/portals/api-control-plane/src/navigation/navigationRegistry.tsx
+++ b/portals/api-control-plane/src/navigation/navigationRegistry.tsx
@@ -16,184 +16,445 @@
* under the License.
*/
+import type { ReactNode } from 'react';
import {
- Boxes,
+ Activity,
+ BellRing,
+ ChartColumn,
+ ChartLine,
+ CircleDollarSign,
+ Code,
ClipboardList,
+ FileCheck,
+ FileText,
+ Gauge,
+ GitBranch,
Home,
+ Layers,
+ MessagesSquare,
Network,
Rocket,
+ Route,
+ ScrollText,
Settings,
+ ShieldCheck,
+ SquareTerminal,
Terminal,
} from '@wso2/oxygen-ui-icons-react';
-import { routes } from '../routes/paths';
-import type { NavigationDefinition } from './navigationTypes';
+import type { ApiCapabilities } from '../pages/appShell/appShellPages/apis/utils/apiCapabilities';
+import {
+ apiScopeSelectPaths,
+ apiScopedPaths,
+ routes,
+ type ApiPathBuilder,
+ type ScopedPathBuilder,
+} from '../routes/paths';
+import type { ConsoleRouteParams } from '../scope/ConsoleScopeContext';
+import type { NavigationDefinition, NavigationLevel } from './navigationTypes';
+
+/**
+ * Divider-separated clusters, in sidebar order. Keys are never displayed — the
+ * sidebar renders no headings (see `NavigationDefinition.group`).
+ */
+const CLUSTER = {
+ /** Where you are and what's alongside it: Overview, Projects, Gateways. */
+ place: 'place',
+ /** What you can do to the API you're in. */
+ api: 'api',
+ /** Reachable at any scope. */
+ global: 'global',
+} as const;
+
+/**
+ * Turns a route pattern into an anchored full-path regex: regex metacharacters
+ * are escaped, then each `:param` becomes a single-segment wildcard. So
+ * `/organizations/:orgHandle/projects/:projectHandler/settings` yields
+ * `^/organizations/[^/]+/projects/[^/]+/settings$`.
+ */
+const toRouteRegex = (pattern: string): RegExp =>
+ new RegExp(
+ `^${pattern
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ .replace(/:[A-Za-z][A-Za-z0-9]*/g, '[^/]+')}$`
+ );
+
+/**
+ * Builds a `match` predicate from the same `routes.*` builders an item links to,
+ * called with their `:param` defaults.
+ *
+ * Hand-writing `match` alongside `to` means maintaining the same path twice, and
+ * the two had already drifted: `settings` matched a bare `/\/settings$/`, so any
+ * future org- or api-level settings page would light up the *project* Settings
+ * item, and `runtime-logs` matched `/\/observe\/runtimelogs$/` with no
+ * org/project segments at all. Deriving both from one builder makes that class
+ * of drift impossible — a renamed route updates the highlight for free.
+ */
+const matchRoutes = (...patterns: string[]) => {
+ const regexes = patterns.map(toRouteRegex);
+ return (pathname: string) => regexes.some((regex) => regex.test(pathname));
+};
+
+/** `to` for an org-level item — always linkable inside the app shell. */
+const orgLevelTo =
+ (build: (orgHandle: string) => string): NavigationDefinition['to'] =>
+ ({ params }) =>
+ params.orgHandle ? build(params.orgHandle) : undefined;
+
+/**
+ * `to` for an API-level item, falling back to the page's scope-less alias when
+ * the project or API is missing.
+ *
+ * The item stays clickable at every scope: the alias mounts the same page, whose
+ * `ScopeGate` prompts for the missing handles and then navigates to the fully
+ * scoped URL. Returning `undefined` — the old behaviour, paired with a filter
+ * that hid the item — meant an org-level page offered no route into any
+ * API-level feature at all.
+ */
+const apiLevelTo =
+ (build: ApiPathBuilder): NavigationDefinition['to'] =>
+ ({ params }) =>
+ params.orgHandle
+ ? build(
+ params.orgHandle,
+ params.projectHandler ?? null,
+ params.apiHandler ?? null
+ )
+ : undefined;
+
+/** One entry in a submenu: its own id, label, icon and page. */
+type SubItem = {
+ icon: ReactNode;
+ id: string;
+ label: string;
+ to: ApiPathBuilder;
+};
+
+/**
+ * A child of a submenu parent: an ordinary API-level item, one nesting level down.
+ *
+ * `match` is the fully-scoped path only. The parent owns the scope-less aliases
+ * (see `submenu` below), so exactly one of the two is ever active.
+ */
+const subItem = ({ icon, id, label, to }: SubItem): NavigationDefinition => ({
+ icon,
+ id,
+ label,
+ // Children render in the order their parent lists them; `order` only sorts
+ // top-level items, so it plays no part here.
+ order: 0,
+ to: apiLevelTo(to),
+ match: matchRoutes(to(':orgHandle', ':projectHandler', ':apiHandler')),
+});
+
+/**
+ * The `to`/`match`/`children` of a submenu parent — an item that opens rather
+ * than navigates.
+ *
+ * A parent has no page of its own, so:
+ *
+ * - `to` is its **first child's** target. In API scope the sidebar drops the link
+ * entirely and a click expands instead (Oxygen's `Sidebar.Item` switches to
+ * `onToggleExpand` as soon as it has nested children), so this only ever
+ * resolves out of scope — where it points at that child's scope-less alias and
+ * the child page's `ScopeGate` asks for an API.
+ * - `match` covers every child's aliases and nothing else, so the parent stays
+ * highlighted on the scope-gate page and hands the highlight to the child once
+ * scope resolves. Oxygen leaves an expanded parent unhighlighted by design.
+ */
+const submenu = (
+ items: SubItem[]
+): Pick => ({
+ children: items.map(subItem),
+ match: matchRoutes(...items.flatMap((item) => apiScopeSelectPaths(item.to))),
+ requires: 'api',
+ to: apiLevelTo(items[0].to),
+});
+
+/** One page an adaptive item points at, plus the scope that page needs. */
+type ScopeTier = { level: NavigationLevel; to: ScopedPathBuilder };
+
+const LEVEL_DEPTH: Record = {
+ organization: 0,
+ project: 1,
+ api: 2,
+};
+
+const isLevelInScope = (level: NavigationLevel, params: ConsoleRouteParams) => {
+ if (level === 'api') return Boolean(params.projectHandler && params.apiHandler);
+ if (level === 'project') return Boolean(params.projectHandler);
+ return Boolean(params.orgHandle);
+};
+
+/** The tier's route pattern, calling its builder with the handles its level takes. */
+const tierPattern = ({ level, to }: ScopeTier): string => {
+ if (level === 'api') return to(':orgHandle', ':projectHandler', ':apiHandler');
+ if (level === 'project') return to(':orgHandle', ':projectHandler');
+ return to(':orgHandle');
+};
+
+/**
+ * One sidebar item pointing at a different page per scope: the deepest tier the
+ * route can satisfy wins.
+ *
+ * This is what lets a single **Overview** item mean "the summary of wherever you
+ * are" — the organization at org scope, the project once you pick one, the API
+ * once you open one. Clicking a project card or an API card navigates into the
+ * deeper tier, and because `match` covers every tier, Overview simply stays
+ * highlighted rather than handing off to a different item.
+ *
+ * Note what it does *not* do: an adaptive item whose shallowest tier is
+ * `organization` never needs a `ScopeGate`, because there is always some tier it
+ * can satisfy. It degrades instead of prompting. Tiers are only ever called with
+ * handles the route already has, which is why `ScopedPathBuilder` takes no
+ * `null`.
+ *
+ * Returns both `to` and `match` so a spread wires them together and they can't
+ * drift apart:
+ *
+ * ```tsx
+ * { id: 'overview', ...adaptive([{ level: 'api', to: routes.api }, ...]) }
+ * ```
+ */
+const adaptive = (
+ tiers: ScopeTier[]
+): Pick => {
+ const deepestFirst = [...tiers].sort(
+ (left, right) => LEVEL_DEPTH[right.level] - LEVEL_DEPTH[left.level]
+ );
+
+ return {
+ match: matchRoutes(...tiers.map(tierPattern)),
+ to: ({ params }) => {
+ if (!params.orgHandle) return undefined;
+ const tier = deepestFirst.find((candidate) =>
+ isLevelInScope(candidate.level, params)
+ );
+ return tier?.to(
+ params.orgHandle,
+ params.projectHandler,
+ params.apiHandler
+ );
+ },
+ };
+};
+
+
+/**
+ * Capability gating for an API-level item, applied only once an API is actually
+ * in scope.
+ *
+ * With no API loaded every capability is `false` (see `getApiCapabilities`), so
+ * a bare `({ capabilities }) => capabilities.canDeploy` would hide Deploy/Test/
+ * Manage from the sidebar in exactly the state where the user needs them as a
+ * way in. Out of API scope the item shows and leads to the scope picker; in API
+ * scope the capability still decides, so an API that can't be deployed has no
+ * Deploy item.
+ */
+const apiCapability =
+ (
+ isSupported: (capabilities: ApiCapabilities) => boolean
+ ): NonNullable =>
+ ({ capabilities, isApiScope }) =>
+ !isApiScope || isSupported(capabilities);
export const navigationRegistry: NavigationDefinition[] = [
{
- id: 'organization-home',
- label: 'Home',
- level: 'organization',
+ id: 'overview',
+ label: 'Overview',
+ group: CLUSTER.place,
order: 10,
icon: ,
- to: ({ params }) =>
- params.orgHandle ? routes.organizationHome(params.orgHandle) : undefined,
- match: (pathname) => /\/organizations\/[^/]+\/home$/.test(pathname),
+ // The summary of wherever you are. Opening a project or an API navigates
+ // into a deeper tier of this same item rather than to a different one.
+ ...adaptive([
+ { level: 'api', to: routes.api },
+ { level: 'project', to: routes.projectHome },
+ { level: 'organization', to: routes.organizationHome },
+ ]),
},
{
id: 'projects',
label: 'Projects',
- level: 'organization',
+ group: CLUSTER.place,
order: 20,
- icon: ,
- to: ({ params }) =>
- params.orgHandle ? routes.projects(params.orgHandle) : undefined,
- match: (pathname) => /\/organizations\/[^/]+\/projects$/.test(pathname),
+ icon: ,
+ // Inside a project this is redundant with Overview, and switching projects
+ // is the header switcher's job.
+ isVisible: ({ isProjectScope }) => !isProjectScope,
+ to: orgLevelTo(routes.projects),
+ match: matchRoutes(routes.projects(), routes.projectHome()),
},
{
id: 'gateways',
label: 'API Gateways',
- level: 'organization',
+ group: CLUSTER.place,
order: 30,
icon: ,
- to: ({ params }) =>
- params.orgHandle ? routes.gateways(params.orgHandle) : undefined,
- match: (pathname) => /\/organizations\/[^/]+\/gateways(\/[^/]+)?$/.test(pathname),
+ to: orgLevelTo(routes.gateways),
+ match: matchRoutes(routes.gateways(), routes.newGateway(), routes.gateway()),
},
{
- 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: 'develop',
+ label: 'Develop',
+ group: CLUSTER.api,
+ order: 35,
+ icon: ,
+ isVisible: apiCapability(({ canDevelop }) => canDevelop),
+ ...submenu([
+ {
+ icon: ,
+ id: 'develop-policies',
+ label: 'Policies',
+ to: routes.apiDevelopPolicies,
+ },
+ {
+ icon: ,
+ id: 'develop-routing',
+ label: 'Routing',
+ to: routes.apiDevelopRouting,
+ },
+ {
+ icon: ,
+ id: 'develop-documents',
+ label: 'Documents',
+ to: routes.apiDevelopDocuments,
+ },
+ ]),
},
{
- id: 'project-home',
- label: 'Project Home',
- level: 'project',
- order: 100,
- icon: ,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler
- ? routes.projectHome(params.orgHandle, params.projectHandler)
- : undefined,
- match: (pathname) =>
- /\/organizations\/[^/]+\/projects\/[^/]+\/home$/.test(pathname),
- },
- {
- id: 'apis',
- label: 'APIs',
- level: 'project',
- order: 110,
- icon: ,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler
- ? routes.apis(params.orgHandle, params.projectHandler)
- : undefined,
- match: (pathname) =>
- /\/projects\/[^/]+\/apis(\/new)?$/.test(pathname),
- },
- {
- id: 'runtime-logs',
- label: 'Runtime Logs',
- level: 'project',
- order: 120,
+ id: 'test',
+ label: 'Test',
+ group: CLUSTER.api,
+ order: 40,
icon: ,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler
- ? routes.runtimeLogs(params.orgHandle, params.projectHandler)
- : undefined,
- match: (pathname) => /\/observe\/runtimelogs$/.test(pathname),
- },
- {
- id: 'settings',
- label: 'Settings',
- level: 'project',
- order: 130,
- icon: ,
- pinned: true,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler
- ? routes.settings(params.orgHandle, params.projectHandler)
- : undefined,
- // 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',
- label: 'API Overview',
- level: 'api',
- order: 200,
- icon: ,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler && params.apiHandler
- ? routes.api(
- params.orgHandle,
- params.projectHandler,
- params.apiHandler
- )
- : undefined,
- match: (pathname) => /\/apis\/[^/]+$/.test(pathname),
+ isVisible: apiCapability(({ canTest }) => canTest),
+ ...submenu([
+ {
+ icon: ,
+ id: 'test-console',
+ label: 'API Console',
+ to: routes.apiTestConsole,
+ },
+ {
+ icon: ,
+ id: 'test-curl',
+ label: 'Curl',
+ to: routes.apiTestCurl,
+ },
+ {
+ icon: ,
+ id: 'test-chat',
+ label: 'API Chat',
+ to: routes.apiTestChat,
+ },
+ ]),
},
{
id: 'deploy',
label: 'Deploy',
- level: 'api',
- order: 210,
+ group: CLUSTER.api,
+ order: 50,
icon: ,
- isVisible: ({ capabilities }) => capabilities.canDeploy,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler && params.apiHandler
- ? routes.apiDeploy(
- params.orgHandle,
- params.projectHandler,
- params.apiHandler
- )
- : undefined,
- match: (pathname) => /\/deploy$/.test(pathname),
+ isVisible: apiCapability(({ canDeploy }) => canDeploy),
+ to: apiLevelTo(routes.apiDeploy),
+ match: matchRoutes(...apiScopedPaths(routes.apiDeploy)),
},
{
- id: 'test',
- label: 'Test',
- level: 'api',
- order: 220,
- icon: ,
- isVisible: ({ capabilities }) => capabilities.canTest,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler && params.apiHandler
- ? routes.apiTest(
- params.orgHandle,
- params.projectHandler,
- params.apiHandler
- )
- : undefined,
- match: (pathname) => /\/test$/.test(pathname),
+ // No capability gate, unlike its neighbours: `hasUsageInsights` is false for
+ // kinds this console shows by default, so gating on it would hide Insights on
+ // exactly the APIs it is meant for. Same for Observability below.
+ id: 'insights',
+ label: 'Insights',
+ group: CLUSTER.api,
+ order: 60,
+ icon: ,
+ ...submenu([
+ {
+ icon: ,
+ id: 'insights-api',
+ label: 'API Insights',
+ to: routes.apiInsightsApi,
+ },
+ {
+ icon: ,
+ id: 'insights-compliance',
+ label: 'Compliance',
+ to: routes.apiInsightsCompliance,
+ },
+ ]),
+ },
+ {
+ id: 'observability',
+ label: 'Observability',
+ group: CLUSTER.api,
+ order: 70,
+ icon: ,
+ ...submenu([
+ {
+ icon: ,
+ id: 'observability-alerts',
+ label: 'Alert',
+ to: routes.apiObservabilityAlerts,
+ },
+ {
+ icon: ,
+ id: 'observability-metrics',
+ label: 'Metrics',
+ to: routes.apiObservabilityMetrics,
+ },
+ {
+ icon: ,
+ id: 'observability-logs',
+ label: 'Logs',
+ to: routes.apiObservabilityLogs,
+ },
+ ]),
},
{
id: 'manage',
label: 'Manage',
- level: 'api',
- order: 230,
+ group: CLUSTER.api,
+ order: 80,
icon: ,
- isVisible: ({ capabilities }) => capabilities.canManage,
- to: ({ params }) =>
- params.orgHandle && params.projectHandler && params.apiHandler
- ? routes.apiManage(
- params.orgHandle,
- params.projectHandler,
- params.apiHandler
- )
- : undefined,
- match: (pathname) => /\/manage$/.test(pathname),
+ isVisible: apiCapability(({ canManage }) => canManage),
+ ...submenu([
+ {
+ icon: ,
+ id: 'manage-monetize',
+ label: 'Monetize',
+ to: routes.apiManageMonetize,
+ },
+ {
+ icon: ,
+ id: 'manage-lifecycle',
+ label: 'LifeCycle',
+ to: routes.apiManageLifecycle,
+ },
+ ]),
+ },
+ {
+ id: 'admin',
+ label: 'Admin',
+ group: CLUSTER.api,
+ order: 90,
+ icon: ,
+ to: apiLevelTo(routes.apiAdmin),
+ match: matchRoutes(...apiScopedPaths(routes.apiAdmin)),
+ },
+ {
+ // The one page with no scope requirement at all, hence its own cluster.
+ id: 'settings',
+ label: 'Settings',
+ group: CLUSTER.global,
+ order: 100,
+ icon: ,
+ // Follows you down one level: the organization's settings while browsing the
+ // org, that project's settings once you are inside one — one pinned link at a
+ // time, never both. Same page either way; only the scope it reads differs.
+ ...adaptive([
+ { level: 'project', to: routes.projectSettings },
+ { level: 'organization', to: routes.settings },
+ ]),
},
];
diff --git a/portals/api-control-plane/src/navigation/navigationTypes.ts b/portals/api-control-plane/src/navigation/navigationTypes.ts
index e0512e1a01..8badf27157 100644
--- a/portals/api-control-plane/src/navigation/navigationTypes.ts
+++ b/portals/api-control-plane/src/navigation/navigationTypes.ts
@@ -19,18 +19,26 @@
import type { ReactNode } from 'react';
import type { ConsoleScope } from '../scope/ConsoleScopeProvider';
+import type { RequiredScope } from '../scope/ScopeGate';
export type NavigationLevel = 'organization' | 'project' | 'api';
export type NavigationDefinition = {
featureKey?: string;
- /** Sidebar section heading this item is grouped under. */
+ /**
+ * Divider-separated cluster for sidebar grouping.
+ */
group?: string;
icon: ReactNode;
id: string;
isVisible?: (scope: ConsoleScope) => boolean;
label: string;
- level: NavigationLevel;
+ /**
+ * Extensions only, where it decides the URL shape of the injected page (see
+ * `buildScopedExtensionPath`). Built-in items leave it unset — a scope-adaptive
+ * item spans several levels at once, and nothing else reads it.
+ */
+ level?: NavigationLevel;
match?: (pathname: string) => boolean;
order: number;
/**
@@ -40,26 +48,33 @@ export type NavigationDefinition = {
*/
pinned?: boolean;
to: (scope: ConsoleScope) => string | undefined;
+ /**
+ * Sub-items, offered only once `requires` is satisfied.
+ *
+ * A parent with children showing is a disclosure, not a link — the sidebar
+ * omits its `link` so a click expands instead of navigating. Out of scope the
+ * children are withheld and the parent behaves as an ordinary link into its
+ * first child's page, where `ScopeGate` asks for what's missing.
+ */
+ children?: NavigationDefinition[];
+ /**
+ * Scope this item's children need. Shares `ScopeGate`'s own union so the
+ * sidebar and the page it opens can never disagree about what "in scope" means.
+ */
+ requires?: RequiredScope;
};
export type NavigationItem = {
- group: string;
+ group?: string;
icon: ReactNode;
id: string;
isActive: boolean;
label: string;
+ /**
+ * Where the item leads. A parent rendering `children` keeps its first child's
+ * target here but the sidebar does not link it — see `children` above.
+ */
pinned?: boolean;
to: string;
-};
-
-/** A sidebar section: a heading plus the nav items under it (order preserved). */
-export type NavigationGroup = {
- label: string;
- items: NavigationItem[];
-};
-
-export const NAVIGATION_GROUP_BY_LEVEL: Record = {
- organization: 'Organization',
- project: 'Project',
- api: 'API',
+ children?: NavigationItem[];
};
diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx b/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx
index be4be6449e..8f28cd9cdc 100644
--- a/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx
+++ b/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx
@@ -16,67 +16,216 @@
* under the License.
*/
+import type { ReactNode } from 'react';
+import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
-import { ExtensionsProvider, type ApiControlPlaneExtension } from '../extensions';
+import type { RestApi } from '../api/resources/restApis';
+import {
+ ExtensionsProvider,
+ type ApiControlPlaneExtension,
+} from '../extensions';
+import { organizations, projects } from '../api/mocks/data';
+import { routes } from '../routes/paths';
+import {
+ ConsoleScopeContext,
+ type ConsoleScope,
+} from '../scope/ConsoleScopeContext';
import { makeConsoleScope } from '../test/mockScope';
-import { renderWithProviders, screen } from '../test/utils';
+import { renderHook } 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 ORG = organizations[0].id;
+const PROJECT = projects[0].id;
+const API = 'api-1';
+
+// A REST API that supports every capability the API-level items gate on, so a
+// hidden item in these tests means the scope rules hid it — not the fixture.
+// Lowercase transports, as the spec documents them — see AppSidebar.test.tsx.
+const COMPONENT = {
+ displayName: 'Orders API',
+ id: API,
+ kind: 'RestApi',
+ transport: ['http', 'https'],
+} as RestApi;
+
+const atOrg = () =>
+ makeConsoleScope({
+ component: COMPONENT,
+ isApiScope: false,
+ isProjectScope: false,
+ params: { orgHandle: ORG },
+ project: undefined,
+ });
+
+const atApi = () =>
+ makeConsoleScope({
+ component: COMPONENT,
+ isApiScope: true,
+ params: { apiHandler: API, orgHandle: ORG, projectHandler: PROJECT },
+ });
+
+const itemsAt = (scope: ConsoleScope, route: string) => {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+ );
+ const { result } = renderHook(() => useNavigationItems(), { wrapper });
+ return result.current;
+};
+
+const itemFor = (scope: ConsoleScope, route: string, id: string) => {
+ const item = itemsAt(scope, route).find((entry) => entry.id === id);
+ if (!item) throw new Error(`No item ${id} at ${route}`);
+ return item;
+};
+
+/*
+ * A submenu parent is two different things depending on scope, and the switch
+ * lives here rather than in the sidebar: withholding `children` is what makes
+ * Oxygen treat the row as a link instead of a disclosure.
+ */
+describe('submenu children follow API scope', () => {
+ it.each(['develop', 'test', 'insights', 'observability', 'manage'])(
+ '%s offers its children once an API is in scope',
+ (id) => {
+ const item = itemFor(atApi(), routes.api(ORG, PROJECT, API), id);
+
+ expect(item.children?.length).toBeGreaterThan(0);
+ // Each child points at its own page under the API.
+ for (const child of item.children ?? []) {
+ expect(child.to).toContain(`/apis/${API}/`);
+ }
+ }
+ );
+
+ it.each(['develop', 'test', 'insights', 'observability', 'manage'])(
+ '%s withholds them outside API scope, and links to the first instead',
+ (id) => {
+ const item = itemFor(atOrg(), routes.organizationHome(ORG), id);
+
+ expect(item.children).toBeUndefined();
+ // The scope-less alias of the first child — where its ScopeGate prompts.
+ expect(item.to).toContain('/select-scope/');
+ }
+ );
+
+ it('marks the child of the open page active, not its parent', () => {
+ const route = routes.apiObservabilityLogs(ORG, PROJECT, API);
+ const parent = itemFor(atApi(), route, 'observability');
+
+ expect(parent.isActive).toBe(false);
+ expect(
+ parent.children?.find((child) => child.id === 'observability-logs')
+ ?.isActive
+ ).toBe(true);
+ });
+
+ it('marks the parent active while its scope gate is open', () => {
+ const route = routes.apiObservabilityAlerts(ORG, null, null);
+ const parent = itemFor(atOrg(), route, 'observability');
+
+ expect(parent.isActive).toBe(true);
+ expect(parent.children).toBeUndefined();
+ });
+
+ it('leaves items without children untouched', () => {
+ const items = itemsAt(atApi(), routes.api(ORG, PROJECT, API));
+ const leaves = ['overview', 'gateways', 'deploy', 'admin'];
+
+ for (const id of leaves) {
+ expect(items.find((item) => item.id === id)?.children).toBeUndefined();
+ }
+ });
+});
+
+/*
+ * Host-injected extensions run through this same pipeline, so the two things
+ * that can only go wrong here are covered: which entries reach the sidebar at
+ * all, and when one counts as active.
+ */
+const itemsWithExtensions = (
+ scope: ConsoleScope,
+ route: string,
+ extensions: ApiControlPlaneExtension[]
+) => {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+
+
+ {children}
+
+
+
+ );
+ const { result } = renderHook(() => useNavigationItems(), { wrapper });
+ return result.current;
+};
+
+const atProject = () =>
+ makeConsoleScope({
+ component: undefined,
+ isApiScope: false,
+ isProjectScope: true,
+ params: { orgHandle: ORG, projectHandler: PROJECT },
+ });
+
+// `routePath: 'environments'` is also the tail segment of an unrelated
+// settings-tab route: the collision a substring matcher cannot tell apart,
+// since `.../settings/environments` contains `/environments` too even though
+// that route belongs to a different feature.
const sidebarExtension: ApiControlPlaneExtension = {
id: 'environments-sidebar',
- routePath: 'environments',
- render: () =>