khata compliant with new react-openapi

This commit is contained in:
2026-06-18 23:20:57 +05:30
parent cbac57dc36
commit 12e5f113b8
12 changed files with 103 additions and 113 deletions

View File

@@ -1,4 +1,11 @@
function getNested(obj: any, path: string): any {
return path.split(".").reduce((o, k) => o?.[k], obj);
}
export function applyDisplayFormat(item: any, format: string): string { export function applyDisplayFormat(item: any, format: string): string {
if (!item || typeof item !== "object") return String(item ?? ""); if (!item || typeof item !== "object") return String(item ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(item[key] ?? "")); return format.replace(/\{([\w.]+)\}/g, (_, key) => {
const val = getNested(item, key);
return val != null ? String(val) : "";
});
} }

View File

@@ -350,12 +350,24 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
export function useResource(resourceName: string): UseResourceReturn { export function useResource(resourceName: string): UseResourceReturn {
const { resources } = useAppContext(); const { resources } = useAppContext();
const resource = resources.find((r) => r.name === resourceName); const resource = resources.find((r) => r.name === resourceName);
if (!resource) {
throw new Error(`Resource "${resourceName}" not found`);
}
const [state, setState] = useState<ResourceState>({ loading: false, error: null }); const [state, setState] = useState<ResourceState>({ 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) => { const setLoading = useCallback((loading: boolean) => {
setState((s) => ({ ...s, loading })); setState((s) => ({ ...s, loading }));
}, []); }, []);
@@ -437,7 +449,8 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null); setError(null);
try { try {
const api = getApi(); const api = getApi();
const res = await api.put(`${resource.path}/${id}`, data); const method = resource.updateMethod ?? "put";
const res = await (method === "patch" ? api.patch : api.put)(`${resource.path}/${id}`, data);
return res.data; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -446,7 +459,7 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); setLoading(false);
} }
}, },
[resource.path, setLoading, setError] [resource.path, resource.updateMethod, setLoading, setError]
); );
const remove = useCallback( const remove = useCallback(

View File

@@ -120,10 +120,10 @@ export function validateSpec(spec: OpenApiSpec): ValidationMessage[] {
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` }); messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` });
} }
if (!itemPath?.put) { if (!itemPath?.put) {
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no PUT endpoint — update not possible` }); messages.push({ type: "info", message: `"${resourcePath}/{id}" has no PUT endpoint — update not available` });
} }
if (!itemPath?.delete) { if (!itemPath?.delete) {
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not possible` }); messages.push({ type: "info", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not available` });
} }
} }
} }

View File

@@ -81,9 +81,10 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
list: hasOperation(collectionPathObj, "get"), list: hasOperation(collectionPathObj, "get"),
get: hasOperation(itemPathObj, "get"), get: hasOperation(itemPathObj, "get"),
create: hasOperation(collectionPathObj, "post"), create: hasOperation(collectionPathObj, "post"),
update: hasOperation(itemPathObj, "put"), update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"),
delete: hasOperation(itemPathObj, "delete"), delete: hasOperation(itemPathObj, "delete"),
}, },
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
pagination: detectPagination(collectionPathObj), pagination: detectPagination(collectionPathObj),
relationships, relationships,
streaming: hasSSE || undefined, streaming: hasSSE || undefined,
@@ -91,6 +92,7 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
if (hasSSE) { if (hasSSE) {
resource.operations = { list: true, get: false, create: false, update: false, delete: false }; resource.operations = { list: true, get: false, create: false, update: false, delete: false };
resource.updateMethod = "put";
resource.pagination = null; resource.pagination = null;
resource.relationships = []; resource.relationships = [];
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))]; resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];

View File

@@ -42,6 +42,7 @@ export interface ResourceConfig {
update: boolean; update: boolean;
delete: boolean; delete: boolean;
}; };
updateMethod: "put" | "patch";
pagination: { pagination: {
limitParam: string; limitParam: string;
offsetParam: string; offsetParam: string;

View File

@@ -145,9 +145,8 @@ function isMathValid(candidate: { amount: number; balance: number }, prevBalance
export default function FetchRequestDetail() { export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { resources, config } = useAppContext(); const { config } = useAppContext();
const fetchRequestResource = resources.find(r => r.name === "fetch-requests")!; const { get, update } = useResource("fetch-requests");
const { get, update } = useResource(fetchRequestResource);
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
queryKey: ["fetch-requests", "detail", id], queryKey: ["fetch-requests", "detail", id],

View File

@@ -47,7 +47,7 @@ import type {
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi"; import { useResource, FormFieldRenderer } from "../react-openapi";
import type { FieldConfig } from "../react-openapi"; import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -118,9 +118,8 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState(""); const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const { resources } = useAppContext(); const fr = useResource("fetch-requests");
const fetchRequestsRes = resources.find(r => r.name === "fetch-requests")!; const { list, create, update, remove, resource: fetchRes } = fr;
const { list, create, update, remove } = useResource(fetchRequestsRes);
const { data: listData, isLoading, isFetching, refetch } = useQuery({ const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }], queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
@@ -131,22 +130,21 @@ export default function FetchRequests() {
}), }),
}); });
const accountsResource = resources.find(r => r.name === "accounts"); const { list: listAccounts } = useResource("accounts");
const { list: listAccounts } = accountsResource ? useResource(accountsResource) : { list: async () => ({ items: [] }) };
const { data: accountsData } = useQuery({ const { data: accountsData } = useQuery({
queryKey: ["accounts", "list"], queryKey: ["accounts", "list"],
queryFn: () => listAccounts(), queryFn: () => listAccounts(),
enabled: !!accountsResource,
}); });
const accountOptions: string[] = React.useMemo(() => { const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean); return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]); }, [accountsData]);
const formatField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "format"); const fields = fetchRes?.orderedFields ?? [];
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const formatOptions: string[] = formatField?.enumValues ?? []; const formatOptions: string[] = formatField?.enumValues ?? [];
const startDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "start_date"); const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "end_date"); const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "payor_username"); const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: any) => create(data), mutationFn: (data: any) => create(data),

View File

@@ -21,8 +21,7 @@ import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle"; import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi"; import { useResource } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
interface ReportSnapshotQuery { interface ReportSnapshotQuery {
@@ -54,9 +53,7 @@ export default function ReportSnapshots() {
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null); const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { resources } = useAppContext(); const { list, create, remove } = useResource("reports");
const reportsResource = resources.find(r => r.name === "reports")!;
const { list, create, remove } = useResource(reportsResource);
const { data: listData, isLoading, isFetching, refetch } = useQuery({ const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["reports", "list"], queryKey: ["reports", "list"],
@@ -73,12 +70,6 @@ export default function ReportSnapshots() {
}, },
}); });
const ignoreSelfField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "ignore_self");
const startDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "end_date");
const minAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "min_amount");
const maxAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "max_amount");
const snapshots: ReportSnapshot[] = listData?.items ?? []; const snapshots: ReportSnapshot[] = listData?.items ?? [];
const handleCreate = async () => { const handleCreate = async () => {
@@ -135,58 +126,50 @@ export default function ReportSnapshots() {
</Typography> </Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ignoreSelfField ? ( <Box sx={{ display: "flex", gap: 2 }}>
<FormFieldRenderer <Box sx={{ flex: 1 }}>
field={ignoreSelfField} <TextField
value={ignoreSelf} label="Start Date"
onChange={(val: boolean) => setIgnoreSelf(val)} type="date"
/> value={startDate}
) : null} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
size="small"
<Box sx={{ display: "flex", gap: 2 }}> InputLabelProps={{ shrink: true }}
<Box sx={{ flex: 1 }}> inputProps={{ max: new Date().toISOString().split("T")[0] }}
<TextField />
label="Start Date"
type="date"
value={startDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
<Box sx={{ flex: 1 }}>
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
</Box> </Box>
<Box sx={{ flex: 1 }}>
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
/>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
{minAmountField ? ( <Box sx={{ flex: 1 }}>
<Box sx={{ flex: 1 }}> <TextField
<FormFieldRenderer label="Min Amount"
field={minAmountField} type="number"
value={minAmount} value={minAmount}
onChange={(val: string) => setMinAmount(val)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMinAmount(e.target.value)}
/> size="small"
</Box> />
) : null} </Box>
{maxAmountField ? ( <Box sx={{ flex: 1 }}>
<Box sx={{ flex: 1 }}> <TextField
<FormFieldRenderer label="Max Amount"
field={maxAmountField} type="number"
value={maxAmount} value={maxAmount}
onChange={(val: string) => setMaxAmount(val)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMaxAmount(e.target.value)}
/> size="small"
</Box> />
) : null} </Box>
</Box> </Box>
<Button <Button

View File

@@ -1,4 +1,4 @@
import { useAppContext, useResource, getApi } from "../../../react-openapi"; import { useResource, getApi } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models"; import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
@@ -7,9 +7,7 @@ export function useFetchRequestsList(params?: {
account_name?: string; account_name?: string;
source_type?: string; source_type?: string;
}) { }) {
const { resources } = useAppContext(); const { list } = useResource("fetch-requests");
const resource = resources.find(r => r.name === "fetch-requests")!;
const { list } = useResource(resource);
return useQuery({ return useQuery({
queryKey: ["fetch-requests", "list", params], queryKey: ["fetch-requests", "list", params],
queryFn: () => list(params), queryFn: () => list(params),
@@ -17,9 +15,7 @@ export function useFetchRequestsList(params?: {
} }
export function useFetchRequest(id: string) { export function useFetchRequest(id: string) {
const { resources } = useAppContext(); const { get } = useResource("fetch-requests");
const resource = resources.find(r => r.name === "fetch-requests")!;
const { get } = useResource(resource);
return useQuery({ return useQuery({
queryKey: ["fetch-requests", "detail", id], queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id), queryFn: () => get(id),
@@ -28,27 +24,21 @@ export function useFetchRequest(id: string) {
} }
export function useCreateFetchRequest() { export function useCreateFetchRequest() {
const { resources } = useAppContext(); const { create } = useResource("fetch-requests");
const resource = resources.find(r => r.name === "fetch-requests")!;
const { create } = useResource(resource);
return useMutation({ return useMutation({
mutationFn: (data: any) => create(data), mutationFn: (data: any) => create(data),
}); });
} }
export function useUpdateFetchRequest() { export function useUpdateFetchRequest() {
const { resources } = useAppContext(); const { update } = useResource("fetch-requests");
const resource = resources.find(r => r.name === "fetch-requests")!;
const { update } = useResource(resource);
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data), mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
}); });
} }
export function useDeleteFetchRequest() { export function useDeleteFetchRequest() {
const { resources } = useAppContext(); const { remove } = useResource("fetch-requests");
const resource = resources.find(r => r.name === "fetch-requests")!;
const { remove } = useResource(resource);
return useMutation({ return useMutation({
mutationFn: (id: string) => remove(id), mutationFn: (id: string) => remove(id),
}); });

View File

@@ -1,10 +1,8 @@
import { useAppContext, useResource } from "../../../react-openapi"; import { useResource } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export function useReportSnapshotsList() { export function useReportSnapshotsList() {
const { resources } = useAppContext(); const { list } = useResource("reports");
const resource = resources.find(r => r.name === "reports")!;
const { list } = useResource(resource);
return useQuery({ return useQuery({
queryKey: ["reports", "list"], queryKey: ["reports", "list"],
queryFn: () => list(), queryFn: () => list(),
@@ -12,9 +10,7 @@ export function useReportSnapshotsList() {
} }
export function useCreateSnapshot() { export function useCreateSnapshot() {
const { resources } = useAppContext(); const { create } = useResource("reports");
const resource = resources.find(r => r.name === "reports")!;
const { create } = useResource(resource);
return useMutation({ return useMutation({
mutationFn: (data: any) => create(data), mutationFn: (data: any) => create(data),
}); });
@@ -22,9 +18,7 @@ export function useCreateSnapshot() {
export function useDeleteSnapshot() { export function useDeleteSnapshot() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { resources } = useAppContext(); const { remove } = useResource("reports");
const resource = resources.find(r => r.name === "reports")!;
const { remove } = useResource(resource);
return useMutation({ return useMutation({
mutationFn: (id: string) => remove(id), mutationFn: (id: string) => remove(id),
onSuccess: () => { onSuccess: () => {

View File

@@ -1,4 +1,4 @@
import { useAppContext, useResource } from "../../../react-openapi"; import { useResource } from "../../../react-openapi";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
export interface ReportParams { export interface ReportParams {
@@ -10,9 +10,7 @@ export interface ReportParams {
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { resources } = useAppContext(); const { get } = useResource("reports");
const resource = resources.find(r => r.name === "reports")!;
const { get } = useResource(resource);
return useQuery({ return useQuery({
queryKey: ["reports", "read", params], queryKey: ["reports", "read", params],

View File

@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { import {
BrowserRouter, BrowserRouter,
Routes, Routes,
@@ -23,6 +24,8 @@ import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme'; import AppTheme from './shared-theme/AppTheme';
const queryClient = new QueryClient();
window.Buffer = Buffer; window.Buffer = Buffer;
window.process = process; window.process = process;
@@ -50,6 +53,7 @@ const routerMapping = [
]; ];
root.render( root.render(
<QueryClientProvider client={queryClient}>
<AppProvider specConfiguration={specConfig}> <AppProvider specConfiguration={specConfig}>
<BrowserRouter> <BrowserRouter>
<AuthProvider authBaseUrl={AUTH_BASE}> <AuthProvider authBaseUrl={AUTH_BASE}>
@@ -82,4 +86,5 @@ root.render(
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</AppProvider> </AppProvider>
</QueryClientProvider>
); );