updated react-openapi
This commit is contained in:
56
react-openapi/src/components/Admin.tsx
Normal file
56
react-openapi/src/components/Admin.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { Layout } from "./Layout";
|
||||
import { ResourceList } from "./ResourceList";
|
||||
import { ResourceForm } from "./ResourceForm";
|
||||
import { ResourceDetail } from "./ResourceDetail";
|
||||
import { ValidationAlert } from "./ValidationAlert";
|
||||
|
||||
interface AdminProps {
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export function Admin({ basePath }: AdminProps) {
|
||||
const { resources, loading, errors, warnings } = useAppContext();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return <ValidationAlert errors={errors} warnings={warnings} />;
|
||||
}
|
||||
|
||||
if (resources.length === 0) {
|
||||
return (
|
||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||
No resources found in the OpenAPI spec with x-resource defined.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
|
||||
<Layout resources={resources} basePath={basePath}>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} />
|
||||
{resources.map((r) => (
|
||||
<React.Fragment key={r.name}>
|
||||
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
|
||||
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} />
|
||||
<Route path={`${r.name}/:id`} element={<ResourceDetail resource={r} basePath={basePath} />} />
|
||||
<Route path={`${r.name}/:id/edit`} element={<ResourceForm resource={r} basePath={basePath} mode="edit" />} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Routes>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
react-openapi/src/components/Layout.tsx
Normal file
42
react-openapi/src/components/Layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Box, Toolbar, IconButton, Typography } from "@mui/material";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
import type { ResourceConfig } from "../types";
|
||||
|
||||
interface LayoutProps {
|
||||
resources: ResourceConfig[];
|
||||
basePath: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Layout({ resources, basePath, children }: LayoutProps) {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "calc(100vh - 128px)" }}>
|
||||
<SideMenu
|
||||
resources={resources}
|
||||
basePath={basePath}
|
||||
mobileOpen={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
sx={{ mr: 2, display: { md: "none" } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap sx={{ display: { md: "none" } }}>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
97
react-openapi/src/components/ResourceDetail.tsx
Normal file
97
react-openapi/src/components/ResourceDetail.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import type { ResourceConfig } from "../types";
|
||||
import { useResource } from "../context/useResource";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resource: ResourceConfig;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const crud = useResource(resource);
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
setLoading(true);
|
||||
crud
|
||||
.get(id)
|
||||
.then(setData)
|
||||
.catch(() => navigate(`${basePath}/${resource.name}`))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
|
||||
Record not found
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
|
||||
|
||||
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} sx={{ flex: 1 }}>
|
||||
{applyDisplayFormat(data, resource.displayFormat)}
|
||||
</Typography>
|
||||
{resource.operations.update && (
|
||||
<Button variant="contained" startIcon={<EditIcon />} onClick={() => navigate(`${basePath}/${resource.name}/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
{visibleFields.map((field) => {
|
||||
let value = data[field.name];
|
||||
let fmt = resource.displayFormat;
|
||||
if (field.fk && typeof value === "object") {
|
||||
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
|
||||
fmt = targetRes!.displayFormat;
|
||||
} else if (field.refSchema && !field.fk && typeof value === "object") {
|
||||
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
|
||||
}
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} />
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
222
react-openapi/src/components/ResourceList.tsx
Normal file
222
react-openapi/src/components/ResourceList.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Paper,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
TableSortLabel,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import type { ResourceConfig, FieldConfig } from "../types";
|
||||
import { useResource } from "../context/useResource";
|
||||
import { useAppContext } from "../context/AppContext";
|
||||
import { ListCellRenderer, applyDisplayFormat } from "./fields";
|
||||
|
||||
interface ResourceListProps {
|
||||
resource: ResourceConfig;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export function ResourceList({ resource, basePath }: ResourceListProps) {
|
||||
const navigate = useNavigate();
|
||||
const crud = useResource(resource);
|
||||
const { resources: allResources } = useAppContext();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortField, setSortField] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const visibleColumns = resource.listColumns
|
||||
.map((colName) => resource.fields.find((f) => f.name === colName))
|
||||
.filter((f): f is FieldConfig => !!f && !f.hidden?.list);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const params: Record<string, any> = {};
|
||||
if (resource.pagination) {
|
||||
params[resource.pagination.limitParam] = rowsPerPage;
|
||||
params[resource.pagination.offsetParam] = page * rowsPerPage;
|
||||
}
|
||||
if (sortField) {
|
||||
params.sort = sortDir === "desc" ? `-${sortField}` : sortField;
|
||||
}
|
||||
const result = await crud.list(params);
|
||||
setData(result.items ?? []);
|
||||
setTotal(result.total ?? result.items?.length ?? 0);
|
||||
}, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleDelete = async (id: string | number) => {
|
||||
if (!window.confirm("Are you sure you want to delete this item?")) return;
|
||||
await crud.remove(id);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir("asc");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700}>
|
||||
{resource.schemaName}
|
||||
</Typography>
|
||||
{resource.operations.create && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "center" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ minWidth: 280 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{visibleColumns.map((col) => (
|
||||
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
|
||||
{col.sortable ? (
|
||||
<TableSortLabel
|
||||
active={sortField === col.name}
|
||||
direction={sortField === col.name ? sortDir : "asc"}
|
||||
onClick={() => handleSort(col.name)}
|
||||
>
|
||||
{col.label}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
col.label
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length + 1} align="center">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
|
||||
No records found
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row) => {
|
||||
const rowId = row[resource.primaryKey];
|
||||
return (
|
||||
<TableRow
|
||||
key={rowId}
|
||||
hover
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}
|
||||
>
|
||||
{visibleColumns.map((col) => {
|
||||
let value = row[col.name];
|
||||
let fmt = resource.displayFormat;
|
||||
if (col.fk) {
|
||||
const targetRes = allResources.find((r) => r.name === col.fk!.resource);
|
||||
fmt = targetRes!.displayFormat;
|
||||
} else if (col.refSchema && !col.fk && col.inlineDisplayFormat) {
|
||||
fmt = col.inlineDisplayFormat;
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.name}>
|
||||
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||
{resource.operations.get && (
|
||||
<Tooltip title="View">
|
||||
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{resource.operations.update && (
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{resource.operations.delete && (
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{resource.pagination && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setRowsPerPage(parseInt(e.target.value, 10));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
110
react-openapi/src/components/SideMenu.tsx
Normal file
110
react-openapi/src/components/SideMenu.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Box,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import type { ResourceConfig } from "../types";
|
||||
|
||||
interface SideMenuProps {
|
||||
resources: ResourceConfig[];
|
||||
basePath: string;
|
||||
mobileOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const drawerWidth = 260;
|
||||
|
||||
export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const colors = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
|
||||
];
|
||||
|
||||
const content = (
|
||||
<Box>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" fontWeight={700} noWrap>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<List sx={{ px: 1 }}>
|
||||
{resources.map((r, i) => {
|
||||
const listPath = `${basePath}/${r.name}`;
|
||||
const active = location.pathname.startsWith(listPath);
|
||||
return (
|
||||
<ListItemButton
|
||||
key={r.name}
|
||||
selected={active}
|
||||
onClick={() => {
|
||||
navigate(listPath);
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
"&.Mui-selected": {
|
||||
bgcolor: `${colors[i % colors.length]}15`,
|
||||
"&:hover": { bgcolor: `${colors[i % colors.length]}20` },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={r.schemaName}
|
||||
primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={onClose}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
"& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
"& .MuiDrawer-paper": { width: drawerWidth, boxSizing: "border-box" },
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export { drawerWidth };
|
||||
73
react-openapi/src/components/ValidationAlert.tsx
Normal file
73
react-openapi/src/components/ValidationAlert.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Alert, Snackbar, List, ListItem, ListItemIcon, ListItemText } from "@mui/material";
|
||||
import ErrorIcon from "@mui/icons-material/Error";
|
||||
import WarningIcon from "@mui/icons-material/Warning";
|
||||
import type { ValidationMessage } from "../types";
|
||||
|
||||
interface ValidationAlertProps {
|
||||
errors: ValidationMessage[];
|
||||
warnings: ValidationMessage[];
|
||||
}
|
||||
|
||||
export function ValidationAlert({ errors, warnings }: ValidationAlertProps) {
|
||||
const [warningOpen, setWarningOpen] = React.useState(warnings.length > 0);
|
||||
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<Box sx={{ p: 4, maxWidth: 700, mx: "auto", mt: 8 }}>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
OpenAPI Spec Validation Failed
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
The spec has {errors.length} error{errors.length > 1 ? "s" : ""}. Fix them before the admin panel can render.
|
||||
</Typography>
|
||||
</Alert>
|
||||
<List dense>
|
||||
{errors.map((e, i) => (
|
||||
<ListItem key={i}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<ErrorIcon color="error" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={e.message} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{warnings.length > 0 && (
|
||||
<>
|
||||
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, color: "text.secondary" }}>
|
||||
Warnings ({warnings.length})
|
||||
</Typography>
|
||||
<List dense>
|
||||
{warnings.map((w, i) => (
|
||||
<ListItem key={i}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<WarningIcon color="warning" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={w.message} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open={warningOpen}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setWarningOpen(false)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Box>
|
||||
{warnings.map((w, i) => (
|
||||
<Alert key={i} severity="warning" sx={{ mb: 1 }} onClose={() => setWarningOpen(false)}>
|
||||
{w.message}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
</Snackbar>
|
||||
);
|
||||
}
|
||||
23
react-openapi/src/components/fields/DetailFieldRenderer.tsx
Normal file
23
react-openapi/src/components/fields/DetailFieldRenderer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { ListCellRenderer } from "./ListCellRenderer";
|
||||
|
||||
interface DetailFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) {
|
||||
if (field.hidden?.detail) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
127
react-openapi/src/components/fields/FormFieldRenderer.tsx
Normal file
127
react-openapi/src/components/fields/FormFieldRenderer.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { StringField } from "./renderers/StringField";
|
||||
import { NumberField } from "./renderers/NumberField";
|
||||
import { DateField } from "./renderers/DateField";
|
||||
import { BooleanField } from "./renderers/BooleanField";
|
||||
import { EnumField } from "./renderers/EnumField";
|
||||
import { FkSelectField } from "./renderers/FkSelectField";
|
||||
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
|
||||
import { ImageField } from "./renderers/ImageField";
|
||||
import { JsonField } from "./renderers/JsonField";
|
||||
|
||||
interface FormFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
fkLoading?: boolean;
|
||||
recordId?: string | number;
|
||||
onFkOpen?: (fieldName: string) => void;
|
||||
}
|
||||
|
||||
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
|
||||
if (field.hidden?.form) return null;
|
||||
|
||||
if (field.readOnly && field.uiType !== "image") {
|
||||
return (
|
||||
<StringField field={field} value={value} onChange={onChange} error={error} />
|
||||
);
|
||||
}
|
||||
|
||||
if (field.uiType === "image") {
|
||||
return (
|
||||
<ImageField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
id={recordId}
|
||||
uploadUrl={field.uploadUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk) {
|
||||
console.log(`[FormFieldRenderer] FK field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} isArray=${field.isArray}`);
|
||||
if (field.isArray) {
|
||||
return (
|
||||
<FkMultiSelectField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fkOptions={fkOptions}
|
||||
fkLoading={fkLoading}
|
||||
onOpen={() => onFkOpen?.(field.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FkSelectField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
fkOptions={fkOptions}
|
||||
onOpen={() => onFkOpen?.(field.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
return (
|
||||
<EnumField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return <BooleanField field={field} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (field.type === "integer" || field.type === "number") {
|
||||
return (
|
||||
<NumberField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.format === "date" || field.format === "date-time") {
|
||||
return (
|
||||
<DateField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.refSchema && !field.fk) {
|
||||
return (
|
||||
<JsonField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StringField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
react-openapi/src/components/fields/ListCellRenderer.tsx
Normal file
66
react-openapi/src/components/fields/ListCellRenderer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Chip, Avatar } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { applyDisplayFormat } from "./utils";
|
||||
import { InlineRefField } from "./renderers/InlineRefField";
|
||||
|
||||
interface ListCellProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) {
|
||||
if (value === null || value === undefined) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
|
||||
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />;
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
|
||||
if (value.length === 0) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{value.map((item: any, i: number) => {
|
||||
const label = typeof item === "object"
|
||||
? applyDisplayFormat(item, displayFormat ?? "")
|
||||
: String(item);
|
||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk && typeof value === "object" && !field.isArray) {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value) && field.fk) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{value.map((item: any, i: number) => {
|
||||
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
|
||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
return <Chip label={value} size="small" />;
|
||||
}
|
||||
|
||||
if (field.uiType === "image" && value) {
|
||||
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return <Chip label={value ? "Yes" : "No"} size="small" color={value ? "success" : "default"} />;
|
||||
}
|
||||
|
||||
return <Typography variant="body2">{String(value)}</Typography>;
|
||||
}
|
||||
5
react-openapi/src/components/fields/index.ts
Normal file
5
react-openapi/src/components/fields/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { FormFieldRenderer } from "./FormFieldRenderer";
|
||||
export { ListCellRenderer } from "./ListCellRenderer";
|
||||
export { DetailFieldRenderer } from "./DetailFieldRenderer";
|
||||
export { applyDisplayFormat } from "./utils";
|
||||
export { JsonField } from "./renderers/JsonField";
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { FormControl, FormControlLabel, Switch, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
|
||||
export function BooleanField({ field, value, onChange }: Props) {
|
||||
return (
|
||||
<FormControl component="fieldset" fullWidth size="small">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
}
|
||||
label={field.label}
|
||||
/>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
30
react-openapi/src/components/fields/renderers/DateField.tsx
Normal file
30
react-openapi/src/components/fields/renderers/DateField.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DateField({ field, value, onChange, error }: Props) {
|
||||
const inputType = field.format === "date" ? "date" : "datetime-local";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
react-openapi/src/components/fields/renderers/EnumField.tsx
Normal file
34
react-openapi/src/components/fields/renderers/EnumField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function EnumField({ field, value, onChange, error }: Props) {
|
||||
return (
|
||||
<FormControl fullWidth size="small" error={!!error}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{(field.enumValues ?? []).map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { TextField, Autocomplete } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
fkLoading?: boolean;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) {
|
||||
console.log(`[FkMultiSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} value=${JSON.stringify(value)}`);
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={fkOptions ?? []}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
|
||||
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
|
||||
onOpen={() => onOpen?.()}
|
||||
loading={fkLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={field.label} helperText={field.description} size="small" />
|
||||
)}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function FkSelectField({ field, value, onChange, error, fkOptions, onOpen }: Props) {
|
||||
console.log(`[FkSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} value=${value}`);
|
||||
return (
|
||||
<FormControl fullWidth size="small" error={!!error}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onOpen={() => onOpen?.()}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{(fkOptions ?? []).map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
60
react-openapi/src/components/fields/renderers/ImageField.tsx
Normal file
60
react-openapi/src/components/fields/renderers/ImageField.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { getApi } from "../../../hooks/useApi";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
id?: string | number;
|
||||
uploadUrl?: string;
|
||||
}
|
||||
|
||||
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!id || !uploadUrl) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const api = getApi();
|
||||
const url = uploadUrl.replace("{id}", String(id));
|
||||
const res = await api.post(url, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
onChange(res.data.url ?? res.data);
|
||||
} catch {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
{value ? (
|
||||
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||
) : (
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
Upload {field.label}
|
||||
<input type="file" hidden accept="image/*" onChange={handleUpload} />
|
||||
</Button>
|
||||
)}
|
||||
<FormHelperText>{field.description}</FormHelperText>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Chip } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { applyDisplayFormat } from "../utils";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function InlineRefField({ field, value, displayFormat }: Props) {
|
||||
if (!value || typeof value !== "object") {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
if (displayFormat) {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{entries.map(([key, v]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${String(v)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
270
react-openapi/src/components/fields/renderers/JsonField.tsx
Normal file
270
react-openapi/src/components/fields/renderers/JsonField.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Box,
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { useAppContext } from "../../../context/AppContext";
|
||||
import { extractFields } from "../../../transformers/field-config";
|
||||
import { FormFieldRenderer } from "../FormFieldRenderer";
|
||||
|
||||
interface JsonFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
|
||||
export function JsonField({ field, value, onChange }: JsonFieldProps) {
|
||||
const { schemas } = useAppContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const refSchema = field.refSchema ? schemas[field.refSchema] : null;
|
||||
const subFields = refSchema
|
||||
? extractFields(field.refSchema!, refSchema, schemas)
|
||||
: [];
|
||||
|
||||
const [editValue, setEditValue] = useState<any>(null);
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditValue(initEditValue(value, field, schemas));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(editValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
setEditValue((prev: any[]) => [...(prev || []), buildDefaultShape(subFields, schemas)]);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
setEditValue((prev: any[]) => prev.filter((_: any, i: number) => i !== index));
|
||||
};
|
||||
|
||||
const handleItemFieldChange = (index: number, fieldName: string, val: any) => {
|
||||
setEditValue((prev: any[]) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [fieldName]: val };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, val: any) => {
|
||||
setEditValue((prev: any) => ({ ...prev, [fieldName]: val }));
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
||||
Set {field.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return (
|
||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
||||
Set {field.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={`${value.length} item${value.length !== 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
onDelete={handleClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const summary = field.inlineDisplayFormat
|
||||
? applyInlineFormat(value, field.inlineDisplayFormat)
|
||||
: Object.entries(value)
|
||||
.filter(([, v]) => v != null)
|
||||
.map(([k, v]) => `${k}: ${String(v)}`)
|
||||
.join(" | ");
|
||||
return (
|
||||
<Chip
|
||||
label={summary || field.label}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
onDelete={handleClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fullScreen open={open} onClose={handleCancel}>
|
||||
<DialogTitle>{field.label}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{field.isArray ? (
|
||||
<ArrayEditor
|
||||
items={editValue ?? []}
|
||||
subFields={subFields}
|
||||
onAddItem={handleAddItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onFieldChange={handleItemFieldChange}
|
||||
schemas={schemas}
|
||||
/>
|
||||
) : (
|
||||
<ObjectEditor
|
||||
value={editValue}
|
||||
subFields={subFields}
|
||||
onFieldChange={handleFieldChange}
|
||||
schemas={schemas}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClear} color="error">
|
||||
Clear
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectEditor({
|
||||
value,
|
||||
subFields,
|
||||
onFieldChange,
|
||||
}: {
|
||||
value: any;
|
||||
subFields: FieldConfig[];
|
||||
onFieldChange: (name: string, val: any) => void;
|
||||
schemas: Record<string, any>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{subFields.map((subField) => (
|
||||
<Box key={subField.name} sx={{ mb: 2 }}>
|
||||
<FormFieldRenderer
|
||||
field={subField}
|
||||
value={value?.[subField.name]}
|
||||
onChange={(val) => onFieldChange(subField.name, val)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayEditor({
|
||||
items,
|
||||
subFields,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
onFieldChange,
|
||||
schemas,
|
||||
}: {
|
||||
items: any[];
|
||||
subFields: FieldConfig[];
|
||||
onAddItem: () => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
onFieldChange: (index: number, name: string, val: any) => void;
|
||||
schemas: Record<string, any>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{items.length === 0 && (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
|
||||
No items added yet.
|
||||
</Typography>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
Item {index + 1}
|
||||
</Typography>
|
||||
<IconButton size="small" color="error" onClick={() => onRemoveItem(index)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ pl: 2 }}>
|
||||
{subFields.map((subField) => (
|
||||
<Box key={subField.name} sx={{ mb: 2 }}>
|
||||
<FormFieldRenderer
|
||||
field={subField}
|
||||
value={item?.[subField.name]}
|
||||
onChange={(val) => onFieldChange(index, subField.name, val)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
))}
|
||||
<Button startIcon={<AddIcon />} onClick={onAddItem} variant="outlined" size="small">
|
||||
Add Item
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultShape(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 ? [] : buildDefaultShape(nestedFields, schemas);
|
||||
} else {
|
||||
shape[f.name] = null;
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
function initEditValue(value: any, field: FieldConfig, schemas: Record<string, any>): any {
|
||||
if (field.isArray) {
|
||||
return value ? value.map((item: any) => ({ ...item })) : [];
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return { ...value };
|
||||
}
|
||||
return buildDefaultShape(
|
||||
field.refSchema ? extractFields(field.refSchema, schemas[field.refSchema], schemas) : [],
|
||||
schemas
|
||||
);
|
||||
}
|
||||
|
||||
function applyInlineFormat(obj: any, format: string): string {
|
||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function NumberField({ field, value, onChange, error }: Props) {
|
||||
const isFloat = field.type === "number" || field.format === "float";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
onChange("");
|
||||
} else {
|
||||
onChange(isFloat ? parseFloat(raw) : parseInt(raw, 10));
|
||||
}
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
inputProps={isFloat ? { step: "any" } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function StringField({ field, value, onChange, error }: Props) {
|
||||
const inputType = field.format === "email" ? "email" : "text";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
react-openapi/src/components/fields/utils.ts
Normal file
4
react-openapi/src/components/fields/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function applyDisplayFormat(item: any, format: string): string {
|
||||
if (!item || typeof item !== "object") return String(item ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(item[key] ?? ""));
|
||||
}
|
||||
Reference in New Issue
Block a user