updated react-openapi

This commit is contained in:
2026-06-17 21:03:08 +05:30
parent cd89eb4c88
commit 0a668cf98d
64 changed files with 2412 additions and 2921 deletions

View File

@@ -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 (
<Box>
<Typography variant="h4" gutterBottom>
Welcome to the Admin Panel
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data.
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 3,
mt: 4,
}}
>
{visibleResources.map((res) => (
<Paper
key={res.name}
sx={{
p: 3,
textAlign: "center",
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
}}
onClick={() => navigate(`/admin/${res.name}`)}
>
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
</Paper>
))}
</Box>
</Box>
);
}
interface AdminAppProps {
basePath: string;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
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 (
<LoginPage
mode="login"
login={login}
register={async () => {}}
loading={loading}
error={error}
onSwitchMode={() => {}}
onBack={() => {}}
currentUser={null}
/>
);
}
return (
<Layout
username={currentUser.username}
onLogout={logout}
onSelectResource={(name) => navigate(`/admin/${name}`)}
resources={visibleResources}
>
<Routes>
<Route path="/" element={<Dashboard basePath={basePath} />} />
<Route path="/profile" element={<ProfileView />} />
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
</Routes>
</Layout>
);
}
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 <Typography>Resource not found</Typography>;
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
}
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<string, any>;
profileConfig?: any;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) {
const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
React.useEffect(() => {
if (!existingConfig) {
getAppConfig(resourceOverrides, profileConfig).then((cfg) => {
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
setConfig(cfg);
});
}
}, [resourceOverrides, profileConfig, existingConfig]);
if (!config) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress />
</Box>
);
}
const content = (
<UploadProvider>
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} />
</UploadProvider>
);
if (existingConfig) {
return content;
}
return (
<ConfigContext.Provider value={config}>
{content}
</ConfigContext.Provider>
);
}

View File

@@ -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: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => {
if (!_api) throw new Error("API client not initialized");
return _api.get<T, R>(url, config);
},
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.post<T, R>(url, data, config);
},
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.put<T, R>(url, data, config);
},
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => {
if (!_api) throw new Error("API client not initialized");
return _api.delete<T, R>(url, config);
},
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.patch<T, R>(url, data, config);
},
};
export const auth = {
post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_auth) throw new Error("Auth client not initialized");
return _auth.post(...args);
},
get: (...args: Parameters<AxiosInstance["get"]>) => {
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));
}

View File

@@ -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 = (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{!isMobile && (
<>
<Box
sx={{
display: 'flex',
justifyContent: isCollapsed ? 'center' : 'flex-end',
p: 1,
}}
>
<IconButton onClick={handleSidebarToggle}>
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</IconButton>
</Box>
<Divider />
</>
)}
{/* Mobile spacing (replaces Toolbar) */}
{isMobile && (
<Box sx={{ height: (theme) => theme.spacing(7) }} />
)}
<List>
<ListItem disablePadding>
<Tooltip
title={isCollapsed && !isMobile ? 'Dashboard' : ''}
placement="right"
>
<ListItemButton
selected={location.pathname === '/admin'}
onClick={() => navigate('/admin')}
sx={{
minHeight: 48,
justifyContent:
isCollapsed && !isMobile ? 'center' : 'initial',
px: 2.5,
}}
>
<ListItemIcon
sx={{
minWidth: 0,
mr: isCollapsed && !isMobile ? 0 : 3,
justifyContent: 'center',
}}
>
<DashboardIcon
color={
location.pathname === '/admin'
? 'primary'
: 'inherit'
}
/>
</ListItemIcon>
{(!isCollapsed || isMobile) && (
<ListItemText primary="Dashboard" />
)}
</ListItemButton>
</Tooltip>
</ListItem>
</List>
<Divider />
<List sx={{ flexGrow: 1 }}>
{resources.map((res) => (
<ListItem key={res.name} disablePadding>
<Tooltip
title={
isCollapsed && !isMobile ? res.pluralLabel : ''
}
placement="right"
>
<ListItemButton
selected={activeResourceName === res.name}
onClick={() => onSelectResource(res.name)}
sx={{
minHeight: 48,
justifyContent:
isCollapsed && !isMobile
? 'center'
: 'initial',
px: 2.5,
}}
>
<ListItemIcon
sx={{
minWidth: 0,
mr: isCollapsed && !isMobile ? 0 : 3,
justifyContent: 'center',
}}
>
<TableViewIcon
color={
activeResourceName === res.name
? 'primary'
: 'inherit'
}
/>
</ListItemIcon>
{(!isCollapsed || isMobile) && (
<ListItemText primary={res.pluralLabel} />
)}
</ListItemButton>
</Tooltip>
</ListItem>
))}
</List>
</Box>
);
return (
<Box sx={{ display: 'flex' }}>
{/* NAV */}
<Box
component="nav"
sx={{ width: { md: currentWidth }, flexShrink: { md: 0 } }}
>
{isMobile ? (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
width: drawerWidth,
},
}}
>
{drawerContent}
</Drawer>
) : (
<Drawer
variant="permanent"
open
sx={{
display: { xs: 'none', md: 'block' },
width: currentWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
[`& .MuiDrawer-paper`]: {
width: currentWidth,
overflowX: 'hidden',
transition: theme.transitions.create('width'),
},
}}
>
{drawerContent}
</Drawer>
)}
</Box>
{/* MAIN */}
<Box
component="main"
sx={{
flexGrow: 1,
p: { xs: 2, md: 3 },
width: {
xs: '100%',
md: `calc(100% - ${currentWidth}px)`,
},
}}
>
{/* Control row (replaces AppBar) */}
{isMobile && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 2,
height: (theme) => theme.spacing(7),
}}
>
<IconButton onClick={handleDrawerToggle}>
<MenuIcon />
</IconButton>
</Box>
)}
{children}
</Box>
</Box>
);
}

View File

@@ -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<GridPaginationModel>({
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) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} />
};
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) => [
<GridActionsCellItem
icon={<VisibilityIcon />}
label="View"
onClick={() => navigate(`/admin/${config.name}/${params.id}`)}
/>,
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
onClick={() => navigate(`/admin/${config.name}/edit/${params.id}`)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
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 (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate} size="small">
Add
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mobileData.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow
row={row}
config={config}
onEdit={onEdit}
onDelete={onDelete}
onNavigate={onNavigateToResource}
navigate={navigate}
components={tableComponents}
/>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<Button size="small" disabled={mobilePage === 0} onClick={() => setMobilePage(mobilePage - 1)}>
Previous
</Button>
<Typography variant="body2" sx={{ alignSelf: 'center', px: 1 }}>
Page {mobilePage + 1} of {mobileTotalPages}
</Typography>
<Button size="small" disabled={mobilePage >= mobileTotalPages - 1} onClick={() => setMobilePage(mobilePage + 1)}>
Next
</Button>
</Box>
</Box>
);
}
return (
<Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label}
</Button>
</Box>
<DataGrid
rows={data || []}
columns={columns}
autoHeight
paginationMode={isServer ? 'server' : 'client'}
{...(isServer ? {
rowCount: (() => {
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' },
}}
/>
</Box>
);
}
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const id = row[config.primaryKey];
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Card variant="outlined" sx={{ borderRadius: 2 }}>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
#{id}
</Typography>
<IconButton size="small" onClick={handleClick}>
<MoreVertIcon fontSize="small" />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/${id}`); }}>View</MenuItem>
<MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/edit/${id}`); }}>Edit</MenuItem>
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
</Menu>
</Box>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
<Box key={key}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label}
</Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} />
</Typography>
</Box>
))}
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
<Button size="small" onClick={() => navigate(`/admin/${config.name}/${id}`)}>View Details</Button>
</CardActions>
</Card>
);
}
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 (
<Chip
label={displayValue}
size="small"
variant="outlined"
color="primary"
onClick={(e) => {
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 (
<Tooltip title={tooltipTitle} arrow placement="top">
<Stack direction="row" spacing={0.5} sx={{ overflow: 'hidden', flexWrap: 'nowrap' }}>
{value.map((item, idx) => (
<Chip
key={idx}
label={getFormattedDisplayValue(item, field.displayFormat)}
size="small"
variant="filled"
sx={{ maxWidth: 120 }}
onClick={(e) => {
e.stopPropagation();
if (field.relation) {
const id = typeof item === 'object' ? (item.id || item._id) : item;
if (id) onNavigate?.(field.relation!, String(id));
}
}}
/>
))}
</Stack>
</Tooltip>
);
}
// 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 (
<Chip
label={value.toLocaleString()}
size="small"
color={color}
variant="filled"
sx={{
fontWeight: 'bold',
minWidth: 60,
// Soft background with bold text for a premium feel
bgcolor: (theme) => alpha(theme.palette[color].main, 0.15),
color: (theme) => theme.palette[color].dark,
'& .MuiChip-label': { px: 1.5 }
}}
/>
);
}
if (field.type === 'boolean') {
return value ? (
<Chip label="Yes" size="small" color="success" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
) : (
<Chip label="No" size="small" color="default" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
);
}
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 (
<Chip
label={value}
size="small"
color="primary"
onClick={(e) => { e.stopPropagation(); navigate(`/admin/${config.name}/${params.row[config.primaryKey]}`); }}
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
/>
);
}
return value;
}

View File

@@ -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<HTMLUListElement>(null);
const scrollPosRef = React.useRef(0);
const [open, setOpen] = React.useState(false);
const [frozenValue, setFrozenValue] = React.useState<string[]>(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 (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
open={open}
onOpen={toggleDropdown}
onClose={toggleDropdown}
options={sortedOptions}
value={value}
getOptionKey={(option) => 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 (
<li key={key} {...rest}>
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: 'primary.main' }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip
key={key}
{...tagProps}
label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>;
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>
)}
</>
);
}}
renderInput={(params) => <TextField {...params} placeholder={`Add ${label}...`} />}
sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }}
/>
);
}
function extractOptions(
fieldName: string,
field: ResourceField,
data: any[]
): string[] {
const values = new Set<string>();
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 <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => 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 <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
const selected = Array.isArray(value) ? value : [];
return (
<FilterAutocomplete
options={options}
value={selected}
label={field.label}
onChange={(val) => onChange("value", val.length > 0 ? val : undefined)}
/>
);
}
export interface FilterBarProps {
fields: Record<string, ResourceField>;
filterableFields: string[];
mode: ResourceMode;
data?: any[];
appliedValues: Record<string, any>;
onApply: (values: Record<string, any>) => 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<Record<string, any>>(() => ({ ...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 (
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
onClick={() => setOpen((o) => !o)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FilterListIcon fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight={600}>
{open ? "Hide Filters" : "Show Filters"}
</Typography>
</Box>
{activeCount > 0 && (
<Typography variant="caption" color="primary" fontWeight={600}>
{activeCount} active
</Typography>
)}
</Box>
{open && (
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
{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 (
<Box key={fieldName} sx={{ display: "flex", flexDirection: "column", flex: { xs: '0 0 100%', sm: 1 }, minWidth: { sm: 200 } }}>
<Box sx={{ typography: "caption", mb: 0.5, color: "text.secondary" }}>
{field.label}
</Box>
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val), filterComponents, fieldComponents
)}
</Box>
);
})}
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<Button variant="contained" onClick={handleApply}>
Apply
</Button>
<Button variant="outlined" onClick={handleClear}>
Clear
</Button>
</Box>
</Box>
)}
</Paper>
);
}

View File

@@ -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<void>;
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, any>): 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<string, any[]> = {};
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 8, gap: 2 }}>
<CircularProgress />
<Typography variant="body2" color="text.secondary">Loading relationships...</Typography>
</Box>
);
}
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="h5">
{getTitle()}
</Typography>
<Divider />
{Object.entries(config.fields).map(([key, field]) => (
<FormField
key={key}
name={key}
field={field}
value={formData[key]}
onChange={(val: any) => handleChange(key, val)}
disabled={readOnly || field.readOnly}
uploadFile={uploadFile}
uploading={uploading}
baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap}
components={fieldComponents}
/>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
<Button variant="outlined" onClick={onCancel} disabled={saving}>
{readOnly ? 'Back to List' : 'Cancel'}
</Button>
{readOnly ? (
<Button variant="contained" color="primary" onClick={onEditClick}>
Edit {config.label}
</Button>
) : (
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
Save {config.label}
</Button>
)}
</Box>
</Box>
);
}

View File

@@ -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 <Alert severity="error">Profile configuration not found.</Alert>;
}
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 (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return <Alert severity="error">Failed to load profile data.</Alert>;
}
return (
<Box sx={{ maxWidth: 800, mx: 'auto', mt: 4 }}>
<Typography variant="h4" gutterBottom>
My Profile
</Typography>
<Paper sx={{ p: 4, mt: 2 }}>
<GenericForm
config={editableConfig}
initialData={profile}
onSave={handleSave}
onCancel={() => window.history.back()}
loading={updateMutation.isPending}
fieldComponents={defaultFieldComponents}
/>
</Paper>
</Box>
);
}

View File

@@ -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<string, any>,
fields: Record<string, ResourceField>
): 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<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
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 <CircularProgress />;
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
return (
<Box>
{isList ? (
<Box>
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
<FilterBar
fields={config.fields}
filterableFields={config.filterOptions.fields}
mode={config.filterOptions?.mode || "server"}
data={rawData}
appliedValues={appliedFilters}
onApply={setAppliedFilters}
onClear={() => setAppliedFilters({})}
fieldComponents={components}
/>
)}
<EnhancedTable
config={config}
data={filteredData}
total={isServer ? totalCount : filteredData.length}
paginationModel={isServer ? paginationModel : undefined}
onPaginationModelChange={isServer ? setPaginationModel : undefined}
loading={listQuery.isFetching}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/>
</Box>
) : (
<Paper sx={{ p: 4 }}>
{components && <components.GenericForm
config={config}
initialData={isCreate ? null : itemQuery.data}
onSave={handleSave}
onCancel={() => navigate(`/admin/${config.name}`)}
loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/>}
</Paper>
)}
</Box>
);
}

View File

@@ -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 (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={field.label}
/>
);
}

View File

@@ -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 (
<MuiTextField
fullWidth
label={field.label}
type={isDatetime ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -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 (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="date"
placeholder="From"
size="small"
value={rangeVal.start ?? ""}
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
<MuiTextField
type="date"
placeholder="To"
size="small"
value={rangeVal.end ?? ""}
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -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,
};

View File

@@ -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 (
<FormControl fullWidth>
<InputLabel>{field.label}</InputLabel>
<Select
value={value || ''}
label={field.label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,13 +0,0 @@
import { TextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function FallbackField({ field, value }: FieldComponentProps) {
return (
<TextField
fullWidth
label={field.label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
}

View File

@@ -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<string | null>;
uploading?: boolean;
baseUrl?: string;
relationDataMap?: Record<string, any[]>;
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) => (
<FormField
name={childProps.name}
field={childProps.field}
value={childProps.value}
onChange={childProps.onChange}
disabled={childProps.disabled}
uploadFile={childProps.uploadFile}
uploading={childProps.uploading}
baseUrl={childProps.baseUrl}
relationDataMap={childProps.relationDataMap}
components={components}
/>
);
return <ObjectField {...fieldProps} renderField={renderChild} />;
}
// 2. Image
if (field.type === 'image') {
const ImageField = components.image;
if (!ImageField) return null;
return <ImageField {...fieldProps} />;
}
// 3. Relation
if (field.relation && relationDataMap[field.relation]) {
const RelationFieldComp = components.relation;
if (!RelationFieldComp) return null;
return <RelationFieldComp {...fieldProps} />;
}
// 4. Lookup by field type
const Component = components[field.type] || components.default;
if (Component) {
return <Component {...fieldProps} />;
}
return null;
}

View File

@@ -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 (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Avatar
src={imgSrc}
sx={{ width: size, height: size, borderRadius: 2 }}
/>
{!disabled && (
<Button
variant="outlined"
component="label"
disabled={uploading}
startIcon={uploading && <CircularProgress size={16} />}
>
{uploading ? "Uploading..." : "Choose File"}
<input
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
}}
/>
</Button>
)}
</Box>
</Box>
);
}

View File

@@ -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 (
<MuiTextField
fullWidth
label={field.label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -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 (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="number"
placeholder="Min"
size="small"
value={rangeVal.min ?? ""}
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
<MuiTextField
type="number"
placeholder="Max"
size="small"
value={rangeVal.max ?? ""}
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -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 (
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
{field.label}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{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 }
)
)}
</Box>
</Box>
);
}

View File

@@ -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 (
<FormControl fullWidth>
<InputLabel shrink>{field.label}</InputLabel>
<Select
multiple={isArrayRelation}
value={normalizedValue}
label={field.label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
}
return options.find(o => o.key === selected)?.value ?? selected;
}}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -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 (
<MuiTextField
fullWidth
label={field.label}
value={value || ''}
multiline={isMarkdown}
rows={isMarkdown ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -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';

View File

@@ -1,21 +0,0 @@
import { AppConfig } from "./types/config";
import { loadConfigFromOpenApi } from "./utils/openapi_loader";
export async function getAppConfig(
resourceOverrides: Record<string, any> = {},
profileConfig: any = {}
): Promise<AppConfig> {
// @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,
};
}

View File

@@ -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<React.ComponentProps<typeof FormField>, 'components'>) =>
React.createElement(FormField, { ...props, components: merged });
}
function wrapGenericForm(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
React.createElement(GenericForm, { ...props, fieldComponents: merged });
}
export function useResource<T = any>(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<T[]>(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<T>(`${endpoint}/${id}`, params ? { params } : undefined);
return res.data;
},
enabled: !!id && !!endpoint,
});
// --- CREATE ---
const useCreate = () =>
useMutation({
mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.post<T>(endpoint, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [name, "list"] });
},
});
// --- UPDATE ---
const useUpdate = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.put<T>(`${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<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.patch<T>(`${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<T[]>(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<T>(`${endpoint}/me`);
return res.data;
},
enabled: !!endpoint,
});
// --- UPDATE ME ---
const useUpdateMe = () =>
useMutation({
mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.put<T>(`${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<T = any>(name: string, options?: { fieldComponents: FieldComponents }) {
const config = React.useContext(ConfigContext);
const resourceConfig = config?.resources.find((r) => r.name === name);
return useResource<T>(resourceConfig, options);
}

View File

@@ -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";

View File

@@ -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<string, any>;
profileConfig?: any;
queryClient?: QueryClient;
}
export function AppProvider({
children,
resourceOverrides = {},
profileConfig = {},
queryClient = defaultQueryClient,
}: AppProviderProps) {
const [config, setConfig] = React.useState<AppConfig | null>(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 (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress />
</Box>
);
}
return (
<QueryClientProvider client={queryClient}>
<ConfigContext.Provider value={config}>
{children}
</ConfigContext.Provider>
</QueryClientProvider>
);
}

View File

@@ -1,12 +0,0 @@
import * as React from "react";
import { AppConfig } from "../types/config";
export const ConfigContext = React.createContext<AppConfig | null>(null);
export function useConfig() {
const context = React.useContext(ConfigContext);
if (context === undefined) {
throw new Error("useConfig must be used within a ConfigProvider");
}
return context;
}

View File

@@ -1,52 +0,0 @@
import React, { createContext, useContext, useState } from "react";
import { api } from "../api/client";
export interface UploadContextModel {
uploadFile: (file: File) => Promise<string | null>;
uploading: boolean;
error: string | null;
}
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = async (file: File): Promise<string | null> => {
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 (
<UploadContext.Provider value={{ uploadFile, uploading, error }}>
{children}
</UploadContext.Provider>
);
};
export const useUpload = (): UploadContextModel => {
const ctx = useContext(UploadContext);
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
return ctx;
};

View File

@@ -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 (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
</Box>
);
}
if (errors.length > 0) {
return <ValidationAlert errors={errors} warnings={warnings} />;
}
if (resources.length === 0) {
return (
<Box sx={{ p: 4, textAlign: "center" }}>
No resources found in the OpenAPI spec with x-resource defined.
</Box>
);
}
return (
<>
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
<Layout resources={resources} basePath={basePath}>
<Routes>
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} />
{resources.map((r) => (
<React.Fragment key={r.name}>
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} />
<Route path={`${r.name}/:id`} element={<ResourceDetail resource={r} basePath={basePath} />} />
<Route path={`${r.name}/:id/edit`} element={<ResourceForm resource={r} basePath={basePath} mode="edit" />} />
</React.Fragment>
))}
</Routes>
</Layout>
</>
);
}

View File

@@ -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 (
<Box sx={{ display: "flex", minHeight: "calc(100vh - 128px)" }}>
<SideMenu
resources={resources}
basePath={basePath}
mobileOpen={mobileOpen}
onClose={() => setMobileOpen(false)}
/>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar>
<IconButton
color="inherit"
edge="start"
onClick={() => setMobileOpen(true)}
sx={{ mr: 2, display: { md: "none" } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap sx={{ display: { md: "none" } }}>
Admin Panel
</Typography>
</Toolbar>
{children}
</Box>
</Box>
);
}

View File

@@ -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<any>(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 (
<Box sx={{ display: "flex", justifyContent: "center", py: 8 }}>
<CircularProgress />
</Box>
);
}
if (!data) {
return (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
Record not found
</Typography>
);
}
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
return (
<Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back
</Button>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
{applyDisplayFormat(data, resource.displayFormat)}
</Typography>
{resource.operations.update && (
<Button variant="contained" startIcon={<EditIcon />} onClick={() => navigate(`${basePath}/${resource.name}/${id}/edit`)}>
Edit
</Button>
)}
</Box>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={2}>
{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 (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} />
</Grid>
);
})}
</Grid>
</Paper>
</Box>
);
}

View File

@@ -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<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
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<Record<string, { value: any; label: string }[]>>({});
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
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<string, any> = {};
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<string, any> = {};
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<string, string> = {};
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 (
<Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back
</Button>
<Typography variant="h5" fontWeight={700}>
{title}
</Typography>
</Box>
<Paper variant="outlined" sx={{ p: 3 }}>
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={2}>
{resource.orderedFields
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer
field={field}
value={formData[field.name]}
onChange={(val) => handleChange(field.name, val)}
error={errors[field.name]}
fkOptions={fkOptions[field.name]}
fkLoading={fkLoading[field.name]}
recordId={id}
onFkOpen={loadFkOnFocus}
/>
</Grid>
))}
</Grid>
<Box sx={{ mt: 3, display: "flex", gap: 2 }}>
<Button
type="submit"
variant="contained"
startIcon={saving ? <CircularProgress size={18} color="inherit" /> : <SaveIcon />}
disabled={saving}
>
{mode === "create" ? "Create" : "Save Changes"}
</Button>
<Button variant="outlined" onClick={() => navigate(`${basePath}/${resource.name}`)}>
Cancel
</Button>
</Box>
</Box>
</Paper>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
>
<Alert severity={snackbar.severity} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}
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<string, any>): Record<string, any> {
const shape: Record<string, any> = {};
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;
}

View File

@@ -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<any[]>([]);
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<string | null>(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<string, any> = {};
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 (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Typography variant="h5" fontWeight={700}>
{resource.schemaName}
</Typography>
{resource.operations.create && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
>
Create
</Button>
)}
</Box>
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "center" }}>
<TextField
size="small"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
sx={{ minWidth: 280 }}
/>
</Box>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
{visibleColumns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
{col.sortable ? (
<TableSortLabel
active={sortField === col.name}
direction={sortField === col.name ? sortDir : "asc"}
onClick={() => handleSort(col.name)}
>
{col.label}
</TableSortLabel>
) : (
col.label
)}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length + 1} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
No records found
</Typography>
</TableCell>
</TableRow>
) : (
data.map((row) => {
const rowId = row[resource.primaryKey];
return (
<TableRow
key={rowId}
hover
sx={{ cursor: "pointer" }}
onClick={() => 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 (
<TableCell key={col.name}>
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
</TableCell>
);
})}
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{resource.operations.get && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{resource.pagination && (
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
)}
</Box>
);
}

View File

@@ -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 = (
<Box>
<Toolbar>
<Typography variant="h6" fontWeight={700} noWrap>
Admin Panel
</Typography>
</Toolbar>
<List sx={{ px: 1 }}>
{resources.map((r, i) => {
const listPath = `${basePath}/${r.name}`;
const active = location.pathname.startsWith(listPath);
return (
<ListItemButton
key={r.name}
selected={active}
onClick={() => {
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` },
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
</ListItemIcon>
<ListItemText
primary={r.schemaName}
primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }}
/>
</ListItemButton>
);
})}
</List>
</Box>
);
if (isMobile) {
return (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
"& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth },
}}
>
{content}
</Drawer>
);
}
return (
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
"& .MuiDrawer-paper": { width: drawerWidth, boxSizing: "border-box" },
}}
>
{content}
</Drawer>
);
}
export { drawerWidth };

View File

@@ -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 (
<Box sx={{ p: 4, maxWidth: 700, mx: "auto", mt: 8 }}>
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
OpenAPI Spec Validation Failed
</Typography>
<Typography variant="body2">
The spec has {errors.length} error{errors.length > 1 ? "s" : ""}. Fix them before the admin panel can render.
</Typography>
</Alert>
<List dense>
{errors.map((e, i) => (
<ListItem key={i}>
<ListItemIcon sx={{ minWidth: 36 }}>
<ErrorIcon color="error" fontSize="small" />
</ListItemIcon>
<ListItemText primary={e.message} />
</ListItem>
))}
</List>
{warnings.length > 0 && (
<>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, color: "text.secondary" }}>
Warnings ({warnings.length})
</Typography>
<List dense>
{warnings.map((w, i) => (
<ListItem key={i}>
<ListItemIcon sx={{ minWidth: 36 }}>
<WarningIcon color="warning" fontSize="small" />
</ListItemIcon>
<ListItemText primary={w.message} />
</ListItem>
))}
</List>
</>
)}
</Box>
);
}
return (
<Snackbar
open={warningOpen}
autoHideDuration={8000}
onClose={() => setWarningOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Box>
{warnings.map((w, i) => (
<Alert key={i} severity="warning" sx={{ mb: 1 }} onClose={() => setWarningOpen(false)}>
{w.message}
</Alert>
))}
</Box>
</Snackbar>
);
}

View File

@@ -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 (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label}
</Typography>
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
</Box>
);
}

View File

@@ -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 (
<StringField field={field} value={value} onChange={onChange} error={error} />
);
}
if (field.uiType === "image") {
return (
<ImageField
field={field}
value={value}
onChange={onChange}
id={recordId}
uploadUrl={field.uploadUrl}
/>
);
}
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 (
<FkMultiSelectField
field={field}
value={value}
onChange={onChange}
fkOptions={fkOptions}
fkLoading={fkLoading}
onOpen={() => onFkOpen?.(field.name)}
/>
);
}
return (
<FkSelectField
field={field}
value={value}
onChange={onChange}
error={error}
fkOptions={fkOptions}
onOpen={() => onFkOpen?.(field.name)}
/>
);
}
if (field.enumValues) {
return (
<EnumField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.type === "boolean") {
return <BooleanField field={field} value={value} onChange={onChange} />;
}
if (field.type === "integer" || field.type === "number") {
return (
<NumberField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.format === "date" || field.format === "date-time") {
return (
<DateField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.refSchema && !field.fk) {
return (
<JsonField
field={field}
value={value}
onChange={onChange}
/>
);
}
return (
<StringField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}

View File

@@ -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 <Typography variant="body2" color="text.disabled"></Typography>;
}
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />;
}
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
if (value.length === 0) {
return <Typography variant="body2" color="text.disabled"></Typography>;
}
return (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{value.map((item: any, i: number) => {
const label = typeof item === "object"
? applyDisplayFormat(item, displayFormat ?? "")
: String(item);
return <Chip key={i} label={label} size="small" variant="outlined" />;
})}
</Box>
);
}
if (field.fk && typeof value === "object" && !field.isArray) {
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
}
if (field.isArray && Array.isArray(value) && field.fk) {
return (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{value.map((item: any, i: number) => {
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
return <Chip key={i} label={label} size="small" variant="outlined" />;
})}
</Box>
);
}
if (field.enumValues) {
return <Chip label={value} size="small" />;
}
if (field.uiType === "image" && value) {
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
}
if (field.type === "boolean") {
return <Chip label={value ? "Yes" : "No"} size="small" color={value ? "success" : "default"} />;
}
return <Typography variant="body2">{String(value)}</Typography>;
}

View File

@@ -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";

View File

@@ -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 (
<FormControl component="fieldset" fullWidth size="small">
<FormControlLabel
control={
<Switch
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={field.readOnly}
/>
}
label={field.label}
/>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</FormControl>
);
}

View File

@@ -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 (
<TextField
fullWidth
label={field.label}
type={inputType}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
helperText={error ?? field.description}
placeholder={field.description}
size="small"
disabled={field.readOnly}
InputLabelProps={{ shrink: true }}
/>
);
}

View File

@@ -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 (
<FormControl fullWidth size="small" error={!!error}>
<InputLabel>{field.label}</InputLabel>
<Select
value={value ?? ""}
label={field.label}
onChange={(e) => onChange(e.target.value)}
disabled={field.readOnly}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{(field.enumValues ?? []).map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</FormControl>
);
}

View File

@@ -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 (
<Autocomplete
multiple
options={fkOptions ?? []}
getOptionLabel={(o) => o.label}
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
onOpen={() => onOpen?.()}
loading={fkLoading}
renderInput={(params) => (
<TextField {...params} label={field.label} helperText={field.description} size="small" />
)}
size="small"
disabled={field.readOnly}
/>
);
}

View File

@@ -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 (
<FormControl fullWidth size="small" error={!!error}>
<InputLabel>{field.label}</InputLabel>
<Select
value={value ?? ""}
label={field.label}
onChange={(e) => onChange(e.target.value)}
onOpen={() => onOpen?.()}
disabled={field.readOnly}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{(fkOptions ?? []).map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</FormControl>
);
}

View File

@@ -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<HTMLInputElement>) => {
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 (
<Box>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{field.label}
</Typography>
{value ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Button variant="outlined" component="label" size="small">
Upload {field.label}
<input type="file" hidden accept="image/*" onChange={handleUpload} />
</Button>
)}
<FormHelperText>{field.description}</FormHelperText>
</Box>
);
}

View File

@@ -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 <Typography variant="body2" color="text.disabled"></Typography>;
}
if (displayFormat) {
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>;
}
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined);
if (entries.length === 0) {
return <Typography variant="body2" color="text.disabled"></Typography>;
}
return (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{entries.map(([key, v]) => (
<Chip
key={key}
label={`${key}: ${String(v)}`}
size="small"
variant="outlined"
/>
))}
</Box>
);
}

View File

@@ -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<any>(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 (
<Button variant="outlined" onClick={handleOpen} size="small">
Set {field.label}
</Button>
);
}
if (field.isArray && Array.isArray(value)) {
if (value.length === 0) {
return (
<Button variant="outlined" onClick={handleOpen} size="small">
Set {field.label}
</Button>
);
}
return (
<Chip
label={`${value.length} item${value.length !== 1 ? "s" : ""}`}
size="small"
color="primary"
variant="outlined"
onClick={handleOpen}
onDelete={handleClear}
/>
);
}
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 (
<Chip
label={summary || field.label}
size="small"
color="primary"
variant="outlined"
onClick={handleOpen}
onDelete={handleClear}
/>
);
}
}
return (
<Dialog fullScreen open={open} onClose={handleCancel}>
<DialogTitle>{field.label}</DialogTitle>
<DialogContent dividers>
{field.isArray ? (
<ArrayEditor
items={editValue ?? []}
subFields={subFields}
onAddItem={handleAddItem}
onRemoveItem={handleRemoveItem}
onFieldChange={handleItemFieldChange}
schemas={schemas}
/>
) : (
<ObjectEditor
value={editValue}
subFields={subFields}
onFieldChange={handleFieldChange}
schemas={schemas}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClear} color="error">
Clear
</Button>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleSave} variant="contained">
Save
</Button>
</DialogActions>
</Dialog>
);
}
function ObjectEditor({
value,
subFields,
onFieldChange,
}: {
value: any;
subFields: FieldConfig[];
onFieldChange: (name: string, val: any) => void;
schemas: Record<string, any>;
}) {
return (
<Box>
{subFields.map((subField) => (
<Box key={subField.name} sx={{ mb: 2 }}>
<FormFieldRenderer
field={subField}
value={value?.[subField.name]}
onChange={(val) => onFieldChange(subField.name, val)}
/>
</Box>
))}
</Box>
);
}
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<string, any>;
}) {
return (
<Box>
{items.length === 0 && (
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
No items added yet.
</Typography>
)}
{items.map((item, index) => (
<Box key={index} sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
Item {index + 1}
</Typography>
<IconButton size="small" color="error" onClick={() => onRemoveItem(index)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ pl: 2 }}>
{subFields.map((subField) => (
<Box key={subField.name} sx={{ mb: 2 }}>
<FormFieldRenderer
field={subField}
value={item?.[subField.name]}
onChange={(val) => onFieldChange(index, subField.name, val)}
/>
</Box>
))}
</Box>
<Divider sx={{ mt: 2 }} />
</Box>
))}
<Button startIcon={<AddIcon />} onClick={onAddItem} variant="outlined" size="small">
Add Item
</Button>
</Box>
);
}
function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
const shape: Record<string, any> = {};
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<string, any>): 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] ?? ""));
}

View File

@@ -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 (
<TextField
fullWidth
label={field.label}
type="number"
value={value ?? ""}
onChange={(e) => {
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}
/>
);
}

View File

@@ -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 (
<TextField
fullWidth
label={field.label}
type={inputType}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
helperText={error ?? field.description}
placeholder={field.description}
size="small"
disabled={field.readOnly}
/>
);
}

View File

@@ -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] ?? ""));
}

View File

@@ -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<string, any>;
loading: boolean;
errors: ValidationMessage[];
warnings: ValidationMessage[];
}
export const AppContext = createContext<AppContextValue | null>(null);
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error("useAppContext must be used within an AppProvider");
}
return ctx;
}

View File

@@ -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<ResourceConfig[]>([]);
const [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
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);
}

View File

@@ -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<string, any>) => Promise<ParsedListResponse>;
get: (id: string | number) => Promise<any>;
create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useResource(resource: ResourceConfig): UseResourceReturn {
const [state, setState] = useState<ResourceState>({ 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<string, any>): Promise<ParsedListResponse> => {
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<any> => {
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<any> => {
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<any> => {
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<void> => {
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 };
}

View File

@@ -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;
}

View File

@@ -0,0 +1,17 @@
import * as yaml from "js-yaml";
import type { OpenApiSpec } from "./types";
export async function loadSpec(url: string): Promise<OpenApiSpec> {
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;
}

View File

@@ -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<string, any>;
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<string, any>).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;
}

View File

@@ -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<string, any>): 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;
});
}

View File

@@ -0,0 +1,32 @@
import type { FKFieldConfig, ResourceRelationship } from "../types";
export function extractRelationships(schema: any, schemas: Record<string, any>): 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;
}

View File

@@ -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;
}

View File

@@ -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<string, any>;
};
paths?: Record<string, any>;
}
export interface ParsedListResponse {
total?: number;
items?: any[];
}

View File

@@ -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<string, ResourceField>;
formatter?: (value: any) => string;
relation?: string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
}
export type ResourceMode = "server" | "client";
export interface ResourceConfig {
name: string;
label: string;
pluralLabel: string;
endpoint: string;
primaryKey: string;
fields: Record<string, ResourceField>;
pagination?: boolean;
hidden?: boolean;
filterOptions?: {
mode?: ResourceMode;
fields?: string[];
};
enumOption?: EnumOption;
}
export interface AppConfig {
baseUrl: string;
authBaseUrl: string;
resources: ResourceConfig[];
enums: Record<string, string[]>;
profile?: {
resource: string;
extraFields?: Record<string, any>;
};
}

View File

@@ -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<string, string>;
// 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<string, FieldOverride>;
pagination?: boolean;
hidden?: boolean;
filterOptions?: {
mode?: "server" | "client";
fields?: string[];
};
enumOption?: EnumOption;
// New optional property for referencetype 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<string, any[]>;
uploadFile?: (file: File) => Promise<string | null>;
uploading?: boolean;
}
export type FieldComponent = React.ComponentType<FieldComponentProps>;
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
relation?: FieldComponent;
image?: FieldComponent;
default?: FieldComponent;
dateRange?: FieldComponent;
numberRange?: FieldComponent;
FormField?: React.ComponentType<any>;
GenericForm?: React.ComponentType<any>;
};
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<CellRendererProps>;
export interface EnhancedTableComponents {
cellRenderers?: Partial<Record<FieldType, CellRenderer>>;
}
export interface FilterBarComponents {
filterInputs?: Record<string, React.ComponentType<{
field: ResourceField;
value: any;
onChange: (val: any) => void;
options: string[];
}>>;
}
export type { FieldType };

View File

@@ -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<string, any>; required: string[] } {
let properties: Record<string, any> = {};
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<any, string>,
configuration: Record<string, any> = {}
): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {};
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<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
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<string, any> = {};
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<any, string>();
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<string, string[]> = {};
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,
};
}

View File

@@ -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 }));
}