common fields
This commit is contained in:
@@ -6,6 +6,7 @@ import ResourceView from "./components/ResourceView";
|
|||||||
import { getAppConfig } from "./config";
|
import { getAppConfig } from "./config";
|
||||||
import { initializeApiClients } from "./api/client";
|
import { initializeApiClients } from "./api/client";
|
||||||
import { AppConfig } from "./types/config";
|
import { AppConfig } from "./types/config";
|
||||||
|
import { FieldComponents } from "./types/overrides";
|
||||||
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Routes,
|
Routes,
|
||||||
@@ -63,7 +64,7 @@ function Dashboard({ basePath }: { basePath: string }) {
|
|||||||
|
|
||||||
import ProfileView from "./components/ProfileView";
|
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 { currentUser, login, logout, loading, error } = useAuth();
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -96,32 +97,33 @@ function AdminApp({ basePath }: { basePath: string }) {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
||||||
<Route path="/profile" element={<ProfileView />} />
|
<Route path="/profile" element={<ProfileView />} />
|
||||||
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceRouteWrapper() {
|
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents?: FieldComponents }) {
|
||||||
const { resourceName } = useParams();
|
const { resourceName } = useParams();
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
||||||
|
|
||||||
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
||||||
|
|
||||||
return <ResourceView config={selectedResource} />;
|
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminProps {
|
interface AdminProps {
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
resourceOverrides?: Record<string, any>;
|
resourceOverrides?: Record<string, any>;
|
||||||
profileConfig?: 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 existingConfig = React.useContext(ConfigContext);
|
||||||
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
||||||
|
|
||||||
@@ -151,7 +153,7 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<UploadProvider>
|
<UploadProvider>
|
||||||
<AdminApp basePath={basePath} />
|
<AdminApp basePath={basePath} fieldComponents={fieldComponents} />
|
||||||
</UploadProvider>
|
</UploadProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ResourceConfig } from '../types/config';
|
import { ResourceConfig } from '../types/config';
|
||||||
|
import { FieldComponents } from '../types/overrides';
|
||||||
import { useUpload } from '../providers/UploadProvider';
|
import { useUpload } from '../providers/UploadProvider';
|
||||||
import { useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
@@ -21,6 +22,7 @@ interface GenericFormProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onEditClick?: () => void;
|
onEditClick?: () => void;
|
||||||
|
fieldComponents?: FieldComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GenericForm({
|
export default function GenericForm({
|
||||||
@@ -31,6 +33,7 @@ export default function GenericForm({
|
|||||||
loading: saving,
|
loading: saving,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
onEditClick,
|
onEditClick,
|
||||||
|
fieldComponents,
|
||||||
}: GenericFormProps) {
|
}: GenericFormProps) {
|
||||||
initialData = initialData || {};
|
initialData = initialData || {};
|
||||||
const [formData, setFormData] = React.useState(initialData);
|
const [formData, setFormData] = React.useState(initialData);
|
||||||
@@ -117,6 +120,7 @@ export default function GenericForm({
|
|||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
baseUrl={appConfig?.baseUrl || ""}
|
baseUrl={appConfig?.baseUrl || ""}
|
||||||
relationDataMap={relationDataMap}
|
relationDataMap={relationDataMap}
|
||||||
|
components={fieldComponents}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|||||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||||
import { ResourceConfig } from '../types/config';
|
import { ResourceConfig } from '../types/config';
|
||||||
import type { ResourceField } from '../types/config';
|
import type { ResourceField } from '../types/config';
|
||||||
|
import { FieldComponents } from '../types/overrides';
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
import { resolveTemplate } from '../utils/options';
|
import { resolveTemplate } from '../utils/options';
|
||||||
import GenericForm from './GenericForm';
|
import GenericForm from './GenericForm';
|
||||||
@@ -12,6 +13,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
interface ResourceViewProps {
|
interface ResourceViewProps {
|
||||||
config: ResourceConfig;
|
config: ResourceConfig;
|
||||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||||
|
fieldComponents?: FieldComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { GridPaginationModel } from '@mui/x-data-grid';
|
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 { id } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -208,6 +210,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
|||||||
loading={createMutation.isPending || updateMutation.isPending}
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
readOnly={isView}
|
readOnly={isView}
|
||||||
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
||||||
|
fieldComponents={fieldComponents}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</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 * as React from 'react';
|
||||||
import {
|
import { TextField as MuiTextField } from '@mui/material';
|
||||||
TextField,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
FormControlLabel,
|
|
||||||
Checkbox,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Divider,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { ResourceField } from '../../types/config';
|
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';
|
import ImageUploadField from './ImageUploadField';
|
||||||
|
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
@@ -24,7 +15,19 @@ interface FormFieldProps {
|
|||||||
uploadFile: (file: File) => Promise<string | null>;
|
uploadFile: (file: File) => Promise<string | null>;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
baseUrl: string;
|
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({
|
export default function FormField({
|
||||||
@@ -37,190 +40,62 @@ export default function FormField({
|
|||||||
uploading,
|
uploading,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
relationDataMap = {},
|
relationDataMap = {},
|
||||||
|
components: componentsProp,
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
const label = field.label;
|
const components = React.useMemo(
|
||||||
|
() => ({ ...defaultFieldComponents, ...componentsProp }),
|
||||||
// 1. Recursive Rendering for Objects (Not Relations)
|
[componentsProp],
|
||||||
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 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 { ConfigContext } from "../providers/ConfigContext";
|
||||||
import * as React from "react";
|
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();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Return empty/disabled hooks if config is missing
|
// 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 {
|
return {
|
||||||
useList,
|
useList,
|
||||||
useRead,
|
useRead,
|
||||||
@@ -157,12 +165,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
useUpdateMe,
|
useUpdateMe,
|
||||||
useDelete,
|
useDelete,
|
||||||
getListQueryOptions,
|
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 config = React.useContext(ConfigContext);
|
||||||
const resourceConfig = config?.resources.find((r) => r.name === name);
|
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 { api, auth, initializeApiClients } from "./api/client";
|
||||||
export { getAppConfig } from "./config";
|
export { getAppConfig } from "./config";
|
||||||
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/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 { AppProvider } from "./providers/AppProvider";
|
||||||
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
||||||
export { useResource, useResourceByName } from "./hooks/useResource";
|
export { useResource, useResourceByName } from "./hooks/useResource";
|
||||||
export { default as FilterBar } from "./components/FilterBar";
|
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 {
|
export interface EnumOption {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -21,3 +23,23 @@ export interface ResourceOverride {
|
|||||||
};
|
};
|
||||||
enumOption?: EnumOption;
|
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