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" }} + /> + ); })} + setInlineItem(null)} maxWidth="sm" fullWidth> + {field.label} + + {inlineItem && renderInlineItemFields(inlineItem)} + + + + + ); } 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" }} /> - ))} - + + setOpen(false)} maxWidth="sm" fullWidth> + {field.label} + + + {subFields.map((sf) => ( + + + + {sf.label} + + {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])} + + + ))} + + + + + + + ); } 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 ( - ); } 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(); +}