From e8faafae7d14940fba23c17ab6bba4fb5356609a Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 15:45:23 +0530 Subject: [PATCH] feat(reports): render generate-report form from OpenAPI spec Replace hand-written GenerateReportPanel inputs with a generic loop over extractFields("ReportQuery") + FormFieldRenderer; submit via useResource("reports").create with server-side validation only. react-openapi additions (all additive): - lift items.enum into FieldConfig.enumValues for array-of-enum props - extract schema `default` into FieldConfig.defaultValue - new MultiEnumField renderer dispatched for isArray && enumValues - FKFieldConfig.value: bind option values to a target property instead of the primary key (account names); sanitize-payload skips resolveFk when set - new useFkFieldOptions hook encapsulating FK option loading/prefetch - export extractFields, useFkFieldOptions, MultiEnumField Drop now-unused granularityOptions/groupDimOptions helpers. --- react-openapi/index.ts | 4 + .../components/fields/FormFieldRenderer.tsx | 12 + react-openapi/src/components/fields/index.ts | 1 + .../fields/renderers/MultiEnumField.tsx | 59 ++++ react-openapi/src/hooks/useFkFieldOptions.ts | 82 +++++ .../src/transformers/field-config.ts | 5 +- react-openapi/src/types.ts | 4 + react-openapi/src/utils/sanitize-payload.ts | 4 +- src/Reports/GenerateReportPanel.tsx | 291 +++++------------- src/Reports/types.ts | 8 - 10 files changed, 240 insertions(+), 230 deletions(-) create mode 100644 react-openapi/src/components/fields/renderers/MultiEnumField.tsx create mode 100644 react-openapi/src/hooks/useFkFieldOptions.ts diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 963d1f2..0dd032d 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -5,6 +5,10 @@ export { useAppContext } from "./src/context/AppContext"; export { useResource } from "./src/context/useResource"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; +export { MultiEnumField } from "./src/components/fields/renderers/MultiEnumField"; +export { extractFields } from "./src/transformers/field-config"; +export { useFkFieldOptions } from "./src/hooks/useFkFieldOptions"; +export type { FkOption } from "./src/hooks/useFkFieldOptions"; export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField"; export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField"; export { SseStreamView } from "./src/components/SseStreamView"; diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx index 4b21f66..eb6a896 100644 --- a/react-openapi/src/components/fields/FormFieldRenderer.tsx +++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx @@ -6,6 +6,7 @@ import { NumberField } from "./renderers/NumberField"; import { DateField } from "./renderers/DateField"; import { BooleanField } from "./renderers/BooleanField"; import { EnumField } from "./renderers/EnumField"; +import { MultiEnumField } from "./renderers/MultiEnumField"; import { FkSelectField } from "./renderers/FkSelectField"; import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; import { FileUploadField } from "./renderers/FileUploadField"; @@ -81,6 +82,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk ); } + if (field.isArray && field.enumValues) { + return ( + + ); + } + if (field.enumValues) { return ( void; + error?: string; +} + +export function MultiEnumField({ field, value, onChange, error }: Props) { + const selected: string[] = Array.isArray(value) ? value : []; + + return ( + onChange(newVal)} + renderOption={(props, option, { selected: isSelected }) => { + const { key, ...rest } = props as any; + return ( +
  • + {isSelected ? ( + + ) : ( + + )} + {option} +
  • + ); + }} + renderTags={(tagValue, getTagProps) => + tagValue.map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + sx={{ + "& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 }, + }} + disabled={field.readOnly} + /> + ); +} diff --git a/react-openapi/src/hooks/useFkFieldOptions.ts b/react-openapi/src/hooks/useFkFieldOptions.ts new file mode 100644 index 0000000..47497a7 --- /dev/null +++ b/react-openapi/src/hooks/useFkFieldOptions.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from "react"; +import type { FieldConfig, FKFieldConfig } from "../types"; +import { useAppContext } from "../context/AppContext"; +import { getApi } from "./useApi"; + +export interface FkOption { + value: any; + label: string; +} + +function applyFormat(obj: any, format: string): string { + if (!obj || typeof obj !== "object") return String(obj ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); +} + +/** + * Loads select options for FK-annotated fields of a field set. + * Option values bind to `fk.value` when set (e.g. account names), + * otherwise to the target resource's primary key. + */ +export function useFkFieldOptions(fields: FieldConfig[]) { + const { resources } = useAppContext(); + const [fkOptions, setFkOptions] = useState>({}); + const [fkLoading, setFkLoading] = useState>({}); + + const loadFkOptions = useCallback( + async (fieldName: string, fk: FKFieldConfig) => { + setFkLoading((prev) => ({ ...prev, [fieldName]: true })); + try { + const targetRes = resources.find((r) => r.name === fk.resource); + if (!targetRes) return; + + 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) { + if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) { + throw new Error(`Expected paginated response from ${targetRes.path}`); + } + items = res.data.items; + } else { + if (!Array.isArray(res.data)) { + throw new Error(`Expected array response from ${targetRes.path}`); + } + items = res.data; + } + + const opts: FkOption[] = items.map((item: any) => ({ + value: item[fk.value ?? targetRes.primaryKey], + label: applyFormat(item, targetRes.displayFormat), + })); + setFkOptions((prev) => ({ ...prev, [fieldName]: opts })); + } catch { + // leave options empty; the field renders without suggestions + } finally { + setFkLoading((prev) => ({ ...prev, [fieldName]: false })); + } + }, + [resources] + ); + + useEffect(() => { + fields.forEach((f) => { + if (f.fk?.prefetch) loadFkOptions(f.name, f.fk); + }); + }, [fields, loadFkOptions]); + + const loadFkOnOpen = useCallback( + (fieldName: string) => { + const field = fields.find((f) => f.name === fieldName); + if (field?.fk && !field.fk.prefetch && !fkOptions[fieldName]) { + loadFkOptions(fieldName, field.fk); + } + }, + [fields, fkOptions, loadFkOptions] + ); + + return { fkOptions, fkLoading, loadFkOnOpen }; +} diff --git a/react-openapi/src/transformers/field-config.ts b/react-openapi/src/transformers/field-config.ts index 87a0e14..cbdb793 100644 --- a/react-openapi/src/transformers/field-config.ts +++ b/react-openapi/src/transformers/field-config.ts @@ -136,7 +136,10 @@ export function extractFields(schemaName: string, schema: any, schemas: Record void; } -const ALL_GRANULARITIES = ["weekly", "monthly", "quarterly"]; - export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { - const { schemas, resources } = useAppContext(); - const { create } = useResource("reports"); - const { list: listAccounts } = useResource("accounts"); + const { schemas } = useAppContext(); + const { create, loading: submitting } = useResource("reports"); const { showToast } = useToast(); - const [name, setName] = useState(""); - const [granularities, setGranularities] = useState(ALL_GRANULARITIES); - const [groupDims, setGroupDims] = useState(["payee", "tag"]); - const [flow, setFlow] = useState("both"); - const [accounts, setAccounts] = useState([]); - const [ignoreSelf, setIgnoreSelf] = useState(true); - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [minAmount, setMinAmount] = useState(""); - const [maxAmount, setMaxAmount] = useState(""); - const [submitting, setSubmitting] = useState(false); + const [formData, setFormData] = useState>({}); const [error, setError] = useState(null); - const [accountOptions, setAccountOptions] = useState([]); - const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({}); - const granularityChoices = useMemo(() => { - const enums = granularityOptions(schemas); - return enums.length ? enums : ALL_GRANULARITIES; + const fields: FieldConfig[] = useMemo(() => { + const schema = schemas?.ReportQuery; + if (!schema) return []; + return extractFields("ReportQuery", schema, schemas).sort( + (a, b) => a.order - b.order || a.name.localeCompare(b.name) + ); }, [schemas]); - const dimChoices = useMemo(() => groupDimOptions(schemas), [schemas]); - const accountsRes = resources.find((r) => r.name === "accounts"); - const accountsFormat = accountsRes?.displayFormat ?? "{name}"; + + const { fkOptions, fkLoading, loadFkOnOpen } = useFkFieldOptions(fields); useEffect(() => { - let mounted = true; - listAccounts({ limit: 0 }).then((res) => { - if (!mounted) return; - const names = (res.items ?? []) - .map((it: any) => applyDisplayFormat(it, accountsFormat)) - .filter((n: string) => n); - setAccountOptions([...new Set(names)].sort((a, b) => a.localeCompare(b))); + setFormData((prev) => { + const next = { ...prev }; + let changed = false; + for (const f of fields) { + if (next[f.name] === undefined && f.defaultValue !== undefined) { + next[f.name] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue; + changed = true; + } + } + return changed ? next : prev; }); - return () => { - mounted = false; - }; - }, [listAccounts, accountsFormat]); + }, [fields]); - const toggle = (list: string[], value: string, setter: (v: string[]) => void) => - setter(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]); - - const validateDates = (): boolean => { - const errs: { start?: string; end?: string } = {}; - if (startDate && !isDdmmyyyy(startDate)) errs.start = "Use DD-MM-YYYY"; - if (endDate && !isDdmmyyyy(endDate)) errs.end = "Use DD-MM-YYYY"; - setDateErrors(errs); - return Object.keys(errs).length === 0; - }; - - const parseAmount = (value: string): number | null => { - const trimmed = value.trim(); - if (!trimmed) return null; - const n = Number(trimmed); - return Number.isFinite(n) ? n : null; - }; - - const handleSubmit = async () => { - if (!validateDates()) return; + const handleChange = useCallback((name: string, value: any) => { + setFormData((prev) => ({ ...prev, [name]: value })); setError(null); - const min = parseAmount(minAmount); - const max = parseAmount(maxAmount); - if (minAmount.trim() && min === null) { - setError("Min amount must be a number"); - return; + }, []); + + const buildPayload = (): Record => { + const payload: Record = {}; + for (const f of fields) { + const v = formData[f.name]; + if (v === undefined || v === null || v === "") continue; + if (Array.isArray(v) && v.length === 0) continue; + payload[f.name] = v; } - if (maxAmount.trim() && max === null) { - setError("Max amount must be a number"); - return; - } - if (min !== null && max !== null && min > max) { - setError("Min amount cannot exceed max amount"); - return; - } - const payload: Record = { - name: name.trim(), - granularities, - group_dims: groupDims, - flow, - ignore_self: ignoreSelf, - }; - if (accounts.length) payload.accounts = accounts; - if (startDate.trim()) payload.start_date = startDate.trim(); - if (endDate.trim()) payload.end_date = endDate.trim(); - if (min !== null) payload.min_amount = min; - if (max !== null) payload.max_amount = max; - setSubmitting(true); + return payload; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); try { - const created = await create(payload); + const created = await create(buildPayload()); showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot"); onGenerated(created); } catch (e: any) { setError(apiErrorMessage(e)); - } finally { - setSubmitting(false); } }; @@ -145,131 +99,28 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { )} - - setName(e.target.value)} - sx={{ maxWidth: 420 }} - /> - - - - Granularities - - - {granularityChoices.map((g) => ( - toggle(granularities, g, setGranularities)} + + + {fields.map((field) => ( + + handleChange(field.name, val)} + fkOptions={fkOptions[field.name]} + fkLoading={fkLoading[field.name]} + onFkOpen={loadFkOnOpen} /> - ))} - + + ))} + + + + - - - - Group dimensions - - - {dimChoices.map((d) => ( - toggle(groupDims, d, setGroupDims)} - /> - ))} - - - - - - Flow - - - setIgnoreSelf(e.target.checked)} />} - label="Ignore self-transfers" - sx={{ mt: 0.25 }} - /> - - - setAccounts(newVal)} - renderInput={(params) => ( - - )} - sx={{ maxWidth: 420 }} - /> - - - setStartDate(e.target.value)} - error={Boolean(dateErrors.start)} - helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} - sx={{ width: 200 }} - /> - setEndDate(e.target.value)} - error={Boolean(dateErrors.end)} - helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} - sx={{ width: 200 }} - /> - - - - setMinAmount(e.target.value)} - sx={{ width: 200 }} - /> - setMaxAmount(e.target.value)} - sx={{ width: 200 }} - /> - - - - - ); -} \ No newline at end of file +} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 9696dbd..c9a990d 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -51,14 +51,6 @@ export function apiErrorMessage(e: any): string { return e?.message ?? "Request failed"; } -export function granularityOptions(schemas: Record): string[] { - return schemas?.ReportQuery?.properties?.granularities?.items?.enum ?? []; -} - -export function groupDimOptions(schemas: Record): string[] { - return schemas?.ReportQuery?.properties?.group_dims?.items?.enum ?? ["payee", "tag"]; -} - export const FLOW_OPTIONS = ["both", "inflows", "outflows"]; export function metricLabels(schemas: Record): MetricLabel[] {