diff --git a/package-lock.json b/package-lock.json
index 0c9e631..90d0a28 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
+ "typescript": "^6.0.3",
"vite": "latest"
}
},
@@ -4103,6 +4104,19 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
diff --git a/package.json b/package.json
index 61dfdc5..73ae1ae 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
+ "typescript": "^6.0.3",
"vite": "latest"
}
}
diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx
index 52fabc8..c50c191 100644
--- a/react-openapi/Admin.tsx
+++ b/react-openapi/Admin.tsx
@@ -6,6 +6,7 @@ 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,
@@ -15,8 +16,9 @@ import {
} from "react-router-dom";
import { ConfigContext } from "./providers/ConfigContext";
+import ProfileView from "./components/ProfileView";
-function Dashboard({ basePath }: { basePath: string }) {
+function DefaultDashboard({ basePath }: { basePath: string }) {
const config = React.useContext(ConfigContext);
const navigate = useNavigate();
@@ -31,7 +33,6 @@ function Dashboard({ basePath }: { basePath: string }) {
Select a resource from the sidebar to manage data.
-
{visibleResources.map((res) => (
- ;
+ Layout?: React.ComponentType;
+ LoginPage?: React.ComponentType;
+}
-function AdminApp({ basePath }: { basePath: string }) {
+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();
@@ -73,10 +80,10 @@ function AdminApp({ basePath }: { basePath: string }) {
if (!currentUser) {
return (
- {}} // Disable registration for Admin
+ register={async () => {}}
loading={loading}
error={error}
onSwitchMode={() => {}}
@@ -87,7 +94,7 @@ function AdminApp({ basePath }: { basePath: string }) {
}
return (
- navigate(`/admin/${name}`)}
@@ -96,32 +103,44 @@ function AdminApp({ basePath }: { basePath: string }) {
} />
} />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
-
+
);
}
-function ResourceRouteWrapper() {
+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 ;
+ 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 = {} }: AdminProps) {
+export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) {
const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState(existingConfig);
@@ -151,16 +170,14 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
const content = (
-
+
);
- // If we have an existing config, we are already inside a Provider and QueryClient
if (existingConfig) {
return content;
}
- // Fallback for standalone usage
return (
{content}
diff --git a/react-openapi/api/client.ts b/react-openapi/api/client.ts
index d60f5d9..1513d41 100644
--- a/react-openapi/api/client.ts
+++ b/react-openapi/api/client.ts
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios";
+import type { AxiosResponse } from "axios";
import { createApiClient } from "../../react-auth";
/**
@@ -30,25 +31,25 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
}
export const api = {
- get: (...args: Parameters) => {
+ get: >(url: string, config?: Parameters[1]) => {
if (!_api) throw new Error("API client not initialized");
- return _api.get(...args);
+ return _api.get(url, config);
},
- post: (...args: Parameters) => {
+ post: >(url: string, data?: any, config?: Parameters[2]) => {
if (!_api) throw new Error("API client not initialized");
- return _api.post(...args);
+ return _api.post(url, data, config);
},
- put: (...args: Parameters) => {
+ put: >(url: string, data?: any, config?: Parameters[2]) => {
if (!_api) throw new Error("API client not initialized");
- return _api.put(...args);
+ return _api.put(url, data, config);
},
- delete: (...args: Parameters) => {
+ delete: >(url: string, config?: Parameters[1]) => {
if (!_api) throw new Error("API client not initialized");
- return _api.delete(...args);
+ return _api.delete(url, config);
},
- patch: (...args: Parameters) => {
+ patch: >(url: string, data?: any, config?: Parameters[2]) => {
if (!_api) throw new Error("API client not initialized");
- return _api.patch(...args);
+ return _api.patch(url, data, config);
},
};
diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx
index 067e3a2..ddc37ea 100644
--- a/react-openapi/components/EnhancedTable.tsx
+++ b/react-openapi/components/EnhancedTable.tsx
@@ -31,6 +31,7 @@ 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 {
@@ -44,6 +45,7 @@ interface EnhancedTableProps {
onDelete: (id: string) => void;
onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void;
+ components?: EnhancedTableComponents;
}
export default function EnhancedTable({
@@ -57,6 +59,7 @@ export default function EnhancedTable({
onDelete,
onCreate,
onNavigateToResource,
+ components: tableComponents,
}: EnhancedTableProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -85,7 +88,7 @@ export default function EnhancedTable({
type: muiType,
flex: 1,
minWidth: 150,
- renderCell: (params: GridRenderCellParams) =>
+ renderCell: (params: GridRenderCellParams) =>
};
if (muiType === 'date' || muiType === 'dateTime') {
@@ -97,7 +100,7 @@ export default function EnhancedTable({
}
if (muiType === 'singleSelect') {
- col.valueOptions = toGridValueOptions(getFieldOptions(field));
+ (col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
}
return col;
@@ -158,6 +161,7 @@ export default function EnhancedTable({
onDelete={onDelete}
onNavigate={onNavigateToResource}
navigate={navigate}
+ components={tableComponents}
/>
))}
@@ -225,7 +229,7 @@ export default function EnhancedTable({
);
}
-function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
+function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) {
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
const id = row[config.primaryKey];
@@ -261,7 +265,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
{field.label}
-
+
))}
@@ -289,12 +293,17 @@ function getFormattedDisplayValue(item: any, displayField?: string | string[], e
return item[displayField] || item.id || JSON.stringify(item);
}
-function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
+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;
diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx
index e2b8a3e..c5e9318 100644
--- a/react-openapi/components/FilterBar.tsx
+++ b/react-openapi/components/FilterBar.tsx
@@ -11,9 +11,10 @@ import {
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";
-function FilterAutocomplete({
+export function FilterAutocomplete({
options,
value,
label,
@@ -123,15 +124,10 @@ function extractOptions(
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
- const df = field.displayField;
- if (!df) return null;
-
- if (Array.isArray(df)) {
- const parts = df.map((k) => item[k]).filter((v) => v != null);
- if (parts.length > 0) return parts.join(" ");
+ // Use displayFormat if defined, otherwise fall back to displayField logic (for backward compatibility)
+ if (field.displayFormat) {
+ return resolveTemplate(field.displayFormat, item);
}
- const v = item[df];
- if (v != null) return String(v);
return null;
};
@@ -160,32 +156,24 @@ function renderFilterInput(
field: ResourceField,
options: string[],
value: any,
- onChange: (key: string, val: any) => void
+ 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("min", e.target.value || undefined)} sx={{ width: 100 }} />
- onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
-
- );
+ 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("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
- onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
-
- );
+ return onChange("value", val)} />;
}
const selected = Array.isArray(value) ? value : [];
@@ -208,6 +196,8 @@ export interface FilterBarProps {
appliedValues: Record;
onApply: (values: Record) => void;
onClear: () => void;
+ components?: FilterBarComponents;
+ fieldComponents?: FieldComponents;
}
export default function FilterBar({
@@ -217,6 +207,8 @@ export default function FilterBar({
appliedValues,
onApply,
onClear,
+ components: filterComponents,
+ fieldComponents,
}: FilterBarProps) {
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState>(() => ({ ...appliedValues }));
@@ -284,7 +276,7 @@ export default function FilterBar({
const field = fields[fieldName];
if (!field) return null;
- const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect";
+ const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect";
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
const raw = draft[fieldName];
@@ -294,7 +286,7 @@ export default function FilterBar({
{field.label}
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
- updateDraft(fieldName, key, val)
+ updateDraft(fieldName, key, val), filterComponents, fieldComponents
)}
);
diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx
index 8931f07..9a51076 100644
--- a/react-openapi/components/GenericForm.tsx
+++ b/react-openapi/components/GenericForm.tsx
@@ -7,6 +7,7 @@ import {
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';
@@ -21,6 +22,7 @@ interface GenericFormProps {
loading?: boolean;
readOnly?: boolean;
onEditClick?: () => void;
+ fieldComponents: FieldComponents;
}
export default function GenericForm({
@@ -31,6 +33,7 @@ export default function GenericForm({
loading: saving,
readOnly = false,
onEditClick,
+ fieldComponents,
}: GenericFormProps) {
initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData);
@@ -54,7 +57,7 @@ export default function GenericForm({
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!);
+ const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents });
return {
...getListQueryOptions(),
enabled: !!relatedRes,
@@ -117,6 +120,7 @@ export default function GenericForm({
uploading={uploading}
baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap}
+ components={fieldComponents}
/>
))}
diff --git a/react-openapi/components/ProfileView.tsx b/react-openapi/components/ProfileView.tsx
index ac76e61..7ebc57d 100644
--- a/react-openapi/components/ProfileView.tsx
+++ b/react-openapi/components/ProfileView.tsx
@@ -3,6 +3,7 @@ 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);
@@ -13,11 +14,10 @@ export default function ProfileView() {
return Profile configuration not found.;
}
- // Create a modified config where only extraFields are editable
const editableConfig = React.useMemo(() => {
const newFields = { ...resourceConfig.fields };
const extraFields = profileConfig.extraFields || [];
-
+
Object.keys(newFields).forEach(key => {
newFields[key] = {
...newFields[key],
@@ -31,13 +31,12 @@ export default function ProfileView() {
};
}, [resourceConfig, profileConfig.extraFields]);
- const { useMe, useUpdateMe } = useResource(resourceConfig);
+ const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents });
const { data: profile, isLoading, error } = useMe();
const updateMutation = useUpdateMe();
const handleSave = async (formData: any) => {
try {
- // Only send editable fields to prevent accidental overwrites of read-only data
const extraFields = profileConfig.extraFields || [];
const dataToSave = Object.keys(formData)
.filter(key => extraFields.includes(key))
@@ -76,6 +75,7 @@ export default function ProfileView() {
onSave={handleSave}
onCancel={() => window.history.back()}
loading={updateMutation.isPending}
+ fieldComponents={defaultFieldComponents}
/>
diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx
index f03885b..e270713 100644
--- a/react-openapi/components/ResourceView.tsx
+++ b/react-openapi/components/ResourceView.tsx
@@ -2,9 +2,9 @@ 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 GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
@@ -12,6 +12,7 @@ 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';
@@ -96,7 +97,7 @@ function applyClientFilters(
);
}
-export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
+export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
const { id } = useParams();
const location = useLocation();
const navigate = useNavigate();
@@ -115,10 +116,10 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
const [appliedFilters, setAppliedFilters] = React.useState>({});
- const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
+ const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
const queryParams = React.useMemo(() => {
- if (!isServer) return { limit: 10000 };
+ if (!isServer) return { limit: 10 };
return {
skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize,
@@ -183,6 +184,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
appliedValues={appliedFilters}
onApply={setAppliedFilters}
onClear={() => setAppliedFilters({})}
+ fieldComponents={components}
/>
)}
) : (
- navigate(`/admin/${config.name}/edit/${id}`)}
- />
+ />}
)}
diff --git a/react-openapi/components/fields/BooleanField.tsx b/react-openapi/components/fields/BooleanField.tsx
new file mode 100644
index 0000000..1deb5e3
--- /dev/null
+++ b/react-openapi/components/fields/BooleanField.tsx
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..04b1f22
--- /dev/null
+++ b/react-openapi/components/fields/DateField.tsx
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 0000000..b8458f2
--- /dev/null
+++ b/react-openapi/components/fields/DateRangeField.tsx
@@ -0,0 +1,30 @@
+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
new file mode 100644
index 0000000..c124dba
--- /dev/null
+++ b/react-openapi/components/fields/DefaultFieldComponents.ts
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000..0633f8a
--- /dev/null
+++ b/react-openapi/components/fields/EnumField.tsx
@@ -0,0 +1,24 @@
+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
new file mode 100644
index 0000000..f5d6dd8
--- /dev/null
+++ b/react-openapi/components/fields/FallbackField.tsx
@@ -0,0 +1,13 @@
+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
index b1915b6..ff06784 100644
--- a/react-openapi/components/fields/FormField.tsx
+++ b/react-openapi/components/fields/FormField.tsx
@@ -1,30 +1,19 @@
import * as React from 'react';
-import {
- TextField,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- FormControlLabel,
- Checkbox,
- Typography,
- Box,
- Divider,
-} from '@mui/material';
import { ResourceField } from '../../types/config';
-import { getFieldOptions } from '../../utils/options';
-import ImageUploadField from './ImageUploadField';
+import { FieldComponentProps, FieldComponents } from '../../types/overrides';
+import ObjectField from './ObjectField';
-interface FormFieldProps {
+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; // Map of relation name to data array
+ uploadFile?: (file: File) => Promise;
+ uploading?: boolean;
+ baseUrl?: string;
+ relationDataMap?: Record;
+ components: FieldComponents;
}
export default function FormField({
@@ -37,190 +26,60 @@ export default function FormField({
uploading,
baseUrl,
relationDataMap = {},
+ components,
}: FormFieldProps) {
- const label = field.label;
+ const fieldProps: FieldComponentProps = {
+ name,
+ field,
+ value,
+ onChange,
+ disabled,
+ baseUrl,
+ relationDataMap,
+ uploadFile,
+ uploading,
+ };
- // 1. Recursive Rendering for Objects (Not Relations)
+ const childComponents = components;
+
+ // 1. Object (recursive) - requires parent FormField for recursion
if (field.type === 'object' && field.schema && !field.relation) {
- return (
-
-
- {label}
-
-
- {Object.entries(field.schema).map(([subKey, subField]) => (
- {
- const updated = { ...(value || {}), [subKey]: newVal };
- onChange(updated);
- }}
- disabled={disabled}
- uploadFile={uploadFile}
- uploading={uploading}
- baseUrl={baseUrl}
- relationDataMap={relationDataMap}
- />
- ))}
-
-
+ const renderChild = (childProps: FieldComponentProps) => (
+
);
+ return ;
}
- // 2. Relation Handling (Select / Multi-Select)
- if (field.relation && relationDataMap[field.relation]) {
- const relationData = relationDataMap[field.relation].data;
- const isArrayRelation = field.type === 'array';
- const options = getFieldOptions(field, relationData);
- const keyField = field.enumOption?.key ?? 'id';
-
- // Normalize value: API returns whole objects on GET, but form uses key strings
- 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 (
-
- {label}
-
-
- );
- }
-
- // 3. Image Handling
+ // 2. Image
if (field.type === 'image') {
- return (
- {
- const url = await uploadFile(file);
- if (url) onChange(url);
- }}
- uploading={uploading}
- baseUrl={baseUrl}
- disabled={disabled}
- />
- );
+ const ImageField = components.image;
+ if (!ImageField) return null;
+ return ;
}
- // 4. Boolean Handling
- if (field.type === 'boolean') {
- return (
- onChange(e.target.checked)}
- disabled={disabled}
- />
- }
- label={label}
- />
- );
+ // 3. Relation
+ if (field.relation && relationDataMap[field.relation]) {
+ const RelationFieldComp = components.relation;
+ if (!RelationFieldComp) return null;
+ return ;
}
- // 5. Enum Handling
- if (field.type === 'enum') {
- const options = getFieldOptions(field);
- return (
-
- {label}
-
-
- );
+ // 4. Lookup by field type
+ const Component = components[field.type] || components.default;
+ if (Component) {
+ return ;
}
- // 6. Common Text Fields
- if (field.type === 'datetime' || field.type === 'date') {
- return (
- onChange(e.target.value)}
- disabled={disabled}
- required={field.required}
- />
- );
- }
-
- if (field.type === 'markdown' || field.type === 'string') {
- return (
- onChange(e.target.value)}
- disabled={disabled}
- required={field.required}
- />
- );
- }
-
- if (field.type === 'number') {
- return (
- onChange(e.target.value === '' ? '' : Number(e.target.value))}
- disabled={disabled}
- required={field.required}
- />
- );
- }
-
- return (
-
- );
+ return null;
}
diff --git a/react-openapi/components/fields/NumberField.tsx b/react-openapi/components/fields/NumberField.tsx
new file mode 100644
index 0000000..677bf1a
--- /dev/null
+++ b/react-openapi/components/fields/NumberField.tsx
@@ -0,0 +1,16 @@
+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
new file mode 100644
index 0000000..ecaa36f
--- /dev/null
+++ b/react-openapi/components/fields/NumberRangeField.tsx
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 0000000..19b7dc1
--- /dev/null
+++ b/react-openapi/components/fields/ObjectField.tsx
@@ -0,0 +1,36 @@
+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]) =>
+ renderField({
+ name: `${name}.${subKey}`,
+ field: subField,
+ value: value?.[subKey],
+ onChange: (newVal: any) => {
+ const updated = { ...(value || {}), [subKey]: newVal };
+ onChange(updated);
+ },
+ disabled,
+ baseUrl,
+ uploadFile,
+ uploading,
+ relationDataMap,
+ })
+ )}
+
+
+ );
+}
diff --git a/react-openapi/components/fields/RelationField.tsx b/react-openapi/components/fields/RelationField.tsx
new file mode 100644
index 0000000..0c28a02
--- /dev/null
+++ b/react-openapi/components/fields/RelationField.tsx
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 0000000..1dd6df0
--- /dev/null
+++ b/react-openapi/components/fields/TextField.tsx
@@ -0,0 +1,18 @@
+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
new file mode 100644
index 0000000..9baa425
--- /dev/null
+++ b/react-openapi/components/fields/index.ts
@@ -0,0 +1,14 @@
+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/hooks/useResource.ts b/react-openapi/hooks/useResource.ts
index f2a2a21..5f0bf28 100644
--- a/react-openapi/hooks/useResource.ts
+++ b/react-openapi/hooks/useResource.ts
@@ -1,28 +1,44 @@
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 * as React from "react";
+import { FieldComponents, FieldComponentProps } from "../types/overrides";
+import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
+import FormField from "../components/fields/FormField";
+import GenericForm from "../components/GenericForm";
-export function useResource(config: ResourceConfig | undefined) {
+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();
-
- // Return empty/disabled hooks if config is missing
+
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
+ const mergedComponents = React.useMemo(
+ () => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
+ [options?.fieldComponents],
+ );
+
// --- READ ALL ---
- const useList = (params?: any) =>
+ const useList = (params?: any) =>
useQuery({
queryKey: [name, "list", params],
queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
- console.log('params:', params);
- // @ts-ignore
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
+ return {
+ data: res.data,
+ total: isNaN(total as any) ? undefined : total
};
},
enabled: !!endpoint,
@@ -35,7 +51,6 @@ export function useResource(config: ResourceConfig | undefined) {
queryKey: [name, "detail", id, params],
queryFn: async () => {
if (!id || !endpoint) return null;
- // @ts-ignore
const res = await api.get(`${endpoint}/${id}`, params ? { params } : undefined);
return res.data;
},
@@ -47,7 +62,6 @@ export function useResource(config: ResourceConfig | undefined) {
useMutation({
mutationFn: async (data: Partial) => {
if (!endpoint) throw new Error("Endpoint not defined");
- // @ts-ignore
const res = await api.post(endpoint, data);
return res.data;
},
@@ -61,12 +75,10 @@ export function useResource(config: ResourceConfig | undefined) {
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial }) => {
if (!endpoint) throw new Error("Endpoint not defined");
- // @ts-ignore
const res = await api.put(`${endpoint}/${id}`, data);
return res.data;
},
- onSuccess: (updatedItem) => {
- // @ts-ignore
+ onSuccess: (updatedItem: any) => {
const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
@@ -78,15 +90,13 @@ export function useResource(config: ResourceConfig | undefined) {
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial }) => {
if (!endpoint) throw new Error("Endpoint not defined");
- // @ts-ignore
const res = await api.patch(`${endpoint}/${id}`, data);
return res.data;
},
- onSuccess: (updatedItem) => {
- // @ts-ignore
- const id = updatedItem[primaryKey];
+ onSuccess: (updatedItem: any) => {
+ const listId = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
- queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
+ queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });
},
});
@@ -108,12 +118,11 @@ export function useResource(config: ResourceConfig | undefined) {
queryKey: [name, "list", params],
queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
- // @ts-ignore
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
+ return {
+ data: res.data,
+ total: isNaN(total as any) ? undefined : total
};
},
enabled: !!endpoint,
@@ -125,7 +134,6 @@ export function useResource(config: ResourceConfig | undefined) {
queryKey: [name, "me"],
queryFn: async () => {
if (!endpoint) return null;
- // @ts-ignore
const res = await api.get(`${endpoint}/me`);
return res.data;
},
@@ -137,7 +145,6 @@ export function useResource(config: ResourceConfig | undefined) {
useMutation({
mutationFn: async (data: Partial) => {
if (!endpoint) throw new Error("Endpoint not defined");
- // @ts-ignore
const res = await api.put(`${endpoint}/me`, data);
return res.data;
},
@@ -147,6 +154,15 @@ export function useResource(config: ResourceConfig | undefined) {
},
});
+ const components = React.useMemo(() => {
+ if (!mergedComponents) return undefined;
+ return {
+ ...mergedComponents,
+ FormField: wrapFormField(mergedComponents),
+ GenericForm: wrapGenericForm(mergedComponents),
+ };
+ }, [mergedComponents]);
+
return {
useList,
useRead,
@@ -157,12 +173,12 @@ export function useResource(config: ResourceConfig | undefined) {
useUpdateMe,
useDelete,
getListQueryOptions,
+ components,
};
}
-export function useResourceByName(name: string) {
+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);
+ return useResource(resourceConfig, options);
}
-
diff --git a/react-openapi/index.ts b/react-openapi/index.ts
index e4012f3..6f60374 100644
--- a/react-openapi/index.ts
+++ b/react-openapi/index.ts
@@ -2,7 +2,12 @@ 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 } from "./components/FilterBar";
+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";
diff --git a/react-openapi/types/config.ts b/react-openapi/types/config.ts
index 347ab7e..cbea504 100644
--- a/react-openapi/types/config.ts
+++ b/react-openapi/types/config.ts
@@ -21,13 +21,13 @@ export interface EnumOption {
}
export interface ResourceField {
+ displayFormat: string;
type: FieldType;
label: string;
required?: boolean;
options?: string[];
readOnly?: boolean;
schema?: Record;
- displayField?: string | string[];
formatter?: (value: any) => string;
relation?: string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts
index 89267be..67a9ae2 100644
--- a/react-openapi/types/overrides.ts
+++ b/react-openapi/types/overrides.ts
@@ -1,14 +1,19 @@
+import { ResourceField, FieldType } from './config';
+
export interface EnumOption {
key: string;
value: string;
}
export interface FieldOverride {
- displayField?: string | string[];
+ 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;
}
export interface ResourceOverride {
@@ -20,4 +25,62 @@ export interface ResourceOverride {
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
index 355c5f6..03cf6c0 100644
--- a/react-openapi/utils/openapi_loader.ts
+++ b/react-openapi/utils/openapi_loader.ts
@@ -65,6 +65,7 @@ function parseSchemaFields(
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
@@ -76,6 +77,12 @@ function parseSchemaFields(
}
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
@@ -108,24 +115,25 @@ function parseSchemaFields(
if (relation) {
fields[key].relation = relation;
- // Propagate enumOption from target resource config, or derive from target schema
- const explicitEnumOption = configuration[relation]?.enumOption;
- if (explicitEnumOption) {
- fields[key].enumOption = explicitEnumOption;
- } else {
- const targetProps = targetSchema.properties || {};
- const valueField = Object.entries(targetProps).find(
- ([name, p]: [string, any]) => name !== 'id' && p.type === 'string'
- )?.[0];
- fields[key].enumOption = {
- key: 'id',
- value: valueField ?? 'id',
- };
- }
+ // 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);
}
}
@@ -137,6 +145,7 @@ function parseSchemaFields(
* 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(
@@ -192,6 +201,7 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
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] || {};
@@ -216,8 +226,9 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
const enums: Record = {};
- if (api.components?.schemas) {
- for (const [name, schema] of Object.entries(api.components.schemas) as [string, any]) {
+ 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;
}
diff --git a/react-openapi/utils/options.ts b/react-openapi/utils/options.ts
index 13ab92a..f995ab1 100644
--- a/react-openapi/utils/options.ts
+++ b/react-openapi/utils/options.ts
@@ -17,7 +17,13 @@ export function getFieldOptions(field: ResourceField, relationData?: any[]): Sel
if (field.relation) {
const data = relationData ?? [];
- const enumOption = field.enumOption ?? { key: 'id', value: 'name' };
+ 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] ?? ''),
diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx
index b55ad77..03e6678 100644
--- a/src/FetchRequestDetail.tsx
+++ b/src/FetchRequestDetail.tsx
@@ -26,8 +26,6 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import {
- useFetchRequest,
- useUpdateFetchRequest,
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./features/fetch-requests";
@@ -37,7 +35,7 @@ import type {
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
-import { useConfig } from "../react-openapi";
+import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
const statusColors: Record = {
pending: "default",
@@ -148,8 +146,9 @@ export default function FetchRequestDetail() {
const navigate = useNavigate();
const config = useConfig();
- const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!);
- const updateMutation = useUpdateFetchRequest();
+ const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
+ const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
+ const updateMutation = usePatch();
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx
index 2abfc6f..3895c2f 100644
--- a/src/FetchRequests.tsx
+++ b/src/FetchRequests.tsx
@@ -4,16 +4,9 @@ import {
Container,
Paper,
Typography,
- TextField,
Button,
ToggleButtonGroup,
ToggleButton,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
Chip,
IconButton,
CircularProgress,
@@ -25,6 +18,7 @@ import {
DialogContentText,
DialogActions,
Tooltip,
+ TextField,
Select,
MenuItem,
InputLabel,
@@ -43,10 +37,6 @@ import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import {
- useFetchRequestsList,
- useCreateFetchRequest,
- useUpdateFetchRequest,
- useDeleteFetchRequest,
useUploadFile,
} from "./features/fetch-requests";
import type {
@@ -57,7 +47,8 @@ import type {
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
-import { useResourceByName, useConfig } from "../react-openapi";
+import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
+import type { ResourceField } from "../react-openapi";
const statusColors: Record = {
pending: "default",
@@ -85,14 +76,14 @@ function formatDate(iso: string) {
}
function formatDateRange(start?: string, end?: string) {
- if (!start && !end) return "—";
+ if (!start && !end) return "\u2014";
const s = start ? new Date(start).toLocaleDateString() : "?";
const e = end ? new Date(end).toLocaleDateString() : "?";
- return `${s} → ${e}`;
+ return `${s} \u2192 ${e}`;
}
function shortId(fp: string) {
- return fp.length > 8 ? fp.slice(0, 8) + "…" : fp;
+ return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
}
export default function FetchRequests() {
@@ -116,11 +107,13 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
- const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({
+ const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
+ const { data: listData, isLoading, isFetching, refetch } = useList({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
});
+
const { useList: useAccountsList } = useResourceByName("accounts");
const { data: accountsData } = useAccountsList();
const accountOptions: string[] = React.useMemo(() => {
@@ -129,11 +122,15 @@ export default function FetchRequests() {
const config = useConfig();
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
- const formatOptions: string[] = fetchRes?.fields?.source?.schema?.format?.options as string[] ?? [];
+ const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
+ const formatOptions: string[] = formatField?.options ?? [];
+ const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
+ const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
+ const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
- const createMutation = useCreateFetchRequest();
- const updateMutation = useUpdateFetchRequest();
- const deleteMutation = useDeleteFetchRequest();
+ const createMutation = useCreate();
+ const updateMutation = usePatch();
+ const deleteMutation = useDelete();
const uploadMutation = useUploadFile();
const requests = listData?.data ?? [];
@@ -178,7 +175,7 @@ export default function FetchRequests() {
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) {
if (err?.response?.status === 409) {
- setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
+ setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
}
@@ -265,25 +262,43 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath}
)}
-
- Format
-
-
+ {formatField && components?.FormField ? (
+
+ ) : (
+
+ Format
+
+
+ )}
>
) : (
<>
-
- Format
-
-
+ {formatField && components?.FormField ? (
+
+ ) : (
+
+ Format
+
+
+ )}
setFromEmail(e.target.value)} size="small" />
setSubject(e.target.value)} size="small" />
setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
@@ -299,29 +314,60 @@ export default function FetchRequests() {
)}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
- setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
+ {payorUsernameField && components?.FormField ? (
+
+ ) : (
+ setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
+ )}
- setStartDate(e.target.value)}
- size="small"
- InputLabelProps={{ shrink: true }}
- inputProps={{ max: new Date().toISOString().split("T")[0] }}
- sx={{ flex: 1 }}
- />
- setEndDate(e.target.value)}
- size="small"
- InputLabelProps={{ shrink: true }}
- inputProps={{ max: new Date().toISOString().split("T")[0] }}
- sx={{ flex: 1 }}
- />
+ {startDateField && components?.date ? (
+
+
+
+ ) : (
+ setStartDate(e.target.value)}
+ size="small"
+ InputLabelProps={{ shrink: true }}
+ inputProps={{ max: new Date().toISOString().split("T")[0] }}
+ sx={{ flex: 1 }}
+ />
+ )}
+ {endDateField && components?.date ? (
+
+
+
+ ) : (
+ setEndDate(e.target.value)}
+ size="small"
+ InputLabelProps={{ shrink: true }}
+ inputProps={{ max: new Date().toISOString().split("T")[0] }}
+ sx={{ flex: 1 }}
+ />
+ )}