diff --git a/react-openapi/src/components/Admin.tsx b/react-openapi/src/components/Admin.tsx
index 0d58bae..9d1b17e 100644
--- a/react-openapi/src/components/Admin.tsx
+++ b/react-openapi/src/components/Admin.tsx
@@ -30,18 +30,20 @@ export function Admin({ basePath }: AdminProps) {
if (resources.length === 0) {
return (
- No resources found in the OpenAPI spec with x-resource defined.
+ No resources found in the OpenAPI spec.
);
}
+ const topLevel = resources.filter((r) => !r.parent);
+
return (
<>
{warnings.length > 0 && }
-
+
- } />
- {resources.map((r) => (
+ } />
+ {topLevel.map((r) => (
} />
{!r.streaming && (
diff --git a/react-openapi/src/components/ResourceDetail.tsx b/react-openapi/src/components/ResourceDetail.tsx
index 188c943..5ef9619 100644
--- a/react-openapi/src/components/ResourceDetail.tsx
+++ b/react-openapi/src/components/ResourceDetail.tsx
@@ -7,6 +7,8 @@ import {
Paper,
Grid,
CircularProgress,
+ Tabs,
+ Tab,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import EditIcon from "@mui/icons-material/Edit";
@@ -14,12 +16,18 @@ import type { ResourceConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
+import { SseStreamView } from "./SseStreamView";
interface ResourceDetailProps {
resource: ResourceConfig;
basePath: string;
}
+function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
+ if (value !== index) return null;
+ return {children};
+}
+
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate();
const { id } = useParams();
@@ -27,6 +35,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const { resources: allResources } = useAppContext();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
+ const [tabIndex, setTabIndex] = useState(0);
useEffect(() => {
if (id) {
@@ -57,6 +66,16 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
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 (
@@ -85,25 +104,47 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
)}
-
-
- {visibleFields.map((field) => {
- let value = data[field.name];
- let fmt = resource.displayFormat;
- if (field.fk && typeof value === "object") {
- const targetRes = allResources.find((r) => r.name === field.fk!.resource);
- fmt = targetRes!.displayFormat;
- } else if (field.refSchema && !field.fk && typeof value === "object") {
- fmt = field.inlineDisplayFormat ?? resource.displayFormat;
- }
- return (
-
-
-
- );
- })}
-
-
+ {tabs.length > 1 && (
+ setTabIndex(v)} sx={{ mb: 1 }}>
+ {tabs.map((t) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {visibleFields.map((field) => {
+ let value = data[field.name];
+ let fmt = resource.displayFormat;
+ if (field.fk && typeof value === "object") {
+ const targetRes = allResources.find((r) => r.name === field.fk!.resource);
+ fmt = targetRes!.displayFormat;
+ } else if (field.refSchema && !field.fk && typeof value === "object") {
+ fmt = field.inlineDisplayFormat ?? resource.displayFormat;
+ }
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+ {tabs.slice(1).map((t, i) => {
+ const sub = allResources.find((r) => r.name === t.key)!;
+ const pathParam = sub.parent?.pathParam ?? "id";
+ return (
+
+ {sub.streaming ? (
+
+ ) : null}
+
+ );
+ })}
);
}
diff --git a/react-openapi/src/components/ResourceForm.tsx b/react-openapi/src/components/ResourceForm.tsx
index 7b3cf93..e600dd1 100644
--- a/react-openapi/src/components/ResourceForm.tsx
+++ b/react-openapi/src/components/ResourceForm.tsx
@@ -106,7 +106,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
}
const opts = items.map((item: any) => ({
- value: resolvePk(item, targetRes.primaryKey),
+ value: item[targetRes.primaryKey],
label: applyFormat(item, targetRes.displayFormat),
}));
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);
if (targetRes) {
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") {
- resolved[rel.fieldName] = resolvePk(val, targetRes.primaryKey);
+ resolved[rel.fieldName] = val[targetRes.primaryKey];
}
}
if (!rel.config.prefetch) {
@@ -236,8 +236,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
{resource.orderedFields
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
+ .filter((f) => !f.hidden?.form)
.map((field) => (
-
+
String(obj[key] ?? ""));
diff --git a/react-openapi/src/components/ResourceList.tsx b/react-openapi/src/components/ResourceList.tsx
index 9677719..4417546 100644
--- a/react-openapi/src/components/ResourceList.tsx
+++ b/react-openapi/src/components/ResourceList.tsx
@@ -323,7 +323,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
}
return (
-
+
);
})}
@@ -387,12 +387,13 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
{detailRow && (
{visibleColumns.map((col) => (
-
-
+
+
))}
diff --git a/react-openapi/src/components/SseStreamView.tsx b/react-openapi/src/components/SseStreamView.tsx
index 509d533..1f206de 100644
--- a/react-openapi/src/components/SseStreamView.tsx
+++ b/react-openapi/src/components/SseStreamView.tsx
@@ -9,9 +9,10 @@ import { SseConnectionStatus } from "./SseConnectionStatus";
interface SseStreamViewProps {
resource: ResourceConfig;
+ pathParams?: Record;
}
-export function SseStreamView({ resource }: SseStreamViewProps) {
+export function SseStreamView({ resource, pathParams }: SseStreamViewProps) {
const { stream } = useResource(resource.name);
const [events, setEvents] = useState(() => readSseCache(resource.name));
const [snackbarOpen, setSnackbarOpen] = useState(false);
@@ -31,7 +32,7 @@ export function SseStreamView({ resource }: SseStreamViewProps) {
},
onOpen: () => setSseConnected(resource.name, true),
onError: () => setSseConnected(resource.name, false),
- });
+ }, pathParams);
return () => {
setSseConnected(resource.name, false);
diff --git a/react-openapi/src/components/fields/DetailFieldRenderer.tsx b/react-openapi/src/components/fields/DetailFieldRenderer.tsx
index 6e3b8da..9fbbd8f 100644
--- a/react-openapi/src/components/fields/DetailFieldRenderer.tsx
+++ b/react-openapi/src/components/fields/DetailFieldRenderer.tsx
@@ -7,9 +7,10 @@ interface DetailFieldProps {
field: FieldConfig;
value: any;
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;
return (
@@ -17,7 +18,7 @@ export function DetailFieldRenderer({ field, value, displayFormat }: DetailField
{field.label}
-
+
);
}
diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx
index 5a336a3..a3f476f 100644
--- a/react-openapi/src/components/fields/FormFieldRenderer.tsx
+++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx
@@ -10,6 +10,7 @@ import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { ImageField } from "./renderers/ImageField";
import { JsonField } from "./renderers/JsonField";
+import { DiscriminatorField } from "./renderers/DiscriminatorField";
interface FormFieldProps {
field: FieldConfig;
@@ -106,6 +107,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
);
}
+ if (field.oneOfOptions) {
+ return (
+
+ );
+ }
+
if (field.refSchema && !field.fk) {
return (
(null);
+
if (value === null || value === undefined) {
return —;
}
+ 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") {
- return ;
+ return ;
}
+ 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 (
+
+ {fields.map((sf) => {
+ const fv = itemValue?.[sf.name];
+ return (
+
+
+
+ {sf.label}
+
+
+ {fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : String(fv)}
+
+
+
+ );
+ })}
+
+ );
+ };
+
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
if (value.length === 0) {
return —;
@@ -29,14 +76,40 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
const label = typeof item === "object"
? applyDisplayFormat(item, displayFormat ?? "")
: String(item);
- return ;
+ return (
+ { e.stopPropagation(); setInlineItem(item); }}
+ sx={{ cursor: "pointer" }}
+ />
+ );
})}
+
);
}
if (field.fk && typeof value === "object" && !field.isArray) {
- return {applyDisplayFormat(value, displayFormat ?? "")};
+ return (
+ handleFkClick(e, value)}
+ sx={basePath ? { cursor: "pointer" } : undefined}
+ />
+ );
}
if (field.isArray && Array.isArray(value) && field.fk) {
@@ -44,7 +117,16 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
{value.map((item: any, i: number) => {
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
- return ;
+ return (
+ handleFkClick(e, item)}
+ sx={basePath ? { cursor: "pointer" } : undefined}
+ />
+ );
})}
);
diff --git a/react-openapi/src/components/fields/renderers/DateField.tsx b/react-openapi/src/components/fields/renderers/DateField.tsx
index fe8ef12..8540006 100644
--- a/react-openapi/src/components/fields/renderers/DateField.tsx
+++ b/react-openapi/src/components/fields/renderers/DateField.tsx
@@ -12,16 +12,20 @@ interface Props {
export function DateField({ field, value, onChange, error }: Props) {
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 (
onChange(e.target.value)}
error={!!error}
- helperText={error ?? field.description}
- placeholder={field.description}
+ helperText={error || field.description || undefined}
+ placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
InputLabelProps={{ shrink: true }}
diff --git a/react-openapi/src/components/fields/renderers/DiscriminatorField.tsx b/react-openapi/src/components/fields/renderers/DiscriminatorField.tsx
new file mode 100644
index 0000000..d5dad58
--- /dev/null
+++ b/react-openapi/src/components/fields/renderers/DiscriminatorField.tsx
@@ -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 = { [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 (
+
+
+ {field.label}
+
+
+ {currentType && activeFields.length > 0 && (
+
+ {activeFields.map((f) => (
+ handleFieldChange(f.name, v)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx b/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx
index 946dfc0..1821034 100644
--- a/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx
+++ b/react-openapi/src/components/fields/renderers/FkMultiSelectField.tsx
@@ -1,5 +1,6 @@
-import React from "react";
-import { TextField, Autocomplete } from "@mui/material";
+import React, { useState, useMemo } from "react";
+import { TextField, Autocomplete, Chip, Box } from "@mui/material";
+import DoneIcon from "@mui/icons-material/Done";
import type { FieldConfig } from "../../../types";
interface Props {
@@ -12,20 +13,86 @@ interface 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([]);
+
+ 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 (
o.label}
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
- onOpen={() => onOpen?.()}
loading={fkLoading}
+ renderOption={(props, option, { selected }) => (
+
+ {selected ? (
+
+ ) : (
+
+ )}
+ {option.label}
+
+ )}
+ renderTags={(tagValue, getTagProps) => {
+ const maxChips = 1;
+ return (
+ <>
+ {tagValue.slice(0, maxChips).map((tag, index) => {
+ const { key, ...tagProps } = getTagProps({ index });
+ return (
+ 10 ? `${tag.label.slice(0, 8)}..` : tag.label}
+ size="small"
+ onClick={open ? handleClose : handleOpen}
+ sx={{ cursor: "pointer" }}
+ />
+ );
+ })}
+ {tagValue.length > maxChips && (
+
+ )}
+ >
+ );
+ }}
renderInput={(params) => (
-
+
)}
size="small"
+ sx={{
+ "& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
+ }}
disabled={field.readOnly}
/>
);
diff --git a/react-openapi/src/components/fields/renderers/ImageField.tsx b/react-openapi/src/components/fields/renderers/ImageField.tsx
index f7bc6a3..0292ba0 100644
--- a/react-openapi/src/components/fields/renderers/ImageField.tsx
+++ b/react-openapi/src/components/fields/renderers/ImageField.tsx
@@ -54,7 +54,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
)}
- {field.description}
+ {field.description && {field.description}}
);
}
diff --git a/react-openapi/src/components/fields/renderers/InlineRefField.tsx b/react-openapi/src/components/fields/renderers/InlineRefField.tsx
index b067fa6..5a4adc6 100644
--- a/react-openapi/src/components/fields/renderers/InlineRefField.tsx
+++ b/react-openapi/src/components/fields/renderers/InlineRefField.tsx
@@ -1,38 +1,83 @@
-import React from "react";
-import { Box, Typography, Chip } from "@mui/material";
+import React, { useState } from "react";
+import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils";
+import { extractFields } from "../../../transformers/field-config";
+import { useAppContext } from "../../../context/AppContext";
+
interface Props {
field: FieldConfig;
value: any;
- displayFormat?: string;
}
-export function InlineRefField({ field, value, displayFormat }: Props) {
+const displayLabels: Record = {
+ 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") {
return —;
}
- if (displayFormat) {
- return {applyDisplayFormat(value, displayFormat)};
- }
+ const discProp = field.discriminatorProperty;
+ 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);
- if (entries.length === 0) {
- return —;
+ const schema = field.refSchema ? schemas[field.refSchema] : undefined;
+ let subFields: FieldConfig[] = [];
+ 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 (
-
- {entries.map(([key, v]) => (
+ <>
+
+ {discChip && }
setOpen(true)}
+ sx={{ cursor: "pointer" }}
/>
- ))}
-
+
+
+ >
);
}
diff --git a/react-openapi/src/components/fields/renderers/JsonField.tsx b/react-openapi/src/components/fields/renderers/JsonField.tsx
index 0e94434..8fd34a9 100644
--- a/react-openapi/src/components/fields/renderers/JsonField.tsx
+++ b/react-openapi/src/components/fields/renderers/JsonField.tsx
@@ -17,6 +17,7 @@ import type { FieldConfig } from "../../../types";
import { useAppContext } from "../../../context/AppContext";
import { extractFields } from "../../../transformers/field-config";
import { FormFieldRenderer } from "../FormFieldRenderer";
+import { applyDisplayFormat } from "../utils";
interface JsonFieldProps {
field: FieldConfig;
@@ -78,8 +79,8 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (!open) {
if (value === null || value === undefined) {
return (
-
);
}
@@ -87,14 +88,14 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (field.isArray && Array.isArray(value)) {
if (value.length === 0) {
return (
-
- Set {field.label}
+ }>
+ Add {field.label}
);
}
return (
v != null)
- .map(([k, v]) => `${k}: ${String(v)}`)
- .join(" | ");
+ const tooltip = field.inlineDisplayFormat
+ ? applyDisplayFormat(value, field.inlineDisplayFormat)
+ : undefined;
return (
String(obj[key] ?? ""));
-}
+
diff --git a/react-openapi/src/components/fields/renderers/NumberField.tsx b/react-openapi/src/components/fields/renderers/NumberField.tsx
index 5b2b483..4f4f944 100644
--- a/react-openapi/src/components/fields/renderers/NumberField.tsx
+++ b/react-openapi/src/components/fields/renderers/NumberField.tsx
@@ -27,8 +27,8 @@ export function NumberField({ field, value, onChange, error }: Props) {
}
}}
error={!!error}
- helperText={error ?? field.description}
- placeholder={field.description}
+ helperText={error || field.description || undefined}
+ placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
inputProps={isFloat ? { step: "any" } : undefined}
diff --git a/react-openapi/src/components/fields/renderers/StringField.tsx b/react-openapi/src/components/fields/renderers/StringField.tsx
index 88cc54f..6c2bc37 100644
--- a/react-openapi/src/components/fields/renderers/StringField.tsx
+++ b/react-openapi/src/components/fields/renderers/StringField.tsx
@@ -20,8 +20,8 @@ export function StringField({ field, value, onChange, error }: Props) {
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
- helperText={error ?? field.description}
- placeholder={field.description}
+ helperText={error || field.description || undefined}
+ placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
/>
diff --git a/react-openapi/src/context/AppProvider.tsx b/react-openapi/src/context/AppProvider.tsx
index b2d19c3..5f5ecb7 100644
--- a/react-openapi/src/context/AppProvider.tsx
+++ b/react-openapi/src/context/AppProvider.tsx
@@ -51,7 +51,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
}
} catch (e: any) {
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 {
if (!cancelled) {
diff --git a/react-openapi/src/context/useResource.tsx b/react-openapi/src/context/useResource.tsx
index 8b195d9..67b29e7 100644
--- a/react-openapi/src/context/useResource.tsx
+++ b/react-openapi/src/context/useResource.tsx
@@ -1,5 +1,6 @@
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 { useAppContext } from "./AppContext";
import { getApi } from "../hooks/useApi";
@@ -10,6 +11,7 @@ import { BooleanField } from "../components/fields/renderers/BooleanField";
import { EnumField } from "../components/fields/renderers/EnumField";
import { FkSelectField } from "../components/fields/renderers/FkSelectField";
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
+import { extractTokens, extractLocalParts, extractDomains, stripNonDigits } from "../utils/filter-utils";
function parseError(e: any): string {
if (e.response?.data) {
@@ -54,7 +56,7 @@ interface UseResourceReturn {
create: (data: any) => Promise;
update: (id: string | number, data: any) => Promise;
remove: (id: string | number) => Promise;
- stream?: (handlers: StreamHandlers) => StreamSubscription;
+ stream?: (handlers: StreamHandlers, pathParams?: Record) => StreamSubscription;
loading: boolean;
error: string | null;
}
@@ -266,51 +268,297 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
);
}
- function buildAutocompleteFilter(getDisplayValue: (row: any) => string) {
- const StringAutocompleteFilter: React.FC = ({ value, onChange, data, labelOverride }) => {
+ // ── text filter (freeSolo, no dropdown) ──────────────────────
+ function buildTextFilter() {
+ const TextFilter: React.FC = ({ value, onChange, labelOverride }) => {
+ return (
+ onChange(newVal ?? "")}
+ renderInput={(params) => (
+
+ )}
+ 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 = ({ value, onChange, data, labelOverride }) => {
+ const [inputValue, setInputValue] = useState("");
+ const [open, setOpen] = useState(false);
+ const [frozenOpts, setFrozenOpts] = useState([]);
+ 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 (
+ { 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 (
+
+ {isSelected ? : }
+ {option}
+
+ );
+ }}
+ renderTags={(tagValue, getTagProps) => {
+ const maxChips = 1;
+ return (
+ <>
+ {tagValue.slice(0, maxChips).map((tag, index) => {
+ const { key, ...tagProps } = getTagProps({ index });
+ return 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
+ })}
+ {tagValue.length > maxChips && }
+ >
+ );
+ }}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ };
+ 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 = ({ value, onChange, labelOverride }) => {
+ const [step, setStep] = useState<"local" | "domain">("local");
+ const [pendingLocal, setPendingLocal] = useState(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 (
+ { 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 18 ? `${tag.slice(0, 16)}..` : tag} size="small" />;
+ })}
+ {tagValue.length > maxChips && }
+ >
+ );
+ }}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ };
+ return EmailFilter;
+ }
+
+ // ── phone filter (multi-select, digits only, suggestions from current page) ─
+ function buildPhoneFilter() {
+ const PhoneFilter: React.FC = ({ value, onChange, data, labelOverride }) => {
+ const [inputValue, setInputValue] = useState("");
+ const [open, setOpen] = useState(false);
+ const [frozenOpts, setFrozenOpts] = useState([]);
+ const pageTokens = useMemo(() => {
+ if (!data) return [];
+ const tokens = new Set();
+ 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 (
+ { 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 (
+
+ {isSelected ? : }
+ {option}
+
+ );
+ }}
+ renderTags={(tagValue, getTagProps) => {
+ const maxChips = 1;
+ return (
+ <>
+ {tagValue.slice(0, maxChips).map((tag, index) => {
+ const { key, ...tagProps } = getTagProps({ index });
+ return 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
+ })}
+ {tagValue.length > maxChips && }
+ >
+ );
+ }}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ };
+ 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 = ({ value, onChange, data, labelOverride }) => {
const { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const [options, setOptions] = useState([]);
const fetched = useRef(false);
useEffect(() => {
- if (filterMode === "client" && data) {
+ const extract = (items: any[]) => {
const vals = new Set();
- for (const row of data) {
- const v = getDisplayValue(row);
- if (v && v !== "") vals.add(v);
+ for (const row of items) {
+ const val = row[field.name];
+ 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;
} else if (filterMode === "server" && !fetched.current) {
- const cacheKey = resourceName + ":" + field.name;
- if (_stringOptionsCache.has(cacheKey)) {
- setOptions(_stringOptionsCache.get(cacheKey)!);
- fetched.current = true;
- } else {
- (async () => {
- try {
- const api = getApi();
- const selfRes = resources.find((r) => r.name === resourceName);
- if (!selfRes) { fetched.current = true; return; }
- const params: Record = {};
- if (selfRes.pagination) params.limit = 0;
- const res = await api.get(selfRes.path, { params });
- let items: any[];
- 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;
- }
- })();
- }
+ (async () => {
+ try {
+ const api = getApi();
+ const selfRes = resources.find((r) => r.name === resourceName);
+ if (!selfRes) { fetched.current = true; return; }
+ const params: Record = {};
+ if (selfRes.pagination) params.limit = 0;
+ const res = await api.get(selfRes.path, { params });
+ const items = selfRes.pagination
+ ? (Array.isArray(res.data) ? res.data : (res.data.items ?? []))
+ : (Array.isArray(res.data) ? res.data : []);
+ setOptions(extract(items));
+ fetched.current = true;
+ } catch { fetched.current = true; }
+ })();
}
}, [data]);
@@ -322,33 +570,12 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
-
+
)}
/>
);
};
- return StringAutocompleteFilter;
- }
-
- 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 RefFilter;
}
return ({ value, onChange, labelOverride }) => (
@@ -492,13 +719,19 @@ export function useResource(resourceName: string): UseResourceReturn {
);
const stream = useCallback(
- (handlers: StreamHandlers): StreamSubscription => {
+ (handlers: StreamHandlers, pathParams?: Record): StreamSubscription => {
if (!rPath || !rStreaming) {
throw new Error(`Resource "${resourceName}" does not support streaming`);
}
const api = getApi();
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);
es.onopen = () => handlers.onOpen?.();
diff --git a/react-openapi/src/spec-validator.ts b/react-openapi/src/spec-validator.ts
index 741ac85..d508912 100644
--- a/react-openapi/src/spec-validator.ts
+++ b/react-openapi/src/spec-validator.ts
@@ -1,5 +1,21 @@
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[] {
const messages: ValidationMessage[] = [];
const schemas = (spec.components?.schemas ?? {}) as Record;
@@ -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" });
}
- for (const [schemaName, schema] of Object.entries(schemas)) {
- if (!schema || typeof schema !== "object") continue;
+ for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
+ 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 (!schema["x-primary-key"]) {
- messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
- }
-
- if (!schema["x-display-format"]) {
- messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` });
- }
-
- if (!schema["x-list-columns"]) {
- messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-list-columns'` });
- }
-
- if (Array.isArray(schema["x-list-columns"])) {
- const props = schema.properties ?? {};
- for (const col of schema["x-list-columns"]) {
- if (!props[col]) {
- messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
+ if (isItemPath || isSubResource) {
+ const responseRef = getResponseSchemaRef(pathObj);
+ if (responseRef) {
+ 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` });
}
}
- }
-
- 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).find(([, s]) => s?.["x-resource"] === fkResource);
- if (!targetSchema) {
- messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` });
- } else {
- const [, target] = targetSchema;
- if (!target["x-display-format"]) {
- messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` });
- }
- if (!target["x-primary-key"]) {
- messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` });
- }
- }
- }
- }
-
- if (!paths[resourcePath]) {
- messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` });
continue;
}
- const collectionPath = paths[resourcePath] as any;
+ 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) {
- messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
+ if (!schema["x-primary-key"]) {
+ messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
+ }
+ if (!schema["x-display-format"]) {
+ messages.push({ type: "error", message: `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 (isSSE) continue;
+ if (!pathObj?.get) {
+ 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 offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
if (limitParam || offsetParam) {
if (!limitParam?.schema?.default) {
- messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` });
+ messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` });
}
}
- if (!collectionPath?.post) {
- messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` });
- }
-
- const itemPath = paths[`${resourcePath}/{id}`] as any;
- if (!itemPath) {
- messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` });
- } else {
- if (!itemPath?.get) {
- messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` });
- }
- if (!itemPath?.put) {
- messages.push({ type: "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` });
- }
+ if (!pathObj?.post) {
+ messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` });
}
}
diff --git a/react-openapi/src/transformers/field-config.ts b/react-openapi/src/transformers/field-config.ts
index 77d758e..21f5012 100644
--- a/react-openapi/src/transformers/field-config.ts
+++ b/react-openapi/src/transformers/field-config.ts
@@ -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 {
return ref.split("/").pop();
}
+function resolveAllOf(schema: any, schemas: Record): 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, 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): FieldConfig[] {
const props = schema.properties ?? {};
const requiredFields: string[] = schema.required ?? [];
@@ -30,13 +109,27 @@ export function extractFields(schemaName: string, schema: any, schemas: Record):
if (!prop["x-fk"]) continue;
const fkResource = prop["x-fk"].resource as string;
- const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource);
- const targetSchemaName = targetEntry ? targetEntry[0] : fkResource;
-
const prefetch = prop["x-fk"].prefetch ?? false;
- console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`);
rels.push({
fieldName: name,
@@ -23,10 +19,9 @@ export function extractRelationships(schema: any, schemas: Record):
resource: fkResource,
prefetch,
},
- targetSchemaName,
+ targetSchemaName: fkResource,
});
}
- console.log(`[FK] total relationships extracted: ${rels.length}`);
return rels;
}
diff --git a/react-openapi/src/transformers/resource-config.ts b/react-openapi/src/transformers/resource-config.ts
index b17f98c..d5d3f34 100644
--- a/react-openapi/src/transformers/resource-config.ts
+++ b/react-openapi/src/transformers/resource-config.ts
@@ -1,5 +1,5 @@
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
-import { extractFields } from "./field-config";
+import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
import { extractRelationships } from "./relationship-config";
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
@@ -47,62 +47,147 @@ const SSE_RECEIVED_FIELD: FieldConfig = {
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[] {
+ clearValidationErrors();
const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {};
const configs: ResourceConfig[] = [];
+ const nameMap = new Map();
- for (const [schemaName, schema] of Object.entries(schemas)) {
- if (!schema || typeof schema !== "object") continue;
+ const sortedPaths = Object.keys(paths).sort(
+ (a, b) => getSegments(a).length - getSegments(b).length
+ );
- const resourceName = schema["x-resource"];
- if (!resourceName || typeof resourceName !== "string") continue;
+ for (const path of sortedPaths) {
+ 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}`;
- const itemPath = `${resourcePath}/{id}`;
- const collectionPathObj = paths[resourcePath];
- const itemPathObj = paths[itemPath];
+ if (isItemPath) {
+ const parentName = segments[segments.length - 2];
+ const parent = nameMap.get(parentName);
+ 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);
- const relationships = extractRelationships(schema, schemas);
- const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
+ if (paramIdx >= 0) {
+ const resourceName = lastSeg;
+ 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 = {
name: resourceName,
- schemaName,
+ schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName),
- path: resourcePath,
- primaryKey: schema["x-primary-key"],
- displayFormat: schema["x-display-format"],
- listColumns: schema["x-list-columns"],
- fields,
- orderedFields: sortFields(fields),
- operations: {
- list: hasOperation(collectionPathObj, "get"),
- get: hasOperation(itemPathObj, "get"),
- create: hasOperation(collectionPathObj, "post"),
- update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"),
- delete: hasOperation(itemPathObj, "delete"),
- },
- updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
- pagination: detectPagination(collectionPathObj),
+ path,
+ primaryKey: schema?.["x-primary-key"] ?? "id",
+ 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: hasOperation(pathObj, "post"), update: false, delete: false },
+ updateMethod: "put",
+ pagination: hasSSE ? null : detectPagination(pathObj),
relationships,
streaming: hasSSE || undefined,
};
+ resource.orderedFields = sortFields(resource.fields);
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.primaryKey = "_received_at";
}
+ nameMap.set(resourceName, resource);
configs.push(resource);
}
+ const errors = getValidationErrors();
+ if (errors.length > 0) {
+ throw new Error(errors.join("\n"));
+ }
+
return configs;
-}
\ No newline at end of file
+}
diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts
index a5a90f0..f405ffc 100644
--- a/react-openapi/src/types.ts
+++ b/react-openapi/src/types.ts
@@ -50,6 +50,19 @@ export interface ResourceConfig {
} | null;
relationships: ResourceRelationship[];
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 {
@@ -71,11 +84,9 @@ export interface FieldConfig {
refSchema?: string;
inlineDisplayFormat?: string;
isArray: boolean;
-}
-
-export interface FKFieldConfig {
- resource: string;
- prefetch: boolean;
+ oneOfOptions?: OneOfOption[];
+ discriminatorProperty?: string;
+ autocomplete?: "text" | "token" | "email" | "phone";
}
export interface OpenApiSpec {
diff --git a/react-openapi/src/utils/filter-utils.ts b/react-openapi/src/utils/filter-utils.ts
new file mode 100644
index 0000000..0896dfa
--- /dev/null
+++ b/react-openapi/src/utils/filter-utils.ts
@@ -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();
+ 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();
+ 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();
+ 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();
+}