common fields
This commit is contained in:
@@ -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 }) {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
||||
<Route path="/profile" element={<ProfileView />} />
|
||||
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Typography>Resource not found</Typography>;
|
||||
|
||||
return <ResourceView config={selectedResource} />;
|
||||
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
|
||||
}
|
||||
|
||||
interface AdminProps {
|
||||
basePath?: string;
|
||||
resourceOverrides?: Record<string, any>;
|
||||
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<AppConfig | null>(existingConfig);
|
||||
|
||||
@@ -151,7 +153,7 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
|
||||
|
||||
const content = (
|
||||
<UploadProvider>
|
||||
<AdminApp basePath={basePath} />
|
||||
<AdminApp basePath={basePath} fieldComponents={fieldComponents} />
|
||||
</UploadProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
20
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
@@ -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,
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>;
|
||||
uploading: boolean;
|
||||
baseUrl: string;
|
||||
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||
relationDataMap?: Record<string, any[]>;
|
||||
components?: FieldComponents;
|
||||
}
|
||||
|
||||
function FallbackField({ field, value }: FieldComponentProps) {
|
||||
return (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Boolean Handling
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
/>
|
||||
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) => (
|
||||
<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={componentsProp}
|
||||
/>
|
||||
);
|
||||
return <ObjectField {...fieldProps} renderField={renderChild} />;
|
||||
}
|
||||
|
||||
// 2. Image
|
||||
if (field.type === 'image') {
|
||||
const ImageField = components.image || ImageUploadField;
|
||||
return <ImageField {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 3. Relation
|
||||
if (field.relation && relationDataMap[field.relation]) {
|
||||
const RelationFieldComp = components.relation || defaultFieldComponents.relation!;
|
||||
return <RelationFieldComp {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 4. Lookup by field type
|
||||
const Component = components[field.type];
|
||||
if (Component) {
|
||||
return <Component {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 5. Fallback for unknown types
|
||||
return <FallbackField {...fieldProps} />;
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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].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 (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
react-openapi/components/fields/index.ts
Normal file
11
react-openapi/components/fields/index.ts
Normal file
@@ -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';
|
||||
@@ -4,7 +4,10 @@ import { ResourceConfig } from "../types/config";
|
||||
import { ConfigContext } from "../providers/ConfigContext";
|
||||
import * as React from "react";
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
import { FieldComponents } from "../types/overrides";
|
||||
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig | undefined, options?: { fieldComponents?: FieldComponents }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Return empty/disabled hooks if config is missing
|
||||
@@ -147,6 +150,11 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
},
|
||||
});
|
||||
|
||||
const components = {
|
||||
...defaultFieldComponents,
|
||||
...options?.fieldComponents,
|
||||
};
|
||||
|
||||
return {
|
||||
useList,
|
||||
useRead,
|
||||
@@ -157,12 +165,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
useUpdateMe,
|
||||
useDelete,
|
||||
getListQueryOptions,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
export function useResourceByName<T = any>(name: string) {
|
||||
export function useResourceByName<T = any>(name: string, options?: { fieldComponents?: FieldComponents }) {
|
||||
const config = React.useContext(ConfigContext);
|
||||
const resourceConfig = config?.resources.find((r) => r.name === name);
|
||||
return useResource<T>(resourceConfig);
|
||||
return useResource<T>(resourceConfig, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, any[]>;
|
||||
uploadFile?: (file: File) => Promise<string | null>;
|
||||
uploading?: boolean;
|
||||
}
|
||||
|
||||
export type FieldComponent = React.ComponentType<FieldComponentProps>;
|
||||
|
||||
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
|
||||
relation?: FieldComponent;
|
||||
image?: FieldComponent;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user