## Summary
Align frontend with updated OpenAPI schema (rename `format` → `bank`, add `pipeline`) and introduce a dedicated `PATCH` operation for partial updates in react-openapi.
## Changes
### react-openapi: new `patch` operation support
- Add `patch` to `ResourceConfig.operations` type
- Separate PUT/PATCH detection in resource-config transformer
- `update()` now always calls PUT; new `patch()` method returned conditionally when spec defines PATCH
- Admin form still uses PUT for full replacements
### Fetch Request custom pages: schema alignment
- Rename `format` → `bank` in create form fields and TypeScript models (`FileSource`, `EmailSource`)
- Add `pipeline` field to create form and `FetchRequestCreate` model
- Export `PipelineType` from barrel index
### Fetch Request retry: use PATCH
- Retry handler now calls `patch(id, { status: "pending" })` instead of the generic `update()`
- Removes `useMutation` wrapper; uses simple local `retrying` state for loading
### Fix Account display in detail page
- Resolve FK displayFormat target in detail field rendering (was always using fetch-request format)
- Fix page title to use `applyDisplayFormat` with resolved FK objects instead of non-existent `account_name`
- Move `useMemo` before early returns to fix hooks ordering violation crash
## Testing
- Custom fetch request create form includes `bank` (select) and `pipeline` (select) fields
- Retry sends `PATCH` instead of `PUT` — no more 500 errors
- Detail page shows proper account name instead of `—`
Reviewed-on: #13
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
825 lines
29 KiB
TypeScript
825 lines
29 KiB
TypeScript
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
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";
|
|
import { sanitizePayload } from "../utils/sanitize-payload";
|
|
import { StringField } from "../components/fields/renderers/StringField";
|
|
import { NumberField } from "../components/fields/renderers/NumberField";
|
|
import { DateField } from "../components/fields/renderers/DateField";
|
|
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) {
|
|
const data = e.response.data;
|
|
if (Array.isArray(data)) {
|
|
return data.map((err: any) => err.msg ?? String(err)).join("; ");
|
|
}
|
|
if (typeof data.detail === "string") {
|
|
return data.detail;
|
|
}
|
|
}
|
|
return e.message ?? "An error occurred";
|
|
}
|
|
|
|
interface ResourceState {
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export interface FilterComponentProps {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
data?: any[];
|
|
labelOverride?: string;
|
|
}
|
|
|
|
interface StreamHandlers {
|
|
onEvent: (data: any) => void;
|
|
onError?: (evt: Event) => void;
|
|
onOpen?: () => void;
|
|
}
|
|
|
|
interface StreamSubscription {
|
|
close: () => void;
|
|
}
|
|
|
|
interface UseResourceReturn {
|
|
resource: ResourceConfig;
|
|
components: Record<string, React.FC<FilterComponentProps>>;
|
|
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
|
|
get: (id: string | number, params?: Record<string, any>) => Promise<any>;
|
|
create: (data: any) => Promise<any>;
|
|
update: (id: string | number, data: any) => Promise<any>;
|
|
patch?: (id: string | number, data: any) => Promise<any>;
|
|
remove: (id: string | number) => Promise<void>;
|
|
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
const _fkOptionsCache = new Map<string, { value: any; label: string }[]>();
|
|
const _stringOptionsCache = new Map<string, string[]>();
|
|
const _sseEventCache = new Map<string, any[]>();
|
|
let _sseSeq = 0;
|
|
|
|
export function readSseCache(resourceName: string): any[] {
|
|
return _sseEventCache.get(resourceName) ?? [];
|
|
}
|
|
|
|
export function appendSseCache(resourceName: string, event: any): any[] {
|
|
const events = _sseEventCache.get(resourceName) ?? [];
|
|
events.push(event);
|
|
if (events.length > 100) events.splice(0, events.length - 100);
|
|
_sseEventCache.set(resourceName, events);
|
|
return events;
|
|
}
|
|
|
|
export function clearSseCache(resourceName: string): void {
|
|
_sseEventCache.delete(resourceName);
|
|
}
|
|
|
|
export function nextSseSeq(): number {
|
|
return ++_sseSeq;
|
|
}
|
|
|
|
const _sseConnection = new Map<string, boolean>();
|
|
const _sseListeners = new Map<string, Set<() => void>>();
|
|
|
|
export function setSseConnected(resourceName: string, connected: boolean): void {
|
|
if (_sseConnection.get(resourceName) === connected) return;
|
|
_sseConnection.set(resourceName, connected);
|
|
_sseListeners.get(resourceName)?.forEach((cb) => cb());
|
|
}
|
|
|
|
export function getSseConnected(resourceName: string): boolean {
|
|
return _sseConnection.get(resourceName) ?? false;
|
|
}
|
|
|
|
export function useSseConnected(resourceName: string): boolean {
|
|
const [connected, setConnected] = useState(() => getSseConnected(resourceName));
|
|
|
|
useEffect(() => {
|
|
const cb = () => setConnected(getSseConnected(resourceName));
|
|
const listeners = _sseListeners.get(resourceName) ?? new Set();
|
|
listeners.add(cb);
|
|
_sseListeners.set(resourceName, listeners);
|
|
return () => {
|
|
listeners.delete(cb);
|
|
if (listeners.size === 0) _sseListeners.delete(resourceName);
|
|
};
|
|
}, [resourceName]);
|
|
|
|
return connected;
|
|
}
|
|
|
|
function extractDataOptions(data: any[], fieldName: string): string[] {
|
|
const values = new Set<string>();
|
|
for (const row of data) {
|
|
const v = row[fieldName];
|
|
if (v != null && v !== "") {
|
|
values.add(String(v));
|
|
}
|
|
}
|
|
return [...values].sort();
|
|
}
|
|
|
|
function applyDisplayFormat(obj: any, format: string): string {
|
|
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
|
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
|
}
|
|
|
|
function buildFilterComponent(field: FieldConfig, resourceName: string): React.FC<FilterComponentProps> {
|
|
if (field.type === "boolean") {
|
|
return ({ value, onChange, labelOverride }) => (
|
|
<BooleanField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
|
value={value}
|
|
onChange={(v) => onChange(v ?? "")}
|
|
nullable
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.fk) {
|
|
const FkFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
|
const { resources, config } = useAppContext();
|
|
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
|
|
const targetRes = resources.find((r) => r.name === field.fk!.resource);
|
|
const [options, setOptions] = useState<{ value: any; label: string }[]>([]);
|
|
const fetched = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (filterMode === "client" && data && targetRes) {
|
|
const seen = new Set<string>();
|
|
const opts: { value: any; label: string }[] = [];
|
|
for (const row of data) {
|
|
const items = Array.isArray(row[field.name]) ? row[field.name] : [row[field.name]];
|
|
for (const item of items) {
|
|
if (item == null) continue;
|
|
const label = applyDisplayFormat(item, targetRes.displayFormat);
|
|
if (!seen.has(label)) {
|
|
seen.add(label);
|
|
opts.push({ value: label, label });
|
|
}
|
|
}
|
|
}
|
|
opts.sort((a, b) => a.label.localeCompare(b.label));
|
|
setOptions(opts);
|
|
fetched.current = true;
|
|
} else if (filterMode === "server" && targetRes && !fetched.current) {
|
|
const cacheKey = targetRes.name;
|
|
if (_fkOptionsCache.has(cacheKey)) {
|
|
setOptions(_fkOptionsCache.get(cacheKey)!);
|
|
fetched.current = true;
|
|
} else {
|
|
(async () => {
|
|
try {
|
|
const api = getApi();
|
|
const params: Record<string, any> = {};
|
|
if (targetRes.pagination) params.limit = 0;
|
|
const res = await api.get(targetRes.path, { params });
|
|
let items: any[];
|
|
if (targetRes.pagination) {
|
|
items = res.data.items ?? [];
|
|
} else {
|
|
items = Array.isArray(res.data) ? res.data : [];
|
|
}
|
|
const opts = items.map((item: any) => {
|
|
const label = applyDisplayFormat(item, targetRes.displayFormat);
|
|
return { value: label, label };
|
|
});
|
|
_fkOptionsCache.set(cacheKey, opts);
|
|
setOptions(opts);
|
|
fetched.current = true;
|
|
} catch {
|
|
fetched.current = true;
|
|
}
|
|
})();
|
|
}
|
|
}
|
|
}, [filterMode, data, targetRes]);
|
|
|
|
if (field.isArray) {
|
|
const selected = value ? value.split(",").filter(Boolean) : [];
|
|
return (
|
|
<FkMultiSelectField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
|
value={selected}
|
|
onChange={(v: any[]) => onChange(v.join(","))}
|
|
fkOptions={options}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FkSelectField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
|
value={value}
|
|
onChange={(v: any) => onChange(v ?? "")}
|
|
fkOptions={options}
|
|
/>
|
|
);
|
|
};
|
|
return FkFilter;
|
|
}
|
|
|
|
if (field.enumValues) {
|
|
const EnumFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
|
|
const dataOptions = useMemo(() => {
|
|
if (!data) return [];
|
|
return extractDataOptions(data, field.name);
|
|
}, [data]);
|
|
const merged = useMemo(
|
|
() => [...new Set([...(field.enumValues ?? []), ...dataOptions])],
|
|
[dataOptions]
|
|
);
|
|
return (
|
|
<EnumField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label, enumValues: merged }}
|
|
value={value}
|
|
onChange={(v) => onChange(v ?? "")}
|
|
/>
|
|
);
|
|
};
|
|
return EnumFilter;
|
|
}
|
|
|
|
if (field.type === "integer" || field.type === "number") {
|
|
return ({ value, onChange, labelOverride }) => (
|
|
<NumberField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
|
value={value}
|
|
onChange={(v) => onChange(v === "" ? "" : String(v))}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.format === "date" || field.format === "date-time") {
|
|
return ({ value, onChange, labelOverride }) => (
|
|
<DateField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
|
value={value}
|
|
onChange={(v) => onChange(v ?? "")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── 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(() => {
|
|
const extract = (items: any[]) => {
|
|
const vals = new Set<string>();
|
|
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);
|
|
}
|
|
return [...vals].sort();
|
|
};
|
|
|
|
if (filterMode === "client" && data) {
|
|
setOptions(extract(data));
|
|
fetched.current = true;
|
|
} else if (filterMode === "server" && !fetched.current) {
|
|
(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]);
|
|
|
|
return (
|
|
<Autocomplete
|
|
freeSolo
|
|
size="small"
|
|
options={options}
|
|
value={value || null}
|
|
onInputChange={(_, newVal) => onChange(newVal ?? "")}
|
|
renderInput={(params) => (
|
|
<TextField {...params} label={labelOverride ?? field.label} size="small" />
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
return RefFilter;
|
|
}
|
|
|
|
return ({ value, onChange, labelOverride }) => (
|
|
<StringField
|
|
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
|
|
value={value}
|
|
onChange={(v) => onChange(v ?? "")}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function useResource(resourceName: string): UseResourceReturn {
|
|
const { resources, schemas } = useAppContext();
|
|
const resource = resources.find((r) => r.name === resourceName);
|
|
|
|
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
|
|
|
|
const rPath = resource?.path;
|
|
const rPagination = resource?.pagination;
|
|
const rPatch = resource ? (resource.operations.patch || undefined) : false;
|
|
const rStreaming = resource?.streaming;
|
|
const rFields = resource?.fields;
|
|
|
|
const setLoading = useCallback((loading: boolean) => {
|
|
setState((s) => ({ ...s, loading }));
|
|
}, []);
|
|
|
|
const setError = useCallback((error: string | null) => {
|
|
setState((s) => ({ ...s, error }));
|
|
}, []);
|
|
|
|
const list = useCallback(
|
|
async (params?: Record<string, any>): Promise<ParsedListResponse> => {
|
|
if (!rPath) return { items: [] };
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const api = getApi();
|
|
const res = await api.get(rPath, { params });
|
|
const data = res.data;
|
|
|
|
if (rPagination) {
|
|
if (Array.isArray(data)) {
|
|
return { items: data };
|
|
}
|
|
if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
|
|
throw new Error(`Expected paginated response { total, items } from ${rPath}`);
|
|
}
|
|
return { items: data.items, total: data.total ?? data.items.length };
|
|
}
|
|
|
|
if (!Array.isArray(data)) {
|
|
throw new Error(`Expected array response from ${rPath}`);
|
|
}
|
|
return { items: data };
|
|
} catch (e: any) {
|
|
const msg = parseError(e);
|
|
setError(msg);
|
|
return { items: [] };
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[rPath, rPagination, setLoading, setError]
|
|
);
|
|
|
|
const get = useCallback(
|
|
async (id: string | number, params?: Record<string, any>): Promise<any> => {
|
|
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const api = getApi();
|
|
const res = await api.get(`${rPath}/${id}`, { params });
|
|
return res.data;
|
|
} catch (e: any) {
|
|
setError(parseError(e));
|
|
throw e;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[rPath, setLoading, setError]
|
|
);
|
|
|
|
const resolveFk = useCallback(async (resourceName: string, id: any) => {
|
|
const targetRes = resources.find((r) => r.name === resourceName);
|
|
if (!targetRes) return id;
|
|
try {
|
|
const api = getApi();
|
|
const res = await api.get(`${targetRes.path}/${id}`);
|
|
return res.data;
|
|
} catch {
|
|
return id;
|
|
}
|
|
}, [resources]);
|
|
|
|
const create = useCallback(
|
|
async (data: any): Promise<any> => {
|
|
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const api = getApi();
|
|
const sanitized = rFields && schemas
|
|
? await sanitizePayload(data, rFields, schemas, resolveFk)
|
|
: data;
|
|
const res = await api.post(rPath, sanitized);
|
|
return res.data;
|
|
} catch (e: any) {
|
|
setError(parseError(e));
|
|
throw e;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[rPath, rFields, schemas, resolveFk, setLoading, setError]
|
|
);
|
|
|
|
const update = useCallback(
|
|
async (id: string | number, data: any): Promise<any> => {
|
|
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const api = getApi();
|
|
const sanitized = rFields && schemas
|
|
? await sanitizePayload(data, rFields, schemas, resolveFk)
|
|
: data;
|
|
const res = await api.put(`${rPath}/${id}`, sanitized);
|
|
return res.data;
|
|
} catch (e: any) {
|
|
setError(parseError(e));
|
|
throw e;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[rPath, rFields, schemas, resolveFk, setLoading, setError]
|
|
);
|
|
|
|
const _patch = useCallback(
|
|
async (id: string | number, data: any): Promise<any> => {
|
|
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const api = getApi();
|
|
const sanitized = rFields && schemas
|
|
? await sanitizePayload(data, rFields, schemas, resolveFk)
|
|
: data;
|
|
const res = await api.patch(`${rPath}/${id}`, sanitized);
|
|
return res.data;
|
|
} catch (e: any) {
|
|
setError(parseError(e));
|
|
throw e;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[rPath, rFields, schemas, resolveFk, setLoading, setError]
|
|
);
|
|
|
|
const remove = useCallback(
|
|
async (id: string | number): Promise<void> => {
|
|
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const api = getApi();
|
|
await api.delete(`${rPath}/${id}`);
|
|
} catch (e: any) {
|
|
setError(parseError(e));
|
|
throw e;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[rPath, setLoading, setError]
|
|
);
|
|
|
|
const stream = useCallback(
|
|
(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(/\/+$/, "");
|
|
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?.();
|
|
es.onmessage = (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
handlers.onEvent(data);
|
|
} catch {
|
|
// ignore malformed JSON payloads
|
|
}
|
|
};
|
|
es.onerror = (e) => {
|
|
handlers.onError?.(e);
|
|
};
|
|
|
|
return { close: () => es.close() };
|
|
},
|
|
[rPath, rStreaming, resourceName]
|
|
);
|
|
|
|
const components = useMemo(
|
|
() => {
|
|
const map: Record<string, React.FC<FilterComponentProps>> = {};
|
|
if (!rFields) return map;
|
|
for (const field of rFields) {
|
|
map[field.name] = buildFilterComponent(field, resourceName);
|
|
}
|
|
return map;
|
|
},
|
|
[rFields, resourceName]
|
|
);
|
|
|
|
if (!resource) {
|
|
return {
|
|
resource: null as unknown as ResourceConfig,
|
|
components,
|
|
list,
|
|
get,
|
|
create,
|
|
update,
|
|
patch: undefined,
|
|
remove,
|
|
stream: undefined,
|
|
loading: false,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
return { resource, components, list, get, create, update, patch: rPatch ? _patch : undefined, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
|
|
} |