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 ( + <> + + + + + + + + + {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 ( {title} - - {message} - {confirmPhrase && ( - setTyped(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && canConfirm) onConfirm(); - }} - placeholder={confirmPhrase} - size="small" - sx={{ mt: 2 }} - value={typed} - /> - )} - - - - - + + + + {message} + {confirmPhrase && ( + // `ElementWrapper` is the Oxygen form primitive for exactly this: + // a full-width `FormControl` with its `FormLabel` bound to the + // control by id. It carries no `required`/`error` of its own, which + // is why it suits this field — the phrase is validated by matching, + // not by field state, so there is nothing to propagate. + + setTyped(event.target.value)} + placeholder={confirmPhrase} + size="small" + value={typed} + /> + + )} + + + + + + + ); } 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 }} - > - - - closeMenu()} - open={Boolean(menuAnchor)} - > - { - closeMenu(event); - onDelete(component); - }} - sx={{ color: 'error.main' }} - > - - - - Delete - - - - )} - - - {/* 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 + + + )} - - + ); } 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

recovered body

; + } + + const { user } = renderWithProviders( + ( + + )} + > + + + ); + + expect( + screen.getByText('This page could not be displayed') + ).toBeInTheDocument(); + + throwing = false; + await user.click(screen.getByRole('button', { name: 'Try again' })); + + expect(await screen.findByText('recovered body')).toBeInTheDocument(); + }); + + it('clears a caught error when resetKeys change', () => { + const { rerender } = renderWithProviders( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + + rerender( + +

next page body

+
+ ); + + 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} + +
console footer
+ + ); + } + + 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 ( + + + + + } + 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 ? ( + + ) : ( + + )} + + + } + 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 ( - - Create project - - - NAME_MAX} - fullWidth - helperText={ - trimmedName.length > NAME_MAX - ? `Name must be ${NAME_MAX} characters or fewer.` - : 'A unique name for the project within this organization.' - } - label="Name" - onChange={(event) => setName(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && canSubmit) handleSubmit(); - }} - placeholder="e.g. Retail APIs" - required - size="small" - value={name} - /> - setDescription(event.target.value)} - placeholder="What this project is for (optional)" - size="small" - value={description} - /> - - - - - - - - ); -} 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. */} - -
- - © {new Date().getFullYear()} WSO2 LLC. - - {runtimeConfig.environmentName} - - Terms of Use - - - Privacy Policy - -
-
-
- - - - - - - - - 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: () =>
Sidebar Environments
, label: 'Environments', - scope: 'project', - slot: 'sidebar.project', + level: 'project', order: 50, + render: () =>
Sidebar Environments
, + routePath: 'environments', + slot: 'sidebar.project', }; -function Probe() { - const items = useNavigationItems(); - const item = items.find((entry) => entry.id === 'environments-sidebar'); - return ( -
- {item ? (item.isActive ? 'active' : 'inactive') : 'missing'} -
- ); -} - -describe('useNavigationItems sidebar extension matching', () => { - const scope = makeConsoleScope(); - - it('is not active on an unrelated route that merely ends with the same segment name', () => { - renderWithProviders( - - - , - { - scope, - route: - '/organizations/api-platform-demo/projects/retail-apis/settings/environments', - } - ); +const PROJECT_BASE = `/organizations/${ORG}/projects/${PROJECT}`; - expect(screen.getByTestId('result')).toHaveTextContent('inactive'); +describe('host-injected sidebar extensions', () => { + it('is active at its own destination', () => { + const [item] = itemsWithExtensions( + atProject(), + `${PROJECT_BASE}/environments`, + [sidebarExtension] + ).filter((entry) => entry.id === sidebarExtension.id); + + expect(item?.isActive).toBe(true); }); - it('is active at its own real destination', () => { - renderWithProviders( - - - , - { - scope, - route: '/organizations/api-platform-demo/projects/retail-apis/environments', - } + it('is not active on an unrelated route ending with the same segment', () => { + const [item] = itemsWithExtensions( + atProject(), + `${PROJECT_BASE}/settings/environments`, + [sidebarExtension] + ).filter((entry) => entry.id === sidebarExtension.id); + + expect(item?.isActive).toBe(false); + }); + + it('keeps a nested-slot extension out of the sidebar entirely', () => { + // A `settings.*` entry is rendered by the Settings sub-nav; surfacing it + // here too would show the same feature in two places. + const settingsTab: ApiControlPlaneExtension = { + ...sidebarExtension, + id: 'environments-settings-tab', + routePath: 'settings/environments', + slot: 'settings.project.tabs', + }; + + const items = itemsWithExtensions( + atProject(), + `${PROJECT_BASE}/settings/environments`, + [settingsTab] ); - expect(screen.getByTestId('result')).toHaveTextContent('active'); + expect(items.find((entry) => entry.id === settingsTab.id)).toBeUndefined(); }); }); diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.ts b/portals/api-control-plane/src/navigation/useNavigationItems.ts index d047bb2403..bb5a10f133 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.ts +++ b/portals/api-control-plane/src/navigation/useNavigationItems.ts @@ -20,29 +20,40 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { runtimeConfig } from '../config/runtime'; -import { useConsoleScope } from '../scope/ConsoleScopeProvider'; -import { buildScopedExtensionPath, useExtensions } from '../extensions'; +import { + useConsoleScope, + type ConsoleScope, +} from '../scope/ConsoleScopeProvider'; +import { + buildScopedExtensionPath, + isSidebarExtension, + useExtensions, +} from '../extensions'; import { navigationRegistry } from './navigationRegistry'; import { - NAVIGATION_GROUP_BY_LEVEL, type NavigationDefinition, - type NavigationGroup, type NavigationItem, } from './navigationTypes'; -const isLevelAvailable = ( - definition: NavigationDefinition, - scope: ReturnType -) => { - if (definition.level === 'organization') return scope.isOrganizationScope; - if (definition.level === 'project') return scope.isProjectScope; - return scope.isApiScope; -}; - const isFeatureEnabled = (definition: NavigationDefinition) => !definition.featureKey || runtimeConfig.featureFlags.includes(definition.featureKey); +/** + * Whether an item's `requires` scope holds — the gate on offering its children. + * + * An item with no `requires` has no scope condition and is treated as satisfied, + * so only submenu parents ever consult this. + */ +const isScopeSatisfied = ( + definition: NavigationDefinition, + scope: ConsoleScope +) => { + if (definition.requires === 'api') return scope.isApiScope; + if (definition.requires === 'project') return scope.isProjectScope; + return true; +}; + export const useNavigationItems = (): NavigationItem[] => { const scope = useConsoleScope(); const location = useLocation(); @@ -51,66 +62,99 @@ export const useNavigationItems = (): NavigationItem[] => { return useMemo(() => { // Host-injected extensions are converted to the same NavigationDefinition // shape the built-in registry uses, so they run through one filter/sort - // pipeline instead of a parallel "Cloud category" implementation. Only - // extensions registered against a `sidebar.*` slot get a top-level - // sidebar entry here — a `settings.*.tabs` extension instead renders - // inside the Settings page's own sub-nav (see `useSettingsTabs`). + // pipeline instead of a parallel "Cloud category" implementation. + // + // Only `sidebar.*` entries belong here: an extension registered against a + // nested slot (e.g. `settings.project.tabs`) renders inside that slot's own + // host and must not also appear as a top-level sidebar item. const extensionDefinitions: NavigationDefinition[] = extensions - .filter((extension) => extension.slot.startsWith('sidebar.')) + .filter(isSidebarExtension) .map((extension) => { const isDescendantRoute = extension.routePath.endsWith('/*'); const routeSuffix = extension.routePath.replace(/\/\*$/, ''); - // Computed once per render from the current scope (not a raw - // substring search) so `match` can't be fooled by an unrelated route - // that merely happens to contain this segment name elsewhere — e.g. - // a `settings/` tab route shouldn't activate a sidebar - // extension whose own destination is `/` at a different depth. - const { orgHandle, projectHandler, apiHandler } = scope.params; - const destination = - orgHandle && - !(extension.scope === 'project' && !projectHandler) && - !(extension.scope === 'api' && (!projectHandler || !apiHandler)) - ? buildScopedExtensionPath(extension.scope, routeSuffix, { - apiHandler, - orgHandle, - projectHandler, - }) - : undefined; + // The one destination this item points at in the current scope, + // computed once and used for both `to` and `match`. A raw substring + // search over the pathname would also fire on an unrelated route that + // merely ends with the same segment name — a `settings/` tab + // route would light up a sidebar extension whose own destination is + // `/` at a different depth. + const destination = scope.params.orgHandle + ? buildScopedExtensionPath(extension.level, routeSuffix, { + apiHandler: scope.params.apiHandler ?? null, + orgHandle: scope.params.orgHandle, + projectHandler: scope.params.projectHandler ?? null, + }) + : undefined; return { + group: extension.group, icon: extension.icon, id: extension.id, isVisible: extension.isVisible, label: extension.label, - level: extension.scope, + level: extension.level, match: (pathname) => destination !== undefined && + // Exactly this destination, or (for a `/*` route) a path + // continuing below it — never a partial segment match. (pathname === destination || (isDescendantRoute && pathname.startsWith(`${destination}/`))), order: extension.order, + // A missing project/API no longer makes the item unlinkable: the path + // degrades to the extension page's scope-less alias, where its own + // `ScopeGate` collects what's missing. Only a route with no + // organization has nothing to link to. to: () => destination, }; }); const combinedRegistry = [...navigationRegistry, ...extensionDefinitions]; + // A definition becomes an item unless it has no target at all. Children go + // through the very same resolution — feature flag, visibility, `to`, + // `isActive` — one level down, so a submenu entry can be flagged off or + // capability-hidden exactly like a top-level one. + const resolve = ( + definition: NavigationDefinition + ): NavigationItem | undefined => { + if (!isFeatureEnabled(definition)) return undefined; + if (!(definition.isVisible?.(scope) ?? true)) return undefined; + + const to = definition.to(scope); + if (!to) return undefined; + + // Children are withheld until their scope holds. That is what makes a + // parent behave as two different things: a disclosure in scope (the + // sidebar drops its link once children are present) and an ordinary link + // to its first child's `ScopeGate` outside it. + const children = + definition.children && isScopeSatisfied(definition, scope) + ? definition.children.reduce((kept, child) => { + const item = resolve(child); + if (item) kept.push(item); + return kept; + }, []) + : undefined; + + return { + group: definition.group, + icon: definition.icon, + id: definition.id, + isActive: definition.match + ? definition.match(location.pathname) + : location.pathname === to, + label: definition.label, + to, + ...(children?.length ? { children } : {}), + }; + }; + + // Items are not filtered by level. An API-level item stays in the sidebar at + // every scope, linking to its page's scope-less alias so the page's + // `ScopeGate` can prompt for the missing project/API; a scope-adaptive item + // links to the deepest tier the route satisfies. The only remaining reason + // `to` comes back undefined is a route with no organization at all (`/`, + // `/organizations`), which has nothing to link to yet. return combinedRegistry - .filter((definition) => isLevelAvailable(definition, scope)) - .filter(isFeatureEnabled) - .filter((definition) => definition.isVisible?.(scope) ?? true) - .map((definition) => { - const to = definition.to(scope); - if (!to) return undefined; - return { - group: definition.group ?? NAVIGATION_GROUP_BY_LEVEL[definition.level], - icon: definition.icon, - id: definition.id, - isActive: definition.match - ? definition.match(location.pathname) - : location.pathname === to, - label: definition.label, - pinned: definition.pinned, - to, - }; - }) + .map(resolve) .filter(Boolean) .sort((left, right) => { const leftOrder = @@ -122,40 +166,35 @@ export const useNavigationItems = (): NavigationItem[] => { }, [location.pathname, scope, extensions]); }; -const groupByLabel = (items: NavigationItem[]): NavigationGroup[] => { - const groups: NavigationGroup[] = []; - const byLabel = new Map(); - - for (const item of items) { - let group = byLabel.get(item.group); - if (!group) { - group = { label: item.group, items: [] }; - byLabel.set(item.group, group); - groups.push(group); - } - group.items.push(item); - } - - return groups; -}; - /** - * Same items as `useNavigationItems`, bucketed into ordered sidebar sections by - * their `group`. Group order follows first appearance in the (order-sorted) - * item list, so Organization → Project → Api falls out naturally. Excludes - * `pinned` items — those render separately, see `useSidebarFooterGroups`. + * The same items, bucketed into the divider-separated clusters the sidebar + * renders. Cluster order follows first appearance in the (order-sorted) item + * list, so the registry's `order` alone decides both item and cluster order. + * + * No labels: the clusters exist to separate, not to title. See + * `NavigationDefinition.group` for why an item can no longer carry a scope + * heading. */ -export const useNavigationGroups = (): NavigationGroup[] => { +export const useNavigationClusters = (): NavigationItem[][] => { const items = useNavigationItems(); - return useMemo(() => groupByLabel(items.filter((item) => !item.pinned)), [items]); -}; -/** - * `pinned` items (e.g. Settings), grouped the same way as - * `useNavigationGroups` but meant for a sidebar's fixed bottom section - * (`Sidebar.Footer`) rather than the scrolling main nav. - */ -export const useSidebarFooterGroups = (): NavigationGroup[] => { - const items = useNavigationItems(); - return useMemo(() => groupByLabel(items.filter((item) => item.pinned)), [items]); + return useMemo(() => { + const clusters: NavigationItem[][] = []; + const byKey = new Map(); + + for (const item of items) { + // Items with no cluster of their own share one, rather than each becoming + // a divider of its own. + const key = item.group ?? ''; + let cluster = byKey.get(key); + if (!cluster) { + cluster = []; + byKey.set(key, cluster); + clusters.push(cluster); + } + cluster.push(item); + } + + return clusters; + }, [items]); }; diff --git a/portals/api-control-plane/src/navigation/useSettingsTabs.tsx b/portals/api-control-plane/src/navigation/useSettingsTabs.tsx index f8072fafd4..464165f9ab 100644 --- a/portals/api-control-plane/src/navigation/useSettingsTabs.tsx +++ b/portals/api-control-plane/src/navigation/useSettingsTabs.tsx @@ -17,13 +17,27 @@ */ import type { ReactNode } from 'react'; -import { Settings as SettingsIcon } from '@wso2/oxygen-ui-icons-react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Settings } from '@wso2/oxygen-ui-icons-react'; import { useConsoleScope } from '../scope/ConsoleScopeProvider'; -import { useExtensions } from '../extensions'; +import { + settingsTabExtensions, + settingsTabSlot, + useExtensions, +} from '../extensions'; import { useIsHidden } from '../slots'; import type { NavigationLevel } from './navigationTypes'; +const messages = defineMessages({ + generalTab: { + id: '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.', + }, +}); + export type SettingsTab = { id: string; label: string; @@ -33,46 +47,43 @@ export type SettingsTab = { order: number; }; -const BUILT_IN_GENERAL_TAB: SettingsTab = { - id: 'general', - label: 'General', - icon: , - path: 'general', - order: 0, -}; - /** - * The sub-nav tabs rendered inside the Settings page for a given scope: the + * The sub-nav tabs rendered inside the Settings page for a given level: the * built-in "General" tab (unless suppressed via `Hideable`) plus any - * cloud-injected extension registered against the matching - * `settings..tabs` slot, sorted by order. Mirrors + * host-injected extension registered against the matching + * `settings..tabs` slot, sorted by `order`. Mirrors * `useNavigationItems`'s registry-plus-extensions merge, one level deeper * (inside Settings rather than the main sidebar). */ -export const useSettingsTabs = (scope: NavigationLevel): SettingsTab[] => { +export const useSettingsTabs = (level: NavigationLevel): SettingsTab[] => { + const intl = useIntl(); const consoleScope = useConsoleScope(); const extensions = useExtensions(); - const generalTabHidden = useIsHidden(`settings.${scope}.tabs.general`); + const generalTabHidden = useIsHidden(`${settingsTabSlot(level)}.general`); - const extensionTabs: SettingsTab[] = extensions - // Require `scope` to agree with the slot name too — a type-valid but - // inconsistent descriptor (e.g. `slot: 'settings.organization.tabs'` - // with `scope: 'project'`) must not render here with the wrong scope's - // Port (see `AppRoutes.tsx`'s equivalent guard for its route). - .filter( - (extension) => - extension.slot === `settings.${scope}.tabs` && extension.scope === scope - ) + const extensionTabs: SettingsTab[] = settingsTabExtensions(extensions, level) .filter((extension) => extension.isVisible?.(consoleScope) ?? true) .map((extension) => ({ + icon: extension.icon ?? , id: extension.id, + // An extension's label is host-supplied and already in the host's own + // locale — it is passed through, never run through this app's catalog. label: extension.label, - icon: extension.icon ?? , - path: extension.routePath.replace(/^settings\//, ''), order: extension.order, + path: extension.routePath.replace(/^settings\//, ''), })); - const builtInTabs = generalTabHidden ? [] : [BUILT_IN_GENERAL_TAB]; + const builtInTabs: SettingsTab[] = generalTabHidden + ? [] + : [ + { + icon: , + id: 'general', + label: intl.formatMessage(messages.generalTab), + order: 0, + path: 'general', + }, + ]; return [...builtInTabs, ...extensionTabs].sort( (left, right) => left.order - right.order diff --git a/portals/api-control-plane/src/pages/appShell/APIQuickSelector.tsx b/portals/api-control-plane/src/pages/appShell/APIQuickSelector.tsx new file mode 100644 index 0000000000..c2aee08319 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/APIQuickSelector.tsx @@ -0,0 +1,190 @@ +/* + * 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, { useEffect, useMemo, useState } from 'react'; +import { + Box, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + TextField, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronRight, Search } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +type SelectableApi = { + id: string; + name: string; + description?: string; +}; + +type Props = { + disabled: boolean; + isApisLoading: boolean; + apisError?: unknown; + apiOptions: SelectableApi[]; + onSelectApi: (apiId: string) => void; +}; + +export default function APIQuickSelector({ + disabled, + isApisLoading, + apisError, + apiOptions, + onSelectApi, +}: Props) { + const intl = useIntl(); + const [anchorEl, setAnchorEl] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const open = Boolean(anchorEl); + + useEffect(() => { + if (!disabled) return; + setAnchorEl(null); + setSearchQuery(''); + }, [disabled]); + + const filteredApis = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return apiOptions; + return apiOptions.filter((api) => { + const haystack = [api.name, api.description] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); + }, [apiOptions, searchQuery]); + + const handleClose = () => { + setAnchorEl(null); + setSearchQuery(''); + }; + + return ( + <> + + + setAnchorEl(event.currentTarget)} + sx={{ + width: 32, + height: 32, + border: '1px solid', + borderColor: 'divider', + // borderRadius: 1, + }} + > + + + + + + + + + + + setSearchQuery(event.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + + {isApisLoading ? ( + + + + ) : apisError ? ( + + + + ) : + (filteredApis.length === 0 ? ( + + + + ) : ( + filteredApis.map(api => ( + { + handleClose(); + onSelectApi(api.id); + }} + > + {api.name} + + )) + ))} + + + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/AppHeader.test.tsx b/portals/api-control-plane/src/pages/appShell/AppHeader.test.tsx new file mode 100644 index 0000000000..5e5b00b209 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/AppHeader.test.tsx @@ -0,0 +1,92 @@ +/* + * 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 { AppShell } from '@wso2/oxygen-ui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SidebarErrorFallback } from '../../components/errors/ErrorFallback'; +import { renderWithProviders, screen } from '../../test/utils'; + +// The switchers own every scope-dependent hook in the header, so making the +// whole module throw is exactly the failure the boundary around it exists for. +vi.mock('./HeaderScopeSwitchers', () => ({ + HeaderScopeSwitchers: () => { + throw new Error('scope lookup returned an unexpected shape'); + }, +})); + +import { AppHeader } from './AppHeader'; + +/** `useAppShell()` throws outside a provider, so the header needs its real slot. */ +const renderHeader = () => + renderWithProviders( + + + + + + ); + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('AppHeader', () => { + it('keeps the rest of the header usable when the switchers throw', async () => { + const { user } = renderHeader(); + + // Brand and actions are unaffected — they read nothing from scope. + expect(screen.getByText('API Platform')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Notifications' }) + ).toBeInTheDocument(); + + // The one that matters: losing a switcher must never cost the user their + // way out of the session. + await user.click(screen.getByRole('button', { name: 'Account' })); + expect(await screen.findByText('Test User')).toBeInTheDocument(); + expect( + await screen.findByText(/log ?out|sign out/i) + ).toBeInTheDocument(); + }); + + it('leaves a visible marker rather than silently dropping the switchers', () => { + renderHeader(); + + expect( + screen.getByRole('status', { + name: /switchers are unavailable/i, + }) + ).toBeInTheDocument(); + expect(console.error).toHaveBeenCalled(); + }); +}); + +describe('SidebarErrorFallback', () => { + it('renders an empty rail carrying a marker', () => { + renderWithProviders(); + + expect( + screen.getByRole('status', { name: /navigation is unavailable/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/AppHeader.tsx b/portals/api-control-plane/src/pages/appShell/AppHeader.tsx new file mode 100644 index 0000000000..e5bbec4fa9 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/AppHeader.tsx @@ -0,0 +1,96 @@ +/* + * 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, + Header, + IconButton, + Tooltip, + UserMenu, + useAppShell, +} from '@wso2/oxygen-ui'; +import { Bell, LogOut, WSO2 } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useLocation } from 'react-router-dom'; + +import { ErrorBoundary } from '../../components/errors/ErrorBoundary'; +import { HeaderSwitchersErrorFallback } from '../../components/errors/ErrorFallback'; +import { useAuth } from '../../contexts/auth/AuthProvider'; +import { HeaderScopeSwitchers } from './HeaderScopeSwitchers'; + +export function AppHeader() { + const intl = useIntl(); + const location = useLocation(); + const { actions } = useAppShell(); + const auth = useAuth(); + + const userName = auth.user?.name || 'User'; + const userEmail = auth.user?.email || ''; + + return ( +
+ + + + + + + + + + + {/* Guard only the switchers: their data (orgs, projects, APIs, etc.) may be + missing or malformed. Keep the brand/actions outside so logout stays + available. Use `resetKeys` with pathname so a broken switcher recovers + after navigation. */} + } + resetKeys={[location.pathname]} + > + + + + + + + + + + + + + + + + + + + } onClick={auth.logout} /> + + +
+ ); +} diff --git a/portals/api-control-plane/src/pages/appShell/AppLayout.tsx b/portals/api-control-plane/src/pages/appShell/AppLayout.tsx new file mode 100644 index 0000000000..8d710e89b4 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/AppLayout.tsx @@ -0,0 +1,203 @@ +/* + * 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, + PageContent, + Stack, +} from '@wso2/oxygen-ui'; +import type { BreadcrumbItem } from '@wso2/oxygen-ui'; +import { Bell } from '@wso2/oxygen-ui-icons-react'; +import { Suspense } from 'react'; +import { matchPath, Outlet, useLocation, useNavigate } from 'react-router-dom'; + +import { ErrorBoundary } from '../../components/errors/ErrorBoundary'; +import { + PageErrorFallback, + SidebarErrorFallback, +} from '../../components/errors/ErrorFallback'; +import { LoadingState } from '../../components/StateViews'; +import { runtimeConfig } from '../../config/runtime'; +import { routes } from '../../routes/paths'; +import { useConsoleScope } from '../../scope/ConsoleScopeProvider'; +import { useNotifications } from '../../components/Notifications'; +import { PortProvider, type CloudHostPort } from '../../hostPort'; +import { AppHeader } from './AppHeader'; +import { APP_FOOTER_ID } from './appLayoutConstants'; +import { AppSidebar } from './AppSidebar'; +import { FormattedMessage } from 'react-intl'; + +/** + * Full-page creation flows, which the shell renders without a breadcrumb trail. + * + * A wizard is creating the very scope a trail would describe, so the crumbs can + * only point at where the user came from — noise beside a form that owns the + * whole page. Built from the route builders rather than written out, so a path + * change cannot silently stop matching (`routes.*` is the single source). + */ +const BREADCRUMB_FREE_ROUTES = [routes.newApi(), routes.newGateway()]; + +export default function AppLayout() { + const navigate = useNavigate(); + const location = useLocation(); + const { organization, project, component, params } = useConsoleScope(); + const { notify } = useNotifications(); + + const hidesBreadcrumbs = BREADCRUMB_FREE_ROUTES.some( + (path) => matchPath(path, location.pathname) !== null + ); + + // 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?.displayName || params.orgHandle, + onClick: () => navigate(routes.organizationHome(params.orgHandle!)), + }); + } + if (params.orgHandle && params.projectHandler) { + crumbs.push({ + key: 'project', + label: project?.displayName || 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 ( + + + + + + + + {/* Keep this outside so Sidebar.Category can inspect children */} + } + resetKeys={[location.pathname]} + > + + + + + + + + + {!hidesBreadcrumbs && breadcrumbItems.length > 1 && ( + + )} + {/* Error boundary scoped to routed page only; resets on pathname change */} + ( + + )} + resetKeys={[location.pathname]} + > + }> + + + + + + + + + + {/* id is an anchor for measuring the footer height so sticky action + bars (develop tabs' SaveBar) can offset above it — see SaveBar. */} + +
+ + © {new Date().getFullYear()} WSO2 LLC. + + {runtimeConfig.environmentName} + + + + + + +
+
+
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/portals/api-control-plane/src/pages/appShell/AppSidebar.test.tsx b/portals/api-control-plane/src/pages/appShell/AppSidebar.test.tsx new file mode 100644 index 0000000000..3201f8ff0c --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/AppSidebar.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { AppShell } from '@wso2/oxygen-ui'; +import { describe, expect, it } from 'vitest'; + +import type { RestApi } from '../../api/resources/restApis'; +import { organizations, projects } from '../../api/mocks/data'; +import { routes } from '../../routes/paths'; +import { makeConsoleScope } from '../../test/mockScope'; +import { renderWithProviders, screen } from '../../test/utils'; +import { AppSidebar } from './AppSidebar'; + +const ORG = organizations[0].id; +const PROJECT = projects[0].id; +const API = 'api-1'; + +// Lowercase transports, exactly as the API sends them: an upper-case-only +// reachability check made `canTest` false and hid the whole Test menu once the +// scope gate had been passed. Keeping the fixture in the spec's casing means +// these tests fail if that returns. +const COMPONENT = { + displayName: 'Orders API', + id: API, + kind: 'RestApi', + transport: ['http', 'https'], +} as RestApi; + +const renderSidebar = (route: string, isApiScope: boolean) => + renderWithProviders( + + + + + , + { + route, + scope: makeConsoleScope({ + component: COMPONENT, + isApiScope, + isProjectScope: isApiScope, + params: isApiScope + ? { apiHandler: API, orgHandle: ORG, projectHandler: PROJECT } + : { orgHandle: ORG }, + // Left unset either way: the sidebar reads scope from the flags and + // params above, never from the loaded project object. + project: undefined, + }), + } + ); + +/* + * The two halves of a submenu parent, as the user meets them. Which one applies + * is decided entirely by whether the item has children — see `renderItem`. + */ +describe('AppSidebar submenus', () => { + it('opens Test as a submenu in API scope rather than navigating', async () => { + const { user } = renderSidebar(routes.api(ORG, PROJECT, API), true); + + const test = screen.getByRole('button', { name: /^Test$/ }); + // A disclosure, not a link: no href to follow. + expect(test.closest('a')).toBeNull(); + expect(test).toHaveAttribute('aria-expanded', 'false'); + + await user.click(test); + + expect(test).toHaveAttribute('aria-expanded', 'true'); + for (const label of ['API Console', 'Curl', 'API Chat']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('links Test at its first child scope gate when no API is open', () => { + renderSidebar(routes.organizationHome(ORG), false); + + const link = screen.getByRole('button', { name: /^Test$/ }).closest('a'); + expect(link).toHaveAttribute( + 'href', + routes.apiTestConsole(ORG, null, null) + ); + // Nothing to disclose, so no submenu entries and no chevron state. + expect(screen.queryByText('API Console')).not.toBeInTheDocument(); + }); + + it('opens Develop onto the three panels lifted off the overview page', async () => { + const { user } = renderSidebar(routes.api(ORG, PROJECT, API), true); + + await user.click(screen.getByRole('button', { name: /^Develop$/ })); + + for (const label of ['Policies', 'Routing', 'Documents']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('auto-opens the submenu holding the active page', () => { + renderSidebar(routes.apiObservabilityLogs(ORG, PROJECT, API), true); + + expect( + screen.getByRole('button', { name: /^Observability$/ }) + ).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Logs')).toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/AppSidebar.tsx b/portals/api-control-plane/src/pages/appShell/AppSidebar.tsx new file mode 100644 index 0000000000..cc64a004b1 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/AppSidebar.tsx @@ -0,0 +1,116 @@ +/* + * 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 { useCallback, useEffect, useMemo, useState } from 'react'; +import { Sidebar, useAppShell } from '@wso2/oxygen-ui'; +import { Link, useNavigate } from 'react-router-dom'; + +import { useNavigationClusters } from '../../navigation/useNavigationItems'; +import type { NavigationItem } from '../../navigation/navigationTypes'; + +/** Every item and sub-item, depth-first — the order the sidebar renders them. */ +const flatten = (items: NavigationItem[]): NavigationItem[] => + items.flatMap((item) => [item, ...flatten(item.children ?? [])]); + +export function AppSidebar() { + const clusters = useNavigationClusters(); + const { state } = useAppShell(); + const navigate = useNavigate(); + const [expandedMenus, setExpandedMenus] = useState>( + {} + ); + + const items = useMemo(() => clusters.flat(), [clusters]); + const allItems = useMemo(() => flatten(items), [items]); + const activeItem = allItems.find((item) => item.isActive)?.id; + + // A parent whose child is active opens itself, so landing on a submenu page — + // by deep link, by browser Back, or after a ScopeGate resolves — shows where + // you are. Only this parent is forced; anything the user opened by hand stays + // as they left it. + const activeParent = items.find((item) => + item.children?.some((child) => child.id === activeItem) + )?.id; + useEffect(() => { + if (!activeParent) return; + setExpandedMenus((previous) => + previous[activeParent] ? previous : { ...previous, [activeParent]: true } + ); + }, [activeParent]); + + const toggleMenu = useCallback((id: string) => { + setExpandedMenus((previous) => ({ ...previous, [id]: !previous[id] })); + }, []); + + // Collapsed to the icon rail, Oxygen renders a submenu as a popover whose + // entries call `onSelect` and ignore each child's `link` — so without this, + // sub-items would be unreachable in the collapsed sidebar. Navigating to the + // item's own target is idempotent for the expanded case, where the `link` has + // already taken the user there. + const selectItem = useCallback( + (id: string) => { + const target = allItems.find((item) => item.id === id)?.to; + if (target) navigate(target); + }, + [allItems, navigate] + ); + + const renderItem = (item: NavigationItem) => { + const children = item.children ?? []; + return ( + } + > + {item.icon} + {item.label} + {children.map(renderItem)} + + ); + }; + + return ( + + {/* + `showDividers` and label-less categories: the sidebar separates its + clusters with a rule rather than a heading, because an item is no longer + tied to one scope — Overview follows you from organization to project to + API, so no single section title fits it. + */} + + {clusters.map((clusterItems) => ( + + {clusterItems.map(renderItem)} + + ))} + + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/HeaderScopeSwitchers.tsx b/portals/api-control-plane/src/pages/appShell/HeaderScopeSwitchers.tsx new file mode 100644 index 0000000000..a8a3327aa9 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/HeaderScopeSwitchers.tsx @@ -0,0 +1,332 @@ +/* + * 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, ComplexSelect, Header, IconButton } from '@wso2/oxygen-ui'; +import { Boxes, Building, Layers, X } from '@wso2/oxygen-ui-icons-react'; +import { useIntl } from 'react-intl'; +import { useNavigate } from 'react-router-dom'; + +import { useRestApis } from '../../api/resources/restApis'; +import SearchableComplexSelect from '../../components/common/SearchableComplexSelect'; +import { routes } from '../../routes/paths'; +import { useConsoleScope } from '../../scope/ConsoleScopeProvider'; +import APIQuickSelector from './APIQuickSelector'; +import ProjectQuickSelector from './ProjectQuickSelector'; + +// Switcher options can carry long display names/handles; bound the trigger width +// and let the option text ellipsize instead of overflowing the header. +const SWITCHER_SELECT_SX = { minWidth: 220, maxWidth: 260 }; + +const TRUNCATED_OPTION_TEXT_SLOT_PROPS = { + primary: { noWrap: true }, + secondary: { variant: 'caption' as const, noWrap: true }, +}; + +/** + * The organization / project / API switchers in the header. + * + * Split out of `AppHeader` so it can sit behind its own error boundary. Every + * scope-dependent hook and every derivation over that data lives here, which is + * the point: those run before any JSX is returned, so a boundary placed inside + * the header's markup could never catch them. Keeping them in a child component + * is what makes the failure containable — the brand, the notification bell and + * the user menu's logout stay reachable when this throws. + * + * It owns the `params.orgHandle` guard too. Lifting that back into `AppHeader` + * would drag `useConsoleScope()` up with it and put the throw back outside the + * boundary. + */ +export function HeaderScopeSwitchers() { + const navigate = useNavigate(); + const intl = useIntl(); + const { + component, + organization, + organizations, + params, + project, + projects, + isLoading, + projectsError, + } = useConsoleScope(); + + 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)); + }; + + const changeApi = (apiHandler: string) => { + if (!params.orgHandle || !params.projectHandler || !apiHandler) return; + navigate(routes.api(params.orgHandle, params.projectHandler, apiHandler)); + }; + + const clearProjectSelection = () => { + if (!params.orgHandle) return; + navigate(routes.organizationHome(params.orgHandle)); + }; + + const clearApiSelection = () => { + if (!params.orgHandle || !params.projectHandler) return; + navigate(routes.projectHome(params.orgHandle, params.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.map((org) => ({ handle: org.id, name: org.displayName || org.id })) + : params.orgHandle + ? [{ handle: params.orgHandle, name: organization?.displayName || params.orgHandle }] + : []; + + const projectOptions: { handler: string; name: string }[] = + projects.length > 0 + ? projects.map((project) => ({ handler: project.id, name: project.displayName || project.id })) + : params.projectHandler && project ? [{ handler: params.projectHandler, name: project.displayName || params.projectHandler }] + : []; + + const apisQuery = useRestApis( + {}, + { projectId: project?.id, orgId: organization?.id } + ); + const apis = apisQuery.data?.list ?? []; + const loadedApiOptions: { handler: string; name: string }[] = + (projects.length > 0 && project) ? + apis + .filter( + ( + api + ): api is NonNullable & { id: string; displayName?: string } => + typeof api?.id === 'string' && api.id.length > 0 + ) + .map((api) => ({ handler: api.id, name: api.displayName ?? api.id })) + : []; + + // apis may not be loaded yet on first paint; keep the current API selectable + // so the switcher never renders an out-of-range value. + const apiOptions: { handler: string; name: string }[] = + loadedApiOptions.length > 0 + ? loadedApiOptions + : params.apiHandler + ? [{ handler: params.apiHandler, name: component?.displayName || params.apiHandler }] + : []; + + if (!params.orgHandle) return null; + + return ( + + item.handle === params.orgHandle).map(item => ({ + id: item.handle, + handler: item.handle, + name: item.name, + }))[0] || undefined} + onChange={(id) => { + changeOrganization(id); + }} + options={orgOptions.map((item) => ({ + id: item.handle, + handler: item.handle, + name: item.name, + }))} + renderOptionContent={(option) => ( + <> + + + + + + )} + searchPlaceholder={intl.formatMessage({ id: 'appShell.header.org.placeholder', defaultMessage: 'Search organizations...' })} + emptyMessage={intl.formatMessage({ id: 'appShell.header.org.empty', defaultMessage: 'No organizations found' })} + noResultsMessage={intl.formatMessage({ id: 'appShell.header.org.noResults', defaultMessage: 'No matching organizations' })} + sx={SWITCHER_SELECT_SX} + /> + + {params.projectHandler && ( + + item.handler === params.projectHandler).map(item => ({ + id: item.handler, + handler: item.handler, + name: item.name, + }))[0] || undefined} + onChange={(id) => { + changeProject(id); + }} + options={projectOptions.map((item) => ({ + id: item.handler, + handler: item.handler, + name: item.name, + }))} + renderOptionContent={(option) => ( + <> + + + + + + )} + searchPlaceholder={intl.formatMessage({ id: 'appShell.header.project.placeholder', defaultMessage: 'Search projects...' })} + emptyMessage={intl.formatMessage({ id: 'appShell.header.project.empty', defaultMessage: 'No projects found' })} + noResultsMessage={intl.formatMessage({ id: 'appShell.header.project.noResults', defaultMessage: 'No matching projects' })} + sx={SWITCHER_SELECT_SX} + /> + + { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + clearProjectSelection(); + }} + sx={{ + position: "absolute", + top: 6, + right: 2, + zIndex: 1, + width: 20, + height: 10, + }} + > + + + + )} + + {!params.projectHandler && ( + ({ + id: item.handler, + handler: item.handler, + name: item.name, + }))} + onSelectProject={(projectHandler) => { + changeProject(projectHandler); + }} + /> + )} + + {params.apiHandler && ( + + item.handler === params.apiHandler).map(item => ({ + id: item.handler, + handler: item.handler, + name: item.name, + }))[0] || undefined} + onChange={(id) => { + changeApi(id); + }} + options={apiOptions.map((item) => ({ + id: item.handler, + handler: item.handler, + name: item.name, + }))} + renderOptionContent={(option) => ( + <> + + + + + + )} + searchPlaceholder={intl.formatMessage({ id: 'appShell.header.api.placeholder', defaultMessage: 'Search APIs...' })} + emptyMessage={intl.formatMessage({ id: 'appShell.header.api.empty', defaultMessage: 'No APIs found' })} + noResultsMessage={intl.formatMessage({ id: 'appShell.header.api.noResults', defaultMessage: 'No matching APIs' })} + sx={SWITCHER_SELECT_SX} + /> + + { + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + clearApiSelection(); + }} + sx={{ + position: "absolute", + top: 6, + right: 2, + zIndex: 1, + width: 20, + height: 10, + }} + > + + + + )} + + {!params.apiHandler && params.projectHandler && ( + ({ + id: item.handler, + handler: item.handler, + name: item.name, + }))} + onSelectApi={(apiHandler) => { + changeApi(apiHandler); + }} + /> + )} + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/ProjectQuickSelector.tsx b/portals/api-control-plane/src/pages/appShell/ProjectQuickSelector.tsx new file mode 100644 index 0000000000..1c4d128f6a --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/ProjectQuickSelector.tsx @@ -0,0 +1,190 @@ +/* + * 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, { useEffect, useMemo, useState } from 'react'; +import { + Box, + Divider, + IconButton, + InputAdornment, + Menu, + MenuItem, + TextField, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { ChevronRight, Search } from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +type SelectableProject = { + id: string; + name: string; + description?: string; +}; + +type Props = { + disabled: boolean; + isProjectsLoading: boolean; + projectsError?: unknown; + projectOptions: SelectableProject[]; + onSelectProject: (projectId: string) => void; +}; + +export default function ProjectQuickSelector({ + disabled, + isProjectsLoading, + projectsError, + projectOptions, + onSelectProject, +}: Props) { + const intl = useIntl(); + const [anchorEl, setAnchorEl] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const open = Boolean(anchorEl); + + useEffect(() => { + if (!disabled) return; + setAnchorEl(null); + setSearchQuery(''); + }, [disabled]); + + const filteredProjects = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return projectOptions; + return projectOptions.filter((project) => { + const haystack = [project.name, project.description] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(query); + }); + }, [projectOptions, searchQuery]); + + const handleClose = () => { + setAnchorEl(null); + setSearchQuery(''); + }; + + return ( + <> + + + setAnchorEl(event.currentTarget)} + sx={{ + width: 32, + height: 32, + border: '1px solid', + borderColor: 'divider', + }} + > + + + + + + + + + + + setSearchQuery(event.target.value)} + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + + {isProjectsLoading ? ( + + + + ) : projectsError ? ( + + + + ) : + (filteredProjects.length === 0 ? ( + + + + ) : ( + filteredProjects.map(project => ( + { + handleClose(); + onSelectProject(project.id); + }} + > + {project.name} + + )) + )) + } + + + + ); +} diff --git a/portals/api-control-plane/src/layouts/appLayoutConstants.ts b/portals/api-control-plane/src/pages/appShell/appLayoutConstants.ts similarity index 93% rename from portals/api-control-plane/src/layouts/appLayoutConstants.ts rename to portals/api-control-plane/src/pages/appShell/appLayoutConstants.ts index dd86d59e69..66575ac143 100644 --- a/portals/api-control-plane/src/layouts/appLayoutConstants.ts +++ b/portals/api-control-plane/src/pages/appShell/appLayoutConstants.ts @@ -18,4 +18,4 @@ /** DOM id on the app footer wrapper, used to measure its height so sticky * action bars (e.g. develop tabs' SaveBar) can offset above it. */ -export const APP_FOOTER_ID = 'apim-app-footer'; +export const APP_FOOTER_ID = 'API Platform Footer'; diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/admin/AdminPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/admin/AdminPage.tsx new file mode 100644 index 0000000000..90f5c6e9aa --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/admin/AdminPage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function AdminPage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/components/cards/ApiCardGrid.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiCardGrid.tsx similarity index 58% rename from portals/api-control-plane/src/components/cards/ApiCardGrid.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiCardGrid.tsx index 2190fd1bff..2de6da2a3b 100644 --- a/portals/api-control-plane/src/components/cards/ApiCardGrid.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiCardGrid.tsx @@ -18,36 +18,34 @@ import { Box } from '@wso2/oxygen-ui'; -import type { Api } from '../../types/domain'; -import { ApiCard } from './ApiCard'; +import type { RestApi } from '../../../../api/resources/restApis'; +import { ApiCard } from './components/ApiCard'; type ApiCardGridProps = { - components: Api[]; - onOpen: (component: Api) => void; - onDelete?: (component: Api) => void; + apis: RestApi[]; + onOpen: (api: RestApi) => void; + onDelete?: (api: RestApi) => void; }; /** Auto-fill card grid, same density as the gateways page. */ -export function ApiCardGrid({ - components, - onOpen, - onDelete, -}: ApiCardGridProps) { +export function ApiCardGrid({ apis, onOpen, onDelete }: ApiCardGridProps) { return ( *': { minWidth: 0 }, }} > - {components.map((component) => ( - + {apis.map((api) => ( + ))} ); diff --git a/portals/api-control-plane/src/features/apis/ApiCreatePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiCreatePage.tsx similarity index 99% rename from portals/api-control-plane/src/features/apis/ApiCreatePage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiCreatePage.tsx index 77c650cb0a..361f5214c1 100644 --- a/portals/api-control-plane/src/features/apis/ApiCreatePage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiCreatePage.tsx @@ -58,16 +58,16 @@ import { import yaml from 'js-yaml'; import { useNavigate, useParams } from 'react-router-dom'; -import { useCreateApi } from '../../api/hooks/useMvpQueries'; -import { useNotifications } from '../../components/Notifications'; -import { routes } from '../../routes/paths'; +import { useCreateApi } from '../../../../api/hooks/useMvpQueries'; +import { useNotifications } from '../../../../components/Notifications'; +import { routes } from '../../../../routes/paths'; import type { ApiOperation, CreateApiInput, CreateApiSource, HttpMethod, UpstreamAuth, -} from '../../types/domain'; +} from '../../../../types/domain'; import { isValidUrl, methodColor } from './develop/developEdit'; const OAS_METHODS = [ diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiDetailPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiDetailPage.tsx new file mode 100644 index 0000000000..a045d896d6 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiDetailPage.tsx @@ -0,0 +1,169 @@ +/* + * 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 { Avatar, Box, Button, Card, Chip, Stack, Typography } from '@wso2/oxygen-ui'; + +import { useApiDetail } from '../../../../api/hooks/useMvpQueries'; +import { ErrorState, LoadingState } from '../../../../components/StateViews'; +import { OverviewTab } from './overview/OverviewTab'; +import { FormattedMessage } from 'react-intl'; +import { Link as RouterLink } from 'react-router-dom'; +import { routes } from '../../../../routes/paths'; +import { useConsoleScope } from '../../../../scope/ConsoleScopeProvider'; + +// No `ScopeGate`: this page is the API tier of the sidebar's Overview item, which +// degrades to a shallower tier rather than linking here without an API. +export function ApiDetailPage() { + const detailQuery = useApiDetail(); + const { params } = useConsoleScope(); + + if (detailQuery.isLoading) return ; + if (detailQuery.error || !detailQuery.data) { + return ; + } + + const detail = detailQuery.data; + + // The page is the API tier of the sidebar's Overview item, so it only ever + // mounts with all three handles in the URL; `apiPath` degrades to the + // scope-less alias for anything still missing. + const deployPath = routes.apiDeploy( + params.orgHandle ?? '', + params.projectHandler ?? null, + params.apiHandler ?? null + ); + + const truncateProviderDisplayName = ( + name?: string | null, + maxLength = 30 +): string => { + const normalizedName = name?.trim() ?? ''; + if (normalizedName.length <= maxLength) { + return normalizedName; + } + + return `${normalizedName.slice(0, maxLength).trim()}…`; +}; + + return ( + <> + + + {/* Header card with editable fields */} + + + + + + {(detail.displayName || '\u2014').trim().slice(0, 2).toUpperCase()} + + + + + + {truncateProviderDisplayName(detail.displayName || '\u2014')} + + + + + + + + + + {detail.context || '/'} + + + + + + + + {detail.updatedAt} + + + + + + + + + + + + + + + + {/* Policy, Routing and Documents used to be tabs here; each is now its own + page under the sidebar's Develop menu, leaving Overview as the whole of + this page — so no tab bar. */} + + + ); +} diff --git a/portals/api-control-plane/src/features/apis/ApiListPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiListPage.tsx similarity index 52% rename from portals/api-control-plane/src/features/apis/ApiListPage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiListPage.tsx index 5c89ff4c3e..c61b7cab0a 100644 --- a/portals/api-control-plane/src/features/apis/ApiListPage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiListPage.tsx @@ -20,142 +20,123 @@ import { useMemo, useState } from 'react'; import { Box, Button, - Chip, InputAdornment, - PageContent, PageTitle, Stack, TextField, ToggleButton, ToggleButtonGroup, - Typography, } from '@wso2/oxygen-ui'; -import { - Boxes, - LayoutGrid, - List, - Plus, - Search, -} from '@wso2/oxygen-ui-icons-react'; +import { LayoutGrid, List, Plus, Search } from '@wso2/oxygen-ui-icons-react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useApis, useDeleteApi } from '../../api/hooks/useMvpQueries'; -import { ApiCardGrid } from '../../components/cards/ApiCardGrid'; -import { filterApis, groupApisByKind } from '../../components/cards/apiDisplay'; -import { ApiListView } from '../../components/cards/ApiListView'; -import { ConfirmDialog } from '../../components/ConfirmDialog'; -import { useNotifications } from '../../components/Notifications'; +import { + useDeleteRestApi, + useRestApis, + type RestApi, +} from '../../../../api/resources/restApis'; +import { ApiCardGrid } from './ApiCardGrid'; +import { ApiListView } from './ApiListView'; +import { filterRestApis } from './restApiDisplay'; +import { ConfirmDialog } from '../../../../components/ConfirmDialog'; +import { useNotifications } from '../../../../components/Notifications'; import { EmptyState, ErrorState, LoadingState, -} from '../../components/StateViews'; -import { routes } from '../../routes/paths'; -import type { Api } from '../../types/domain'; +} from '../../../../components/StateViews'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; +import { FormattedMessage } from 'react-intl'; type ViewMode = 'grid' | 'list'; -function ApiSection({ - title, - icon, - components, - view, - onOpen, - onDelete, -}: { - title: string; - icon: React.ReactNode; - components: Api[]; - view: ViewMode; - onOpen: (component: Api) => void; - onDelete: (component: Api) => void; -}) { - if (components.length === 0) return null; +export function ApiListPage() { + // Gating the whole body, not just the JSX: out of project scope `useRestApis` + // stays disabled and `isPending` never clears, so the loading branch below + // would sit there forever instead of the scope prompt showing. return ( - - - - {icon} - - {title} - - - - {view === 'grid' ? ( - - ) : ( - - )} - + + + ); } -export function ApiListPage() { +function ApiList() { const { orgHandle = '', projectHandler = '' } = useParams(); const navigate = useNavigate(); - const apisQuery = useApis(); - const deleteApiMutation = useDeleteApi(); + const apisQuery = useRestApis(); + const deleteApiMutation = useDeleteRestApi(); const { notify } = useNotifications(); const [search, setSearch] = useState(''); const [view, setView] = useState('grid'); - const [toDelete, setToDelete] = useState(null); + const [toDelete, setToDelete] = useState(null); + + const apis = useMemo(() => apisQuery.data?.list ?? [], [apisQuery.data]); + const searched = useMemo(() => filterRestApis(apis, search), [apis, search]); const confirmDelete = () => { - if (!toDelete) return; - deleteApiMutation.mutate(toDelete, { - onSuccess: () => { - notify(`Deleted "${toDelete.displayName}".`, 'success'); - setToDelete(null); - }, - onError: (error) => - notify( - error instanceof Error ? error.message : 'Delete failed', - 'error' - ), - }); + if (!toDelete?.id) return; + deleteApiMutation.mutate( + { restApiId: toDelete.id }, + { + onSuccess: () => { + notify(`Deleted "${toDelete.displayName}".`, 'success'); + setToDelete(null); + }, + onError: (error) => notify(error.message || 'Delete failed', 'error'), + } + ); }; - const components = useMemo(() => apisQuery.data || [], [apisQuery.data]); - const searched = useMemo( - () => filterApis(components, search), - [components, search] - ); - const groups = useMemo(() => groupApisByKind(searched), [searched]); - const matchCount = groups.apiProxies.length + groups.others.length; - - const openApi = (component: Api) => - navigate(routes.api(orgHandle, projectHandler, component.handler)); + const openApi = (api: RestApi) => + navigate(routes.api(orgHandle, projectHandler, api.id ?? '')); const createApi = () => navigate(routes.newApi(orgHandle, projectHandler)); - if (apisQuery.isLoading) return ; + // `isPending` rather than `isLoading`: the query stays disabled until the + // route's org/project resolve, and in that window `isLoading` is already + // false with no data — which would flash the "No APIs yet" empty state. + if (apisQuery.isPending) return ; if (apisQuery.error) { return ; } return ( - + <> - APIs - API proxies in this project. + + + + + + - {components.length === 0 ? ( + {apis.length === 0 ? ( @@ -203,30 +184,23 @@ export function ApiListPage() {
- {matchCount === 0 ? ( + {searched.length === 0 ? ( + ) : view === 'grid' ? ( + ) : ( - - } - onDelete={setToDelete} - onOpen={openApi} - title="API Proxies" - view={view} - /> - } - onDelete={setToDelete} - onOpen={openApi} - title="Other APIs" - view={view} - /> - + )}
)} @@ -239,7 +213,7 @@ export function ApiListPage() { loading={deleteApiMutation.isPending} message={ toDelete - ? `This permanently deletes the API proxy "${toDelete.displayName}" ` + + ? `This permanently deletes the API "${toDelete.displayName}" ` + 'and all related details. This action is irreversible.' : '' } @@ -248,6 +222,6 @@ export function ApiListPage() { open={toDelete !== null} title="Delete API" /> - + ); } diff --git a/portals/api-control-plane/src/components/cards/ApiListView.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiListView.tsx similarity index 71% rename from portals/api-control-plane/src/components/cards/ApiListView.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiListView.tsx index 80fe9dca1e..f312effc10 100644 --- a/portals/api-control-plane/src/components/cards/ApiListView.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/ApiListView.tsx @@ -26,26 +26,32 @@ import { ListItemText, Menu, MenuItem, + Tooltip, Typography, } from '@wso2/oxygen-ui'; import { Clock, MoreVertical, Trash2 } from '@wso2/oxygen-ui-icons-react'; -import type { Api } from '../../types/domain'; -import { relativeTime } from '../../utils/relativeTime'; -import { COMPONENT_KIND_LABEL } from './apiDisplay'; -import { EnvStatusChips } from './EnvStatusChips'; -import { KindIconTile } from './KindIconTile'; -import { StatusPill } from './StatusPill'; +import type { RestApi } from '../../../../api/resources/restApis'; +import { relativeTime } from '../../../../utils/relativeTime'; +import { KindIconTile } from '../../../../components/cards/KindIconTile'; +import { + DeploymentStateLabel, + GatewayChips, + LifecycleChip, +} from './components/RestApiChips'; +import { apiKindLabel, useApiDeploymentState } from './restApiDisplay'; type ApiRowProps = { - component: Api; - onOpen: (component: Api) => void; - onDelete?: (component: Api) => void; + api: RestApi; + onOpen: (api: RestApi) => void; + onDelete?: (api: RestApi) => void; }; -function ApiRow({ component, onOpen, onDelete }: ApiRowProps) { - const updated = component.updatedAt || component.createdAt; +function ApiRow({ api, onOpen, onDelete }: ApiRowProps) { const [menuAnchor, setMenuAnchor] = useState(null); + const { gatewayIds, state } = useApiDeploymentState(api.id); + + const updated = api.updatedAt || api.createdAt; const closeMenu = (event?: React.MouseEvent) => { event?.stopPropagation(); @@ -54,10 +60,10 @@ function ApiRow({ component, onOpen, onDelete }: ApiRowProps) { return ( onOpen(component)} - sx={{ + onClick={() => onOpen(api)} + sx={(theme) => ({ alignItems: 'center', - borderBottom: '1px solid', + borderBottom: `${theme.border.width} ${theme.border.style}`, borderColor: 'divider', cursor: 'pointer', display: 'flex', @@ -67,12 +73,16 @@ function ApiRow({ component, onOpen, onDelete }: ApiRowProps) { transition: 'background-color 250ms', '&:hover': { bgcolor: 'action.hover' }, '&:last-of-type': { borderBottom: 0 }, - }} + })} > - + + + + + - {component.displayName} + {api.displayName} - {COMPONENT_KIND_LABEL[component.kind]} - {component.version ? ` · v${component.version}` : ''} + {api.context} + {api.version ? ` · v${api.version}` : ''} - + - + + - {component.owner && ( + {api.createdBy && ( - {component.owner.charAt(0).toUpperCase()} + {api.createdBy.charAt(0).toUpperCase()} - {component.owner} + {api.createdBy} )} @@ -146,7 +157,7 @@ function ApiRow({ component, onOpen, onDelete }: ApiRowProps) { { closeMenu(event); - onDelete(component); + onDelete(api); }} sx={{ color: 'error.main' }} > @@ -163,23 +174,19 @@ function ApiRow({ component, onOpen, onDelete }: ApiRowProps) { } type ApiListViewProps = { - components: Api[]; - onOpen: (component: Api) => void; - onDelete?: (component: Api) => void; + apis: RestApi[]; + onOpen: (api: RestApi) => void; + onDelete?: (api: RestApi) => void; }; /** Compact row layout for APIs — the list-view counterpart of ApiCardGrid. */ -export function ApiListView({ - components, - onOpen, - onDelete, -}: ApiListViewProps) { +export function ApiListView({ apis, onOpen, onDelete }: ApiListViewProps) { return ( - {components.map((component) => ( + {apis.map((api) => ( diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiCard.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiCard.tsx new file mode 100644 index 0000000000..152fc1e6b0 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/ApiCard.tsx @@ -0,0 +1,225 @@ +/* + * 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 { + Avatar, + Card, + CardActions, + CardContent, + CardHeader, + Chip, + Divider, + IconButton, + Stack, + Tooltip, + Typography, +} from '@wso2/oxygen-ui'; +import { Boxes, Clock, Lock, Trash2 } from '@wso2/oxygen-ui-icons-react'; +import { useIntl } from 'react-intl'; + +import type { RestApi } from '../../../../../api/resources/restApis'; +import { relativeTime } from '../../../../../utils/relativeTime'; +import { interactiveCardSx } from '../../../../../theme'; +import { TransportChips, VersionChip } from './RestApiChips'; +import { apiKindLabel } from '../restApiDisplay'; + +type ApiCardProps = { + api: RestApi; + onOpen: (api: RestApi) => void; + onDelete?: (api: RestApi) => void; +}; + +/** + * Marks the delete button so the card can reveal it on hover. One constant, so + * the selector below and the button it targets can never drift apart. + */ +const DELETE_CLASS = 'ApiCard-delete'; + +/** Edge of the square kind tile, and the icon sitting inside it. */ +const AVATAR_SIZE = 56; +const AVATAR_ICON_SIZE = 32; + +/** + * API card for the grid view, rendering the spec's `RESTAPI` shape. + * + * Built from the Card family Oxygen re-exports — `CardHeader` for the + * monogram/name/version band, `CardContent` for the description, `CardActions` + * for the footer — so padding, dividers, chip and avatar treatments all come + * from the theme rather than from `sx` literals here. What is left in `sx` is + * layout only: the flex column that lets equal-height cards sit in a grid. + * + * Deliberately sparse: name, version, what it speaks, where it is live and when + * it last changed. Context, operation count and lifecycle status live on the + * API's own page — a grid is for finding an API, and per-card status marks + * compete with that at a dozen cards on screen. + */ +export function ApiCard({ api, onOpen, onDelete }: ApiCardProps) { + const { formatMessage } = useIntl(); + const updated = api.updatedAt || api.createdAt; + const transports = api.transport ?? []; + + return ( + onOpen(api)} + sx={{ + ...interactiveCardSx, + display: 'flex', + flexDirection: 'column', + height: '100%', + // Hide delete until hover; `focus-within` keeps it keyboard-reachable. + [`&:hover .${DELETE_CLASS}, &:focus-within .${DELETE_CLASS}`]: { + opacity: 1, + }, + }} + > + + ({ + bgcolor: 'primary.light', + color: 'primary.contrastText', + height: AVATAR_SIZE, + width: AVATAR_SIZE, + })} + variant="rounded" + > + + + + } + slotProps={{ + // `content` has no min-width of its own, so a long name would widen + // the card instead of truncating. Both slots render a Stack, which + // cannot legally sit inside the default `span`. + content: { sx: { minWidth: 0 } }, + subheader: { component: 'div' }, + title: { component: 'div', sx: { mb: 1 } }, + }} + subheader={ + + + {api.readOnly && ( + + } + label="Gateway-managed" + size="small" + variant="outlined" + /> + + )} + + } + sx={{ alignItems: 'flex-start' }} + title={ + + + {api.displayName} + + + + } + /> + + {/* Two-line clamped description; the flex grow is what keeps every + card's footer on the same line across the grid. */} + + + {api.description || ''} + + + + + + {/* Footer: when it last changed, and the one destructive action. */} + + + {updated && ( + <> + + + {formatMessage( + { + id: 'apiCard.updated', + defaultMessage: 'Updated {relative}', + description: + 'Card footer timestamp; {relative} is a phrase such as "3 hours ago".', + }, + { relative: relativeTime(updated) } + )} + + + )} + + {onDelete && ( + + { + event.stopPropagation(); + onDelete(api); + }} + size="small" + sx={(theme) => ({ + opacity: 0, + transition: theme.transitions.create(['opacity', 'color']), + '&:hover': { color: 'error.main' }, + })} + > + + + + )} + + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/RestApiChips.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/RestApiChips.tsx new file mode 100644 index 0000000000..e8450ffa83 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/components/RestApiChips.tsx @@ -0,0 +1,158 @@ +/* + * 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, Chip, Stack, Typography, useTheme } from '@wso2/oxygen-ui'; +import type { Theme } from '@wso2/oxygen-ui'; +import { Globe, Lock } from '@wso2/oxygen-ui-icons-react'; + +import { + deploymentMeta, + lifecycleMeta, + type ApiDeploymentState, + type ChipColor, + type LifeCycleStatus, +} from '../restApiDisplay'; + +/** + * The status marks shared by the API card (grid) and the API row (list), so the + * two views can never drift on what "Published" or "Active" looks like. + */ + +/** + * Metadata chips run a step smaller than MUI's `size="small"`, which is scaled + * for chips you click. These are read-only marks sitting under a card title, so + * they take the theme's `caption` scale — one definition, applied by every chip + * here, and no font-size literal. + */ +const metaChipSx = { typography: 'caption' } as const; + +/** Resolves a semantic chip colour to the palette; `default` stays neutral. */ +const tone = (theme: Theme, color: ChipColor): string => + color === 'default' ? theme.palette.text.secondary : theme.palette[color].main; + +/** Lifecycle of the API definition itself — Published, Created, Deprecated… */ +export function LifecycleChip({ status }: { status?: LifeCycleStatus }) { + const { color, label } = lifecycleMeta(status); + + return ( + + ); +} + +/** + * The API's version, beside its name. Monospace so digits line up down a column + * of cards; the chip's size, radius and border are the theme's. + */ +export function VersionChip({ version }: { version?: string }) { + if (!version) return null; + + return ( + + ); +} + +/** + * Icons per transport. `https` earns the padlock because the distinction the + * user cares about is whether the hop is encrypted; anything the gateway + * reports that we have no icon for falls back to the neutral globe rather than + * rendering an iconless odd-one-out. + */ +const TRANSPORT_ICON: Record = { + http: Globe, + https: Lock, +}; + +/** Protocols the API is exposed over — `HTTP`, `HTTPS`. */ +export function TransportChips({ transports }: { transports: string[] }) { + return ( + <> + {transports.map((transport) => { + const Icon = TRANSPORT_ICON[transport.toLowerCase()] ?? Globe; + return ( + } + key={transport} + label={transport.toUpperCase()} + size="small" + sx={{ ...metaChipSx, fontSize: "0.7rem" }} + variant="filled" + /> + ); + })} + + ); +} + +/** + * Runtime state across the API's gateways — a dot plus a word, deliberately + * quieter than the lifecycle chip since it sits in the card footer. + */ +export function DeploymentStateLabel({ state }: { state: ApiDeploymentState }) { + const theme = useTheme(); + const { color, label } = deploymentMeta(state); + const main = + color === 'default' ? theme.palette.text.disabled : tone(theme, color); + + return ( + + + {label} + + ); +} + +/** + * Gateways the API is live on, labelled by gateway handle. Renders nothing when + * it is deployed nowhere — an empty rail reads as a loading glitch, and the + * neighbouring state label already says "Not deployed". + */ +export function GatewayChips({ gatewayIds }: { gatewayIds: string[] }) { + if (gatewayIds.length === 0) return null; + + return ( + <> + {gatewayIds.map((gatewayId) => ( + + ))} + + ); +} diff --git a/portals/api-control-plane/src/features/apis/develop/AttachedPolicyList.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/AttachedPolicyList.tsx similarity index 98% rename from portals/api-control-plane/src/features/apis/develop/AttachedPolicyList.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/AttachedPolicyList.tsx index 2b0b401efb..80cb37dac0 100644 --- a/portals/api-control-plane/src/features/apis/develop/AttachedPolicyList.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/AttachedPolicyList.tsx @@ -28,7 +28,7 @@ import { import { GripVertical, Pencil, Plus, Shield, Trash2 } from '@wso2/oxygen-ui-icons-react'; import { useState } from 'react'; -import type { ApiPolicy } from '../../../types/domain'; +import type { ApiPolicy } from '../../../../../types/domain'; /** * Renders a flat, ordered list of attached policies (the Hybrid-style policy diff --git a/portals/api-control-plane/src/features/apis/develop/AvailablePoliciesPanel.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/AvailablePoliciesPanel.tsx similarity index 96% rename from portals/api-control-plane/src/features/apis/develop/AvailablePoliciesPanel.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/AvailablePoliciesPanel.tsx index dff8f23e28..648c4bafc8 100644 --- a/portals/api-control-plane/src/features/apis/develop/AvailablePoliciesPanel.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/AvailablePoliciesPanel.tsx @@ -30,13 +30,13 @@ import { import { ExternalLink, GripVertical, Search, Shield } from '@wso2/oxygen-ui-icons-react'; import { useMemo, useState } from 'react'; -import type { PolicySummary } from '../../../api/policyHub/policyHubClient'; +import type { PolicySummary } from '../../../../../api/policyHub/policyHubClient'; import { usePolicyHubCategories, usePolicyHubPolicies, -} from '../../../api/policyHub/usePolicyHub'; -import { EmptyState, ErrorState } from '../../../components/StateViews'; -import { runtimeConfig } from '../../../config/runtime'; +} from '../../../../../api/policyHub/usePolicyHub'; +import { EmptyState, ErrorState } from '../../../../../components/StateViews'; +import { runtimeConfig } from '../../../../../config/runtime'; import { POLICY_DND_MIME, setDraggedPolicy } from './policyDnd'; const PAGE_SIZE = 20; diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DevelopPageShell.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DevelopPageShell.tsx new file mode 100644 index 0000000000..cf128afdad --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DevelopPageShell.tsx @@ -0,0 +1,76 @@ +/* + * 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 { PageTitle } from '@wso2/oxygen-ui'; +import { FormattedMessage, type MessageDescriptor } from 'react-intl'; + +import { useApiDetail } from '../../../../../api/hooks/useMvpQueries'; +import { ErrorState, LoadingState } from '../../../../../components/StateViews'; +import type { ApiDetail } from '../../../../../types/domain'; + +export type DevelopPageShellProps = { + /** Section name, e.g. Policies. */ + title: MessageDescriptor; + /** Sub-header taking the API's `displayName` as `{apiName}`. */ + subtitle: MessageDescriptor; + children: (detail: ApiDetail) => ReactNode; +}; + +/** + * Heading plus loaded API detail for a Develop page. + * + * The three panels were tabs on the API overview page, where one `useApiDetail()` + * served all of them and the header card said which API you were looking at. Now + * that each is its own route they each need both, so this holds the pair in one + * place rather than repeating the query and its loading/error branches three + * times. `children` takes the loaded detail, so a panel needing it can't be + * rendered before it exists. + */ +export function DevelopPageShell({ + children, + subtitle, + title, +}: DevelopPageShellProps) { + const detailQuery = useApiDetail(); + + if (detailQuery.isLoading) return ; + if (detailQuery.error || !detailQuery.data) { + return ; + } + + const detail = detailQuery.data; + + return ( + <> + + + + + + + + + + {children(detail)} + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DocumentsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DocumentsPage.tsx new file mode 100644 index 0000000000..c86fdcef94 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DocumentsPage.tsx @@ -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 { defineMessages } from 'react-intl'; + +import { routes } from '../../../../../routes/paths'; +import { ScopeGate } from '../../../../../scope/ScopeGate'; +import { DevelopPageShell } from './DevelopPageShell'; +import { DocumentsTab } from './DocumentsTab'; + +const messages = defineMessages({ + title: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.develop.DocumentsPage.title', + defaultMessage: 'Documents', + }, + subtitle: { + id: '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.', + }, +}); + +export function DocumentsPage() { + return ( + + {/* `DocumentsTab` takes no detail of its own; the shell is here for the + heading, which still names the API being documented. */} + + {() => } + + + ); +} diff --git a/portals/api-control-plane/src/features/apis/develop/DocumentsTab.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DocumentsTab.tsx similarity index 92% rename from portals/api-control-plane/src/features/apis/develop/DocumentsTab.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DocumentsTab.tsx index d799365747..3b7650bd85 100644 --- a/portals/api-control-plane/src/features/apis/develop/DocumentsTab.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/DocumentsTab.tsx @@ -16,7 +16,7 @@ * under the License. */ -import { EmptyState } from '../../../components/StateViews'; +import { EmptyState } from '../../../../../components/StateViews'; export function DocumentsTab() { return ( diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PoliciesPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PoliciesPage.tsx new file mode 100644 index 0000000000..78e1015341 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PoliciesPage.tsx @@ -0,0 +1,51 @@ +/* + * 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 { defineMessages } from 'react-intl'; + +import { routes } from '../../../../../routes/paths'; +import { ScopeGate } from '../../../../../scope/ScopeGate'; +import { DevelopPageShell } from './DevelopPageShell'; +import { PolicyTab } from './PolicyTab'; + +const messages = defineMessages({ + title: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.develop.PoliciesPage.title', + defaultMessage: 'Policies', + }, + subtitle: { + id: '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.', + }, +}); + +export function PoliciesPage() { + return ( + + + {(detail) => } + + + ); +} diff --git a/portals/api-control-plane/src/features/apis/develop/PolicyConfigDrawer.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PolicyConfigDrawer.tsx similarity index 94% rename from portals/api-control-plane/src/features/apis/develop/PolicyConfigDrawer.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PolicyConfigDrawer.tsx index 9a04657fb6..76453d4674 100644 --- a/portals/api-control-plane/src/features/apis/develop/PolicyConfigDrawer.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PolicyConfigDrawer.tsx @@ -28,7 +28,7 @@ import { import { ChevronLeft, X } from '@wso2/oxygen-ui-icons-react'; import { useMemo, useState } from 'react'; -import type { PolicySummary } from '../../../api/policyHub/policyHubClient'; +import type { PolicySummary } from '../../../../../api/policyHub/policyHubClient'; import { getByPath, initValues, @@ -36,10 +36,10 @@ import { type ParameterValues, setByPath, topLevelRequiredMissing, -} from '../../../api/policyHub/policySchema'; -import { usePolicyDefinition } from '../../../api/policyHub/usePolicyHub'; -import { ErrorState } from '../../../components/StateViews'; -import type { ApiPolicy } from '../../../types/domain'; +} from '../../../../../api/policyHub/policySchema'; +import { usePolicyDefinition } from '../../../../../api/policyHub/usePolicyHub'; +import { ErrorState } from '../../../../../components/StateViews'; +import type { ApiPolicy } from '../../../../../types/domain'; import { defaultForSchema, SchemaField } from './SchemaField'; /** Minimal reference needed to load a policy's definition. */ diff --git a/portals/api-control-plane/src/features/apis/develop/PolicyTab.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PolicyTab.tsx similarity index 96% rename from portals/api-control-plane/src/features/apis/develop/PolicyTab.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PolicyTab.tsx index edf12bff3c..2403923aef 100644 --- a/portals/api-control-plane/src/features/apis/develop/PolicyTab.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/PolicyTab.tsx @@ -31,18 +31,18 @@ import { import { ChevronDown, Globe } from '@wso2/oxygen-ui-icons-react'; import { useState } from 'react'; -import { useUpdateApi } from '../../../api/hooks/useMvpQueries'; -import type { PolicySummary } from '../../../api/policyHub/policyHubClient'; -import { usePolicyHub } from '../../../api/policyHub/usePolicyHub'; -import { useNotifications } from '../../../components/Notifications'; +import { useUpdateApi } from '../../../../../api/hooks/useMvpQueries'; +import type { PolicySummary } from '../../../../../api/policyHub/policyHubClient'; +import { usePolicyHub } from '../../../../../api/policyHub/usePolicyHub'; +import { useNotifications } from '../../../../../components/Notifications'; import type { ApiOperation, ApiPolicy, ApiDetail, -} from '../../../types/domain'; +} from '../../../../../types/domain'; import { AttachedPolicyList } from './AttachedPolicyList'; import { AvailablePoliciesPanel } from './AvailablePoliciesPanel'; -import { SaveBar } from './SaveBar'; +import { SaveBar } from './components/SaveBar'; import { methodColor, reorderPolicies } from './developEdit'; import { getDraggedPolicy, diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingPage.tsx new file mode 100644 index 0000000000..0a509d18e2 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingPage.tsx @@ -0,0 +1,51 @@ +/* + * 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 { defineMessages } from 'react-intl'; + +import { routes } from '../../../../../routes/paths'; +import { ScopeGate } from '../../../../../scope/ScopeGate'; +import { DevelopPageShell } from './DevelopPageShell'; +import { RoutingTab } from './RoutingTab'; + +const messages = defineMessages({ + title: { + id: 'apiControlPlane.pages.appShell.appShellPages.apis.develop.RoutingPage.title', + defaultMessage: 'Routing', + }, + subtitle: { + id: '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.', + }, +}); + +export function RoutingPage() { + return ( + + + {(detail) => } + + + ); +} diff --git a/portals/api-control-plane/src/features/apis/develop/RoutingTab.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingTab.test.tsx similarity index 94% rename from portals/api-control-plane/src/features/apis/develop/RoutingTab.test.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingTab.test.tsx index 3943d1bb23..634a2a40bf 100644 --- a/portals/api-control-plane/src/features/apis/develop/RoutingTab.test.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingTab.test.tsx @@ -19,9 +19,9 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; -import { server } from '../../../test/server'; -import { renderWithProviders, screen } from '../../../test/utils'; -import type { ApiDetail } from '../../../types/domain'; +import { server } from '../../../../../test/server'; +import { renderWithProviders, screen } from '../../../../../test/utils'; +import type { ApiDetail } from '../../../../../types/domain'; import { RoutingTab } from './RoutingTab'; const detail: ApiDetail = { diff --git a/portals/api-control-plane/src/features/apis/develop/RoutingTab.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingTab.tsx similarity index 99% rename from portals/api-control-plane/src/features/apis/develop/RoutingTab.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingTab.tsx index 3ea5771def..47dd844d35 100644 --- a/portals/api-control-plane/src/features/apis/develop/RoutingTab.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/RoutingTab.tsx @@ -46,10 +46,10 @@ import { X, } from '@wso2/oxygen-ui-icons-react'; -import { useUpdateApi } from '../../../api/hooks/useMvpQueries'; -import { ConfirmDialog } from '../../../components/ConfirmDialog'; -import { useNotifications } from '../../../components/Notifications'; -import type { ApiOperation, ApiDetail } from '../../../types/domain'; +import { useUpdateApi } from '../../../../../api/hooks/useMvpQueries'; +import { ConfirmDialog } from '../../../../../components/ConfirmDialog'; +import { useNotifications } from '../../../../../components/Notifications'; +import type { ApiOperation, ApiDetail } from '../../../../../types/domain'; import { type BackendResource, discoverBackendResources, @@ -66,7 +66,7 @@ import { updateOperation, withRoutingEdits, } from './developEdit'; -import { SaveBar } from './SaveBar'; +import { SaveBar } from './components/SaveBar'; // --- canvas geometry (fixed coords → SVG links need no DOM measurement) --- const OP_X = 4; diff --git a/portals/api-control-plane/src/features/apis/develop/SchemaField.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/SchemaField.tsx similarity index 99% rename from portals/api-control-plane/src/features/apis/develop/SchemaField.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/SchemaField.tsx index 3e7ac2881d..ad0157b986 100644 --- a/portals/api-control-plane/src/features/apis/develop/SchemaField.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/SchemaField.tsx @@ -36,7 +36,7 @@ import { defaultForSchema, getByPath, type ParameterSchema, -} from '../../../api/policyHub/policySchema'; +} from '../../../../../api/policyHub/policySchema'; type FieldProps = { schema: ParameterSchema; diff --git a/portals/api-control-plane/src/features/apis/develop/backendDiscovery.test.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/backendDiscovery.test.ts similarity index 98% rename from portals/api-control-plane/src/features/apis/develop/backendDiscovery.test.ts rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/backendDiscovery.test.ts index c5043b05ad..05338df217 100644 --- a/portals/api-control-plane/src/features/apis/develop/backendDiscovery.test.ts +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/backendDiscovery.test.ts @@ -19,7 +19,7 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; -import { server } from '../../../test/server'; +import { server } from '../../../../../test/server'; import { contractCandidates, discoverBackendResources, diff --git a/portals/api-control-plane/src/features/apis/develop/backendDiscovery.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/backendDiscovery.ts similarity index 100% rename from portals/api-control-plane/src/features/apis/develop/backendDiscovery.ts rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/backendDiscovery.ts diff --git a/portals/api-control-plane/src/features/apis/develop/SaveBar.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/components/SaveBar.tsx similarity index 51% rename from portals/api-control-plane/src/features/apis/develop/SaveBar.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/components/SaveBar.tsx index 133f735cd5..fe689b9915 100644 --- a/portals/api-control-plane/src/features/apis/develop/SaveBar.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/apis/develop/components/SaveBar.tsx @@ -17,30 +17,17 @@ */ import { Box, Button } from '@wso2/oxygen-ui'; -import { useEffect, useState } from 'react'; -import { APP_FOOTER_ID } from '../../../layouts/appLayoutConstants'; +import { useFooterHeight } from '../../../../../../hooks/useFooterHeight'; +import { stickyBottomBarSx } from '../../../../../../theme'; -/** - * Upward elevation shadow for the bottom action bar. Kept as a named token - * (theme.shadows are all downward) rather than an inline magic value. - */ -const SAVE_BAR_SHADOW = '0 -2px 10px rgba(0, 0, 0, 0.16)'; - -/** Static styles for the sticky save bar, kept out of JSX (theme-token based). */ -const saveBarBaseSx = { - // alignItems: 'center', - // Solid surface (palette token) so scrolling content never shows through. - borderColor: 'divider', - borderTop: '1px solid', - boxShadow: SAVE_BAR_SHADOW, +/** Layout for the save bar's own content; the sticky treatment is shared. */ +const saveBarLayoutSx = { display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 1, - position: 'sticky', py: 1.5, - backdropFilter: 'blur(10px)', } as const; type SaveBarProps = { @@ -53,25 +40,7 @@ type SaveBarProps = { }; /** - * Measures the app footer's height (it sits at the bottom of the same scroll - * area this bar sticks to). Returns 0 when the footer is absent (e.g. in tests). - */ -function useFooterHeight(): number { - const [height, setHeight] = useState(0); - useEffect(() => { - const el = document.getElementById(APP_FOOTER_ID); - if (!el) return; - const update = () => setHeight(el.offsetHeight); - update(); - const observer = new ResizeObserver(update); - observer.observe(el); - return () => observer.disconnect(); - }, []); - return height; -} - -/** - * Solid save action bar pinned to the bottom of a develop tab's scroll area + * Save action bar pinned to the bottom of a develop tab's scroll area * (`position: sticky`), offset above the app footer so it is never covered. */ export function SaveBar({ disabled, saving, onSave, label = 'Save changes' }: SaveBarProps) { @@ -79,9 +48,9 @@ export function SaveBar({ disabled, saving, onSave, label = 'Save changes' }: Sa return ( ({ zIndex: theme.zIndex.appBar }), ]} > @@ -406,9 +417,15 @@ export function GatewaysPage() { size="small" value={filter} > - All - Managed - Self-hosted + + + + + + + + + @@ -470,6 +487,6 @@ export function GatewaysPage() { )} )} - + ); } diff --git a/portals/api-control-plane/src/features/gateways/CopyableCommand.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/gateways/components/CopyableCommand.tsx similarity index 100% rename from portals/api-control-plane/src/features/gateways/CopyableCommand.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/gateways/components/CopyableCommand.tsx diff --git a/portals/api-control-plane/src/features/gateways/gatewayEnvironments.test.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewayEnvironments.test.ts similarity index 96% rename from portals/api-control-plane/src/features/gateways/gatewayEnvironments.test.ts rename to portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewayEnvironments.test.ts index 1222d6a42b..262259f839 100644 --- a/portals/api-control-plane/src/features/gateways/gatewayEnvironments.test.ts +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewayEnvironments.test.ts @@ -18,7 +18,7 @@ import { describe, expect, it } from 'vitest'; -import type { Gateway } from '../../types/domain'; +import type { Gateway } from '../../../../types/domain'; import { environmentForGateway, groupGatewaysByEnvironment, diff --git a/portals/api-control-plane/src/features/gateways/gatewayEnvironments.ts b/portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewayEnvironments.ts similarity index 97% rename from portals/api-control-plane/src/features/gateways/gatewayEnvironments.ts rename to portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewayEnvironments.ts index f378427e5b..42d044028d 100644 --- a/portals/api-control-plane/src/features/gateways/gatewayEnvironments.ts +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewayEnvironments.ts @@ -16,7 +16,7 @@ * under the License. */ -import type { Gateway } from '../../types/domain'; +import type { Gateway } from '../../../../types/domain'; /** * Client-side mock environments. Until the dedicated environment service exists, diff --git a/portals/api-control-plane/src/features/gateways/gatewaysUi.css b/portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewaysUi.css similarity index 100% rename from portals/api-control-plane/src/features/gateways/gatewaysUi.css rename to portals/api-control-plane/src/pages/appShell/appShellPages/gateways/gatewaysUi.css diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/insights/CompliancePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/insights/CompliancePage.tsx new file mode 100644 index 0000000000..d6f8063337 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/insights/CompliancePage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function CompliancePage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx new file mode 100644 index 0000000000..a8f4535585 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function InsightsPage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/manage/LifeCyclePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/manage/LifeCyclePage.tsx new file mode 100644 index 0000000000..0cfcab66e1 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/manage/LifeCyclePage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function LifeCyclePage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/manage/MonetizePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/manage/MonetizePage.tsx new file mode 100644 index 0000000000..b56cccc9ee --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/manage/MonetizePage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function MonetizePage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/observability/AlertsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/observability/AlertsPage.tsx new file mode 100644 index 0000000000..17fa922639 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/observability/AlertsPage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function AlertsPage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/observability/MetricsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/observability/MetricsPage.tsx new file mode 100644 index 0000000000..f0302a480d --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/observability/MetricsPage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function MetricsPage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/features/logs/RuntimeLogsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/observability/RuntimeLogsPage.tsx similarity index 50% rename from portals/api-control-plane/src/features/logs/RuntimeLogsPage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/observability/RuntimeLogsPage.tsx index f14b702594..37a3227ed1 100644 --- a/portals/api-control-plane/src/features/logs/RuntimeLogsPage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/observability/RuntimeLogsPage.tsx @@ -16,27 +16,54 @@ * under the License. */ -import { Card, CardContent, CodeBlock, PageContent, PageTitle } from '@wso2/oxygen-ui'; +import { Card, CardContent, CodeBlock, PageTitle } from '@wso2/oxygen-ui'; import { useParams } from 'react-router-dom'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; +import { FormattedMessage } from 'react-intl'; + export function RuntimeLogsPage() { - const { projectHandler } = useParams(); + return ( + + + + ); +} + +function RuntimeLogs() { + const { apiHandler } = useParams(); return ( - + <> - Runtime logs - Project-level logs entry for {projectHandler}. + + + + + + - + ); } diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/organizations/ExploreMoreCard.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/organizations/ExploreMoreCard.tsx new file mode 100644 index 0000000000..3a5b8e55bc --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/organizations/ExploreMoreCard.tsx @@ -0,0 +1,150 @@ +/* + * 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 from 'react'; +import { + Box, + Card, + CardContent, + Grid, + Stack, + Typography, +} from '@wso2/oxygen-ui'; +import { + ArrowRight, + BookOpen, + Network, + Server, +} from '@wso2/oxygen-ui-icons-react'; +import { FormattedMessage } from 'react-intl'; + +export default function ExploreMoreCard() { + const sections = [ + { + title: 'Quick Start ', + subtitle: 'Start with AI Workspace basics and set up your first AI Gateway.', + icon: BookOpen, + links: [ + { + label: 'AI Workspace Getting Started Guide', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/getting-started/', + }, + { + label: 'Set Up and Configure an AI Gateway', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/ai-gateways/setting-up/', + }, + ], + }, + { + title: 'LLM Provider Integration', + subtitle: + 'Connect, configure, and maintain model providers for your workspace.', + icon: Server, + links: [ + { + label: 'LLM Providers Overview', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/llm-providers/overview/', + }, + { + label: 'Configure a New LLM Provider', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/llm-providers/configure-provider/', + }, + { + label: 'Manage Existing LLM Providers', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/llm-providers/manage-provider/', + }, + ], + }, + { + title: 'App LLM Proxy Management', + subtitle: 'Create and operate App LLM Proxies for secure and governed access.', + icon: Network, + links: [ + { + label: 'App LLM Proxies Overview', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/llm-proxies/overview/', + }, + { + label: 'Create a New App LLM Proxy', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/llm-proxies/configure-proxy/', + }, + { + label: 'Manage Existing App LLM Proxies', + href: 'https://wso2.com/api-platform/docs/next/ai-workspace/llm-proxies/manage-proxy/', + }, + ], + }, + ]; + + return ( + + + + + + + + {sections.map((section) => { + const Icon = section.icon; + return ( + + + + + + + {section.title} + + + + + {section.links.map((link) => ( + + + {link.label} + + ))} + + + + ); + })} + + + + + ); +} diff --git a/portals/api-control-plane/src/features/organizations/OrganizationHomePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/organizations/OrganizationHomePage.tsx similarity index 61% rename from portals/api-control-plane/src/features/organizations/OrganizationHomePage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/organizations/OrganizationHomePage.tsx index 94809bf121..d12bdab678 100644 --- a/portals/api-control-plane/src/features/organizations/OrganizationHomePage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/organizations/OrganizationHomePage.tsx @@ -17,44 +17,24 @@ */ import { - Box, - Card, - CardContent, Grid, - PageContent, - Stack, - Typography, } from '@wso2/oxygen-ui'; import { Boxes, Layers } from '@wso2/oxygen-ui-icons-react'; import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useOrganization } from '../../api/hooks/useMvpQueries'; -import { QuickStartBanner } from '../../components/cards/QuickStartBanner'; +import {useOrganization} from '../../../../api/resources/organizations'; +import { QuickStartBanner } from '../../../../components/cards/QuickStartBanner'; import { SummaryCardSection, type SummaryRow, -} from '../../components/cards/SummaryCardSection'; -import { ErrorState, LoadingState } from '../../components/StateViews'; -import { routes } from '../../routes/paths'; -import { useConsoleScope } from '../../scope/ConsoleScopeProvider'; -import { relativeTime } from '../../utils/relativeTime'; +} from '../../../../components/cards/SummaryCardSection'; +import { ErrorState, LoadingState } from '../../../../components/StateViews'; +import { routes } from '../../../../routes/paths'; +import { useConsoleScope } from '../../../../scope/ConsoleScopeProvider'; +import { relativeTime } from '../../../../utils/relativeTime'; import { NewProjectDialog } from '../projects/NewProjectDialog'; - -const GETTING_STARTED = [ - { - title: '1. Open a project', - description: 'Projects group the APIs you build and operate.', - }, - { - title: '2. Create APIs', - description: 'Add APIs inside a project.', - }, - { - title: '3. Deploy, test & observe', - description: 'Use deploy, test, manage, and runtime logs per API.', - }, -]; +import ExploreMoreCard from './ExploreMoreCard'; export function OrganizationHomePage() { const navigate = useNavigate(); @@ -62,7 +42,7 @@ export function OrganizationHomePage() { useConsoleScope(); const orgHandle = params.orgHandle || ''; const [createOpen, setCreateOpen] = useState(false); - const organizationQuery = useOrganization(); + const organizationQuery = useOrganization(orgHandle); const currentOrganization = organizationQuery.data || organization || organizations[0]; @@ -75,11 +55,9 @@ export function OrganizationHomePage() { new Date(left.updatedAt || 0).getTime() ) .map((project) => ({ - id: project.handler, - title: project.name, - description: project.region - ? `@${project.handler} · ${project.region}` - : `@${project.handler}`, + id: project.id, + title: project.displayName || project.id, + description: project.description, meta: project.updatedAt ? relativeTime(project.updatedAt) : undefined, })), [projects] @@ -95,23 +73,21 @@ export function OrganizationHomePage() { } return ( - + <> } onAction={() => navigate(routes.projects(orgHandle))} - title={`Welcome to ${currentOrganization.name}`} + title={`Welcome to ${currentOrganization.displayName}`} /> - - - - - - - - - Getting started - - - {GETTING_STARTED.map((step) => ( - - {step.title} - - {step.description} - - - ))} - - - + + + + setCreateOpen(false)} open={createOpen} orgHandle={orgHandle} /> - + ); } diff --git a/portals/api-control-plane/src/features/projects/NewProjectDialog.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/NewProjectDialog.test.tsx similarity index 54% rename from portals/api-control-plane/src/features/projects/NewProjectDialog.test.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/projects/NewProjectDialog.test.tsx index f9cf6bfb90..ebe2489797 100644 --- a/portals/api-control-plane/src/features/projects/NewProjectDialog.test.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/NewProjectDialog.test.tsx @@ -16,30 +16,43 @@ * under the License. */ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderWithProviders, screen, waitFor } from '../../test/utils'; -import type { Project } from '../../types/domain'; +import { ApiScopeProvider } from '../../../../api/core/ApiScopeProvider'; +import { resetHttpClient } from '../../../../api/core/http'; +import { + accepts, + aProject, + failure, + recorder, + type Recorder, +} from '../../../../test/msw'; +import { server } from '../../../../test/server'; +import { renderWithProviders, screen, waitFor } from '../../../../test/utils'; import { NewProjectDialog } from './NewProjectDialog'; const ORG = 'api-platform-demo'; -const created: Project = { - id: 'proj-new', - orgId: 'org-1', - name: 'Billing', - handler: 'proj-new', -}; +let requests: Recorder; -function setup( - createProject = vi.fn().mockResolvedValue(created) -) { +beforeEach(() => { + requests = recorder(); + resetHttpClient(); +}); + +/** + * The dialog takes its organization from `ApiScopeContext` rather than its + * `orgHandle` prop (which only builds the post-create redirect), so the scope + * provider has to be mounted for the create to be allowed out at all. + */ +function setup() { const onClose = vi.fn(); const utils = renderWithProviders( - , - { apiClient: { createProject } } + + + ); - return { ...utils, createProject, onClose }; + return { ...utils, onClose }; } describe('NewProjectDialog', () => { @@ -49,26 +62,32 @@ describe('NewProjectDialog', () => { }); it('creates a project with name + description, then closes', async () => { - const { user, createProject, onClose } = setup(); + server.use( + accepts('post', '/projects', aProject({ id: 'billing' }), { + record: requests, + }) + ); + const { user, onClose } = setup(); await user.type(screen.getByLabelText(/Name/), 'Billing'); await user.type(screen.getByLabelText(/Description/), 'Invoices'); await user.click(screen.getByRole('button', { name: 'Create' })); - await waitFor(() => - expect(createProject).toHaveBeenCalledWith(ORG, { - name: 'Billing', - description: 'Invoices', - }) - ); + await waitFor(() => expect(requests.count()).toBe(1)); + expect(JSON.parse(requests.last()!.body)).toEqual({ + displayName: 'Billing', + description: 'Invoices', + }); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); it('keeps the dialog open and surfaces the error message on failure', async () => { - const failing = vi - .fn() - .mockRejectedValue(new Error('Project already exists in organization')); - const { user, onClose } = setup(failing); + server.use( + failure('post', '/projects', 409, 'PROJECT_ALREADY_EXISTS', { + message: 'Project already exists in organization', + }) + ); + const { user, onClose } = setup(); await user.type(screen.getByLabelText(/Name/), 'Retail APIs'); await user.click(screen.getByRole('button', { name: 'Create' })); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/projects/NewProjectDialog.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/NewProjectDialog.tsx new file mode 100644 index 0000000000..b391703df8 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/NewProjectDialog.tsx @@ -0,0 +1,167 @@ +/* + * 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, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Form, + FormControl, + FormHelperText, + FormLabel, + OutlinedInput, +} from '@wso2/oxygen-ui'; +import { useEffect, useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useCreateProject } from '../../../../api/resources/projects'; +import { useNotifications } from '../../../../components/Notifications'; +import { routes } from '../../../../routes/paths'; + +const NAME_MAX = 120; + +/** + * Field ids, shared by each control and the label/helper text that describe it. + * Named constants rather than inline strings because a typo between `htmlFor` + * and `id` silently breaks the label-to-input association — which looks fine and + * is invisible to everything except a screen reader. + */ +const NAME_FIELD = 'new-project-name'; +const DESCRIPTION_FIELD = 'new-project-description'; + +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. + * + * Each field is a `FormControl` owning its own `FormLabel`, input and + * `FormHelperText`: `required` and `error` are declared once on the control and + * reach all three through context, instead of being repeated on each part where + * they could disagree. + */ +export function NewProjectDialog({ + open, + orgHandle, + onClose, +}: NewProjectDialogProps) { + const navigate = useNavigate(); + const { notify } = useNotifications(); + // Scope comes from the route via `ApiScopeProvider`; `orgHandle` is only + // needed to build the redirect once the project exists. + const mutation = useCreateProject(); + 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 isTooLong = trimmedName.length > NAME_MAX; + const canSubmit = trimmedName.length > 0 && !isTooLong && !mutation.isPending; + + const handleSubmit = async (event: FormEvent) => { + // A real `form` element, so Enter in the name field submits the way a form + // is expected to — no key handler second-guessing the browser. + event.preventDefault(); + if (!canSubmit) return; + try { + const project = await mutation.mutateAsync({ + displayName: trimmedName, + description: description.trim() || undefined, + }); + notify('Project created', 'success'); + onClose(); + navigate(routes.projectHome(orgHandle, project.id)); + } catch (error) { + notify( + error instanceof Error ? error.message : 'Failed to create project', + 'error' + ); + } + }; + + return ( + + Create project + + + + + Name + setName(event.target.value)} + placeholder="e.g. Retail APIs" + size="small" + value={name} + /> + + {isTooLong + ? `Name must be ${NAME_MAX} characters or fewer.` + : 'A unique name for the project within this organization.'} + + + + + Description + setDescription(event.target.value)} + placeholder="What this project is for (optional)" + size="small" + value={description} + /> + + + + + + + + + + ); +} diff --git a/portals/api-control-plane/src/features/projects/ProjectHomePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectHomePage.tsx similarity index 79% rename from portals/api-control-plane/src/features/projects/ProjectHomePage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectHomePage.tsx index bf8b7495db..af071c5f5e 100644 --- a/portals/api-control-plane/src/features/projects/ProjectHomePage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectHomePage.tsx @@ -22,7 +22,6 @@ import { CardContent, Chip, Grid, - PageContent, Stack, Typography, } from '@wso2/oxygen-ui'; @@ -30,18 +29,22 @@ import { Boxes, Rocket } from '@wso2/oxygen-ui-icons-react'; import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useApis, useProject } from '../../api/hooks/useMvpQueries'; -import { groupApisByKind } from '../../components/cards/apiDisplay'; -import { QuickStartBanner } from '../../components/cards/QuickStartBanner'; +import { useApis, useProject } from '../../../../api/hooks/useMvpQueries'; +import { groupApisByKind } from '../../../../components/cards/apiDisplay'; +import { QuickStartBanner } from '../../../../components/cards/QuickStartBanner'; import { SummaryCardSection, type SummaryRow, -} from '../../components/cards/SummaryCardSection'; -import { ErrorState, LoadingState } from '../../components/StateViews'; -import { routes } from '../../routes/paths'; -import type { Api } from '../../types/domain'; -import { relativeTime } from '../../utils/relativeTime'; +} from '../../../../components/cards/SummaryCardSection'; +import { ErrorState, LoadingState } from '../../../../components/StateViews'; +import { routes } from '../../../../routes/paths'; +import type { Api } from '../../../../types/domain'; +import { relativeTime } from '../../../../utils/relativeTime'; +import { FormattedMessage } from 'react-intl'; +// No `ScopeGate`: this page is the project tier of the sidebar's Overview item, +// which degrades to the organization tier rather than linking here without a +// project — so it is never reached out of scope. export function ProjectHomePage() { const { orgHandle = '', projectHandler = '' } = useParams(); const navigate = useNavigate(); @@ -78,7 +81,7 @@ export function ProjectHomePage() { const project = projectQuery.data; return ( - + <> - Project details + @@ -147,7 +153,10 @@ export function ProjectHomePage() { !project.repository && !project.createdDate && ( - No additional project metadata available. + )} @@ -159,13 +168,17 @@ export function ProjectHomePage() { sx={{ cursor: 'pointer', display: 'inline-block', fontWeight: 600 }} variant="body2" > - View all APIs ({components.length}) +
- + ); } diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectListPage.test.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectListPage.test.tsx new file mode 100644 index 0000000000..76160eb60a --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectListPage.test.tsx @@ -0,0 +1,207 @@ +/* + * 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 } from 'vitest'; + +import { ApiScopeProvider } from '../../../../api/core/ApiScopeProvider'; +import { resetHttpClient } from '../../../../api/core/http'; +import { + aProject, + collection, + failure, + noContent, + recorder, + type ProjectFixture, + type Recorder, +} from '../../../../test/msw'; +import { server } from '../../../../test/server'; +import { + renderWithProviders, + screen, + waitFor, + within, +} from '../../../../test/utils'; +import { makeConsoleScope } from '../../../../test/mockScope'; +import { ProjectListPage } from './ProjectListPage'; + +const ORG = 'api-platform-demo'; + +const projectFixtures: ProjectFixture[] = [ + aProject({ id: 'retail', displayName: 'Retail APIs' }), + aProject({ id: 'internal-tools', displayName: 'Internal Tools' }), +]; + +/** Enough projects to force a second page at the default size of 12. */ +const manyProjects = Array.from({ length: 14 }, (_, index) => + aProject({ + id: `project-${index + 1}`, + displayName: `Project ${index + 1}`, + }) +); + +let requests: Recorder; + +/** + * The page's hooks read `ApiScopeContext`, not the console scope, so the + * provider has to be mounted here — without it every query stays `enabled: + * false` and the page renders its loading state forever. + */ +function renderPage() { + return renderWithProviders( + + + } + /> + + , + { + route: `/organizations/${ORG}/projects`, + scope: makeConsoleScope(), + } + ); +} + +beforeEach(() => { + requests = recorder(); + resetHttpClient(); + // Cards count the APIs in their project; every card issues one of these. + server.use(collection('/rest-apis', [])); +}); + +describe('ProjectListPage', () => { + it('shows the loading state before the first response lands', () => { + server.use(collection('/projects', projectFixtures)); + renderPage(); + expect(screen.getByText('Loading projects')).toBeInTheDocument(); + }); + + it('shows an error state with the reason', async () => { + server.use( + failure('get', '/projects', 500, 'INTERNAL_SERVER_ERROR', { + message: 'boom', + }) + ); + renderPage(); + expect( + await screen.findByText(/Unable to load projects\./) + ).toBeInTheDocument(); + }); + + it('shows the empty state when the organization has no projects', async () => { + server.use(collection('/projects', [])); + renderPage(); + expect(await screen.findByText('No projects found')).toBeInTheDocument(); + }); + + it('renders the first page and asks the server for the paging window', async () => { + server.use(collection('/projects', projectFixtures, { record: requests })); + renderPage(); + + expect(await screen.findByText('Retail APIs')).toBeInTheDocument(); + expect(screen.getByText('Internal Tools')).toBeInTheDocument(); + expect(requests.last()?.params.get('limit')).toBe('12'); + expect(requests.last()?.params.get('offset')).toBe('0'); + }); + + it('searches server-side rather than filtering the current page', async () => { + server.use(collection('/projects', projectFixtures, { record: requests })); + const { user } = renderPage(); + + await screen.findByText('Retail APIs'); + await user.type(screen.getByPlaceholderText('Search projects'), 'internal'); + + await waitFor(() => + expect(requests.last()?.params.get('query')).toBe('internal') + ); + await waitFor(() => + expect(screen.queryByText('Retail APIs')).not.toBeInTheDocument() + ); + expect(screen.getByText('Internal Tools')).toBeInTheDocument(); + }); + + it('defaults to newest-first and sends the chosen order to the server', async () => { + server.use(collection('/projects', projectFixtures, { record: requests })); + const { user } = renderPage(); + + await screen.findByText('Retail APIs'); + expect(requests.last()?.params.get('sortBy')).toBe('createdAt'); + expect(requests.last()?.params.get('sortOrder')).toBe('desc'); + + await user.click(screen.getByRole('combobox', { name: 'Sort by' })); + await user.click(screen.getByRole('option', { name: 'Name (A–Z)' })); + + await waitFor(() => { + expect(requests.last()?.params.get('sortBy')).toBe('name'); + expect(requests.last()?.params.get('sortOrder')).toBe('asc'); + }); + }); + + it('returns to the first page when the sort order changes', async () => { + server.use(collection('/projects', manyProjects, { record: requests })); + const { user } = renderPage(); + + await screen.findByText('Project 1'); + await user.click(screen.getByRole('button', { name: /next page/i })); + await waitFor(() => expect(requests.last()?.params.get('offset')).toBe('12')); + + await user.click(screen.getByRole('combobox', { name: 'Sort by' })); + await user.click(screen.getByRole('option', { name: 'Oldest first' })); + + await waitFor(() => expect(requests.last()?.params.get('offset')).toBe('0')); + }); + + it('requests the next page when the pagination control advances', async () => { + server.use(collection('/projects', manyProjects, { record: requests })); + const { user } = renderPage(); + + await screen.findByText('Project 1'); + expect(screen.queryByText('Project 13')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /next page/i })); + + await waitFor(() => expect(requests.last()?.params.get('offset')).toBe('12')); + expect(await screen.findByText('Project 13')).toBeInTheDocument(); + }); + + it('deletes a project after type-to-confirm', async () => { + server.use( + collection('/projects', projectFixtures), + noContent('delete', '/projects/:projectId', { record: requests }) + ); + const { user } = renderPage(); + + await screen.findByText('Retail APIs'); + // 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(requests.count()).toBe(1)); + expect(requests.last()?.url.pathname).toMatch(/\/projects\/retail$/); + }); +}); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectListPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectListPage.tsx new file mode 100644 index 0000000000..d3ac42177d --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectListPage.tsx @@ -0,0 +1,469 @@ +/* + * 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, + MenuItem, + PageTitle, + Stack, + TablePagination, + TextField, + Typography, +} from '@wso2/oxygen-ui'; +import { Plus, Search } from '@wso2/oxygen-ui-icons-react'; +import { useEffect, useState } from 'react'; +import { + defineMessages, + FormattedMessage, + useIntl, + type MessageDescriptor, +} from 'react-intl'; +import { useNavigate, useParams } from 'react-router-dom'; + +import type { Project } from '../../../../api/resources/projects'; +import { + useDeleteProject, + useProjects, + type ProjectListFilters, +} from '../../../../api/resources/projects'; +import { ProjectsGrid } from './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 { NewProjectDialog } from './NewProjectDialog'; + +const PAGE_SIZE_OPTIONS = [12, 24, 48]; +const SEARCH_DEBOUNCE_MS = 300; + +/** + * Server-side sort is limited to `name | createdAt` and `asc | desc`. + */ +type SortBy = NonNullable; +type SortOrder = NonNullable; + + +const messages = defineMessages({ + createProject: { + id: 'project.list.createProjectButton', + defaultMessage: 'Create Project', + }, + deleteConfirmInputLabel: { + id: 'project.list.delete.confirmInputLabel', + defaultMessage: 'Type "{name}" to confirm', + description: + 'Label for the type-to-confirm field guarding an irreversible delete.', + }, + deleteConfirm: { + id: 'project.list.delete.confirmLabel', + defaultMessage: 'Delete', + }, + deleteFailed: { + id: 'project.list.delete.failed', + defaultMessage: 'Delete failed', + description: 'Fallback toast when the server gives no reason for a failure.', + }, + deleteMessage: { + id: '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.', + }, + deleteSucceeded: { + id: 'project.list.delete.succeeded', + defaultMessage: 'Deleted "{name}".', + }, + deleteTitle: { + id: 'project.list.delete.title', + defaultMessage: 'Delete project', + }, + emptyAction: { + id: 'project.list.empty.action', + defaultMessage: 'Create project', + }, + emptyDescription: { + id: 'project.list.empty.description', + defaultMessage: 'Create a project to organize and manage your APIs.', + }, + emptyTitle: { + id: 'project.list.empty.title', + defaultMessage: 'No projects found', + }, + errorMessage: { + id: 'project.list.error.message', + defaultMessage: 'Unable to load projects. {reason}', + }, + loading: { + id: 'project.list.loading', + defaultMessage: 'Loading projects', + }, + noMatchesDescription: { + id: 'project.list.noMatches.description', + defaultMessage: 'Try a different project name or handle.', + }, + noMatchesTitle: { + id: 'project.list.noMatches.title', + defaultMessage: 'No matching projects', + }, + projectCount: { + id: 'project.list.count', + defaultMessage: '{count, plural, one {# project} other {# projects}}', + }, + rowsPerPage: { + id: 'project.list.rowsPerPage', + defaultMessage: 'Projects per page', + }, + searchPlaceholder: { + id: 'project.list.searchPlaceholder', + defaultMessage: 'Search projects', + }, + sortLabel: { + id: 'project.list.sortLabel', + defaultMessage: 'Sort by', + description: 'Label for the control choosing the project list order.', + }, + sortNameAscending: { + id: 'project.list.sort.nameAscending', + defaultMessage: 'Name (A–Z)', + description: 'Sort option: alphabetical by project name, ascending.', + }, + sortNameDescending: { + id: 'project.list.sort.nameDescending', + defaultMessage: 'Name (Z–A)', + description: 'Sort option: alphabetical by project name, descending.', + }, + sortNewest: { + id: 'project.list.sort.newest', + defaultMessage: 'Newest first', + description: 'Sort option: by creation date, most recent project first.', + }, + sortOldest: { + id: 'project.list.sort.oldest', + defaultMessage: 'Oldest first', + description: 'Sort option: by creation date, earliest project first.', + }, +}); + +/** + * Sort options with both field and direction. + */ +const SORT_OPTIONS = [ + { + label: messages.sortNewest, + sortBy: 'createdAt', + sortOrder: 'desc', + value: 'createdAt:desc', + }, + { + label: messages.sortOldest, + sortBy: 'createdAt', + sortOrder: 'asc', + value: 'createdAt:asc', + }, + { + label: messages.sortNameAscending, + sortBy: 'name', + sortOrder: 'asc', + value: 'name:asc', + }, + { + label: messages.sortNameDescending, + sortBy: 'name', + sortOrder: 'desc', + value: 'name:desc', + }, +] as const satisfies readonly { + label: MessageDescriptor; + sortBy: SortBy; + sortOrder: SortOrder; + value: string; +}[]; + +type SortOption = (typeof SORT_OPTIONS)[number]; + +/** + * Delays a fast-changing value so it can drive a request. Typing updates the + * field on every keystroke; the query only follows once the user pauses. + */ +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} + +export function ProjectListPage() { + const { orgHandle = '' } = useParams(); + const navigate = useNavigate(); + const intl = useIntl(); + const { organization } = useConsoleScope(); + const { notify } = useNotifications(); + + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[0]); + const [sort, setSort] = useState(SORT_OPTIONS[0]); + const [createOpen, setCreateOpen] = useState(false); + const [toDelete, setToDelete] = useState(null); + + const debouncedSearch = useDebouncedValue(search.trim(), SEARCH_DEBOUNCE_MS); + + // Page 1 is the only page that still means anything once the filter or the + // order changes — the row that was at offset 24 is somewhere else now. + useEffect(() => setPage(0), [debouncedSearch, sort.value]); + + const projectsQuery = useProjects({ + limit: rowsPerPage, + offset: page * rowsPerPage, + query: debouncedSearch || undefined, + sortBy: sort.sortBy, + sortOrder: sort.sortOrder, + }); + const deleteProjectMutation = useDeleteProject(); + + const projects = projectsQuery.data?.list ?? []; + const total = projectsQuery.data?.pagination?.total ?? projects.length; + const lastPage = Math.max(0, Math.ceil(total / rowsPerPage) - 1); + // Deleting the last row of the last page leaves `page` past the end. Render + // the clamped value (an out-of-range `page` makes TablePagination complain), + // and correct the state so the next request asks for a window that exists. + const currentPage = Math.min(page, lastPage); + const isSearching = debouncedSearch.length > 0; + + useEffect(() => { + if (page > lastPage) setPage(lastPage); + }, [page, lastPage]); + + const confirmDelete = () => { + if (!toDelete) return; + const { displayName } = toDelete; + deleteProjectMutation.mutate( + { projectId: toDelete.id }, + { + onSuccess: () => { + notify( + intl.formatMessage(messages.deleteSucceeded, { name: displayName }), + 'success' + ); + setToDelete(null); + }, + onError: (error) => + notify( + error.message || intl.formatMessage(messages.deleteFailed), + 'error' + ), + } + ); + }; + + const openProject = (project: Project) => + navigate(routes.projectHome(orgHandle, project.id)); + + if (projectsQuery.isLoading) { + return ; + } + if (projectsQuery.error) { + return ( + + ); + } + + return ( + <> + + + + + + {organization?.displayName + ? ( + + ) + : ( + + )} + + + + + + + {total === 0 && !isSearching ? ( + setCreateOpen(true)} + title={intl.formatMessage(messages.emptyTitle)} + description={intl.formatMessage(messages.emptyDescription)} + /> + ) : ( + + {/* Full-bleed search: the field owns its own row across the page. */} + setSearch(event.target.value)} + placeholder={intl.formatMessage(messages.searchPlaceholder)} + size="small" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + value={search} + /> + + + + + { + const next = SORT_OPTIONS.find( + (option) => option.value === event.target.value + ); + if (next) setSort(next); + }} + select + size="small" + sx={{ minWidth: 200 }} + value={sort.value} + > + {SORT_OPTIONS.map((option) => ( + + {intl.formatMessage(option.label)} + + ))} + + + {projects.length === 0 ? ( + + ) : ( + <> + {/* Takes the column's leftover height, which is what keeps the + pagination bar at the bottom on a half-empty page. Dimmed + rather than unmounted while the next page is in flight, so the + grid keeps its height and the page does not jump. */} + + + + {total > PAGE_SIZE_OPTIONS[0] && ( + + setPage(nextPage)} + onRowsPerPageChange={(event) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }} + page={currentPage} + rowsPerPage={rowsPerPage} + rowsPerPageOptions={PAGE_SIZE_OPTIONS} + /> + + )} + + )} + + )} + + setCreateOpen(false)} + open={createOpen} + orgHandle={orgHandle} + /> + + setToDelete(null)} + onConfirm={confirmDelete} + open={toDelete !== null} + title={intl.formatMessage(messages.deleteTitle)} + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectsGrid.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectsGrid.tsx new file mode 100644 index 0000000000..1837508beb --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/projects/ProjectsGrid.tsx @@ -0,0 +1,67 @@ +/* + * 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 } from '@wso2/oxygen-ui'; + +import type { Project } from '../../../../api/resources/projects'; +import { ProjectCard } from '../../../../components/cards/ProjectCard'; + +type ProjectsGridProps = { + projects: Project[]; + orgHandle: string; + onOpen: (project: Project) => void; + onDelete?: (project: Project) => void; +}; + +/** + * Renders exactly the projects it is given. Paging lives on the page, which + * owns the request — slicing here as well would page an already-paged response. + */ +export function ProjectsGrid({ + projects, + orgHandle, + onOpen, + onDelete, +}: ProjectsGridProps) { + return ( + *': { minWidth: 0 }, + }} + > + {projects.map((project) => ( + + ))} + + ); +} diff --git a/portals/api-control-plane/src/features/settings/GeneralSettingsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/settings/GeneralSettingsPage.tsx similarity index 60% rename from portals/api-control-plane/src/features/settings/GeneralSettingsPage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/settings/GeneralSettingsPage.tsx index 6e122d1c56..ab521d5a9c 100644 --- a/portals/api-control-plane/src/features/settings/GeneralSettingsPage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/settings/GeneralSettingsPage.tsx @@ -17,28 +17,30 @@ */ import { Card, CardContent, Typography } from '@wso2/oxygen-ui'; +import { FormattedMessage, defineMessages } from 'react-intl'; -import { useConsoleScope } from '../../scope/ConsoleScopeProvider'; +const messages = defineMessages({ + mvpScope: { + id: '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.', + }, +}); /** * Shared "General" tab content for both the organization- and project-level - * Settings pages — the scope name in the copy is the only thing that - * differs, so one component covers both rather than two near-duplicates. + * Settings pages — the scope name in the copy is the only thing that differs, + * and the surrounding `SettingsLayout` already names it, so one component + * covers both rather than two near-duplicates. */ export function GeneralSettingsPage() { - const { organization, project, params } = useConsoleScope(); - const scopeName = params.projectHandler - ? project?.name || params.projectHandler - : organization?.name || params.orgHandle; - return ( - Minimal settings overview for {scopeName}. Advanced organization - admin settings, governance, marketplace, and developer portal - configuration are intentionally excluded from the MVP replacement - app. + diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/settings/SettingsLayout.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/settings/SettingsLayout.tsx new file mode 100644 index 0000000000..4fb9278769 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/settings/SettingsLayout.tsx @@ -0,0 +1,151 @@ +/* + * 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, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + PageTitle, + Stack, +} from '@wso2/oxygen-ui'; +import { defineMessages, useIntl } from 'react-intl'; +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'; + +const messages = defineMessages({ + title: { + id: 'apiControlPlane.pages.appShell.appShellPages.settings.SettingsLayout.title', + defaultMessage: 'Settings', + description: 'Heading of the Settings page.', + }, + subtitle: { + id: '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.', + }, +}); + +export type SettingsLayoutProps = { + /** Which Settings page this is — organization- or project-scoped. */ + level: Extract; +}; + +/** + * Settings shell: a persistent left sub-nav (the built-in tabs plus any + * host-injected `settings..tabs` extension, see `useSettingsTabs`) and + * the active tab in the right pane via ``. + * + * No `ScopeGate`: Settings is the one page with no scope requirement. The + * sidebar links to the organization-level path while browsing the org and to + * the project's once one is selected, and a project card's gear deep-links the + * same page — so it renders at whatever scope it is reached in. + */ +export function SettingsLayout({ level }: SettingsLayoutProps) { + const intl = useIntl(); + const navigate = useNavigate(); + const location = useLocation(); + const { organization, params, project } = useConsoleScope(); + const tabs = useSettingsTabs(level); + + // Whichever scope the page was reached in, named rather than handled: the + // heading reads "…for Retail APIs", not "…for retail-apis". Falls back to the + // handle, which the route always carries, so the heading still says what it is + // about while the display name is still loading. + const subject = + project?.displayName ?? + organization?.displayName ?? + params.projectHandler ?? + params.orgHandle; + + // The index route carries no tab segment, and renders the first tab's + // content — so it highlights the first tab rather than nothing at all. + const selectedId = + tabs.find((tab) => location.pathname.endsWith(`/settings/${tab.path}`))?.id ?? + tabs[0]?.id; + + const goToTab = (path: string) => { + if (!params.orgHandle) return; + if (level === 'project') { + if (!params.projectHandler) return; + navigate( + routes.projectSettingsTab(path, params.orgHandle, params.projectHandler) + ); + return; + } + navigate(routes.settingsTab(path, params.orgHandle)); + }; + + return ( + + + + + + {intl.formatMessage(messages.title)} + + + {intl.formatMessage(messages.subtitle, { subject })} + + + + {tabs.map((tab) => ( + goToTab(tab.path)} + selected={tab.id === selectedId} + sx={{ + borderColor: 'divider', + borderRadius: 1, + border: 1, + mb: 0.5, + }} + > + {tab.icon} + + + ))} + + + + + + + + + + + ); +} diff --git a/portals/api-control-plane/src/features/system/SystemPages.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/system/SystemPages.tsx similarity index 92% rename from portals/api-control-plane/src/features/system/SystemPages.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/system/SystemPages.tsx index df1f05c388..542ee1833c 100644 --- a/portals/api-control-plane/src/features/system/SystemPages.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/system/SystemPages.tsx @@ -19,10 +19,10 @@ import { Button, PageContent, PageTitle, Stack, Typography } from '@wso2/oxygen-ui'; import { Navigate, useNavigate } from 'react-router-dom'; -import { useOrganizations } from '../../api/hooks/useMvpQueries'; -import { EmptyState, ErrorState, LoadingState } from '../../components/StateViews'; -import { routes } from '../../routes/paths'; -import { useAuth } from '../auth/AuthProvider'; +import { useOrganizations } from '../../../../api/hooks/useMvpQueries'; +import { EmptyState, ErrorState, LoadingState } from '../../../../components/StateViews'; +import { routes } from '../../../../routes/paths'; +import { useAuth } from '../../../../contexts/auth/AuthProvider'; export function OrganizationRedirectPage() { const organizationsQuery = useOrganizations(); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/test/ApiChatPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/test/ApiChatPage.tsx new file mode 100644 index 0000000000..bf07ff9a12 --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/test/ApiChatPage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function ApiChatPage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/test/ApiConsolePage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/test/ApiConsolePage.tsx new file mode 100644 index 0000000000..a28b7d522d --- /dev/null +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/test/ApiConsolePage.tsx @@ -0,0 +1,38 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function ApiConsolePage() { + return ( + + + } + /> + + ); +} diff --git a/portals/api-control-plane/src/features/test/TestPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/test/TestPage.tsx similarity index 58% rename from portals/api-control-plane/src/features/test/TestPage.tsx rename to portals/api-control-plane/src/pages/appShell/appShellPages/test/TestPage.tsx index a04744dbc0..b8a2b29608 100644 --- a/portals/api-control-plane/src/features/test/TestPage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/test/TestPage.tsx @@ -20,14 +20,28 @@ import { Card, CardContent, CodeBlock, - PageContent, PageTitle, } from '@wso2/oxygen-ui'; -import { useApiProxy, useApi } from '../../api/hooks/useMvpQueries'; -import { ErrorState, LoadingState } from '../../components/StateViews'; +import { useApiProxy, useApi } from '../../../../api/hooks/useMvpQueries'; +import { ErrorState, LoadingState } from '../../../../components/StateViews'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; +import { FormattedMessage } from 'react-intl'; export function TestPage() { + return ( + + + + ); +} + +function Test() { const apiQuery = useApi(); const apiProxyQuery = useApiProxy(apiQuery.data?.id); @@ -37,11 +51,20 @@ export function TestPage() { const context = apiProxyQuery.data?.context || `/${apiQuery.data.name}`; return ( - + <> - Test {apiQuery.data.displayName} + + + - cURL test console for HTTP/API proxies + @@ -49,10 +72,10 @@ export function TestPage() { "`} + -H "Authorization: Bearer "`} /> - + ); } diff --git a/portals/api-control-plane/src/features/auth/AuthCallbackPage.tsx b/portals/api-control-plane/src/pages/auth/AuthCallbackPage.tsx similarity index 100% rename from portals/api-control-plane/src/features/auth/AuthCallbackPage.tsx rename to portals/api-control-plane/src/pages/auth/AuthCallbackPage.tsx diff --git a/portals/api-control-plane/src/features/auth/LoginPage.tsx b/portals/api-control-plane/src/pages/auth/LoginPage.tsx similarity index 99% rename from portals/api-control-plane/src/features/auth/LoginPage.tsx rename to portals/api-control-plane/src/pages/auth/LoginPage.tsx index 9c1ff7081f..d17fd4393d 100644 --- a/portals/api-control-plane/src/features/auth/LoginPage.tsx +++ b/portals/api-control-plane/src/pages/auth/LoginPage.tsx @@ -30,7 +30,7 @@ import { ChangeEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from import { Navigate, useLocation } from 'react-router-dom'; import { runtimeConfig } from '../../config/runtime'; -import { useAuth } from './AuthProvider'; +import { useAuth } from '../../contexts/auth/AuthProvider'; type LoginLocationState = { confirmationKey?: string; diff --git a/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx b/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx index ba9752d870..10cc43f0f3 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.orgSettings.test.tsx @@ -19,7 +19,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AppRoutes } from './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'; // Covers the org-level Settings page (mirrors ai-workspace, which mounts the @@ -28,10 +30,28 @@ import { renderWithProviders, screen } from '../test/utils'; // time — organization-level while browsing the org, project-level once a // project is selected — never both at once. describe('Org-level Settings', () => { - beforeEach(() => vi.stubEnv('VITE_USE_MOCK_API', 'true')); + // The scope hooks always go to the real transport — `VITE_USE_MOCK_API` only + // governs the legacy client — so the endpoints `ConsoleScopeProvider` resolves + // the org and project from are stubbed at the network layer. + const org = anOrganization({ + id: 'api-platform-demo', + displayName: 'API Platform Demo', + }); + const project = aProject({ id: 'retail-apis', displayName: 'Retail APIs' }); + + beforeEach(() => { + vi.stubEnv('VITE_USE_MOCK_API', 'true'); + server.use( + collection('/organizations', [org]), + resource('/organizations/:organizationId', org), + collection('/projects', [project]), + resource('/projects/:projectId', project), + collection('/rest-apis', []) + ); + }); afterEach(() => vi.unstubAllEnvs()); - it('redirects /settings to /settings/general at the org level', async () => { + it('names the organization on the org-level Settings page', async () => { renderWithProviders(, { route: '/organizations/api-platform-demo/settings', authState: authStatePresets.authenticated(), diff --git a/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx b/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx index 3c93d3c3c9..dc2691973f 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.settingsTab.test.tsx @@ -18,87 +18,108 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ExtensionsProvider, type ApiControlPlaneExtension } from '../extensions'; +import { + ExtensionsProvider, + type ApiControlPlaneExtension, +} from '../extensions'; import { AppRoutes } from './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'; -// Covers the `settings..tabs` slot: a host-injected extension -// registered against that slot should render inside the Settings page's own -// sub-nav (nested under /settings) rather than as a top-level sidebar/route -// entry. +// Covers the `settings..tabs` slot: a host-injected extension +// registered against that slot renders inside the Settings page's own sub-nav +// (nested under /settings), never as a top-level sidebar/route entry. describe('AppRoutes settingsTab extensions', () => { - beforeEach(() => vi.stubEnv('VITE_USE_MOCK_API', 'true')); + const org = anOrganization({ + id: 'api-platform-demo', + displayName: 'API Platform Demo', + }); + const project = aProject({ id: 'retail-apis', displayName: 'Retail APIs' }); + + beforeEach(() => { + vi.stubEnv('VITE_USE_MOCK_API', 'true'); + server.use( + collection('/organizations', [org]), + resource('/organizations/:organizationId', org), + collection('/projects', [project]), + resource('/projects/:projectId', project), + collection('/rest-apis', []) + ); + }); afterEach(() => vi.unstubAllEnvs()); + const projectSettingsRoute = + '/organizations/api-platform-demo/projects/retail-apis/settings'; + const mockExtension: ApiControlPlaneExtension = { id: 'environments', - routePath: 'settings/environments', - render: () =>
Mock Environments page
, label: 'Environments', - scope: 'project', - slot: 'settings.project.tabs', + level: 'project', order: 10, + render: () =>
Mock Environments page
, + routePath: 'settings/environments', + slot: 'settings.project.tabs', }; - it('lists the extension as a Settings sub-nav tab and does not add a top-level sidebar entry', async () => { + const renderWithExtension = ( + extension: ApiControlPlaneExtension, + route: string + ) => renderWithProviders( - - + + , - { - route: '/organizations/api-platform-demo/projects/retail-apis/settings', - authState: authStatePresets.authenticated(), - } + { authState: authStatePresets.authenticated(), route } ); - // Settings sub-nav shows both the built-in "General" tab and the - // extension-contributed "Environments" tab. + it('lists the extension as a Settings sub-nav tab and adds no top-level sidebar entry', async () => { + renderWithExtension(mockExtension, projectSettingsRoute); + expect(await screen.findByText('General')).toBeInTheDocument(); expect(await screen.findByText('Environments')).toBeInTheDocument(); - // It must not also appear as its own top-level sidebar entry (there is - // only ever one "Environments" text node on the page: the settings tab). + // One text node only — the settings tab. A second would mean it also + // registered itself as a top-level sidebar item. expect(screen.getAllByText('Environments')).toHaveLength(1); }); - it('renders the extension element when its settings tab route is visited', async () => { - renderWithProviders( - - - , - { - route: '/organizations/api-platform-demo/projects/retail-apis/settings/environments', - authState: authStatePresets.authenticated(), - } - ); + it('renders what the extension returns when its settings tab route is visited', async () => { + renderWithExtension(mockExtension, `${projectSettingsRoute}/environments`); + + expect( + await screen.findByText('Mock Environments page') + ).toBeInTheDocument(); + }); + + it('hands the extension a Port carrying the scope it was rendered in', async () => { + const portAware: ApiControlPlaneExtension = { + ...mockExtension, + render: (port) =>
Port project: {port.projectHandle}
, + }; + + renderWithExtension(portAware, `${projectSettingsRoute}/environments`); - expect(await screen.findByText('Mock Environments page')).toBeInTheDocument(); + expect( + await screen.findByText('Port project: retail-apis') + ).toBeInTheDocument(); }); - it('does not register a conflicting descriptor whose slot and scope disagree', async () => { + it('drops a descriptor whose slot and level disagree', async () => { // Type-valid but internally inconsistent: the slot says "project" while - // `scope` says "organization". Neither `useSettingsTabs` nor - // `settingsTabRoutesFor` may accept this — it must not render in EITHER - // settings page, rather than rendering in the wrong one with a mismatched - // Port (e.g. missing `projectHandle`). - const conflictingExtension: ApiControlPlaneExtension = { + // `level` says "organization". Neither `useSettingsTabs` nor the route pass + // may accept it — it must render in NEITHER settings page rather than in + // the wrong one, against a Port missing the scope it expects. + const conflicting: ApiControlPlaneExtension = { ...mockExtension, id: 'conflicting', label: 'Conflicting', + level: 'organization', slot: 'settings.project.tabs', - scope: 'organization', }; - renderWithProviders( - - - , - { - route: '/organizations/api-platform-demo/projects/retail-apis/settings', - authState: authStatePresets.authenticated(), - } - ); + renderWithExtension(conflicting, projectSettingsRoute); expect(await screen.findByText('General')).toBeInTheDocument(); expect(screen.queryByText('Conflicting')).not.toBeInTheDocument(); diff --git a/portals/api-control-plane/src/routes/AppRoutes.tsx b/portals/api-control-plane/src/routes/AppRoutes.tsx index 14ac63d83b..186a05c41f 100644 --- a/portals/api-control-plane/src/routes/AppRoutes.tsx +++ b/portals/api-control-plane/src/routes/AppRoutes.tsx @@ -16,144 +16,211 @@ * under the License. */ -import { lazy } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; +import { lazy, type ReactNode } from 'react'; +import { Route, Routes } from 'react-router-dom'; -import { AuthCallbackPage } from '../features/auth/AuthCallbackPage'; -import { LoginPage } from '../features/auth/LoginPage'; +import { AuthCallbackPage } from '../pages/auth/AuthCallbackPage'; +import { LoginPage } from '../pages/auth/LoginPage'; import { NotFoundPage, OrganizationRedirectPage, ServerErrorPage, SessionExpiredPage, UnauthorizedPage, -} from '../features/system/SystemPages'; +} from '../pages/appShell/appShellPages/system/SystemPages'; import { ConsoleScopeProvider } from '../scope/ConsoleScopeProvider'; -import AppLayout from '../layouts/AppLayout'; +import AppLayout from '../pages/appShell/AppLayout'; import { - buildScopedExtensionPath, + extensionScopedPaths, + isSidebarExtension, + settingsTabExtensions, type ApiControlPlaneExtension, } from '../extensions'; +import type { NavigationLevel } from '../navigation/navigationTypes'; import { usePort } from '../hostPort'; import { ProtectedRoute } from './ProtectedRoute'; -import { routes } from './paths'; - -/** Resolves the real `CloudHostPort` and hands it to the extension's `render`. */ -function ExtensionRoute({ extension }: { extension: ApiControlPlaneExtension }) { - const port = usePort(); - return <>{extension.render(port)}; -} +import { apiScopedPaths, projectScopedPaths, routes } from './paths'; // Code-split the authenticated feature pages so they are not pulled into the // initial (login) bundle. const OrganizationHomePage = lazy(() => - import('../features/organizations/OrganizationHomePage').then((m) => ({ + import('../pages/appShell/appShellPages/organizations/OrganizationHomePage').then((m) => ({ default: m.OrganizationHomePage, })) ); const ProjectListPage = lazy(() => - import('../features/projects/ProjectListPage').then((m) => ({ + import('../pages/appShell/appShellPages/projects/ProjectListPage').then((m) => ({ default: m.ProjectListPage, })) ); const GatewaysPage = lazy(() => - import('../features/gateways/GatewaysPage').then((m) => ({ + import('../pages/appShell/appShellPages/gateways/GatewaysPage').then((m) => ({ default: m.GatewaysPage, })) ); const GatewayCreatePage = lazy(() => - import('../features/gateways/GatewayCreatePage').then((m) => ({ + import('../pages/appShell/appShellPages/gateways/GatewayCreatePage').then((m) => ({ default: m.GatewayCreatePage, })) ); const GatewayDetailPage = lazy(() => - import('../features/gateways/GatewayDetailPage').then((m) => ({ + import('../pages/appShell/appShellPages/gateways/GatewayDetailPage').then((m) => ({ default: m.GatewayDetailPage, })) ); const ProjectHomePage = lazy(() => - import('../features/projects/ProjectHomePage').then((m) => ({ + import('../pages/appShell/appShellPages/projects/ProjectHomePage').then((m) => ({ default: m.ProjectHomePage, })) ); const ApiListPage = lazy(() => - import('../features/apis/ApiListPage').then((m) => ({ + import('../pages/appShell/appShellPages/apis/ApiListPage').then((m) => ({ default: m.ApiListPage, })) ); const ApiCreatePage = lazy(() => - import('../features/apis/ApiCreatePage').then((m) => ({ - default: m.ApiCreatePage, + import('../pages/appShell/appShellPages/apis/create/ApiCreationWizard').then((m) => ({ + default: m.ApiCreationWizard, })) ); const ApiDetailPage = lazy(() => - import('../features/apis/ApiDetailPage').then((m) => ({ + import('../pages/appShell/appShellPages/apis/ApiDetailPage').then((m) => ({ default: m.ApiDetailPage, })) ); const DeployPage = lazy(() => - import('../features/deploy/DeployPage').then((m) => ({ default: m.DeployPage })) + import('../pages/appShell/appShellPages/deploy/DeployPage').then((m) => ({ default: m.DeployPage })) ); const TestPage = lazy(() => - import('../features/test/TestPage').then((m) => ({ default: m.TestPage })) + import('../pages/appShell/appShellPages/test/TestPage').then((m) => ({ default: m.TestPage })) +); +const PoliciesPage = lazy(() => + import('../pages/appShell/appShellPages/apis/develop/PoliciesPage').then((m) => ({ + default: m.PoliciesPage, + })) +); +const RoutingPage = lazy(() => + import('../pages/appShell/appShellPages/apis/develop/RoutingPage').then((m) => ({ + default: m.RoutingPage, + })) +); +const DocumentsPage = lazy(() => + import('../pages/appShell/appShellPages/apis/develop/DocumentsPage').then((m) => ({ + default: m.DocumentsPage, + })) +); +const ApiConsolePage = lazy(() => + import('../pages/appShell/appShellPages/test/ApiConsolePage').then((m) => ({ + default: m.ApiConsolePage, + })) +); +const ApiChatPage = lazy(() => + import('../pages/appShell/appShellPages/test/ApiChatPage').then((m) => ({ + default: m.ApiChatPage, + })) +); +const AlertsPage = lazy(() => + import('../pages/appShell/appShellPages/observability/AlertsPage').then((m) => ({ + default: m.AlertsPage, + })) +); +const MetricsPage = lazy(() => + import('../pages/appShell/appShellPages/observability/MetricsPage').then((m) => ({ + default: m.MetricsPage, + })) +); +const MonetizePage = lazy(() => + import('../pages/appShell/appShellPages/manage/MonetizePage').then((m) => ({ + default: m.MonetizePage, + })) +); +const LifeCyclePage = lazy(() => + import('../pages/appShell/appShellPages/manage/LifeCyclePage').then((m) => ({ + default: m.LifeCyclePage, + })) ); -const ManagePage = lazy(() => - import('../features/manage/ManagePage').then((m) => ({ default: m.ManagePage })) +const InsightsPage = lazy(() => + import('../pages/appShell/appShellPages/insights/InsightsPage').then((m) => ({ + default: m.InsightsPage, + })) +); +const CompliancePage = lazy(() => + import('../pages/appShell/appShellPages/insights/CompliancePage').then((m) => ({ + default: m.CompliancePage, + })) +); +const AdminPage = lazy(() => + import('../pages/appShell/appShellPages/admin/AdminPage').then((m) => ({ + default: m.AdminPage, + })) ); const RuntimeLogsPage = lazy(() => - import('../features/logs/RuntimeLogsPage').then((m) => ({ + import('../pages/appShell/appShellPages/observability/RuntimeLogsPage').then((m) => ({ default: m.RuntimeLogsPage, })) ); const SettingsLayout = lazy(() => - import('../features/settings/SettingsLayout').then((m) => ({ + import('../pages/appShell/appShellPages/settings/SettingsLayout').then((m) => ({ default: m.SettingsLayout, })) ); const GeneralSettingsPage = lazy(() => - import('../features/settings/GeneralSettingsPage').then((m) => ({ - default: m.GeneralSettingsPage, - })) + import('../pages/appShell/appShellPages/settings/GeneralSettingsPage').then( + (m) => ({ default: m.GeneralSettingsPage }) + ) ); export type AppRoutesProps = { extensions?: readonly ApiControlPlaneExtension[]; }; -export function AppRoutes({ extensions = [] }: AppRoutesProps) { - const topLevelExtensions = extensions.filter((ext) => - ext.slot.startsWith('sidebar.') - ); +/** + * One `` per path a scoped page answers on — its fully-scoped path plus + * the scope-less aliases the sidebar links to when the project (or API) isn't + * selected yet. The same element renders at all of them; the `ScopeGate` inside + * it shows a picker instead of the page body until scope is complete. + */ +const scopedRoutes = (paths: string[], element: ReactNode) => + paths.map((path) => ); - const extensionRoutes = topLevelExtensions.map((extension) => ( - } - /> - )); +/** + * Resolves the real `CloudHostPort` and hands it to the extension's `render`. + * + * The port is read from context here, inside the router, rather than passed in + * from the registration site — an extension only ever receives it as a plain + * value, so it never imports this portal's hooks itself (see `hostPort.tsx`). + */ +function ExtensionRoute({ extension }: { extension: ApiControlPlaneExtension }) { + const port = usePort(); + return <>{extension.render(port)}; +} - // Extensions registered against a `settings..tabs` slot render - // nested under the matching (org- or project-level) Settings layout - // instead of as a sibling top-level route — the path is relative to - // `/settings/`. Also requires `ext.scope === scope`: a type-valid but - // inconsistent descriptor (slot says one scope, `scope` field says - // another) must not register a route here with the wrong scope's Port — - // see `useSettingsTabs`'s matching guard for the sub-nav tab list itself. - const settingsTabRoutesFor = (scope: 'organization' | 'project') => - extensions - .filter((ext) => ext.slot === `settings.${scope}.tabs` && ext.scope === scope) - .map((extension) => ( +export function AppRoutes({ extensions = [] }: AppRoutesProps) { + // Extensions registered against a `settings..tabs` slot render nested + // under the matching Settings layout, at a path relative to it — so the tab's + // own route and the sub-nav entry `useSettingsTabs` builds stay in step. + const settingsTabRoutes = (level: NavigationLevel) => + settingsTabExtensions(extensions, level).map((extension) => ( + } + /> + )); + + // Only `sidebar.*` entries become top-level routes; a Settings tab extension + // is routed by `settingsTabRoutes` above, nested under the Settings layout. + const extensionRoutes = extensions + .filter(isSidebarExtension) + .flatMap((extension) => + extensionScopedPaths(extension.level, extension.routePath).map((path) => ( } /> - )); + )) + ); return ( @@ -174,27 +241,86 @@ export function AppRoutes({ extensions = [] }: AppRoutesProps) { } /> } /> } /> - }> - } /> - } /> - {settingsTabRoutesFor('organization')} - } /> } /> } /> } /> + {/* + Project and API overview take a single fully-scoped path each: they + are the deeper tiers of the sidebar's Overview item, which degrades + to a shallower tier instead of linking here un-scoped, so there is no + alias to register. + */} } /> - } /> - } /> } /> - } /> - } /> - } /> - } /> - }> - } /> + {scopedRoutes(projectScopedPaths(routes.apis), )} + } /> + {/* + Test, Observability and Manage are sidebar parents with no page of + their own — only their children are routed. Out of API scope a parent + links to its first child's alias, so `.../test` and friends are never + produced and are not registered. + */} + {scopedRoutes( + apiScopedPaths(routes.apiDevelopPolicies), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiDevelopRouting), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiDevelopDocuments), + + )} + {scopedRoutes(apiScopedPaths(routes.apiTestConsole), )} + {scopedRoutes(apiScopedPaths(routes.apiTestCurl), )} + {scopedRoutes(apiScopedPaths(routes.apiTestChat), )} + {scopedRoutes(apiScopedPaths(routes.apiDeploy), )} + {scopedRoutes(apiScopedPaths(routes.apiInsightsApi), )} + {scopedRoutes( + apiScopedPaths(routes.apiInsightsCompliance), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiObservabilityAlerts), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiObservabilityMetrics), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiObservabilityLogs), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiManageMonetize), + + )} + {scopedRoutes( + apiScopedPaths(routes.apiManageLifecycle), + + )} + {scopedRoutes(apiScopedPaths(routes.apiAdmin), )} + {/* Two entry points, one page, no scope requirement either way. The + index route renders the same content as `general`, so `/settings` + is never a blank pane. */} + } + > + } /> + } /> + {settingsTabRoutes('organization')} + + } + > + } /> } /> - {settingsTabRoutesFor('project')} + {settingsTabRoutes('project')} {extensionRoutes} } /> diff --git a/portals/api-control-plane/src/routes/ProtectedRoute.tsx b/portals/api-control-plane/src/routes/ProtectedRoute.tsx index fc00136e8d..8296e283be 100644 --- a/portals/api-control-plane/src/routes/ProtectedRoute.tsx +++ b/portals/api-control-plane/src/routes/ProtectedRoute.tsx @@ -19,7 +19,7 @@ import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { LoadingState } from '../components/StateViews'; -import { useAuth } from '../features/auth/AuthProvider'; +import { useAuth } from '../contexts/auth/AuthProvider'; import { routes } from './paths'; export function ProtectedRoute() { diff --git a/portals/api-control-plane/src/routes/paths.test.ts b/portals/api-control-plane/src/routes/paths.test.ts new file mode 100644 index 0000000000..fd2c8a2f61 --- /dev/null +++ b/portals/api-control-plane/src/routes/paths.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, it } from 'vitest'; + +import { getRouteParamsFromPathname } from '../scope/consoleRouteParams'; +import { routes, type ApiPathBuilder, type ProjectPathBuilder } from './paths'; + +const ORG = 'acme'; +const PROJECT = 'orders'; +const API = 'api-1'; + +// Only the pages whose scope-less aliases `AppRoutes` actually registers, i.e. +// the ones a sidebar item can link to before its scope is known. Overview's own +// tiers (`projectHome`, `api`) are absent deliberately: that item degrades to a +// shallower tier instead of linking un-scoped, so those aliases don't exist. +const PROJECT_LEVEL: [string, ProjectPathBuilder][] = [ + ['apis', routes.apis], +]; + +const API_LEVEL: [string, ApiPathBuilder][] = [ + ['apiDevelopPolicies', routes.apiDevelopPolicies], + ['apiDevelopRouting', routes.apiDevelopRouting], + ['apiDevelopDocuments', routes.apiDevelopDocuments], + ['apiTestConsole', routes.apiTestConsole], + ['apiTestCurl', routes.apiTestCurl], + ['apiTestChat', routes.apiTestChat], + ['apiDeploy', routes.apiDeploy], + ['apiInsightsApi', routes.apiInsightsApi], + ['apiInsightsCompliance', routes.apiInsightsCompliance], + ['apiObservabilityAlerts', routes.apiObservabilityAlerts], + ['apiObservabilityMetrics', routes.apiObservabilityMetrics], + ['apiObservabilityLogs', routes.apiObservabilityLogs], + ['apiManageMonetize', routes.apiManageMonetize], + ['apiManageLifecycle', routes.apiManageLifecycle], + ['apiAdmin', routes.apiAdmin], +]; + +/* + * `ConsoleScopeProvider` reads scope back out of the pathname, and the sidebar's + * scope-less aliases are URLs it has to read as "no project/API selected". If an + * alias left a `projects` or `apis` segment with the page's suffix behind it, the + * suffix would come back as a handle, `ScopeGate` would think scope was complete, + * and the page would query a project that doesn't exist. These tests hold the two + * halves — builder and parser — to that contract. + */ +describe('scope-less aliases round-trip through the scope parser', () => { + it.each(PROJECT_LEVEL)('%s: a project-less alias has no handles', (_id, build) => { + const params = getRouteParamsFromPathname(build(ORG, null)); + + expect(params.orgHandle).toBe(ORG); + expect(params.projectHandler).toBeUndefined(); + expect(params.apiHandler).toBeUndefined(); + }); + + it.each(PROJECT_LEVEL)('%s: the scoped path still yields its project', (_id, build) => { + expect(getRouteParamsFromPathname(build(ORG, PROJECT))).toMatchObject({ + orgHandle: ORG, + projectHandler: PROJECT, + }); + }); + + it.each(API_LEVEL)('%s: an api-less alias keeps the project only', (_id, build) => { + const params = getRouteParamsFromPathname(build(ORG, PROJECT, null)); + + expect(params.projectHandler).toBe(PROJECT); + expect(params.apiHandler).toBeUndefined(); + }); + + it.each(API_LEVEL)('%s: a fully scope-less alias has no handles', (_id, build) => { + const params = getRouteParamsFromPathname(build(ORG, null, null)); + + expect(params.projectHandler).toBeUndefined(); + expect(params.apiHandler).toBeUndefined(); + }); + + it.each(API_LEVEL)('%s: the scoped path still yields both handles', (_id, build) => { + expect( + getRouteParamsFromPathname(build(ORG, PROJECT, API)) + ).toMatchObject({ apiHandler: API, orgHandle: ORG, projectHandler: PROJECT }); + }); + + /* + * `newApi` is the one page whose suffix sits in a handle slot (`.../apis/new`), + * so the alias convention above cannot protect it — the parser's reserved-segment + * list does. Read back as a handle, `new` would name a phantom API in the header + * switcher and breadcrumbs, turn `isApiScope` on before the API exists, and fire + * a detail request for it. + */ + it('newApi: the create page is not an API called "new"', () => { + const params = getRouteParamsFromPathname(routes.newApi(ORG, PROJECT)); + + expect(params.orgHandle).toBe(ORG); + expect(params.projectHandler).toBe(PROJECT); + expect(params.apiHandler).toBeUndefined(); + }); + + it('an API legitimately handled "new" is unreachable, by design', () => { + // Documents the trade-off rather than asserting a wish: the reserved segment + // wins, so the backend must never mint `new` as an API handle. + expect( + getRouteParamsFromPathname(routes.api(ORG, PROJECT, 'new')).apiHandler + ).toBeUndefined(); + }); +}); + +describe('path builders', () => { + it('keeps every alias distinct from every other page at the same scope', () => { + const aliases = [ + ...PROJECT_LEVEL.map(([, build]) => build(ORG, null)), + ...API_LEVEL.flatMap(([, build]) => [ + build(ORG, PROJECT, null), + build(ORG, null, null), + ]), + // Pages that keep a fully-scoped path of their own. + routes.organizationHome(ORG), + routes.projects(ORG), + routes.gateways(ORG), + routes.settings(ORG), + routes.projectSettings(ORG, PROJECT), + routes.projectHome(ORG, PROJECT), + routes.api(ORG, PROJECT, API), + ...PROJECT_LEVEL.map(([, build]) => build(ORG, PROJECT)), + ...API_LEVEL.map(([, build]) => build(ORG, PROJECT, API)), + routes.newApi(ORG, PROJECT), + ]; + + expect(new Set(aliases).size).toBe(aliases.length); + }); + + it('does not put a page suffix where an API handle is read', () => { + // `.../apis/deploy` must stay the detail path of an API handled `deploy`, + // never the Deploy page awaiting an API. + expect(routes.apiDeploy(ORG, PROJECT, null)).not.toContain('/apis/'); + expect(routes.api(ORG, PROJECT, 'deploy')).toBe( + `/organizations/${ORG}/projects/${PROJECT}/apis/deploy` + ); + }); +}); diff --git a/portals/api-control-plane/src/routes/paths.ts b/portals/api-control-plane/src/routes/paths.ts index a2ab9ce208..e93f6c9798 100644 --- a/portals/api-control-plane/src/routes/paths.ts +++ b/portals/api-control-plane/src/routes/paths.ts @@ -16,6 +16,102 @@ * under the License. */ +/** + * A project or API handle for a path builder. + * + * `null` means "this scope is not selected yet". The builder then replaces the + * unresolved scope's segments with `SELECT_SCOPE_SEGMENT`, producing the + * **scope-less alias** of the same page — the URL the sidebar links to when the + * user clicks, say, Deploy while no API is in scope. The page still mounts at + * that URL; its `ScopeGate` renders a scope picker instead of the page body + * until the handle is filled in. + * + * Omitting the argument entirely keeps the `:param` placeholder, which is what + * route registration and the sidebar's `match` predicates want. + */ +export type ScopeHandle = string | null; + +/** + * Marks where a page's path runs out of resolved scope. + * + * Reserved: no organization, project or API handle may take this value, and no + * page suffix may start with it. + */ +export const SELECT_SCOPE_SEGMENT = 'select-scope'; + +const join = (...segments: (string | undefined)[]) => + `/${segments.filter(Boolean).join('/')}`; + +/** + * Builds a **project-level** page's path, marking an unresolved project: + * + * ``` + * projectPath('acme', 'orders', 'apis') -> /organizations/acme/projects/orders/apis + * projectPath('acme', null, 'apis') -> /organizations/acme/select-scope/apis + * ``` + * + * The marker earns its place by keeping the alias unambiguous in both + * directions: + * + * - `ConsoleScopeProvider` reads scope back out of the pathname positionally — + * the segment after `projects` is the project handle, the one after `apis` is + * the API handle. Merely dropping the handle would leave + * `/organizations/acme/projects/apis`, read as *project `apis`*, so the gate + * would believe a project was selected and the page would query one that + * doesn't exist. The marker keeps `projects`/`apis` out of the alias entirely, + * so those lookups correctly find nothing. + * - Nothing can collide. Dropping the segments instead would put project + * Settings at `/organizations/:orgHandle/settings` and project Home at + * `/organizations/:orgHandle/home`, which is `organizationHome`. + */ +export const projectPath = ( + orgHandle: string, + projectHandler: ScopeHandle, + suffix?: string +): string => + projectHandler + ? join('organizations', orgHandle, 'projects', projectHandler, suffix) + : join('organizations', orgHandle, SELECT_SCOPE_SEGMENT, suffix); + +/** + * Builds an **API-level** page's path. The marker replaces whichever scope runs + * out first, so one alias covers "no project yet" and another "project known, + * API still to pick": + * + * ``` + * apiPath('acme', 'orders', 'a-1', 'deploy') -> /organizations/acme/projects/orders/apis/a-1/deploy + * apiPath('acme', 'orders', null, 'deploy') -> /organizations/acme/projects/orders/select-scope/deploy + * apiPath('acme', null, null, 'deploy') -> /organizations/acme/select-scope/deploy + * ``` + */ +export const apiPath = ( + orgHandle: string, + projectHandler: ScopeHandle, + apiHandler: ScopeHandle, + suffix?: string +): string => { + if (!projectHandler) return projectPath(orgHandle, null, suffix); + if (!apiHandler) { + return join( + 'organizations', + orgHandle, + 'projects', + projectHandler, + SELECT_SCOPE_SEGMENT, + suffix + ); + } + return join( + 'organizations', + orgHandle, + 'projects', + projectHandler, + 'apis', + apiHandler, + suffix + ); +}; + export const routes = { login: '/login', authCallback: '/login/callback', @@ -34,48 +130,194 @@ export const routes = { `/organizations/${orgHandle}/gateways/new`, gateway: (orgHandle = ':orgHandle', gatewayId = ':gatewayId') => `/organizations/${orgHandle}/gateways/${gatewayId}`, + // Project and API overview are the deeper tiers of the sidebar's Overview + // item, which degrades to a shallower tier rather than gating (see + // `adaptive` in navigation/navigationRegistry.tsx) — so neither is ever built + // without its handle, and neither needs a scope-less alias. projectHome: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => - `/organizations/${orgHandle}/projects/${projectHandler}/home`, - apis: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => - `/organizations/${orgHandle}/projects/${projectHandler}/apis`, - newApi: ( + projectPath(orgHandle, projectHandler, 'home'), + apis: ( orgHandle = ':orgHandle', - projectHandler = ':projectHandler' - ) => `/organizations/${orgHandle}/projects/${projectHandler}/apis/new`, + projectHandler: ScopeHandle = ':projectHandler' + ) => projectPath(orgHandle, projectHandler, 'apis'), + // Only reachable from inside a project (the APIs page's own create button), + // so it has no scope-less alias. + newApi: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => + projectPath(orgHandle, projectHandler, 'apis/new'), api: ( orgHandle = ':orgHandle', projectHandler = ':projectHandler', apiHandler = ':apiHandler' - ) => - `/organizations/${orgHandle}/projects/${projectHandler}/apis/${apiHandler}`, + ) => apiPath(orgHandle, projectHandler, apiHandler), + // Develop's own submenu: the three panels that used to be tabs on the API + // overview page. + apiDevelopPolicies: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'develop/policies'), + apiDevelopRouting: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'develop/routing'), + apiDevelopDocuments: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'develop/documents'), apiDeploy: ( orgHandle = ':orgHandle', - projectHandler = ':projectHandler', - apiHandler = ':apiHandler' - ) => - `/organizations/${orgHandle}/projects/${projectHandler}/apis/${apiHandler}/deploy`, - apiTest: ( + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'deploy'), + // Test, Observability and Manage are sidebar *parents*: in API scope they open + // a submenu rather than a page, so only their children have paths. There is no + // bare `.../test` route — nothing links to one. + apiTestConsole: ( orgHandle = ':orgHandle', - projectHandler = ':projectHandler', - apiHandler = ':apiHandler' - ) => - `/organizations/${orgHandle}/projects/${projectHandler}/apis/${apiHandler}/test`, - apiManage: ( + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'test/console'), + apiTestCurl: ( orgHandle = ':orgHandle', - projectHandler = ':projectHandler', - apiHandler = ':apiHandler' - ) => - `/organizations/${orgHandle}/projects/${projectHandler}/apis/${apiHandler}/manage`, - runtimeLogs: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => - `/organizations/${orgHandle}/projects/${projectHandler}/observe/runtimelogs`, - settings: (orgHandle = ':orgHandle', projectHandler = ':projectHandler') => - `/organizations/${orgHandle}/projects/${projectHandler}/settings`, - settingsTab: ( - tab: string, + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'test/curl'), + apiTestChat: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'test/chat'), + apiManageMonetize: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'manage/monetize'), + apiManageLifecycle: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'manage/lifecycle'), + // The doubled `api` is the child's own label ("API Insights") under the + // Insights parent, not a stutter in the naming scheme. + apiInsightsApi: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'insights/api'), + apiInsightsCompliance: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'insights/compliance'), + apiObservabilityAlerts: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'observability/alerts'), + apiObservabilityMetrics: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'observability/metrics'), + // Runtime logs, scoped to one API. Was project-wide (`observe/runtimelogs`) + // when the sidebar had a project section; it now sits under Observability. + apiObservabilityLogs: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'observability/logs'), + apiAdmin: ( + orgHandle = ':orgHandle', + projectHandler: ScopeHandle = ':projectHandler', + apiHandler: ScopeHandle = ':apiHandler' + ) => apiPath(orgHandle, projectHandler, apiHandler, 'admin'), + // Settings is the one page with no scope requirement, so the sidebar links to + // the organization-level path and it renders whatever the scope. It needs no + // scope-less alias for the same reason: there is nothing to select. + settings: (orgHandle = ':orgHandle') => `/organizations/${orgHandle}/settings`, + // The same page, deep-linked for one project — the gear on a project card. + // Kept as its own builder rather than a `ScopeHandle` on `settings` because + // these two are alternative entry points, not a scoped/scope-less pair. + projectSettings: ( orgHandle = ':orgHandle', projectHandler = ':projectHandler' - ) => `/organizations/${orgHandle}/projects/${projectHandler}/settings/${tab}`, - orgSettings: (orgHandle = ':orgHandle') => `/organizations/${orgHandle}/settings`, - orgSettingsTab: (tab: string, orgHandle = ':orgHandle') => + ) => projectPath(orgHandle, projectHandler, 'settings'), + // One Settings sub-nav tab, org- and project-scoped. `tab` is the segment + // below `/settings/` ("general", or an extension's own `routePath` with the + // `settings/` prefix stripped) — the sub-nav and the routes are built from + // these two, so a tab can never link somewhere no route answers. + settingsTab: (tab: string, orgHandle = ':orgHandle') => `/organizations/${orgHandle}/settings/${tab}`, + projectSettingsTab: ( + tab: string, + orgHandle = ':orgHandle', + projectHandler = ':projectHandler' + ) => projectPath(orgHandle, projectHandler, `settings/${tab}`), }; + +export type ProjectPathBuilder = ( + orgHandle: string, + projectHandler: ScopeHandle +) => string; + +export type ApiPathBuilder = ( + orgHandle: string, + projectHandler: ScopeHandle, + apiHandler: ScopeHandle +) => string; + +/** + * Any `routes.*` page builder, whatever depth it takes — an org-level one + * ignores the trailing arguments. + * + * This is the shape the sidebar's scope-adaptive items need: one item pointing at + * an org-, project- and API-level page, each called with as many handles as its + * own level takes. Deliberately `string | undefined` rather than `ScopeHandle`: + * every builder here accepts it, and the adaptive item only ever calls a tier + * whose handles it already has, so `null` never comes up. + */ +export type ScopedPathBuilder = ( + orgHandle: string, + projectHandler?: string, + apiHandler?: string +) => string; + +/** + * Every route pattern a project-level page answers on: its fully-scoped path + * plus the scope-less alias the sidebar links to when no project is selected. + * + * `AppRoutes` registers these and `navigationRegistry` matches against them, so + * the route table and the sidebar highlight can't drift apart — both are + * generated from the same builder. + */ +export const projectScopedPaths = (build: ProjectPathBuilder): string[] => [ + build(':orgHandle', ':projectHandler'), + build(':orgHandle', null), +]; + +/** + * Same as `projectScopedPaths`, for API-level pages. Three patterns, because an + * API-level page can be missing the API alone or both the API and the project. + */ +export const apiScopedPaths = (build: ApiPathBuilder): string[] => [ + build(':orgHandle', ':projectHandler', ':apiHandler'), + ...apiScopeSelectPaths(build), +]; + +/** + * Just the scope-less aliases of an API-level page — `apiScopedPaths` without the + * fully-scoped path. + * + * This is what a submenu *parent* matches on. A parent has no page of its own, so + * out of API scope it links to its first child's alias, and matching those + * aliases is what keeps it highlighted while the `ScopeGate` asks for an API. + * Deliberately not the fully-scoped path: once scope resolves, the child owns the + * highlight (Oxygen leaves an expanded parent unhighlighted by design), and + * matching both would make parent and child claim active at once. + */ +export const apiScopeSelectPaths = (build: ApiPathBuilder): string[] => [ + build(':orgHandle', ':projectHandler', null), + build(':orgHandle', null, null), +]; diff --git a/portals/api-control-plane/src/scope/ConsoleScopeContext.ts b/portals/api-control-plane/src/scope/ConsoleScopeContext.ts index 587127b8a0..13ae68702d 100644 --- a/portals/api-control-plane/src/scope/ConsoleScopeContext.ts +++ b/portals/api-control-plane/src/scope/ConsoleScopeContext.ts @@ -18,8 +18,10 @@ import { createContext, useContext } from 'react'; -import type { ApiCapabilities } from '../features/apis/apiCapabilities'; -import type { Api, Organization, Project } from '../types/domain'; +import type { ApiCapabilities } from '../pages/appShell/appShellPages/apis/utils/apiCapabilities'; +import { RestApi } from '../api/resources/restApis'; +import { Organization } from '../api/resources/organizations'; +import { Project } from '../api/resources/projects'; export type ConsoleRouteParams = { apiHandler?: string; @@ -44,7 +46,7 @@ export type ActiveScope = { export type ConsoleScope = { activeScope: ActiveScope; capabilities: ApiCapabilities; - component?: Api; + component?: RestApi; isApiScope: boolean; isLoading: boolean; isOrganizationScope: boolean; diff --git a/portals/api-control-plane/src/scope/ConsoleScopeProvider.tsx b/portals/api-control-plane/src/scope/ConsoleScopeProvider.tsx index eddd2b2f18..32b4dbf1b6 100644 --- a/portals/api-control-plane/src/scope/ConsoleScopeProvider.tsx +++ b/portals/api-control-plane/src/scope/ConsoleScopeProvider.tsx @@ -19,20 +19,18 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; -import { - useApi, - useOrganizations, - useProject, - useProjects, -} from '../api/hooks/useMvpQueries'; import { ApiScopeProvider } from '../api/core/ApiScopeProvider'; -import { useAuth } from '../features/auth/AuthProvider'; -import { getApiCapabilities } from '../features/apis/apiCapabilities'; +import { useAuth } from '../contexts/auth/AuthProvider'; +import { getApiCapabilities } from '../pages/appShell/appShellPages/apis/utils/apiCapabilities'; import { ConsoleScopeContext, type ConsoleRouteParams, type ConsoleScope, } from './ConsoleScopeContext'; +import { getRouteParamsFromPathname } from './consoleRouteParams'; +import { useRestApi } from '../api/resources/restApis'; +import { useOrganizations } from '../api/resources/organizations'; +import { useProject, useProjects } from '../api/resources/projects'; // Re-export so existing imports from this module keep working. export { @@ -42,34 +40,6 @@ export { type ConsoleScope, } from './ConsoleScopeContext'; -const getRouteParamsFromPathname = (pathname: string): ConsoleRouteParams => { - const segments = pathname.split('/').filter(Boolean); - const organizationsIndex = segments.indexOf('organizations'); - if (organizationsIndex < 0) return {}; - - const orgHandle = segments[organizationsIndex + 1]; - const projectsIndex = segments.indexOf('projects'); - const projectHandler = - projectsIndex >= 0 ? segments[projectsIndex + 1] : undefined; - const apisIndex = segments.indexOf('apis'); - const apiHandler = - apisIndex >= 0 ? segments[apisIndex + 1] : undefined; - const environmentsIndex = segments.indexOf('environments'); - const environmentId = - environmentsIndex >= 0 ? segments[environmentsIndex + 1] : undefined; - const deploymentsIndex = segments.indexOf('deployments'); - const deploymentId = - deploymentsIndex >= 0 ? segments[deploymentsIndex + 1] : undefined; - - return { - apiHandler, - deploymentId, - environmentId, - orgHandle, - projectHandler, - }; -}; - export function ConsoleScopeProvider({ children }: { children: ReactNode }) { const routeParams = useParams(); const location = useLocation(); @@ -144,24 +114,22 @@ export function ConsoleScopeProvider({ children }: { children: ReactNode }) { const queryOrgHandle = tokenReadyOrgHandle === params.orgHandle ? params.orgHandle : undefined; + + const apiQuery = useRestApi(params.apiHandler, {orgId: queryOrgHandle }); const organizationsQuery = useOrganizations(); - const projectsQuery = useProjects(queryOrgHandle); - const projectQuery = useProject(queryOrgHandle, params.projectHandler); - const apiQuery = useApi( - queryOrgHandle, - params.projectHandler, - params.apiHandler - ); + const projectsQuery = useProjects({}, {orgId: queryOrgHandle }); + const projectQuery = useProject(params.projectHandler, {orgId: queryOrgHandle }); const organization = useMemo( () => - organizationsQuery.data?.find((item) => item.handle === params.orgHandle), - [organizationsQuery.data, params.orgHandle] + organizationsQuery.data?.list?.find((item) => item.id === params.orgHandle), + [organizationsQuery.data?.list, params.orgHandle] ); const project = projectQuery.data || - projectsQuery.data?.find((item) => item.handler === params.projectHandler); + projectsQuery.data?.list?.find((item) => item.id === params.projectHandler); + const component = apiQuery.data; const capabilities = useMemo( () => getApiCapabilities(component), @@ -190,10 +158,10 @@ export function ConsoleScopeProvider({ children }: { children: ReactNode }) { isOrganizationScope: Boolean(params.orgHandle), isProjectScope: Boolean(params.projectHandler), organization, - organizations: organizationsQuery.data || [], + organizations: organizationsQuery.data?.list || [], params, project, - projects: projectsQuery.data || [], + projects: projectsQuery.data?.list || [], projectsError: orgTokenError || projectsQuery.error || undefined, }), [ diff --git a/portals/api-control-plane/src/scope/ScopeGate.test.tsx b/portals/api-control-plane/src/scope/ScopeGate.test.tsx new file mode 100644 index 0000000000..adf68cfaa9 --- /dev/null +++ b/portals/api-control-plane/src/scope/ScopeGate.test.tsx @@ -0,0 +1,211 @@ +/* + * 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, useLocation } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ApiError } from '../api/core/errors'; +import { organizations } from '../api/mocks/data'; +import type { + RestApi, + RestApiListResponse, +} from '../api/resources/restApis'; +import { routes } from '../routes/paths'; +import { makeConsoleScope } from '../test/mockScope'; +import { renderWithProviders, screen } from '../test/utils'; + +vi.mock('../api/resources/restApis', async (importActual) => ({ + ...(await importActual()), + useRestApis: vi.fn(), +})); + +import { useRestApis } from '../api/resources/restApis'; +import { ScopeGate } from './ScopeGate'; + +const API_LIST = [ + { + context: '/orders', + displayName: 'Orders API', + id: 'orders-api', + projectId: 'retail-apis', + upstream: { main: { url: 'https://backend.example.com' } }, + version: '1.0.0', + }, +] as RestApi[]; + +const listQuery = (list: RestApi[]) => + ({ + data: { count: list.length, list }, + isPending: false, + }) as UseQueryResult; + +// `makeConsoleScope` seeds itself from these fixtures, and `ScopeGate` reads the +// org handle from scope rather than from the URL — so the routes under test have +// to be built from the same handles. +const ORG = organizations[0].id; + +/* + * The project list is supplied by the test rather than taken from + * `makeConsoleScope`'s defaults, because the picker reads `displayName`/`id` (the + * generated shape) while `api/mocks/data` still carries `name`/`handler`. Pinning + * it here keeps these tests about the gate rather than about which fixture shape + * happens to be current. + */ +const PROJECT_OPTION = { displayName: 'Retail APIs', id: 'retail-apis' }; +const PROJECT = PROJECT_OPTION.id; +const projectScope = () => + makeConsoleScope({ + isProjectScope: false, + project: undefined, + projects: [PROJECT_OPTION] as ReturnType< + typeof makeConsoleScope + >['projects'], + }); + +/** The select's own control — `getByLabelText` also matches the visible label. */ +const selectFor = (name: string) => screen.getByRole('combobox', { name }); + +/** + * An option inside an open select. Matched by role rather than text: once a + * value is chosen the select renders it again in its own display, so plain text + * matches two nodes. + */ +const optionFor = (name: RegExp) => screen.getByRole('option', { name }); + +/** Renders the current pathname so a test can assert where submitting landed. */ +function Located() { + return {`at ${useLocation().pathname}`}; +} + +describe('ScopeGate', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useRestApis).mockReturnValue(listQuery(API_LIST)); + }); + + const renderGate = ( + route: string, + gate: React.ReactNode, + scope: ReturnType + ) => + renderWithProviders( + + + , + { route, scope } + ); + + it('renders the page once the required scope is on the route', () => { + renderGate( + routes.apis(ORG, PROJECT), + + page body + , + makeConsoleScope() + ); + + expect(screen.getByText('page body')).toBeInTheDocument(); + }); + + it('prompts for a project instead of the page when none is selected', () => { + renderGate( + routes.apis(ORG, null), + + page body + , + projectScope() + ); + + expect( + screen.getByText('APIs are created and managed at the project level.') + ).toBeInTheDocument(); + expect(screen.queryByText('page body')).not.toBeInTheDocument(); + }); + + it('navigates to the fully scoped page after picking a project', async () => { + const { user } = renderGate( + routes.apis(ORG, null), + <> + + page body + + + , + projectScope() + ); + + await user.click(selectFor('Project')); + await user.click(optionFor(/Retail APIs/)); + await user.click(screen.getByRole('button', { name: 'Go to Project Level' })); + + expect( + screen.getByText(`at ${routes.apis(ORG, PROJECT)}`) + ).toBeInTheDocument(); + }); + + it('asks for both handles on an api-level page outside any project', async () => { + const { user } = renderGate( + routes.apiDeploy(ORG, null, null), + <> + + page body + + + , + projectScope() + ); + + // Disabled until a project narrows the API list. + const continueButton = screen.getByRole('button', { + name: 'Go to API Level', + }); + expect(continueButton).toBeDisabled(); + + await user.click(selectFor('Project')); + await user.click(optionFor(/Retail APIs/)); + await user.click(selectFor('API')); + await user.click(optionFor(/Orders API/)); + await user.click(continueButton); + + expect( + screen.getByText( + `at ${routes.apiDeploy(ORG, PROJECT, 'orders-api')}` + ) + ).toBeInTheDocument(); + }); + + it('asks only for the API when the route already has a project', () => { + renderGate( + routes.apiDeploy(ORG, PROJECT, null), + + page body + , + makeConsoleScope() + ); + + expect(selectFor('API')).toBeInTheDocument(); + expect( + screen.queryByRole('combobox', { name: 'Project' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/portals/api-control-plane/src/scope/ScopeGate.tsx b/portals/api-control-plane/src/scope/ScopeGate.tsx new file mode 100644 index 0000000000..2cc6d43334 --- /dev/null +++ b/portals/api-control-plane/src/scope/ScopeGate.tsx @@ -0,0 +1,364 @@ +/* + * 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, type ReactNode } from 'react'; +import { + Box, + Button, + Card, + CardContent, + ComplexSelect, + FormControl, + FormLabel, + MenuItem, + PageContent, + Select, + Stack, + Typography, +} from '@wso2/oxygen-ui'; +import { Boxes, Layers } from '@wso2/oxygen-ui-icons-react'; +import { useNavigate } from 'react-router-dom'; + +import { useRestApis } from '../api/resources/restApis'; +import { ErrorState, LoadingState } from '../components/StateViews'; +import { routes, type ApiPathBuilder } from '../routes/paths'; +import { useConsoleScope } from './ConsoleScopeContext'; +import { FormattedMessage } from 'react-intl'; + +/** The scope a page needs before it can render anything meaningful. */ +export type RequiredScope = 'project' | 'api'; + +export type ScopeGateProps = { + children: ReactNode; + /** + * Sentence naming what this page manages and at which level, e.g. + * `"APIs are created and managed at the project level."` Supplied per page so + * the copy reads naturally (a generated one can't get singular/plural right). + */ + prompt?: string; + /** Scope the page needs. */ + requires: RequiredScope; + /** + * This page's own path builder —> pass the `routes.*` function the page is + * registered under (e.g. `routes.apiDeploy`). Called with the handles the + * user picks, so submitting lands on this same page, now fully scoped. + */ + to: ApiPathBuilder; +}; + +/** + * Renders `children` only once the route carries the scope the page needs, + * and a scope picker until then. + * + * Every sidebar item is always visible, including ones for pages that live + * deeper than the current scope — clicking Deploy from an org-level page is a + * normal thing to do. Those items link to the page's *scope-less alias* (see + * `ScopeHandle` in `routes/paths.ts`), so the page mounts, this gate takes + * over, and picking a project (and API) navigates to the fully-scoped URL + * where `children` render. The alternative — hiding the item until scope + * happens to be right — leaves the user with no way to reach the page at all. + */ +export function ScopeGate({ children, prompt, requires, to }: ScopeGateProps) { + const { isApiScope, isProjectScope } = useConsoleScope(); + const satisfied = requires === 'api' ? isApiScope : isProjectScope; + + // Picker in separate component so API-list query only mounts when gate is closed. + if (satisfied) return <>{children}; + return ; +} + +const DEFAULT_PROMPT: Record = { + api: 'This page is available at the API level.', + project: 'This page is available at the project level.', +}; + +/** Prevents long names from widening the option row; `minWidth: 0` enables ellipsis. */ +const OPTION_ROW_SX = { minWidth: 0, overflow: 'hidden' } as const; + +/** Same clipping for the value the closed Select renders. */ +const SELECT_VALUE_SX = { + '& .MuiSelect-select': { minWidth: 0, overflow: 'hidden' }, +} as const; + +function ScopeSelection({ + prompt, + requires, + to, +}: Omit) { + const navigate = useNavigate(); + const { isLoading, isProjectScope, params, projects, projectsError, organization, organizations } = + useConsoleScope(); + // When only the API is missing, the project stays fixed at the route's own, + // switching project is the header switcher's job, not this card's. + const [chosenProject, setChosenProject] = useState( + params.projectHandler ?? '' + ); + const [chosenApi, setChosenApi] = useState(''); + + const needsApi = requires === 'api'; + const apisQuery = useRestApis( + {}, + { projectId: needsApi ? chosenProject || undefined : undefined } + ); + const apis = apisQuery.data?.list ?? []; + + if (projectsError) { + return ( + + + + ); + } + if (isLoading && projects.length === 0) { + return ; + } + + const orgHandle = params.orgHandle ?? ''; + const canContinue = Boolean(chosenProject && (!needsApi || chosenApi)); + const submit = () => { + if (!canContinue) return; + // `replace` keeps Back on the previous page. + navigate(to(orgHandle, chosenProject, chosenApi || null), { + replace: true, + }); + }; + + return ( + <> + + + + + + {prompt ?? DEFAULT_PROMPT[requires]} + + + {needsApi + ? + : } + + + + + {projects.length === 0 ? ( + + + + + + + ) : ( + + {!isProjectScope && ( + + {/* + `id` here and `labelId` below: a bare FormLabel is not + associated with the Select, which leaves the combobox with + no accessible name for screen readers (and nothing for a + test to query it by). + */} + + + + + + + )} + + {needsApi && ( + + + + + + + )} + + + + )} + + + + {needsApi && + chosenProject && + !apisQuery.isPending && + apis.length === 0 && ( + + + + )} + + + + ); +} diff --git a/portals/api-control-plane/src/scope/consoleRouteParams.ts b/portals/api-control-plane/src/scope/consoleRouteParams.ts new file mode 100644 index 0000000000..28bd3bf58a --- /dev/null +++ b/portals/api-control-plane/src/scope/consoleRouteParams.ts @@ -0,0 +1,81 @@ +/* + * 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 { SELECT_SCOPE_SEGMENT } from '../routes/paths'; +import type { ConsoleRouteParams } from './ConsoleScopeContext'; + +/** + * Segments that sit where a handle would but name a page, not a resource. + * + * `routes.newApi` is `.../apis/new`, so without this the create page reads back + * as an API whose handle is the literal `new` — which would put a bogus entry in + * the header's API switcher and the breadcrumb trail, flip `isApiScope` on while + * the API does not exist yet, and fire a detail request for it. `select-scope` is + * reserved for the same reason (`SELECT_SCOPE_SEGMENT`), though the builders + * already keep it out of a handle position by construction. + */ +const RESERVED_HANDLE_SEGMENTS = new Set([SELECT_SCOPE_SEGMENT, 'new']); + +/** + * The handle one segment past `marker`, or `undefined` when that slot is absent + * or holds a reserved segment rather than a real handle. + */ +const handleAfter = ( + segments: string[], + marker: string +): string | undefined => { + const markerIndex = segments.indexOf(marker); + if (markerIndex < 0) return undefined; + + const candidate = segments[markerIndex + 1]; + if (!candidate || RESERVED_HANDLE_SEGMENTS.has(candidate)) return undefined; + return candidate; +}; + +/** + * Reads scope handles out of a pathname positionally: the segment after + * `organizations` is the org handle, the one after `projects` the project, the + * one after `apis` the API. + * + * `ConsoleScopeProvider` needs this because it is mounted as a pathless layout + * route, where `useParams()` sees only the params of the branch matched so far — + * not the leaf page's `:projectHandler`/`:apiHandler`. + * + * Being positional, it also constrains what a URL may look like: a page's + * scope-less alias must not leave a `projects`/`apis` segment with the page's own + * suffix behind it, or that suffix is read back as a handle. This is exactly why + * those aliases carry `SELECT_SCOPE_SEGMENT` instead of dropping segments — see + * `projectPath` in `routes/paths.ts`, and the round-trip test beside it. + * + * A page whose suffix genuinely does sit in a handle slot — `routes.newApi`, at + * `.../apis/new` — is excluded by `RESERVED_HANDLE_SEGMENTS` instead. + */ +export const getRouteParamsFromPathname = ( + pathname: string +): ConsoleRouteParams => { + const segments = pathname.split('/').filter(Boolean); + if (segments.indexOf('organizations') < 0) return {}; + + return { + apiHandler: handleAfter(segments, 'apis'), + deploymentId: handleAfter(segments, 'deployments'), + environmentId: handleAfter(segments, 'environments'), + orgHandle: handleAfter(segments, 'organizations'), + projectHandler: handleAfter(segments, 'projects'), + }; +}; diff --git a/portals/api-control-plane/src/test/mockAuthState.ts b/portals/api-control-plane/src/test/mockAuthState.ts index 8f2cafbade..d6bfd9918f 100644 --- a/portals/api-control-plane/src/test/mockAuthState.ts +++ b/portals/api-control-plane/src/test/mockAuthState.ts @@ -18,7 +18,7 @@ import { vi } from 'vitest'; -import type { AuthState, AuthStatus } from '../features/auth/authTypes'; +import type { AuthState, AuthStatus } from '../contexts/auth/authTypes'; /** * Builds a complete `AuthState` for tests, with every callback as a `vi.fn()` diff --git a/portals/api-control-plane/src/test/mockScope.ts b/portals/api-control-plane/src/test/mockScope.ts index d389969e89..007a34a67a 100644 --- a/portals/api-control-plane/src/test/mockScope.ts +++ b/portals/api-control-plane/src/test/mockScope.ts @@ -16,7 +16,7 @@ * under the License. */ -import { getApiCapabilities } from '../features/apis/apiCapabilities'; +import { getApiCapabilities } from '../pages/appShell/appShellPages/apis/utils/apiCapabilities'; import { organizations, projects } from '../api/mocks/data'; import type { ConsoleScope } from '../scope/ConsoleScopeProvider'; @@ -32,8 +32,8 @@ export function makeConsoleScope( const project = overrides.project ?? projects[0]; const component = overrides.component; const params = { - orgHandle: organization?.handle, - projectHandler: project?.handler, + orgHandle: organization?.id, + projectHandler: project?.id, ...overrides.params, }; return { @@ -42,7 +42,7 @@ export function makeConsoleScope( activeScope: { orgHandle: params.orgHandle, projectHandler: params.projectHandler, - apiHandler: params.apiHandler ?? component?.handler, + apiHandler: params.apiHandler ?? component?.id, }, capabilities: getApiCapabilities(component), component, diff --git a/portals/api-control-plane/src/test/utils.tsx b/portals/api-control-plane/src/test/utils.tsx index 896d95496a..d9594c015b 100644 --- a/portals/api-control-plane/src/test/utils.tsx +++ b/portals/api-control-plane/src/test/utils.tsx @@ -30,8 +30,8 @@ import { type ApiClient, } from '../api/ApiClientProvider'; import { NotificationProvider } from '../components/Notifications'; -import { AuthStateContext } from '../features/auth/AuthStateContext'; -import type { AuthState } from '../features/auth/authTypes'; +import { AuthStateContext } from '../contexts/auth/AuthStateContext'; +import type { AuthState } from '../contexts/auth/authTypes'; import { ConsoleScopeContext, type ConsoleScope, diff --git a/portals/api-control-plane/src/theme/index.ts b/portals/api-control-plane/src/theme/index.ts new file mode 100644 index 0000000000..35398e5f50 --- /dev/null +++ b/portals/api-control-plane/src/theme/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { + glassSurfaceSx, + hairline, + interactiveCardSx, + stickyBottomBarSx, +} from './receipes'; + +export { glassSurfaceSx, hairline, interactiveCardSx, stickyBottomBarSx }; diff --git a/portals/api-control-plane/src/theme/receipes.ts b/portals/api-control-plane/src/theme/receipes.ts new file mode 100644 index 0000000000..09e73803ac --- /dev/null +++ b/portals/api-control-plane/src/theme/receipes.ts @@ -0,0 +1,114 @@ +/* + * 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. + */ + +// src/theme/recipes.ts +// +// Shared style recipes — the middle tier between the Oxygen theme and one-off +// layout `sx`. +// +// 1. Theme — global design decisions, owned by @wso2/oxygen-ui (and any +// app-level override registered in ./themes). +// 2. Recipes — repeated multi-property treatments that are too +// instance-specific to be a global component override. THIS +// FILE. One definition, imported by every call site. +// 3. Local `sx` — layout only (flex, gap, grid columns, min/max sizing). +// +// Everything here resolves through theme tokens. No colour, radius, blur or +// border literals belong in this file or in any call site. + +import { alpha, type Theme } from '@wso2/oxygen-ui'; + +/** + * The `border` shorthand for a one-pixel rule, from `theme.border` rather than + * a `'1px solid'` literal. Pair it with a `borderColor` token — the colour is + * the part that actually varies between light, dark and high-contrast themes. + */ +export const hairline = (theme: Theme) => + `${theme.border.width} ${theme.border.style}`; + +/** Blur radius behind a glass surface. One value, so every pane matches. */ +const GLASS_BLUR = '14px'; + +/** + * Translucent "glass" surface: what sits behind the element shows through, + * blurred, instead of the flat `background.paper` fill. + * + * Both gradient stops derive from `background.paper`, so the sheen that makes + * it read as glass stays correct in light, dark and high-contrast themes + * without branching on the palette mode. Compose it as + * `sx={(theme) => ({ ...glassSurfaceSx(theme), ...layout })}`. + */ +export const glassSurfaceSx = (theme: Theme) => + ({ + backdropFilter: `blur(${GLASS_BLUR})`, + WebkitBackdropFilter: `blur(${GLASS_BLUR})`, + backgroundColor: 'transparent', + backgroundImage: `linear-gradient(135deg, ${alpha( + theme.palette.background.paper, + 0.6 + )}, ${alpha(theme.palette.background.paper, 0.25)})`, + border: hairline(theme), + borderColor: alpha(theme.palette.divider, 0.6), + borderRadius: 1, + }) as const; + +/** + * Hover treatment for a card that behaves as a button (the whole surface + * navigates). Deliberately NOT a global `MuiCard` override: non-interactive + * cards — the tab shell, the save bar, the Explore More panel — must not lift + * under the cursor. + */ +export const interactiveCardSx = { + cursor: 'pointer', + transition: + 'transform .18s ease, border-color .18s ease, box-shadow .18s ease', + '&:hover': { + borderColor: 'primary.main', + boxShadow: 4, + transform: 'translateY(-3px)', + }, +} as const; + +/** + * Upward elevation for a bar that floats over scrolling content (the develop + * tabs' sticky save bar). `theme.shadows` is entirely downward-casting, and the + * duplicated @mui/material install blocks adding a typed custom theme token, so + * this lives here as the single definition rather than inline at the call site. + */ +export const overlayBarShadow = '0 -2px 10px rgba(0, 0, 0, 0.16)'; + +/** + * A bar pinned to the bottom of a page's scroll area — the develop tabs' save + * bar, a list page's pagination row. + * + * Blurred, bordered and shadowed upward so the content scrolling underneath + * stays readable behind it instead of colliding with it. It deliberately does + * **not** set a `bottom` offset: the app footer shares this scroll area, so each + * call site pairs this with `bottom: useFooterHeight()` rather than 0, or the bar + * ends up behind the footer. + */ +export const stickyBottomBarSx = (theme: Theme) => + ({ + backdropFilter: 'blur(10px)', + WebkitBackdropFilter: 'blur(10px)', + borderColor: 'divider', + borderTop: hairline(theme), + boxShadow: overlayBarShadow, + position: 'sticky', + zIndex: theme.zIndex.appBar, + }) as const; diff --git a/portals/api-control-plane/src/utils/errors/errorClassification.ts b/portals/api-control-plane/src/utils/errors/errorClassification.ts new file mode 100644 index 0000000000..b88d7c6dd4 --- /dev/null +++ b/portals/api-control-plane/src/utils/errors/errorClassification.ts @@ -0,0 +1,56 @@ +/* + * 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. + */ + +/** + * Substrings browsers use when a dynamic `import()` cannot be fetched. Matched + * case-insensitively because the wording differs per engine: Chrome says + * "Failed to fetch dynamically imported module", Firefox "error loading + * dynamically imported module", Safari "Importing a module script failed". + */ +const CHUNK_LOAD_MESSAGES = [ + 'failed to fetch dynamically imported module', + 'error loading dynamically imported module', + 'importing a module script failed', + 'unable to preload css', + 'loading chunk', + 'loading css chunk', +]; + +/** + * Whether a caught error is a failed code-split chunk fetch rather than a fault + * in the page's own logic. + * + * Worth separating because the two need opposite recovery actions. Every page + * in `AppRoutes` is `lazy()`, so after a deploy an already-open tab asks for a + * chunk hash the server no longer has: the code is fine, the *bundle* the tab + * is running is stale. Reloading fetches the new index and fixes it, whereas + * for a genuine render fault reloading the same URL just reproduces it — which + * is why the generic fallback offers "try again"/"go home" instead. + */ +export const isChunkLoadError = (error: unknown): boolean => { + if (!error) return false; + + const named = (error as { name?: unknown }).name; + if (named === 'ChunkLoadError') return true; + + const message = (error as { message?: unknown }).message; + if (typeof message !== 'string') return false; + + const normalized = message.toLowerCase(); + return CHUNK_LOAD_MESSAGES.some((needle) => normalized.includes(needle)); +};