updated react-openapi
This commit is contained in:
301
react-openapi/src/components/ResourceForm.tsx
Normal file
301
react-openapi/src/components/ResourceForm.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user