Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. (#11)
# Summary Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. ## Changes ### Admin UI Customization * Added support for custom: * Dashboard component * Layout component * Login page component * Introduced `AdminAppProps` and extended `Admin` configuration API. * Renamed internal dashboard implementation to `DefaultDashboard`. ### Field Component Architecture * Extracted field rendering into dedicated field components: * TextField * NumberField * BooleanField * DateField * EnumField * RelationField * ObjectField * FallbackField * DateRangeField * NumberRangeField * Added `defaultFieldComponents` registry. * Refactored `FormField` to resolve components dynamically from a component map instead of hardcoded field type handling. ### Resource Customization * Added `FieldComponents` support across: * Admin * ResourceView * GenericForm * useResource * Introduced wrapped `FormField` and `GenericForm` components generated from configured field overrides. ### Table Customization * Added `EnhancedTableComponents`. * Added support for custom cell renderers per field type. * Enabled custom rendering for both desktop and mobile table layouts. ### Filter Improvements * Exported `FilterAutocomplete`. * Added support for custom date-range and number-range filter components. * Added filter component extension points. * Updated filter option label resolution to support `displayFormat`. ### Display Formatting * Replaced `displayField` usage with `displayFormat`. * Added template-based display rendering support through `resolveTemplate`. * Improved relation display configuration handling. ### TypeScript Improvements * Added TypeScript as a project dependency. * Removed multiple `@ts-ignore` usages. * Added strongly typed Axios wrapper methods with generic response support. * Improved typing across hooks and component interfaces. ### OpenAPI Configuration Validation * Added validation for enum fields without enum values. * Added validation for relation resources missing `referenceOptions.enumOption`. * Improved relation metadata propagation during schema parsing. ### Library Exports * Exported: * Field component types * Override types * EnhancedTable * GenericForm * ResourceView * Field components and defaults * Expanded public API surface for consumers extending the framework. ## Benefits * Enables complete UI customization without modifying framework internals. * Simplifies creation of custom field types and renderers. * Improves type safety and developer experience. * Provides consistent extension points for forms, tables, filters, and admin layouts. * Makes the framework more suitable for reusable library distribution. Reviewed-on: #11 Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com> Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
@@ -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) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} />
|
||||
};
|
||||
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
@@ -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 | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const id = row[config.primaryKey];
|
||||
@@ -261,7 +265,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} />
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<TextField type="number" placeholder="Min" size="small" value={rangeVal.min ?? ""}
|
||||
onChange={(e) => onChange("min", e.target.value || undefined)} sx={{ width: 100 }} />
|
||||
<TextField type="number" placeholder="Max" size="small" value={rangeVal.max ?? ""}
|
||||
onChange={(e) => onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
|
||||
</Box>
|
||||
);
|
||||
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
||||
}
|
||||
|
||||
if (filterType === "date-range") {
|
||||
const RangeComponent = fieldComponents?.dateRange;
|
||||
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
|
||||
const rangeVal = (value as { start?: string; end?: string }) || {};
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<TextField type="datetime-local" placeholder="From" size="small" value={rangeVal.start ?? ""}
|
||||
onChange={(e) => onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
|
||||
<TextField type="datetime-local" placeholder="To" size="small" value={rangeVal.end ?? ""}
|
||||
onChange={(e) => onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
|
||||
</Box>
|
||||
);
|
||||
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
||||
}
|
||||
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
@@ -208,6 +196,8 @@ export interface FilterBarProps {
|
||||
appliedValues: Record<string, any>;
|
||||
onApply: (values: Record<string, any>) => 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<Record<string, any>>(() => ({ ...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}
|
||||
</Box>
|
||||
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
|
||||
updateDraft(fieldName, key, val)
|
||||
updateDraft(fieldName, key, val), filterComponents, fieldComponents
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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 <Alert severity="error">Profile configuration not found.</Alert>;
|
||||
}
|
||||
|
||||
// Create a modified config where only extraFields are editable
|
||||
const editableConfig = React.useMemo(() => {
|
||||
const newFields = { ...resourceConfig.fields };
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
|
||||
|
||||
Object.keys(newFields).forEach(key => {
|
||||
newFields[key] = {
|
||||
...newFields[key],
|
||||
@@ -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}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -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<Record<string, any>>({});
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<EnhancedTable
|
||||
@@ -200,7 +202,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
</Box>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
{components && <components.GenericForm
|
||||
config={config}
|
||||
initialData={isCreate ? null : itemQuery.data}
|
||||
onSave={handleSave}
|
||||
@@ -208,7 +210,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
readOnly={isView}
|
||||
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
||||
/>
|
||||
/>}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
17
react-openapi/components/fields/BooleanField.tsx
Normal file
17
react-openapi/components/fields/BooleanField.tsx
Normal file
@@ -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 (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
react-openapi/components/fields/DateField.tsx
Normal file
18
react-openapi/components/fields/DateField.tsx
Normal file
@@ -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 (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={isDatetime ? "datetime-local" : "date"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
react-openapi/components/fields/DateRangeField.tsx
Normal file
30
react-openapi/components/fields/DateRangeField.tsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<MuiTextField
|
||||
type="date"
|
||||
placeholder="From"
|
||||
size="small"
|
||||
value={rangeVal.start ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 170 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MuiTextField
|
||||
type="date"
|
||||
placeholder="To"
|
||||
size="small"
|
||||
value={rangeVal.end ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 170 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
40
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
40
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
@@ -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,
|
||||
};
|
||||
24
react-openapi/components/fields/EnumField.tsx
Normal file
24
react-openapi/components/fields/EnumField.tsx
Normal file
@@ -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 (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
13
react-openapi/components/fields/FallbackField.tsx
Normal file
13
react-openapi/components/fields/FallbackField.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function FallbackField({ field, value }: FieldComponentProps) {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,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<string | null>;
|
||||
uploading: boolean;
|
||||
baseUrl: string;
|
||||
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||
uploadFile?: (file: File) => Promise<string | null>;
|
||||
uploading?: boolean;
|
||||
baseUrl?: string;
|
||||
relationDataMap?: Record<string, any[]>;
|
||||
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 (
|
||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(field.schema).map(([subKey, subField]) => (
|
||||
<FormField
|
||||
key={subKey}
|
||||
name={`${name}.${subKey}`}
|
||||
field={subField}
|
||||
value={value?.[subKey]}
|
||||
onChange={(newVal) => {
|
||||
const updated = { ...(value || {}), [subKey]: newVal };
|
||||
onChange(updated);
|
||||
}}
|
||||
disabled={disabled}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
relationDataMap={relationDataMap}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
const renderChild = (childProps: FieldComponentProps) => (
|
||||
<FormField
|
||||
name={childProps.name}
|
||||
field={childProps.field}
|
||||
value={childProps.value}
|
||||
onChange={childProps.onChange}
|
||||
disabled={childProps.disabled}
|
||||
uploadFile={childProps.uploadFile}
|
||||
uploading={childProps.uploading}
|
||||
baseUrl={childProps.baseUrl}
|
||||
relationDataMap={childProps.relationDataMap}
|
||||
components={components}
|
||||
/>
|
||||
);
|
||||
return <ObjectField {...fieldProps} renderField={renderChild} />;
|
||||
}
|
||||
|
||||
// 2. 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 (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<Select
|
||||
multiple={isArrayRelation}
|
||||
value={normalizedValue}
|
||||
label={label}
|
||||
displayEmpty
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected: any) => {
|
||||
if (isArrayRelation) {
|
||||
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
||||
}
|
||||
return options.find(o => o.key === selected)?.value ?? selected;
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Image Handling
|
||||
// 2. Image
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<ImageUploadField
|
||||
label={label}
|
||||
value={value}
|
||||
onUpload={async (file: any) => {
|
||||
const url = await uploadFile(file);
|
||||
if (url) onChange(url);
|
||||
}}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
const ImageField = components.image;
|
||||
if (!ImageField) return null;
|
||||
return <ImageField {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 4. Boolean Handling
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => 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 <RelationFieldComp {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 5. Enum Handling
|
||||
if (field.type === 'enum') {
|
||||
const options = getFieldOptions(field);
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
// 4. Lookup by field type
|
||||
const Component = components[field.type] || components.default;
|
||||
if (Component) {
|
||||
return <Component {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 6. Common Text Fields
|
||||
if (field.type === 'datetime' || field.type === 'date') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type={field.type === 'datetime' ? "datetime-local" : "date"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'markdown' || field.type === 'string') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value || ''}
|
||||
multiline={field.type === 'markdown'}
|
||||
rows={field.type === 'markdown' ? 4 : 1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? '' : value}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
16
react-openapi/components/fields/NumberField.tsx
Normal file
16
react-openapi/components/fields/NumberField.tsx
Normal file
@@ -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 (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? '' : value}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
react-openapi/components/fields/NumberRangeField.tsx
Normal file
28
react-openapi/components/fields/NumberRangeField.tsx
Normal file
@@ -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 (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<MuiTextField
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
size="small"
|
||||
value={rangeVal.min ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
|
||||
sx={{ width: 100 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MuiTextField
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
size="small"
|
||||
value={rangeVal.max ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
|
||||
sx={{ width: 100 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
36
react-openapi/components/fields/ObjectField.tsx
Normal file
36
react-openapi/components/fields/ObjectField.tsx
Normal file
@@ -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 (
|
||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(field.schema).map(([subKey, subField]) =>
|
||||
renderField({
|
||||
name: `${name}.${subKey}`,
|
||||
field: subField,
|
||||
value: value?.[subKey],
|
||||
onChange: (newVal: any) => {
|
||||
const updated = { ...(value || {}), [subKey]: newVal };
|
||||
onChange(updated);
|
||||
},
|
||||
disabled,
|
||||
baseUrl,
|
||||
uploadFile,
|
||||
uploading,
|
||||
relationDataMap,
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
react-openapi/components/fields/RelationField.tsx
Normal file
50
react-openapi/components/fields/RelationField.tsx
Normal file
@@ -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 (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel shrink>{field.label}</InputLabel>
|
||||
<Select
|
||||
multiple={isArrayRelation}
|
||||
value={normalizedValue}
|
||||
label={field.label}
|
||||
displayEmpty
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected: any) => {
|
||||
if (isArrayRelation) {
|
||||
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
||||
}
|
||||
return options.find(o => o.key === selected)?.value ?? selected;
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
18
react-openapi/components/fields/TextField.tsx
Normal file
18
react-openapi/components/fields/TextField.tsx
Normal file
@@ -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 (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
value={value || ''}
|
||||
multiline={isMarkdown}
|
||||
rows={isMarkdown ? 4 : 1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
react-openapi/components/fields/index.ts
Normal file
14
react-openapi/components/fields/index.ts
Normal file
@@ -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';
|
||||
Reference in New Issue
Block a user