From 0a668cf98db01d81b351343b624c1b1b5d97ccab Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Wed, 17 Jun 2026 21:03:08 +0530 Subject: [PATCH] updated react-openapi --- react-openapi/Admin.tsx | 186 -------- react-openapi/api/client.ts | 70 --- react-openapi/components/AdminLayout.tsx | 265 ------------ react-openapi/components/EnhancedTable.tsx | 404 ------------------ react-openapi/components/FilterBar.tsx | 308 ------------- react-openapi/components/GenericForm.tsx | 143 ------- react-openapi/components/ProfileView.tsx | 83 ---- react-openapi/components/ResourceView.tsx | 213 --------- .../components/fields/BooleanField.tsx | 17 - react-openapi/components/fields/DateField.tsx | 18 - .../components/fields/DateRangeField.tsx | 30 -- .../fields/DefaultFieldComponents.ts | 40 -- react-openapi/components/fields/EnumField.tsx | 24 -- .../components/fields/FallbackField.tsx | 13 - react-openapi/components/fields/FormField.tsx | 85 ---- .../components/fields/ImageUploadField.tsx | 60 --- .../components/fields/NumberField.tsx | 16 - .../components/fields/NumberRangeField.tsx | 28 -- .../components/fields/ObjectField.tsx | 42 -- .../components/fields/RelationField.tsx | 50 --- react-openapi/components/fields/TextField.tsx | 18 - react-openapi/components/fields/index.ts | 14 - react-openapi/config.ts | 21 - react-openapi/hooks/useResource.ts | 184 -------- react-openapi/index.ts | 18 +- react-openapi/providers/AppProvider.tsx | 70 --- react-openapi/providers/ConfigContext.tsx | 12 - react-openapi/providers/UploadProvider.tsx | 52 --- react-openapi/src/components/Admin.tsx | 56 +++ react-openapi/src/components/Layout.tsx | 42 ++ .../src/components/ResourceDetail.tsx | 97 +++++ react-openapi/src/components/ResourceForm.tsx | 301 +++++++++++++ react-openapi/src/components/ResourceList.tsx | 222 ++++++++++ react-openapi/src/components/SideMenu.tsx | 110 +++++ .../src/components/ValidationAlert.tsx | 73 ++++ .../components/fields/DetailFieldRenderer.tsx | 23 + .../components/fields/FormFieldRenderer.tsx | 127 ++++++ .../components/fields/ListCellRenderer.tsx | 66 +++ react-openapi/src/components/fields/index.ts | 5 + .../fields/renderers/BooleanField.tsx | 27 ++ .../components/fields/renderers/DateField.tsx | 30 ++ .../components/fields/renderers/EnumField.tsx | 34 ++ .../fields/renderers/FkMultiSelectField.tsx | 32 ++ .../fields/renderers/FkSelectField.tsx | 38 ++ .../fields/renderers/ImageField.tsx | 60 +++ .../fields/renderers/InlineRefField.tsx | 38 ++ .../components/fields/renderers/JsonField.tsx | 270 ++++++++++++ .../fields/renderers/NumberField.tsx | 37 ++ .../fields/renderers/StringField.tsx | 29 ++ react-openapi/src/components/fields/utils.ts | 4 + react-openapi/src/context/AppContext.tsx | 21 + react-openapi/src/context/AppProvider.tsx | 83 ++++ react-openapi/src/context/useResource.ts | 147 +++++++ react-openapi/src/hooks/useApi.ts | 45 ++ react-openapi/src/spec-loader.ts | 17 + react-openapi/src/spec-validator.ts | 129 ++++++ .../src/transformers/field-config.ts | 53 +++ .../src/transformers/relationship-config.ts | 32 ++ .../src/transformers/resource-config.ts | 74 ++++ react-openapi/src/types.ts | 85 ++++ react-openapi/types/config.ts | 65 --- react-openapi/types/overrides.ts | 89 ---- react-openapi/utils/openapi_loader.ts | 249 ----------- react-openapi/utils/options.ts | 39 -- 64 files changed, 2412 insertions(+), 2921 deletions(-) delete mode 100644 react-openapi/Admin.tsx delete mode 100644 react-openapi/api/client.ts delete mode 100644 react-openapi/components/AdminLayout.tsx delete mode 100644 react-openapi/components/EnhancedTable.tsx delete mode 100644 react-openapi/components/FilterBar.tsx delete mode 100644 react-openapi/components/GenericForm.tsx delete mode 100644 react-openapi/components/ProfileView.tsx delete mode 100644 react-openapi/components/ResourceView.tsx delete mode 100644 react-openapi/components/fields/BooleanField.tsx delete mode 100644 react-openapi/components/fields/DateField.tsx delete mode 100644 react-openapi/components/fields/DateRangeField.tsx delete mode 100644 react-openapi/components/fields/DefaultFieldComponents.ts delete mode 100644 react-openapi/components/fields/EnumField.tsx delete mode 100644 react-openapi/components/fields/FallbackField.tsx delete mode 100644 react-openapi/components/fields/FormField.tsx delete mode 100644 react-openapi/components/fields/ImageUploadField.tsx delete mode 100644 react-openapi/components/fields/NumberField.tsx delete mode 100644 react-openapi/components/fields/NumberRangeField.tsx delete mode 100644 react-openapi/components/fields/ObjectField.tsx delete mode 100644 react-openapi/components/fields/RelationField.tsx delete mode 100644 react-openapi/components/fields/TextField.tsx delete mode 100644 react-openapi/components/fields/index.ts delete mode 100644 react-openapi/config.ts delete mode 100644 react-openapi/hooks/useResource.ts delete mode 100644 react-openapi/providers/AppProvider.tsx delete mode 100644 react-openapi/providers/ConfigContext.tsx delete mode 100644 react-openapi/providers/UploadProvider.tsx create mode 100644 react-openapi/src/components/Admin.tsx create mode 100644 react-openapi/src/components/Layout.tsx create mode 100644 react-openapi/src/components/ResourceDetail.tsx create mode 100644 react-openapi/src/components/ResourceForm.tsx create mode 100644 react-openapi/src/components/ResourceList.tsx create mode 100644 react-openapi/src/components/SideMenu.tsx create mode 100644 react-openapi/src/components/ValidationAlert.tsx create mode 100644 react-openapi/src/components/fields/DetailFieldRenderer.tsx create mode 100644 react-openapi/src/components/fields/FormFieldRenderer.tsx create mode 100644 react-openapi/src/components/fields/ListCellRenderer.tsx create mode 100644 react-openapi/src/components/fields/index.ts create mode 100644 react-openapi/src/components/fields/renderers/BooleanField.tsx create mode 100644 react-openapi/src/components/fields/renderers/DateField.tsx create mode 100644 react-openapi/src/components/fields/renderers/EnumField.tsx create mode 100644 react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx create mode 100644 react-openapi/src/components/fields/renderers/FkSelectField.tsx create mode 100644 react-openapi/src/components/fields/renderers/ImageField.tsx create mode 100644 react-openapi/src/components/fields/renderers/InlineRefField.tsx create mode 100644 react-openapi/src/components/fields/renderers/JsonField.tsx create mode 100644 react-openapi/src/components/fields/renderers/NumberField.tsx create mode 100644 react-openapi/src/components/fields/renderers/StringField.tsx create mode 100644 react-openapi/src/components/fields/utils.ts create mode 100644 react-openapi/src/context/AppContext.tsx create mode 100644 react-openapi/src/context/AppProvider.tsx create mode 100644 react-openapi/src/context/useResource.ts create mode 100644 react-openapi/src/hooks/useApi.ts create mode 100644 react-openapi/src/spec-loader.ts create mode 100644 react-openapi/src/spec-validator.ts create mode 100644 react-openapi/src/transformers/field-config.ts create mode 100644 react-openapi/src/transformers/relationship-config.ts create mode 100644 react-openapi/src/transformers/resource-config.ts create mode 100644 react-openapi/src/types.ts delete mode 100644 react-openapi/types/config.ts delete mode 100644 react-openapi/types/overrides.ts delete mode 100644 react-openapi/utils/openapi_loader.ts delete mode 100644 react-openapi/utils/options.ts diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx deleted file mode 100644 index c50c191..0000000 --- a/react-openapi/Admin.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import * as React from "react"; -import { useAuth, AuthPage } from "../react-auth"; -import { UploadProvider } from "./providers/UploadProvider"; -import AdminLayout from "./components/AdminLayout"; -import ResourceView from "./components/ResourceView"; -import { getAppConfig } from "./config"; -import { initializeApiClients } from "./api/client"; -import { AppConfig } from "./types/config"; -import { FieldComponents } from "./types/overrides"; -import { Box, Typography, Paper, CircularProgress } from "@mui/material"; -import { - Routes, - Route, - useNavigate, - useParams, -} from "react-router-dom"; - -import { ConfigContext } from "./providers/ConfigContext"; -import ProfileView from "./components/ProfileView"; - -function DefaultDashboard({ basePath }: { basePath: string }) { - const config = React.useContext(ConfigContext); - const navigate = useNavigate(); - - const resources = config?.resources || []; - const visibleResources = resources.filter((res) => !res.hidden); - - return ( - - - Welcome to the Admin Panel - - - Select a resource from the sidebar to manage data. - - - {visibleResources.map((res) => ( - navigate(`/admin/${res.name}`)} - > - {res.pluralLabel} - Manage {res.pluralLabel.toLowerCase()} - - ))} - - - ); -} - -interface AdminAppProps { - basePath: string; - fieldComponents: FieldComponents; - Dashboard?: React.ComponentType<{ basePath: string }>; - Layout?: React.ComponentType; - LoginPage?: React.ComponentType; -} - -function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) { - const { currentUser, login, logout, loading, error } = useAuth(); - const config = React.useContext(ConfigContext); - const navigate = useNavigate(); - - const resources = config?.resources || []; - const visibleResources = resources.filter((res) => !res.hidden); - - if (!currentUser) { - return ( - {}} - loading={loading} - error={error} - onSwitchMode={() => {}} - onBack={() => {}} - currentUser={null} - /> - ); - } - - return ( - navigate(`/admin/${name}`)} - resources={visibleResources} - > - - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -} - -function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) { - const { resourceName } = useParams(); - const config = React.useContext(ConfigContext); - const selectedResource = config?.resources.find((r) => r.name === resourceName); - - if (!selectedResource) return Resource not found; - - return ; -} - -interface AdminLayoutProps { - children: React.ReactNode; - onSelectResource: (resourceName: string | null) => void; - onLogout: () => void; - username?: string; - resources: import("./types/config").ResourceConfig[]; -} - -interface AdminProps { - basePath?: string; - resourceOverrides?: Record; - profileConfig?: any; - fieldComponents: FieldComponents; - Dashboard?: React.ComponentType<{ basePath: string }>; - Layout?: React.ComponentType; - LoginPage?: React.ComponentType; -} - -export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) { - const existingConfig = React.useContext(ConfigContext); - const [config, setConfig] = React.useState(existingConfig); - - React.useEffect(() => { - if (!existingConfig) { - getAppConfig(resourceOverrides, profileConfig).then((cfg) => { - initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); - setConfig(cfg); - }); - } - }, [resourceOverrides, profileConfig, existingConfig]); - - if (!config) { - return ( - - - - ); - } - - const content = ( - - - - ); - - if (existingConfig) { - return content; - } - - return ( - - {content} - - ); -} diff --git a/react-openapi/api/client.ts b/react-openapi/api/client.ts deleted file mode 100644 index 1513d41..0000000 --- a/react-openapi/api/client.ts +++ /dev/null @@ -1,70 +0,0 @@ -import axios, { AxiosInstance } from "axios"; -import type { AxiosResponse } from "axios"; -import { createApiClient } from "../../react-auth"; - -/** - * We expose a singleton-like getter/setter for the API clients - */ -let _api: AxiosInstance | null = null; -let _auth: AxiosInstance | null = null; - -function withParamsSerializer(instance: AxiosInstance): AxiosInstance { - instance.defaults.paramsSerializer = { - serialize: (params) => { - const searchParams = new URLSearchParams(); - - Object.entries(params).forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => { - searchParams.append(key, String(v)); // NO [] - }); - } else if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - - return searchParams.toString(); - }, - }; - - return instance; -} - -export const api = { - get: >(url: string, config?: Parameters[1]) => { - if (!_api) throw new Error("API client not initialized"); - return _api.get(url, config); - }, - post: >(url: string, data?: any, config?: Parameters[2]) => { - if (!_api) throw new Error("API client not initialized"); - return _api.post(url, data, config); - }, - put: >(url: string, data?: any, config?: Parameters[2]) => { - if (!_api) throw new Error("API client not initialized"); - return _api.put(url, data, config); - }, - delete: >(url: string, config?: Parameters[1]) => { - if (!_api) throw new Error("API client not initialized"); - return _api.delete(url, config); - }, - patch: >(url: string, data?: any, config?: Parameters[2]) => { - if (!_api) throw new Error("API client not initialized"); - return _api.patch(url, data, config); - }, -}; - -export const auth = { - post: (...args: Parameters) => { - if (!_auth) throw new Error("Auth client not initialized"); - return _auth.post(...args); - }, - get: (...args: Parameters) => { - if (!_auth) throw new Error("Auth client not initialized"); - return _auth.get(...args); - }, -}; - -export function initializeApiClients(baseUrl: string, authBaseUrl: string) { - _api = withParamsSerializer(createApiClient(baseUrl)); - _auth = withParamsSerializer(createApiClient(authBaseUrl)); -} diff --git a/react-openapi/components/AdminLayout.tsx b/react-openapi/components/AdminLayout.tsx deleted file mode 100644 index b22dedd..0000000 --- a/react-openapi/components/AdminLayout.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import * as React from 'react'; -import { - Box, - Drawer, - List, - Divider, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, - IconButton, - Tooltip, - useMediaQuery, - useTheme, -} from '@mui/material'; -import TableViewIcon from '@mui/icons-material/TableView'; -import DashboardIcon from '@mui/icons-material/Dashboard'; -import MenuIcon from '@mui/icons-material/Menu'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { ResourceConfig } from '../types/config'; -import { useLocation, useNavigate } from 'react-router-dom'; - -const drawerWidth = 240; -const collapsedWidth = 64; - -interface AdminLayoutProps { - children: React.ReactNode; - onSelectResource: (resourceName: string | null) => void; - onLogout: () => void; - username?: string; - resources: ResourceConfig[]; -} - -export default function AdminLayout({ - children, - onSelectResource, - resources, -}: AdminLayoutProps) { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - const location = useLocation(); - const navigate = useNavigate(); - - const [isCollapsed, setIsCollapsed] = React.useState(false); - const [mobileOpen, setMobileOpen] = React.useState(false); - - const activeResourceName = location.pathname.split('/admin')[1] || null; - - // AUTO-TOGGLE LOGIC (unchanged) - React.useEffect(() => { - if (isMobile) { - setIsCollapsed(false); - setMobileOpen(false); - } else { - if (location.pathname === '/admin' || location.pathname === '') { - setIsCollapsed(false); - } else { - setIsCollapsed(true); - } - } - }, [location.pathname, isMobile]); - - const currentWidth = isMobile - ? drawerWidth - : isCollapsed - ? collapsedWidth - : drawerWidth; - - const handleDrawerToggle = () => { - setMobileOpen((prev) => !prev); - }; - - const handleSidebarToggle = () => { - setIsCollapsed((prev) => !prev); - }; - - const drawerContent = ( - - {!isMobile && ( - <> - - - {isCollapsed ? : } - - - - - )} - - {/* Mobile spacing (replaces Toolbar) */} - {isMobile && ( - theme.spacing(7) }} /> - )} - - - - - navigate('/admin')} - sx={{ - minHeight: 48, - justifyContent: - isCollapsed && !isMobile ? 'center' : 'initial', - px: 2.5, - }} - > - - - - {(!isCollapsed || isMobile) && ( - - )} - - - - - - - - - {resources.map((res) => ( - - - onSelectResource(res.name)} - sx={{ - minHeight: 48, - justifyContent: - isCollapsed && !isMobile - ? 'center' - : 'initial', - px: 2.5, - }} - > - - - - {(!isCollapsed || isMobile) && ( - - )} - - - - ))} - - - ); - - return ( - - {/* NAV */} - - {isMobile ? ( - - {drawerContent} - - ) : ( - - {drawerContent} - - )} - - - {/* MAIN */} - - {/* Control row (replaces AppBar) */} - {isMobile && ( - theme.spacing(7), - }} - > - - - - - )} - - {children} - - - ); -} diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx deleted file mode 100644 index 6a78677..0000000 --- a/react-openapi/components/EnhancedTable.tsx +++ /dev/null @@ -1,404 +0,0 @@ -import * as React from 'react'; -import { alpha } from '@mui/material/styles'; -import { - Box, - Typography, - Button, - IconButton, - Tooltip, - Card, - CardContent, - CardActions, - Grid, - Menu, - MenuItem, - useMediaQuery, - useTheme, - Divider, - Chip, - Stack, -} from '@mui/material'; -import { - DataGrid, - GridColDef, - GridActionsCellItem, - GridRenderCellParams, - GridPaginationModel, -} from '@mui/x-data-grid'; -import EditIcon from '@mui/icons-material/Edit'; -import DeleteIcon from '@mui/icons-material/Delete'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import { useNavigate } from 'react-router-dom'; -import { ResourceConfig } from '../types/config'; -import { EnhancedTableComponents } from '../types/overrides'; -import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options'; - -interface EnhancedTableProps { - config: ResourceConfig; - data: any[]; - total?: number; - paginationModel?: GridPaginationModel; - onPaginationModelChange?: (model: GridPaginationModel) => void; - loading?: boolean; - onEdit: (item: any) => void; - onDelete: (id: string) => void; - onCreate: () => void; - onNavigateToResource?: (resourceName: string, id: string) => void; - components?: EnhancedTableComponents; -} - -export default function EnhancedTable({ - config, - data, - total, - paginationModel: externalPaginationModel, - onPaginationModelChange: externalOnPaginationModelChange, - loading = false, - onEdit, - onDelete, - onCreate, - onNavigateToResource, - components: tableComponents, -}: EnhancedTableProps) { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); - const navigate = useNavigate(); - - const isServer = config.filterOptions?.mode !== "client"; - const [internalPaginationModel, setInternalPaginationModel] = React.useState({ - page: 0, - pageSize: 10, - }); - const paginationModel = isServer ? externalPaginationModel : internalPaginationModel; - const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel; - - const columns: GridColDef[] = React.useMemo(() => { - const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { - let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string'; - if (field.type === 'number') muiType = 'number'; - if (field.type === 'boolean') muiType = 'boolean'; - if (field.type === 'date') muiType = 'date'; - if (field.type === 'datetime') muiType = 'dateTime'; - if (field.type === 'enum') muiType = 'singleSelect'; - - const col: GridColDef = { - field: key, - headerName: field.label, - type: muiType, - flex: 1, - minWidth: 150, - renderCell: (params: GridRenderCellParams) => - }; - - if (muiType === 'date' || muiType === 'dateTime') { - col.valueGetter = (value: any) => { - if (!value) return null; - const date = new Date(value); - return isNaN(date.getTime()) ? null : date; - }; - } - - if (muiType === 'singleSelect') { - (col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field)); - } - - return col; - }); - - cols.push({ - field: 'actions', - type: 'actions', - headerName: 'Actions', - width: 120, - getActions: (params) => [ - } - label="View" - onClick={() => navigate(`/admin/${config.name}/${params.id}`)} - />, - } - label="Edit" - onClick={() => navigate(`/admin/${config.name}/edit/${params.id}`)} - />, - } - label="Delete" - onClick={() => onDelete(params.id as string)} - />, - ], - }); - - return cols; - }, [config, onDelete, navigate, onNavigateToResource]); - - const mobilePageSize = 10; - const [mobilePage, setMobilePage] = React.useState(0); - const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1; - const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize); - - React.useEffect(() => { - if (mobilePage >= mobileTotalPages) setMobilePage(0); - }, [data.length, mobilePage, mobileTotalPages]); - - if (isMobile) { - return ( - - - {config.pluralLabel} - - - - {mobileData.map((row) => ( - - - - ))} - - - - - Page {mobilePage + 1} of {mobileTotalPages} - - - - - ); - } - - return ( - - - {config.pluralLabel} - - - { - if (total !== undefined) return total; - const page = paginationModel?.page || 0; - const pageSize = paginationModel?.pageSize || 10; - if (data.length < pageSize) { - return page * pageSize + data.length; - } - return (page + 2) * pageSize; - })(), - } : {})} - loading={loading} - paginationModel={paginationModel || { page: 0, pageSize: 10 }} - onPaginationModelChange={onPaginationModelChange} - getRowId={(row) => { - const pk = config.primaryKey; - if (row[pk] !== undefined && row[pk] !== null) return row[pk]; - const fallbackKeys = ['id', '_id', 'uuid', 'pk']; - for (const key of fallbackKeys) { - if (row[key] !== undefined && row[key] !== null) return row[key]; - } - return `temp-id-${data.indexOf(row)}`; - }} - disableRowSelectionOnClick - pageSizeOptions={[10, 25, 50]} - sx={{ - border: 'none', - '& .MuiDataGrid-cell:focus': { outline: 'none' }, - '& .MuiDataGrid-columnHeader:focus': { outline: 'none' }, - }} - /> - - ); -} - -function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) { - const [anchorEl, setAnchorEl] = React.useState(null); - const open = Boolean(anchorEl); - const id = row[config.primaryKey]; - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - - return ( - - - - - #{id} - - - - - - { handleClose(); navigate(`/admin/${config.name}/${id}`); }}>View - { handleClose(); navigate(`/admin/${config.name}/edit/${id}`); }}>Edit - { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete - - - - - {Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => ( - - - {field.label} - - - - - - ))} - - - - - - - ); -} - -function getFormattedDisplayValue(item: any, displayFormat: string) { - if (!item) return ""; - - return resolveTemplate(displayFormat, item); -} - -function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) { - const value = params.value; - const isPk = fieldKey === config.primaryKey; - - if (field.formatter) return field.formatter(value); - - const customRenderer = components?.cellRenderers?.[field.type as string]; - if (customRenderer) { - return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile }); - } - - // 1. Single Relation - if (field.relation && value && !Array.isArray(value)) { - const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value; - const displayValue = getFormattedDisplayValue(value, field.displayFormat); - - return ( - { - e.stopPropagation(); - if (relationId) onNavigate?.(field.relation!, String(relationId)); - }} - sx={{ cursor: 'pointer' }} - /> - ); - } - - // 2. Multi-Select (Array of relations or simple strings) - if (field.type === 'array' && Array.isArray(value)) { - const enumValue = field.enumOption?.value; - const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', '); - - return ( - - - {value.map((item, idx) => ( - { - e.stopPropagation(); - if (field.relation) { - const id = typeof item === 'object' ? (item.id || item._id) : item; - if (id) onNavigate?.(field.relation!, String(id)); - } - }} - /> - ))} - - - ); - } - - // 3. Simple Objects - if (field.type === 'object' && value) { - return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value)); - } - - if (field.type === 'number' && typeof value === 'number') { - const isNegative = value < 0; - const color = isNegative ? 'error' : 'success'; - - return ( - alpha(theme.palette[color].main, 0.15), - color: (theme) => theme.palette[color].dark, - '& .MuiChip-label': { px: 1.5 } - }} - /> - ); - } - - if (field.type === 'boolean') { - return value ? ( - - ) : ( - - ); - } - - if (field.type === 'datetime') return value ? new Date(value).toLocaleString() : ''; - if (field.type === 'date') return value ? new Date(value).toLocaleDateString() : ''; - - - if (field.type === 'enum') { - const opt = getFieldOptions(field).find(o => o.key === value); - return opt?.value ?? value; - } - - if (isPk && !isMobile) { - return ( - { e.stopPropagation(); navigate(`/admin/${config.name}/${params.row[config.primaryKey]}`); }} - sx={{ cursor: 'pointer', fontWeight: 'bold' }} - /> - ); - } - - return value; -} diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx deleted file mode 100644 index de9bb62..0000000 --- a/react-openapi/components/FilterBar.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import * as React from "react"; -import { - Box, - Button, - Chip, - Paper, - TextField, - Autocomplete, - Typography, -} from "@mui/material"; -import DoneIcon from "@mui/icons-material/Done"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import { ResourceField, ResourceMode } from "../types/config"; -import { FilterBarComponents, FieldComponents } from "../types/overrides"; -import { getFieldOptions, resolveTemplate } from "../utils/options"; - -export function FilterAutocomplete({ - options, - value, - label, - onChange, -}: { - options: string[]; - value: string[]; - label: string; - onChange: (val: string[]) => void; -}) { - const listboxRef = React.useRef(null); - const scrollPosRef = React.useRef(0); - const [open, setOpen] = React.useState(false); - const [frozenValue, setFrozenValue] = React.useState(value); - - const toggleDropdown = () => { - setOpen(prev => { - const next = !prev; - setFrozenValue(value); - return next; - }); - }; - const sortedOptions = React.useMemo(() => { - const sel = new Set(frozenValue); - const picked: string[] = []; - const rest: string[] = []; - for (const o of options) { - if (sel.has(o)) picked.push(o); - else rest.push(o); - } - return [...picked, ...rest]; - }, [options, frozenValue]); - - return ( - option} - onChange={(_, val) => onChange(val.length > 0 ? val : [])} - ListboxProps={{ - ref: listboxRef, - onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; }, - }} - renderOption={(props, option, { selected }) => { - const { key, ...rest } = props; - return ( -
  • - {selected ? : } - {option} -
  • - ); - }} - renderTags={(tagValue, getTagProps) => { - const maxChips = 1; - return ( - <> - {tagValue.slice(0, maxChips).map((tag, index) => { - const { key, ...tagProps } = getTagProps({ index }); - return 10 ? `${tag.slice(0, 8)}..` : tag} - size="small" - onClick={toggleDropdown} - sx={{ cursor: 'pointer' }} - />; - })} - {tagValue.length > maxChips && ( - - )} - - ); - }} - renderInput={(params) => } - sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }} - /> - ); -} - -function extractOptions( - fieldName: string, - field: ResourceField, - data: any[] -): string[] { - const values = new Set(); - - if (field.type === 'enum') { - return getFieldOptions(field).map(o => o.value); - } - if (!data) return []; - - const pull = (item: any): string | null => { - if (item == null) return null; - if (typeof item === "string") return item; - if (typeof item !== "object") return String(item); - - if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item); - - // Use displayFormat if defined - if (field.displayFormat) { - return resolveTemplate(field.displayFormat, item); - } - - return null; - }; - - for (const row of data) { - const v = row[fieldName]; - if (v == null) continue; - - if (Array.isArray(v)) { - for (const el of v) { - const label = pull(el); - if (label) values.add(label); - } - } else { - const label = pull(v); - if (label) values.add(label); - } - } - - // console.log('extracted', fieldName, Array.from(values).sort()) - return Array.from(values).sort(); -} - -function renderFilterInput( - fieldName: string, - field: ResourceField, - options: string[], - value: any, - onChange: (key: string, val: any) => void, - components?: FilterBarComponents, - fieldComponents?: FieldComponents, -) { - const filterType = field.filterType; - - if (filterType === "number-range") { - const RangeComponent = fieldComponents?.numberRange; - if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`); - const rangeVal = (value as { min?: string; max?: string }) || {}; - return onChange("value", val)} />; - } - - if (filterType === "date-range") { - const RangeComponent = fieldComponents?.dateRange; - if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`); - const rangeVal = (value as { start?: string; end?: string }) || {}; - return onChange("value", val)} />; - } - - const selected = Array.isArray(value) ? value : []; - - return ( - onChange("value", val.length > 0 ? val : undefined)} - /> - ); -} - -export interface FilterBarProps { - fields: Record; - filterableFields: string[]; - mode: ResourceMode; - data?: any[]; - appliedValues: Record; - onApply: (values: Record) => void; - onClear: () => void; - components?: FilterBarComponents; - fieldComponents?: FieldComponents; -} - -export default function FilterBar({ - fields, - filterableFields, - data, - appliedValues, - onApply, - onClear, - components: filterComponents, - fieldComponents, -}: FilterBarProps) { - const [open, setOpen] = React.useState(false); - const [draft, setDraft] = React.useState>(() => ({ ...appliedValues })); - - React.useEffect(() => { - if (!open) setDraft({ ...appliedValues }); - }, [appliedValues, open]); - - if (!filterableFields || filterableFields.length === 0) return null; - - const activeCount = Object.keys(appliedValues).filter((k) => { - const v = appliedValues[k]; - if (v == null || v === "") return false; - if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false; - return true; - }).length; - - const handleApply = () => onApply({ ...draft }); - const handleClear = () => { - setDraft({}); - onClear(); - }; - - const updateDraft = (fieldName: string, key: string, val: any) => { - setDraft((prev) => { - if (key === "value") { - return { ...prev, [fieldName]: val }; - } - const existing = prev[fieldName] || {}; - return { ...prev, [fieldName]: { ...existing, [key]: val } }; - }); - }; - - return ( - - setOpen((o) => !o)} - > - - - - {open ? "Hide Filters" : "Show Filters"} - - - {activeCount > 0 && ( - - {activeCount} active - - )} - - - {open && ( - - - {filterableFields.map((fieldName) => { - const field = fields[fieldName]; - if (!field) return null; - - const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect"; - const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : []; - const raw = draft[fieldName]; - - return ( - - - {field.label} - - {renderFilterInput(fieldName, field, options, raw, (key, val) => - updateDraft(fieldName, key, val), filterComponents, fieldComponents - )} - - ); - })} - - - - - - - - )} - - ); -} diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx deleted file mode 100644 index 9a51076..0000000 --- a/react-openapi/components/GenericForm.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import * as React from 'react'; -import { - Box, - Button, - Typography, - Divider, - CircularProgress, -} from '@mui/material'; -import { ResourceConfig } from '../types/config'; -import { FieldComponents } from '../types/overrides'; -import { useUpload } from '../providers/UploadProvider'; -import { useQueries } from '@tanstack/react-query'; -import { useResource } from '../hooks/useResource'; -import FormField from './fields/FormField'; -import { ConfigContext } from '../providers/ConfigContext'; - -interface GenericFormProps { - config: ResourceConfig; - initialData?: any; - onSave: (data: any) => Promise; - onCancel: () => void; - loading?: boolean; - readOnly?: boolean; - onEditClick?: () => void; - fieldComponents: FieldComponents; -} - -export default function GenericForm({ - config, - initialData = {}, - onSave, - onCancel, - loading: saving, - readOnly = false, - onEditClick, - fieldComponents, -}: GenericFormProps) { - initialData = initialData || {}; - const [formData, setFormData] = React.useState(initialData); - const { uploadFile, uploading } = useUpload(); - const appConfig = React.useContext(ConfigContext); - - // 1. Identify all unique relations in the schema (including nested ones) - const getRelationFields = (fields: Record): string[] => { - let relations: string[] = []; - Object.values(fields).forEach(field => { - if (field.relation) relations.push(field.relation); - if (field.schema) relations = [...relations, ...getRelationFields(field.schema)]; - }); - return Array.from(new Set(relations)); - }; - - const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]); - - // 2. Parallel fetch for all related resource lists - const queries = useQueries({ - queries: allRelations.map(relName => { - const relatedRes = appConfig?.resources.find(r => r.name === relName); - // eslint-disable-next-line react-hooks/rules-of-hooks - const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents }); - return { - ...getListQueryOptions(), - enabled: !!relatedRes, - }; - }), - }); - - const isLoadingRelations = queries.some(q => q.isLoading); - - const relationDataMap = React.useMemo(() => { - const map: Record = {}; - allRelations.forEach((relName, index) => { - // @ts-ignore - map[relName] = queries[index].data || []; - }); - return map; - }, [allRelations, queries]); - - const handleChange = (key: string, value: any) => { - if (readOnly) return; - setFormData((prev: any) => ({ ...prev, [key]: value })); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (readOnly) return; - onSave(formData); - }; - - const getTitle = () => { - if (readOnly) return `View ${config.label}`; - return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`; - }; - - if (isLoadingRelations) { - return ( - - - Loading relationships... - - ); - } - - return ( - - - {getTitle()} - - - - {Object.entries(config.fields).map(([key, field]) => ( - handleChange(key, val)} - disabled={readOnly || field.readOnly} - uploadFile={uploadFile} - uploading={uploading} - baseUrl={appConfig?.baseUrl || ""} - relationDataMap={relationDataMap} - components={fieldComponents} - /> - ))} - - - - {readOnly ? ( - - ) : ( - - )} - - - ); -} diff --git a/react-openapi/components/ProfileView.tsx b/react-openapi/components/ProfileView.tsx deleted file mode 100644 index 7ebc57d..0000000 --- a/react-openapi/components/ProfileView.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import * as React from 'react'; -import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material'; -import { useResource } from '../hooks/useResource'; -import GenericForm from './GenericForm'; -import { ConfigContext } from '../providers/ConfigContext'; -import { defaultFieldComponents } from './fields/DefaultFieldComponents'; - -export default function ProfileView() { - const appConfig = React.useContext(ConfigContext); - const profileConfig = appConfig?.profile; - const resourceConfig = appConfig?.resources.find(r => r.name === profileConfig?.resource); - - if (!profileConfig || !resourceConfig) { - return Profile configuration not found.; - } - - const editableConfig = React.useMemo(() => { - const newFields = { ...resourceConfig.fields }; - const extraFields = profileConfig.extraFields || []; - - Object.keys(newFields).forEach(key => { - newFields[key] = { - ...newFields[key], - readOnly: !extraFields.includes(key), - }; - }); - - return { - ...resourceConfig, - fields: newFields, - }; - }, [resourceConfig, profileConfig.extraFields]); - - const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents }); - const { data: profile, isLoading, error } = useMe(); - const updateMutation = useUpdateMe(); - - const handleSave = async (formData: any) => { - try { - const extraFields = profileConfig.extraFields || []; - const dataToSave = Object.keys(formData) - .filter(key => extraFields.includes(key)) - .reduce((obj: any, key) => { - obj[key] = formData[key]; - return obj; - }, {}); - - await updateMutation.mutateAsync(dataToSave); - } catch (err) { - console.error('Profile update failed:', err); - } - }; - - if (isLoading) { - return ( - - - - ); - } - - if (error) { - return Failed to load profile data.; - } - - return ( - - - My Profile - - - window.history.back()} - loading={updateMutation.isPending} - fieldComponents={defaultFieldComponents} - /> - - - ); -} diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx deleted file mode 100644 index 26c7bbb..0000000 --- a/react-openapi/components/ResourceView.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import * as React from 'react'; -import { Box, Paper, CircularProgress } from '@mui/material'; -import { ResourceConfig } from '../types/config'; -import type { ResourceField } from '../types/config'; -import { FieldComponents } from '../types/overrides'; -import { useResource } from '../hooks/useResource'; -import { resolveTemplate } from '../utils/options'; -import EnhancedTable from './EnhancedTable'; -import FilterBar from './FilterBar'; -import { useParams, useLocation, useNavigate } from 'react-router-dom'; - -interface ResourceViewProps { - config: ResourceConfig; - onNavigateToResource?: (resourceName: string, id: string) => void; - fieldComponents: FieldComponents; -} - -import { GridPaginationModel } from '@mui/x-data-grid'; - -function getDisplayString(item: any, field: ResourceField): string { - if (item == null || typeof item !== 'object') return String(item ?? ''); - if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item); - if (field.displayFormat) return resolveTemplate(field.displayFormat, item); - throw new Error('cannot get display string') -} - -function applyClientFilters( - data: any[], - filters: Record, - fields: Record -): any[] { - const entries = Object.entries(filters).filter(([_, v]) => { - if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) return false; - if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false; - return true; - }); - - if (entries.length === 0) return data; - - return data.filter((item) => - entries.every(([fieldName, filterValue]) => { - const field = fields[fieldName]; - if (!field) return true; - - const itemValue = item[fieldName]; - - if (typeof filterValue === "object" && !Array.isArray(filterValue)) { - if (field.type === "number") { - if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false; - if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false; - return true; - } - if (field.type === "datetime" || field.type === "date") { - const itemTime = new Date(itemValue).getTime(); - if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false; - if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false; - return true; - } - return true; - } - - if (Array.isArray(filterValue)) { - if (field.type === "array" && Array.isArray(itemValue)) { - return itemValue.some((el: any) => - filterValue.includes(getDisplayString(el, field)) - ); - } - if (itemValue && typeof itemValue === "object") { - return filterValue.includes(getDisplayString(itemValue, field)); - } - return filterValue.includes(String(itemValue)); - } - - if (!filterValue) return true; - - if (field.type === "boolean") { - return String(itemValue) === filterValue; - } - - if (field.type === "array" && Array.isArray(itemValue)) { - return itemValue.some((el: any) => - getDisplayString(el, field) === String(filterValue) - ); - } - - if (itemValue && typeof itemValue === "object") { - return getDisplayString(itemValue, field) === String(filterValue); - } - - return String(itemValue) === String(filterValue); - }) - ); -} - -export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) { - const { id } = useParams(); - const location = useLocation(); - const navigate = useNavigate(); - - const isCreate = location.pathname.endsWith('/create'); - const isEdit = location.pathname.includes('/edit/'); - const isView = !!id && !isEdit; - const isList = !id && !isCreate; - - const isServer = config.filterOptions?.mode !== "client"; - - const [paginationModel, setPaginationModel] = React.useState({ - page: 0, - pageSize: 10, - }); - - const [appliedFilters, setAppliedFilters] = React.useState>({}); - - const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents }); - - const queryParams = React.useMemo(() => { - if (!isServer) return { limit: 10000 }; - return { - skip: paginationModel.page * paginationModel.pageSize, - limit: paginationModel.pageSize, - }; - }, [isServer, paginationModel]); - - const listQuery = useList(queryParams); - const itemQuery = useRead(id || ""); - - const rawData = listQuery.data?.data || []; - const totalCount = listQuery.data?.total; - - const filteredData = React.useMemo( - () => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)), - [isServer, rawData, appliedFilters, config.fields] - ); - - const createMutation = useCreate(); - const updateMutation = useUpdate(); - const deleteMutation = useDelete(); - - const handleEdit = (item: any) => { - navigate(`/admin/${config.name}/edit/${item[config.primaryKey]}`); - }; - - const handleCreate = () => { - navigate(`/admin/${config.name}/create`); - }; - - const handleSave = async (formData: any) => { - try { - if (isEdit) { - await updateMutation.mutateAsync({ id: id!, data: formData }); - } else { - await createMutation.mutateAsync(formData); - } - navigate(`/admin/${config.name}`); - } catch (err) { - console.error('Save failed:', err); - } - }; - - const handleDelete = async (itemId: string) => { - if (window.confirm('Are you sure you want to delete this item?')) { - await deleteMutation.mutateAsync(itemId); - } - }; - - if (isList && listQuery.isLoading) return ; - if ((isEdit || isView) && itemQuery.isLoading) return ; - - return ( - - {isList ? ( - - {!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && ( - setAppliedFilters({})} - fieldComponents={components} - /> - )} - navigate(`/admin/${res}/${id}`)} - /> - - ) : ( - - {components && navigate(`/admin/${config.name}`)} - loading={createMutation.isPending || updateMutation.isPending} - readOnly={isView} - onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)} - />} - - )} - - ); -} diff --git a/react-openapi/components/fields/BooleanField.tsx b/react-openapi/components/fields/BooleanField.tsx deleted file mode 100644 index 1deb5e3..0000000 --- a/react-openapi/components/fields/BooleanField.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { FormControlLabel, Checkbox } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) { - return ( - onChange(e.target.checked)} - disabled={disabled} - /> - } - label={field.label} - /> - ); -} diff --git a/react-openapi/components/fields/DateField.tsx b/react-openapi/components/fields/DateField.tsx deleted file mode 100644 index 04b1f22..0000000 --- a/react-openapi/components/fields/DateField.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TextField as MuiTextField } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) { - const isDatetime = field.type === 'datetime'; - return ( - onChange(e.target.value)} - disabled={disabled} - required={field.required} - /> - ); -} diff --git a/react-openapi/components/fields/DateRangeField.tsx b/react-openapi/components/fields/DateRangeField.tsx deleted file mode 100644 index b8458f2..0000000 --- a/react-openapi/components/fields/DateRangeField.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Box, TextField as MuiTextField } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function DateRangeField({ value, onChange, disabled }: FieldComponentProps) { - const rangeVal = (value as { start?: string; end?: string }) || {}; - return ( - - onChange({ ...rangeVal, start: e.target.value || undefined })} - InputLabelProps={{ shrink: true }} - sx={{ width: 170 }} - disabled={disabled} - /> - onChange({ ...rangeVal, end: e.target.value || undefined })} - InputLabelProps={{ shrink: true }} - sx={{ width: 170 }} - disabled={disabled} - /> - - ); -} diff --git a/react-openapi/components/fields/DefaultFieldComponents.ts b/react-openapi/components/fields/DefaultFieldComponents.ts deleted file mode 100644 index c124dba..0000000 --- a/react-openapi/components/fields/DefaultFieldComponents.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; -import { FieldComponents, FieldComponentProps } from '../../types/overrides'; -import TextFieldEntry from './TextField'; -import NumberField from './NumberField'; -import BooleanField from './BooleanField'; -import DateField from './DateField'; -import EnumField from './EnumField'; -import RelationField from './RelationField'; -import ImageUploadField from './ImageUploadField'; -import FallbackField from './FallbackField'; -import DateRangeField from './DateRangeField'; -import NumberRangeField from './NumberRangeField'; - -const WrappedImageUploadField = (props: FieldComponentProps) => - React.createElement(ImageUploadField, { - label: props.field.label, - value: props.value || '', - onUpload: async (file: File) => { - const url = await props.uploadFile?.(file); - if (url) props.onChange(url); - }, - uploading: props.uploading, - baseUrl: props.baseUrl || '', - disabled: props.disabled, - }); - -export const defaultFieldComponents: FieldComponents = { - string: TextFieldEntry, - markdown: TextFieldEntry, - number: NumberField, - boolean: BooleanField, - date: DateField, - datetime: DateField, - enum: EnumField, - image: WrappedImageUploadField, - relation: RelationField, - default: FallbackField, - dateRange: DateRangeField, - numberRange: NumberRangeField, -}; diff --git a/react-openapi/components/fields/EnumField.tsx b/react-openapi/components/fields/EnumField.tsx deleted file mode 100644 index 0633f8a..0000000 --- a/react-openapi/components/fields/EnumField.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; -import { getFieldOptions } from '../../utils/options'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) { - const options = getFieldOptions(field); - return ( - - {field.label} - - - ); -} diff --git a/react-openapi/components/fields/FallbackField.tsx b/react-openapi/components/fields/FallbackField.tsx deleted file mode 100644 index f5d6dd8..0000000 --- a/react-openapi/components/fields/FallbackField.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { TextField } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function FallbackField({ field, value }: FieldComponentProps) { - return ( - - ); -} diff --git a/react-openapi/components/fields/FormField.tsx b/react-openapi/components/fields/FormField.tsx deleted file mode 100644 index ff06784..0000000 --- a/react-openapi/components/fields/FormField.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from 'react'; -import { ResourceField } from '../../types/config'; -import { FieldComponentProps, FieldComponents } from '../../types/overrides'; -import ObjectField from './ObjectField'; - -export interface FormFieldProps { - name: string; - field: ResourceField; - value: any; - onChange: (val: any) => void; - disabled?: boolean; - uploadFile?: (file: File) => Promise; - uploading?: boolean; - baseUrl?: string; - relationDataMap?: Record; - components: FieldComponents; -} - -export default function FormField({ - name, - field, - value, - onChange, - disabled, - uploadFile, - uploading, - baseUrl, - relationDataMap = {}, - components, -}: FormFieldProps) { - const fieldProps: FieldComponentProps = { - name, - field, - value, - onChange, - disabled, - baseUrl, - relationDataMap, - uploadFile, - uploading, - }; - - const childComponents = components; - - // 1. Object (recursive) - requires parent FormField for recursion - if (field.type === 'object' && field.schema && !field.relation) { - const renderChild = (childProps: FieldComponentProps) => ( - - ); - return ; - } - - // 2. Image - if (field.type === 'image') { - const ImageField = components.image; - if (!ImageField) return null; - return ; - } - - // 3. Relation - if (field.relation && relationDataMap[field.relation]) { - const RelationFieldComp = components.relation; - if (!RelationFieldComp) return null; - return ; - } - - // 4. Lookup by field type - const Component = components[field.type] || components.default; - if (Component) { - return ; - } - - return null; -} diff --git a/react-openapi/components/fields/ImageUploadField.tsx b/react-openapi/components/fields/ImageUploadField.tsx deleted file mode 100644 index 9183f60..0000000 --- a/react-openapi/components/fields/ImageUploadField.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material"; - -interface ImageUploadFieldProps { - label?: string; - value: string; - uploading?: boolean; - onUpload: (file: File) => void; - size?: number; - baseUrl: string; - disabled?: boolean; -} - -export default function ImageUploadField({ - label = "Upload Image", - value, - uploading = false, - onUpload, - size = 64, - baseUrl, - disabled = false, -}: ImageUploadFieldProps) { - - const imgSrc = value - ? baseUrl.replace(/\/+$/, "") + - "/" + - value.replace(/^\/+/, "") - : ""; - - return ( - - {label} - - - - {!disabled && ( - - )} - - - ); -} diff --git a/react-openapi/components/fields/NumberField.tsx b/react-openapi/components/fields/NumberField.tsx deleted file mode 100644 index 677bf1a..0000000 --- a/react-openapi/components/fields/NumberField.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { TextField as MuiTextField } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) { - return ( - onChange(e.target.value === '' ? '' : Number(e.target.value))} - disabled={disabled} - required={field.required} - /> - ); -} diff --git a/react-openapi/components/fields/NumberRangeField.tsx b/react-openapi/components/fields/NumberRangeField.tsx deleted file mode 100644 index ecaa36f..0000000 --- a/react-openapi/components/fields/NumberRangeField.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Box, TextField as MuiTextField } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function NumberRangeField({ value, onChange, disabled }: FieldComponentProps) { - const rangeVal = (value as { min?: string; max?: string }) || {}; - return ( - - onChange({ ...rangeVal, min: e.target.value || undefined })} - sx={{ width: 100 }} - disabled={disabled} - /> - onChange({ ...rangeVal, max: e.target.value || undefined })} - sx={{ width: 100 }} - disabled={disabled} - /> - - ); -} diff --git a/react-openapi/components/fields/ObjectField.tsx b/react-openapi/components/fields/ObjectField.tsx deleted file mode 100644 index 0d0789e..0000000 --- a/react-openapi/components/fields/ObjectField.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from 'react'; -import { Box, Typography } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export interface ObjectFieldProps extends FieldComponentProps { - renderField: (props: FieldComponentProps) => React.ReactNode; -} - -export default function ObjectField({ name, field, value, onChange, disabled, baseUrl, uploadFile, uploading, relationDataMap, renderField }: ObjectFieldProps) { - if (!field.schema) return null; - - return ( - - - {field.label} - - - {Object.entries(field.schema).map(([subKey, subField]) => - React.cloneElement( - renderField({ - name: `${name}.${subKey}`, - field: subField, - value: value?.[subKey], - onChange: (newVal: any) => { - const updated = { ...(value || {}), [subKey]: newVal }; - onChange(updated); - }, - disabled, - baseUrl, - uploadFile, - uploading, - relationDataMap, - }) as React.ReactElement, - { key: subKey } - ) - )} - - - - - ); -} diff --git a/react-openapi/components/fields/RelationField.tsx b/react-openapi/components/fields/RelationField.tsx deleted file mode 100644 index 0c28a02..0000000 --- a/react-openapi/components/fields/RelationField.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; -import { getFieldOptions } from '../../utils/options'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function RelationField({ field, value, onChange, disabled, relationDataMap = {} }: FieldComponentProps) { - if (!field.relation || !relationDataMap[field.relation]) { - return null; - } - - const relationData = relationDataMap[field.relation]; - const isArrayRelation = field.type === 'array'; - const options = getFieldOptions(field, relationData); - const keyField = field.enumOption?.key ?? 'id'; - - const normalizedValue = (() => { - if (isArrayRelation && Array.isArray(value)) { - return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v))); - } - if (value != null && typeof value === 'object') { - return String(value[keyField] ?? ''); - } - return value ?? (isArrayRelation ? [] : ""); - })(); - - return ( - - {field.label} - - - ); -} diff --git a/react-openapi/components/fields/TextField.tsx b/react-openapi/components/fields/TextField.tsx deleted file mode 100644 index 1dd6df0..0000000 --- a/react-openapi/components/fields/TextField.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TextField as MuiTextField } from '@mui/material'; -import { FieldComponentProps } from '../../types/overrides'; - -export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) { - const isMarkdown = field.type === 'markdown'; - return ( - onChange(e.target.value)} - disabled={disabled} - required={field.required} - /> - ); -} diff --git a/react-openapi/components/fields/index.ts b/react-openapi/components/fields/index.ts deleted file mode 100644 index 9baa425..0000000 --- a/react-openapi/components/fields/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { default as FormField } from './FormField'; -export { default as ImageUploadField } from './ImageUploadField'; -export { default as TextField } from './TextField'; -export { default as NumberField } from './NumberField'; -export { default as BooleanField } from './BooleanField'; -export { default as DateField } from './DateField'; -export { default as EnumField } from './EnumField'; -export { default as RelationField } from './RelationField'; -export { default as ObjectField } from './ObjectField'; -export { default as FallbackField } from './FallbackField'; -export { default as DateRangeField } from './DateRangeField'; -export { default as NumberRangeField } from './NumberRangeField'; -export { defaultFieldComponents } from './DefaultFieldComponents'; -export type { ObjectFieldProps } from './ObjectField'; diff --git a/react-openapi/config.ts b/react-openapi/config.ts deleted file mode 100644 index d628a7e..0000000 --- a/react-openapi/config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AppConfig } from "./types/config"; -import { loadConfigFromOpenApi } from "./utils/openapi_loader"; - -export async function getAppConfig( - resourceOverrides: Record = {}, - profileConfig: any = {} -): Promise { - // @ts-ignore - const baseUrl = import.meta.env.VITE_API_BASE_URL - - // @ts-ignore - const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL - const config = await loadConfigFromOpenApi(baseUrl, resourceOverrides, profileConfig); - - // You can still apply overrides here - return { - ...config, - authBaseUrl: authBaseUrl, - baseUrl: baseUrl, - }; -} diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts deleted file mode 100644 index 5f0bf28..0000000 --- a/react-openapi/hooks/useResource.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; -import * as React from "react"; -import { api } from "../api/client"; -import { ResourceConfig } from "../types/config"; -import { ConfigContext } from "../providers/ConfigContext"; -import { FieldComponents, FieldComponentProps } from "../types/overrides"; -import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents"; -import FormField from "../components/fields/FormField"; -import GenericForm from "../components/GenericForm"; - -function wrapFormField(merged: FieldComponents) { - return (props: Omit, 'components'>) => - React.createElement(FormField, { ...props, components: merged }); -} - -function wrapGenericForm(merged: FieldComponents) { - return (props: Omit, 'fieldComponents'>) => - React.createElement(GenericForm, { ...props, fieldComponents: merged }); -} - -export function useResource(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) { - const queryClient = useQueryClient(); - - const { name = '', endpoint = '', primaryKey = 'id' } = config || {}; - - const mergedComponents = React.useMemo( - () => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined, - [options?.fieldComponents], - ); - - // --- READ ALL --- - const useList = (params?: any) => - useQuery({ - queryKey: [name, "list", params], - queryFn: async () => { - if (!endpoint) return { data: [], total: 0 }; - const res = await api.get(endpoint, { params }); - const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; - return { - data: res.data, - total: isNaN(total as any) ? undefined : total - }; - }, - enabled: !!endpoint, - placeholderData: keepPreviousData, - }); - - // --- READ ONE --- - const useRead = (id: string, params?: any | null) => - useQuery({ - queryKey: [name, "detail", id, params], - queryFn: async () => { - if (!id || !endpoint) return null; - const res = await api.get(`${endpoint}/${id}`, params ? { params } : undefined); - return res.data; - }, - enabled: !!id && !!endpoint, - }); - - // --- CREATE --- - const useCreate = () => - useMutation({ - mutationFn: async (data: Partial) => { - if (!endpoint) throw new Error("Endpoint not defined"); - const res = await api.post(endpoint, data); - return res.data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [name, "list"] }); - }, - }); - - // --- UPDATE --- - const useUpdate = () => - useMutation({ - mutationFn: async ({ id, data }: { id: string; data: Partial }) => { - if (!endpoint) throw new Error("Endpoint not defined"); - const res = await api.put(`${endpoint}/${id}`, data); - return res.data; - }, - onSuccess: (updatedItem: any) => { - const id = updatedItem[primaryKey]; - queryClient.invalidateQueries({ queryKey: [name, "list"] }); - queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); - }, - }); - - // --- PATCH --- - const usePatch = () => - useMutation({ - mutationFn: async ({ id, data }: { id: string; data: Partial }) => { - if (!endpoint) throw new Error("Endpoint not defined"); - const res = await api.patch(`${endpoint}/${id}`, data); - return res.data; - }, - onSuccess: (updatedItem: any) => { - const listId = updatedItem[primaryKey]; - queryClient.invalidateQueries({ queryKey: [name, "list"] }); - queryClient.invalidateQueries({ queryKey: [name, "detail", listId] }); - }, - }); - - // --- DELETE --- - const useDelete = () => - useMutation({ - mutationFn: async (id: string) => { - if (!endpoint) throw new Error("Endpoint not defined"); - await api.delete(`${endpoint}/${id}`); - return id; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [name, "list"] }); - }, - }); - - // --- HELPERS FOR useQueries --- - const getListQueryOptions = (params?: any) => ({ - queryKey: [name, "list", params], - queryFn: async () => { - if (!endpoint) return { data: [], total: 0 }; - const res = await api.get(endpoint, { params }); - const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; - return { - data: res.data, - total: isNaN(total as any) ? undefined : total - }; - }, - enabled: !!endpoint, - }); - - // --- READ ME --- - const useMe = () => - useQuery({ - queryKey: [name, "me"], - queryFn: async () => { - if (!endpoint) return null; - const res = await api.get(`${endpoint}/me`); - return res.data; - }, - enabled: !!endpoint, - }); - - // --- UPDATE ME --- - const useUpdateMe = () => - useMutation({ - mutationFn: async (data: Partial) => { - if (!endpoint) throw new Error("Endpoint not defined"); - const res = await api.put(`${endpoint}/me`, data); - return res.data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [name, "me"] }); - queryClient.invalidateQueries({ queryKey: [name, "list"] }); - }, - }); - - const components = React.useMemo(() => { - if (!mergedComponents) return undefined; - return { - ...mergedComponents, - FormField: wrapFormField(mergedComponents), - GenericForm: wrapGenericForm(mergedComponents), - }; - }, [mergedComponents]); - - return { - useList, - useRead, - useMe, - useCreate, - useUpdate, - usePatch, - useUpdateMe, - useDelete, - getListQueryOptions, - components, - }; -} - -export function useResourceByName(name: string, options?: { fieldComponents: FieldComponents }) { - const config = React.useContext(ConfigContext); - const resourceConfig = config?.resources.find((r) => r.name === name); - return useResource(resourceConfig, options); -} diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 6f60374..a26a3c6 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -1,13 +1,5 @@ -export { default as Admin } from "./Admin"; -export { api, auth, initializeApiClients } from "./api/client"; -export { getAppConfig } from "./config"; -export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; -export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides"; -export { AppProvider } from "./providers/AppProvider"; -export { ConfigContext, useConfig } from "./providers/ConfigContext"; -export { useResource, useResourceByName } from "./hooks/useResource"; -export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar"; -export { default as EnhancedTable } from "./components/EnhancedTable"; -export { default as GenericForm } from "./components/GenericForm"; -export { default as ResourceView } from "./components/ResourceView"; -export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields"; +export { AppProvider } from "./src/context/AppProvider"; +export { Admin } from "./src/components/Admin"; +export { useAppContext } from "./src/context/AppContext"; +export { useResource } from "./src/context/useResource"; +export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types"; diff --git a/react-openapi/providers/AppProvider.tsx b/react-openapi/providers/AppProvider.tsx deleted file mode 100644 index c8c9646..0000000 --- a/react-openapi/providers/AppProvider.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ConfigContext } from "./ConfigContext"; -import { getAppConfig } from "../config"; -import { initializeApiClients } from "../api/client"; -import { AppConfig } from "../types/config"; -import { Box, CircularProgress } from "@mui/material"; - -const defaultQueryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); - -interface AppProviderProps { - children: React.ReactNode; - resourceOverrides?: Record; - profileConfig?: any; - queryClient?: QueryClient; -} - -export function AppProvider({ - children, - resourceOverrides = {}, - profileConfig = {}, - queryClient = defaultQueryClient, -}: AppProviderProps) { - const [config, setConfig] = React.useState(null); - const [loading, setLoading] = React.useState(true); - - React.useEffect(() => { - getAppConfig(resourceOverrides, profileConfig) - .then((cfg) => { - initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); - setConfig(cfg); - setLoading(false); - }) - .catch((err) => { - console.error("Failed to load OpenAPI configuration:", err); - setLoading(false); - }); - }, [resourceOverrides, profileConfig]); - - if (loading) { - return ( - - - - ); - } - - return ( - - - {children} - - - ); -} diff --git a/react-openapi/providers/ConfigContext.tsx b/react-openapi/providers/ConfigContext.tsx deleted file mode 100644 index 5e96db0..0000000 --- a/react-openapi/providers/ConfigContext.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import * as React from "react"; -import { AppConfig } from "../types/config"; - -export const ConfigContext = React.createContext(null); - -export function useConfig() { - const context = React.useContext(ConfigContext); - if (context === undefined) { - throw new Error("useConfig must be used within a ConfigProvider"); - } - return context; -} diff --git a/react-openapi/providers/UploadProvider.tsx b/react-openapi/providers/UploadProvider.tsx deleted file mode 100644 index ed73a47..0000000 --- a/react-openapi/providers/UploadProvider.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { createContext, useContext, useState } from "react"; -import { api } from "../api/client"; - -export interface UploadContextModel { - uploadFile: (file: File) => Promise; - uploading: boolean; - error: string | null; -} - -const UploadContext = createContext(undefined); - -export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); - - const uploadFile = async (file: File): Promise => { - setUploading(true); - setError(null); - - try { - const arrayBuffer = await file.arrayBuffer(); - const binary = new Uint8Array(arrayBuffer); - - const res = await api.post("/uploads", binary, { - headers: { - "Content-Type": file.type, - "Content-Disposition": `attachment; filename="${file.name}"`, - }, - }); - - return res.data.url as string; - } catch (err: any) { - console.error("File upload failed:", err); - setError(err.response?.data?.detail || "Failed to upload file"); - return null; - } finally { - setUploading(false); - } - }; - - return ( - - {children} - - ); -}; - -export const useUpload = (): UploadContextModel => { - const ctx = useContext(UploadContext); - if (!ctx) throw new Error("useUpload must be used within UploadProvider"); - return ctx; -}; diff --git a/react-openapi/src/components/Admin.tsx b/react-openapi/src/components/Admin.tsx new file mode 100644 index 0000000..206d40b --- /dev/null +++ b/react-openapi/src/components/Admin.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { Box, CircularProgress } from "@mui/material"; +import { useAppContext } from "../context/AppContext"; +import { Layout } from "./Layout"; +import { ResourceList } from "./ResourceList"; +import { ResourceForm } from "./ResourceForm"; +import { ResourceDetail } from "./ResourceDetail"; +import { ValidationAlert } from "./ValidationAlert"; + +interface AdminProps { + basePath: string; +} + +export function Admin({ basePath }: AdminProps) { + const { resources, loading, errors, warnings } = useAppContext(); + + if (loading) { + return ( + + + + ); + } + + if (errors.length > 0) { + return ; + } + + if (resources.length === 0) { + return ( + + No resources found in the OpenAPI spec with x-resource defined. + + ); + } + + return ( + <> + {warnings.length > 0 && } + + + } /> + {resources.map((r) => ( + + } /> + } /> + } /> + } /> + + ))} + + + + ); +} diff --git a/react-openapi/src/components/Layout.tsx b/react-openapi/src/components/Layout.tsx new file mode 100644 index 0000000..d67aae8 --- /dev/null +++ b/react-openapi/src/components/Layout.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Box, Toolbar, IconButton, Typography } from "@mui/material"; +import MenuIcon from "@mui/icons-material/Menu"; +import { SideMenu } from "./SideMenu"; +import type { ResourceConfig } from "../types"; + +interface LayoutProps { + resources: ResourceConfig[]; + basePath: string; + children: React.ReactNode; +} + +export function Layout({ resources, basePath, children }: LayoutProps) { + const [mobileOpen, setMobileOpen] = React.useState(false); + + return ( + + setMobileOpen(false)} + /> + + + setMobileOpen(true)} + sx={{ mr: 2, display: { md: "none" } }} + > + + + + Admin Panel + + + {children} + + + ); +} diff --git a/react-openapi/src/components/ResourceDetail.tsx b/react-openapi/src/components/ResourceDetail.tsx new file mode 100644 index 0000000..8b4b9c9 --- /dev/null +++ b/react-openapi/src/components/ResourceDetail.tsx @@ -0,0 +1,97 @@ +import React, { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Typography, + Button, + Paper, + Grid, + CircularProgress, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import EditIcon from "@mui/icons-material/Edit"; +import type { ResourceConfig } from "../types"; +import { useResource } from "../context/useResource"; +import { useAppContext } from "../context/AppContext"; +import { DetailFieldRenderer, applyDisplayFormat } from "./fields"; + +interface ResourceDetailProps { + resource: ResourceConfig; + basePath: string; +} + +export function ResourceDetail({ resource, basePath }: ResourceDetailProps) { + const navigate = useNavigate(); + const { id } = useParams(); + const crud = useResource(resource); + const { resources: allResources } = useAppContext(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (id) { + setLoading(true); + crud + .get(id) + .then(setData) + .catch(() => navigate(`${basePath}/${resource.name}`)) + .finally(() => setLoading(false)); + } + }, [id]); + + if (loading) { + return ( + + + + ); + } + + if (!data) { + return ( + + Record not found + + ); + } + + const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail); + + return ( + + + + + {applyDisplayFormat(data, resource.displayFormat)} + + {resource.operations.update && ( + + )} + + + + + {visibleFields.map((field) => { + let value = data[field.name]; + let fmt = resource.displayFormat; + if (field.fk && typeof value === "object") { + const targetRes = allResources.find((r) => r.name === field.fk!.resource); + fmt = targetRes!.displayFormat; + } else if (field.refSchema && !field.fk && typeof value === "object") { + fmt = field.inlineDisplayFormat ?? resource.displayFormat; + } + return ( + + + + ); + })} + + + + ); +} diff --git a/react-openapi/src/components/ResourceForm.tsx b/react-openapi/src/components/ResourceForm.tsx new file mode 100644 index 0000000..281afb3 --- /dev/null +++ b/react-openapi/src/components/ResourceForm.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Box, + Typography, + Button, + Paper, + Grid, + CircularProgress, + Alert, + Snackbar, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import SaveIcon from "@mui/icons-material/Save"; +import type { ResourceConfig, FieldConfig } from "../types"; +import { useResource } from "../context/useResource"; +import { useAppContext } from "../context/AppContext"; +import { getApi } from "../hooks/useApi"; +import { FormFieldRenderer } from "./fields"; +import { extractFields } from "../transformers/field-config"; + +interface ResourceFormProps { + resource: ResourceConfig; + basePath: string; + mode: "create" | "edit"; +} + +export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) { + const navigate = useNavigate(); + const { id } = useParams(); + const crud = useResource(resource); + const { resources: allResources } = useAppContext(); + const [formData, setFormData] = useState>({}); + const [errors, setErrors] = useState>({}); + const [saving, setSaving] = useState(false); + const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({ + open: false, + message: "", + severity: "success", + }); + + const [fkOptions, setFkOptions] = useState>({}); + const [fkLoading, setFkLoading] = useState>({}); + const { schemas } = useAppContext(); + + useEffect(() => { + console.log(`[ResourceForm] mounted resource="${resource.name}" mode=${mode} id=${id}`); + console.log(`[ResourceForm] relationships:`, resource.relationships.map(r => `{field=${r.fieldName} target=${r.config.resource} prefetch=${r.config.prefetch}}`)); + }, []); + + useEffect(() => { + if (mode === "create") { + const initial: Record = {}; + resource.orderedFields.forEach((f) => { + if (f.refSchema && !f.fk && formData[f.name] === undefined) { + const refSchemaObj = schemas[f.refSchema!]; + if (refSchemaObj) { + const nestedFields = extractFields(f.refSchema!, refSchemaObj, schemas); + initial[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas); + } else { + initial[f.name] = null; + } + } + }); + if (Object.keys(initial).length > 0) { + setFormData((prev) => ({ ...prev, ...initial })); + } + } + }, [mode, resource.name]); + + const loadFkOptions = useCallback(async (fieldName: string, fk: { resource: string; prefetch: boolean }) => { + console.log(`[loadFkOptions] CALLED field="${fieldName}" resource="${fk.resource}" prefetch=${fk.prefetch}`); + setFkLoading((prev) => ({ ...prev, [fieldName]: true })); + try { + const targetRes = allResources.find((r) => r.name === fk.resource); + if (!targetRes) { + console.log(`[loadFkOptions] targetRes NOT FOUND for "${fk.resource}"`); + return; + } + console.log(`[loadFkOptions] targetRes found: path="${targetRes.path}" pagination=${!!targetRes.pagination}`); + + const api = getApi(); + const params: Record = {}; + if (targetRes.pagination) { + params.limit = 0; + } + console.log(`[loadFkOptions] fetching GET ${targetRes.path}`, params); + const res = await api.get(targetRes.path, { params }); + console.log(`[loadFkOptions] response status=${res.status} data type=${typeof res.data} isArray=${Array.isArray(res.data)}`); + + let items: any[]; + if (targetRes.pagination) { + if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) { + console.log(`[loadFkOptions] paginated parse FAILED: data=`, res.data); + throw new Error(`Expected paginated response from ${targetRes.path}`); + } + items = res.data.items; + console.log(`[loadFkOptions] paginated: total=${res.data.total} items.length=${items.length}`); + } else { + if (!Array.isArray(res.data)) { + console.log(`[loadFkOptions] non-paginated parse FAILED: data=`, res.data); + throw new Error(`Expected array response from ${targetRes.path}`); + } + items = res.data; + console.log(`[loadFkOptions] non-paginated: items.length=${items.length}`); + } + + const opts = items.map((item: any) => ({ + value: item[targetRes.primaryKey], + label: applyFormat(item, targetRes.displayFormat), + })); + console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3)); + + setFkOptions((prev) => ({ ...prev, [fieldName]: opts })); + } catch (e) { + console.log(`[loadFkOptions] ERROR field="${fieldName}":`, e); + } finally { + setFkLoading((prev) => ({ ...prev, [fieldName]: false })); + } + }, [allResources]); + + useEffect(() => { + console.log(`[prefetch effect] ${resource.relationships.length} relationships, checking prefetch...`); + resource.relationships.forEach((rel) => { + console.log(`[prefetch effect] field="${rel.fieldName}" prefetch=${rel.config.prefetch} -> ${rel.config.prefetch ? "WILL FETCH" : "skipped (onFocus)"}`); + if (rel.config.prefetch) { + loadFkOptions(rel.fieldName, rel.config); + } + }); + }, [resource.relationships, loadFkOptions]); + + useEffect(() => { + if (mode === "edit" && id) { + crud.get(id).then((data) => { + const resolved = { ...(data ?? {}) }; + resource.relationships.forEach((rel) => { + const val = resolved[rel.fieldName]; + if (val != null) { + const targetRes = allResources.find((r) => r.name === rel.config.resource); + if (targetRes) { + if (Array.isArray(val)) { + resolved[rel.fieldName] = val.map((item: any) => item[targetRes.primaryKey]); + } else if (typeof val === "object") { + resolved[rel.fieldName] = val[targetRes.primaryKey]; + } + } + if (!rel.config.prefetch) { + loadFkOptions(rel.fieldName, rel.config); + } + } + }); + setFormData(resolved); + }); + } + }, [mode, id, loadFkOptions, resource.relationships]); + + const loadFkOnFocus = (fieldName: string) => { + console.log(`[loadFkOnFocus] CALLED field="${fieldName}"`); + const rel = resource.relationships.find((r) => r.fieldName === fieldName); + if (rel) { + console.log(`[loadFkOnFocus] found rel: prefetch=${rel.config.prefetch} fkOptions[${fieldName}]=${fkOptions[fieldName] ? "exists" : "undefined"}`); + } else { + console.log(`[loadFkOnFocus] NO RELATIONSHIP found for field="${fieldName}"`); + } + if (rel && !rel.config.prefetch && !fkOptions[fieldName]) { + console.log(`[loadFkOnFocus] conditions met -> calling loadFkOptions`); + loadFkOptions(fieldName, rel.config); + } else { + console.log(`[loadFkOnFocus] NOT calling loadFkOptions: rel=${!!rel} !prefetch=${rel && !rel.config.prefetch} !hasOptions=${!fkOptions[fieldName]}`); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const validationErrors: Record = {}; + resource.orderedFields + .filter((f) => f.required && !f.readOnly && f.name !== resource.primaryKey) + .forEach((f) => { + if (formData[f.name] === undefined || formData[f.name] === null || formData[f.name] === "") { + validationErrors[f.name] = `${f.label} is required`; + } + }); + + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + setErrors({}); + setSaving(true); + + try { + if (mode === "create") { + await crud.create(formData); + setSnackbar({ open: true, message: "Created successfully", severity: "success" }); + navigate(`${basePath}/${resource.name}`); + } else { + await crud.update(id!, formData); + setSnackbar({ open: true, message: "Updated successfully", severity: "success" }); + navigate(`${basePath}/${resource.name}/${id}`); + } + } catch (e: any) { + setSnackbar({ open: true, message: e.message ?? "Operation failed", severity: "error" }); + } finally { + setSaving(false); + } + }; + + const handleChange = (fieldName: string, value: any) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + if (errors[fieldName]) { + setErrors((prev) => { + const copy = { ...prev }; + delete copy[fieldName]; + return copy; + }); + } + }; + + const title = mode === "create" ? `Create ${resource.schemaName}` : `Edit ${resource.schemaName}`; + + return ( + + + + + {title} + + + + + + + {resource.orderedFields + .filter((f) => !(f.name === resource.primaryKey && mode === "edit")) + .map((field) => ( + + handleChange(field.name, val)} + error={errors[field.name]} + fkOptions={fkOptions[field.name]} + fkLoading={fkLoading[field.name]} + recordId={id} + onFkOpen={loadFkOnFocus} + /> + + ))} + + + + + + + + + + setSnackbar((s) => ({ ...s, open: false }))} + > + setSnackbar((s) => ({ ...s, open: false }))}> + {snackbar.message} + + + + ); +} + +function applyFormat(obj: any, format: string): string { + if (!obj || typeof obj !== "object") return String(obj ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); +} + +function buildInitialShape(fields: FieldConfig[], schemas: Record): Record { + const shape: Record = {}; + for (const f of fields) { + if (f.refSchema && !f.fk) { + const refSchemaObj = schemas[f.refSchema!]; + const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : []; + shape[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas); + } else { + shape[f.name] = null; + } + } + return shape; +} diff --git a/react-openapi/src/components/ResourceList.tsx b/react-openapi/src/components/ResourceList.tsx new file mode 100644 index 0000000..60ee283 --- /dev/null +++ b/react-openapi/src/components/ResourceList.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Typography, + Button, + IconButton, + Tooltip, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Paper, + TextField, + InputAdornment, + TableSortLabel, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import SearchIcon from "@mui/icons-material/Search"; +import type { ResourceConfig, FieldConfig } from "../types"; +import { useResource } from "../context/useResource"; +import { useAppContext } from "../context/AppContext"; +import { ListCellRenderer, applyDisplayFormat } from "./fields"; + +interface ResourceListProps { + resource: ResourceConfig; + basePath: string; +} + +export function ResourceList({ resource, basePath }: ResourceListProps) { + const navigate = useNavigate(); + const crud = useResource(resource); + const { resources: allResources } = useAppContext(); + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20); + const [search, setSearch] = useState(""); + const [sortField, setSortField] = useState(null); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + + const visibleColumns = resource.listColumns + .map((colName) => resource.fields.find((f) => f.name === colName)) + .filter((f): f is FieldConfig => !!f && !f.hidden?.list); + + const fetchData = useCallback(async () => { + const params: Record = {}; + if (resource.pagination) { + params[resource.pagination.limitParam] = rowsPerPage; + params[resource.pagination.offsetParam] = page * rowsPerPage; + } + if (sortField) { + params.sort = sortDir === "desc" ? `-${sortField}` : sortField; + } + const result = await crud.list(params); + setData(result.items ?? []); + setTotal(result.total ?? result.items?.length ?? 0); + }, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleDelete = async (id: string | number) => { + if (!window.confirm("Are you sure you want to delete this item?")) return; + await crud.remove(id); + fetchData(); + }; + + const handleSort = (field: string) => { + if (sortField === field) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDir("asc"); + } + }; + + return ( + + + + {resource.schemaName} + + {resource.operations.create && ( + + )} + + + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ minWidth: 280 }} + /> + + + + + + + {visibleColumns.map((col) => ( + + {col.sortable ? ( + handleSort(col.name)} + > + {col.label} + + ) : ( + col.label + )} + + ))} + Actions + + + + {data.length === 0 ? ( + + + + No records found + + + + ) : ( + data.map((row) => { + const rowId = row[resource.primaryKey]; + return ( + navigate(`${basePath}/${resource.name}/${rowId}`)} + > + {visibleColumns.map((col) => { + let value = row[col.name]; + let fmt = resource.displayFormat; + if (col.fk) { + const targetRes = allResources.find((r) => r.name === col.fk!.resource); + fmt = targetRes!.displayFormat; + } else if (col.refSchema && !col.fk && col.inlineDisplayFormat) { + fmt = col.inlineDisplayFormat; + } + return ( + + + + ); + })} + e.stopPropagation()}> + {resource.operations.get && ( + + navigate(`${basePath}/${resource.name}/${rowId}`)}> + + + + )} + {resource.operations.update && ( + + navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> + + + + )} + {resource.operations.delete && ( + + handleDelete(rowId)} color="error"> + + + + )} + + + ); + }) + )} + +
    +
    + + {resource.pagination && ( + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[10, 20, 50, 100]} + /> + )} +
    + ); +} diff --git a/react-openapi/src/components/SideMenu.tsx b/react-openapi/src/components/SideMenu.tsx new file mode 100644 index 0000000..d0bfb7e --- /dev/null +++ b/react-openapi/src/components/SideMenu.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, + Typography, + Box, + useMediaQuery, + useTheme, +} from "@mui/material"; +import CircleIcon from "@mui/icons-material/Circle"; +import type { ResourceConfig } from "../types"; + +interface SideMenuProps { + resources: ResourceConfig[]; + basePath: string; + mobileOpen: boolean; + onClose: () => void; +} + +const drawerWidth = 260; + +export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuProps) { + const navigate = useNavigate(); + const location = useLocation(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + const colors = [ + "#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", + "#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16", + ]; + + const content = ( + + + + Admin Panel + + + + {resources.map((r, i) => { + const listPath = `${basePath}/${r.name}`; + const active = location.pathname.startsWith(listPath); + return ( + { + navigate(listPath); + if (isMobile) onClose(); + }} + sx={{ + borderRadius: 2, + mb: 0.5, + "&.Mui-selected": { + bgcolor: `${colors[i % colors.length]}15`, + "&:hover": { bgcolor: `${colors[i % colors.length]}20` }, + }, + }} + > + + + + + + ); + })} + + + ); + + if (isMobile) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} + +export { drawerWidth }; diff --git a/react-openapi/src/components/ValidationAlert.tsx b/react-openapi/src/components/ValidationAlert.tsx new file mode 100644 index 0000000..928e994 --- /dev/null +++ b/react-openapi/src/components/ValidationAlert.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Box, Typography, Alert, Snackbar, List, ListItem, ListItemIcon, ListItemText } from "@mui/material"; +import ErrorIcon from "@mui/icons-material/Error"; +import WarningIcon from "@mui/icons-material/Warning"; +import type { ValidationMessage } from "../types"; + +interface ValidationAlertProps { + errors: ValidationMessage[]; + warnings: ValidationMessage[]; +} + +export function ValidationAlert({ errors, warnings }: ValidationAlertProps) { + const [warningOpen, setWarningOpen] = React.useState(warnings.length > 0); + + if (errors.length > 0) { + return ( + + + + OpenAPI Spec Validation Failed + + + The spec has {errors.length} error{errors.length > 1 ? "s" : ""}. Fix them before the admin panel can render. + + + + {errors.map((e, i) => ( + + + + + + + ))} + + {warnings.length > 0 && ( + <> + + Warnings ({warnings.length}) + + + {warnings.map((w, i) => ( + + + + + + + ))} + + + )} + + ); + } + + return ( + setWarningOpen(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + + {warnings.map((w, i) => ( + setWarningOpen(false)}> + {w.message} + + ))} + + + ); +} diff --git a/react-openapi/src/components/fields/DetailFieldRenderer.tsx b/react-openapi/src/components/fields/DetailFieldRenderer.tsx new file mode 100644 index 0000000..6e3b8da --- /dev/null +++ b/react-openapi/src/components/fields/DetailFieldRenderer.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import type { FieldConfig } from "../../types"; +import { ListCellRenderer } from "./ListCellRenderer"; + +interface DetailFieldProps { + field: FieldConfig; + value: any; + displayFormat?: string; +} + +export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) { + if (field.hidden?.detail) return null; + + return ( + + + {field.label} + + + + ); +} diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx new file mode 100644 index 0000000..5a336a3 --- /dev/null +++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { TextField } from "@mui/material"; +import type { FieldConfig } from "../../types"; +import { StringField } from "./renderers/StringField"; +import { NumberField } from "./renderers/NumberField"; +import { DateField } from "./renderers/DateField"; +import { BooleanField } from "./renderers/BooleanField"; +import { EnumField } from "./renderers/EnumField"; +import { FkSelectField } from "./renderers/FkSelectField"; +import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; +import { ImageField } from "./renderers/ImageField"; +import { JsonField } from "./renderers/JsonField"; + +interface FormFieldProps { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + error?: string; + fkOptions?: { value: any; label: string }[]; + fkLoading?: boolean; + recordId?: string | number; + onFkOpen?: (fieldName: string) => void; +} + +export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) { + if (field.hidden?.form) return null; + + if (field.readOnly && field.uiType !== "image") { + return ( + + ); + } + + if (field.uiType === "image") { + return ( + + ); + } + + if (field.fk) { + console.log(`[FormFieldRenderer] FK field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} isArray=${field.isArray}`); + if (field.isArray) { + return ( + onFkOpen?.(field.name)} + /> + ); + } + return ( + onFkOpen?.(field.name)} + /> + ); + } + + if (field.enumValues) { + return ( + + ); + } + + if (field.type === "boolean") { + return ; + } + + if (field.type === "integer" || field.type === "number") { + return ( + + ); + } + + if (field.format === "date" || field.format === "date-time") { + return ( + + ); + } + + if (field.refSchema && !field.fk) { + return ( + + ); + } + + return ( + + ); +} diff --git a/react-openapi/src/components/fields/ListCellRenderer.tsx b/react-openapi/src/components/fields/ListCellRenderer.tsx new file mode 100644 index 0000000..0650924 --- /dev/null +++ b/react-openapi/src/components/fields/ListCellRenderer.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { Box, Typography, Chip, Avatar } from "@mui/material"; +import type { FieldConfig } from "../../types"; +import { applyDisplayFormat } from "./utils"; +import { InlineRefField } from "./renderers/InlineRefField"; + +interface ListCellProps { + field: FieldConfig; + value: any; + displayFormat?: string; +} + +export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) { + if (value === null || value === undefined) { + return ; + } + + if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") { + return ; + } + + if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) { + if (value.length === 0) { + return ; + } + return ( + + {value.map((item: any, i: number) => { + const label = typeof item === "object" + ? applyDisplayFormat(item, displayFormat ?? "") + : String(item); + return ; + })} + + ); + } + + if (field.fk && typeof value === "object" && !field.isArray) { + return {applyDisplayFormat(value, displayFormat ?? "")}; + } + + if (field.isArray && Array.isArray(value) && field.fk) { + return ( + + {value.map((item: any, i: number) => { + const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item); + return ; + })} + + ); + } + + if (field.enumValues) { + return ; + } + + if (field.uiType === "image" && value) { + return ; + } + + if (field.type === "boolean") { + return ; + } + + return {String(value)}; +} diff --git a/react-openapi/src/components/fields/index.ts b/react-openapi/src/components/fields/index.ts new file mode 100644 index 0000000..dcb48b8 --- /dev/null +++ b/react-openapi/src/components/fields/index.ts @@ -0,0 +1,5 @@ +export { FormFieldRenderer } from "./FormFieldRenderer"; +export { ListCellRenderer } from "./ListCellRenderer"; +export { DetailFieldRenderer } from "./DetailFieldRenderer"; +export { applyDisplayFormat } from "./utils"; +export { JsonField } from "./renderers/JsonField"; diff --git a/react-openapi/src/components/fields/renderers/BooleanField.tsx b/react-openapi/src/components/fields/renderers/BooleanField.tsx new file mode 100644 index 0000000..45afc1a --- /dev/null +++ b/react-openapi/src/components/fields/renderers/BooleanField.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { FormControl, FormControlLabel, Switch, FormHelperText } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; +} + +export function BooleanField({ field, value, onChange }: Props) { + return ( + + onChange(e.target.checked)} + disabled={field.readOnly} + /> + } + label={field.label} + /> + {field.description && {field.description}} + + ); +} diff --git a/react-openapi/src/components/fields/renderers/DateField.tsx b/react-openapi/src/components/fields/renderers/DateField.tsx new file mode 100644 index 0000000..fe8ef12 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/DateField.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { TextField } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + error?: string; +} + +export function DateField({ field, value, onChange, error }: Props) { + const inputType = field.format === "date" ? "date" : "datetime-local"; + + return ( + onChange(e.target.value)} + error={!!error} + helperText={error ?? field.description} + placeholder={field.description} + size="small" + disabled={field.readOnly} + InputLabelProps={{ shrink: true }} + /> + ); +} diff --git a/react-openapi/src/components/fields/renderers/EnumField.tsx b/react-openapi/src/components/fields/renderers/EnumField.tsx new file mode 100644 index 0000000..ff8838f --- /dev/null +++ b/react-openapi/src/components/fields/renderers/EnumField.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + error?: string; +} + +export function EnumField({ field, value, onChange, error }: Props) { + return ( + + {field.label} + + {field.description && {field.description}} + + ); +} diff --git a/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx b/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx new file mode 100644 index 0000000..946dfc0 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { TextField, Autocomplete } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + fkOptions?: { value: any; label: string }[]; + fkLoading?: boolean; + onOpen?: () => void; +} + +export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) { + console.log(`[FkMultiSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} value=${JSON.stringify(value)}`); + return ( + o.label} + value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []} + onChange={(_, newVal) => onChange(newVal.map((v) => v.value))} + onOpen={() => onOpen?.()} + loading={fkLoading} + renderInput={(params) => ( + + )} + size="small" + disabled={field.readOnly} + /> + ); +} diff --git a/react-openapi/src/components/fields/renderers/FkSelectField.tsx b/react-openapi/src/components/fields/renderers/FkSelectField.tsx new file mode 100644 index 0000000..c2b6013 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/FkSelectField.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + error?: string; + fkOptions?: { value: any; label: string }[]; + onOpen?: () => void; +} + +export function FkSelectField({ field, value, onChange, error, fkOptions, onOpen }: Props) { + console.log(`[FkSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} value=${value}`); + return ( + + {field.label} + + {field.description && {field.description}} + + ); +} diff --git a/react-openapi/src/components/fields/renderers/ImageField.tsx b/react-openapi/src/components/fields/renderers/ImageField.tsx new file mode 100644 index 0000000..f7bc6a3 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/ImageField.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Box, Typography, Avatar, FormHelperText } from "@mui/material"; +import Button from "@mui/material/Button"; +import type { FieldConfig } from "../../../types"; +import { getApi } from "../../../hooks/useApi"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + id?: string | number; + uploadUrl?: string; +} + +export function ImageField({ field, value, onChange, id, uploadUrl }: Props) { + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!id || !uploadUrl) { + const reader = new FileReader(); + reader.onload = () => onChange(reader.result); + reader.readAsDataURL(file); + return; + } + + const formData = new FormData(); + formData.append("file", file); + + try { + const api = getApi(); + const url = uploadUrl.replace("{id}", String(id)); + const res = await api.post(url, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + onChange(res.data.url ?? res.data); + } catch { + const reader = new FileReader(); + reader.onload = () => onChange(reader.result); + reader.readAsDataURL(file); + } + }; + + return ( + + + {field.label} + + {value ? ( + + ) : ( + + )} + {field.description} + + ); +} diff --git a/react-openapi/src/components/fields/renderers/InlineRefField.tsx b/react-openapi/src/components/fields/renderers/InlineRefField.tsx new file mode 100644 index 0000000..b067fa6 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/InlineRefField.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Box, Typography, Chip } from "@mui/material"; +import type { FieldConfig } from "../../../types"; +import { applyDisplayFormat } from "../utils"; + +interface Props { + field: FieldConfig; + value: any; + displayFormat?: string; +} + +export function InlineRefField({ field, value, displayFormat }: Props) { + if (!value || typeof value !== "object") { + return ; + } + + if (displayFormat) { + return {applyDisplayFormat(value, displayFormat)}; + } + + const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined); + if (entries.length === 0) { + return ; + } + + return ( + + {entries.map(([key, v]) => ( + + ))} + + ); +} diff --git a/react-openapi/src/components/fields/renderers/JsonField.tsx b/react-openapi/src/components/fields/renderers/JsonField.tsx new file mode 100644 index 0000000..0e94434 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/JsonField.tsx @@ -0,0 +1,270 @@ +import React, { useState } from "react"; +import { + Button, + Chip, + Box, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + IconButton, + Divider, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import type { FieldConfig } from "../../../types"; +import { useAppContext } from "../../../context/AppContext"; +import { extractFields } from "../../../transformers/field-config"; +import { FormFieldRenderer } from "../FormFieldRenderer"; + +interface JsonFieldProps { + field: FieldConfig; + value: any; + onChange: (val: any) => void; +} + +export function JsonField({ field, value, onChange }: JsonFieldProps) { + const { schemas } = useAppContext(); + const [open, setOpen] = useState(false); + + const refSchema = field.refSchema ? schemas[field.refSchema] : null; + const subFields = refSchema + ? extractFields(field.refSchema!, refSchema, schemas) + : []; + + const [editValue, setEditValue] = useState(null); + + const handleOpen = () => { + setEditValue(initEditValue(value, field, schemas)); + setOpen(true); + }; + + const handleSave = () => { + onChange(editValue); + setOpen(false); + }; + + const handleCancel = () => { + setEditValue(null); + setOpen(false); + }; + + const handleClear = () => { + onChange(null); + setOpen(false); + }; + + const handleAddItem = () => { + setEditValue((prev: any[]) => [...(prev || []), buildDefaultShape(subFields, schemas)]); + }; + + const handleRemoveItem = (index: number) => { + setEditValue((prev: any[]) => prev.filter((_: any, i: number) => i !== index)); + }; + + const handleItemFieldChange = (index: number, fieldName: string, val: any) => { + setEditValue((prev: any[]) => { + const next = [...prev]; + next[index] = { ...next[index], [fieldName]: val }; + return next; + }); + }; + + const handleFieldChange = (fieldName: string, val: any) => { + setEditValue((prev: any) => ({ ...prev, [fieldName]: val })); + }; + + if (!open) { + if (value === null || value === undefined) { + return ( + + ); + } + + if (field.isArray && Array.isArray(value)) { + if (value.length === 0) { + return ( + + ); + } + return ( + + ); + } + + if (typeof value === "object") { + const summary = field.inlineDisplayFormat + ? applyInlineFormat(value, field.inlineDisplayFormat) + : Object.entries(value) + .filter(([, v]) => v != null) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" | "); + return ( + + ); + } + } + + return ( + + {field.label} + + {field.isArray ? ( + + ) : ( + + )} + + + + + + + + ); +} + +function ObjectEditor({ + value, + subFields, + onFieldChange, +}: { + value: any; + subFields: FieldConfig[]; + onFieldChange: (name: string, val: any) => void; + schemas: Record; +}) { + return ( + + {subFields.map((subField) => ( + + onFieldChange(subField.name, val)} + /> + + ))} + + ); +} + +function ArrayEditor({ + items, + subFields, + onAddItem, + onRemoveItem, + onFieldChange, + schemas, +}: { + items: any[]; + subFields: FieldConfig[]; + onAddItem: () => void; + onRemoveItem: (index: number) => void; + onFieldChange: (index: number, name: string, val: any) => void; + schemas: Record; +}) { + return ( + + {items.length === 0 && ( + + No items added yet. + + )} + {items.map((item, index) => ( + + + + Item {index + 1} + + onRemoveItem(index)}> + + + + + {subFields.map((subField) => ( + + onFieldChange(index, subField.name, val)} + /> + + ))} + + + + ))} + + + ); +} + +function buildDefaultShape(fields: FieldConfig[], schemas: Record): Record { + const shape: Record = {}; + for (const f of fields) { + if (f.refSchema && !f.fk) { + const refSchemaObj = schemas[f.refSchema!]; + const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : []; + shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas); + } else { + shape[f.name] = null; + } + } + return shape; +} + +function initEditValue(value: any, field: FieldConfig, schemas: Record): any { + if (field.isArray) { + return value ? value.map((item: any) => ({ ...item })) : []; + } + if (value && typeof value === "object") { + return { ...value }; + } + return buildDefaultShape( + field.refSchema ? extractFields(field.refSchema, schemas[field.refSchema], schemas) : [], + schemas + ); +} + +function applyInlineFormat(obj: any, format: string): string { + if (!obj || typeof obj !== "object") return String(obj ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); +} diff --git a/react-openapi/src/components/fields/renderers/NumberField.tsx b/react-openapi/src/components/fields/renderers/NumberField.tsx new file mode 100644 index 0000000..5b2b483 --- /dev/null +++ b/react-openapi/src/components/fields/renderers/NumberField.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { TextField } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + error?: string; +} + +export function NumberField({ field, value, onChange, error }: Props) { + const isFloat = field.type === "number" || field.format === "float"; + + return ( + { + const raw = e.target.value; + if (raw === "") { + onChange(""); + } else { + onChange(isFloat ? parseFloat(raw) : parseInt(raw, 10)); + } + }} + error={!!error} + helperText={error ?? field.description} + placeholder={field.description} + size="small" + disabled={field.readOnly} + inputProps={isFloat ? { step: "any" } : undefined} + /> + ); +} diff --git a/react-openapi/src/components/fields/renderers/StringField.tsx b/react-openapi/src/components/fields/renderers/StringField.tsx new file mode 100644 index 0000000..88cc54f --- /dev/null +++ b/react-openapi/src/components/fields/renderers/StringField.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { TextField } from "@mui/material"; +import type { FieldConfig } from "../../../types"; + +interface Props { + field: FieldConfig; + value: any; + onChange: (value: any) => void; + error?: string; +} + +export function StringField({ field, value, onChange, error }: Props) { + const inputType = field.format === "email" ? "email" : "text"; + + return ( + onChange(e.target.value)} + error={!!error} + helperText={error ?? field.description} + placeholder={field.description} + size="small" + disabled={field.readOnly} + /> + ); +} diff --git a/react-openapi/src/components/fields/utils.ts b/react-openapi/src/components/fields/utils.ts new file mode 100644 index 0000000..c524fab --- /dev/null +++ b/react-openapi/src/components/fields/utils.ts @@ -0,0 +1,4 @@ +export function applyDisplayFormat(item: any, format: string): string { + if (!item || typeof item !== "object") return String(item ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(item[key] ?? "")); +} diff --git a/react-openapi/src/context/AppContext.tsx b/react-openapi/src/context/AppContext.tsx new file mode 100644 index 0000000..7d4bb42 --- /dev/null +++ b/react-openapi/src/context/AppContext.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from "react"; +import type { ResourceConfig, SpecConfiguration, ValidationMessage } from "../types"; + +export interface AppContextValue { + config: SpecConfiguration; + resources: ResourceConfig[]; + schemas: Record; + loading: boolean; + errors: ValidationMessage[]; + warnings: ValidationMessage[]; +} + +export const AppContext = createContext(null); + +export function useAppContext(): AppContextValue { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error("useAppContext must be used within an AppProvider"); + } + return ctx; +} diff --git a/react-openapi/src/context/AppProvider.tsx b/react-openapi/src/context/AppProvider.tsx new file mode 100644 index 0000000..fffbf88 --- /dev/null +++ b/react-openapi/src/context/AppProvider.tsx @@ -0,0 +1,83 @@ +import React, { useEffect, useState, useMemo } from "react"; +import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types"; +import { AppContext } from "./AppContext"; +import { loadSpec } from "../spec-loader"; +import { validateSpec } from "../spec-validator"; +import { buildResourceConfigs } from "../transformers/resource-config"; +import { initApi } from "../hooks/useApi"; + +interface AppProviderProps { + specConfiguration: SpecConfiguration; + children: React.ReactNode; +} + +export function AppProvider({ specConfiguration, children }: AppProviderProps) { + const [loading, setLoading] = useState(true); + const [resources, setResources] = useState([]); + const [schemas, setSchemas] = useState>({}); + const [errors, setErrors] = useState([]); + const [warnings, setWarnings] = useState([]); + + useEffect(() => { + let cancelled = false; + + async function init() { + try { + setLoading(true); + + const spec = await loadSpec(specConfiguration.specUrl); + + const allMessages = validateSpec(spec); + + const errs = allMessages.filter((m) => m.type === "error"); + const warns = allMessages.filter((m) => m.type === "warning"); + + if (!cancelled) { + setErrors(errs); + setWarnings(warns); + setSchemas(spec.components?.schemas ?? {}); + } + + if (errs.length === 0) { + const configs = buildResourceConfigs(spec); + if (!cancelled) { + setResources(configs); + } + + const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? ""; + if (baseUrl) { + initApi(baseUrl, specConfiguration.getToken); + } + } + } catch (e: any) { + if (!cancelled) { + setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + init(); + + return () => { + cancelled = true; + }; + }, [specConfiguration.specUrl]); + + const value = useMemo( + () => ({ + config: specConfiguration, + resources, + schemas, + loading, + errors, + warnings, + }), + [specConfiguration, resources, schemas, loading, errors, warnings] + ); + + return React.createElement(AppContext.Provider, { value }, children); +} diff --git a/react-openapi/src/context/useResource.ts b/react-openapi/src/context/useResource.ts new file mode 100644 index 0000000..4738886 --- /dev/null +++ b/react-openapi/src/context/useResource.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from "react"; +import type { ResourceConfig, ParsedListResponse } from "../types"; +import { getApi } from "../hooks/useApi"; + +function parseError(e: any): string { + if (e.response?.data) { + const data = e.response.data; + if (Array.isArray(data)) { + return data.map((err: any) => err.msg ?? String(err)).join("; "); + } + if (typeof data.detail === "string") { + return data.detail; + } + } + return e.message ?? "An error occurred"; +} + +interface ResourceState { + loading: boolean; + error: string | null; +} + +interface UseResourceReturn { + list: (params?: Record) => Promise; + get: (id: string | number) => Promise; + create: (data: any) => Promise; + update: (id: string | number, data: any) => Promise; + remove: (id: string | number) => Promise; + loading: boolean; + error: string | null; +} + +export function useResource(resource: ResourceConfig): UseResourceReturn { + const [state, setState] = useState({ loading: false, error: null }); + + const setLoading = useCallback((loading: boolean) => { + setState((s) => ({ ...s, loading })); + }, []); + + const setError = useCallback((error: string | null) => { + setState((s) => ({ ...s, error })); + }, []); + + const list = useCallback( + async (params?: Record): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.get(resource.path, { params }); + const data = res.data; + + if (resource.pagination) { + if (!data || typeof data !== "object" || !Array.isArray(data.items)) { + throw new Error(`Expected paginated response { total, items } from ${resource.path}`); + } + return { items: data.items, total: data.total ?? data.items.length }; + } + + if (!Array.isArray(data)) { + throw new Error(`Expected array response from ${resource.path}`); + } + return { items: data }; + } catch (e: any) { + const msg = parseError(e); + setError(msg); + return { items: [] }; + } finally { + setLoading(false); + } + }, + [resource.path, resource.pagination, setLoading, setError] + ); + + const get = useCallback( + async (id: string | number): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.get(`${resource.path}/${id}`); + return res.data; + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const create = useCallback( + async (data: any): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.post(resource.path, data); + return res.data; + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const update = useCallback( + async (id: string | number, data: any): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.put(`${resource.path}/${id}`, data); + return res.data; + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const remove = useCallback( + async (id: string | number): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + await api.delete(`${resource.path}/${id}`); + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + return { list, get, create, update, remove, loading: state.loading, error: state.error }; +} diff --git a/react-openapi/src/hooks/useApi.ts b/react-openapi/src/hooks/useApi.ts new file mode 100644 index 0000000..794640a --- /dev/null +++ b/react-openapi/src/hooks/useApi.ts @@ -0,0 +1,45 @@ +import axios, { AxiosInstance } from "axios"; + +let apiClient: AxiosInstance | null = null; + +export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance { + if (apiClient && apiClient.defaults.baseURL === baseUrl) { + return apiClient; + } + + apiClient = axios.create({ + baseURL: baseUrl, + headers: { "Content-Type": "application/json" }, + }); + + apiClient.interceptors.request.use((config) => { + const token = getToken?.(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + apiClient.interceptors.response.use( + (res) => res, + (error) => { + if (error.response?.status === 401 && getToken) { + const currentToken = getToken(); + if (currentToken) { + const tokenStore = { clear: () => localStorage.removeItem("token") }; + tokenStore.clear(); + } + } + return Promise.reject(error); + } + ); + + return apiClient; +} + +export function getApi(): AxiosInstance { + if (!apiClient) { + throw new Error("API client not initialized. Make sure AppProvider is mounted."); + } + return apiClient; +} diff --git a/react-openapi/src/spec-loader.ts b/react-openapi/src/spec-loader.ts new file mode 100644 index 0000000..9db56b0 --- /dev/null +++ b/react-openapi/src/spec-loader.ts @@ -0,0 +1,17 @@ +import * as yaml from "js-yaml"; +import type { OpenApiSpec } from "./types"; + +export async function loadSpec(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch spec: ${response.status} ${response.statusText}`); + } + const text = await response.text(); + + const parsed = yaml.load(text); + if (!parsed || typeof parsed !== "object") { + throw new Error("Spec is empty or not an object"); + } + + return parsed as OpenApiSpec; +} diff --git a/react-openapi/src/spec-validator.ts b/react-openapi/src/spec-validator.ts new file mode 100644 index 0000000..6844b43 --- /dev/null +++ b/react-openapi/src/spec-validator.ts @@ -0,0 +1,129 @@ +import type { OpenApiSpec, ValidationMessage } from "./types"; + +export function validateSpec(spec: OpenApiSpec): ValidationMessage[] { + const messages: ValidationMessage[] = []; + const schemas = (spec.components?.schemas ?? {}) as Record; + const paths = spec.paths ?? {}; + + if (!spec.openapi) { + messages.push({ type: "error", message: "Missing 'openapi' version field" }); + } + + if (!spec.info?.title) { + messages.push({ type: "error", message: "Missing 'info.title'" }); + } + + if (!spec.servers?.[0]?.url) { + messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" }); + } + + for (const [schemaName, schema] of Object.entries(schemas)) { + if (!schema || typeof schema !== "object") continue; + + const isResource = typeof schema["x-resource"] === "string"; + + if (!isResource) continue; + + const resourcePath = `/${schema["x-resource"]}`; + + if (!schema["x-primary-key"]) { + messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` }); + } + + if (!schema["x-display-format"]) { + messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` }); + } + + if (!schema["x-list-columns"]) { + messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-list-columns'` }); + } + + if (Array.isArray(schema["x-list-columns"])) { + const props = schema.properties ?? {}; + for (const col of schema["x-list-columns"]) { + if (!props[col]) { + messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` }); + } + } + } + + const props = schema.properties ?? {}; + for (const [propName, _raw] of Object.entries(props)) { + const prop = _raw as any; + if (!prop || typeof prop !== "object") continue; + if (!prop["x-label"]) { + messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` }); + } + if (prop["x-order"] === undefined || prop["x-order"] === null) { + messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` }); + } + + if (prop["$ref"] && !prop["x-fk"]) { + const refName = (prop["$ref"] as string).split("/").pop(); + messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` }); + } + + if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) { + const refName = (prop.items.$ref as string).split("/").pop(); + messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` }); + } + + if (prop["x-fk"]) { + const fkResource = prop["x-fk"].resource as string; + const targetSchema = Object.entries(schemas as Record).find(([, s]) => s?.["x-resource"] === fkResource); + if (!targetSchema) { + messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` }); + } else { + const [, target] = targetSchema; + if (!target["x-display-format"]) { + messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` }); + } + if (!target["x-primary-key"]) { + messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` }); + } + } + } + } + + if (!paths[resourcePath]) { + messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` }); + continue; + } + + const collectionPath = paths[resourcePath] as any; + + if (!collectionPath?.get) { + messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` }); + } + + const listParams = collectionPath?.get?.parameters ?? []; + const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit"); + const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset"); + if (limitParam || offsetParam) { + if (!limitParam?.schema?.default) { + messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` }); + } + } + + if (!collectionPath?.post) { + messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` }); + } + + const itemPath = paths[`${resourcePath}/{id}`] as any; + if (!itemPath) { + messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` }); + } else { + if (!itemPath?.get) { + messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` }); + } + if (!itemPath?.put) { + messages.push({ type: "error", message: `"${resourcePath}/{id}" has no PUT endpoint — update not possible` }); + } + if (!itemPath?.delete) { + messages.push({ type: "error", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not possible` }); + } + } + } + + return messages; +} diff --git a/react-openapi/src/transformers/field-config.ts b/react-openapi/src/transformers/field-config.ts new file mode 100644 index 0000000..fcd7e2a --- /dev/null +++ b/react-openapi/src/transformers/field-config.ts @@ -0,0 +1,53 @@ +import type { FieldConfig } from "../types"; + +function resolveRef(ref: string): string | undefined { + return ref.split("/").pop(); +} + +export function extractFields(schemaName: string, schema: any, schemas: Record): FieldConfig[] { + const props = schema.properties ?? {}; + const requiredFields: string[] = schema.required ?? []; + + return Object.entries(props) + .filter(([, prop]: [string, any]) => prop && typeof prop === "object") + .map(([name, prop]: [string, any]) => { + const isDirectRef = !!prop.$ref; + const isItemsRef = prop.type === "array" && !!prop.items?.$ref; + const isRef = isDirectRef || isItemsRef; + + const refSchemaName = isDirectRef + ? resolveRef(prop.$ref) + : isItemsRef + ? resolveRef(prop.items.$ref) + : undefined; + + const refSchema = refSchemaName ? schemas[refSchemaName] : undefined; + + const inlineDisplayFormat = isRef && refSchema && !prop["x-fk"] + ? refSchema["x-display-format"] + : undefined; + + const field: FieldConfig = { + name, + label: prop["x-label"], + description: prop["x-description"] ?? prop["x-label"] ?? name, + type: isRef && refSchema ? "object" : (prop.type ?? "string"), + format: prop.format, + order: prop["x-order"], + hidden: prop["x-hidden"] ?? {}, + filterable: prop["x-filterable"] ?? false, + sortable: prop["x-sortable"] ?? false, + readOnly: prop.readOnly ?? false, + required: requiredFields.includes(name), + enumValues: prop.enum, + fk: prop["x-fk"], + uiType: prop["x-ui-type"], + uploadUrl: prop["x-upload-url"], + refSchema: refSchemaName, + inlineDisplayFormat, + isArray: prop.type === "array", + }; + + return field; + }); +} diff --git a/react-openapi/src/transformers/relationship-config.ts b/react-openapi/src/transformers/relationship-config.ts new file mode 100644 index 0000000..57fca73 --- /dev/null +++ b/react-openapi/src/transformers/relationship-config.ts @@ -0,0 +1,32 @@ +import type { FKFieldConfig, ResourceRelationship } from "../types"; + +export function extractRelationships(schema: any, schemas: Record): ResourceRelationship[] { + const props = schema.properties ?? {}; + const rels: ResourceRelationship[] = []; + + for (const [name, _raw] of Object.entries(props)) { + const prop = _raw as any; + if (!prop || typeof prop !== "object") continue; + + if (!prop["x-fk"]) continue; + + const fkResource = prop["x-fk"].resource as string; + const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource); + const targetSchemaName = targetEntry ? targetEntry[0] : fkResource; + + const prefetch = prop["x-fk"].prefetch ?? false; + console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`); + + rels.push({ + fieldName: name, + config: { + resource: fkResource, + prefetch, + }, + targetSchemaName, + }); + } + + console.log(`[FK] total relationships extracted: ${rels.length}`); + return rels; +} diff --git a/react-openapi/src/transformers/resource-config.ts b/react-openapi/src/transformers/resource-config.ts new file mode 100644 index 0000000..2852466 --- /dev/null +++ b/react-openapi/src/transformers/resource-config.ts @@ -0,0 +1,74 @@ +import type { OpenApiSpec, ResourceConfig, FieldConfig, ResourceRelationship } from "../types"; +import { extractFields } from "./field-config"; +import { extractRelationships } from "./relationship-config"; + +function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null { + const params = pathObj?.get?.parameters ?? []; + const limit = params.find((p: any) => p.in === "query" && p.name === "limit"); + const offset = params.find((p: any) => p.in === "query" && p.name === "offset"); + if (limit && offset) { + return { + limitParam: "limit", + offsetParam: "offset", + defaultLimit: limit.schema.default, + }; + } + return null; +} + +function hasOperation(pathObj: any, method: string): boolean { + return !!pathObj?.[method]; +} + +function sortFields(fields: FieldConfig[]): FieldConfig[] { + return [...fields].sort((a, b) => { + const orderDiff = a.order - b.order; + if (orderDiff !== 0) return orderDiff; + return a.name.localeCompare(b.name); + }); +} + +export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { + const schemas = spec.components?.schemas ?? {}; + const paths = spec.paths ?? {}; + const configs: ResourceConfig[] = []; + + for (const [schemaName, schema] of Object.entries(schemas)) { + if (!schema || typeof schema !== "object") continue; + + const resourceName = schema["x-resource"]; + if (!resourceName || typeof resourceName !== "string") continue; + + const resourcePath = `/${resourceName}`; + const itemPath = `${resourcePath}/{id}`; + const collectionPathObj = paths[resourcePath]; + const itemPathObj = paths[itemPath]; + + const fields = extractFields(schemaName, schema, schemas); + const relationships = extractRelationships(schema, schemas); + + const resource: ResourceConfig = { + name: resourceName, + schemaName, + path: resourcePath, + primaryKey: schema["x-primary-key"], + displayFormat: schema["x-display-format"], + listColumns: schema["x-list-columns"], + fields, + orderedFields: sortFields(fields), + operations: { + list: hasOperation(collectionPathObj, "get"), + get: hasOperation(itemPathObj, "get"), + create: hasOperation(collectionPathObj, "post"), + update: hasOperation(itemPathObj, "put"), + delete: hasOperation(itemPathObj, "delete"), + }, + pagination: detectPagination(collectionPathObj), + relationships, + }; + + configs.push(resource); + } + + return configs; +} diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts new file mode 100644 index 0000000..dcb7d97 --- /dev/null +++ b/react-openapi/src/types.ts @@ -0,0 +1,85 @@ +export interface SpecConfiguration { + specUrl: string; + baseApiUrl?: string; + title?: string; + getToken?: () => string | null; +} + +export interface ValidationMessage { + type: "error" | "warning" | "info"; + message: string; +} + +export interface ResourceRelationship { + fieldName: string; + config: FKFieldConfig; + targetSchemaName: string; +} + +export interface ResourceConfig { + name: string; + schemaName: string; + path: string; + primaryKey: string; + displayFormat: string; + listColumns: string[]; + fields: FieldConfig[]; + orderedFields: FieldConfig[]; + operations: { + list: boolean; + get: boolean; + create: boolean; + update: boolean; + delete: boolean; + }; + pagination: { + limitParam: string; + offsetParam: string; + defaultLimit: number; + } | null; + relationships: ResourceRelationship[]; +} + +export interface FieldConfig { + name: string; + label: string; + description: string; + type: string; + format?: string; + order: number; + hidden: { form?: boolean; list?: boolean; detail?: boolean }; + filterable: boolean; + sortable: boolean; + readOnly: boolean; + required: boolean; + enumValues?: string[]; + fk?: FKFieldConfig; + uiType?: string; + uploadUrl?: string; + refSchema?: string; + inlineDisplayFormat?: string; + isArray: boolean; +} + +export interface FKFieldConfig { + resource: string; + prefetch: boolean; +} + +export interface OpenApiSpec { + openapi: string; + info: { + title: string; + version: string; + }; + servers?: { url: string }[]; + components?: { + schemas?: Record; + }; + paths?: Record; +} + +export interface ParsedListResponse { + total?: number; + items?: any[]; +} diff --git a/react-openapi/types/config.ts b/react-openapi/types/config.ts deleted file mode 100644 index cbea504..0000000 --- a/react-openapi/types/config.ts +++ /dev/null @@ -1,65 +0,0 @@ -export type FieldType = - | 'string' - | 'number' - | 'boolean' - | 'date' - | 'datetime' - | 'markdown' - | 'enum' - | 'image' - | 'object' - | 'array'; - -export interface SelectOption { - key: string; - value: string; -} - -export interface EnumOption { - key: string; - value: string; -} - -export interface ResourceField { - displayFormat: string; - type: FieldType; - label: string; - required?: boolean; - options?: string[]; - readOnly?: boolean; - schema?: Record; - formatter?: (value: any) => string; - relation?: string; - filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; - enumOption?: EnumOption; - enumLabels?: Record; -} - -export type ResourceMode = "server" | "client"; - -export interface ResourceConfig { - name: string; - label: string; - pluralLabel: string; - endpoint: string; - primaryKey: string; - fields: Record; - pagination?: boolean; - hidden?: boolean; - filterOptions?: { - mode?: ResourceMode; - fields?: string[]; - }; - enumOption?: EnumOption; -} - -export interface AppConfig { - baseUrl: string; - authBaseUrl: string; - resources: ResourceConfig[]; - enums: Record; - profile?: { - resource: string; - extraFields?: Record; - }; -} diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts deleted file mode 100644 index 420e9cd..0000000 --- a/react-openapi/types/overrides.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { ResourceField, FieldType } from './config'; - -export interface EnumOption { - key: string; - value: string; -} - -export interface FieldOverride { - displayFormat?: string; - display?: boolean; - formatter?: (value: any) => string; - filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; - enumLabels?: Record; - // New optional properties to support custom config extensions - path?: string; - refers?: string; - // Added support for overriding the base field type and label - type?: FieldType; - label?: string; -} - -export interface ResourceOverride { - fields?: Record; - pagination?: boolean; - hidden?: boolean; - filterOptions?: { - mode?: "server" | "client"; - fields?: string[]; - }; - enumOption?: EnumOption; - // New optional property for reference‑type resources - referenceOptions?: { - enumOption?: EnumOption; - autoComplete?: boolean; - prefetch?: boolean; - }; -} - -export interface FieldComponentProps { - name: string; - field: ResourceField; - value: any; - onChange: (val: any) => void; - disabled?: boolean; - error?: string; - baseUrl?: string; - relationDataMap?: Record; - uploadFile?: (file: File) => Promise; - uploading?: boolean; -} - -export type FieldComponent = React.ComponentType; - -export type FieldComponents = Partial> & { - relation?: FieldComponent; - image?: FieldComponent; - default?: FieldComponent; - dateRange?: FieldComponent; - numberRange?: FieldComponent; - FormField?: React.ComponentType; - GenericForm?: React.ComponentType; -}; - -export interface CellRendererProps { - value: any; - row: any; - field: ResourceField; - fieldKey: string; - config: import('./config').ResourceConfig; - onNavigate?: (resourceName: string, id: string) => void; - isMobile?: boolean; -} - -export type CellRenderer = React.ComponentType; - -export interface EnhancedTableComponents { - cellRenderers?: Partial>; -} - -export interface FilterBarComponents { - filterInputs?: Record void; - options: string[]; - }>>; -} - -export type { FieldType }; diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts deleted file mode 100644 index 03cf6c0..0000000 --- a/react-openapi/utils/openapi_loader.ts +++ /dev/null @@ -1,249 +0,0 @@ -import SwaggerParser from "@apidevtools/swagger-parser"; -import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config"; - -/** - * Maps OpenAPI property types to our internal FieldType - */ -function mapOpenApiType(prop: any): FieldType { - const type = prop.type; - const format = prop.format; - - if (format === "date-time") return "datetime"; - if (format === "date") return "date"; - if (prop.enum) return "enum"; - if ( - type === "string" && - (prop.description?.toLowerCase().includes("image") || - prop.name?.toLowerCase().includes("icon")) - ) - return "image"; - - switch (type) { - case "integer": - case "number": - return "number"; - case "boolean": - return "boolean"; - case "object": - return "object"; - case "array": - return "array"; - default: - return "string"; - } -} - -/** - * Recursively converts OpenAPI schemas to ResourceField map - */ -function mergeProperties(schema: any): { properties: Record; required: string[] } { - let properties: Record = {}; - let required: string[] = []; - - if (schema.allOf) { - for (const sub of schema.allOf) { - const merged = mergeProperties(sub); - properties = { ...properties, ...merged.properties }; - required = [...required, ...merged.required]; - } - } - if (schema.properties) { - properties = { ...properties, ...schema.properties }; - } - if (schema.required) { - required = [...required, ...schema.required]; - } - return { properties, required }; -} - -function parseSchemaFields( - schema: any, - resourceName: string, - schemaToResourceMap: Map, - configuration: Record = {} -): Record { - const fields: Record = {}; - const { properties, required } = mergeProperties(schema); - const overrides = configuration[resourceName]?.fields || {}; - console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions']) - - for (const [key, prop] of Object.entries(properties) as [string, any]) { - // Resolve oneOf/anyOf by merging all branch properties - let resolvedProp = prop; - if (prop.oneOf || prop.anyOf) { - const branches = prop.oneOf || prop.anyOf; - const merged = mergeProperties({ allOf: branches }); - resolvedProp = { ...prop, type: 'object', properties: merged.properties, required: merged.required }; - } - - const type = mapOpenApiType(resolvedProp); - if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) { - throw new Error( - `OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` + - `Add an "enum" array with at least one value to the OpenAPI schema definition.` - ); - } - const override = overrides[key]; - - // Explicitly skip 'id' as it's the primary key and handled elsewhere - if (key === "id" || override?.display === false) continue; - - fields[key] = { - type, - label: - resolvedProp.title || - key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "), - required: required.includes(key), - options: resolvedProp.enum, - readOnly: - resolvedProp.readOnly || - key === "created_at" || - key === "updated_at", - ...override, - }; - - // STRICT RELATION DETECTION - // A field is a relation ONLY if its schema object (or items schema) - // exactly matches a schema that is defined as a resource. - let targetSchema = resolvedProp; - if (type === "array" && resolvedProp.items) { - targetSchema = resolvedProp.items; - } - - // Check if this schema object is registered as a resource - const relation = schemaToResourceMap.get(targetSchema); - if (relation) { - fields[key].relation = relation; - - // Propagate enumOption from target resource config, or derive from target schema - const explicitEnumOption = configuration[relation].referenceOptions.enumOption; - console.log('if relation configuration...', configuration['accounts']['referenceOptions']) - if (explicitEnumOption) { - fields[key].enumOption = explicitEnumOption; - } else { - // No explicit enumOption supplied – this is a configuration error. - // We abort loading so the problem is visible immediately. - throw new Error( - `Missing enumOption for relation "${relation}" on field "${key}". ` + - `Define referenceOptions.enumOption in the configuration for resource "${relation}".` - ); - } - - } - - // Recursively parse nested objects (only if not a relation) - if (fields[key].type === "object" && resolvedProp.properties && !relation) { - console.log('recursive configuration...', configuration['accounts']['referenceOptions']) - fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration); - } - } - - return fields; -} - -/** - * Scans paths to identify resources and their basic configuration - */ -export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record = {}, profileConfiguration: any = {}): Promise { - console.log('init configuration...', configuration['accounts']['referenceOptions']) - // Use SwaggerParser to dereference the spec. - // Dereferencing preserves object identity for $ref targets. - const api = await SwaggerParser.dereference( - new URL("/openapi.json", baseUrl).href - ); - - const resources: ResourceConfig[] = []; - const paths = api.paths || {}; - - // Group paths by base resource name - const resourcePaths: Record = {}; - for (const path of Object.keys(paths)) { - const base = path.split("/")[1]; - if (!base) continue; - - if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] }; - const methods = Object.keys(paths[path] || {}); - resourcePaths[base].methods.push(...methods); - - // Identify the list endpoint for this resource - if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) { - resourcePaths[base].listPath = path; - } - } - - // 1. Identify which schema objects correspond to which resources - const schemaToResourceMap = new Map(); - for (const [name, info] of Object.entries(resourcePaths)) { - const listPath = info.listPath || `/${name}`; - const listOp = paths[listPath]?.get; - if (!listOp) continue; - - // @ts-ignore - const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema; - let schemaObj = responseSchema; - if (responseSchema?.type === "array" && responseSchema.items) { - schemaObj = responseSchema.items; - } - - if (schemaObj) { - schemaToResourceMap.set(schemaObj, name); - resourcePaths[name].schemaObj = schemaObj; - } - } - - // 2. Generate ResourceConfig for each identified resource - for (const [name, info] of Object.entries(resourcePaths)) { - const listPath = info.listPath || `/${name}`; - const listOp = paths[listPath]?.get; - if (!listOp || !info.schemaObj) continue; - - const schema = info.schemaObj; - const label = name.charAt(0).toUpperCase() + name.slice(1, -1); - const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1); - - console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions']) - const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration); - - const resourceOverride = configuration[name] || {}; - - const fo = resourceOverride.filterOptions || {}; - - resources.push({ - name, - label: schema.title || label, - pluralLabel: pluralLabel, - endpoint: listPath, - primaryKey: "id", - fields, - pagination: resourceOverride.pagination, - hidden: resourceOverride.hidden, - filterOptions: { - mode: fo.mode || "server", - fields: fo.fields, - }, - }); - } - - // Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.) - const enums: Record = {}; - const apiDoc = api as any; - if (apiDoc.components?.schemas) { - for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) { - if (schema.enum) { - enums[name] = schema.enum; - } - } - } - - // @ts-ignore - const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "") - // @ts-ignore - const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || "" - return { - baseUrl: serverBaseUrl, - authBaseUrl: authBaseUrl, - resources, - enums, - profile: profileConfiguration, - }; -} diff --git a/react-openapi/utils/options.ts b/react-openapi/utils/options.ts deleted file mode 100644 index 2188492..0000000 --- a/react-openapi/utils/options.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ResourceField, SelectOption } from "../types/config"; - -export function resolveTemplate(template: string, item: any): string { - if (/\{(\w+)\}/.test(template)) { - return template.replace(/\{(\w+)\}/g, (_, field: string) => String(item[field] ?? '')); - } - return String(item[template] ?? ''); -} - -export function getFieldOptions(field: ResourceField, relationData?: any[]): SelectOption[] { - if (field.type === 'enum') { - return (field.options ?? []).map(opt => ({ - key: opt, - value: field.enumLabels?.[opt] ?? opt, - })); - } - - if (field.relation) { - const data = Array.isArray(relationData) ? relationData : []; - const enumOption = field.enumOption; - if (!enumOption) { - throw new Error( - `Missing enumOption for relation "${field.relation}" on field "${field}". ` + - `Define referenceOptions.enumOption in the configuration for resource "${field.relation}".` - ); - } - - return data.map(item => ({ - key: String(item[enumOption.key]), - value: resolveTemplate(enumOption.value, item), - })); - } - - return []; -} - -export function toGridValueOptions(options: SelectOption[]): { value: string; label: string }[] { - return options.map(opt => ({ value: opt.key, label: opt.value })); -}