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