From d1668f8e3352001928c16f7a493774354b0e98ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 12:44:23 +0000 Subject: [PATCH 1/4] Fix Email module Badge variants to match component API The Badge component supports 'default', 'success', 'danger', 'warning', and 'info' variants. The Email Dashboard and History pages were using non-existent variants ('destructive', 'secondary', 'outline') causing the badges to render without proper styling. --- .../Email/src/SimpleModule.Email/Pages/Dashboard.tsx | 6 +++--- modules/Email/src/SimpleModule.Email/Pages/History.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/Email/src/SimpleModule.Email/Pages/Dashboard.tsx b/modules/Email/src/SimpleModule.Email/Pages/Dashboard.tsx index 8ebe8130..a71ec210 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/Dashboard.tsx +++ b/modules/Email/src/SimpleModule.Email/Pages/Dashboard.tsx @@ -25,10 +25,10 @@ interface Props { stats: EmailStats; } -function failureRateVariant(rate: number): 'default' | 'secondary' | 'destructive' { +function failureRateVariant(rate: number): 'default' | 'warning' | 'danger' { if (rate < 5) return 'default'; - if (rate <= 15) return 'secondary'; - return 'destructive'; + if (rate <= 15) return 'warning'; + return 'danger'; } export default function Dashboard({ stats }: Props) { diff --git a/modules/Email/src/SimpleModule.Email/Pages/History.tsx b/modules/Email/src/SimpleModule.Email/Pages/History.tsx index 8c902399..f0811b36 100644 --- a/modules/Email/src/SimpleModule.Email/Pages/History.tsx +++ b/modules/Email/src/SimpleModule.Email/Pages/History.tsx @@ -25,17 +25,17 @@ import type { EmailMessage } from '../types'; type EmailStatus = 'Queued' | 'Sending' | 'Sent' | 'Failed' | 'Retrying'; -function statusVariant(status: EmailStatus): 'default' | 'secondary' | 'destructive' | 'outline' { +function statusVariant(status: EmailStatus): 'default' | 'success' | 'danger' | 'warning' | 'info' { switch (status) { case 'Sent': - return 'default'; + return 'success'; case 'Failed': - return 'destructive'; + return 'danger'; case 'Sending': case 'Retrying': - return 'secondary'; + return 'warning'; default: - return 'outline'; + return 'default'; } } From f3b9bafa364e12d50cbc899e100e73c86a01fa19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 13:03:45 +0000 Subject: [PATCH 2/4] Add TypeScript type checking to CI and fix all type errors The project had tsconfig.json with strict mode but never ran tsc, allowing invalid component prop types to slip through (like the Email module's Badge variant bug). Changes: - Add tools/typecheck.mjs that runs tsc --noEmit per module - Add "typecheck" script to package.json check pipeline - Fix SharedProps missing index signature for Inertia PageProps - Fix Badge variant mismatches in RateLimiting and Email modules - Fix Button variant 'destructive' -> 'danger' in Users module - Fix number-to-string type mismatches across multiple modules - Fix string|undefined -> string assignments in Products/Tenants - Add CSS module declaration to PageBuilder tsconfig --- .../SimpleModule.AuditLogs/Pages/Browse.tsx | 4 +- .../SimpleModule.AuditLogs/Pages/Detail.tsx | 6 +- .../Pages/Detail.tsx | 4 +- .../Pages/List.tsx | 2 +- .../Pages/Recurring.tsx | 2 +- .../SimpleModule.PageBuilder/tsconfig.json | 3 +- .../SimpleModule.Products/Pages/Manage.tsx | 2 +- .../SimpleModule.RateLimiting/Pages/Admin.tsx | 4 +- .../Pages/MenuManager.tsx | 4 +- .../src/SimpleModule.Tenants/Pages/Browse.tsx | 2 +- .../src/SimpleModule.Tenants/Pages/Manage.tsx | 4 +- .../Account/Manage/DeletePersonalData.tsx | 2 +- package.json | 3 +- .../components/layouts/types.ts | 1 + tools/typecheck.mjs | 85 +++++++++++++++++++ 15 files changed, 109 insertions(+), 19 deletions(-) create mode 100644 tools/typecheck.mjs diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx index 853f49ce..3162f271 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Browse.tsx @@ -415,8 +415,8 @@ export default function Browse({ result, filters }: Props) {
{t(AuditLogsKeys.Browse.Showing, { - start: startItem, - end: endItem, + start: String(startItem), + end: String(endItem), total: result.totalCount.toLocaleString(), })} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx index cffd9698..f5ee2057 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pages/Detail.tsx @@ -173,7 +173,7 @@ export default function Detail({ entry, correlated }: Props) { router.get('/audit-logs/browse')}> {t(AuditLogsKeys.Detail.BackToBrowse)} @@ -181,7 +181,7 @@ export default function Detail({ entry, correlated }: Props) { } breadcrumbs={[ { label: t(AuditLogsKeys.Detail.BreadcrumbAuditLogs), href: '/audit-logs/browse' }, - { label: t(AuditLogsKeys.Detail.BreadcrumbEntry, { id: entry.id }) }, + { label: t(AuditLogsKeys.Detail.BreadcrumbEntry, { id: String(entry.id) }) }, ]} > {/* Overview Card */} @@ -382,7 +382,7 @@ export default function Detail({ entry, correlated }: Props) { {t(AuditLogsKeys.Detail.CorrelatedTitle)} - {t(AuditLogsKeys.Detail.CorrelatedRelated, { count: correlated.length })} + {t(AuditLogsKeys.Detail.CorrelatedRelated, { count: String(correlated.length) })} diff --git a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx index 8cdeca05..d09287de 100644 --- a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx +++ b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/Detail.tsx @@ -82,7 +82,7 @@ export default function Detail({ job }: Props) { {job.state} {job.retryCount > 0 && ( - {t(BackgroundJobsKeys.Detail.RetryCount, { count: job.retryCount })} + {t(BackgroundJobsKeys.Detail.RetryCount, { count: String(job.retryCount) })} )}
@@ -138,7 +138,7 @@ export default function Detail({ job }: Props) { - {t(BackgroundJobsKeys.Detail.LogsCard, { count: job.logs.length })} + {t(BackgroundJobsKeys.Detail.LogsCard, { count: String(job.logs.length) })} diff --git a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx index a03b9487..77e30005 100644 --- a/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx +++ b/modules/BackgroundJobs/src/SimpleModule.BackgroundJobs/Pages/List.tsx @@ -41,7 +41,7 @@ export default function List({ jobs }: Props) { return ( {t(ProductsKeys.Manage.DeleteDialog.Title)} - {t(ProductsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name })} + {t(ProductsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name ?? '' })} diff --git a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx index c8b5d5fa..bcb37494 100644 --- a/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx +++ b/modules/RateLimiting/src/SimpleModule.RateLimiting/Pages/Admin.tsx @@ -73,12 +73,12 @@ const API_BASE = '/api/rate-limiting'; function PolicyTypeBadge({ type }: { type: string }) { const variant = - type === 'TokenBucket' ? 'secondary' : type === 'SlidingWindow' ? 'outline' : 'default'; + type === 'TokenBucket' ? 'info' : type === 'SlidingWindow' ? 'warning' : 'default'; return {type}; } function TargetBadge({ target }: { target: string }) { - return {target}; + return {target}; } export default function Admin({ rules, activePolicies }: AdminProps) { diff --git a/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx b/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx index 42e155de..f34fe1b1 100644 --- a/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx +++ b/modules/Settings/src/SimpleModule.Settings/Pages/MenuManager.tsx @@ -167,7 +167,9 @@ export default function MenuManager({ menuItems: initial, availablePages }: Menu {t(SettingsKeys.MenuManager.CardTreeTitle)} {totalItems > 0 && ( - {t(SettingsKeys.MenuManager.ItemsCount, { count: totalItems })} + + {t(SettingsKeys.MenuManager.ItemsCount, { count: String(totalItems) })} + )}
diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx b/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx index 684d66d5..69baf0e9 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/Browse.tsx @@ -31,7 +31,7 @@ export default function Browse({ tenants }: { tenants: BrowseTenant[] }) { tenant.hostCount !== 1 ? TenantsKeys.Browse.HostCount_other : TenantsKeys.Browse.HostCount_one, - { count: tenant.hostCount }, + { count: String(tenant.hostCount) }, )} diff --git a/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx b/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx index 0a50e93c..9568f418 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx +++ b/modules/Tenants/src/SimpleModule.Tenants/Pages/Manage.tsx @@ -35,7 +35,7 @@ export default function Manage({ tenants }: { tenants: Tenant[] }) { <> router.get('/tenants/create')}> {t(TenantsKeys.Manage.CreateButton)} @@ -108,7 +108,7 @@ export default function Manage({ tenants }: { tenants: Tenant[] }) { {t(TenantsKeys.Manage.DeleteDialog.Title)} - {t(TenantsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name })} + {t(TenantsKeys.Manage.DeleteDialog.Confirm, { name: deleteTarget?.name ?? '' })} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx index 08fecac4..69f7440d 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Manage/DeletePersonalData.tsx @@ -57,7 +57,7 @@ export default function DeletePersonalData({ requirePassword, errors }: Props) { /> )} - diff --git a/package.json b/package.json index 4166cb6a..eb0a4ed5 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "dev": "node tools/dev-orchestrator.mjs", "lint": "biome lint .", "format": "biome format --write .", - "check": "biome check . && npm run validate-pages && npm run validate:i18n", + "check": "biome check . && npm run validate-pages && npm run validate:i18n && npm run typecheck", + "typecheck": "node tools/typecheck.mjs", "check:fix": "biome check --write . && npm run validate-pages", "validate-pages": "node template/SimpleModule.Host/ClientApp/validate-pages.mjs", "generate:types": "dotnet build template/SimpleModule.Host && node tools/extract-ts-types.mjs template/SimpleModule.Host/obj/Debug/net10.0/generated/SimpleModule.Generator/SimpleModule.Generator.ModuleDiscovererGenerator modules", diff --git a/packages/SimpleModule.UI/components/layouts/types.ts b/packages/SimpleModule.UI/components/layouts/types.ts index 45ecd09d..c59a81c3 100644 --- a/packages/SimpleModule.UI/components/layouts/types.ts +++ b/packages/SimpleModule.UI/components/layouts/types.ts @@ -17,6 +17,7 @@ export interface PublicMenuItem { } export interface SharedProps { + [key: string]: unknown; auth: { isAuthenticated: boolean; userName: string | null; diff --git a/tools/typecheck.mjs b/tools/typecheck.mjs new file mode 100644 index 00000000..69b55eca --- /dev/null +++ b/tools/typecheck.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node + +/** + * typecheck.mjs + * + * Runs `tsc --noEmit` in every module and package that has a tsconfig.json. + * Each project is checked independently so @/* path aliases resolve correctly. + * + * Exit codes: + * 0 = All projects pass type checking + * 1 = Type errors found + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const projectRoot = path.resolve(import.meta.dirname, '..'); +const modulesDir = path.join(projectRoot, 'modules'); +const packagesDir = path.join(projectRoot, 'packages'); + +function findTsConfigs(baseDir, depth) { + const dirs = []; + if (!fs.existsSync(baseDir)) return dirs; + + for (const entry of fs.readdirSync(baseDir)) { + const full = path.join(baseDir, entry); + if (!fs.statSync(full).isDirectory()) continue; + + if (depth === 1) { + // packages/Foo — check directly + if (fs.existsSync(path.join(full, 'tsconfig.json'))) { + dirs.push(full); + } + } else { + // modules/Foo/src/Bar — recurse into src/* + const srcDir = path.join(full, 'src'); + if (!fs.existsSync(srcDir)) continue; + for (const sub of fs.readdirSync(srcDir)) { + const subFull = path.join(srcDir, sub); + if ( + fs.statSync(subFull).isDirectory() && + fs.existsSync(path.join(subFull, 'tsconfig.json')) + ) { + dirs.push(subFull); + } + } + } + } + return dirs; +} + +const projects = [ + ...findTsConfigs(modulesDir, 2), + ...findTsConfigs(packagesDir, 1), +]; + +let failed = false; +const failures = []; + +for (const dir of projects) { + const label = path.relative(projectRoot, dir); + try { + execSync('npx tsc --noEmit', { cwd: dir, stdio: 'pipe' }); + console.log(` \u2713 ${label}`); + } catch (err) { + failed = true; + const output = err.stdout?.toString() || err.stderr?.toString() || ''; + failures.push({ label, output }); + console.log(` \u2717 ${label}`); + } +} + +if (failures.length > 0) { + console.log('\n--- Type errors ---\n'); + for (const { label, output } of failures) { + console.log(`${label}:`); + console.log(output); + } +} + +console.log( + `\nTypecheck: ${projects.length - failures.length}/${projects.length} passed`, +); +process.exit(failed ? 1 : 0); From 324aba44193c7d25ed83b22820113ba2be913047 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 13:05:23 +0000 Subject: [PATCH 3/4] Split CI lint job into discrete steps for early failure Break the single `npm run check` into separate steps: biome lint, pages validation, i18n validation, and TypeScript type check. Each step fails fast independently, and since build/e2e depend on the lint job, type errors now block the entire pipeline. --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c5b2d3d..ad0c8b8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,16 @@ jobs: run: npm ci - name: Lint and format check - run: npm run check + run: npx biome check . + + - name: Validate pages registry + run: npm run validate-pages + + - name: Validate i18n keys + run: npm run validate:i18n + + - name: TypeScript type check + run: npm run typecheck build: runs-on: ubuntu-latest From 7f63f766e0e16216dc9f09fa34ea1c76d6f7e25a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 13:10:17 +0000 Subject: [PATCH 4/4] Simplify: improve typecheck perf and preserve SharedProps type safety - Run tsc checks in parallel via Promise.all instead of sequentially - Resolve tsc binary directly instead of spawning npx on each invocation - Move Record from SharedProps interface to usePage<> call sites, preserving type safety on the exported interface - Replace magic depth parameter with descriptive 'flat'/'nested' layout --- .../components/layouts/app-layout.tsx | 2 +- .../components/layouts/layout-provider.tsx | 2 +- .../components/layouts/public-layout.tsx | 2 +- .../components/layouts/types.ts | 1 - tools/typecheck.mjs | 43 +++++++++++++------ 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/SimpleModule.UI/components/layouts/app-layout.tsx b/packages/SimpleModule.UI/components/layouts/app-layout.tsx index 7176bd52..cbfb8a8a 100644 --- a/packages/SimpleModule.UI/components/layouts/app-layout.tsx +++ b/packages/SimpleModule.UI/components/layouts/app-layout.tsx @@ -175,7 +175,7 @@ function AdminGroup({ } export function AppLayout({ children }: { children: React.ReactNode }) { - const { props } = usePage(); + const { props } = usePage>(); const { auth, menus, csrfToken } = props; const pathname = typeof window !== 'undefined' ? window.location.pathname : '/'; diff --git a/packages/SimpleModule.UI/components/layouts/layout-provider.tsx b/packages/SimpleModule.UI/components/layouts/layout-provider.tsx index dc9a15e4..5def956a 100644 --- a/packages/SimpleModule.UI/components/layouts/layout-provider.tsx +++ b/packages/SimpleModule.UI/components/layouts/layout-provider.tsx @@ -6,7 +6,7 @@ import { PublicLayout } from './public-layout'; import type { SharedProps } from './types'; function AutoLayout({ children }: { children: React.ReactNode }) { - const { props } = usePage(); + const { props } = usePage>(); const { auth } = props; if (auth?.isAuthenticated) { diff --git a/packages/SimpleModule.UI/components/layouts/public-layout.tsx b/packages/SimpleModule.UI/components/layouts/public-layout.tsx index 2ae524a6..b2bbd5a5 100644 --- a/packages/SimpleModule.UI/components/layouts/public-layout.tsx +++ b/packages/SimpleModule.UI/components/layouts/public-layout.tsx @@ -287,7 +287,7 @@ function MobileOverlay({ } export function PublicLayout({ children }: { children: React.ReactNode }) { - const { props } = usePage(); + const { props } = usePage>(); const { publicMenu = [] } = props; const [mobileOpen, setMobileOpen] = React.useState(false); const closeMobile = React.useCallback(() => setMobileOpen(false), []); diff --git a/packages/SimpleModule.UI/components/layouts/types.ts b/packages/SimpleModule.UI/components/layouts/types.ts index c59a81c3..45ecd09d 100644 --- a/packages/SimpleModule.UI/components/layouts/types.ts +++ b/packages/SimpleModule.UI/components/layouts/types.ts @@ -17,7 +17,6 @@ export interface PublicMenuItem { } export interface SharedProps { - [key: string]: unknown; auth: { isAuthenticated: boolean; userName: string | null; diff --git a/tools/typecheck.mjs b/tools/typecheck.mjs index 69b55eca..214766bc 100644 --- a/tools/typecheck.mjs +++ b/tools/typecheck.mjs @@ -5,13 +5,15 @@ * * Runs `tsc --noEmit` in every module and package that has a tsconfig.json. * Each project is checked independently so @/* path aliases resolve correctly. + * All checks run in parallel for speed. * * Exit codes: * 0 = All projects pass type checking * 1 = Type errors found */ -import { execSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; import fs from 'node:fs'; import path from 'node:path'; @@ -19,7 +21,10 @@ const projectRoot = path.resolve(import.meta.dirname, '..'); const modulesDir = path.join(projectRoot, 'modules'); const packagesDir = path.join(projectRoot, 'packages'); -function findTsConfigs(baseDir, depth) { +const require = createRequire(import.meta.url); +const tscBin = require.resolve('typescript/bin/tsc'); + +function findProjects(baseDir, layout) { const dirs = []; if (!fs.existsSync(baseDir)) return dirs; @@ -27,7 +32,7 @@ function findTsConfigs(baseDir, depth) { const full = path.join(baseDir, entry); if (!fs.statSync(full).isDirectory()) continue; - if (depth === 1) { + if (layout === 'flat') { // packages/Foo — check directly if (fs.existsSync(path.join(full, 'tsconfig.json'))) { dirs.push(full); @@ -50,22 +55,32 @@ function findTsConfigs(baseDir, depth) { return dirs; } +function checkProject(dir) { + return new Promise((resolve) => { + const proc = spawn(process.execPath, [tscBin, '--noEmit'], { + cwd: dir, + stdio: 'pipe', + }); + let output = ''; + proc.stdout.on('data', (d) => (output += d)); + proc.stderr.on('data', (d) => (output += d)); + proc.on('close', (code) => resolve({ dir, code, output })); + }); +} + const projects = [ - ...findTsConfigs(modulesDir, 2), - ...findTsConfigs(packagesDir, 1), + ...findProjects(modulesDir, 'nested'), + ...findProjects(packagesDir, 'flat'), ]; -let failed = false; -const failures = []; +const results = await Promise.all(projects.map(checkProject)); -for (const dir of projects) { +const failures = []; +for (const { dir, code, output } of results) { const label = path.relative(projectRoot, dir); - try { - execSync('npx tsc --noEmit', { cwd: dir, stdio: 'pipe' }); + if (code === 0) { console.log(` \u2713 ${label}`); - } catch (err) { - failed = true; - const output = err.stdout?.toString() || err.stderr?.toString() || ''; + } else { failures.push({ label, output }); console.log(` \u2717 ${label}`); } @@ -82,4 +97,4 @@ if (failures.length > 0) { console.log( `\nTypecheck: ${projects.length - failures.length}/${projects.length} passed`, ); -process.exit(failed ? 1 : 0); +process.exit(failures.length > 0 ? 1 : 0);