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>; list: (params?: Record) => Promise; get: (id: string | number, params?: Record) => Promise; create: (data: any) => Promise; update: (id: string | number, data: any) => Promise; patch?: (id: string | number, data: any) => Promise; remove: (id: string | number) => Promise; stream?: (handlers: StreamHandlers, pathParams?: Record) => 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 ?? "")} /> ); } // ── 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(() => { const extract = (items: any[]) => { const vals = new Set(); 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 = {}; 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 ( onChange(newVal ?? "")} renderInput={(params) => ( )} /> ); }; return RefFilter; } return ({ value, onChange, labelOverride }) => ( onChange(v ?? "")} /> ); } export function useResource(resourceName: string): UseResourceReturn { const { resources, schemas } = useAppContext(); const resource = resources.find((r) => r.name === resourceName); const [state, setState] = useState({ 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): Promise => { 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): Promise => { 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 => { 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 => { 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 => { 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 => { 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): 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> = {}; 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 }; }