10 Commits

12 changed files with 851 additions and 398 deletions

View File

@@ -5,6 +5,9 @@ export function attachAuthInterceptors(client: AxiosInstance) {
client.interceptors.request.use((config) => { client.interceptors.request.use((config) => {
const token = tokenStore.get(); const token = tokenStore.get();
if (token) { if (token) {
if (!config.headers) {
(config as any).headers = {};
}
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;

View File

@@ -9,6 +9,14 @@ import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config"; import { AppConfig } from "./types/config";
import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import AppTheme from "../src/shared-theme/AppTheme"; import AppTheme from "../src/shared-theme/AppTheme";
import {
BrowserRouter,
Routes,
Route,
useNavigate,
useParams,
Navigate,
} from "react-router-dom";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -17,26 +25,38 @@ export const ConfigContext = React.createContext<AppConfig | null>(null);
function Dashboard() { function Dashboard() {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const navigate = useNavigate();
return ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
Welcome to the Admin Panel Welcome to the Admin Panel
</Typography> </Typography>
<Typography variant="body1"> <Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data. Select a resource from the sidebar to manage data.
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 3, gap: 3,
mt: 4, mt: 4,
}} }}
> >
{config?.resources.map((res) => ( {config?.resources.map((res) => (
<Paper key={res.name} sx={{ p: 3, textAlign: "center" }}> <Paper
<Typography variant="h6">{res.pluralLabel}</Typography> key={res.name}
sx={{
p: 3,
textAlign: "center",
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
}}
onClick={() => navigate(`/${res.name}`)}
>
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
</Paper> </Paper>
))} ))}
</Box> </Box>
@@ -47,15 +67,7 @@ function Dashboard() {
function AdminApp() { function AdminApp() {
const { currentUser, login, logout, loading, error } = useAuth(); const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const [selectedResourceName, setSelectedResourceName] = React.useState< const navigate = useNavigate();
string | null
>(null);
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null);
const handleNavigateToResource = (resourceName: string, id: string) => {
setSelectedResourceName(resourceName);
setSelectedItemId(id);
};
if (!currentUser) { if (!currentUser) {
return ( return (
@@ -72,34 +84,34 @@ function AdminApp() {
); );
} }
const selectedResource = config?.resources.find(
(r) => r.name === selectedResourceName
);
return ( return (
<AdminLayout <AdminLayout
username={currentUser.username} username={currentUser.username}
onLogout={logout} onLogout={logout}
selectedResourceName={selectedResourceName} onSelectResource={(name) => navigate(`/${name}`)}
onSelectResource={(name) => {
setSelectedResourceName(name);
setSelectedItemId(null);
}}
resources={config?.resources || []} resources={config?.resources || []}
> >
{selectedResource ? ( <Routes>
<ResourceView <Route path="/" element={<Dashboard />} />
key={`${selectedResource.name}-${selectedItemId}`} <Route path="/:resourceName" element={<ResourceRouteWrapper />} />
config={selectedResource} <Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
onNavigateToResource={handleNavigateToResource} <Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
/> <Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
) : ( </Routes>
<Dashboard />
)}
</AdminLayout> </AdminLayout>
); );
} }
function ResourceRouteWrapper() {
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} />;
}
export default function App() { export default function App() {
const [config, setConfig] = React.useState<AppConfig | null>(null); const [config, setConfig] = React.useState<AppConfig | null>(null);
@@ -133,7 +145,9 @@ export default function App() {
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={config}>
<AuthProvider authBaseUrl={config.authBaseUrl}> <AuthProvider authBaseUrl={config.authBaseUrl}>
<UploadProvider> <UploadProvider>
<AdminApp /> <BrowserRouter>
<AdminApp />
</BrowserRouter>
</UploadProvider> </UploadProvider>
</AuthProvider> </AuthProvider>
</ConfigContext.Provider> </ConfigContext.Provider>

View File

@@ -12,19 +12,26 @@ import {
ListItemIcon, ListItemIcon,
ListItemText, ListItemText,
CssBaseline, CssBaseline,
IconButton IconButton,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material'; } from '@mui/material';
import TableViewIcon from '@mui/icons-material/TableView'; import TableViewIcon from '@mui/icons-material/TableView';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
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 { ResourceConfig } from '../types/config';
import { useLocation, useNavigate } from 'react-router-dom';
const drawerWidth = 240; const drawerWidth = 240;
const collapsedWidth = 64;
interface AdminLayoutProps { interface AdminLayoutProps {
children: React.ReactNode; children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void; onSelectResource: (resourceName: string | null) => void;
selectedResourceName: string | null;
onLogout: () => void; onLogout: () => void;
username?: string; username?: string;
resources: ResourceConfig[]; resources: ResourceConfig[];
@@ -33,70 +40,212 @@ interface AdminLayoutProps {
export default function AdminLayout({ export default function AdminLayout({
children, children,
onSelectResource, onSelectResource,
selectedResourceName,
onLogout, onLogout,
username, username,
resources, resources,
}: AdminLayoutProps) { }: 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('/')[1] || null;
// AUTO-TOGGLE LOGIC
React.useEffect(() => {
if (isMobile) {
setIsCollapsed(false); // Mobile drawer is never "mini"
setMobileOpen(false); // Close on navigation
} else {
if (location.pathname === '/' || location.pathname === '') {
setIsCollapsed(false);
} else {
setIsCollapsed(true);
}
}
}, [location.pathname, isMobile]);
const currentWidth = isMobile ? drawerWidth : (isCollapsed ? collapsedWidth : drawerWidth);
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const handleSidebarToggle = () => {
setIsCollapsed(!isCollapsed);
};
const drawerContent = (
<Box sx={{ overflow: 'hidden', 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 />
</>
)}
{isMobile && <Toolbar />}
<List>
<ListItem disablePadding>
<Tooltip title={(isCollapsed && !isMobile) ? "Dashboard" : ""} placement="right">
<ListItemButton
selected={location.pathname === '/'}
onClick={() => navigate('/')}
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 === '/' ? '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 ( return (
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<CssBaseline /> <CssBaseline />
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}> <AppBar
<Toolbar> position="fixed"
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}> sx={{
Admin Panel zIndex: (theme) => theme.zIndex.drawer + 1,
</Typography> backdropFilter: 'blur(8px)',
<Typography variant="body1" sx={{ mr: 2 }}> backgroundColor: 'rgba(255, 255, 255, 0.8)',
{username} color: 'text.primary',
</Typography> boxShadow: 'none',
<IconButton color="inherit" onClick={onLogout}> borderBottom: '1px solid',
<LogoutIcon /> borderColor: 'divider',
</IconButton> }}
</Toolbar> >
</AppBar> <Toolbar>
<Drawer {isMobile && (
variant="permanent" <IconButton
sx={{ color="inherit"
width: drawerWidth, aria-label="open drawer"
flexShrink: 0, edge="start"
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' }, onClick={handleDrawerToggle}
sx={{ mr: 2 }}
>
<MenuIcon />
</IconButton>
)}
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
Admin Panel
</Typography>
<Typography variant="body1" sx={{ mr: 2, fontWeight: 500, display: { xs: 'none', sm: 'block' } }}>
{username}
</Typography>
<Tooltip title="Logout">
<IconButton color="inherit" onClick={onLogout}>
<LogoutIcon />
</IconButton>
</Tooltip>
</Toolbar>
</AppBar>
<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': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawerContent}
</Drawer>
) : (
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
width: currentWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
boxSizing: 'border-box',
transition: (theme) => theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
[`& .MuiDrawer-paper`]: {
width: currentWidth,
boxSizing: 'border-box',
overflowX: 'hidden',
transition: (theme) => theme.transitions.create('width', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
}}
open
>
{drawerContent}
</Drawer>
)}
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
p: { xs: 2, md: 3 },
width: { xs: '100%', md: `calc(100% - ${currentWidth}px)` },
transition: (theme) => theme.transitions.create(['margin', 'width'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}} }}
> >
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
<ListItem disablePadding>
<ListItemButton
selected={selectedResourceName === null}
onClick={() => onSelectResource(null)}
>
<ListItemIcon>
<DashboardIcon />
</ListItemIcon>
<ListItemText primary="Dashboard" />
</ListItemButton>
</ListItem>
</List>
<Divider />
<List>
{resources.map((res) => (
<ListItem key={res.name} disablePadding>
<ListItemButton
selected={selectedResourceName === res.name}
onClick={() => onSelectResource(res.name)}
>
<ListItemIcon>
<TableViewIcon />
</ListItemIcon>
<ListItemText primary={res.pluralLabel} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
</Box>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar /> <Toolbar />
{children} {children}
</Box> </Box>

View File

@@ -6,6 +6,15 @@ import {
IconButton, IconButton,
Link, Link,
Tooltip, Tooltip,
Card,
CardContent,
CardActions,
Grid,
Menu,
MenuItem,
useMediaQuery,
useTheme,
Divider,
} from '@mui/material'; } from '@mui/material';
import { import {
DataGrid, DataGrid,
@@ -15,7 +24,10 @@ import {
} from '@mui/x-data-grid'; } from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { ResourceConfig, ResourceField } from '../types/config'; 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';
interface EnhancedTableProps { interface EnhancedTableProps {
config: ResourceConfig; config: ResourceConfig;
@@ -34,6 +46,9 @@ export default function EnhancedTable({
onCreate, onCreate,
onNavigateToResource, onNavigateToResource,
}: EnhancedTableProps) { }: EnhancedTableProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const columns: GridColDef[] = React.useMemo(() => { const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
@@ -42,59 +57,7 @@ export default function EnhancedTable({
headerName: field.label, headerName: field.label,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
const value = params.value;
// 1. Custom Formatter
if (field.formatter) {
return field.formatter(value);
}
// 2. Relational Link
if (field.relation && value) {
const relationId = typeof value === 'object' ? value.id : value;
if (relationId) {
return (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.stopPropagation();
onNavigateToResource?.(field.relation!, relationId);
}}
>
{relationId}
</Link>
);
}
}
// 3. Nested Object / Array Display
if (field.type === 'array' && Array.isArray(value)) {
if (field.displayField) {
return value
.map((item) => (typeof item === 'object' ? item[field.displayField!] : item))
.filter(Boolean)
.join(', ');
}
return `${value.length} items`;
}
if (field.type === 'object' && value) {
if (field.displayField && value[field.displayField]) {
return value[field.displayField];
}
return JSON.stringify(value);
}
// 4. Default renderings
if (field.type === 'boolean') return value ? 'Yes' : 'No';
if (field.type === 'datetime' || field.type === 'date') {
return new Date(value).toLocaleString();
}
return value;
}
}; };
return col; return col;
}); });
@@ -103,12 +66,17 @@ export default function EnhancedTable({
field: 'actions', field: 'actions',
type: 'actions', type: 'actions',
headerName: 'Actions', headerName: 'Actions',
width: 100, width: 120,
getActions: (params) => [ getActions: (params) => [
<GridActionsCellItem
icon={<VisibilityIcon />}
label="View"
onClick={() => navigate(`/${config.name}/${params.id}`)}
/>,
<GridActionsCellItem <GridActionsCellItem
icon={<EditIcon />} icon={<EditIcon />}
label="Edit" label="Edit"
onClick={() => onEdit(params.row)} onClick={() => navigate(`/${config.name}/edit/${params.id}`)}
/>, />,
<GridActionsCellItem <GridActionsCellItem
icon={<DeleteIcon />} icon={<DeleteIcon />}
@@ -119,12 +87,39 @@ export default function EnhancedTable({
}); });
return cols; return cols;
}, [config, onEdit, onDelete, onNavigateToResource]); }, [config, onDelete, navigate, onNavigateToResource]);
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>
<Grid container spacing={2}>
{data.map((row) => (
<Grid item xs={12} key={row[config.primaryKey] || Math.random()}>
<MobileCardRow
row={row}
config={config}
onEdit={onEdit}
onDelete={onDelete}
onNavigate={onNavigateToResource}
navigate={navigate}
/>
</Grid>
))}
</Grid>
</Box>
);
}
return ( return (
<Box sx={{ height: 600, width: '100%' }}> <Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5">{config.pluralLabel}</Typography> <Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}> <Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label} Add {config.label}
</Button> </Button>
@@ -132,17 +127,14 @@ export default function EnhancedTable({
<DataGrid <DataGrid
rows={data || []} rows={data || []}
columns={columns} columns={columns}
autoHeight
getRowId={(row) => { getRowId={(row) => {
const pk = config.primaryKey; const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk]; if (row[pk] !== undefined && row[pk] !== null) return row[pk];
// Fallback: search for common ID fields const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
const fallbackKeys = ['id', 'uuid', 'pk'];
for (const key of fallbackKeys) { for (const key of fallbackKeys) {
if (row[key] !== undefined && row[key] !== null) return row[key]; if (row[key] !== undefined && row[key] !== null) return row[key];
} }
debugger;
// Absolute fallback: index (not ideal but avoids crash)
return `temp-id-${data.indexOf(row)}`; return `temp-id-${data.indexOf(row)}`;
}} }}
disableRowSelectionOnClick disableRowSelectionOnClick
@@ -151,8 +143,108 @@ export default function EnhancedTable({
paginationModel: { page: 0, pageSize: 10 }, paginationModel: { page: 0, pageSize: 10 },
}, },
}} }}
pageSizeOptions={[5, 10, 25]} pageSizeOptions={[10, 25, 50]}
sx={{
border: 'none',
'& .MuiDataGrid-cell:focus': { outline: 'none' },
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
}}
/> />
</Box> </Box>
); );
} }
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: 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(`/${config.name}/${id}`); }}>View</MenuItem>
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/edit/${id}`); }}>Edit</MenuItem>
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
</Menu>
</Box>
<Divider sx={{ mb: 2 }} />
<Grid container spacing={1}>
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
<Grid item xs={6} key={key}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography>
</Grid>
))}
</Grid>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button>
</CardActions>
</Card>
);
}
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
const value = params.value;
const isPk = fieldKey === config.primaryKey;
if (field.formatter) return field.formatter(value);
if (field.relation && value) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = typeof value === "object" ?
((field?.displayField && (value as Record<string, any>)[field.displayField]) || (value as any).id || (value as any)._id || (value as any).pk) : value;
if (relationId) {
return (
<Link component="button" variant="body2" sx={{ fontWeight: 'inherit', textAlign: 'left' }} onClick={(e) => { e.stopPropagation(); onNavigate?.(field.relation!, String(relationId)); }}>
{displayValue}
</Link>
);
}
}
if (field.type === 'array' && Array.isArray(value)) {
if (field.displayField) {
return value.map((item) => (typeof item === 'object' ? item[field.displayField!] : item)).filter(Boolean).join(', ');
}
return `${value.length} items`;
}
if (field.type === 'object' && value) {
if (field.displayField && value[field.displayField]) return value[field.displayField];
return isMobile ? 'Object' : JSON.stringify(value);
}
if (field.type === 'boolean') return value ? 'Yes' : 'No';
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (isPk && !isMobile) {
return (
<Link component="button" variant="body2" sx={{ fontWeight: 'inherit' }} onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }}>
{value}
</Link>
);
}
return value;
}

View File

@@ -1,20 +1,17 @@
import * as React from 'react'; import * as React from 'react';
import { import {
Box, Box,
TextField,
Button, Button,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Typography, Typography,
Divider, Divider,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { ResourceConfig, ResourceField } from '../types/config'; import { ResourceConfig } from '../types/config';
import { useUpload } from '../providers/UploadProvider'; import { useUpload } from '../providers/UploadProvider';
import ImageUploadField from './fields/ImageUploadField'; import { useQueries } from '@tanstack/react-query';
import { useResource } from '../hooks/useResource';
import FormField from './fields/FormField';
import { ConfigContext } from '../App';
interface GenericFormProps { interface GenericFormProps {
config: ResourceConfig; config: ResourceConfig;
@@ -22,35 +19,88 @@ interface GenericFormProps {
onSave: (data: any) => Promise<void>; onSave: (data: any) => Promise<void>;
onCancel: () => void; onCancel: () => void;
loading?: boolean; loading?: boolean;
readOnly?: boolean;
onEditClick?: () => void;
} }
import { ConfigContext } from '../App';
export default function GenericForm({ export default function GenericForm({
config, config,
initialData = {}, initialData = {},
onSave, onSave,
onCancel, onCancel,
loading: saving, loading: saving,
readOnly = false,
onEditClick,
}: GenericFormProps) { }: GenericFormProps) {
initialData = initialData || {}; initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData); const [formData, setFormData] = React.useState(initialData);
const { uploadFile, uploading } = useUpload(); const { uploadFile, uploading } = useUpload();
const appConfig = React.useContext(ConfigContext); 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!);
return {
...getListQueryOptions(),
enabled: !!relatedRes,
};
}),
});
const isLoadingRelations = queries.some(q => q.isLoading);
const relationDataMap = React.useMemo(() => {
const map: Record<string, any[]> = {};
allRelations.forEach((relName, index) => {
map[relName] = queries[index].data || [];
});
return map;
}, [allRelations, queries]);
const handleChange = (key: string, value: any) => { const handleChange = (key: string, value: any) => {
if (readOnly) return;
setFormData((prev: any) => ({ ...prev, [key]: value })); setFormData((prev: any) => ({ ...prev, [key]: value }));
}; };
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (readOnly) return;
onSave(formData); 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 ( return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="h5"> <Typography variant="h5">
{initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`} {getTitle()}
</Typography> </Typography>
<Divider /> <Divider />
@@ -61,143 +111,28 @@ export default function GenericForm({
field={field} field={field}
value={formData[key]} value={formData[key]}
onChange={(val: any) => handleChange(key, val)} onChange={(val: any) => handleChange(key, val)}
disabled={field.readOnly} disabled={readOnly || field.readOnly}
uploadFile={uploadFile} uploadFile={uploadFile}
uploading={uploading} uploading={uploading}
baseUrl={appConfig?.baseUrl || ""} baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap}
/> />
))} ))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
<Button variant="outlined" onClick={onCancel} disabled={saving}> <Button variant="outlined" onClick={onCancel} disabled={saving}>
Cancel {readOnly ? 'Back to List' : 'Cancel'}
</Button>
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
Save {config.label}
</Button> </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>
</Box> </Box>
); );
} }
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading, baseUrl }: any) {
const label = field.label;
if (field.type === 'image') {
return (
<ImageUploadField
label={label}
value={value}
onUpload={async (file: any) => {
const url = await uploadFile(file);
if (url) onChange(url);
}}
uploading={uploading}
baseUrl={baseUrl}
/>
);
}
if (field.type === 'boolean') {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={label}
/>
);
}
if (field.type === 'enum' && field.options) {
return (
<FormControl fullWidth>
<InputLabel>{label}</InputLabel>
<Select
value={value || ''}
label={label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{field.options.map((opt: string) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
);
}
if (field.type === 'datetime') {
return (
<TextField
fullWidth
label={label}
type="datetime-local"
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'date') {
return (
<TextField
fullWidth
label={label}
type="date"
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().split('T')[0] : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'markdown' || field.type === 'string') {
return (
<TextField
fullWidth
label={label}
value={value || ''}
multiline={field.type === 'markdown'}
rows={field.type === 'markdown' ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'number') {
return (
<TextField
fullWidth
label={label}
type="number"
value={value || 0}
onChange={(e) => onChange(Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}
return (
<TextField
fullWidth
label={label}
value={JSON.stringify(value)}
disabled
/>
);
}

View File

@@ -4,6 +4,7 @@ import { ResourceConfig } from '../types/config';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
@@ -11,68 +12,75 @@ interface ResourceViewProps {
} }
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list'); const { id } = useParams();
const [selectedItem, setSelectedItem] = React.useState<any>(null); 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 { useList, useCreate, useUpdate, useDelete } = useResource(config); const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
const { data, isLoading, error } = useList(); const listQuery = useList();
const itemQuery = useRead(id || "");
const createMutation = useCreate(); const createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
const handleEdit = (item: any) => { const handleEdit = (item: any) => {
setSelectedItem(item); navigate(`/${config.name}/edit/${item[config.primaryKey]}`);
setView('edit');
}; };
const handleCreate = () => { const handleCreate = () => {
setSelectedItem(null); navigate(`/${config.name}/create`);
setView('create');
}; };
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
try { try {
if (view === 'edit') { if (isEdit) {
const id = formData[config.primaryKey]; await updateMutation.mutateAsync({ id: id!, data: formData });
await updateMutation.mutateAsync({ id, data: formData });
} else { } else {
await createMutation.mutateAsync(formData); await createMutation.mutateAsync(formData);
} }
setView('list'); navigate(`/${config.name}`);
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('Save failed:', err);
} }
}; };
const handleDelete = async (id: string) => { const handleDelete = async (itemId: string) => {
if (window.confirm('Are you sure you want to delete this item?')) { if (window.confirm('Are you sure you want to delete this item?')) {
await deleteMutation.mutateAsync(id); await deleteMutation.mutateAsync(itemId);
} }
}; };
if (isLoading) return <CircularProgress />; if (isList && listQuery.isLoading) return <CircularProgress />;
if (error) return <Typography color="error">Error loading {config.pluralLabel}</Typography>; if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
return ( return (
<Box> <Box>
{view === 'list' ? ( {isList ? (
<EnhancedTable <EnhancedTable
config={config} config={config}
data={data || []} data={listQuery.data || []}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCreate={handleCreate} onCreate={handleCreate}
onNavigateToResource={onNavigateToResource} onNavigateToResource={(res, id) => navigate(`/${res}/${id}`)}
/> />
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
<GenericForm <GenericForm
config={config} config={config}
initialData={selectedItem} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
onCancel={() => setView('list')} onCancel={() => navigate(`/${config.name}`)}
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView}
onEditClick={() => navigate(`/${config.name}/edit/${id}`)}
/> />
</Paper> </Paper>
)} )}

View File

@@ -0,0 +1,224 @@
import * as React from 'react';
import {
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Typography,
Box,
Divider,
} from '@mui/material';
import { ResourceField } from '../../types/config';
import ImageUploadField from './ImageUploadField';
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[]>; // Map of relation name to data array
}
export default function FormField({
name,
field,
value,
onChange,
disabled,
uploadFile,
uploading,
baseUrl,
relationDataMap = {},
}: FormFieldProps) {
const label = field.label;
// 1. Recursive Rendering for Objects (Not Relations)
if (field.type === 'object' && field.schema && !field.relation) {
return (
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
{label}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(field.schema).map(([subKey, subField]) => (
<FormField
key={subKey}
name={`${name}.${subKey}`}
field={subField}
value={value?.[subKey]}
onChange={(newVal) => {
const updated = { ...(value || {}), [subKey]: newVal };
onChange(updated);
}}
disabled={disabled}
uploadFile={uploadFile}
uploading={uploading}
baseUrl={baseUrl}
relationDataMap={relationDataMap}
/>
))}
</Box>
</Box>
);
}
// 2. Relation Handling (Select / Multi-Select)
if (field.relation && relationDataMap[field.relation]) {
const relationData = relationDataMap[field.relation];
const isArrayRelation = field.type === 'array';
// Determine how to display the related item
const getOptionLabel = (option: any) => {
if (!option) return "";
if (field.displayField && option[field.displayField]) return option[field.displayField];
// Standard naming fields
return option.name || option.title || option.label || option.id || JSON.stringify(option);
};
const getOptionValue = (option: any) => {
// Return the whole object to maintain identity
return option;
};
return (
<FormControl fullWidth>
<InputLabel shrink>{label}</InputLabel>
<Select
multiple={isArrayRelation}
value={value || (isArrayRelation ? [] : "")}
label={label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as any[]).map(getOptionLabel).join(', ');
}
return getOptionLabel(selected);
}}
>
{relationData.map((option) => (
<MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
{getOptionLabel(option)}
</MenuItem>
))}
</Select>
</FormControl>
);
}
// 3. Image Handling
if (field.type === 'image') {
return (
<ImageUploadField
label={label}
value={value}
onUpload={async (file: any) => {
const url = await uploadFile(file);
if (url) onChange(url);
}}
uploading={uploading}
baseUrl={baseUrl}
disabled={disabled}
/>
);
}
// 4. Boolean Handling
if (field.type === 'boolean') {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={label}
/>
);
}
// 5. Enum Handling
if (field.type === 'enum' && field.options) {
return (
<FormControl fullWidth>
<InputLabel>{label}</InputLabel>
<Select
value={value || ''}
label={label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{field.options.map((opt: string) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
);
}
// 6. Common Text Fields
if (field.type === 'datetime' || field.type === 'date') {
return (
<TextField
fullWidth
label={label}
type={field.type === 'datetime' ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'markdown' || field.type === 'string') {
return (
<TextField
fullWidth
label={label}
value={value || ''}
multiline={field.type === 'markdown'}
rows={field.type === 'markdown' ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'number') {
return (
<TextField
fullWidth
label={label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}
return (
<TextField
fullWidth
label={label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
}

View File

@@ -7,6 +7,7 @@ interface ImageUploadFieldProps {
onUpload: (file: File) => void; onUpload: (file: File) => void;
size?: number; size?: number;
baseUrl: string; baseUrl: string;
disabled?: boolean;
} }
export default function ImageUploadField({ export default function ImageUploadField({
@@ -16,6 +17,7 @@ export default function ImageUploadField({
onUpload, onUpload,
size = 64, size = 64,
baseUrl, baseUrl,
disabled = false,
}: ImageUploadFieldProps) { }: ImageUploadFieldProps) {
const imgSrc = value const imgSrc = value
@@ -33,23 +35,25 @@ export default function ImageUploadField({
sx={{ width: size, height: size, borderRadius: 2 }} sx={{ width: size, height: size, borderRadius: 2 }}
/> />
<Button {!disabled && (
variant="outlined" <Button
component="label" variant="outlined"
disabled={uploading} component="label"
startIcon={uploading && <CircularProgress size={16} />} disabled={uploading}
> startIcon={uploading && <CircularProgress size={16} />}
{uploading ? "Uploading..." : "Choose File"} >
<input {uploading ? "Uploading..." : "Choose File"}
type="file" <input
accept="image/*" type="file"
hidden accept="image/*"
onChange={(e) => { hidden
const file = e.target.files?.[0]; onChange={(e) => {
if (file) onUpload(file); const file = e.target.files?.[0];
}} if (file) onUpload(file);
/> }}
</Button> />
</Button>
)}
</Box> </Box>
</Box> </Box>
); );

View File

@@ -1,4 +1,4 @@
import { ResourceOverride } from "./utils/overrides"; import { ResourceOverride } from "./types/overrides";
export const configuration: Record<string, ResourceOverride> = { export const configuration: Record<string, ResourceOverride> = {
expenses: { expenses: {

View File

@@ -11,17 +11,19 @@ export function useResource<T = any>(config: ResourceConfig) {
useQuery({ useQuery({
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
return res.data; return res.data;
} }
}); });
// --- READ ONE --- // --- READ ONE ---
const useOne = (id: string | null) => const useRead = (id: string | null) =>
useQuery({ useQuery({
queryKey: [name, "detail", id], queryKey: [name, "detail", id],
queryFn: async () => { queryFn: async () => {
if (!id) return null; if (!id) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`); const res = await api.get<T>(`${endpoint}/${id}`);
return res.data; return res.data;
}, },
@@ -32,6 +34,7 @@ export function useResource<T = any>(config: ResourceConfig) {
const useCreate = () => const useCreate = () =>
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
// @ts-ignore
const res = await api.post<T>(endpoint, data); const res = await api.post<T>(endpoint, data);
return res.data; return res.data;
}, },
@@ -44,6 +47,7 @@ export function useResource<T = any>(config: ResourceConfig) {
const useUpdate = () => const useUpdate = () =>
useMutation({ useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => { mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
// @ts-ignore
const res = await api.put<T>(`${endpoint}/${id}`, data); const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data; return res.data;
}, },
@@ -67,11 +71,22 @@ export function useResource<T = any>(config: ResourceConfig) {
}, },
}); });
// --- HELPERS FOR useQueries ---
const getListQueryOptions = (params?: any) => ({
queryKey: [name, "list", params],
queryFn: async () => {
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params });
return res.data;
},
});
return { return {
useList, useList,
useOne, useRead,
useCreate, useCreate,
useUpdate, useUpdate,
useDelete, useDelete,
getListQueryOptions,
}; };
} }

View File

@@ -40,52 +40,51 @@ function mapOpenApiType(prop: any): FieldType {
function parseSchemaFields( function parseSchemaFields(
schema: any, schema: any,
resourceName: string, resourceName: string,
allResources: string[] schemaToResourceMap: Map<any, string>
): Record<string, ResourceField> { ): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {}; const fields: Record<string, ResourceField> = {};
const properties = schema.properties || {}; const properties = schema.properties || {};
const required = schema.required || []; const required = schema.required || [];
const overrides = configuration[resourceName]?.fields || {}; const overrides = configuration[resourceName]?.fields || {};
for (const [key, prop] of Object.entries(properties) as any) { for (const [key, prop] of Object.entries(properties) as [string, any]) {
const type = mapOpenApiType(prop); const type = mapOpenApiType(prop);
const override = overrides[key]; const override = overrides[key];
console.log("key", key, "type", type, "prop", prop, "override", override); // Explicitly skip 'id' as it's the primary key and handled elsewhere
if (key !== "id" && override?.display !== false) { if (key === "id" || override?.display === false) continue;
fields[key] = {
type,
label:
prop.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key),
options: prop.enum,
readOnly:
prop.readOnly ||
key === "created_at" ||
key === "updated_at",
...override,
};
} else continue;
// Schema-based Relation Detection fields[key] = {
// If it's an object/string and matches a resource name, it might be a relation type,
const potentialRelation = allResources.find( label:
(res) => prop.title ||
key === res || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
key === `${res}_id` || required: required.includes(key),
prop.title?.toLowerCase() === res || options: prop.enum,
prop["x-resource"] === res readOnly:
); prop.readOnly ||
key === "created_at" ||
key === "updated_at",
...override,
};
if (potentialRelation) { // STRICT RELATION DETECTION
if (type === "string" || (type === "object" && prop.properties?.id)) { // A field is a relation ONLY if its schema object (or items schema)
fields[key].relation = potentialRelation; // exactly matches a schema that is defined as a resource.
} let targetSchema = prop;
if (type === "array" && prop.items) {
targetSchema = prop.items;
} }
if (fields[key].type === "object" && prop.properties) { // Check if this schema object is registered as a resource
fields[key].schema = parseSchemaFields(prop, resourceName, allResources); const relation = schemaToResourceMap.get(targetSchema);
if (relation) {
fields[key].relation = relation;
}
// Recursively parse nested objects (only if not a relation)
if (fields[key].type === "object" && prop.properties && !relation) {
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap);
} }
} }
@@ -96,7 +95,8 @@ function parseSchemaFields(
* Scans paths to identify resources and their basic configuration * Scans paths to identify resources and their basic configuration
*/ */
export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> { export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> {
// 1. Parse and dereference the spec (handles all $ref) // Use SwaggerParser to dereference the spec.
// Dereferencing preserves object identity for $ref targets.
const api = await SwaggerParser.dereference( const api = await SwaggerParser.dereference(
new URL("/openapi.json", baseUrl).href new URL("/openapi.json", baseUrl).href
); );
@@ -104,9 +104,8 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
const resources: ResourceConfig[] = []; const resources: ResourceConfig[] = [];
const paths = api.paths || {}; const paths = api.paths || {};
// Group paths by base resource name (e.g., /expenses, /expenses/{id} -> expenses) // Group paths by base resource name
const resourcePaths: Record<string, any> = {}; const resourcePaths: Record<string, any> = {};
for (const path of Object.keys(paths)) { for (const path of Object.keys(paths)) {
const base = path.split("/")[1]; const base = path.split("/")[1];
if (!base) continue; if (!base) continue;
@@ -115,51 +114,61 @@ export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig>
const methods = Object.keys(paths[path] || {}); const methods = Object.keys(paths[path] || {});
resourcePaths[base].methods.push(...methods); resourcePaths[base].methods.push(...methods);
// We prefer the plural GET path for schema extraction // Identify the list endpoint for this resource
if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) { if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) {
resourcePaths[base].listPath = path; resourcePaths[base].listPath = path;
} }
} }
const allResourceNames = Object.keys(resourcePaths); // 1. Identify which schema objects correspond to which resources
const schemaToResourceMap = new Map<any, string>();
// Generate ResourceConfig for each identified base path
for (const [name, info] of Object.entries(resourcePaths)) { for (const [name, info] of Object.entries(resourcePaths)) {
const listPath = info.listPath || `/${name}`; const listPath = info.listPath || `/${name}`;
const listOp = paths[listPath]?.get; const listOp = paths[listPath]?.get;
if (!listOp) continue; if (!listOp) continue;
// Use common naming conventions or metadata from the spec // @ts-ignore
const label = name.charAt(0).toUpperCase() + name.slice(1, -1); // naive singularization const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1); let schemaObj = responseSchema;
// Extract schema from the 200 response of the list endpoint
let schema: any = null;
const responseSchema =
listOp.responses?.["200"]?.content?.["application/json"]?.schema;
if (responseSchema?.type === "array" && responseSchema.items) { if (responseSchema?.type === "array" && responseSchema.items) {
schema = responseSchema.items; schemaObj = responseSchema.items;
} else {
schema = responseSchema;
} }
if (schema) { if (schemaObj) {
resources.push({ schemaToResourceMap.set(schemaObj, name);
name, resourcePaths[name].schemaObj = schemaObj;
label: schema.title || label,
pluralLabel: pluralLabel,
endpoint: listPath,
primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique'
fields: parseSchemaFields(schema, name, allResourceNames),
});
} }
} }
// 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);
const fields = parseSchemaFields(schema, name, schemaToResourceMap);
resources.push({
name,
label: schema.title || label,
pluralLabel: pluralLabel,
endpoint: listPath,
primaryKey: "id", // Strict default, no heuristics
fields,
});
}
// @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 { return {
baseUrl: baseUrl: serverBaseUrl,
import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""), authBaseUrl: authBaseUrl,
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "",
resources, resources,
}; };
} }