Skip to content

Commit 28ee64b

Browse files
authored
SEP-1580: Single-source the shell nav-icon map from backend NavIcon (#1133)
Collapses the shell sidebar-icon map (`ICON_BY_KEY`) to a single source derived from the backend `NavIcon` vocabulary, in two commits. **C1 — compile-time enforcement.** `ICON_BY_KEY` (`frontend/packages/shell/src/appNavConfig.ts`) is retyped as an exact `Record<SepComponents['schemas']['nav_icons__NavIcon'], NavIcon>`, so `tsc --noEmit` now fails on a missing key (TS2741) or an extra key (TS2353) instead of silently falling back to the default icon. `EnabledApp.nav_icon` (`frontend/packages/api/src/hooks/useEnabledApps.ts`) is narrowed from `string | null` to the generated `nav_icons__NavIcon` union so the single map lookup no longer indexes an exact record with a plain string. The hand-mirrored `NAV_ICON_KEYS` array and its self-referential assertions (`appNavConfig.test.ts`) are deleted — the compile check plus `buildNavigation.test.ts` subsume them. **C2 — codegen.** `ICON_BY_KEY` is now generated from the `nav_icons__NavIcon` enum in the committed OpenAPI spec via an MUI kebab→Pascal transform plus a three-entry brand exception table (`mysql`/`mongo`/`postgresql` → `@percona/percona-ui`). The pure resolver lives in the typed, unit-tested `frontend/packages/shell/src/appNavIcons.codegen.ts`; `frontend/packages/shell/scripts/gen-icon-map.ts` is a thin `tsx` I/O wrapper that emits `frontend/packages/shell/src/generated/appNavIcons.ts`. `appNavConfig.ts` imports the generated map and drops its ten MUI plus three brand icon imports; the `NavIcon` value type moves to `contexts/navigation.tsx` (shared type-only, so no new runtime import cycle). Adding a sidebar icon is now a backend enum edit plus `pnpm --filter @sep/shell codegen`. The generated map resolves the same 13 keys to the same components as the previous hand map — sidebar icons render identically. The `NavIcon` vocabulary, `App.NAV_ICON`, the `/api/apps` payload shape, and the brand override registry are unchanged; the backend `nav_icons.py` module docstring is refreshed to describe the codegen-derived map. ## Bundled changes - **CI codegen-freshness** (`.github/workflows/frontend.yaml`): the existing OpenAPI→TS freshness step is extended to also run `pnpm --filter @sep/shell codegen` + `oxfmt` + `git diff --exit-code` on the shell generated dir, so a `NavIcon` change without a codegen re-run fails CI. - **License-header exclude** (`.pre-commit-config.yaml`): the `addlicense` exclude is generalized from `packages/api/src/generated` to `packages/[^/]+/src/generated`, matching the generated-dir convention, so the new shell generated file keeps only its auto-generated header (as the api generated files already do). - **`tsx` devDependency** added to `@sep/shell` for the codegen script (already a dependency of `@sep/api`). ## Notes - **Brand deep-import (incidental, recorded and skipped).** The incidental cleanup of deep-importing the three brand icons is skipped: `@percona/percona-ui@1.0.16` declares only a `"."` entry in `exports` (no subpath map), so `@percona/percona-ui/<subpath>` deep imports are unresolvable — only the barrel works; and the barrel is already imported by eight other shell modules, so it adds no net bundle weight. The acceptance criterion sanctions recording-and-skipping in exactly this case.
1 parent ea4d265 commit 28ee64b

14 files changed

Lines changed: 342 additions & 104 deletions

File tree

.github/workflows/frontend.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
pnpm --filter @sep/api codegen
3333
pnpm --filter @sep/api exec oxfmt --write src/generated
3434
git diff --exit-code -- packages/api/src/generated/
35+
pnpm --filter @sep/shell codegen
36+
pnpm --filter @sep/shell exec oxfmt --write src/generated
37+
git diff --exit-code -- packages/shell/src/generated/
3538
3639
- name: Lint (oxlint)
3740
run: pnpm lint

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ repos:
2020
- id: addlicense
2121
args: ["-f", ".license_header", "-v"]
2222
types_or: [python, javascript, ts, tsx, css, jinja]
23-
exclude: ^(static/(js|css)/vendor/|frontend/(node_modules|.*/dist|packages/api/src/generated)/)
23+
exclude: ^(static/(js|css)/vendor/|frontend/(node_modules|.*/dist|packages/[^/]+/src/generated)/)
2424
- repo: local
2525
hooks:
2626
- id: js-beautify

app/sep/apps/nav_icons.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@
1919
the React shell's ``ICON_BY_KEY`` map to a concrete MUI component. The set is
2020
closed -- it names the icons the frontend bundles -- so an app definition that
2121
declares an unknown icon fails Pydantic validation at import. Members name the
22-
icon, not the app, so apps sharing an icon share a member. The frontend mirrors
23-
this vocabulary in an ``ICON_BY_KEY`` map; the two copies are kept in sync by
24-
hand (no codegen), so a member added here needs the matching frontend entry or
25-
that app silently falls back to the default sidebar icon.
22+
icon, not the app, so apps sharing an icon share a member. The frontend derives
23+
its ``ICON_BY_KEY`` map from this vocabulary via codegen
24+
(``pnpm --filter @sep/shell codegen``), so a member added here needs a codegen
25+
re-run rather than a hand-edited frontend entry, and a missing map entry is a
26+
compile error rather than a silent fallback to the default sidebar icon.
2627
2728
This is a leaf module (only ``enum``) deliberately kept outside the
2829
``app.sep.apps.framework`` package: ``app.sep.config`` types its ``App.NAV_ICON``

frontend/packages/api/src/hooks/useEnabledApps.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import { useQuery } from '@tanstack/react-query';
1919
import { apiClient } from '../client';
20+
import type { components } from '../generated/sep';
2021

2122
/** Per-app entry returned by the public ``GET /api/apps/`` endpoint. */
2223
export interface EnabledApp {
@@ -33,7 +34,7 @@ export interface EnabledApp {
3334
/** Canonical React route the shell mounts and links to; always concrete. */
3435
react_route: string;
3536
/** Sidebar icon key; ``null`` falls back to the shell's default app icon. */
36-
nav_icon: string | null;
37+
nav_icon: components['schemas']['nav_icons__NavIcon'] | null;
3738
}
3839

3940
export const ENABLED_APPS_QUERY_KEY = ['apps'] as const;

frontend/packages/shell/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"build": "tsc --noEmit && vite build",
99
"preview": "vite preview",
1010
"type-check": "tsc --noEmit",
11-
"test": "vitest run --passWithNoTests"
11+
"test": "vitest run --passWithNoTests",
12+
"codegen": "tsx scripts/gen-icon-map.ts"
1213
},
1314
"dependencies": {
1415
"@emotion/react": "^11.14.0",
@@ -54,6 +55,7 @@
5455
"@vitejs/plugin-react": "^6.0.1",
5556
"jsdom": "^29.1.1",
5657
"msw": "^2.13.6",
58+
"tsx": "^4.21.0",
5759
"typescript": "~6.0.3",
5860
"vite": "^8.0.16",
5961
"vitest": "^4.0.0"
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Copyright (C) 2026 Percona LLC
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
/**
20+
* Generate the shell ICON_BY_KEY map from the backend NavIcon vocabulary.
21+
*
22+
* Reads the `nav_icons__NavIcon` enum from the committed OpenAPI spec (the same
23+
* fixture the api type codegen consumes) and emits
24+
* `src/generated/appNavIcons.ts`, so adding a sidebar icon is a backend-only
25+
* edit plus a codegen re-run. All resolution logic lives in the typed, unit-
26+
* tested `src/appNavIcons.codegen.ts`; this wrapper is pure I/O.
27+
*
28+
* Usage:
29+
* pnpm --filter @sep/shell codegen
30+
*/
31+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
32+
import path from 'node:path';
33+
import { fileURLToPath } from 'node:url';
34+
import { resolveNavIcon } from '../src/appNavIcons.codegen';
35+
36+
const HERE = path.dirname(fileURLToPath(import.meta.url));
37+
const SPEC = path.resolve(HERE, '..', '..', 'api', 'specs', 'sep.json');
38+
const OUT_DIR = path.resolve(HERE, '..', 'src', 'generated');
39+
const OUT = path.join(OUT_DIR, 'appNavIcons.ts');
40+
41+
const HEADER = [
42+
'/**',
43+
' * This file is auto-generated by `pnpm --filter @sep/shell codegen`.',
44+
' * Do not edit by hand — regenerate from the source OpenAPI spec.',
45+
' */',
46+
'',
47+
].join('\n');
48+
49+
/** Quote an object key only when it is not a bare JS identifier. */
50+
function quoteKey(key: string): string {
51+
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : `'${key}'`;
52+
}
53+
54+
async function main(): Promise<void> {
55+
const spec = JSON.parse(await readFile(SPEC, 'utf8'));
56+
const keys: string[] = spec.components.schemas.nav_icons__NavIcon.enum;
57+
58+
const muiImports: string[] = [];
59+
const brandNames: string[] = [];
60+
const entries: string[] = [];
61+
62+
for (const key of keys) {
63+
const { module, importName } = resolveNavIcon(key);
64+
if (module === '@percona/percona-ui') {
65+
brandNames.push(importName);
66+
} else {
67+
muiImports.push(`import ${importName} from '${module}';`);
68+
}
69+
entries.push(` ${quoteKey(key)}: ${importName},`);
70+
}
71+
72+
const imports = [
73+
"import type { SepComponents } from '@sep/api';",
74+
...muiImports,
75+
...(brandNames.length
76+
? [`import { ${brandNames.join(', ')} } from '@percona/percona-ui';`]
77+
: []),
78+
"import type { NavIcon } from '../contexts/navigation';",
79+
];
80+
81+
const body = [
82+
...imports,
83+
'',
84+
"export const ICON_BY_KEY: Record<SepComponents['schemas']['nav_icons__NavIcon'], NavIcon> = {",
85+
...entries,
86+
'};',
87+
'',
88+
].join('\n');
89+
90+
await mkdir(OUT_DIR, { recursive: true });
91+
await writeFile(OUT, HEADER + body, 'utf8');
92+
// eslint-disable-next-line no-console
93+
console.log(`[codegen] wrote ${path.relative(process.cwd(), OUT)}`);
94+
}
95+
96+
main().catch((err) => {
97+
// eslint-disable-next-line no-console
98+
console.error(err);
99+
process.exit(1);
100+
});

frontend/packages/shell/src/appNavConfig.test.ts

Lines changed: 0 additions & 55 deletions
This file was deleted.

frontend/packages/shell/src/appNavConfig.ts

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -30,27 +30,13 @@
3030
import type { EnabledApp } from '@sep/api';
3131
import DashboardIcon from '@mui/icons-material/Dashboard';
3232
import DnsIcon from '@mui/icons-material/Dns';
33-
import AssignmentIcon from '@mui/icons-material/Assignment';
34-
import CodeIcon from '@mui/icons-material/Code';
3533
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
36-
import DescriptionIcon from '@mui/icons-material/Description';
37-
import TroubleshootIcon from '@mui/icons-material/Troubleshoot';
38-
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
39-
import TableChartIcon from '@mui/icons-material/TableChart';
4034
import BackupIcon from '@mui/icons-material/Backup';
41-
import ArchiveIcon from '@mui/icons-material/Archive';
42-
import BarChartIcon from '@mui/icons-material/BarChart';
4335
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
44-
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
45-
import ScienceIcon from '@mui/icons-material/Science';
4636
import ExtensionIcon from '@mui/icons-material/Extension';
47-
import { MySqlIcon, MongoIcon, PostgreSqlIcon } from '@percona/percona-ui';
4837
import { ROUTES } from '@sep/shared';
49-
import type { SvgIconComponent } from '@mui/icons-material';
50-
import type { SvgIconProps } from '@mui/material';
51-
import type { NavItem } from './contexts/navigation';
52-
53-
export type NavIcon = SvgIconComponent | ((props: SvgIconProps) => React.JSX.Element);
38+
import { ICON_BY_KEY } from './generated/appNavIcons';
39+
import type { NavIcon, NavItem } from './contexts/navigation';
5440

5541
/** React routing metadata keyed by backend ``app_key``. */
5642
export interface AppRouteMeta {
@@ -116,31 +102,6 @@ const NAV_GROUPS: Record<string, { label: string; icon: NavIcon }> = {
116102

117103
const DEFAULT_APP_ICON: NavIcon = ExtensionIcon;
118104

119-
/**
120-
* Sidebar icon per backend ``nav_icon`` key; falls back to ``DEFAULT_APP_ICON``.
121-
*
122-
* Mirrors the backend ``app.sep.apps.nav_icons.NavIcon`` StrEnum, kept in sync
123-
* by hand. ``appNavConfig.test.ts`` asserts this map matches its own
124-
* ``NAV_ICON_KEYS`` mirror exactly — catching a key added to one but not the
125-
* other — but neither half is checked against the backend enum, so a new backend
126-
* key must still be added here by hand.
127-
*/
128-
export const ICON_BY_KEY: Record<string, NavIcon> = {
129-
assignment: AssignmentIcon,
130-
code: CodeIcon,
131-
'support-agent': SupportAgentIcon,
132-
description: DescriptionIcon,
133-
troubleshoot: TroubleshootIcon,
134-
'table-chart': TableChartIcon,
135-
'check-circle': CheckCircleIcon,
136-
mysql: MySqlIcon,
137-
mongo: MongoIcon,
138-
postgresql: PostgreSqlIcon,
139-
archive: ArchiveIcon,
140-
science: ScienceIcon,
141-
'bar-chart': BarChartIcon,
142-
};
143-
144105
/** Always-on non-app destinations, prepended ahead of the derived app tree. */
145106
const STATIC_NAV_ENTRIES: NavItem[] = [
146107
{ title: 'Dashboard', icon: DashboardIcon, to: ROUTES.dashboard },
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Copyright (C) 2026 Percona LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU Affero General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU Affero General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU Affero General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
18+
import { readFileSync } from 'node:fs';
19+
import path from 'node:path';
20+
import { fileURLToPath } from 'node:url';
21+
import { describe, expect, it } from 'vitest';
22+
import { BRAND_ICONS, pascalCase, resolveNavIcon } from './appNavIcons.codegen';
23+
24+
const SPEC_PATH = path.resolve(
25+
path.dirname(fileURLToPath(import.meta.url)),
26+
'../../api/specs/sep.json',
27+
);
28+
const NAV_ICON_KEYS: string[] = JSON.parse(readFileSync(SPEC_PATH, 'utf8')).components.schemas
29+
.nav_icons__NavIcon.enum;
30+
31+
describe('pascalCase', () => {
32+
it('joins kebab segments into a single PascalCase token', () => {
33+
expect(pascalCase('support-agent')).toBe('SupportAgent');
34+
expect(pascalCase('bar-chart')).toBe('BarChart');
35+
expect(pascalCase('code')).toBe('Code');
36+
});
37+
});
38+
39+
describe('resolveNavIcon', () => {
40+
it('brands exactly the three non-MUI keys', () => {
41+
expect(Object.keys(BRAND_ICONS).sort()).toEqual(['mongo', 'mysql', 'postgresql']);
42+
});
43+
44+
it('routes brand keys to the @percona/percona-ui barrel', () => {
45+
expect(resolveNavIcon('mysql')).toEqual({
46+
module: '@percona/percona-ui',
47+
importName: 'MySqlIcon',
48+
});
49+
expect(resolveNavIcon('mongo')).toEqual({
50+
module: '@percona/percona-ui',
51+
importName: 'MongoIcon',
52+
});
53+
expect(resolveNavIcon('postgresql')).toEqual({
54+
module: '@percona/percona-ui',
55+
importName: 'PostgreSqlIcon',
56+
});
57+
});
58+
59+
it('routes non-brand keys to the MUI subpath with an Icon binding', () => {
60+
expect(resolveNavIcon('support-agent')).toEqual({
61+
module: '@mui/icons-material/SupportAgent',
62+
importName: 'SupportAgentIcon',
63+
});
64+
expect(resolveNavIcon('code')).toEqual({
65+
module: '@mui/icons-material/Code',
66+
importName: 'CodeIcon',
67+
});
68+
});
69+
70+
it('does not treat inherited Object.prototype keys as brand keys', () => {
71+
const resolved = resolveNavIcon('toString');
72+
expect(resolved.module).not.toBe('@percona/percona-ui');
73+
expect(typeof resolved.importName).toBe('string');
74+
});
75+
76+
it('resolves every NavIcon vocabulary key to an import source', () => {
77+
for (const key of NAV_ICON_KEYS) {
78+
const resolved = resolveNavIcon(key);
79+
expect(resolved.module).toBeTruthy();
80+
expect(resolved.importName).toBeTruthy();
81+
}
82+
});
83+
});

0 commit comments

Comments
 (0)