302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import React, { useEffect, useState, useCallback } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Button,
|
|
Paper,
|
|
Grid,
|
|
CircularProgress,
|
|
Alert,
|
|
Snackbar,
|
|
} from "@mui/material";
|
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
|
import SaveIcon from "@mui/icons-material/Save";
|
|
import type { ResourceConfig, FieldConfig } from "../types";
|
|
import { useResource } from "../context/useResource";
|
|
import { useAppContext } from "../context/AppContext";
|
|
import { getApi } from "../hooks/useApi";
|
|
import { FormFieldRenderer } from "./fields";
|
|
import { extractFields } from "../transformers/field-config";
|
|
|
|
interface ResourceFormProps {
|
|
resource: ResourceConfig;
|
|
basePath: string;
|
|
mode: "create" | "edit";
|
|
}
|
|
|
|
export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
|
const navigate = useNavigate();
|
|
const { id } = useParams();
|
|
const crud = useResource(resource);
|
|
const { resources: allResources } = useAppContext();
|
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({
|
|
open: false,
|
|
message: "",
|
|
severity: "success",
|
|
});
|
|
|
|
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
|
|
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
|
|
const { schemas } = useAppContext();
|
|
|
|
useEffect(() => {
|
|
console.log(`[ResourceForm] mounted resource="${resource.name}" mode=${mode} id=${id}`);
|
|
console.log(`[ResourceForm] relationships:`, resource.relationships.map(r => `{field=${r.fieldName} target=${r.config.resource} prefetch=${r.config.prefetch}}`));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (mode === "create") {
|
|
const initial: Record<string, any> = {};
|
|
resource.orderedFields.forEach((f) => {
|
|
if (f.refSchema && !f.fk && formData[f.name] === undefined) {
|
|
const refSchemaObj = schemas[f.refSchema!];
|
|
if (refSchemaObj) {
|
|
const nestedFields = extractFields(f.refSchema!, refSchemaObj, schemas);
|
|
initial[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas);
|
|
} else {
|
|
initial[f.name] = null;
|
|
}
|
|
}
|
|
});
|
|
if (Object.keys(initial).length > 0) {
|
|
setFormData((prev) => ({ ...prev, ...initial }));
|
|
}
|
|
}
|
|
}, [mode, resource.name]);
|
|
|
|
const loadFkOptions = useCallback(async (fieldName: string, fk: { resource: string; prefetch: boolean }) => {
|
|
console.log(`[loadFkOptions] CALLED field="${fieldName}" resource="${fk.resource}" prefetch=${fk.prefetch}`);
|
|
setFkLoading((prev) => ({ ...prev, [fieldName]: true }));
|
|
try {
|
|
const targetRes = allResources.find((r) => r.name === fk.resource);
|
|
if (!targetRes) {
|
|
console.log(`[loadFkOptions] targetRes NOT FOUND for "${fk.resource}"`);
|
|
return;
|
|
}
|
|
console.log(`[loadFkOptions] targetRes found: path="${targetRes.path}" pagination=${!!targetRes.pagination}`);
|
|
|
|
const api = getApi();
|
|
const params: Record<string, any> = {};
|
|
if (targetRes.pagination) {
|
|
params.limit = 0;
|
|
}
|
|
console.log(`[loadFkOptions] fetching GET ${targetRes.path}`, params);
|
|
const res = await api.get(targetRes.path, { params });
|
|
console.log(`[loadFkOptions] response status=${res.status} data type=${typeof res.data} isArray=${Array.isArray(res.data)}`);
|
|
|
|
let items: any[];
|
|
if (targetRes.pagination) {
|
|
if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) {
|
|
console.log(`[loadFkOptions] paginated parse FAILED: data=`, res.data);
|
|
throw new Error(`Expected paginated response from ${targetRes.path}`);
|
|
}
|
|
items = res.data.items;
|
|
console.log(`[loadFkOptions] paginated: total=${res.data.total} items.length=${items.length}`);
|
|
} else {
|
|
if (!Array.isArray(res.data)) {
|
|
console.log(`[loadFkOptions] non-paginated parse FAILED: data=`, res.data);
|
|
throw new Error(`Expected array response from ${targetRes.path}`);
|
|
}
|
|
items = res.data;
|
|
console.log(`[loadFkOptions] non-paginated: items.length=${items.length}`);
|
|
}
|
|
|
|
const opts = items.map((item: any) => ({
|
|
value: item[targetRes.primaryKey],
|
|
label: applyFormat(item, targetRes.displayFormat),
|
|
}));
|
|
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
|
|
|
|
setFkOptions((prev) => ({ ...prev, [fieldName]: opts }));
|
|
} catch (e) {
|
|
console.log(`[loadFkOptions] ERROR field="${fieldName}":`, e);
|
|
} finally {
|
|
setFkLoading((prev) => ({ ...prev, [fieldName]: false }));
|
|
}
|
|
}, [allResources]);
|
|
|
|
useEffect(() => {
|
|
console.log(`[prefetch effect] ${resource.relationships.length} relationships, checking prefetch...`);
|
|
resource.relationships.forEach((rel) => {
|
|
console.log(`[prefetch effect] field="${rel.fieldName}" prefetch=${rel.config.prefetch} -> ${rel.config.prefetch ? "WILL FETCH" : "skipped (onFocus)"}`);
|
|
if (rel.config.prefetch) {
|
|
loadFkOptions(rel.fieldName, rel.config);
|
|
}
|
|
});
|
|
}, [resource.relationships, loadFkOptions]);
|
|
|
|
useEffect(() => {
|
|
if (mode === "edit" && id) {
|
|
crud.get(id).then((data) => {
|
|
const resolved = { ...(data ?? {}) };
|
|
resource.relationships.forEach((rel) => {
|
|
const val = resolved[rel.fieldName];
|
|
if (val != null) {
|
|
const targetRes = allResources.find((r) => r.name === rel.config.resource);
|
|
if (targetRes) {
|
|
if (Array.isArray(val)) {
|
|
resolved[rel.fieldName] = val.map((item: any) => item[targetRes.primaryKey]);
|
|
} else if (typeof val === "object") {
|
|
resolved[rel.fieldName] = val[targetRes.primaryKey];
|
|
}
|
|
}
|
|
if (!rel.config.prefetch) {
|
|
loadFkOptions(rel.fieldName, rel.config);
|
|
}
|
|
}
|
|
});
|
|
setFormData(resolved);
|
|
});
|
|
}
|
|
}, [mode, id, loadFkOptions, resource.relationships]);
|
|
|
|
const loadFkOnFocus = (fieldName: string) => {
|
|
console.log(`[loadFkOnFocus] CALLED field="${fieldName}"`);
|
|
const rel = resource.relationships.find((r) => r.fieldName === fieldName);
|
|
if (rel) {
|
|
console.log(`[loadFkOnFocus] found rel: prefetch=${rel.config.prefetch} fkOptions[${fieldName}]=${fkOptions[fieldName] ? "exists" : "undefined"}`);
|
|
} else {
|
|
console.log(`[loadFkOnFocus] NO RELATIONSHIP found for field="${fieldName}"`);
|
|
}
|
|
if (rel && !rel.config.prefetch && !fkOptions[fieldName]) {
|
|
console.log(`[loadFkOnFocus] conditions met -> calling loadFkOptions`);
|
|
loadFkOptions(fieldName, rel.config);
|
|
} else {
|
|
console.log(`[loadFkOnFocus] NOT calling loadFkOptions: rel=${!!rel} !prefetch=${rel && !rel.config.prefetch} !hasOptions=${!fkOptions[fieldName]}`);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
const validationErrors: Record<string, string> = {};
|
|
resource.orderedFields
|
|
.filter((f) => f.required && !f.readOnly && f.name !== resource.primaryKey)
|
|
.forEach((f) => {
|
|
if (formData[f.name] === undefined || formData[f.name] === null || formData[f.name] === "") {
|
|
validationErrors[f.name] = `${f.label} is required`;
|
|
}
|
|
});
|
|
|
|
if (Object.keys(validationErrors).length > 0) {
|
|
setErrors(validationErrors);
|
|
return;
|
|
}
|
|
|
|
setErrors({});
|
|
setSaving(true);
|
|
|
|
try {
|
|
if (mode === "create") {
|
|
await crud.create(formData);
|
|
setSnackbar({ open: true, message: "Created successfully", severity: "success" });
|
|
navigate(`${basePath}/${resource.name}`);
|
|
} else {
|
|
await crud.update(id!, formData);
|
|
setSnackbar({ open: true, message: "Updated successfully", severity: "success" });
|
|
navigate(`${basePath}/${resource.name}/${id}`);
|
|
}
|
|
} catch (e: any) {
|
|
setSnackbar({ open: true, message: e.message ?? "Operation failed", severity: "error" });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleChange = (fieldName: string, value: any) => {
|
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
|
if (errors[fieldName]) {
|
|
setErrors((prev) => {
|
|
const copy = { ...prev };
|
|
delete copy[fieldName];
|
|
return copy;
|
|
});
|
|
}
|
|
};
|
|
|
|
const title = mode === "create" ? `Create ${resource.schemaName}` : `Edit ${resource.schemaName}`;
|
|
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
|
|
Back
|
|
</Button>
|
|
<Typography variant="h5" fontWeight={700}>
|
|
{title}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Paper variant="outlined" sx={{ p: 3 }}>
|
|
<Box component="form" onSubmit={handleSubmit}>
|
|
<Grid container spacing={2}>
|
|
{resource.orderedFields
|
|
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
|
|
.map((field) => (
|
|
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
|
<FormFieldRenderer
|
|
field={field}
|
|
value={formData[field.name]}
|
|
onChange={(val) => handleChange(field.name, val)}
|
|
error={errors[field.name]}
|
|
fkOptions={fkOptions[field.name]}
|
|
fkLoading={fkLoading[field.name]}
|
|
recordId={id}
|
|
onFkOpen={loadFkOnFocus}
|
|
/>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
<Box sx={{ mt: 3, display: "flex", gap: 2 }}>
|
|
<Button
|
|
type="submit"
|
|
variant="contained"
|
|
startIcon={saving ? <CircularProgress size={18} color="inherit" /> : <SaveIcon />}
|
|
disabled={saving}
|
|
>
|
|
{mode === "create" ? "Create" : "Save Changes"}
|
|
</Button>
|
|
<Button variant="outlined" onClick={() => navigate(`${basePath}/${resource.name}`)}>
|
|
Cancel
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
|
|
<Snackbar
|
|
open={snackbar.open}
|
|
autoHideDuration={4000}
|
|
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
|
|
>
|
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
|
|
{snackbar.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function applyFormat(obj: any, format: string): string {
|
|
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
|
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
|
}
|
|
|
|
function buildInitialShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
|
|
const shape: Record<string, any> = {};
|
|
for (const f of fields) {
|
|
if (f.refSchema && !f.fk) {
|
|
const refSchemaObj = schemas[f.refSchema!];
|
|
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
|
|
shape[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas);
|
|
} else {
|
|
shape[f.name] = null;
|
|
}
|
|
}
|
|
return shape;
|
|
}
|