Files
khata-ui/react-openapi/src/components/ResourceForm.tsx

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;
}