new react-openapi
This commit is contained in:
@@ -30,18 +30,20 @@ export function Admin({ basePath }: AdminProps) {
|
|||||||
if (resources.length === 0) {
|
if (resources.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 4, textAlign: "center" }}>
|
<Box sx={{ p: 4, textAlign: "center" }}>
|
||||||
No resources found in the OpenAPI spec with x-resource defined.
|
No resources found in the OpenAPI spec.
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const topLevel = resources.filter((r) => !r.parent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
|
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
|
||||||
<Layout resources={resources} basePath={basePath}>
|
<Layout resources={topLevel} basePath={basePath}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} />
|
<Route index element={<Navigate to={`${basePath}/${topLevel[0].name}`} replace />} />
|
||||||
{resources.map((r) => (
|
{topLevel.map((r) => (
|
||||||
<React.Fragment key={r.name}>
|
<React.Fragment key={r.name}>
|
||||||
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
|
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
|
||||||
{!r.streaming && (
|
{!r.streaming && (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Grid,
|
Grid,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
@@ -14,12 +16,18 @@ import type { ResourceConfig } from "../types";
|
|||||||
import { useResource } from "../context/useResource";
|
import { useResource } from "../context/useResource";
|
||||||
import { useAppContext } from "../context/AppContext";
|
import { useAppContext } from "../context/AppContext";
|
||||||
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
|
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
|
||||||
|
import { SseStreamView } from "./SseStreamView";
|
||||||
|
|
||||||
interface ResourceDetailProps {
|
interface ResourceDetailProps {
|
||||||
resource: ResourceConfig;
|
resource: ResourceConfig;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
|
||||||
|
if (value !== index) return null;
|
||||||
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -27,6 +35,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
|||||||
const { resources: allResources } = useAppContext();
|
const { resources: allResources } = useAppContext();
|
||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tabIndex, setTabIndex] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -57,6 +66,16 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
|||||||
|
|
||||||
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
|
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
|
||||||
|
|
||||||
|
const tabs = [{ label: "Details", key: "details" }];
|
||||||
|
if (resource.subResources) {
|
||||||
|
for (const subName of resource.subResources) {
|
||||||
|
const sub = allResources.find((r) => r.name === subName);
|
||||||
|
if (sub) {
|
||||||
|
tabs.push({ label: sub.displayName, key: subName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
|
||||||
@@ -85,25 +104,47 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
{tabs.length > 1 && (
|
||||||
<Grid container spacing={2}>
|
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)} sx={{ mb: 1 }}>
|
||||||
{visibleFields.map((field) => {
|
{tabs.map((t) => (
|
||||||
let value = data[field.name];
|
<Tab key={t.key} label={t.label} />
|
||||||
let fmt = resource.displayFormat;
|
))}
|
||||||
if (field.fk && typeof value === "object") {
|
</Tabs>
|
||||||
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
|
)}
|
||||||
fmt = targetRes!.displayFormat;
|
|
||||||
} else if (field.refSchema && !field.fk && typeof value === "object") {
|
<TabPanel value={tabIndex} index={0}>
|
||||||
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
|
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||||
}
|
<Grid container spacing={2}>
|
||||||
return (
|
{visibleFields.map((field) => {
|
||||||
<Grid size={12} key={field.name}>
|
let value = data[field.name];
|
||||||
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} />
|
let fmt = resource.displayFormat;
|
||||||
</Grid>
|
if (field.fk && typeof value === "object") {
|
||||||
);
|
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
|
||||||
})}
|
fmt = targetRes!.displayFormat;
|
||||||
</Grid>
|
} else if (field.refSchema && !field.fk && typeof value === "object") {
|
||||||
</Paper>
|
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||||
|
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} basePath={basePath} />
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{tabs.slice(1).map((t, i) => {
|
||||||
|
const sub = allResources.find((r) => r.name === t.key)!;
|
||||||
|
const pathParam = sub.parent?.pathParam ?? "id";
|
||||||
|
return (
|
||||||
|
<TabPanel key={t.key} value={tabIndex} index={i + 1}>
|
||||||
|
{sub.streaming ? (
|
||||||
|
<SseStreamView resource={sub} pathParams={{ [pathParam]: Number(id!) }} />
|
||||||
|
) : null}
|
||||||
|
</TabPanel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const opts = items.map((item: any) => ({
|
const opts = items.map((item: any) => ({
|
||||||
value: resolvePk(item, targetRes.primaryKey),
|
value: item[targetRes.primaryKey],
|
||||||
label: applyFormat(item, targetRes.displayFormat),
|
label: applyFormat(item, targetRes.displayFormat),
|
||||||
}));
|
}));
|
||||||
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
|
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
|
||||||
@@ -139,9 +139,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
|||||||
const targetRes = allResources.find((r) => r.name === rel.config.resource);
|
const targetRes = allResources.find((r) => r.name === rel.config.resource);
|
||||||
if (targetRes) {
|
if (targetRes) {
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
resolved[rel.fieldName] = val.map((item: any) => resolvePk(item, targetRes.primaryKey));
|
resolved[rel.fieldName] = val.map((item: any) => item[targetRes.primaryKey]);
|
||||||
} else if (typeof val === "object") {
|
} else if (typeof val === "object") {
|
||||||
resolved[rel.fieldName] = resolvePk(val, targetRes.primaryKey);
|
resolved[rel.fieldName] = val[targetRes.primaryKey];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!rel.config.prefetch) {
|
if (!rel.config.prefetch) {
|
||||||
@@ -236,8 +236,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{resource.orderedFields
|
{resource.orderedFields
|
||||||
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
|
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
|
||||||
|
.filter((f) => !f.hidden?.form)
|
||||||
.map((field) => (
|
.map((field) => (
|
||||||
<Grid size={12} key={field.name}>
|
<Grid item xs={12} sm={6} md={4} key={field.name}>
|
||||||
<FormFieldRenderer
|
<FormFieldRenderer
|
||||||
field={field}
|
field={field}
|
||||||
value={formData[field.name]}
|
value={formData[field.name]}
|
||||||
@@ -281,11 +282,6 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePk(item: any, pk: string): any {
|
|
||||||
const v = item?.[pk];
|
|
||||||
return v != null ? v : item?.[`_${pk}`];
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFormat(obj: any, format: string): string {
|
function applyFormat(obj: any, format: string): string {
|
||||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TableCell key={col.name}>
|
<TableCell key={col.name}>
|
||||||
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
|
<ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -387,12 +387,13 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
|
|||||||
{detailRow && (
|
{detailRow && (
|
||||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
{visibleColumns.map((col) => (
|
{visibleColumns.map((col) => (
|
||||||
<Grid key={col.name} size={{ xs: 12, sm: 6 }}>
|
<Grid key={col.name} item xs={12} sm={6}>
|
||||||
<DetailFieldRenderer
|
<DetailFieldRenderer
|
||||||
field={col}
|
field={col}
|
||||||
value={detailRow[col.name]}
|
value={detailRow[col.name]}
|
||||||
displayFormat={resource.displayFormat}
|
displayFormat={resource.displayFormat}
|
||||||
/>
|
basePath={basePath}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { SseConnectionStatus } from "./SseConnectionStatus";
|
|||||||
|
|
||||||
interface SseStreamViewProps {
|
interface SseStreamViewProps {
|
||||||
resource: ResourceConfig;
|
resource: ResourceConfig;
|
||||||
|
pathParams?: Record<string, string | number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SseStreamView({ resource }: SseStreamViewProps) {
|
export function SseStreamView({ resource, pathParams }: SseStreamViewProps) {
|
||||||
const { stream } = useResource(resource.name);
|
const { stream } = useResource(resource.name);
|
||||||
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
|
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
|
||||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
@@ -31,7 +32,7 @@ export function SseStreamView({ resource }: SseStreamViewProps) {
|
|||||||
},
|
},
|
||||||
onOpen: () => setSseConnected(resource.name, true),
|
onOpen: () => setSseConnected(resource.name, true),
|
||||||
onError: () => setSseConnected(resource.name, false),
|
onError: () => setSseConnected(resource.name, false),
|
||||||
});
|
}, pathParams);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setSseConnected(resource.name, false);
|
setSseConnected(resource.name, false);
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ interface DetailFieldProps {
|
|||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
value: any;
|
value: any;
|
||||||
displayFormat?: string;
|
displayFormat?: string;
|
||||||
|
basePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) {
|
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
|
||||||
if (field.hidden?.detail) return null;
|
if (field.hidden?.detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -17,7 +18,7 @@ export function DetailFieldRenderer({ field, value, displayFormat }: DetailField
|
|||||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||||
{field.label}
|
{field.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
|
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FkSelectField } from "./renderers/FkSelectField";
|
|||||||
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
|
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
|
||||||
import { ImageField } from "./renderers/ImageField";
|
import { ImageField } from "./renderers/ImageField";
|
||||||
import { JsonField } from "./renderers/JsonField";
|
import { JsonField } from "./renderers/JsonField";
|
||||||
|
import { DiscriminatorField } from "./renderers/DiscriminatorField";
|
||||||
|
|
||||||
interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
@@ -106,6 +107,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field.oneOfOptions) {
|
||||||
|
return (
|
||||||
|
<DiscriminatorField
|
||||||
|
field={field}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.refSchema && !field.fk) {
|
if (field.refSchema && !field.fk) {
|
||||||
return (
|
return (
|
||||||
<JsonField
|
<JsonField
|
||||||
|
|||||||
@@ -1,24 +1,71 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Box, Typography, Chip, Avatar } from "@mui/material";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
|
||||||
import type { FieldConfig } from "../../types";
|
import type { FieldConfig } from "../../types";
|
||||||
import { applyDisplayFormat } from "./utils";
|
import { applyDisplayFormat } from "./utils";
|
||||||
import { InlineRefField } from "./renderers/InlineRefField";
|
import { InlineRefField } from "./renderers/InlineRefField";
|
||||||
|
import { extractFields } from "../../transformers/field-config";
|
||||||
|
import { useAppContext } from "../../context/AppContext";
|
||||||
|
|
||||||
interface ListCellProps {
|
interface ListCellProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
value: any;
|
value: any;
|
||||||
displayFormat?: string;
|
displayFormat?: string;
|
||||||
|
basePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) {
|
export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { schemas } = useAppContext();
|
||||||
|
const [inlineItem, setInlineItem] = useState<any>(null);
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFkClick = (e: React.MouseEvent, fkValue: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!basePath || !field.fk || typeof fkValue !== "object") return;
|
||||||
|
const id = fkValue?.id;
|
||||||
|
if (id != null) {
|
||||||
|
navigate(`${basePath}/${field.fk.resource}/${id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
|
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
|
||||||
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />;
|
return <InlineRefField field={field} value={value} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderInlineItemFields = (itemValue: any) => {
|
||||||
|
const schema = field.refSchema ? schemas[field.refSchema] : undefined;
|
||||||
|
let fields: FieldConfig[] = [];
|
||||||
|
if (field.oneOfOptions && field.discriminatorProperty) {
|
||||||
|
const opt = field.oneOfOptions.find((o) => o.value === itemValue?.[field.discriminatorProperty!]);
|
||||||
|
if (opt) fields = opt.fields;
|
||||||
|
} else if (schema && field.refSchema) {
|
||||||
|
fields = extractFields(field.refSchema, schema, schemas);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
|
{fields.map((sf) => {
|
||||||
|
const fv = itemValue?.[sf.name];
|
||||||
|
return (
|
||||||
|
<Grid key={sf.name} item xs={12} sm={6}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||||
|
{sf.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : String(fv)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
|
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||||
@@ -29,14 +76,40 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
|
|||||||
const label = typeof item === "object"
|
const label = typeof item === "object"
|
||||||
? applyDisplayFormat(item, displayFormat ?? "")
|
? applyDisplayFormat(item, displayFormat ?? "")
|
||||||
: String(item);
|
: String(item);
|
||||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
return (
|
||||||
|
<Chip
|
||||||
|
key={i}
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setInlineItem(item); }}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
|
<Dialog open={!!inlineItem} onClose={() => setInlineItem(null)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{field.label}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{inlineItem && renderInlineItemFields(inlineItem)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setInlineItem(null)}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.fk && typeof value === "object" && !field.isArray) {
|
if (field.fk && typeof value === "object" && !field.isArray) {
|
||||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
|
return (
|
||||||
|
<Chip
|
||||||
|
label={applyDisplayFormat(value, displayFormat ?? "")}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => handleFkClick(e, value)}
|
||||||
|
sx={basePath ? { cursor: "pointer" } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.isArray && Array.isArray(value) && field.fk) {
|
if (field.isArray && Array.isArray(value) && field.fk) {
|
||||||
@@ -44,7 +117,16 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
|
|||||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||||
{value.map((item: any, i: number) => {
|
{value.map((item: any, i: number) => {
|
||||||
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
|
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
|
||||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
return (
|
||||||
|
<Chip
|
||||||
|
key={i}
|
||||||
|
label={label}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => handleFkClick(e, item)}
|
||||||
|
sx={basePath ? { cursor: "pointer" } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,16 +12,20 @@ interface Props {
|
|||||||
export function DateField({ field, value, onChange, error }: Props) {
|
export function DateField({ field, value, onChange, error }: Props) {
|
||||||
const inputType = field.format === "date" ? "date" : "datetime-local";
|
const inputType = field.format === "date" ? "date" : "datetime-local";
|
||||||
|
|
||||||
|
const normalized = field.format === "date-time" && typeof value === "string"
|
||||||
|
? value.replace(/\.\d+Z$/, "").replace(/Z$/, "")
|
||||||
|
: value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label={field.label}
|
label={field.label}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
value={value ?? ""}
|
value={normalized ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
error={!!error}
|
error={!!error}
|
||||||
helperText={error ?? field.description}
|
helperText={error || field.description || undefined}
|
||||||
placeholder={field.description}
|
placeholder={field.description || undefined}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={field.readOnly}
|
disabled={field.readOnly}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { Box, FormControl, InputLabel, Select, MenuItem, Typography } from "@mui/material";
|
||||||
|
import type { FieldConfig } from "../../../types";
|
||||||
|
import { FormFieldRenderer } from "../FormFieldRenderer";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
field: FieldConfig;
|
||||||
|
value: any;
|
||||||
|
onChange: (value: any) => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscriminatorField({ field, value, onChange, error }: Props) {
|
||||||
|
const options = field.oneOfOptions ?? [];
|
||||||
|
const discProp = field.discriminatorProperty ?? "type";
|
||||||
|
const currentType = value?.[discProp] ?? "";
|
||||||
|
|
||||||
|
const handleTypeChange = useCallback((e: any) => {
|
||||||
|
const newType = e.target.value;
|
||||||
|
const option = options.find((o) => o.value === newType);
|
||||||
|
const newValue: Record<string, any> = { [discProp]: newType };
|
||||||
|
if (option) {
|
||||||
|
for (const f of option.fields) {
|
||||||
|
newValue[f.name] = f.enumValues?.[0] ?? f.type === "number" ? 0 : f.type === "integer" ? 0 : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(newValue);
|
||||||
|
}, [discProp, onChange, options]);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback((fieldName: string, fieldValue: any) => {
|
||||||
|
onChange({ ...(value ?? {}), [fieldName]: fieldValue });
|
||||||
|
}, [onChange, value]);
|
||||||
|
|
||||||
|
const activeFields = options.find((o) => o.value === currentType)?.fields ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||||
|
<InputLabel>{field.label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={currentType}
|
||||||
|
label={field.label}
|
||||||
|
onChange={handleTypeChange}
|
||||||
|
error={!!error}
|
||||||
|
>
|
||||||
|
<MenuItem value="" disabled>Select type</MenuItem>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{currentType && activeFields.length > 0 && (
|
||||||
|
<Box sx={{ pl: 2, borderLeft: "2px solid", borderColor: "divider" }}>
|
||||||
|
{activeFields.map((f) => (
|
||||||
|
<FormFieldRenderer
|
||||||
|
key={f.name}
|
||||||
|
field={f}
|
||||||
|
value={value?.[f.name]}
|
||||||
|
onChange={(v) => handleFieldChange(f.name, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React, { useState, useMemo } from "react";
|
||||||
import { TextField, Autocomplete } from "@mui/material";
|
import { TextField, Autocomplete, Chip, Box } from "@mui/material";
|
||||||
|
import DoneIcon from "@mui/icons-material/Done";
|
||||||
import type { FieldConfig } from "../../../types";
|
import type { FieldConfig } from "../../../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,20 +13,86 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) {
|
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)}`);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [frozenValue, setFrozenValue] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
onOpen?.();
|
||||||
|
setFrozenValue(value ?? []);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedOptions = useMemo(() => {
|
||||||
|
const sel = new Set(frozenValue);
|
||||||
|
const picked: { value: any; label: string }[] = [];
|
||||||
|
const rest: { value: any; label: string }[] = [];
|
||||||
|
for (const opt of fkOptions ?? []) {
|
||||||
|
(sel.has(opt.value) ? picked : rest).push(opt);
|
||||||
|
}
|
||||||
|
return [...picked, ...rest];
|
||||||
|
}, [fkOptions, frozenValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
multiple
|
multiple
|
||||||
options={fkOptions ?? []}
|
disableCloseOnSelect
|
||||||
|
open={open}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
options={sortedOptions}
|
||||||
getOptionLabel={(o) => o.label}
|
getOptionLabel={(o) => o.label}
|
||||||
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
|
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
|
||||||
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
|
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
|
||||||
onOpen={() => onOpen?.()}
|
|
||||||
loading={fkLoading}
|
loading={fkLoading}
|
||||||
|
renderOption={(props, option, { selected }) => (
|
||||||
|
<li {...props}>
|
||||||
|
{selected ? (
|
||||||
|
<DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ width: 22, mr: 1 }} />
|
||||||
|
)}
|
||||||
|
{option.label}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
renderTags={(tagValue, getTagProps) => {
|
||||||
|
const maxChips = 1;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||||
|
const { key, ...tagProps } = getTagProps({ index });
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={key}
|
||||||
|
{...tagProps}
|
||||||
|
label={tag.label.length > 10 ? `${tag.label.slice(0, 8)}..` : tag.label}
|
||||||
|
size="small"
|
||||||
|
onClick={open ? handleClose : handleOpen}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{tagValue.length > maxChips && (
|
||||||
|
<Chip
|
||||||
|
label={`+${tagValue.length - maxChips}`}
|
||||||
|
size="small"
|
||||||
|
onClick={open ? handleClose : handleOpen}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField {...params} label={field.label} helperText={field.description} size="small" />
|
<TextField {...params} label={field.label} helperText={field.description || undefined} size="small" />
|
||||||
)}
|
)}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||||
|
}}
|
||||||
disabled={field.readOnly}
|
disabled={field.readOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
|||||||
<input type="file" hidden accept="image/*" onChange={handleUpload} />
|
<input type="file" hidden accept="image/*" onChange={handleUpload} />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<FormHelperText>{field.description}</FormHelperText>
|
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,83 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Box, Typography, Chip } from "@mui/material";
|
import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
|
||||||
import type { FieldConfig } from "../../../types";
|
import type { FieldConfig } from "../../../types";
|
||||||
import { applyDisplayFormat } from "../utils";
|
import { applyDisplayFormat } from "../utils";
|
||||||
|
import { extractFields } from "../../../transformers/field-config";
|
||||||
|
import { useAppContext } from "../../../context/AppContext";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
value: any;
|
value: any;
|
||||||
displayFormat?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InlineRefField({ field, value, displayFormat }: Props) {
|
const displayLabels: Record<string, string> = {
|
||||||
|
basic: "Basic",
|
||||||
|
heart_rate: "Heart Rate",
|
||||||
|
dental: "Dental",
|
||||||
|
vaccine: "Vaccine",
|
||||||
|
preop: "PreOp",
|
||||||
|
surgery: "Surgery",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InlineRefField({ field, value }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { schemas } = useAppContext();
|
||||||
|
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayFormat) {
|
const discProp = field.discriminatorProperty;
|
||||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>;
|
const discValue = discProp ? value[discProp] : undefined;
|
||||||
}
|
const discChip = discValue ? displayLabels[discValue] ?? discValue : undefined;
|
||||||
|
const tooltip = field.inlineDisplayFormat
|
||||||
|
? applyDisplayFormat(value, field.inlineDisplayFormat)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined);
|
const schema = field.refSchema ? schemas[field.refSchema] : undefined;
|
||||||
if (entries.length === 0) {
|
let subFields: FieldConfig[] = [];
|
||||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
if (field.oneOfOptions && field.discriminatorProperty) {
|
||||||
|
const activeOption = field.oneOfOptions.find((o) => o.value === value[field.discriminatorProperty!]);
|
||||||
|
if (activeOption) subFields = activeOption.fields;
|
||||||
|
} else if (schema && field.refSchema) {
|
||||||
|
subFields = extractFields(field.refSchema, schema, schemas);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
<>
|
||||||
{entries.map(([key, v]) => (
|
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
{discChip && <Chip label={discChip} size="small" color="primary" variant="outlined" />}
|
||||||
<Chip
|
<Chip
|
||||||
key={key}
|
label={field.label}
|
||||||
label={`${key}: ${String(v)}`}
|
title={tooltip}
|
||||||
size="small"
|
size="small"
|
||||||
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
sx={{ cursor: "pointer" }}
|
||||||
/>
|
/>
|
||||||
))}
|
</Box>
|
||||||
</Box>
|
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{field.label}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
|
{subFields.map((sf) => (
|
||||||
|
<Grid key={sf.name} item xs={12} sm={6}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||||
|
{sf.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : String(value[sf.name])}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { FieldConfig } from "../../../types";
|
|||||||
import { useAppContext } from "../../../context/AppContext";
|
import { useAppContext } from "../../../context/AppContext";
|
||||||
import { extractFields } from "../../../transformers/field-config";
|
import { extractFields } from "../../../transformers/field-config";
|
||||||
import { FormFieldRenderer } from "../FormFieldRenderer";
|
import { FormFieldRenderer } from "../FormFieldRenderer";
|
||||||
|
import { applyDisplayFormat } from "../utils";
|
||||||
|
|
||||||
interface JsonFieldProps {
|
interface JsonFieldProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
@@ -78,8 +79,8 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return (
|
return (
|
||||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
<Button variant="outlined" onClick={handleOpen} size="small" startIcon={field.isArray ? <AddIcon /> : undefined}>
|
||||||
Set {field.label}
|
{field.isArray ? `Add ${field.label}` : `Set ${field.label}`}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -87,14 +88,14 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
|
|||||||
if (field.isArray && Array.isArray(value)) {
|
if (field.isArray && Array.isArray(value)) {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
<Button variant="outlined" onClick={handleOpen} size="small" startIcon={<AddIcon />}>
|
||||||
Set {field.label}
|
Add {field.label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
label={`${value.length} item${value.length !== 1 ? "s" : ""}`}
|
label={`${field.label} (${value.length})`}
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -105,15 +106,13 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "object") {
|
if (typeof value === "object") {
|
||||||
const summary = field.inlineDisplayFormat
|
const tooltip = field.inlineDisplayFormat
|
||||||
? applyInlineFormat(value, field.inlineDisplayFormat)
|
? applyDisplayFormat(value, field.inlineDisplayFormat)
|
||||||
: Object.entries(value)
|
: undefined;
|
||||||
.filter(([, v]) => v != null)
|
|
||||||
.map(([k, v]) => `${k}: ${String(v)}`)
|
|
||||||
.join(" | ");
|
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
label={summary || field.label}
|
label={field.label}
|
||||||
|
title={tooltip}
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -264,7 +263,4 @@ function initEditValue(value: any, field: FieldConfig, schemas: Record<string, a
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyInlineFormat(obj: any, format: string): string {
|
|
||||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
|
||||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export function NumberField({ field, value, onChange, error }: Props) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
error={!!error}
|
error={!!error}
|
||||||
helperText={error ?? field.description}
|
helperText={error || field.description || undefined}
|
||||||
placeholder={field.description}
|
placeholder={field.description || undefined}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={field.readOnly}
|
disabled={field.readOnly}
|
||||||
inputProps={isFloat ? { step: "any" } : undefined}
|
inputProps={isFloat ? { step: "any" } : undefined}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function StringField({ field, value, onChange, error }: Props) {
|
|||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
error={!!error}
|
error={!!error}
|
||||||
helperText={error ?? field.description}
|
helperText={error || field.description || undefined}
|
||||||
placeholder={field.description}
|
placeholder={field.description || undefined}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={field.readOnly}
|
disabled={field.readOnly}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
|
|||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]);
|
const lines = (e.message ?? "Failed to load spec").split("\n");
|
||||||
|
setErrors(lines.map((msg: string) => ({ type: "error" as const, message: msg })));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
import { Autocomplete, TextField } from "@mui/material";
|
import { Autocomplete, TextField, Chip, Box } from "@mui/material";
|
||||||
|
import DoneIcon from "@mui/icons-material/Done";
|
||||||
import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
|
import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
|
||||||
import { useAppContext } from "./AppContext";
|
import { useAppContext } from "./AppContext";
|
||||||
import { getApi } from "../hooks/useApi";
|
import { getApi } from "../hooks/useApi";
|
||||||
@@ -10,6 +11,7 @@ import { BooleanField } from "../components/fields/renderers/BooleanField";
|
|||||||
import { EnumField } from "../components/fields/renderers/EnumField";
|
import { EnumField } from "../components/fields/renderers/EnumField";
|
||||||
import { FkSelectField } from "../components/fields/renderers/FkSelectField";
|
import { FkSelectField } from "../components/fields/renderers/FkSelectField";
|
||||||
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
|
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
|
||||||
|
import { extractTokens, extractLocalParts, extractDomains, stripNonDigits } from "../utils/filter-utils";
|
||||||
|
|
||||||
function parseError(e: any): string {
|
function parseError(e: any): string {
|
||||||
if (e.response?.data) {
|
if (e.response?.data) {
|
||||||
@@ -54,7 +56,7 @@ interface UseResourceReturn {
|
|||||||
create: (data: any) => Promise<any>;
|
create: (data: any) => Promise<any>;
|
||||||
update: (id: string | number, data: any) => Promise<any>;
|
update: (id: string | number, data: any) => Promise<any>;
|
||||||
remove: (id: string | number) => Promise<void>;
|
remove: (id: string | number) => Promise<void>;
|
||||||
stream?: (handlers: StreamHandlers) => StreamSubscription;
|
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -266,51 +268,297 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAutocompleteFilter(getDisplayValue: (row: any) => string) {
|
// ── text filter (freeSolo, no dropdown) ──────────────────────
|
||||||
const StringAutocompleteFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
function buildTextFilter() {
|
||||||
|
const TextFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
size="small"
|
||||||
|
options={[]}
|
||||||
|
value={value || null}
|
||||||
|
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return TextFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── token filter (multi-select, suggestions from current page) ─
|
||||||
|
function buildTokenFilter() {
|
||||||
|
const TokenFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
|
||||||
|
const pageTokens = useMemo(() => data ? extractTokens(data, field.name) : [], [data]);
|
||||||
|
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||||
|
const sortedOptions = useMemo(() => {
|
||||||
|
const sel = new Set(selected);
|
||||||
|
const picked: string[] = [];
|
||||||
|
const rest: string[] = [];
|
||||||
|
for (const t of pageTokens) {
|
||||||
|
(sel.has(t) ? picked : rest).push(t);
|
||||||
|
}
|
||||||
|
return [...picked, ...rest];
|
||||||
|
}, [pageTokens, selected]);
|
||||||
|
const displayOptions = open ? frozenOpts : sortedOptions;
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
|
||||||
|
onClose={(_, reason) => {
|
||||||
|
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
|
||||||
|
}}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={(_, v, reason) => {
|
||||||
|
if (reason !== "reset") setInputValue(v);
|
||||||
|
}}
|
||||||
|
options={displayOptions}
|
||||||
|
value={selected}
|
||||||
|
onChange={(_, newVal) => onChange(newVal.join(","))}
|
||||||
|
filterOptions={(opts, { inputValue }) => {
|
||||||
|
if (!inputValue) return [];
|
||||||
|
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected: isSelected }) => {
|
||||||
|
const { key, ...rest } = props as any;
|
||||||
|
return (
|
||||||
|
<li key={key} {...rest}>
|
||||||
|
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
|
||||||
|
{option}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderTags={(tagValue, getTagProps) => {
|
||||||
|
const maxChips = 1;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||||
|
const { key, ...tagProps } = getTagProps({ index });
|
||||||
|
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
|
||||||
|
})}
|
||||||
|
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return TokenFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── email filter (two-step local→domain, no server fetch) ───
|
||||||
|
function buildEmailFilter() {
|
||||||
|
const COMMON_DOMAINS = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "protonmail.com", "aol.com", "mail.com", "zoho.com", "yandex.com"];
|
||||||
|
const EmailFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
|
||||||
|
const [step, setStep] = useState<"local" | "domain">("local");
|
||||||
|
const [pendingLocal, setPendingLocal] = useState<string | null>(null);
|
||||||
|
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||||
|
|
||||||
|
const handleChange = (_: any, newVal: string[], reason: string, details: any) => {
|
||||||
|
if (reason === "removeOption") {
|
||||||
|
if (step === "domain") { setStep("local"); setPendingLocal(null); }
|
||||||
|
onChange(newVal.join(","));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reason !== "selectOption" || !details?.option) return;
|
||||||
|
if (step === "local") {
|
||||||
|
setPendingLocal(String(details.option));
|
||||||
|
setStep("domain");
|
||||||
|
} else if (step === "domain" && pendingLocal) {
|
||||||
|
const email = `${pendingLocal}@${String(details.option)}`;
|
||||||
|
onChange([...selected, email].join(","));
|
||||||
|
setStep("local"); setPendingLocal(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
disableCloseOnSelect
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||||
|
}}
|
||||||
|
open={step === "domain"}
|
||||||
|
onClose={() => { if (step === "domain") { setStep("local"); setPendingLocal(null); } }}
|
||||||
|
options={step === "domain" ? COMMON_DOMAINS : []}
|
||||||
|
value={selected}
|
||||||
|
onChange={handleChange}
|
||||||
|
inputValue={pendingLocal ? `${pendingLocal}@` : undefined}
|
||||||
|
renderTags={(tagValue, getTagProps) => {
|
||||||
|
const maxChips = 1;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||||
|
const { key, ...tagProps } = getTagProps({ index });
|
||||||
|
return <Chip key={key} {...tagProps} label={tag.length > 18 ? `${tag.slice(0, 16)}..` : tag} size="small" />;
|
||||||
|
})}
|
||||||
|
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={labelOverride ?? field.label}
|
||||||
|
placeholder={step === "domain" && pendingLocal ? "Select email domain..." : undefined}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return EmailFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── phone filter (multi-select, digits only, suggestions from current page) ─
|
||||||
|
function buildPhoneFilter() {
|
||||||
|
const PhoneFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
|
||||||
|
const pageTokens = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const tokens = new Set<string>();
|
||||||
|
for (const row of data) {
|
||||||
|
const val = row[field.name];
|
||||||
|
if (val != null && val !== "") {
|
||||||
|
const digits = stripNonDigits(String(val));
|
||||||
|
if (digits) tokens.add(digits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...tokens].sort();
|
||||||
|
}, [data]);
|
||||||
|
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||||
|
const sortedOptions = useMemo(() => {
|
||||||
|
const sel = new Set(selected);
|
||||||
|
const picked: string[] = [];
|
||||||
|
const rest: string[] = [];
|
||||||
|
for (const t of pageTokens) {
|
||||||
|
(sel.has(t) ? picked : rest).push(t);
|
||||||
|
}
|
||||||
|
return [...picked, ...rest];
|
||||||
|
}, [pageTokens, selected]);
|
||||||
|
const displayOptions = open ? frozenOpts : sortedOptions;
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
|
||||||
|
onClose={(_, reason) => {
|
||||||
|
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
|
||||||
|
}}
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={(_, v, reason) => {
|
||||||
|
if (reason !== "reset") setInputValue(v);
|
||||||
|
}}
|
||||||
|
options={displayOptions}
|
||||||
|
value={selected}
|
||||||
|
onChange={(_, newVal) => onChange(newVal.map((v) => stripNonDigits(v)).filter(Boolean).join(","))}
|
||||||
|
filterOptions={(opts, { inputValue }) => {
|
||||||
|
if (!inputValue) return [];
|
||||||
|
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
|
||||||
|
}}
|
||||||
|
renderOption={(props, option, { selected: isSelected }) => {
|
||||||
|
const { key, ...rest } = props as any;
|
||||||
|
return (
|
||||||
|
<li key={key} {...rest}>
|
||||||
|
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
|
||||||
|
{option}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderTags={(tagValue, getTagProps) => {
|
||||||
|
const maxChips = 1;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||||
|
const { key, ...tagProps } = getTagProps({ index });
|
||||||
|
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
|
||||||
|
})}
|
||||||
|
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return PhoneFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── routing ──────────────────────────────────────────────────
|
||||||
|
if (field.autocomplete) {
|
||||||
|
switch (field.autocomplete) {
|
||||||
|
case "text": return buildTextFilter();
|
||||||
|
case "token": return buildTokenFilter();
|
||||||
|
case "email": return buildEmailFilter();
|
||||||
|
case "phone": return buildPhoneFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.refSchema && field.inlineDisplayFormat) {
|
||||||
|
const RefFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||||
const { resources, config } = useAppContext();
|
const { resources, config } = useAppContext();
|
||||||
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
||||||
const [options, setOptions] = useState<string[]>([]);
|
const [options, setOptions] = useState<string[]>([]);
|
||||||
const fetched = useRef(false);
|
const fetched = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filterMode === "client" && data) {
|
const extract = (items: any[]) => {
|
||||||
const vals = new Set<string>();
|
const vals = new Set<string>();
|
||||||
for (const row of data) {
|
for (const row of items) {
|
||||||
const v = getDisplayValue(row);
|
const val = row[field.name];
|
||||||
if (v && v !== "") vals.add(v);
|
if (val == null || typeof val !== "object") continue;
|
||||||
|
const v = field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_: string, key: string) => String(val[key] ?? ""));
|
||||||
|
if (v) vals.add(v);
|
||||||
}
|
}
|
||||||
setOptions([...vals].sort());
|
return [...vals].sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filterMode === "client" && data) {
|
||||||
|
setOptions(extract(data));
|
||||||
fetched.current = true;
|
fetched.current = true;
|
||||||
} else if (filterMode === "server" && !fetched.current) {
|
} else if (filterMode === "server" && !fetched.current) {
|
||||||
const cacheKey = resourceName + ":" + field.name;
|
(async () => {
|
||||||
if (_stringOptionsCache.has(cacheKey)) {
|
try {
|
||||||
setOptions(_stringOptionsCache.get(cacheKey)!);
|
const api = getApi();
|
||||||
fetched.current = true;
|
const selfRes = resources.find((r) => r.name === resourceName);
|
||||||
} else {
|
if (!selfRes) { fetched.current = true; return; }
|
||||||
(async () => {
|
const params: Record<string, any> = {};
|
||||||
try {
|
if (selfRes.pagination) params.limit = 0;
|
||||||
const api = getApi();
|
const res = await api.get(selfRes.path, { params });
|
||||||
const selfRes = resources.find((r) => r.name === resourceName);
|
const items = selfRes.pagination
|
||||||
if (!selfRes) { fetched.current = true; return; }
|
? (Array.isArray(res.data) ? res.data : (res.data.items ?? []))
|
||||||
const params: Record<string, any> = {};
|
: (Array.isArray(res.data) ? res.data : []);
|
||||||
if (selfRes.pagination) params.limit = 0;
|
setOptions(extract(items));
|
||||||
const res = await api.get(selfRes.path, { params });
|
fetched.current = true;
|
||||||
let items: any[];
|
} catch { fetched.current = true; }
|
||||||
if (selfRes.pagination) {
|
})();
|
||||||
items = Array.isArray(res.data) ? res.data : (res.data.items ?? []);
|
|
||||||
} else {
|
|
||||||
items = Array.isArray(res.data) ? res.data : [];
|
|
||||||
}
|
|
||||||
const values = [...new Set(items.map((r: any) => getDisplayValue(r)).filter(Boolean))].sort();
|
|
||||||
_stringOptionsCache.set(cacheKey, values);
|
|
||||||
setOptions(values);
|
|
||||||
fetched.current = true;
|
|
||||||
} catch {
|
|
||||||
fetched.current = true;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
@@ -322,33 +570,12 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
|
|||||||
value={value || null}
|
value={value || null}
|
||||||
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
||||||
renderInput={(params) => (
|
renderInput={(params) => (
|
||||||
<TextField
|
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||||
{...params}
|
|
||||||
label={labelOverride ?? field.label}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return StringAutocompleteFilter;
|
return RefFilter;
|
||||||
}
|
|
||||||
|
|
||||||
const isSimpleField =
|
|
||||||
!field.fk && !field.enumValues &&
|
|
||||||
field.type !== "boolean" && field.type !== "integer" && field.type !== "number" &&
|
|
||||||
field.format !== "date" && field.format !== "date-time";
|
|
||||||
|
|
||||||
if (isSimpleField && !field.refSchema) {
|
|
||||||
return buildAutocompleteFilter((row) => String(row[field.name] ?? ""));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.refSchema && field.inlineDisplayFormat) {
|
|
||||||
return buildAutocompleteFilter((row) => {
|
|
||||||
const val = row[field.name];
|
|
||||||
if (val == null || typeof val !== "object") return "";
|
|
||||||
return field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_, key) => String(val[key] ?? ""));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ({ value, onChange, labelOverride }) => (
|
return ({ value, onChange, labelOverride }) => (
|
||||||
@@ -492,13 +719,19 @@ export function useResource(resourceName: string): UseResourceReturn {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stream = useCallback(
|
const stream = useCallback(
|
||||||
(handlers: StreamHandlers): StreamSubscription => {
|
(handlers: StreamHandlers, pathParams?: Record<string, string | number>): StreamSubscription => {
|
||||||
if (!rPath || !rStreaming) {
|
if (!rPath || !rStreaming) {
|
||||||
throw new Error(`Resource "${resourceName}" does not support streaming`);
|
throw new Error(`Resource "${resourceName}" does not support streaming`);
|
||||||
}
|
}
|
||||||
const api = getApi();
|
const api = getApi();
|
||||||
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
|
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||||
const url = baseUrl + rPath;
|
let resolvedPath = rPath;
|
||||||
|
if (pathParams) {
|
||||||
|
for (const [key, value] of Object.entries(pathParams)) {
|
||||||
|
resolvedPath = resolvedPath.replace(`{${key}}`, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = baseUrl + resolvedPath;
|
||||||
const es = new EventSource(url);
|
const es = new EventSource(url);
|
||||||
|
|
||||||
es.onopen = () => handlers.onOpen?.();
|
es.onopen = () => handlers.onOpen?.();
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
|
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
|
||||||
|
|
||||||
|
function getSegments(path: string): string[] {
|
||||||
|
return path.split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponseSchemaRef(pathObj: any): string | undefined {
|
||||||
|
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"]
|
||||||
|
?? pathObj?.post?.responses?.["200"] ?? pathObj?.post?.responses?.["201"];
|
||||||
|
const content = response?.content;
|
||||||
|
if (!content) return;
|
||||||
|
for (const mediaType of Object.values(content) as any[]) {
|
||||||
|
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
|
||||||
|
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
|
||||||
|
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
|
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
|
||||||
const messages: ValidationMessage[] = [];
|
const messages: ValidationMessage[] = [];
|
||||||
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
|
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
|
||||||
@@ -17,114 +33,101 @@ export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration):
|
|||||||
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
|
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
|
||||||
if (!schema || typeof schema !== "object") continue;
|
if (!pathObj || typeof pathObj !== "object") continue;
|
||||||
|
|
||||||
const isResource = typeof schema["x-resource"] === "string";
|
const segments = getSegments(path);
|
||||||
|
const lastSeg = segments[segments.length - 1];
|
||||||
|
const isItemPath = /^\{.*\}$/.test(lastSeg);
|
||||||
|
const paramIdx = segments.findIndex((s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s));
|
||||||
|
const isSubResource = paramIdx >= 0 && !isItemPath;
|
||||||
|
|
||||||
if (!isResource) continue;
|
const hasSSE = pathObj?.get?.["x-sse"] === true;
|
||||||
|
if (hasSSE) continue;
|
||||||
|
|
||||||
const resourcePath = `/${schema["x-resource"]}`;
|
if (isItemPath || isSubResource) {
|
||||||
|
const responseRef = getResponseSchemaRef(pathObj);
|
||||||
if (!schema["x-primary-key"]) {
|
if (responseRef) {
|
||||||
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
|
const schemaName = responseRef.split("/").pop()!;
|
||||||
}
|
if (!schemas[schemaName]) {
|
||||||
|
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const collectionPath = paths[resourcePath] as any;
|
const responseRef = getResponseSchemaRef(pathObj);
|
||||||
|
if (responseRef) {
|
||||||
|
const schemaName = responseRef.split("/").pop()!;
|
||||||
|
const schema = schemas[schemaName];
|
||||||
|
if (!schema) {
|
||||||
|
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!collectionPath?.get) {
|
if (!schema["x-primary-key"]) {
|
||||||
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
|
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
|
||||||
|
}
|
||||||
|
if (!schema["x-display-format"]) {
|
||||||
|
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-display-format'` });
|
||||||
|
}
|
||||||
|
if (!schema["x-list-columns"]) {
|
||||||
|
messages.push({ type: "error", message: `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 fkPaths = Object.keys(paths).filter((p) => !/^\{.*\}$/.test(getSegments(p).pop() ?? ""));
|
||||||
|
const targetExists = fkPaths.some((p) => getSegments(p).pop() === fkResource);
|
||||||
|
if (!targetExists) {
|
||||||
|
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no path matches that resource name` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSSE = collectionPath?.get?.["x-sse"] === true;
|
if (!pathObj?.get) {
|
||||||
if (isSSE) continue;
|
messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` });
|
||||||
|
}
|
||||||
|
|
||||||
const listParams = collectionPath?.get?.parameters ?? [];
|
const listParams = pathObj?.get?.parameters ?? [];
|
||||||
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
|
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
|
||||||
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
|
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
|
||||||
if (limitParam || offsetParam) {
|
if (limitParam || offsetParam) {
|
||||||
if (!limitParam?.schema?.default) {
|
if (!limitParam?.schema?.default) {
|
||||||
messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` });
|
messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!collectionPath?.post) {
|
if (!pathObj?.post) {
|
||||||
messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` });
|
messages.push({ type: "error", message: `"${path}" 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: "info", message: `"${resourcePath}/{id}" has no PUT endpoint — update not available` });
|
|
||||||
}
|
|
||||||
if (!itemPath?.delete) {
|
|
||||||
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not available` });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,88 @@
|
|||||||
import type { FieldConfig } from "../types";
|
import type { FieldConfig, OneOfOption } from "../types";
|
||||||
|
|
||||||
|
const _validationErrors: string[] = [];
|
||||||
|
|
||||||
|
export function clearValidationErrors(): void {
|
||||||
|
_validationErrors.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidationErrors(): string[] {
|
||||||
|
return [..._validationErrors];
|
||||||
|
}
|
||||||
|
|
||||||
function resolveRef(ref: string): string | undefined {
|
function resolveRef(ref: string): string | undefined {
|
||||||
return ref.split("/").pop();
|
return ref.split("/").pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAllOf(schema: any, schemas: Record<string, any>): any {
|
||||||
|
if (!schema || !schema.allOf) return schema;
|
||||||
|
const merged: any = { type: "object", properties: {}, required: [] };
|
||||||
|
for (const entry of schema.allOf) {
|
||||||
|
const resolved = entry.$ref
|
||||||
|
? resolveAllOf(schemas[resolveRef(entry.$ref)!] ?? {}, schemas)
|
||||||
|
: entry;
|
||||||
|
if (resolved.properties) {
|
||||||
|
Object.assign(merged.properties, resolved.properties);
|
||||||
|
}
|
||||||
|
if (resolved.required) {
|
||||||
|
merged.required.push(...resolved.required);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOneOfOptions(schema: any, schemas: Record<string, any>, discriminatorProperty: string): OneOfOption[] {
|
||||||
|
if (!schema.oneOf) return [];
|
||||||
|
const result = schema.oneOf.flatMap((option: any) => {
|
||||||
|
if (!option.$ref) return [];
|
||||||
|
const variantName = resolveRef(option.$ref);
|
||||||
|
if (!variantName) return [];
|
||||||
|
const variantSchema = schemas[variantName];
|
||||||
|
if (!variantSchema) return [];
|
||||||
|
const merged = resolveAllOf(variantSchema, schemas);
|
||||||
|
const props = merged.properties ?? {};
|
||||||
|
const requiredFields: string[] = merged.required ?? [];
|
||||||
|
const discriminatorProp = props[discriminatorProperty];
|
||||||
|
if (!discriminatorProp?.enum?.[0]) return [];
|
||||||
|
const value = discriminatorProp.enum[0];
|
||||||
|
const label = variantName.replace(/Note$/, "").replace(/([A-Z])/g, " $1").trim() || value;
|
||||||
|
const fields: FieldConfig[] = Object.entries(props)
|
||||||
|
.filter(([k]) => k !== discriminatorProperty)
|
||||||
|
.filter(([, p]: [string, any]) => p && typeof p === "object")
|
||||||
|
.map(([name, prop]: [string, any]) => {
|
||||||
|
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
|
||||||
|
const isPlainString = !prop["x-fk"] && !prop.enum && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
|
||||||
|
if (!autocomplete && isPlainString && prop["x-filterable"]) {
|
||||||
|
autocomplete = "text";
|
||||||
|
}
|
||||||
|
if (autocomplete && !prop["x-filterable"]) {
|
||||||
|
_validationErrors.push(`[field-config] field "${name}" in oneOf variant has x-autocomplete but is not x-filterable`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
label: prop["x-label"] ?? name,
|
||||||
|
description: prop["x-description"] ?? "",
|
||||||
|
type: prop.type ?? "string",
|
||||||
|
format: prop.format,
|
||||||
|
order: prop["x-order"] ?? Infinity,
|
||||||
|
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"],
|
||||||
|
isArray: prop.type === "array",
|
||||||
|
autocomplete,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return [{ value, label, fields }];
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
|
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
|
||||||
const props = schema.properties ?? {};
|
const props = schema.properties ?? {};
|
||||||
const requiredFields: string[] = schema.required ?? [];
|
const requiredFields: string[] = schema.required ?? [];
|
||||||
@@ -30,13 +109,27 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
|
|||||||
? refSchema["x-display-format"]
|
? refSchema["x-display-format"]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const isDiscriminatedUnion = isRef && refSchema?.oneOf && refSchema?.discriminator;
|
||||||
|
const discriminatorProperty = isDiscriminatedUnion ? refSchema.discriminator.propertyName : undefined;
|
||||||
|
const oneOfOptions = isDiscriminatedUnion ? extractOneOfOptions(refSchema, schemas, discriminatorProperty!) : undefined;
|
||||||
|
|
||||||
|
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
|
||||||
|
const isPlainString = !prop["x-fk"] && !prop.enum && !isRef && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
|
||||||
|
if (!autocomplete && isPlainString && prop["x-filterable"]) {
|
||||||
|
autocomplete = "text";
|
||||||
|
console.warn(`[field-config] missing x-autocomplete on "${name}" in schema "${schemaName}", defaulting to "text"`);
|
||||||
|
}
|
||||||
|
if (autocomplete && !prop["x-filterable"]) {
|
||||||
|
_validationErrors.push(`[field-config] field "${name}" in schema "${schemaName}" has x-autocomplete but is not x-filterable`);
|
||||||
|
}
|
||||||
|
|
||||||
const field: FieldConfig = {
|
const field: FieldConfig = {
|
||||||
name,
|
name,
|
||||||
label: prop["x-label"],
|
label: prop["x-label"],
|
||||||
description: prop["x-description"] ?? prop["x-label"] ?? name,
|
description: prop["x-description"] ?? "",
|
||||||
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
|
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
|
||||||
format: prop.format,
|
format: prop.format,
|
||||||
order: prop["x-order"],
|
order: prop["x-order"] ?? Infinity,
|
||||||
hidden: prop["x-hidden"] ?? {},
|
hidden: prop["x-hidden"] ?? {},
|
||||||
filterable: prop["x-filterable"] ?? false,
|
filterable: prop["x-filterable"] ?? false,
|
||||||
sortable: prop["x-sortable"] ?? false,
|
sortable: prop["x-sortable"] ?? false,
|
||||||
@@ -49,6 +142,9 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
|
|||||||
refSchema: refSchemaName,
|
refSchema: refSchemaName,
|
||||||
inlineDisplayFormat,
|
inlineDisplayFormat,
|
||||||
isArray: prop.type === "array",
|
isArray: prop.type === "array",
|
||||||
|
oneOfOptions,
|
||||||
|
discriminatorProperty,
|
||||||
|
autocomplete,
|
||||||
};
|
};
|
||||||
|
|
||||||
return field;
|
return field;
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
|
|||||||
if (!prop["x-fk"]) continue;
|
if (!prop["x-fk"]) continue;
|
||||||
|
|
||||||
const fkResource = prop["x-fk"].resource as string;
|
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;
|
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({
|
rels.push({
|
||||||
fieldName: name,
|
fieldName: name,
|
||||||
@@ -23,10 +19,9 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
|
|||||||
resource: fkResource,
|
resource: fkResource,
|
||||||
prefetch,
|
prefetch,
|
||||||
},
|
},
|
||||||
targetSchemaName,
|
targetSchemaName: fkResource,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[FK] total relationships extracted: ${rels.length}`);
|
|
||||||
return rels;
|
return rels;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
|
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
|
||||||
import { extractFields } from "./field-config";
|
import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
|
||||||
import { extractRelationships } from "./relationship-config";
|
import { extractRelationships } from "./relationship-config";
|
||||||
|
|
||||||
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
|
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
|
||||||
@@ -47,62 +47,147 @@ const SSE_RECEIVED_FIELD: FieldConfig = {
|
|||||||
isArray: false,
|
isArray: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSegments(path: string): string[] {
|
||||||
|
return path.split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResponseSchemaRef(pathObj: any): string | undefined {
|
||||||
|
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"];
|
||||||
|
const content = response?.content;
|
||||||
|
if (!content) return;
|
||||||
|
for (const mediaType of Object.values(content) as any[]) {
|
||||||
|
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
|
||||||
|
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
|
||||||
|
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRef(ref: string): string {
|
||||||
|
return ref.split("/").pop()!;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
|
||||||
|
clearValidationErrors();
|
||||||
const schemas = spec.components?.schemas ?? {};
|
const schemas = spec.components?.schemas ?? {};
|
||||||
const paths = spec.paths ?? {};
|
const paths = spec.paths ?? {};
|
||||||
const configs: ResourceConfig[] = [];
|
const configs: ResourceConfig[] = [];
|
||||||
|
const nameMap = new Map<string, ResourceConfig>();
|
||||||
|
|
||||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
const sortedPaths = Object.keys(paths).sort(
|
||||||
if (!schema || typeof schema !== "object") continue;
|
(a, b) => getSegments(a).length - getSegments(b).length
|
||||||
|
);
|
||||||
|
|
||||||
const resourceName = schema["x-resource"];
|
for (const path of sortedPaths) {
|
||||||
if (!resourceName || typeof resourceName !== "string") continue;
|
const segments = getSegments(path);
|
||||||
|
const pathObj = paths[path];
|
||||||
|
const lastSeg = segments[segments.length - 1];
|
||||||
|
const isItemPath = /^\{.*\}$/.test(lastSeg);
|
||||||
|
const paramIdx = segments.findIndex(
|
||||||
|
(s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s)
|
||||||
|
);
|
||||||
|
|
||||||
const resourcePath = `/${resourceName}`;
|
if (isItemPath) {
|
||||||
const itemPath = `${resourcePath}/{id}`;
|
const parentName = segments[segments.length - 2];
|
||||||
const collectionPathObj = paths[resourcePath];
|
const parent = nameMap.get(parentName);
|
||||||
const itemPathObj = paths[itemPath];
|
if (!parent) continue;
|
||||||
|
if (hasOperation(pathObj, "get")) parent.operations.get = true;
|
||||||
|
if (hasOperation(pathObj, "put") || hasOperation(pathObj, "patch")) parent.operations.update = true;
|
||||||
|
if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
|
||||||
|
if (hasOperation(pathObj, "patch") && !hasOperation(pathObj, "put")) parent.updateMethod = "patch";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const fields = extractFields(schemaName, schema, schemas);
|
if (paramIdx >= 0) {
|
||||||
const relationships = extractRelationships(schema, schemas);
|
const resourceName = lastSeg;
|
||||||
const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
|
const parentName = segments[paramIdx - 1];
|
||||||
|
const pathParamName = segments[paramIdx].replace(/[{}]/g, "");
|
||||||
|
|
||||||
|
const responseRef = getResponseSchemaRef(pathObj);
|
||||||
|
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
|
||||||
|
const schema = schemaName ? schemas[schemaName] : undefined;
|
||||||
|
|
||||||
|
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
|
||||||
|
const hasSSE = pathObj?.get?.["x-sse"] === true;
|
||||||
|
|
||||||
|
const resource: ResourceConfig = {
|
||||||
|
name: resourceName,
|
||||||
|
schemaName: schemaName ?? resourceName,
|
||||||
|
displayName: formatDisplayName(resourceName),
|
||||||
|
path,
|
||||||
|
primaryKey: schema?.["x-primary-key"] ?? "_received_at",
|
||||||
|
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
|
||||||
|
listColumns: schema?.["x-list-columns"] ?? [],
|
||||||
|
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
|
||||||
|
orderedFields: [],
|
||||||
|
operations: hasSSE
|
||||||
|
? { list: true, get: false, create: false, update: false, delete: false }
|
||||||
|
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, delete: false },
|
||||||
|
updateMethod: "put",
|
||||||
|
pagination: hasSSE ? null : detectPagination(pathObj),
|
||||||
|
relationships: [],
|
||||||
|
streaming: hasSSE || undefined,
|
||||||
|
parent: { resource: parentName, pathParam: pathParamName },
|
||||||
|
};
|
||||||
|
|
||||||
|
resource.orderedFields = sortFields(resource.fields);
|
||||||
|
if (hasSSE) {
|
||||||
|
resource.listColumns = ["_received_at", ...resource.listColumns];
|
||||||
|
resource.primaryKey = "_received_at";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = nameMap.get(parentName);
|
||||||
|
if (parent) {
|
||||||
|
parent.subResources = parent.subResources ?? [];
|
||||||
|
parent.subResources.push(resourceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
nameMap.set(resourceName, resource);
|
||||||
|
configs.push(resource);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceName = lastSeg;
|
||||||
|
const responseRef = getResponseSchemaRef(pathObj);
|
||||||
|
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
|
||||||
|
const schema = schemaName ? schemas[schemaName] : undefined;
|
||||||
|
|
||||||
|
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
|
||||||
|
const relationships = schema ? extractRelationships(schema, schemas) : [];
|
||||||
|
const hasSSE = pathObj?.get?.["x-sse"] === true;
|
||||||
|
|
||||||
const resource: ResourceConfig = {
|
const resource: ResourceConfig = {
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
schemaName,
|
schemaName: schemaName ?? resourceName,
|
||||||
displayName: formatDisplayName(resourceName),
|
displayName: formatDisplayName(resourceName),
|
||||||
path: resourcePath,
|
path,
|
||||||
primaryKey: schema["x-primary-key"],
|
primaryKey: schema?.["x-primary-key"] ?? "id",
|
||||||
displayFormat: schema["x-display-format"],
|
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
|
||||||
listColumns: schema["x-list-columns"],
|
listColumns: schema?.["x-list-columns"] ?? [],
|
||||||
fields,
|
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
|
||||||
orderedFields: sortFields(fields),
|
orderedFields: [],
|
||||||
operations: {
|
operations: hasSSE
|
||||||
list: hasOperation(collectionPathObj, "get"),
|
? { list: true, get: false, create: false, update: false, delete: false }
|
||||||
get: hasOperation(itemPathObj, "get"),
|
: { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, delete: false },
|
||||||
create: hasOperation(collectionPathObj, "post"),
|
updateMethod: "put",
|
||||||
update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"),
|
pagination: hasSSE ? null : detectPagination(pathObj),
|
||||||
delete: hasOperation(itemPathObj, "delete"),
|
|
||||||
},
|
|
||||||
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
|
|
||||||
pagination: detectPagination(collectionPathObj),
|
|
||||||
relationships,
|
relationships,
|
||||||
streaming: hasSSE || undefined,
|
streaming: hasSSE || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
resource.orderedFields = sortFields(resource.fields);
|
||||||
if (hasSSE) {
|
if (hasSSE) {
|
||||||
resource.operations = { list: true, get: false, create: false, update: false, delete: false };
|
|
||||||
resource.updateMethod = "put";
|
|
||||||
resource.pagination = null;
|
|
||||||
resource.relationships = [];
|
|
||||||
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
|
|
||||||
resource.orderedFields = sortFields(resource.fields);
|
|
||||||
resource.listColumns = ["_received_at", ...resource.listColumns];
|
resource.listColumns = ["_received_at", ...resource.listColumns];
|
||||||
resource.primaryKey = "_received_at";
|
resource.primaryKey = "_received_at";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nameMap.set(resourceName, resource);
|
||||||
configs.push(resource);
|
configs.push(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errors = getValidationErrors();
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(errors.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
return configs;
|
return configs;
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,19 @@ export interface ResourceConfig {
|
|||||||
} | null;
|
} | null;
|
||||||
relationships: ResourceRelationship[];
|
relationships: ResourceRelationship[];
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
parent?: { resource: string; pathParam: string };
|
||||||
|
subResources?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FKFieldConfig {
|
||||||
|
resource: string;
|
||||||
|
prefetch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneOfOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
fields: FieldConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldConfig {
|
export interface FieldConfig {
|
||||||
@@ -71,11 +84,9 @@ export interface FieldConfig {
|
|||||||
refSchema?: string;
|
refSchema?: string;
|
||||||
inlineDisplayFormat?: string;
|
inlineDisplayFormat?: string;
|
||||||
isArray: boolean;
|
isArray: boolean;
|
||||||
}
|
oneOfOptions?: OneOfOption[];
|
||||||
|
discriminatorProperty?: string;
|
||||||
export interface FKFieldConfig {
|
autocomplete?: "text" | "token" | "email" | "phone";
|
||||||
resource: string;
|
|
||||||
prefetch: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenApiSpec {
|
export interface OpenApiSpec {
|
||||||
|
|||||||
46
react-openapi/src/utils/filter-utils.ts
Normal file
46
react-openapi/src/utils/filter-utils.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export function tokenize(text: string): string[] {
|
||||||
|
return text.split(/[,\s.]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripNonDigits(text: string): string {
|
||||||
|
return text.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTokens(data: any[], fieldName: string): string[] {
|
||||||
|
const all = new Set<string>();
|
||||||
|
for (const row of data) {
|
||||||
|
const val = row[fieldName];
|
||||||
|
if (val != null && val !== "") {
|
||||||
|
for (const t of tokenize(String(val))) {
|
||||||
|
all.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...all].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractLocalParts(data: any[], fieldName: string): string[] {
|
||||||
|
const parts = new Set<string>();
|
||||||
|
for (const row of data) {
|
||||||
|
const val = row[fieldName];
|
||||||
|
if (val != null && val !== "") {
|
||||||
|
const m = String(val).match(/^([^@]+)@/);
|
||||||
|
if (m) parts.add(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...parts].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDomains(data: any[], fieldName: string, localPart: string): string[] {
|
||||||
|
const domains = new Set<string>();
|
||||||
|
const escaped = localPart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp(`^${escaped}@(.+)$`);
|
||||||
|
for (const row of data) {
|
||||||
|
const val = row[fieldName];
|
||||||
|
if (val != null && val !== "") {
|
||||||
|
const m = String(val).match(re);
|
||||||
|
if (m) domains.add(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...domains].sort();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user