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