diff --git a/json-components-registry.json b/json-components-registry.json index 6b4cd2c..9a23f7b 100644 --- a/json-components-registry.json +++ b/json-components-registry.json @@ -717,6 +717,348 @@ "status": "supported", "source": "atoms" }, + { + "type": "ArrowLeft", + "name": "ArrowLeft", + "category": "display", + "canHaveChildren": false, + "description": "ArrowLeft icon", + "status": "supported", + "source": "icons" + }, + { + "type": "ArrowRight", + "name": "ArrowRight", + "category": "display", + "canHaveChildren": false, + "description": "ArrowRight icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Check", + "name": "Check", + "category": "display", + "canHaveChildren": false, + "description": "Check icon", + "status": "supported", + "source": "icons" + }, + { + "type": "X", + "name": "X", + "category": "display", + "canHaveChildren": false, + "description": "X icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Plus", + "name": "Plus", + "category": "display", + "canHaveChildren": false, + "description": "Plus icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Minus", + "name": "Minus", + "category": "display", + "canHaveChildren": false, + "description": "Minus icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Search", + "name": "Search", + "category": "display", + "canHaveChildren": false, + "description": "Search icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Filter", + "name": "Filter", + "category": "display", + "canHaveChildren": false, + "description": "Filter icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Download", + "name": "Download", + "category": "display", + "canHaveChildren": false, + "description": "Download icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Upload", + "name": "Upload", + "category": "display", + "canHaveChildren": false, + "description": "Upload icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Edit", + "name": "Edit", + "category": "display", + "canHaveChildren": false, + "description": "Edit icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Trash", + "name": "Trash", + "category": "display", + "canHaveChildren": false, + "description": "Trash icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Eye", + "name": "Eye", + "category": "display", + "canHaveChildren": false, + "description": "Eye icon", + "status": "supported", + "source": "icons" + }, + { + "type": "EyeOff", + "name": "EyeOff", + "category": "display", + "canHaveChildren": false, + "description": "EyeOff icon", + "status": "supported", + "source": "icons" + }, + { + "type": "ChevronUp", + "name": "ChevronUp", + "category": "display", + "canHaveChildren": false, + "description": "ChevronUp icon", + "status": "supported", + "source": "icons" + }, + { + "type": "ChevronDown", + "name": "ChevronDown", + "category": "display", + "canHaveChildren": false, + "description": "ChevronDown icon", + "status": "supported", + "source": "icons" + }, + { + "type": "ChevronLeft", + "name": "ChevronLeft", + "category": "display", + "canHaveChildren": false, + "description": "ChevronLeft icon", + "status": "supported", + "source": "icons" + }, + { + "type": "ChevronRight", + "name": "ChevronRight", + "category": "display", + "canHaveChildren": false, + "description": "ChevronRight icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Settings", + "name": "Settings", + "category": "display", + "canHaveChildren": false, + "description": "Settings icon", + "status": "supported", + "source": "icons" + }, + { + "type": "User", + "name": "User", + "category": "display", + "canHaveChildren": false, + "description": "User icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Bell", + "name": "Bell", + "category": "display", + "canHaveChildren": false, + "description": "Bell icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Mail", + "name": "Mail", + "category": "display", + "canHaveChildren": false, + "description": "Mail icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Calendar", + "name": "Calendar", + "category": "display", + "canHaveChildren": false, + "description": "Calendar icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Clock", + "name": "Clock", + "category": "display", + "canHaveChildren": false, + "description": "Clock icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Star", + "name": "Star", + "category": "display", + "canHaveChildren": false, + "description": "Star icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Heart", + "name": "Heart", + "category": "display", + "canHaveChildren": false, + "description": "Heart icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Share", + "name": "Share", + "category": "display", + "canHaveChildren": false, + "description": "Share icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Link", + "name": "Link", + "category": "display", + "canHaveChildren": false, + "description": "Link icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Copy", + "name": "Copy", + "category": "display", + "canHaveChildren": false, + "description": "Copy icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Save", + "name": "Save", + "category": "display", + "canHaveChildren": false, + "description": "Save icon", + "status": "supported", + "source": "icons" + }, + { + "type": "RefreshCw", + "name": "RefreshCw", + "category": "display", + "canHaveChildren": false, + "description": "RefreshCw icon", + "status": "supported", + "source": "icons" + }, + { + "type": "AlertCircle", + "name": "AlertCircle", + "category": "display", + "canHaveChildren": false, + "description": "AlertCircle icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Info", + "name": "Info", + "category": "display", + "canHaveChildren": false, + "description": "Info icon", + "status": "supported", + "source": "icons" + }, + { + "type": "HelpCircle", + "name": "HelpCircle", + "category": "display", + "canHaveChildren": false, + "description": "HelpCircle icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Home", + "name": "Home", + "category": "display", + "canHaveChildren": false, + "description": "Home icon", + "status": "supported", + "source": "icons" + }, + { + "type": "Menu", + "name": "Menu", + "category": "display", + "canHaveChildren": false, + "description": "Menu icon", + "status": "supported", + "source": "icons" + }, + { + "type": "MoreVertical", + "name": "MoreVertical", + "category": "display", + "canHaveChildren": false, + "description": "MoreVertical icon", + "status": "supported", + "source": "icons" + }, + { + "type": "MoreHorizontal", + "name": "MoreHorizontal", + "category": "display", + "canHaveChildren": false, + "description": "MoreHorizontal icon", + "status": "supported", + "source": "icons" + }, { "type": "Breadcrumb", "name": "Breadcrumb", @@ -1926,25 +2268,27 @@ } ], "statistics": { - "total": 222, - "supported": 209, + "total": 239, + "supported": 226, "planned": 0, - "jsonCompatible": 13, + "jsonCompatible": 50, "maybeJsonCompatible": 0, "byCategory": { - "layout": 25, - "input": 34, - "display": 31, - "navigation": 15, - "feedback": 23, - "data": 25, - "custom": 69 + "layout": 24, + "input": 26, + "display": 64, + "navigation": 12, + "feedback": 21, + "data": 27, + "custom": 65 }, "bySource": { "atoms": 117, - "molecules": 40, - "organisms": 15, - "ui": 50 + "molecules": 36, + "organisms": 13, + "ui": 25, + "wrappers": 10, + "icons": 38 } } } diff --git a/schemas/json-components-registry-schema.json b/schemas/json-components-registry-schema.json new file mode 100644 index 0000000..e2160a4 --- /dev/null +++ b/schemas/json-components-registry-schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JSON Components Registry", + "type": "object", + "required": ["version", "description", "components"], + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "lastUpdated": { + "type": "string" + }, + "categories": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "components": { + "type": "array", + "items": { + "type": "object", + "required": [ + "type", + "name", + "category", + "canHaveChildren", + "description", + "status", + "source" + ], + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "export": { + "type": "string" + }, + "category": { + "type": "string" + }, + "canHaveChildren": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "status": { + "type": "string" + }, + "source": { + "type": "string", + "enum": ["atoms", "molecules", "organisms", "ui", "wrappers", "icons"] + }, + "jsonCompatible": { + "type": "boolean" + }, + "wrapperRequired": { + "type": "boolean" + }, + "wrapperComponent": { + "type": "string" + }, + "wrapperFor": { + "type": "string" + }, + "deprecated": { + "type": "object", + "properties": { + "replacedBy": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": false + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "statistics": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/src/hooks/use-pwa.ts b/src/hooks/use-pwa.ts index 3a09de4..ae8684f 100644 --- a/src/hooks/use-pwa.ts +++ b/src/hooks/use-pwa.ts @@ -69,6 +69,12 @@ export function usePWA() { setState(prev => ({ ...prev, isOnline: false })) } + const handleServiceWorkerMessage = (event: MessageEvent) => { + if (event.data && event.data.type === 'CACHE_CLEARED') { + window.location.reload() + } + } + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) window.addEventListener('appinstalled', handleAppInstalled) window.addEventListener('online', handleOnline) @@ -96,11 +102,7 @@ export function usePWA() { console.error('[PWA] Service Worker registration failed:', error) }) - navigator.serviceWorker.addEventListener('message', (event) => { - if (event.data && event.data.type === 'CACHE_CLEARED') { - window.location.reload() - } - }) + navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage) } return () => { @@ -108,6 +110,9 @@ export function usePWA() { window.removeEventListener('appinstalled', handleAppInstalled) window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) + if ('serviceWorker' in navigator) { + navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage) + } } }, []) diff --git a/src/lib/bundle-metrics.ts b/src/lib/bundle-metrics.ts index 4c284f3..fdb28fc 100644 --- a/src/lib/bundle-metrics.ts +++ b/src/lib/bundle-metrics.ts @@ -87,7 +87,12 @@ export function analyzePerformance() { return null } - const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming + const navigation = performance.getEntriesByType('navigation')[0] as + | PerformanceNavigationTiming + | undefined + if (!navigation) { + console.warn('[BUNDLE] ⚠️ Navigation performance entry not available') + } const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[] const jsResources = resources.filter(r => r.name.endsWith('.js')) @@ -97,9 +102,11 @@ export function analyzePerformance() { const totalCssSize = cssResources.reduce((sum, r) => sum + (r.transferSize || 0), 0) const analysis = { - domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart, - loadComplete: navigation.loadEventEnd - navigation.fetchStart, - ttfb: navigation.responseStart - navigation.fetchStart, + domContentLoaded: navigation + ? navigation.domContentLoadedEventEnd - navigation.fetchStart + : NaN, + loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : NaN, + ttfb: navigation ? navigation.responseStart - navigation.fetchStart : NaN, resources: { js: { count: jsResources.length, diff --git a/src/lib/generators/__tests__/generateFlaskBlueprint.test.ts b/src/lib/generators/__tests__/generateFlaskBlueprint.test.ts new file mode 100644 index 0000000..6db0602 --- /dev/null +++ b/src/lib/generators/__tests__/generateFlaskBlueprint.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import type { FlaskBlueprint } from '@/types/project' + +import { generateFlaskBlueprint } from '../generateFlaskBlueprint' + +const isValidIdentifier = (name: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) + +const extractBlueprintVariable = (code: string): { variable: string; name: string } => { + const match = code.match(/^([A-Za-z_][A-Za-z0-9_]*)_bp = Blueprint\('([^']+)'/m) + if (!match) { + throw new Error('Blueprint definition not found.') + } + return { variable: `${match[1]}_bp`, name: match[2] } +} + +const extractFunctionNames = (code: string): string[] => { + return Array.from(code.matchAll(/^def ([A-Za-z_][A-Za-z0-9_]*)\(\):/gm)).map(match => match[1]) +} + +const extractDecoratorBlueprints = (code: string): string[] => { + return Array.from(code.matchAll(/^@([A-Za-z_][A-Za-z0-9_]*)\.route/gm)).map(match => match[1]) +} + +describe('generateFlaskBlueprint identifier sanitization', () => { + it('creates valid, consistent identifiers for tricky endpoint names', () => { + const blueprint: FlaskBlueprint = { + id: 'bp-1', + name: 'User Auth', + urlPrefix: '/auth', + description: 'Auth endpoints', + endpoints: [ + { + id: 'ep-1', + name: 'get-user', + description: 'Fetch a user', + method: 'GET', + path: '/user' + }, + { + id: 'ep-2', + name: '2fa', + description: 'Two factor auth', + method: 'POST', + path: '/2fa' + }, + { + id: 'ep-3', + name: 'user.v1', + description: 'User v1 endpoint', + method: 'GET', + path: '/user/v1' + } + ] + } + + const code = generateFlaskBlueprint(blueprint) + const blueprintDefinition = extractBlueprintVariable(code) + const functionNames = extractFunctionNames(code) + const decoratorBlueprints = extractDecoratorBlueprints(code) + + expect(isValidIdentifier(blueprintDefinition.name)).toBe(true) + expect(isValidIdentifier(blueprintDefinition.variable)).toBe(true) + expect(blueprintDefinition.variable).toBe('user_auth_bp') + expect(blueprintDefinition.name).toBe('user_auth') + expect(new Set(decoratorBlueprints)).toEqual(new Set([blueprintDefinition.variable])) + + expect(functionNames).toEqual(['get_user', '_2fa', 'user_v1']) + functionNames.forEach(name => { + expect(isValidIdentifier(name)).toBe(true) + }) + }) +}) diff --git a/src/lib/generators/generateFlaskApp.ts b/src/lib/generators/generateFlaskApp.ts index 09858e1..a244d87 100644 --- a/src/lib/generators/generateFlaskApp.ts +++ b/src/lib/generators/generateFlaskApp.ts @@ -1,5 +1,6 @@ import { FlaskConfig } from '@/types/project' import { generateFlaskBlueprint } from './generateFlaskBlueprint' +import { sanitizeIdentifier } from './sanitizeIdentifier' export function generateFlaskApp(config: FlaskConfig): Record { const files: Record = {} @@ -11,7 +12,7 @@ export function generateFlaskApp(config: FlaskConfig): Record { appCode += `\n` config.blueprints.forEach(blueprint => { - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' }) appCode += `from blueprints.${blueprintVarName} import ${blueprintVarName}_bp\n` }) @@ -34,7 +35,7 @@ export function generateFlaskApp(config: FlaskConfig): Record { } config.blueprints.forEach(blueprint => { - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' }) appCode += ` app.register_blueprint(${blueprintVarName}_bp)\n` }) @@ -50,7 +51,7 @@ export function generateFlaskApp(config: FlaskConfig): Record { files['app.py'] = appCode config.blueprints.forEach(blueprint => { - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' }) files[`blueprints/${blueprintVarName}.py`] = generateFlaskBlueprint(blueprint) }) diff --git a/src/lib/generators/generateFlaskBlueprint.ts b/src/lib/generators/generateFlaskBlueprint.ts index 301efae..1cd8cc7 100644 --- a/src/lib/generators/generateFlaskBlueprint.ts +++ b/src/lib/generators/generateFlaskBlueprint.ts @@ -1,14 +1,28 @@ import { FlaskBlueprint } from '@/types/project' +import { sanitizeIdentifier } from './sanitizeIdentifier' + +function toPythonIdentifier(value: string, fallback: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + let safe = normalized || fallback + if (/^[0-9]/.test(safe)) { + safe = `_${safe}` + } + return safe +} export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string { let code = `from flask import Blueprint, request, jsonify\n` code += `from typing import Dict, Any\n\n` - const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_') + const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' }) code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n` blueprint.endpoints.forEach(endpoint => { - const functionName = endpoint.name.toLowerCase().replace(/\s+/g, '_') + const functionName = sanitizeIdentifier(endpoint.name, { fallback: 'endpoint' }) code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n` code += `def ${functionName}():\n` code += ` """\n` @@ -31,13 +45,14 @@ export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string { if (endpoint.queryParams && endpoint.queryParams.length > 0) { endpoint.queryParams.forEach(param => { + const paramVarName = sanitizeIdentifier(param.name, { fallback: 'param' }) if (param.required) { - code += ` ${param.name} = request.args.get('${param.name}')\n` - code += ` if ${param.name} is None:\n` + code += ` ${paramVarName} = request.args.get('${param.name}')\n` + code += ` if ${paramVarName} is None:\n` code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n` } else { const defaultVal = param.defaultValue || (param.type === 'string' ? "''" : param.type === 'number' ? '0' : 'None') - code += ` ${param.name} = request.args.get('${param.name}', ${defaultVal})\n` + code += ` ${paramVarName} = request.args.get('${param.name}', ${defaultVal})\n` } }) code += `\n` diff --git a/src/lib/generators/sanitizeIdentifier.ts b/src/lib/generators/sanitizeIdentifier.ts new file mode 100644 index 0000000..bb94954 --- /dev/null +++ b/src/lib/generators/sanitizeIdentifier.ts @@ -0,0 +1,23 @@ +type SanitizeIdentifierOptions = { + fallback?: string +} + +export function sanitizeIdentifier(value: string, options: SanitizeIdentifierOptions = {}): string { + const fallback = options.fallback ?? 'identifier' + const trimmed = value.trim() + const normalized = trimmed + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .replace(/_+/g, '_') + + if (!normalized) { + return fallback + } + + if (/^[0-9]/.test(normalized)) { + return `_${normalized}` + } + + return normalized +} diff --git a/src/lib/json-ui/component-registry.ts b/src/lib/json-ui/component-registry.ts index 36a5c38..736e039 100644 --- a/src/lib/json-ui/component-registry.ts +++ b/src/lib/json-ui/component-registry.ts @@ -1,27 +1,47 @@ import { ComponentType } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { InputOtp } from '@/components/ui/input-otp' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { AlertDialog } from '@/components/ui/alert-dialog' +import { AspectRatio } from '@/components/ui/aspect-ratio' +import { Carousel } from '@/components/ui/carousel' +import { ChartContainer as Chart } from '@/components/ui/chart' +import { Collapsible } from '@/components/ui/collapsible' +import { Command } from '@/components/ui/command' import { Switch } from '@/components/ui/switch' import { Checkbox } from '@/components/ui/checkbox' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { DropdownMenu } from '@/components/ui/dropdown-menu' +import { Menubar } from '@/components/ui/menubar' +import { NavigationMenu } from '@/components/ui/navigation-menu' import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton' import { Progress } from '@/components/ui/progress' +import { Pagination } from '@/components/ui/pagination' +import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable' +import { Sheet } from '@/components/ui/sheet' +import { Sidebar } from '@/components/ui/sidebar' +import { Toaster as Sonner } from '@/components/ui/sonner' +import { ToggleGroup } from '@/components/ui/toggle-group' import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { CircularProgress, Divider, ProgressBar } from '@/components/atoms' import * as AtomComponents from '@/components/atoms' import * as MoleculeComponents from '@/components/molecules' import * as OrganismComponents from '@/components/organisms' import { + ComponentBindingDialogWrapper, + ComponentTreeWrapper, + DataSourceEditorDialogWrapper, + GitHubBuildStatusWrapper, LazyBarChartWrapper, LazyD3BarChartWrapper, LazyLineChartWrapper, @@ -49,6 +69,9 @@ interface JsonRegistryEntry { export?: string source?: string status?: string + wrapperRequired?: boolean + wrapperComponent?: string + wrapperFor?: string deprecated?: DeprecatedComponentInfo } @@ -63,6 +86,9 @@ export interface DeprecatedComponentInfo { const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry +const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined => + entry.export ?? entry.name ?? entry.type + const buildRegistryFromNames = ( names: string[], components: Record> @@ -77,10 +103,18 @@ const buildRegistryFromNames = ( } const jsonRegistryEntries = jsonRegistry.components ?? [] +const registryEntryByType = new Map( + jsonRegistryEntries + .map((entry) => { + const entryName = getRegistryEntryName(entry) + return entryName ? [entryName, entry] : null + }) + .filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry)) +) const atomComponentMap = AtomComponents as Record> const deprecatedComponentInfo = jsonRegistryEntries.reduce>( (acc, entry) => { - const entryName = entry.export ?? entry.name ?? entry.type + const entryName = getRegistryEntryName(entry) if (!entryName) { return acc } @@ -93,15 +127,27 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce entry.source === 'atoms') - .map((entry) => entry.export ?? entry.name ?? entry.type) + .map((entry) => getRegistryEntryName(entry)) .filter((name): name is string => Boolean(name)) const moleculeRegistryNames = jsonRegistryEntries .filter((entry) => entry.source === 'molecules') - .map((entry) => entry.export ?? entry.name ?? entry.type) + .map((entry) => getRegistryEntryName(entry)) .filter((name): name is string => Boolean(name)) const organismRegistryNames = jsonRegistryEntries .filter((entry) => entry.source === 'organisms') - .map((entry) => entry.export ?? entry.name ?? entry.type) + .map((entry) => getRegistryEntryName(entry)) + .filter((name): name is string => Boolean(name)) +const shadcnRegistryNames = jsonRegistryEntries + .filter((entry) => entry.source === 'ui') + .map((entry) => getRegistryEntryName(entry)) + .filter((name): name is string => Boolean(name)) +const wrapperRegistryNames = jsonRegistryEntries + .filter((entry) => entry.source === 'wrappers') + .map((entry) => getRegistryEntryName(entry)) + .filter((name): name is string => Boolean(name)) +const iconRegistryNames = jsonRegistryEntries + .filter((entry) => entry.source === 'icons') + .map((entry) => getRegistryEntryName(entry)) .filter((name): name is string => Boolean(name)) export const primitiveComponents: UIComponentRegistry = { @@ -123,9 +169,17 @@ export const primitiveComponents: UIComponentRegistry = { nav: 'nav' as any, } -export const shadcnComponents: UIComponentRegistry = { +const shadcnComponentMap: Record> = { + AlertDialog, + AspectRatio, Button, + Carousel, + Chart, + Collapsible, + Command, + DropdownMenu, Input, + InputOtp, Textarea, Label, Card, @@ -164,13 +218,26 @@ export const shadcnComponents: UIComponentRegistry = { DialogFooter, DialogHeader, DialogTitle, + Menubar, + NavigationMenu, Skeleton: ShadcnSkeleton, + Pagination, Progress, + Resizable, + Sheet, + Sidebar, + Sonner, + ToggleGroup, Avatar: ShadcnAvatar, AvatarFallback, AvatarImage, } +export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames( + shadcnRegistryNames, + shadcnComponentMap +) + export const atomComponents: UIComponentRegistry = { ...buildRegistryFromNames( atomRegistryNames, @@ -208,16 +275,25 @@ export const organismComponents: UIComponentRegistry = buildRegistryFromNames( OrganismComponents as Record> ) -export const jsonWrapperComponents: UIComponentRegistry = { - SaveIndicator: SaveIndicatorWrapper, - LazyBarChart: LazyBarChartWrapper, - LazyLineChart: LazyLineChartWrapper, - LazyD3BarChart: LazyD3BarChartWrapper, - SeedDataManager: SeedDataManagerWrapper, - StorageSettings: StorageSettingsWrapper, +const wrapperComponentMap: Record> = { + ComponentBindingDialogWrapper, + ComponentTreeWrapper, + DataSourceEditorDialogWrapper, + GitHubBuildStatusWrapper, + SaveIndicatorWrapper, + LazyBarChartWrapper, + LazyLineChartWrapper, + LazyD3BarChartWrapper, + SeedDataManagerWrapper, + StorageSettingsWrapper, } -export const iconComponents: UIComponentRegistry = { +export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames( + wrapperRegistryNames, + wrapperComponentMap +) + +const iconComponentMap: Record> = { ArrowLeft, ArrowRight, Check, @@ -258,6 +334,11 @@ export const iconComponents: UIComponentRegistry = { MoreHorizontal: DotsThree, } +export const iconComponents: UIComponentRegistry = buildRegistryFromNames( + iconRegistryNames, + iconComponentMap +) + export const uiComponentRegistry: UIComponentRegistry = { ...primitiveComponents, ...shadcnComponents, diff --git a/src/lib/unified-storage-adapters/__tests__/flask-backend-adapter.test.ts b/src/lib/unified-storage-adapters/__tests__/flask-backend-adapter.test.ts new file mode 100644 index 0000000..c0937a6 --- /dev/null +++ b/src/lib/unified-storage-adapters/__tests__/flask-backend-adapter.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import { FlaskBackendAdapter } from '../flask-backend-adapter' + +type MockResponse = { + ok: boolean + status: number + statusText: string + text: ReturnType +} + +const createMockResponse = (status: number, body: string): MockResponse => ({ + ok: status >= 200 && status < 300, + status, + statusText: status === 204 ? 'No Content' : 'OK', + text: vi.fn().mockResolvedValue(body), +}) + +describe('FlaskBackendAdapter.request', () => { + const baseUrl = 'http://example.test' + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.resetAllMocks() + }) + + it('resolves delete/clear when response is 204 or empty body', async () => { + fetchMock + .mockResolvedValueOnce(createMockResponse(204, '') as unknown as Response) + .mockResolvedValueOnce(createMockResponse(200, '') as unknown as Response) + + const adapter = new FlaskBackendAdapter(baseUrl) + + await expect(adapter.delete('example-key')).resolves.toBeUndefined() + await expect(adapter.clear()).resolves.toBeUndefined() + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/lib/unified-storage-adapters/flask-backend-adapter.ts b/src/lib/unified-storage-adapters/flask-backend-adapter.ts index 01ccb65..9630fd6 100644 --- a/src/lib/unified-storage-adapters/flask-backend-adapter.ts +++ b/src/lib/unified-storage-adapters/flask-backend-adapter.ts @@ -38,7 +38,11 @@ export class FlaskBackendAdapter implements StorageAdapter { return undefined as T } - return response.json() + const responseText = await response.text() + if (!responseText) { + return undefined as T + } + return JSON.parse(responseText) as T } catch (error: any) { clearTimeout(timeoutId) if (error.name === 'AbortError') { diff --git a/src/lib/unified-storage.test.ts b/src/lib/unified-storage.test.ts new file mode 100644 index 0000000..276c9af --- /dev/null +++ b/src/lib/unified-storage.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + callOrder, + mockFlaskGet, + mockIndexedGet, + mockSQLiteGet, + mockSparkGet, + MockFlaskBackendAdapter, + MockIndexedDBAdapter, + MockSQLiteAdapter, + MockSparkKVAdapter +} = vi.hoisted(() => { + const callOrder: string[] = [] + const mockFlaskGet = vi.fn<[], Promise>() + const mockIndexedGet = vi.fn<[], Promise>() + const mockSQLiteGet = vi.fn<[], Promise>() + const mockSparkGet = vi.fn<[], Promise>() + + class MockFlaskBackendAdapter { + constructor() { + callOrder.push('flask') + } + + get = mockFlaskGet + } + + class MockIndexedDBAdapter { + constructor() { + callOrder.push('indexeddb') + } + + get = mockIndexedGet + } + + class MockSQLiteAdapter { + constructor() { + callOrder.push('sqlite') + } + + get = mockSQLiteGet + } + + class MockSparkKVAdapter { + constructor() { + callOrder.push('sparkkv') + } + + get = mockSparkGet + } + + return { + callOrder, + mockFlaskGet, + mockIndexedGet, + mockSQLiteGet, + mockSparkGet, + MockFlaskBackendAdapter, + MockIndexedDBAdapter, + MockSQLiteAdapter, + MockSparkKVAdapter + } +}) + +vi.mock('./unified-storage-adapters', () => ({ + FlaskBackendAdapter: MockFlaskBackendAdapter, + IndexedDBAdapter: MockIndexedDBAdapter, + SQLiteAdapter: MockSQLiteAdapter, + SparkKVAdapter: MockSparkKVAdapter +})) + +const createLocalStorageMock = () => { + const store = new Map() + + return { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + store.set(key, value) + }), + removeItem: vi.fn((key: string) => { + store.delete(key) + }), + clear: vi.fn(() => { + store.clear() + }) + } +} + +describe('UnifiedStorage.detectAndInitialize', () => { + let localStorageMock: ReturnType + + beforeEach(() => { + vi.resetModules() + callOrder.length = 0 + mockFlaskGet.mockReset() + mockIndexedGet.mockReset() + mockSQLiteGet.mockReset() + mockSparkGet.mockReset() + + localStorageMock = createLocalStorageMock() + vi.stubGlobal('localStorage', localStorageMock) + vi.stubGlobal('window', { spark: undefined }) + + if (!(import.meta as { env?: Record }).env) { + ;(import.meta as { env?: Record }).env = {} + } + }) + + it('tries Flask before IndexedDB when prefer-flask is set', async () => { + localStorageMock.setItem('codeforge-prefer-flask', 'true') + mockFlaskGet.mockRejectedValue(new Error('flask down')) + mockIndexedGet.mockResolvedValue(undefined) + vi.stubGlobal('indexedDB', {}) + + const { unifiedStorage } = await import('./unified-storage') + await unifiedStorage.getBackend() + + expect(callOrder[0]).toBe('flask') + expect(callOrder).toContain('indexeddb') + }) + + it('falls back to IndexedDB when Flask initialization fails', async () => { + localStorageMock.setItem('codeforge-prefer-flask', 'true') + mockFlaskGet.mockRejectedValue(new Error('flask down')) + mockIndexedGet.mockResolvedValue(undefined) + vi.stubGlobal('indexedDB', {}) + + const { unifiedStorage } = await import('./unified-storage') + const backend = await unifiedStorage.getBackend() + + expect(backend).toBe('indexeddb') + }) + + it('honors prefer-sqlite when configured', async () => { + localStorageMock.setItem('codeforge-prefer-sqlite', 'true') + mockSQLiteGet.mockResolvedValue(undefined) + delete (globalThis as { indexedDB?: unknown }).indexedDB + + const { unifiedStorage } = await import('./unified-storage') + const backend = await unifiedStorage.getBackend() + + expect(backend).toBe('sqlite') + expect(callOrder).toContain('sqlite') + }) +}) diff --git a/src/lib/unified-storage.ts b/src/lib/unified-storage.ts index d430886..2ddd8a2 100644 --- a/src/lib/unified-storage.ts +++ b/src/lib/unified-storage.ts @@ -19,6 +19,23 @@ class UnifiedStorage { const flaskEnvUrl = import.meta.env.VITE_FLASK_BACKEND_URL const preferSQLite = localStorage.getItem('codeforge-prefer-sqlite') === 'true' + if (preferFlask || flaskEnvUrl) { + try { + console.log('[Storage] Flask backend explicitly configured, attempting to initialize...') + const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl) + await Promise.race([ + flaskAdapter.get('_health_check'), + new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000)) + ]) + this.adapter = flaskAdapter + this.backend = 'flask' + console.log('[Storage] ✓ Using Flask backend') + return + } catch (error) { + console.warn('[Storage] Flask backend not available, falling back to IndexedDB:', error) + } + } + if (typeof indexedDB !== 'undefined') { try { console.log('[Storage] Initializing default IndexedDB backend...') @@ -33,26 +50,6 @@ class UnifiedStorage { } } - if (preferFlask || flaskEnvUrl) { - try { - console.log('[Storage] Flask backend explicitly configured, attempting to initialize...') - const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl) - const testResponse = await Promise.race([ - flaskAdapter.get('_health_check'), - new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000)) - ]) - this.adapter = flaskAdapter - this.backend = 'flask' - console.log('[Storage] ✓ Using Flask backend') - return - } catch (error) { - console.warn('[Storage] Flask backend not available, already using IndexedDB:', error) - if (this.adapter && this.backend === 'indexeddb') { - return - } - } - } - if (preferSQLite) { try { console.log('[Storage] SQLite fallback, attempting to initialize...')