smarter single item components
This commit is contained in:
@@ -1,20 +1,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
TextField,
|
|
||||||
Button,
|
Button,
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
FormControlLabel,
|
|
||||||
Checkbox,
|
|
||||||
Typography,
|
Typography,
|
||||||
Divider,
|
Divider,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ResourceConfig, ResourceField } from '../types/config';
|
import { ResourceConfig } from '../types/config';
|
||||||
import { useUpload } from '../providers/UploadProvider';
|
import { useUpload } from '../providers/UploadProvider';
|
||||||
import ImageUploadField from './fields/ImageUploadField';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
import { useResource } from '../hooks/useResource';
|
||||||
|
import FormField from './fields/FormField';
|
||||||
|
import { ConfigContext } from '../App';
|
||||||
|
|
||||||
interface GenericFormProps {
|
interface GenericFormProps {
|
||||||
config: ResourceConfig;
|
config: ResourceConfig;
|
||||||
@@ -26,8 +23,6 @@ interface GenericFormProps {
|
|||||||
onEditClick?: () => void;
|
onEditClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { ConfigContext } from '../App';
|
|
||||||
|
|
||||||
export default function GenericForm({
|
export default function GenericForm({
|
||||||
config,
|
config,
|
||||||
initialData = {},
|
initialData = {},
|
||||||
@@ -42,6 +37,41 @@ export default function GenericForm({
|
|||||||
const { uploadFile, uploading } = useUpload();
|
const { uploadFile, uploading } = useUpload();
|
||||||
const appConfig = React.useContext(ConfigContext);
|
const appConfig = React.useContext(ConfigContext);
|
||||||
|
|
||||||
|
// 1. Identify all unique relations in the schema (including nested ones)
|
||||||
|
const getRelationFields = (fields: Record<string, any>): string[] => {
|
||||||
|
let relations: string[] = [];
|
||||||
|
Object.values(fields).forEach(field => {
|
||||||
|
if (field.relation) relations.push(field.relation);
|
||||||
|
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
|
||||||
|
});
|
||||||
|
return Array.from(new Set(relations));
|
||||||
|
};
|
||||||
|
|
||||||
|
const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]);
|
||||||
|
|
||||||
|
// 2. Parallel fetch for all related resource lists
|
||||||
|
const queries = useQueries({
|
||||||
|
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!);
|
||||||
|
return {
|
||||||
|
...getListQueryOptions(),
|
||||||
|
enabled: !!relatedRes,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoadingRelations = queries.some(q => q.isLoading);
|
||||||
|
|
||||||
|
const relationDataMap = React.useMemo(() => {
|
||||||
|
const map: Record<string, any[]> = {};
|
||||||
|
allRelations.forEach((relName, index) => {
|
||||||
|
map[relName] = queries[index].data || [];
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allRelations, queries]);
|
||||||
|
|
||||||
const handleChange = (key: string, value: any) => {
|
const handleChange = (key: string, value: any) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||||
@@ -58,6 +88,15 @@ export default function GenericForm({
|
|||||||
return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`;
|
return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoadingRelations) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 8, gap: 2 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography variant="body2" color="text.secondary">Loading relationships...</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
<Typography variant="h5">
|
<Typography variant="h5">
|
||||||
@@ -76,6 +115,7 @@ export default function GenericForm({
|
|||||||
uploadFile={uploadFile}
|
uploadFile={uploadFile}
|
||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
baseUrl={appConfig?.baseUrl || ""}
|
baseUrl={appConfig?.baseUrl || ""}
|
||||||
|
relationDataMap={relationDataMap}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -96,125 +136,3 @@ export default function GenericForm({
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading, baseUrl }: any) {
|
|
||||||
const label = field.label;
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'boolean') {
|
|
||||||
return (
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={!!value}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'enum' && field.options) {
|
|
||||||
return (
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>{label}</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={value || ''}
|
|
||||||
label={label}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{field.options.map((opt: string) => (
|
|
||||||
<MenuItem key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'datetime') {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
type="datetime-local"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'date') {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
type="date"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
value={value ? new Date(value).toISOString().split('T')[0] : ''}
|
|
||||||
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 || 0}
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
disabled={disabled}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={JSON.stringify(value)}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -67,11 +67,21 @@ export function useResource<T = any>(config: ResourceConfig) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- HELPERS FOR useQueries ---
|
||||||
|
const getListQueryOptions = (params?: any) => ({
|
||||||
|
queryKey: [name, "list", params],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useList,
|
useList,
|
||||||
useRead,
|
useRead,
|
||||||
useCreate,
|
useCreate,
|
||||||
useUpdate,
|
useUpdate,
|
||||||
useDelete,
|
useDelete,
|
||||||
|
getListQueryOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user