Compare commits
18 Commits
a8581325fa
...
generic-re
| Author | SHA1 | Date | |
|---|---|---|---|
| cddc4d350f | |||
| c73b55b737 | |||
| f8cea025a3 | |||
| 9b87fb31a7 | |||
| 7edf3e75da | |||
| 63b31f0fc5 | |||
| 0f44a8e1b6 | |||
| 399b2656b8 | |||
| aa04b105d0 | |||
| c7095ed481 | |||
| ff3094cf09 | |||
| 1f64b566cb | |||
| 4b0d9ca425 | |||
| 08a84ea63f | |||
| 004a8a6876 | |||
| 60d817fa8a | |||
| 36086e4b77 | |||
| 71f7ee83f1 |
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -44,18 +64,12 @@ function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import ProfileView from "./components/ProfileView";
|
||||||
|
|
||||||
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 +86,35 @@ 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="/profile" element={<ProfileView />} />
|
||||||
config={selectedResource}
|
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
||||||
onNavigateToResource={handleNavigateToResource}
|
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
||||||
/>
|
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
||||||
) : (
|
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
||||||
<Dashboard />
|
</Routes>
|
||||||
)}
|
|
||||||
</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 +148,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>
|
||||||
|
|||||||
@@ -12,19 +12,27 @@ import {
|
|||||||
ListItemIcon,
|
ListItemIcon,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
CssBaseline,
|
CssBaseline,
|
||||||
IconButton
|
Button,
|
||||||
|
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 +41,218 @@ 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 }}>
|
|
||||||
Admin Panel
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body1" sx={{ mr: 2 }}>
|
|
||||||
{username}
|
|
||||||
</Typography>
|
|
||||||
<IconButton color="inherit" onClick={onLogout}>
|
|
||||||
<LogoutIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
<Drawer
|
|
||||||
variant="permanent"
|
|
||||||
sx={{
|
sx={{
|
||||||
width: drawerWidth,
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
flexShrink: 0,
|
backdropFilter: 'blur(8px)',
|
||||||
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
|
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
color: 'text.primary',
|
||||||
|
boxShadow: 'none',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||||
|
Admin Panel
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: { xs: 'none', sm: 'flex' }, alignItems: 'center', mr: 2 }}>
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { alpha } from '@mui/material/styles';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Grid,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
DataGrid,
|
||||||
GridColDef,
|
GridColDef,
|
||||||
GridActionsCellItem,
|
GridActionsCellItem,
|
||||||
GridRenderCellParams,
|
GridRenderCellParams,
|
||||||
|
GridPaginationModel,
|
||||||
} 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;
|
||||||
data: any[];
|
data: any[];
|
||||||
|
total?: number;
|
||||||
|
paginationModel?: GridPaginationModel;
|
||||||
|
onPaginationModelChange?: (model: GridPaginationModel) => void;
|
||||||
|
loading?: boolean;
|
||||||
onEdit: (item: any) => void;
|
onEdit: (item: any) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
@@ -29,73 +48,50 @@ interface EnhancedTableProps {
|
|||||||
export default function EnhancedTable({
|
export default function EnhancedTable({
|
||||||
config,
|
config,
|
||||||
data,
|
data,
|
||||||
|
total,
|
||||||
|
paginationModel,
|
||||||
|
onPaginationModelChange,
|
||||||
|
loading = false,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
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]) => {
|
||||||
|
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 = {
|
const col: GridColDef = {
|
||||||
field: key,
|
field: key,
|
||||||
headerName: field.label,
|
headerName: field.label,
|
||||||
|
type: muiType,
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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' && field.options) {
|
||||||
|
// @ts-ignore
|
||||||
|
col.valueOptions = field.options;
|
||||||
|
}
|
||||||
|
|
||||||
return col;
|
return col;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,12 +99,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 +120,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>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{data.map((row) => (
|
||||||
|
<Box key={row[config.primaryKey] || Math.random()}>
|
||||||
|
<MobileCardRow
|
||||||
|
row={row}
|
||||||
|
config={config}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onNavigate={onNavigateToResource}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</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,27 +160,210 @@ export default function EnhancedTable({
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
rows={data || []}
|
rows={data || []}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
autoHeight
|
||||||
|
paginationMode={config.pagination ? 'server' : 'client'}
|
||||||
|
rowCount={(() => {
|
||||||
|
if (!config.pagination) return data.length;
|
||||||
|
if (total !== undefined) return total;
|
||||||
|
|
||||||
|
// Graceful fallback for missing total count
|
||||||
|
const page = paginationModel?.page || 0;
|
||||||
|
const pageSize = paginationModel?.pageSize || 10;
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
return page * pageSize + data.length;
|
||||||
|
}
|
||||||
|
// Enable 'Next' button by pretending there's at least one more page
|
||||||
|
return (page + 2) * pageSize;
|
||||||
|
})()}
|
||||||
|
loading={loading}
|
||||||
|
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
|
||||||
|
onPaginationModelChange={onPaginationModelChange}
|
||||||
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
|
||||||
initialState={{
|
pageSizeOptions={[10, 25, 50]}
|
||||||
pagination: {
|
sx={{
|
||||||
paginationModel: { page: 0, pageSize: 10 },
|
border: 'none',
|
||||||
},
|
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||||
|
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||||
}}
|
}}
|
||||||
pageSizeOptions={[5, 10, 25]}
|
|
||||||
/>
|
/>
|
||||||
</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 }} />
|
||||||
|
<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" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||||
|
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
|
||||||
|
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
|
||||||
|
if (!item) return "";
|
||||||
|
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
|
||||||
|
|
||||||
|
if (Array.isArray(displayField)) {
|
||||||
|
return displayField
|
||||||
|
.map(key => item[key])
|
||||||
|
.filter(val => val !== undefined && val !== null)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return item[displayField] || item.id || JSON.stringify(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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.displayField);
|
||||||
|
|
||||||
|
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 tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).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.displayField)}
|
||||||
|
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.displayField) || (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' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
|
||||||
|
|
||||||
|
if (isPk && !isMobile) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={value}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }}
|
||||||
|
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
83
src_generic/components/ProfileView.tsx
Normal file
83
src_generic/components/ProfileView.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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 '../App';
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a modified config where only extraFields are editable
|
||||||
|
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);
|
||||||
|
const { data: profile, isLoading, error } = useMe();
|
||||||
|
const updateMutation = useUpdateMe();
|
||||||
|
|
||||||
|
const handleSave = async (formData: any) => {
|
||||||
|
try {
|
||||||
|
// Only send editable fields to prevent accidental overwrites of read-only data
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,75 +4,104 @@ 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;
|
||||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||||
|
|
||||||
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 { useList, useCreate, useUpdate, useDelete } = useResource(config);
|
const isCreate = location.pathname.endsWith('/create');
|
||||||
|
const isEdit = location.pathname.includes('/edit/');
|
||||||
|
const isView = !!id && !isEdit;
|
||||||
|
const isList = !id && !isCreate;
|
||||||
|
|
||||||
const { data, isLoading, error } = useList();
|
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
|
||||||
|
page: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||||
|
|
||||||
|
// Determine query parameters based on pagination config
|
||||||
|
const queryParams = React.useMemo(() => {
|
||||||
|
if (!config.pagination) return {};
|
||||||
|
return {
|
||||||
|
skip: paginationModel.page * paginationModel.pageSize,
|
||||||
|
limit: paginationModel.pageSize,
|
||||||
|
};
|
||||||
|
}, [config.pagination, paginationModel]);
|
||||||
|
|
||||||
|
const listQuery = useList(queryParams);
|
||||||
|
const itemQuery = useRead(id || "");
|
||||||
|
|
||||||
|
const paginatedData = listQuery.data || { data: [], total: undefined };
|
||||||
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={paginatedData.data || []}
|
||||||
|
total={paginatedData.total}
|
||||||
|
paginationModel={paginationModel}
|
||||||
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
loading={listQuery.isFetching}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
224
src_generic/components/fields/FormField.tsx
Normal file
224
src_generic/components/fields/FormField.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {
|
||||||
@@ -14,7 +14,7 @@ export const configuration: Record<string, ResourceOverride> = {
|
|||||||
displayField: "name",
|
displayField: "name",
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
displayField: "icon",
|
displayField: ["name", "icon"],
|
||||||
},
|
},
|
||||||
occurred_at: {
|
occurred_at: {
|
||||||
formatter: (val: string) => {
|
formatter: (val: string) => {
|
||||||
@@ -38,5 +38,13 @@ export const configuration: Record<string, ResourceOverride> = {
|
|||||||
display: false
|
display: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
pagination: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const profileConfiguration = {
|
||||||
|
"extraFields": ['name'],
|
||||||
|
"resource": "payors",
|
||||||
|
// not in use
|
||||||
|
"hidden": true,
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,17 +11,23 @@ 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;
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 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 +38,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 +51,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 +75,53 @@ 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 });
|
||||||
|
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
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- READ ME ---
|
||||||
|
const useMe = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [name, "me"],
|
||||||
|
queryFn: async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.get<T>(`${endpoint}/me`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UPDATE ME ---
|
||||||
|
const useUpdateMe = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (data: Partial<T>) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "me"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useList,
|
useList,
|
||||||
useOne,
|
useRead,
|
||||||
|
useMe,
|
||||||
useCreate,
|
useCreate,
|
||||||
useUpdate,
|
useUpdate,
|
||||||
|
useUpdateMe,
|
||||||
useDelete,
|
useDelete,
|
||||||
|
getListQueryOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface ResourceField {
|
|||||||
options?: string[];
|
options?: string[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
schema?: Record<string, ResourceField>;
|
schema?: Record<string, ResourceField>;
|
||||||
displayField?: string;
|
displayField?: string | string[];
|
||||||
formatter?: (value: any) => string;
|
formatter?: (value: any) => string;
|
||||||
relation?: string; // Name of the target resource
|
relation?: string; // Name of the target resource
|
||||||
}
|
}
|
||||||
@@ -29,10 +29,15 @@ export interface ResourceConfig {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
primaryKey: string;
|
primaryKey: string;
|
||||||
fields: Record<string, ResourceField>;
|
fields: Record<string, ResourceField>;
|
||||||
|
pagination?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
authBaseUrl: string;
|
authBaseUrl: string;
|
||||||
resources: ResourceConfig[];
|
resources: ResourceConfig[];
|
||||||
|
profile?: {
|
||||||
|
resource: string;
|
||||||
|
extraFields?: Record<string, any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface FieldOverride {
|
export interface FieldOverride {
|
||||||
displayField?: string;
|
displayField?: string | string[];
|
||||||
display?: boolean;
|
display?: boolean;
|
||||||
formatter?: (value: any) => string;
|
formatter?: (value: any) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceOverride {
|
export interface ResourceOverride {
|
||||||
fields?: Record<string, FieldOverride>;
|
fields?: Record<string, FieldOverride>;
|
||||||
|
pagination?: boolean;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||||
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||||
import { configuration } from "../configuration";
|
import { configuration, profileConfiguration } from "../configuration";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps OpenAPI property types to our internal FieldType
|
* Maps OpenAPI property types to our internal FieldType
|
||||||
@@ -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,65 @@ 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 (!resourcePaths[base].listPath && !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);
|
||||||
|
|
||||||
|
const resourceOverride = configuration[name] || {};
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
name,
|
||||||
|
label: schema.title || label,
|
||||||
|
pluralLabel: pluralLabel,
|
||||||
|
endpoint: listPath,
|
||||||
|
primaryKey: "id", // Strict default, no heuristics
|
||||||
|
fields,
|
||||||
|
pagination: resourceOverride.pagination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @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,
|
||||||
|
profile: profileConfiguration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user