diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx
index 52fabc8..6fc16c4 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,
@@ -63,7 +64,7 @@ function Dashboard({ basePath }: { basePath: string }) {
import ProfileView from "./components/ProfileView";
-function AdminApp({ basePath }: { basePath: string }) {
+function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldComponents?: FieldComponents }) {
const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext);
const navigate = useNavigate();
@@ -96,32 +97,33 @@ 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 AdminProps {
basePath?: string;
resourceOverrides?: Record;
profileConfig?: any;
+ fieldComponents?: FieldComponents;
}
-export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
+export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents = {} }: AdminProps) {
const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState(existingConfig);
@@ -151,7 +153,7 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
const content = (
-
+
);
diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx
index 8931f07..fe7d1e6 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);
@@ -117,6 +120,7 @@ export default function GenericForm({
uploading={uploading}
baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap}
+ components={fieldComponents}
/>
))}
diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx
index f03885b..568bf8f 100644
--- a/react-openapi/components/ResourceView.tsx
+++ b/react-openapi/components/ResourceView.tsx
@@ -2,6 +2,7 @@ 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';
@@ -12,6 +13,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 +98,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();
@@ -208,6 +210,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
+ fieldComponents={fieldComponents}
/>
)}
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/DefaultFieldComponents.ts b/react-openapi/components/fields/DefaultFieldComponents.ts
new file mode 100644
index 0000000..48c591a
--- /dev/null
+++ b/react-openapi/components/fields/DefaultFieldComponents.ts
@@ -0,0 +1,20 @@
+import { FieldComponents } 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';
+
+export const defaultFieldComponents: FieldComponents = {
+ string: TextFieldEntry,
+ markdown: TextFieldEntry,
+ number: NumberField,
+ boolean: BooleanField,
+ date: DateField,
+ datetime: DateField,
+ enum: EnumField,
+ image: ImageUploadField,
+ relation: RelationField,
+};
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/FormField.tsx b/react-openapi/components/fields/FormField.tsx
index b1915b6..976338a 100644
--- a/react-openapi/components/fields/FormField.tsx
+++ b/react-openapi/components/fields/FormField.tsx
@@ -1,18 +1,9 @@
import * as React from 'react';
-import {
- TextField,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- FormControlLabel,
- Checkbox,
- Typography,
- Box,
- Divider,
-} from '@mui/material';
+import { TextField as MuiTextField } from '@mui/material';
import { ResourceField } from '../../types/config';
-import { getFieldOptions } from '../../utils/options';
+import { FieldComponentProps, FieldComponents } from '../../types/overrides';
+import { defaultFieldComponents } from './DefaultFieldComponents';
+import ObjectField from './ObjectField';
import ImageUploadField from './ImageUploadField';
interface FormFieldProps {
@@ -24,7 +15,19 @@ interface FormFieldProps {
uploadFile: (file: File) => Promise;
uploading: boolean;
baseUrl: string;
- relationDataMap?: Record; // Map of relation name to data array
+ relationDataMap?: Record;
+ components?: FieldComponents;
+}
+
+function FallbackField({ field, value }: FieldComponentProps) {
+ return (
+
+ );
}
export default function FormField({
@@ -37,190 +40,62 @@ export default function FormField({
uploading,
baseUrl,
relationDataMap = {},
+ components: componentsProp,
}: FormFieldProps) {
- const label = field.label;
-
- // 1. Recursive Rendering for Objects (Not Relations)
- 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}
- />
- ))}
-
-
- );
- }
-
- // 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
- if (field.type === 'image') {
- return (
- {
- const url = await uploadFile(file);
- if (url) onChange(url);
- }}
- uploading={uploading}
- baseUrl={baseUrl}
- disabled={disabled}
- />
- );
- }
-
- // 4. Boolean Handling
- if (field.type === 'boolean') {
- return (
- onChange(e.target.checked)}
- disabled={disabled}
- />
- }
- label={label}
- />
- );
- }
-
- // 5. Enum Handling
- if (field.type === 'enum') {
- const options = getFieldOptions(field);
- return (
-
- {label}
-
-
- );
- }
-
- // 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 (
-
+ const components = React.useMemo(
+ () => ({ ...defaultFieldComponents, ...componentsProp }),
+ [componentsProp],
);
+
+ const fieldProps: FieldComponentProps = {
+ name,
+ field,
+ value,
+ onChange,
+ disabled,
+ baseUrl,
+ relationDataMap,
+ uploadFile,
+ uploading,
+ };
+
+ // 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 || ImageUploadField;
+ return ;
+ }
+
+ // 3. Relation
+ if (field.relation && relationDataMap[field.relation]) {
+ const RelationFieldComp = components.relation || defaultFieldComponents.relation!;
+ return ;
+ }
+
+ // 4. Lookup by field type
+ const Component = components[field.type];
+ if (Component) {
+ return ;
+ }
+
+ // 5. Fallback for unknown types
+ return ;
}
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/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..d381428
--- /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].data;
+ 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..9af5170
--- /dev/null
+++ b/react-openapi/components/fields/index.ts
@@ -0,0 +1,11 @@
+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 { defaultFieldComponents } from './DefaultFieldComponents';
+export type { ObjectFieldProps } from './ObjectField';
diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts
index f2a2a21..7805654 100644
--- a/react-openapi/hooks/useResource.ts
+++ b/react-openapi/hooks/useResource.ts
@@ -4,7 +4,10 @@ import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext";
import * as React from "react";
-export function useResource(config: ResourceConfig | undefined) {
+import { FieldComponents } from "../types/overrides";
+import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
+
+export function useResource(config: ResourceConfig | undefined, options?: { fieldComponents?: FieldComponents }) {
const queryClient = useQueryClient();
// Return empty/disabled hooks if config is missing
@@ -147,6 +150,11 @@ export function useResource(config: ResourceConfig | undefined) {
},
});
+ const components = {
+ ...defaultFieldComponents,
+ ...options?.fieldComponents,
+ };
+
return {
useList,
useRead,
@@ -157,12 +165,13 @@ 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..604a90d 100644
--- a/react-openapi/index.ts
+++ b/react-openapi/index.ts
@@ -2,7 +2,20 @@ 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 } 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 {
+ defaultFieldComponents,
+ FormField,
+ TextField,
+ NumberField,
+ BooleanField,
+ DateField,
+ EnumField,
+ RelationField,
+ ObjectField,
+ ImageUploadField,
+} from "./components/fields";
diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts
index 89267be..b54907c 100644
--- a/react-openapi/types/overrides.ts
+++ b/react-openapi/types/overrides.ts
@@ -1,3 +1,5 @@
+import { ResourceField, FieldType } from './config';
+
export interface EnumOption {
key: string;
value: string;
@@ -21,3 +23,23 @@ export interface ResourceOverride {
};
enumOption?: EnumOption;
}
+
+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;
+};