new react-openapi
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<any>;
|
||||
update: (id: string | number, data: any) => Promise<any>;
|
||||
remove: (id: string | number) => Promise<void>;
|
||||
stream?: (handlers: StreamHandlers) => StreamSubscription;
|
||||
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => 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<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
// ── text filter (freeSolo, no dropdown) ──────────────────────
|
||||
function buildTextFilter() {
|
||||
const TextFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
|
||||
return (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
size="small"
|
||||
options={[]}
|
||||
value={value || null}
|
||||
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||
)}
|
||||
sx={{
|
||||
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return TextFilter;
|
||||
}
|
||||
|
||||
// ── token filter (multi-select, suggestions from current page) ─
|
||||
function buildTokenFilter() {
|
||||
const TokenFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
|
||||
const pageTokens = useMemo(() => data ? extractTokens(data, field.name) : [], [data]);
|
||||
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||
const sortedOptions = useMemo(() => {
|
||||
const sel = new Set(selected);
|
||||
const picked: string[] = [];
|
||||
const rest: string[] = [];
|
||||
for (const t of pageTokens) {
|
||||
(sel.has(t) ? picked : rest).push(t);
|
||||
}
|
||||
return [...picked, ...rest];
|
||||
}, [pageTokens, selected]);
|
||||
const displayOptions = open ? frozenOpts : sortedOptions;
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||
}}
|
||||
open={open}
|
||||
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
|
||||
onClose={(_, reason) => {
|
||||
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, v, reason) => {
|
||||
if (reason !== "reset") setInputValue(v);
|
||||
}}
|
||||
options={displayOptions}
|
||||
value={selected}
|
||||
onChange={(_, newVal) => onChange(newVal.join(","))}
|
||||
filterOptions={(opts, { inputValue }) => {
|
||||
if (!inputValue) return [];
|
||||
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
|
||||
}}
|
||||
renderOption={(props, option, { selected: isSelected }) => {
|
||||
const { key, ...rest } = props as any;
|
||||
return (
|
||||
<li key={key} {...rest}>
|
||||
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
|
||||
{option}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderTags={(tagValue, getTagProps) => {
|
||||
const maxChips = 1;
|
||||
return (
|
||||
<>
|
||||
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
|
||||
})}
|
||||
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return TokenFilter;
|
||||
}
|
||||
|
||||
// ── email filter (two-step local→domain, no server fetch) ───
|
||||
function buildEmailFilter() {
|
||||
const COMMON_DOMAINS = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "protonmail.com", "aol.com", "mail.com", "zoho.com", "yandex.com"];
|
||||
const EmailFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
|
||||
const [step, setStep] = useState<"local" | "domain">("local");
|
||||
const [pendingLocal, setPendingLocal] = useState<string | null>(null);
|
||||
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||
|
||||
const handleChange = (_: any, newVal: string[], reason: string, details: any) => {
|
||||
if (reason === "removeOption") {
|
||||
if (step === "domain") { setStep("local"); setPendingLocal(null); }
|
||||
onChange(newVal.join(","));
|
||||
return;
|
||||
}
|
||||
if (reason !== "selectOption" || !details?.option) return;
|
||||
if (step === "local") {
|
||||
setPendingLocal(String(details.option));
|
||||
setStep("domain");
|
||||
} else if (step === "domain" && pendingLocal) {
|
||||
const email = `${pendingLocal}@${String(details.option)}`;
|
||||
onChange([...selected, email].join(","));
|
||||
setStep("local"); setPendingLocal(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
disableCloseOnSelect
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||
}}
|
||||
open={step === "domain"}
|
||||
onClose={() => { if (step === "domain") { setStep("local"); setPendingLocal(null); } }}
|
||||
options={step === "domain" ? COMMON_DOMAINS : []}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
inputValue={pendingLocal ? `${pendingLocal}@` : undefined}
|
||||
renderTags={(tagValue, getTagProps) => {
|
||||
const maxChips = 1;
|
||||
return (
|
||||
<>
|
||||
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip key={key} {...tagProps} label={tag.length > 18 ? `${tag.slice(0, 16)}..` : tag} size="small" />;
|
||||
})}
|
||||
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={labelOverride ?? field.label}
|
||||
placeholder={step === "domain" && pendingLocal ? "Select email domain..." : undefined}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return EmailFilter;
|
||||
}
|
||||
|
||||
// ── phone filter (multi-select, digits only, suggestions from current page) ─
|
||||
function buildPhoneFilter() {
|
||||
const PhoneFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
|
||||
const pageTokens = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const tokens = new Set<string>();
|
||||
for (const row of data) {
|
||||
const val = row[field.name];
|
||||
if (val != null && val !== "") {
|
||||
const digits = stripNonDigits(String(val));
|
||||
if (digits) tokens.add(digits);
|
||||
}
|
||||
}
|
||||
return [...tokens].sort();
|
||||
}, [data]);
|
||||
const selected = value ? value.split(",").filter(Boolean) : [];
|
||||
const sortedOptions = useMemo(() => {
|
||||
const sel = new Set(selected);
|
||||
const picked: string[] = [];
|
||||
const rest: string[] = [];
|
||||
for (const t of pageTokens) {
|
||||
(sel.has(t) ? picked : rest).push(t);
|
||||
}
|
||||
return [...picked, ...rest];
|
||||
}, [pageTokens, selected]);
|
||||
const displayOptions = open ? frozenOpts : sortedOptions;
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
|
||||
}}
|
||||
open={open}
|
||||
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
|
||||
onClose={(_, reason) => {
|
||||
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, v, reason) => {
|
||||
if (reason !== "reset") setInputValue(v);
|
||||
}}
|
||||
options={displayOptions}
|
||||
value={selected}
|
||||
onChange={(_, newVal) => onChange(newVal.map((v) => stripNonDigits(v)).filter(Boolean).join(","))}
|
||||
filterOptions={(opts, { inputValue }) => {
|
||||
if (!inputValue) return [];
|
||||
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
|
||||
}}
|
||||
renderOption={(props, option, { selected: isSelected }) => {
|
||||
const { key, ...rest } = props as any;
|
||||
return (
|
||||
<li key={key} {...rest}>
|
||||
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
|
||||
{option}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderTags={(tagValue, getTagProps) => {
|
||||
const maxChips = 1;
|
||||
return (
|
||||
<>
|
||||
{tagValue.slice(0, maxChips).map((tag, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
|
||||
})}
|
||||
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return PhoneFilter;
|
||||
}
|
||||
|
||||
// ── routing ──────────────────────────────────────────────────
|
||||
if (field.autocomplete) {
|
||||
switch (field.autocomplete) {
|
||||
case "text": return buildTextFilter();
|
||||
case "token": return buildTokenFilter();
|
||||
case "email": return buildEmailFilter();
|
||||
case "phone": return buildPhoneFilter();
|
||||
}
|
||||
}
|
||||
|
||||
if (field.refSchema && field.inlineDisplayFormat) {
|
||||
const RefFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
||||
const { resources, config } = useAppContext();
|
||||
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === "client" && data) {
|
||||
const extract = (items: any[]) => {
|
||||
const vals = new Set<string>();
|
||||
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<string, any> = {};
|
||||
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<string, any> = {};
|
||||
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) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={labelOverride ?? field.label}
|
||||
size="small"
|
||||
/>
|
||||
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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<string, string | number>): 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?.();
|
||||
|
||||
Reference in New Issue
Block a user