diff --git a/docs/superpowers/plans/2026-06-10-grouped-api-names-selector.md b/docs/superpowers/plans/2026-06-10-grouped-api-names-selector.md new file mode 100644 index 0000000..835cef3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-grouped-api-names-selector.md @@ -0,0 +1,780 @@ +# Grouped (by-module) API Names selector — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the flat 765-button API Names picker on the Application Edit page with a collapsible accordion grouped by module, with grouping data generated and served by the backend and gracefully derivable on the frontend. + +**Architecture:** The backend catalog generator emits a grouped structure alongside the existing flat list; the applications controller returns both. The frontend service reads `groups` when present and otherwise derives them client-side (same `module = prefix-before-first-dot` rule), so the UI works regardless of backend deploy order. The Application Edit page renders an accordion (collapsed by default) with per-module select-all and a filter that auto-expands matches. + +**Tech Stack:** Backend — NestJS + a standalone TS generator script run via Bun. Frontend — React 18 + TypeScript (Vite), shadcn/ui, lucide-react. + +**Spec:** `docs/superpowers/specs/2026-06-10-grouped-api-names-selector-design.md` + +> **Testing note (read before starting):** This is the established reality of these repos, not an omission. +> - **Frontend** has **no TS unit-test runner** (Vitest is a pending project item); the only runners are `node --test` for `.mjs` script helpers and Playwright e2e (which needs a live authed backend). Frontend verification gates in this plan are therefore **`CI=true bun run build`** (strict `tsc` typecheck + `vite-plugin-eslint` with warnings-as-errors) plus scripted manual verification at the running dev server. +> - **Backend generator** lives at repo root with no test runner; its verification is **running the generator and asserting the generated output** (deterministic). +> - **Backend controller** verification is **`nest build`** typecheck; the live endpoint is confirmed by the user after their DEV deploy. +> Pure logic is extracted into named functions for clarity even though there is no runner to unit-test them in isolation. + +--- + +## File Structure + +**Backend repo (`/Users/samutpra/GitHub/carmensoftware-organize/carmen-turborepo-backend-v2`):** +- Modify: `scripts/generate-app-api-catalog/run.ts` — also build + emit the grouped export. +- Regenerate (do not hand-edit): `apps/backend-gateway/src/platform/applications/app-api-catalog.generated.ts` — gains `APP_API_CATALOG_GROUPS`. +- Modify: `apps/backend-gateway/src/platform/applications/applications.controller.ts` — import + return `groups`; document it in `@ApiResponse`. + +**Frontend repo (`/Users/samutpra/GitHub/carmensoftware-organize/carmen-platform`):** +- Modify: `src/types/index.ts` — add `ApiCatalogGroup`. +- Create: `src/utils/apiCatalog.ts` — pure helpers `groupApiNames()` + `actionOf()`. +- Modify: `src/services/applicationService.ts` — `getApiCatalog()` returns `{ groups, api_names }` with client-side fallback. +- Modify: `src/pages/ApplicationEdit.tsx` — accordion edit view, grouped read-only view, state + handlers. + +> **Sequencing:** Tasks 1–2 (backend) and Tasks 3–8 (frontend) are independent. The frontend works against either the new or old backend response thanks to the Task 4 fallback. If splitting work, the frontend can ship first. + +--- + +## Task 1: Backend generator emits grouped catalog + +**Files:** +- Modify: `carmen-turborepo-backend-v2/scripts/generate-app-api-catalog/run.ts` +- Regenerate: `carmen-turborepo-backend-v2/apps/backend-gateway/src/platform/applications/app-api-catalog.generated.ts` + +- [ ] **Step 1: Replace the output-building tail of `run.ts`** + +Open `scripts/generate-app-api-catalog/run.ts`. Replace everything from `const sorted = Array.from(names).sort();` to the end of the file with: + +```ts +const sorted = Array.from(names).sort(); + +// Group by module = the prefix before the first '.'. A name with no dot forms +// its own single-entry group keyed by the full string. Modules are sorted; each +// group's api_names keep the already-sorted order. +const groupsMap = new Map(); +for (const name of sorted) { + const dot = name.indexOf('.'); + const moduleName = dot === -1 ? name : name.slice(0, dot); + const list = groupsMap.get(moduleName) ?? []; + list.push(name); + groupsMap.set(moduleName, list); +} +const groups = Array.from(groupsMap.keys()) + .sort() + .map((moduleName) => ({ module: moduleName, api_names: groupsMap.get(moduleName)! })); + +const flatBody = sorted.map((name) => ` '${name}',`).join('\n'); +const groupsBody = groups + .map( + (g) => + ` { module: '${g.module}', api_names: [${g.api_names + .map((n) => `'${n}'`) + .join(', ')}] },`, + ) + .join('\n'); + +const out = `/** + * Auto-generated catalog of guarded api_name values. Regenerate with: bun run scripts/generate-app-api-catalog/run.ts + * catalog ของ api_name ที่ถูกป้องกันซึ่งสร้างอัตโนมัติ สร้างใหม่ด้วย: bun run scripts/generate-app-api-catalog/run.ts + */ +export const APP_API_CATALOG: readonly string[] = [ +${flatBody} +] as const; + +export const APP_API_CATALOG_GROUPS: readonly { module: string; api_names: readonly string[] }[] = [ +${groupsBody} +] as const; +`; + +fs.writeFileSync(OUTPUT, out, 'utf-8'); +console.log( + `Wrote ${sorted.length} api_name entries in ${groups.length} modules to ${OUTPUT}`, +); +``` + +- [ ] **Step 2: Run the generator** + +Run (from the backend repo root): +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-turborepo-backend-v2 +bun run scripts/generate-app-api-catalog/run.ts +``` +Expected stdout: a line of the form `Wrote api_name entries in modules to .../app-api-catalog.generated.ts` where both `` and `` are non-zero (at time of writing, ~765 entries / ~120 modules — numbers may differ if guards changed since). + +- [ ] **Step 3: Assert the generated output is grouped correctly** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-turborepo-backend-v2 +grep -c "export const APP_API_CATALOG_GROUPS" apps/backend-gateway/src/platform/applications/app-api-catalog.generated.ts +grep -n "module: 'cluster'" apps/backend-gateway/src/platform/applications/app-api-catalog.generated.ts +``` +Expected: first command prints `1`; second prints a line like +`{ module: 'cluster', api_names: ['cluster.create', 'cluster.delete', ...] }`. +Also confirm `export const APP_API_CATALOG: readonly string[]` is still present (flat list unchanged): +```bash +grep -c "export const APP_API_CATALOG:" apps/backend-gateway/src/platform/applications/app-api-catalog.generated.ts +``` +Expected: `1`. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-turborepo-backend-v2 +git add scripts/generate-app-api-catalog/run.ts apps/backend-gateway/src/platform/applications/app-api-catalog.generated.ts +git commit -m "feat(applications): emit grouped api_name catalog from generator" +``` + +--- + +## Task 2: Backend controller returns groups + +**Files:** +- Modify: `carmen-turborepo-backend-v2/apps/backend-gateway/src/platform/applications/applications.controller.ts` + +- [ ] **Step 1: Import the grouped export** + +Replace line 46: +```ts +import { APP_API_CATALOG } from './app-api-catalog.generated'; +``` +with: +```ts +import { APP_API_CATALOG, APP_API_CATALOG_GROUPS } from './app-api-catalog.generated'; +``` + +- [ ] **Step 2: Extend the `@ApiResponse` schema and the response body** + +In the `getApiCatalog` handler block, update the `@ApiResponse` `schema.properties` to add `groups`, and update the `respond` call. + +Replace this `schema` object: +```ts + schema: { + type: 'object', + properties: { + api_names: { + type: 'array', + items: { type: 'string' }, + example: ['activityLog.findAll', 'application.apiCatalog', 'purchaseRequest.create'], + }, + }, + }, +``` +with: +```ts + schema: { + type: 'object', + properties: { + api_names: { + type: 'array', + items: { type: 'string' }, + example: ['activityLog.findAll', 'application.apiCatalog', 'purchaseRequest.create'], + }, + groups: { + type: 'array', + items: { + type: 'object', + properties: { + module: { type: 'string' }, + api_names: { type: 'array', items: { type: 'string' } }, + }, + }, + example: [ + { module: 'cluster', api_names: ['cluster.create', 'cluster.findAll'] }, + ], + }, + }, + }, +``` +Then replace the response line: +```ts + this.respond(res, { api_names: APP_API_CATALOG }); +``` +with: +```ts + this.respond(res, { api_names: APP_API_CATALOG, groups: APP_API_CATALOG_GROUPS }); +``` + +- [ ] **Step 3: Typecheck the gateway build** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-turborepo-backend-v2/apps/backend-gateway +bun run build +``` +Expected: `nest build` completes with no TypeScript errors. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-turborepo-backend-v2 +git add apps/backend-gateway/src/platform/applications/applications.controller.ts +git commit -m "feat(applications): return module groups from api-catalog endpoint" +``` + +> The live endpoint (`GET /api-system/applications/api-catalog` now returning `groups`) is verified by the user after the DEV deploy. The frontend does not depend on this deploy (Task 4 fallback). + +--- + +## Task 3: Frontend type + pure grouping helpers + +**Files:** +- Modify: `carmen-platform/src/types/index.ts` +- Create: `carmen-platform/src/utils/apiCatalog.ts` + +- [ ] **Step 1: Add the `ApiCatalogGroup` type** + +In `src/types/index.ts`, immediately after the `Application` interface (after its closing `}` on line 57), add: +```ts + +// A module group of api_names, e.g. { module: 'cluster', api_names: ['cluster.create', ...] }. +// Returned by the api-catalog endpoint (or derived client-side from a flat api_names list). +export interface ApiCatalogGroup { + module: string; + api_names: string[]; +} +``` + +- [ ] **Step 2: Create the pure helpers** + +Create `src/utils/apiCatalog.ts`: +```ts +import type { ApiCatalogGroup } from '../types'; + +/** + * The module an api_name belongs to: the prefix before the first '.'. + * A name with no dot is its own module. + */ +export const moduleOf = (apiName: string): string => { + const dot = apiName.indexOf('.'); + return dot === -1 ? apiName : apiName.slice(0, dot); +}; + +/** + * The action portion of an api_name: the text after the first '.'. + * A name with no dot returns the whole string. + */ +export const actionOf = (apiName: string): string => { + const dot = apiName.indexOf('.'); + return dot === -1 ? apiName : apiName.slice(dot + 1); +}; + +/** + * Group a flat list of api_names by module. Modules are sorted alphabetically; + * each group's api_names are sorted. Mirrors the backend generator's rule so a + * client-derived grouping is identical to a server-provided one. + */ +export const groupApiNames = (apiNames: string[]): ApiCatalogGroup[] => { + const map = new Map(); + for (const name of apiNames) { + const mod = moduleOf(name); + const list = map.get(mod) ?? []; + list.push(name); + map.set(mod, list); + } + return Array.from(map.keys()) + .sort() + .map((module) => ({ module, api_names: (map.get(module) ?? []).slice().sort() })); +}; +``` + +- [ ] **Step 3: Typecheck** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +CI=true bun run build +``` +Expected: build succeeds (no TS/eslint errors). The new file is not yet imported anywhere, which is fine. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +git add src/types/index.ts src/utils/apiCatalog.ts +git commit -m "feat(applications): add ApiCatalogGroup type and grouping helpers" +``` + +--- + +## Task 4: Service returns groups with client-side fallback + +**Files:** +- Modify: `carmen-platform/src/services/applicationService.ts` + +- [ ] **Step 1: Import the type and helper** + +At the top of `src/services/applicationService.ts`, update the type import (currently line 3) to add `ApiCatalogGroup`: +```ts +import type { PaginateParams, Application, ApplicationWritePayload, ApiListResponse, ApiCatalogGroup } from '../types'; +``` +Add a new import line directly below the existing imports: +```ts +import { groupApiNames } from '../utils/apiCatalog'; +``` + +- [ ] **Step 2: Replace `getApiCatalog`** + +Replace the entire `getApiCatalog` method (currently lines ~51–59, the comment block + method) with: +```ts + // Catalog of selectable api_name values. The endpoint returns + // { api_names: string[], groups?: { module, api_names }[] } (optionally inside the + // standard { data } envelope). Tolerate a bare string[] too. When the backend has + // not yet been redeployed with `groups`, derive the same grouping client-side from + // api_names (identical split rule), so the UI works regardless of deploy order. + getApiCatalog: async (): Promise<{ groups: ApiCatalogGroup[]; api_names: string[] }> => { + const response = await api.get('/api-system/applications/api-catalog'); + const body = response.data?.data ?? response.data; + + const api_names: string[] = Array.isArray(body) + ? body + : Array.isArray(body?.api_names) + ? body.api_names + : []; + + const rawGroups = body?.groups; + const validGroups: ApiCatalogGroup[] = + Array.isArray(rawGroups) && + rawGroups.every( + (g: unknown) => + !!g && + typeof (g as ApiCatalogGroup).module === 'string' && + Array.isArray((g as ApiCatalogGroup).api_names), + ) + ? (rawGroups as ApiCatalogGroup[]) + : groupApiNames(api_names); + + return { groups: validGroups, api_names }; + }, +``` + +- [ ] **Step 3: Typecheck** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +CI=true bun run build +``` +Expected: build succeeds. (`ApplicationEdit.tsx` still calls `getApiCatalog().then(setCatalog)` where `setCatalog` expects `string[]`, so this will now be a **type error** — that is expected and fixed in Task 5. If the build fails only on `ApplicationEdit.tsx` `setCatalog` type mismatch, proceed to Task 5; do not "fix" it here.) + +> Note: if you prefer a green build at every commit, do Task 4 and Task 5 Step 1–2 together before building. Either way, commit after Task 5's build passes. To keep this commit isolated, commit now and accept that the working tree typechecks green only after Task 5. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +git add src/services/applicationService.ts +git commit -m "feat(applications): getApiCatalog returns module groups with fallback" +``` + +--- + +## Task 5: ApplicationEdit — catalog state + handlers + +**Files:** +- Modify: `carmen-platform/src/pages/ApplicationEdit.tsx` + +- [ ] **Step 1: Update imports** + +At the top of the file, add the `ChevronRight` and `ChevronDown` icons to the existing lucide-react import (line 14). Change: +```ts +import { ArrowLeft, Save, Code, Copy, Check, Pencil, X, Loader2, Search } from 'lucide-react'; +``` +to: +```ts +import { ArrowLeft, Save, Code, Copy, Check, Pencil, X, Loader2, Search, ChevronRight, ChevronDown } from 'lucide-react'; +``` +Add these two imports directly below the `Skeleton` import (after line 19): +```ts +import { groupApiNames, actionOf } from '../utils/apiCatalog'; +import type { ApiCatalogGroup } from '../types'; +``` + +- [ ] **Step 2: Replace the catalog state** + +Replace these two state lines (currently lines 51–52): +```ts + const [catalog, setCatalog] = useState([]); + const [catalogFailed, setCatalogFailed] = useState(false); +``` +with: +```ts + const [catalogGroups, setCatalogGroups] = useState([]); + const [catalogNames, setCatalogNames] = useState([]); + const [catalogFailed, setCatalogFailed] = useState(false); + const [expandedModules, setExpandedModules] = useState>(new Set()); +``` + +- [ ] **Step 3: Update the catalog fetch effect** + +Replace the catalog `useEffect` (currently lines 81–85): +```ts + useEffect(() => { + applicationService.getApiCatalog() + .then(setCatalog) + .catch((err) => { setCatalogFailed(true); devLog('Failed to load api catalog:', err); }); + }, []); +``` +with: +```ts + useEffect(() => { + applicationService.getApiCatalog() + .then(({ groups, api_names }) => { setCatalogGroups(groups); setCatalogNames(api_names); }) + .catch((err) => { setCatalogFailed(true); devLog('Failed to load api catalog:', err); }); + }, []); +``` + +- [ ] **Step 4: Add module handlers** + +Directly below the existing `toggleApiName` function (after its closing `};` on line 140), add: +```ts + const toggleModule = (module: string) => { + setExpandedModules(prev => { + const next = new Set(prev); + if (next.has(module)) next.delete(module); + else next.add(module); + return next; + }); + }; + + // Select-all / deselect-all for one module. If every api_name in the module is + // already selected, remove them all; otherwise add the missing ones. + const toggleModuleSelection = (groupNames: string[]) => { + setFormData(prev => { + const allSelected = groupNames.every(n => prev.api_names.includes(n)); + const api_names = allSelected + ? prev.api_names.filter(n => !groupNames.includes(n)) + : Array.from(new Set([...prev.api_names, ...groupNames])); + return { ...prev, api_names }; + }); + setError(''); + }; + + const expandAll = (modules: string[]) => setExpandedModules(new Set(modules)); + const collapseAll = () => setExpandedModules(new Set()); +``` + +- [ ] **Step 5: Typecheck** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +CI=true bun run build +``` +Expected: build **fails** with errors in the JSX that still references the removed `catalog` variable (the `api_names` render block, lines ~383–412 in the original). This is expected — Task 6 replaces that JSX. Do not patch around it here. + +> If you committed Task 4 separately and want a green checkpoint, complete Task 6 before building/committing. The build only returns green after Task 6. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +git add src/pages/ApplicationEdit.tsx +git commit -m "feat(applications): add grouped catalog state and module handlers" +``` + +--- + +## Task 6: ApplicationEdit — accordion edit view + +**Files:** +- Modify: `carmen-platform/src/pages/ApplicationEdit.tsx` + +- [ ] **Step 1: Replace the editing branch of the api_names selector** + +In the `{!formData.allow_all && (...)}` block, find the editing branch. It is the inner `catalogFailed ? () : (
...
)` ternary — the non-failed `(...)` arm spans the search input + the `rounded-md border ... max-h-60` catalog box (originally lines ~360–415). + +Keep the `catalogFailed ? ()` arm exactly as-is. Replace **only** the `: (` non-failed arm (the entire `
` that contains the search box and the flat button cloud) with: + +```tsx + ) : ( +
+ {/* Filter + total + expand/collapse-all controls */} +
+
+ + setApiSearch(e.target.value)} + placeholder="Filter by module or api_name..." + className="pl-9 pr-9" + aria-label="Filter API names" + /> + {apiSearch && ( + + )} +
+
+ + {catalogGroups.length === 0 ? ( +
+

Loading catalog…

+
+ ) : (() => { + const q = apiSearch.trim().toLowerCase(); + // A group matches if its module name matches; then only matching + // api_names show. If the module name itself matches, show all of it. + const visibleGroups = catalogGroups + .map((g) => { + if (!q) return g; + const moduleMatch = g.module.toLowerCase().includes(q); + if (moduleMatch) return g; + const api_names = g.api_names.filter((n) => n.toLowerCase().includes(q)); + return api_names.length ? { ...g, api_names } : null; + }) + .filter((g): g is ApiCatalogGroup => g !== null); + + if (visibleGroups.length === 0) { + return ( +
+

No API names matching “{apiSearch}”

+
+ ); + } + + const allVisibleModules = visibleGroups.map((g) => g.module); + return ( + <> +
+ +
+
+ {visibleGroups.map((g) => { + // A search auto-expands matching groups; otherwise honor manual state. + const expanded = q ? true : expandedModules.has(g.module); + const selectedCount = g.api_names.filter((n) => formData.api_names.includes(n)).length; + const allSelected = selectedCount === g.api_names.length; + return ( +
+
+ + +
+ {expanded && ( +
+ {g.api_names.map((api) => { + const selected = formData.api_names.includes(api); + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ + ); + })()} +
+ ) +``` + +- [ ] **Step 2: Fix the "N selected" footer guard** + +The footer line (originally lines ~427–429) reads: +```tsx + {editing && !catalogFailed && ( +

{formData.api_names.length} selected

+ )} +``` +Leave it unchanged — `formData.api_names.length` is still the right total. + +- [ ] **Step 3: Typecheck + lint** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +CI=true bun run build +``` +Expected: build **passes** (no TS or eslint errors). The previously-failing references to `catalog` are now gone. + +- [ ] **Step 4: Manual verification (dev server)** + +Run `bun start` (dev server on :3100), log in, open an existing application at `/applications//edit`, and click **Edit**. Verify: +- The API Names section shows a filter box + an "Expand all" button + a bordered, scrollable list of module rows (collapsed), each with a `selected/total` badge and an "All" button. +- Clicking a module row's chevron/name expands it to show action-only buttons (e.g. `create`, `findAll`); hovering shows the full `api_name` as a tooltip. +- Clicking an action button toggles it (fills in + shows `X`); the module badge count and the bottom "N selected" both update. +- Clicking **All** on a module selects every action in it; the button flips to **None**; clicking **None** clears them. +- Typing in the filter (e.g. `cluster`) hides non-matching modules and auto-expands matches; typing an action fragment (e.g. `findAll`) shows only matching actions within their modules; clearing the filter restores collapsed state. +- Toggling **Allow all APIs** hides the whole selector; unchecking restores it. +- Save, confirm a success toast, and that the read-only view reflects the selection (Task 7). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +git add src/pages/ApplicationEdit.tsx +git commit -m "feat(applications): grouped accordion API Names selector (edit view)" +``` + +--- + +## Task 7: ApplicationEdit — grouped read-only view + +**Files:** +- Modify: `carmen-platform/src/pages/ApplicationEdit.tsx` + +- [ ] **Step 1: Replace the read-only branch of the api_names selector** + +Still inside the `{!formData.allow_all && (...)}` block, find the `) : (` arm that renders when **not** `editing` — the original flat-badge block (originally lines ~416–425): +```tsx + ) : ( + formData.api_names.length === 0 ? ( +
-
+ ) : ( +
+ {formData.api_names.map((api) => ( + {api} + ))} +
+ ) + )} +``` +Replace it with a grouped read-only view: +```tsx + ) : ( + formData.api_names.length === 0 ? ( +
-
+ ) : ( +
+ {groupApiNames(formData.api_names).map((g) => ( +
+

+ {g.module} ({g.api_names.length}) +

+
+ {g.api_names.map((api) => ( + {actionOf(api)} + ))} +
+
+ ))} +
+ ) + )} +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +CI=true bun run build +``` +Expected: build passes (no TS or eslint errors). + +- [ ] **Step 3: Manual verification** + +On `/applications//edit` in **view** (not editing) mode, with an application that has several api_names across modules, verify the API Names section shows them grouped under small module subheaders (`module (count)`), each api_name as an outline badge showing the action with the full name on hover. An application with no api_names shows `-`. Toggle Edit and back to confirm both views stay consistent. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +git add src/pages/ApplicationEdit.tsx +git commit -m "feat(applications): grouped read-only API Names view" +``` + +--- + +## Task 8: Full build + final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Clean frontend build** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +CI=true bun run build +``` +Expected: production build completes with no TypeScript or eslint errors, output emitted to `build/`. + +- [ ] **Step 2: Regression sweep at the running app** + +`bun start`, then on `/applications//edit`: +- Create flow: go to `/applications/new`, the accordion appears in edit mode by default; select a few api_names across modules; create; confirm the success toast and redirect to `/applications//edit`. +- `catalogFailed` fallback: this only triggers if the catalog request errors; no action needed unless you can simulate it — confirm the code path still renders `` (unchanged). +- Debug Sheet (dev only): open it and confirm the raw response is shown (unchanged behavior). + +- [ ] **Step 3: Confirm no stray references** + +Run: +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +grep -n "setCatalog\b\|\bcatalog\b" src/pages/ApplicationEdit.tsx +``` +Expected: no matches for the old `catalog`/`setCatalog` identifiers (only `catalogGroups`, `catalogNames`, `catalogFailed`, `catalogGroups.length` should remain). `catalogNames` is currently unused by the UI but kept for the "N selected"/future use — if eslint flags it as unused under `CI=true`, either reference it or drop it; the Step 1 build is the source of truth. + +> **Decision if `catalogNames` trips no-unused-vars:** drop `catalogNames` state and the `setCatalogNames` call (use only `setCatalogGroups(groups)` in the effect). The total count comes from `formData.api_names.length`, not the catalog, so `catalogNames` is not required. Keep it only if a later need is concrete (YAGNI). + +- [ ] **Step 4: Final commit (if Step 3 required an edit)** + +```bash +cd /Users/samutpra/GitHub/carmensoftware-organize/carmen-platform +git add src/pages/ApplicationEdit.tsx +git commit -m "chore(applications): drop unused catalogNames state" +``` + +--- + +## Self-Review (completed during planning) + +- **Spec coverage:** Part 1 generator → Task 1; Part 2 controller + OpenAPI → Task 2; Part 3 types + service fallback → Tasks 3–4; Part 4 UI (accordion edit view, per-module select-all, filter auto-expand, expand/collapse-all, grouped read-only) → Tasks 5–7; build/verify → Task 8. Edge cases (dotless name, backend not redeployed, no-match filter, selected-not-in-catalog) covered by `moduleOf`/`groupApiNames` rules + the Task 4 fallback + Task 6 no-match branch; the read-only view renders any selected name regardless of catalog membership. +- **Placeholder scan:** no TBD/TODO; every code step shows full code; commands have expected output. +- **Type consistency:** `ApiCatalogGroup { module, api_names }` defined in Task 3 and used identically in Tasks 4/6/7; `getApiCatalog(): Promise<{ groups, api_names }>` in Task 4 matches the destructuring in Task 5's effect; helpers `moduleOf`/`actionOf`/`groupApiNames` used with the same signatures throughout. + +> **Note on YAGNI:** `catalogNames`/`setCatalogNames` are introduced in Task 5 but the UI total comes from `formData.api_names`. Task 8 Step 3 explicitly resolves whether to keep or drop them based on the lint result, so the plan does not ship dead state silently. diff --git a/docs/superpowers/specs/2026-06-10-grouped-api-names-selector-design.md b/docs/superpowers/specs/2026-06-10-grouped-api-names-selector-design.md new file mode 100644 index 0000000..a75ccbf --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-grouped-api-names-selector-design.md @@ -0,0 +1,159 @@ +# Grouped (by-module) API Names selector — Design + +**Date:** 2026-06-10 +**Status:** Approved, pending implementation plan +**Repos touched:** `carmen-platform` (frontend) + `carmen-turborepo-backend-v2` (backend) + +## Problem + +The Application Edit page (`/applications/:id/edit`) lets an admin pick which +`api_name` values an application is allowed to call. The picker is a single flat +list of toggle buttons fed by `GET /api-system/applications/api-catalog`, which +returns a flat `string[]`. There are **765 api_names across 120 modules**, so the +flat list is an unscannable wall of buttons. + +Every `api_name` follows a `module.action` convention (e.g. `cluster.create`, +`activityLog.findAll`); the prefix before the first `.` is the module. We want to +group the catalog by module and present it as a collapsible accordion, with the +grouping computed on the backend (authoritative) and gracefully derivable on the +frontend. + +## Decisions (from brainstorming) + +- **Grouping source:** backend + generator (most thorough). The catalog generator + emits grouped data; the controller returns it. Frontend can also derive the same + grouping from the flat list as a fallback. +- **Module rule:** `module = name.split('.')[0]`. A name with no dot becomes its own + single-entry group (full string as the module). Same rule on both sides, so + results are identical regardless of which side computes them. +- **UI:** collapsible accordion, collapsed by default, with per-module + `[selected/total]` badge, per-module "All" toggle, a filter box, and a global + total + expand/collapse-all control. +- **Read-only view:** selected api_names grouped under module subheaders. +- **No new libraries.** No change to `allow_all` behavior or the `catalogFailed` + ChipInput fallback. + +## Part 1 — Backend generator + +**File:** `carmen-turborepo-backend-v2/scripts/generate-app-api-catalog/run.ts` + +The script already walks the gateway source, collects `AppIdGuard('module.action')` +names into a `Set`, and writes a sorted flat array. Add a second derivation and a +second export: + +1. After computing `sorted` (flat, alphabetical — unchanged), build a grouped + structure: reduce `sorted` into a map `module → api_names[]` where + `module = name.includes('.') ? name.split('.')[0] : name`. Produce an array of + `{ module, api_names }` sorted by `module`, with each group's `api_names` kept in + the already-sorted order. +2. Emit **both** exports into `app-api-catalog.generated.ts`: + - `APP_API_CATALOG: readonly string[]` — unchanged, kept for back-compat. + - `APP_API_CATALOG_GROUPS: readonly { module: string; api_names: readonly string[] }[]` — new. +3. Update the console log to report module count alongside the entry count. + +The generated file's header comment (regenerate instructions) stays the same. + +## Part 2 — Backend controller + +**File:** `carmen-turborepo-backend-v2/apps/backend-gateway/src/platform/applications/applications.controller.ts` + +- Import `APP_API_CATALOG_GROUPS` alongside `APP_API_CATALOG`. +- `getApiCatalog` returns `{ api_names: APP_API_CATALOG, groups: APP_API_CATALOG_GROUPS }`. + Keeping `api_names` preserves the existing response contract for any other consumer. +- Extend the `@ApiResponse` schema to document the new `groups` property + (array of objects with `module: string` and `api_names: string[]`, with an example). + +No service/DB changes — this endpoint is static data only. + +## Part 3 — Frontend service + types + +**Files:** `carmen-platform/src/types/index.ts`, `carmen-platform/src/services/applicationService.ts` + +- Add to `types/index.ts`: + ```ts + export interface ApiCatalogGroup { + module: string; + api_names: string[]; + } + ``` +- `applicationService.getApiCatalog()` return type becomes + `Promise<{ groups: ApiCatalogGroup[]; api_names: string[] }>`. +- Parsing (tolerant, in this order): + 1. Unwrap the `{ data }` envelope as today (`response.data?.data ?? response.data`). + 2. Read `api_names` (array; tolerate a bare `string[]` body as before → `api_names`). + 3. If the body has a `groups` array, use it (validate each item has `module` + + `api_names[]`). + 4. **Else derive groups from `api_names`** by splitting on the first `.` + (dotless → own group), sorted by module. This is the deploy-order safety net: + the frontend renders grouped even before the backend redeploy lands, because the + split rule is identical to the generator's. +- Always return a non-null `api_names` (flat, for the "N selected" total and any other + use) and `groups`. + +## Part 4 — Frontend UI + +**File:** `carmen-platform/src/pages/ApplicationEdit.tsx` + +State changes: +- Replace `catalog: string[]` with `catalogGroups: ApiCatalogGroup[]` (keep a derived + flat list, or store both — flat is still needed for the total count and to know all + selectable names). `catalogFailed`, `apiSearch` unchanged. +- Add `expandedModules: Set` (or `Record`) for accordion state. + +Editing view (when `!formData.allow_all` and `!catalogFailed`), replacing the current +flat button cloud: +- **Filter box** (existing search input, kept): the query matches a group if the + **module name** matches OR any of its `api_names` match. Non-matching groups are + hidden; within a matching group only matching api_names render. While a non-empty + filter is active, matching groups are **auto-expanded**. +- **Header row:** total `N selected` (count of `formData.api_names`) + an + **Expand all / Collapse all** toggle button. +- **Per module row:** chevron (▸/▾) to expand/collapse, module name, a + `[selected/total]` count badge, and an **All** button that selects every api_name in + the module (flips to deselect-all when all are already selected). +- **Expanded group:** the module's api_names as toggle buttons (reusing the existing + selected/outline styling and the `X` on selected). Button label shows the + **action only** (text after the first `.`); the `title` attribute and the stored + value remain the full `api_name`. +- Default state: all groups **collapsed** (120 modules). + +Handlers: +- Reuse `toggleApiName(api)` unchanged. +- Add `toggleModule(module)` — expand/collapse accordion row. +- Add `selectAllInModule(module)` / deselect — adds or removes that module's full + api_name set from `formData.api_names`. + +Read-only view (not editing): +- Group the selected `formData.api_names` by module (same split rule) and render each + module as a small subheader with its selected api_names as ``. +- `-` placeholder when none selected. + +Unchanged: +- `allow_all` still hides the entire selector. +- `catalogFailed` still falls back to ``. +- The `N selected` footer line. +- Debug Sheet, validation, save/cancel, keyboard shortcuts. + +## Edge cases + +- **Dotless api_name:** becomes its own single-entry module (both sides). Consistent. +- **Backend not yet redeployed:** response has only `api_names` → frontend derives + groups itself. No visible difference. +- **Filter with no matches:** show the existing "No API names matching …" message. +- **Selected api_name no longer in catalog** (renamed/removed guard): it still lives in + `formData.api_names` and renders in the read-only grouped view; in edit mode it + simply won't appear as a toggle. (Pre-existing behavior; not regressed.) + +## Out of scope + +- Changing how `api_name`s are guarded or named in the backend. +- Persisting expand/collapse state across sessions. +- Any change to create/update write semantics (`details.add[]` mapping is untouched). + +## Build sequence + +1. Backend generator → regenerate `app-api-catalog.generated.ts`. +2. Backend controller + OpenAPI schema. +3. Frontend types + service (with client-side fallback). +4. Frontend UI accordion (edit + read-only views). +5. Verify against running app at `/applications/:id/edit`. diff --git a/src/pages/ApplicationEdit.tsx b/src/pages/ApplicationEdit.tsx index 4b7aa3b..d4c4048 100644 --- a/src/pages/ApplicationEdit.tsx +++ b/src/pages/ApplicationEdit.tsx @@ -11,12 +11,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../co import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } from '../components/ui/sheet'; import { ChipInput } from '../components/ui/chip-input'; import Can from '../components/Can'; -import { ArrowLeft, Save, Code, Copy, Check, Pencil, X, Loader2, Search } from 'lucide-react'; +import { ArrowLeft, Save, Code, Copy, Check, Pencil, X, Loader2, Search, ChevronRight, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { validateField } from '../utils/validation'; import { getErrorDetail, devLog } from '../utils/errorParser'; import { useUnsavedChanges } from '../hooks/useUnsavedChanges'; import { Skeleton } from '../components/ui/skeleton'; +import { groupApiNames, actionOf } from '../utils/apiCatalog'; +import type { ApiCatalogGroup } from '../types'; interface ApplicationFormData { name: string; @@ -48,8 +50,9 @@ const ApplicationEdit: React.FC = () => { const [rawResponse, setRawResponse] = useState(null); const [copied, setCopied] = useState(false); const [fieldErrors, setFieldErrors] = useState>({}); - const [catalog, setCatalog] = useState([]); + const [catalogGroups, setCatalogGroups] = useState([]); const [catalogFailed, setCatalogFailed] = useState(false); + const [expandedModules, setExpandedModules] = useState>(new Set()); const [apiSearch, setApiSearch] = useState(''); const formRef = useRef(null); @@ -80,7 +83,7 @@ const ApplicationEdit: React.FC = () => { useEffect(() => { applicationService.getApiCatalog() - .then(setCatalog) + .then(({ groups }) => { setCatalogGroups(groups); }) .catch((err) => { setCatalogFailed(true); devLog('Failed to load api catalog:', err); }); }, []); @@ -139,6 +142,37 @@ const ApplicationEdit: React.FC = () => { setError(''); }; + const toggleModule = (module: string) => { + setExpandedModules(prev => { + const next = new Set(prev); + if (next.has(module)) next.delete(module); + else next.add(module); + return next; + }); + }; + + // Select-all / deselect-all for one module. If every api_name in the module is + // already selected, remove them all; otherwise add the missing ones. + const toggleModuleSelection = (groupNames: string[]) => { + setFormData(prev => { + const allSelected = groupNames.every(n => prev.api_names.includes(n)); + const api_names = allSelected + ? prev.api_names.filter(n => !groupNames.includes(n)) + : Array.from(new Set([...prev.api_names, ...groupNames])); + return { ...prev, api_names }; + }); + setError(''); + }; + + const expandModules = (modules: string[]) => + setExpandedModules(prev => new Set([...Array.from(prev), ...modules])); + const collapseModules = (modules: string[]) => + setExpandedModules(prev => { + const next = new Set(prev); + modules.forEach(m => next.delete(m)); + return next; + }); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); // Pre-submit validation: name is required. @@ -359,67 +393,153 @@ const ApplicationEdit: React.FC = () => { /> ) : (
+ {/* Filter input */}
- - setApiSearch(e.target.value)} - placeholder="Filter API names..." - className="pl-9 pr-9" - aria-label="Filter API names" - /> - {apiSearch && ( - - )} + + setApiSearch(e.target.value)} + placeholder="Filter by module or api_name..." + className="pl-9 pr-9" + aria-label="Filter API names" + /> + {apiSearch && ( + + )}
-
- {catalog.length === 0 ? ( + + {catalogGroups.length === 0 ? ( +

Loading catalog…

- ) : (() => { - const filtered = catalog.filter((api) => api.toLowerCase().includes(apiSearch.trim().toLowerCase())); - if (filtered.length === 0) { - return

No API names matching “{apiSearch}”

; - } +
+ ) : (() => { + const q = apiSearch.trim().toLowerCase(); + // A group matches if its module name matches; then only matching + // api_names show. If the module name itself matches, show all of it. + const visibleGroups = catalogGroups + .map((g) => { + if (!q) return g; + const moduleMatch = g.module.toLowerCase().includes(q); + if (moduleMatch) return g; + const api_names = g.api_names.filter((n) => n.toLowerCase().includes(q)); + return api_names.length ? { ...g, api_names } : null; + }) + .filter((g): g is ApiCatalogGroup => g !== null); + + if (visibleGroups.length === 0) { return ( -
- {filtered.map((api) => { - const selected = formData.api_names.includes(api); +
+

No API names matching “{apiSearch}”

+
+ ); + } + + const allVisibleModules = visibleGroups.map((g) => g.module); + const allVisibleExpanded = visibleGroups.every((g) => expandedModules.has(g.module)); + return ( + <> +
+ +
+
+ {visibleGroups.map((g) => { + // A search auto-expands matching groups; otherwise honor manual state. + const expanded = q ? true : expandedModules.has(g.module); + const selectedCount = g.api_names.filter((n) => formData.api_names.includes(n)).length; + const allSelected = selectedCount === g.api_names.length; return ( - +
+
+ + +
+ {expanded && ( +
+ {g.api_names.map((api) => { + const selected = formData.api_names.includes(api); + return ( + + ); + })} +
+ )} +
); })}
- ); - })()} -
+ + ); + })()}
) ) : ( formData.api_names.length === 0 ? (
-
) : ( -
- {formData.api_names.map((api) => ( - {api} +
+ {groupApiNames(formData.api_names).map((g) => ( +
+

+ {g.module} ({g.api_names.length}) +

+
+ {g.api_names.map((api) => ( + {actionOf(api)} + ))} +
+
))}
) diff --git a/src/services/applicationService.ts b/src/services/applicationService.ts index 4f8e7ea..a1e0b5b 100644 --- a/src/services/applicationService.ts +++ b/src/services/applicationService.ts @@ -1,6 +1,7 @@ import api from './api'; import QueryParams from '../utils/QueryParams'; -import type { PaginateParams, Application, ApplicationWritePayload, ApiListResponse } from '../types'; +import type { PaginateParams, Application, ApplicationWritePayload, ApiListResponse, ApiCatalogGroup } from '../types'; +import { groupApiNames } from '../utils/apiCatalog'; const defaultSearchFields = ['name', 'description']; @@ -27,6 +28,15 @@ const toWritePayload = (data: { return payload; }; +// Runtime guard for a catalog group from an untrusted API response: both fields +// present and correctly typed (including every api_name being a string). +const isApiCatalogGroup = (g: unknown): g is ApiCatalogGroup => + typeof g === 'object' && + g !== null && + typeof (g as ApiCatalogGroup).module === 'string' && + Array.isArray((g as ApiCatalogGroup).api_names) && + (g as ApiCatalogGroup).api_names.every((n: unknown) => typeof n === 'string'); + const applicationService = { getAll: async (paginate: PaginateParams = {}): Promise> => { const q = new QueryParams( @@ -49,13 +59,27 @@ const applicationService = { }, // Catalog of selectable api_name values. The endpoint returns - // { api_names: string[] } (optionally inside the standard { data } envelope). - // Tolerate a bare string[] too, just in case. - getApiCatalog: async (): Promise => { + // { api_names: string[], groups?: { module, api_names }[] } (optionally inside the + // standard { data } envelope). Tolerate a bare string[] too. When the backend has + // not yet been redeployed with `groups`, derive the same grouping client-side from + // api_names (identical split rule), so the UI works regardless of deploy order. + getApiCatalog: async (): Promise<{ groups: ApiCatalogGroup[]; api_names: string[] }> => { const response = await api.get('/api-system/applications/api-catalog'); const body = response.data?.data ?? response.data; - const names = Array.isArray(body) ? body : body?.api_names; - return Array.isArray(names) ? names : []; + + const api_names: string[] = Array.isArray(body) + ? body + : Array.isArray(body?.api_names) + ? body.api_names + : []; + + const rawGroups = body?.groups; + const validGroups: ApiCatalogGroup[] = + Array.isArray(rawGroups) && rawGroups.every(isApiCatalogGroup) + ? (rawGroups as ApiCatalogGroup[]) + : groupApiNames(api_names); + + return { groups: validGroups, api_names }; }, create: async (data: Parameters[0]) => { diff --git a/src/types/index.ts b/src/types/index.ts index 32c7049..53e11b8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,6 +56,13 @@ export interface Application { updated_by_name?: string; } +// A module group of api_names, e.g. { module: 'cluster', api_names: ['cluster.create', ...] }. +// Returned by the api-catalog endpoint (or derived client-side from a flat api_names list). +export interface ApiCatalogGroup { + module: string; + api_names: string[]; +} + // Write payload for create/update. The backend is asymmetric to the read model: // selected api_names are sent through details.add[]. Update uses replace semantics // (send the full desired set). diff --git a/src/utils/apiCatalog.ts b/src/utils/apiCatalog.ts new file mode 100644 index 0000000..301e3ca --- /dev/null +++ b/src/utils/apiCatalog.ts @@ -0,0 +1,37 @@ +import type { ApiCatalogGroup } from '../types'; + +/** + * The module an api_name belongs to: the prefix before the first '.'. + * A name with no dot is its own module. + */ +export const moduleOf = (apiName: string): string => { + const dot = apiName.indexOf('.'); + return dot === -1 ? apiName : apiName.slice(0, dot); +}; + +/** + * The action portion of an api_name: the text after the first '.'. + * A name with no dot returns the whole string. + */ +export const actionOf = (apiName: string): string => { + const dot = apiName.indexOf('.'); + return dot === -1 ? apiName : apiName.slice(dot + 1); +}; + +/** + * Group a flat list of api_names by module. Modules are sorted alphabetically; + * each group's api_names are sorted. Mirrors the backend generator's rule so a + * client-derived grouping is identical to a server-provided one. + */ +export const groupApiNames = (apiNames: string[]): ApiCatalogGroup[] => { + const map = new Map(); + for (const name of apiNames) { + const mod = moduleOf(name); + const list = map.get(mod) ?? []; + list.push(name); + map.set(mod, list); + } + return Array.from(map.keys()) + .sort() + .map((module) => ({ module, api_names: (map.get(module) ?? []).slice().sort() })); +};