proper use of react-openapi for resource api calls

This commit is contained in:
2026-04-25 12:13:28 +05:30
parent 5acbb7ccdd
commit 71afc157ff
13 changed files with 191 additions and 222 deletions

View File

@@ -1,5 +1,4 @@
import * as React from "react"; import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAuth, AuthPage } from "../react-auth"; import { useAuth, AuthPage } from "../react-auth";
import { UploadProvider } from "./providers/UploadProvider"; import { UploadProvider } from "./providers/UploadProvider";
import AdminLayout from "./components/AdminLayout"; import AdminLayout from "./components/AdminLayout";
@@ -13,13 +12,9 @@ import {
Route, Route,
useNavigate, useNavigate,
useParams, useParams,
Navigate,
} from "react-router-dom"; } from "react-router-dom";
const queryClient = new QueryClient(); import { ConfigContext } from "./providers/ConfigContext";
// Create a context for the app config
export const ConfigContext = React.createContext<AppConfig | null>(null);
function Dashboard({ basePath }: { basePath: string }) { function Dashboard({ basePath }: { basePath: string }) {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
@@ -127,14 +122,17 @@ interface AdminProps {
} }
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) { export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
const [config, setConfig] = React.useState<AppConfig | null>(null); const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
React.useEffect(() => { React.useEffect(() => {
getAppConfig(resourceOverrides, profileConfig).then((cfg) => { if (!existingConfig) {
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl); getAppConfig(resourceOverrides, profileConfig).then((cfg) => {
setConfig(cfg); initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
}); setConfig(cfg);
}, [resourceOverrides, profileConfig]); });
}
}, [resourceOverrides, profileConfig, existingConfig]);
if (!config) { if (!config) {
return ( return (
@@ -151,13 +149,21 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
); );
} }
const content = (
<UploadProvider>
<AdminApp basePath={basePath} />
</UploadProvider>
);
// If we have an existing config, we are already inside a Provider and QueryClient
if (existingConfig) {
return content;
}
// Fallback for standalone usage
return ( return (
<QueryClientProvider client={queryClient}> <ConfigContext.Provider value={config}>
<ConfigContext.Provider value={config}> {content}
<UploadProvider> </ConfigContext.Provider>
<AdminApp basePath={basePath} />
</UploadProvider>
</ConfigContext.Provider>
</QueryClientProvider>
); );
} }

View File

@@ -11,7 +11,7 @@ import { useUpload } from '../providers/UploadProvider';
import { useQueries } from '@tanstack/react-query'; import { useQueries } from '@tanstack/react-query';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import FormField from './fields/FormField'; import FormField from './fields/FormField';
import { ConfigContext } from '../Admin'; import { ConfigContext } from '../providers/ConfigContext';
interface GenericFormProps { interface GenericFormProps {
config: ResourceConfig; config: ResourceConfig;
@@ -67,7 +67,8 @@ export default function GenericForm({
const relationDataMap = React.useMemo(() => { const relationDataMap = React.useMemo(() => {
const map: Record<string, any[]> = {}; const map: Record<string, any[]> = {};
allRelations.forEach((relName, index) => { allRelations.forEach((relName, index) => {
map[relName] = queries[index].data || []; // @ts-ignore
map[relName] = queries[index].data || [];
}); });
return map; return map;
}, [allRelations, queries]); }, [allRelations, queries]);

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material'; import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import { ConfigContext } from '../Admin'; import { ConfigContext } from '../providers/ConfigContext';
export default function ProfileView() { export default function ProfileView() {
const appConfig = React.useContext(ConfigContext); const appConfig = React.useContext(ConfigContext);

View File

@@ -1,16 +1,21 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client"; import { api } from "../api/client";
import { ResourceConfig } from "../types/config"; import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext";
import * as React from "react";
export function useResource<T = any>(config: ResourceConfig) { export function useResource<T = any>(config: ResourceConfig | undefined) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { name, endpoint, primaryKey } = config;
// Return empty/disabled hooks if config is missing
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
// --- READ ALL --- // --- READ ALL ---
const useList = (params?: any) => const useList = (params?: any) =>
useQuery({ useQuery({
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
// @ts-ignore // @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
@@ -18,7 +23,8 @@ export function useResource<T = any>(config: ResourceConfig) {
data: res.data, data: res.data,
total: isNaN(total as any) ? undefined : total total: isNaN(total as any) ? undefined : total
}; };
} },
enabled: !!endpoint,
}); });
// --- READ ONE --- // --- READ ONE ---
@@ -26,18 +32,19 @@ export function useResource<T = any>(config: ResourceConfig) {
useQuery({ useQuery({
queryKey: [name, "detail", id], queryKey: [name, "detail", id],
queryFn: async () => { queryFn: async () => {
if (!id) return null; if (!id || !endpoint) return null;
// @ts-ignore // @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`); const res = await api.get<T>(`${endpoint}/${id}`);
return res.data; return res.data;
}, },
enabled: !!id, enabled: !!id && !!endpoint,
}); });
// --- CREATE --- // --- CREATE ---
const useCreate = () => const useCreate = () =>
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore // @ts-ignore
const res = await api.post<T>(endpoint, data); const res = await api.post<T>(endpoint, data);
return res.data; return res.data;
@@ -51,6 +58,7 @@ export function useResource<T = any>(config: ResourceConfig) {
const useUpdate = () => const useUpdate = () =>
useMutation({ useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => { mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore // @ts-ignore
const res = await api.put<T>(`${endpoint}/${id}`, data); const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data; return res.data;
@@ -67,6 +75,7 @@ export function useResource<T = any>(config: ResourceConfig) {
const useDelete = () => const useDelete = () =>
useMutation({ useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
if (!endpoint) throw new Error("Endpoint not defined");
await api.delete(`${endpoint}/${id}`); await api.delete(`${endpoint}/${id}`);
return id; return id;
}, },
@@ -79,6 +88,7 @@ export function useResource<T = any>(config: ResourceConfig) {
const getListQueryOptions = (params?: any) => ({ const getListQueryOptions = (params?: any) => ({
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
// @ts-ignore // @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
@@ -87,6 +97,7 @@ export function useResource<T = any>(config: ResourceConfig) {
total: isNaN(total as any) ? undefined : total total: isNaN(total as any) ? undefined : total
}; };
}, },
enabled: !!endpoint,
}); });
// --- READ ME --- // --- READ ME ---
@@ -94,16 +105,19 @@ export function useResource<T = any>(config: ResourceConfig) {
useQuery({ useQuery({
queryKey: [name, "me"], queryKey: [name, "me"],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return null;
// @ts-ignore // @ts-ignore
const res = await api.get<T>(`${endpoint}/me`); const res = await api.get<T>(`${endpoint}/me`);
return res.data; return res.data;
}, },
enabled: !!endpoint,
}); });
// --- UPDATE ME --- // --- UPDATE ME ---
const useUpdateMe = () => const useUpdateMe = () =>
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore // @ts-ignore
const res = await api.put<T>(`${endpoint}/me`, data); const res = await api.put<T>(`${endpoint}/me`, data);
return res.data; return res.data;
@@ -125,3 +139,10 @@ export function useResource<T = any>(config: ResourceConfig) {
getListQueryOptions, getListQueryOptions,
}; };
} }
export function useResourceByName<T = any>(name: string) {
const config = React.useContext(ConfigContext);
const resourceConfig = config?.resources.find((r) => r.name === name);
return useResource<T>(resourceConfig);
}

View File

@@ -2,3 +2,6 @@ export { default as Admin } from "./Admin";
export { api, auth, initializeApiClients } from "./api/client"; export { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config"; export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField } from "./types/config"; export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";
export { AppProvider } from "./providers/AppProvider";
export { ConfigContext, useConfig } from "./providers/ConfigContext";
export { useResource, useResourceByName } from "./hooks/useResource";

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfigContext } from "./ConfigContext";
import { getAppConfig } from "../config";
import { initializeApiClients } from "../api/client";
import { AppConfig } from "../types/config";
import { Box, CircularProgress } from "@mui/material";
const defaultQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
interface AppProviderProps {
children: React.ReactNode;
resourceOverrides?: Record<string, any>;
profileConfig?: any;
queryClient?: QueryClient;
}
export function AppProvider({
children,
resourceOverrides = {},
profileConfig = {},
queryClient = defaultQueryClient,
}: AppProviderProps) {
const [config, setConfig] = React.useState<AppConfig | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
getAppConfig(resourceOverrides, profileConfig)
.then((cfg) => {
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
setConfig(cfg);
setLoading(false);
})
.catch((err) => {
console.error("Failed to load OpenAPI configuration:", err);
setLoading(false);
});
}, [resourceOverrides, profileConfig]);
if (loading) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress />
</Box>
);
}
return (
<QueryClientProvider client={queryClient}>
<ConfigContext.Provider value={config}>
{children}
</ConfigContext.Provider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,12 @@
import * as React from "react";
import { AppConfig } from "../types/config";
export const ConfigContext = React.createContext<AppConfig | null>(null);
export function useConfig() {
const context = React.useContext(ConfigContext);
if (context === undefined) {
throw new Error("useConfig must be used within a ConfigProvider");
}
return context;
}

View File

@@ -1,24 +1,15 @@
import { api } from "../../../react-openapi";
import { LatestItem } from "../../components/LatestItems";
import * as React from "react"; import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn"; import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
import { LatestItem } from "../../components/LatestItems";
import { fetchReport } from "../report/report.api";
import { mapReportToDashboard } from "../report/report.mapper";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, { const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" } sx: { color: "#388e3c" }
}); });
export async function fetchLatestTransactions( export function mapToLatestItems(
items: any[],
type: "expense" | "income" type: "expense" | "income"
): Promise<LatestItem[]> { ): LatestItem[] {
const res = await api.get("/expenses", {
params: { limit: 100, sort: "-occurred_at" }
});
const items = res.data || [];
const isValid = (amt: number) => const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0; type === "expense" ? amt < 0 : amt > 0;
@@ -47,20 +38,3 @@ export async function fetchLatestTransactions(
}; };
}); });
} }
export async function fetchAggregatedData(
type: "expense" | "income"
) {
const [weekly, monthly] = await Promise.all([
fetchReport({ period: "weekly", rolling: true }),
fetchReport({ period: "monthly", rolling: true }),
]);
return mapReportToDashboard(weekly.buckets, monthly.buckets, type);
}
export const fetchAggregatedExpenses = () =>
fetchAggregatedData("expense");
export const fetchAggregatedIncome = () =>
fetchAggregatedData("income");

View File

@@ -1,24 +1,48 @@
import { useQuery } from "@tanstack/react-query"; import { useResourceByName } from "../../../react-openapi";
import { import { mapToLatestItems } from "./dashboard.mapper";
fetchAggregatedData, import { mapReportToDashboard } from "../report/report.mapper";
fetchLatestTransactions,
} from "./dashboard.service";
export function useDashboardData(type: "expense" | "income") { export function useDashboardData(type: "expense" | "income") {
const aggregated = useQuery({ const { useList: useExpenseList } = useResourceByName("expenses");
queryKey: ["dashboard", type], const { useList: useReportList } = useResourceByName("reports");
queryFn: () => fetchAggregatedData(type),
// Fetch latest transactions
const latestQuery = useExpenseList({
limit: 100,
sort: "-occurred_at"
}); });
const latest = useQuery({ // Fetch reports for aggregation
queryKey: ["latest", type], const weeklyReport = useReportList({ period: "weekly", rolling: true });
queryFn: () => fetchLatestTransactions(type), const monthlyReport = useReportList({ period: "monthly", rolling: true });
});
const isLoading =
latestQuery.isLoading ||
weeklyReport.isLoading ||
monthlyReport.isLoading;
const error =
latestQuery.error ||
weeklyReport.error ||
monthlyReport.error;
const latest = latestQuery.data?.data
? mapToLatestItems(latestQuery.data.data, type)
: [];
const aggregatedData =
weeklyReport.data?.data && monthlyReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
type
)
: null;
return { return {
data: aggregated.data, data: aggregatedData,
latest: latest.data, latest: latest,
isLoading: aggregated.isLoading || latest.isLoading, isLoading,
error: aggregated.error || latest.error, error,
}; };
} }

View File

@@ -1,9 +0,0 @@
import { api } from "../../../react-openapi";
export async function fetchReport(params: {
period: "weekly" | "monthly" | "yearly" | "fyly";
rolling?: boolean;
}) {
const res = await api.get("/reports", { params });
return res.data;
}

View File

@@ -1,114 +0,0 @@
import { fetchReport } from "./report.api";
import {
AggregatedDashboardData,
ChartData,
ChartDataPoint,
} from "../components/HistoryChart";
type ReportBucket = any; // replace with generated type if available
function sumBucket(bucket: ReportBucket, flow: "expenses" | "incomes") {
return bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
}
function toLabel(start: string, end: string, type: "weekly" | "monthly") {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
const sd = s.getDate();
const ed = e.getDate();
const m = e.toLocaleString("default", { month: "short" });
return `${sd}${ed} ${m}`;
}
function toChartPoints(
buckets: ReportBucket[],
type: "weekly" | "monthly",
flow: "expenses" | "incomes"
): ChartDataPoint[] {
return buckets.map((b, i) => {
const amount = sumBucket(b, flow);
const prev = buckets[i - 1];
const compareAmount = prev ? sumBucket(prev, flow) : 0;
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: compareAmount,
}
: undefined,
};
});
}
function buildChartData(
weekly: ReportBucket[],
monthly: ReportBucket[],
flow: "expenses" | "incomes"
): ChartData {
return {
daily: [], // not supported by /reports → keep empty or drop
weekly: {
rolling: toChartPoints(weekly, "weekly", flow),
calendar: toChartPoints(weekly, "weekly", flow), // same unless backend differentiates
},
monthly: {
rolling: toChartPoints(monthly, "monthly", flow),
calendar: toChartPoints(monthly, "monthly", flow),
},
};
}
function getTopPayees(buckets: ReportBucket[], flow: "expenses" | "incomes") {
const map: Record<string, number> = {};
for (const b of buckets) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
map[key] = (map[key] || 0) + amt;
}
}
return Object.entries(map)
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
}
export async function getDashboardData(
type: "expense" | "income"
): Promise<AggregatedDashboardData> {
const flow = type === "expense" ? "expenses" : "incomes";
const [weeklyBuckets, monthlyBuckets] = await Promise.all([
fetchReport({ period: "weekly", rolling: true }),
fetchReport({ period: "monthly", rolling: true }),
]);
const chartData = buildChartData(weeklyBuckets, monthlyBuckets, flow);
const totalAmount = weeklyBuckets.reduce(
(acc: number, b: any) => acc + sumBucket(b, flow),
0
);
const topPayees = getTopPayees(weeklyBuckets, flow);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

@@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useResourceByName } from "../../../react-openapi";
import { fetchReport } from "./report.api";
export interface ReportParams { export interface ReportParams {
period: "weekly" | "monthly" | "yearly" | "fyly"; period: "weekly" | "monthly" | "yearly" | "fyly";
@@ -10,8 +9,6 @@ export interface ReportParams {
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
return useQuery({ const { useList } = useResourceByName("reports");
queryKey: ["report", params], return useList(params);
queryFn: () => fetchReport(params),
});
} }

View File

@@ -12,7 +12,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import { Admin, initializeApiClients } from '../react-openapi'; import { Admin, AppProvider } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config'; import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import process from 'process'; import process from 'process';
@@ -21,30 +21,14 @@ import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './AppTheme'; import AppTheme from './AppTheme';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
window.Buffer = Buffer; window.Buffer = Buffer;
window.process = process; window.process = process;
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
const API_BASE = import.meta.env.VITE_API_BASE_URL;
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
// Initialize global API clients so all components across khata-ui have generic API access
initializeApiClients(API_BASE, AUTH_BASE);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
const routerMapping = [ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
@@ -53,7 +37,7 @@ const routerMapping = [
]; ];
root.render( root.render(
<QueryClientProvider client={queryClient}> <AppProvider resourceOverrides={configuration} profileConfig={profileConfiguration}>
<BrowserRouter> <BrowserRouter>
<AuthProvider authBaseUrl={AUTH_BASE}> <AuthProvider authBaseUrl={AUTH_BASE}>
<AppTheme> <AppTheme>
@@ -70,7 +54,7 @@ root.render(
path={path} path={path}
element={ element={
path.startsWith("/admin") ? ( path.startsWith("/admin") ? (
<Component basePath="/admin" resourceOverrides={configuration} profileConfig={profileConfiguration} /> <Component basePath="/admin" />
) : ( ) : (
<Component /> <Component />
) )
@@ -84,5 +68,5 @@ root.render(
</AppTheme> </AppTheme>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </AppProvider>
); );