import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Autocomplete, TextField } from "@mui/material"; import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types"; import { useAppContext } from "./AppContext"; import { getApi } from "../hooks/useApi"; 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"; 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>; list: (params?: Record) => Promise; get: (id: string | number) => Promise; create: (data: any) => Promise; update: (id: string | number, data: any) => Promise; remove: (id: string | number) => Promise; stream?: (handlers: StreamHandlers) => StreamSubscription; loading: boolean; error: string | null; } const _fkOptionsCache = new Map(); const _stringOptionsCache = new Map(); const _sseEventCache = new Map(); 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(); const _sseListeners = new Map 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(); 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 { if (field.type === "boolean") { return ({ value, onChange, labelOverride }) => ( onChange(v ?? "")} nullable /> ); } if (field.fk) { const FkFilter: React.FC = ({ 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(); 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 = {}; 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 ( onChange(v.join(","))} fkOptions={options} /> ); } return ( onChange(v ?? "")} fkOptions={options} /> ); }; return FkFilter; } if (field.enumValues) { const EnumFilter: React.FC = ({ 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 ( onChange(v ?? "")} /> ); }; return EnumFilter; } if (field.type === "integer" || field.type === "number") { return ({ value, onChange, labelOverride }) => ( onChange(v === "" ? "" : String(v))} /> ); } if (field.format === "date" || field.format === "date-time") { return ({ value, onChange, labelOverride }) => ( onChange(v ?? "")} /> ); } if ( !field.fk && !field.enumValues && field.type !== "boolean" && field.type !== "integer" && field.type !== "number" && field.format !== "date" && field.format !== "date-time" && !field.refSchema ) { const StringAutocompleteFilter: 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) { setOptions(extractDataOptions(data, field.name)); 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 = res.data.items ?? []; } else { items = Array.isArray(res.data) ? res.data : []; } const values = [...new Set(items.map((r: any) => String(r[field.name] ?? "")).filter(Boolean))].sort(); _stringOptionsCache.set(cacheKey, values); setOptions(values); fetched.current = true; } catch { fetched.current = true; } })(); } } }, [data]); return ( onChange(newVal ?? "")} renderInput={(params) => ( )} /> ); }; return StringAutocompleteFilter; } return ({ value, onChange, labelOverride }) => ( onChange(v ?? "")} /> ); } export function useResource(resourceName: string): UseResourceReturn { const { resources } = useAppContext(); const resource = resources.find((r) => r.name === resourceName); const [state, setState] = useState({ loading: false, error: null }); if (!resource) { const noop = async () => { throw new Error(`Resource "${resourceName}" not found yet`); }; return { resource: null as unknown as ResourceConfig, components: {}, list: noop, get: noop, create: noop, update: noop, remove: noop, loading: false, error: null, }; } 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): Promise => { setLoading(true); setError(null); try { const api = getApi(); const res = await api.get(resource.path, { params }); const data = res.data; if (resource.pagination) { if (!data || typeof data !== "object" || !Array.isArray(data.items)) { throw new Error(`Expected paginated response { total, items } from ${resource.path}`); } return { items: data.items, total: data.total ?? data.items.length }; } if (!Array.isArray(data)) { throw new Error(`Expected array response from ${resource.path}`); } return { items: data }; } catch (e: any) { const msg = parseError(e); setError(msg); return { items: [] }; } finally { setLoading(false); } }, [resource.path, resource.pagination, setLoading, setError] ); const get = useCallback( async (id: string | number): Promise => { setLoading(true); setError(null); try { const api = getApi(); const res = await api.get(`${resource.path}/${id}`); return res.data; } catch (e: any) { setError(parseError(e)); throw e; } finally { setLoading(false); } }, [resource.path, setLoading, setError] ); const create = useCallback( async (data: any): Promise => { setLoading(true); setError(null); try { const api = getApi(); const res = await api.post(resource.path, data); return res.data; } catch (e: any) { setError(parseError(e)); throw e; } finally { setLoading(false); } }, [resource.path, setLoading, setError] ); const update = useCallback( async (id: string | number, data: any): Promise => { setLoading(true); setError(null); try { const api = getApi(); const method = resource.updateMethod ?? "put"; const res = await (method === "patch" ? api.patch : api.put)(`${resource.path}/${id}`, data); return res.data; } catch (e: any) { setError(parseError(e)); throw e; } finally { setLoading(false); } }, [resource.path, resource.updateMethod, setLoading, setError] ); const remove = useCallback( async (id: string | number): Promise => { setLoading(true); setError(null); try { const api = getApi(); await api.delete(`${resource.path}/${id}`); } catch (e: any) { setError(parseError(e)); throw e; } finally { setLoading(false); } }, [resource.path, setLoading, setError] ); const stream = useCallback( (handlers: StreamHandlers): StreamSubscription => { if (!resource.streaming) { throw new Error(`Resource "${resourceName}" does not support streaming`); } const api = getApi(); const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, ""); const url = baseUrl + resource.path; 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() }; }, [resource.path, resource.streaming, resourceName] ); const components = useMemo( () => { const map: Record> = {}; for (const field of resource.fields) { map[field.name] = buildFilterComponent(field, resourceName); } return map; }, [resource.fields, resourceName] ); return { resource, components, list, get, create, update, remove, stream: resource.streaming ? stream : undefined, loading: state.loading, error: state.error }; }