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] ?? ""));
|
||||
}
|
||||
21
react-openapi/src/context/AppContext.tsx
Normal file
21
react-openapi/src/context/AppContext.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { ResourceConfig, SpecConfiguration, ValidationMessage } from "../types";
|
||||
|
||||
export interface AppContextValue {
|
||||
config: SpecConfiguration;
|
||||
resources: ResourceConfig[];
|
||||
schemas: Record<string, any>;
|
||||
loading: boolean;
|
||||
errors: ValidationMessage[];
|
||||
warnings: ValidationMessage[];
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextValue | null>(null);
|
||||
|
||||
export function useAppContext(): AppContextValue {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAppContext must be used within an AppProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
83
react-openapi/src/context/AppProvider.tsx
Normal file
83
react-openapi/src/context/AppProvider.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
|
||||
import { AppContext } from "./AppContext";
|
||||
import { loadSpec } from "../spec-loader";
|
||||
import { validateSpec } from "../spec-validator";
|
||||
import { buildResourceConfigs } from "../transformers/resource-config";
|
||||
import { initApi } from "../hooks/useApi";
|
||||
|
||||
interface AppProviderProps {
|
||||
specConfiguration: SpecConfiguration;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppProvider({ specConfiguration, children }: AppProviderProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resources, setResources] = useState<ResourceConfig[]>([]);
|
||||
const [schemas, setSchemas] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<ValidationMessage[]>([]);
|
||||
const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const spec = await loadSpec(specConfiguration.specUrl);
|
||||
|
||||
const allMessages = validateSpec(spec);
|
||||
|
||||
const errs = allMessages.filter((m) => m.type === "error");
|
||||
const warns = allMessages.filter((m) => m.type === "warning");
|
||||
|
||||
if (!cancelled) {
|
||||
setErrors(errs);
|
||||
setWarnings(warns);
|
||||
setSchemas(spec.components?.schemas ?? {});
|
||||
}
|
||||
|
||||
if (errs.length === 0) {
|
||||
const configs = buildResourceConfigs(spec);
|
||||
if (!cancelled) {
|
||||
setResources(configs);
|
||||
}
|
||||
|
||||
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";
|
||||
if (baseUrl) {
|
||||
initApi(baseUrl, specConfiguration.getToken);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) {
|
||||
setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [specConfiguration.specUrl]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
config: specConfiguration,
|
||||
resources,
|
||||
schemas,
|
||||
loading,
|
||||
errors,
|
||||
warnings,
|
||||
}),
|
||||
[specConfiguration, resources, schemas, loading, errors, warnings]
|
||||
);
|
||||
|
||||
return React.createElement(AppContext.Provider, { value }, children);
|
||||
}
|
||||
147
react-openapi/src/context/useResource.ts
Normal file
147
react-openapi/src/context/useResource.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { ResourceConfig, ParsedListResponse } from "../types";
|
||||
import { getApi } from "../hooks/useApi";
|
||||
|
||||
function parseError(e: any): string {
|
||||
if (e.response?.data) {
|
||||
const data = e.response.data;
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((err: any) => err.msg ?? String(err)).join("; ");
|
||||
}
|
||||
if (typeof data.detail === "string") {
|
||||
return data.detail;
|
||||
}
|
||||
}
|
||||
return e.message ?? "An error occurred";
|
||||
}
|
||||
|
||||
interface ResourceState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface UseResourceReturn {
|
||||
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
|
||||
get: (id: string | number) => Promise<any>;
|
||||
create: (data: any) => Promise<any>;
|
||||
update: (id: string | number, data: any) => Promise<any>;
|
||||
remove: (id: string | number) => Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useResource(resource: ResourceConfig): UseResourceReturn {
|
||||
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
|
||||
|
||||
const setLoading = useCallback((loading: boolean) => {
|
||||
setState((s) => ({ ...s, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState((s) => ({ ...s, error }));
|
||||
}, []);
|
||||
|
||||
const list = useCallback(
|
||||
async (params?: Record<string, any>): Promise<ParsedListResponse> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(resource.path, { params });
|
||||
const data = res.data;
|
||||
|
||||
if (resource.pagination) {
|
||||
if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
|
||||
throw new Error(`Expected paginated response { total, items } from ${resource.path}`);
|
||||
}
|
||||
return { items: data.items, total: data.total ?? data.items.length };
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Expected array response from ${resource.path}`);
|
||||
}
|
||||
return { items: data };
|
||||
} catch (e: any) {
|
||||
const msg = parseError(e);
|
||||
setError(msg);
|
||||
return { items: [] };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, resource.pagination, setLoading, setError]
|
||||
);
|
||||
|
||||
const get = useCallback(
|
||||
async (id: string | number): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.get(`${resource.path}/${id}`);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const create = useCallback(
|
||||
async (data: any): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.post(resource.path, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string | number, data: any): Promise<any> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
const res = await api.put(`${resource.path}/${id}`, data);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string | number): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getApi();
|
||||
await api.delete(`${resource.path}/${id}`);
|
||||
} catch (e: any) {
|
||||
setError(parseError(e));
|
||||
throw e;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[resource.path, setLoading, setError]
|
||||
);
|
||||
|
||||
return { list, get, create, update, remove, loading: state.loading, error: state.error };
|
||||
}
|
||||
45
react-openapi/src/hooks/useApi.ts
Normal file
45
react-openapi/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
|
||||
let apiClient: AxiosInstance | null = null;
|
||||
|
||||
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance {
|
||||
if (apiClient && apiClient.defaults.baseURL === baseUrl) {
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
apiClient = axios.create({
|
||||
baseURL: baseUrl,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken?.();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && getToken) {
|
||||
const currentToken = getToken();
|
||||
if (currentToken) {
|
||||
const tokenStore = { clear: () => localStorage.removeItem("token") };
|
||||
tokenStore.clear();
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
export function getApi(): AxiosInstance {
|
||||
if (!apiClient) {
|
||||
throw new Error("API client not initialized. Make sure AppProvider is mounted.");
|
||||
}
|
||||
return apiClient;
|
||||
}
|
||||
17
react-openapi/src/spec-loader.ts
Normal file
17
react-openapi/src/spec-loader.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as yaml from "js-yaml";
|
||||
import type { OpenApiSpec } from "./types";
|
||||
|
||||
export async function loadSpec(url: string): Promise<OpenApiSpec> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch spec: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
|
||||
const parsed = yaml.load(text);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Spec is empty or not an object");
|
||||
}
|
||||
|
||||
return parsed as OpenApiSpec;
|
||||
}
|
||||
129
react-openapi/src/spec-validator.ts
Normal file
129
react-openapi/src/spec-validator.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { OpenApiSpec, ValidationMessage } from "./types";
|
||||
|
||||
export function validateSpec(spec: OpenApiSpec): ValidationMessage[] {
|
||||
const messages: ValidationMessage[] = [];
|
||||
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
|
||||
const paths = spec.paths ?? {};
|
||||
|
||||
if (!spec.openapi) {
|
||||
messages.push({ type: "error", message: "Missing 'openapi' version field" });
|
||||
}
|
||||
|
||||
if (!spec.info?.title) {
|
||||
messages.push({ type: "error", message: "Missing 'info.title'" });
|
||||
}
|
||||
|
||||
if (!spec.servers?.[0]?.url) {
|
||||
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
|
||||
}
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (!schema || typeof schema !== "object") continue;
|
||||
|
||||
const isResource = typeof schema["x-resource"] === "string";
|
||||
|
||||
if (!isResource) continue;
|
||||
|
||||
const resourcePath = `/${schema["x-resource"]}`;
|
||||
|
||||
if (!schema["x-primary-key"]) {
|
||||
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
|
||||
}
|
||||
|
||||
if (!schema["x-display-format"]) {
|
||||
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` });
|
||||
}
|
||||
|
||||
if (!schema["x-list-columns"]) {
|
||||
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-list-columns'` });
|
||||
}
|
||||
|
||||
if (Array.isArray(schema["x-list-columns"])) {
|
||||
const props = schema.properties ?? {};
|
||||
for (const col of schema["x-list-columns"]) {
|
||||
if (!props[col]) {
|
||||
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = schema.properties ?? {};
|
||||
for (const [propName, _raw] of Object.entries(props)) {
|
||||
const prop = _raw as any;
|
||||
if (!prop || typeof prop !== "object") continue;
|
||||
if (!prop["x-label"]) {
|
||||
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
|
||||
}
|
||||
if (prop["x-order"] === undefined || prop["x-order"] === null) {
|
||||
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
|
||||
}
|
||||
|
||||
if (prop["$ref"] && !prop["x-fk"]) {
|
||||
const refName = (prop["$ref"] as string).split("/").pop();
|
||||
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
|
||||
}
|
||||
|
||||
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
|
||||
const refName = (prop.items.$ref as string).split("/").pop();
|
||||
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
|
||||
}
|
||||
|
||||
if (prop["x-fk"]) {
|
||||
const fkResource = prop["x-fk"].resource as string;
|
||||
const targetSchema = Object.entries(schemas as Record<string, any>).find(([, s]) => s?.["x-resource"] === fkResource);
|
||||
if (!targetSchema) {
|
||||
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` });
|
||||
} else {
|
||||
const [, target] = targetSchema;
|
||||
if (!target["x-display-format"]) {
|
||||
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` });
|
||||
}
|
||||
if (!target["x-primary-key"]) {
|
||||
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!paths[resourcePath]) {
|
||||
messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectionPath = paths[resourcePath] as any;
|
||||
|
||||
if (!collectionPath?.get) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
|
||||
}
|
||||
|
||||
const listParams = collectionPath?.get?.parameters ?? [];
|
||||
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
|
||||
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
|
||||
if (limitParam || offsetParam) {
|
||||
if (!limitParam?.schema?.default) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` });
|
||||
}
|
||||
}
|
||||
|
||||
if (!collectionPath?.post) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` });
|
||||
}
|
||||
|
||||
const itemPath = paths[`${resourcePath}/{id}`] as any;
|
||||
if (!itemPath) {
|
||||
messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` });
|
||||
} else {
|
||||
if (!itemPath?.get) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` });
|
||||
}
|
||||
if (!itemPath?.put) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no PUT endpoint — update not possible` });
|
||||
}
|
||||
if (!itemPath?.delete) {
|
||||
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not possible` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
53
react-openapi/src/transformers/field-config.ts
Normal file
53
react-openapi/src/transformers/field-config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { FieldConfig } from "../types";
|
||||
|
||||
function resolveRef(ref: string): string | undefined {
|
||||
return ref.split("/").pop();
|
||||
}
|
||||
|
||||
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
|
||||
const props = schema.properties ?? {};
|
||||
const requiredFields: string[] = schema.required ?? [];
|
||||
|
||||
return Object.entries(props)
|
||||
.filter(([, prop]: [string, any]) => prop && typeof prop === "object")
|
||||
.map(([name, prop]: [string, any]) => {
|
||||
const isDirectRef = !!prop.$ref;
|
||||
const isItemsRef = prop.type === "array" && !!prop.items?.$ref;
|
||||
const isRef = isDirectRef || isItemsRef;
|
||||
|
||||
const refSchemaName = isDirectRef
|
||||
? resolveRef(prop.$ref)
|
||||
: isItemsRef
|
||||
? resolveRef(prop.items.$ref)
|
||||
: undefined;
|
||||
|
||||
const refSchema = refSchemaName ? schemas[refSchemaName] : undefined;
|
||||
|
||||
const inlineDisplayFormat = isRef && refSchema && !prop["x-fk"]
|
||||
? refSchema["x-display-format"]
|
||||
: undefined;
|
||||
|
||||
const field: FieldConfig = {
|
||||
name,
|
||||
label: prop["x-label"],
|
||||
description: prop["x-description"] ?? prop["x-label"] ?? name,
|
||||
type: isRef && refSchema ? "object" : (prop.type ?? "string"),
|
||||
format: prop.format,
|
||||
order: prop["x-order"],
|
||||
hidden: prop["x-hidden"] ?? {},
|
||||
filterable: prop["x-filterable"] ?? false,
|
||||
sortable: prop["x-sortable"] ?? false,
|
||||
readOnly: prop.readOnly ?? false,
|
||||
required: requiredFields.includes(name),
|
||||
enumValues: prop.enum,
|
||||
fk: prop["x-fk"],
|
||||
uiType: prop["x-ui-type"],
|
||||
uploadUrl: prop["x-upload-url"],
|
||||
refSchema: refSchemaName,
|
||||
inlineDisplayFormat,
|
||||
isArray: prop.type === "array",
|
||||
};
|
||||
|
||||
return field;
|
||||
});
|
||||
}
|
||||
32
react-openapi/src/transformers/relationship-config.ts
Normal file
32
react-openapi/src/transformers/relationship-config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { FKFieldConfig, ResourceRelationship } from "../types";
|
||||
|
||||
export function extractRelationships(schema: any, schemas: Record<string, any>): ResourceRelationship[] {
|
||||
const props = schema.properties ?? {};
|
||||
const rels: ResourceRelationship[] = [];
|
||||
|
||||
for (const [name, _raw] of Object.entries(props)) {
|
||||
const prop = _raw as any;
|
||||
if (!prop || typeof prop !== "object") continue;
|
||||
|
||||
if (!prop["x-fk"]) continue;
|
||||
|
||||
const fkResource = prop["x-fk"].resource as string;
|
||||
const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource);
|
||||
const targetSchemaName = targetEntry ? targetEntry[0] : fkResource;
|
||||
|
||||
const prefetch = prop["x-fk"].prefetch ?? false;
|
||||
console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`);
|
||||
|
||||
rels.push({
|
||||
fieldName: name,
|
||||
config: {
|
||||
resource: fkResource,
|
||||
prefetch,
|
||||
},
|
||||
targetSchemaName,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[FK] total relationships extracted: ${rels.length}`);
|
||||
return rels;
|
||||
}
|
||||
74
react-openapi/src/transformers/resource-config.ts
Normal file
74
react-openapi/src/transformers/resource-config.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { OpenApiSpec, ResourceConfig, FieldConfig, ResourceRelationship } from "../types";
|
||||
import { extractFields } from "./field-config";
|
||||
import { extractRelationships } from "./relationship-config";
|
||||
|
||||
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
|
||||
const params = pathObj?.get?.parameters ?? [];
|
||||
const limit = params.find((p: any) => p.in === "query" && p.name === "limit");
|
||||
const offset = params.find((p: any) => p.in === "query" && p.name === "offset");
|
||||
if (limit && offset) {
|
||||
return {
|
||||
limitParam: "limit",
|
||||
offsetParam: "offset",
|
||||
defaultLimit: limit.schema.default,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasOperation(pathObj: any, method: string): boolean {
|
||||
return !!pathObj?.[method];
|
||||
}
|
||||
|
||||
function sortFields(fields: FieldConfig[]): FieldConfig[] {
|
||||
return [...fields].sort((a, b) => {
|
||||
const orderDiff = a.order - b.order;
|
||||
if (orderDiff !== 0) return orderDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||
const schemas = spec.components?.schemas ?? {};
|
||||
const paths = spec.paths ?? {};
|
||||
const configs: ResourceConfig[] = [];
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
if (!schema || typeof schema !== "object") continue;
|
||||
|
||||
const resourceName = schema["x-resource"];
|
||||
if (!resourceName || typeof resourceName !== "string") continue;
|
||||
|
||||
const resourcePath = `/${resourceName}`;
|
||||
const itemPath = `${resourcePath}/{id}`;
|
||||
const collectionPathObj = paths[resourcePath];
|
||||
const itemPathObj = paths[itemPath];
|
||||
|
||||
const fields = extractFields(schemaName, schema, schemas);
|
||||
const relationships = extractRelationships(schema, schemas);
|
||||
|
||||
const resource: ResourceConfig = {
|
||||
name: resourceName,
|
||||
schemaName,
|
||||
path: resourcePath,
|
||||
primaryKey: schema["x-primary-key"],
|
||||
displayFormat: schema["x-display-format"],
|
||||
listColumns: schema["x-list-columns"],
|
||||
fields,
|
||||
orderedFields: sortFields(fields),
|
||||
operations: {
|
||||
list: hasOperation(collectionPathObj, "get"),
|
||||
get: hasOperation(itemPathObj, "get"),
|
||||
create: hasOperation(collectionPathObj, "post"),
|
||||
update: hasOperation(itemPathObj, "put"),
|
||||
delete: hasOperation(itemPathObj, "delete"),
|
||||
},
|
||||
pagination: detectPagination(collectionPathObj),
|
||||
relationships,
|
||||
};
|
||||
|
||||
configs.push(resource);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
85
react-openapi/src/types.ts
Normal file
85
react-openapi/src/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export interface SpecConfiguration {
|
||||
specUrl: string;
|
||||
baseApiUrl?: string;
|
||||
title?: string;
|
||||
getToken?: () => string | null;
|
||||
}
|
||||
|
||||
export interface ValidationMessage {
|
||||
type: "error" | "warning" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ResourceRelationship {
|
||||
fieldName: string;
|
||||
config: FKFieldConfig;
|
||||
targetSchemaName: string;
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
schemaName: string;
|
||||
path: string;
|
||||
primaryKey: string;
|
||||
displayFormat: string;
|
||||
listColumns: string[];
|
||||
fields: FieldConfig[];
|
||||
orderedFields: FieldConfig[];
|
||||
operations: {
|
||||
list: boolean;
|
||||
get: boolean;
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
pagination: {
|
||||
limitParam: string;
|
||||
offsetParam: string;
|
||||
defaultLimit: number;
|
||||
} | null;
|
||||
relationships: ResourceRelationship[];
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: string;
|
||||
format?: string;
|
||||
order: number;
|
||||
hidden: { form?: boolean; list?: boolean; detail?: boolean };
|
||||
filterable: boolean;
|
||||
sortable: boolean;
|
||||
readOnly: boolean;
|
||||
required: boolean;
|
||||
enumValues?: string[];
|
||||
fk?: FKFieldConfig;
|
||||
uiType?: string;
|
||||
uploadUrl?: string;
|
||||
refSchema?: string;
|
||||
inlineDisplayFormat?: string;
|
||||
isArray: boolean;
|
||||
}
|
||||
|
||||
export interface FKFieldConfig {
|
||||
resource: string;
|
||||
prefetch: boolean;
|
||||
}
|
||||
|
||||
export interface OpenApiSpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
version: string;
|
||||
};
|
||||
servers?: { url: string }[];
|
||||
components?: {
|
||||
schemas?: Record<string, any>;
|
||||
};
|
||||
paths?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ParsedListResponse {
|
||||
total?: number;
|
||||
items?: any[];
|
||||
}
|
||||
Reference in New Issue
Block a user