Align frontend with updated OpenAPI schema and PATCH operation (#13)

## Summary

Align frontend with updated OpenAPI schema (rename `format` → `bank`, add `pipeline`) and introduce a dedicated `PATCH` operation for partial updates in react-openapi.

## Changes

### react-openapi: new `patch` operation support
- Add `patch` to `ResourceConfig.operations` type
- Separate PUT/PATCH detection in resource-config transformer
- `update()` now always calls PUT; new `patch()` method returned conditionally when spec defines PATCH
- Admin form still uses PUT for full replacements

### Fetch Request custom pages: schema alignment
- Rename `format` → `bank` in create form fields and TypeScript models (`FileSource`, `EmailSource`)
- Add `pipeline` field to create form and `FetchRequestCreate` model
- Export `PipelineType` from barrel index

### Fetch Request retry: use PATCH
- Retry handler now calls `patch(id, { status: "pending" })` instead of the generic `update()`
- Removes `useMutation` wrapper; uses simple local `retrying` state for loading

### Fix Account display in detail page
- Resolve FK displayFormat target in detail field rendering (was always using fetch-request format)
- Fix page title to use `applyDisplayFormat` with resolved FK objects instead of non-existent `account_name`
- Move `useMemo` before early returns to fix hooks ordering violation crash

## Testing
- Custom fetch request create form includes `bank` (select) and `pipeline` (select) fields
- Retry sends `PATCH` instead of `PUT` — no more 500 errors
- Detail page shows proper account name instead of `—`

Reviewed-on: #13
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
2026-07-30 18:49:56 +00:00
committed by aetos
parent 8795894c2c
commit 885ccdcfa7
10 changed files with 94 additions and 1221 deletions

View File

@@ -7,7 +7,7 @@ import { useNavigate } from "react-router-dom";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
const CREATE_FIELDS = ["account", "format", "start_date", "end_date", "source"];
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
function FetchRequestList() {
const navigate = useNavigate();

View File

@@ -9,7 +9,6 @@ import {
Chip,
CircularProgress,
Alert,
IconButton,
Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -20,8 +19,8 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { useResource, useItemSse, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests";
import { PipelineStepper } from "./components/PipelineStepper";
@@ -71,9 +70,11 @@ function sseIcon(status: SSEEvent["status"]) {
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, update, resource } = useResource("fetch-requests");
const { get, patch, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [failNotif, setFailNotif] = useState<string | null>(null);
const feedRef = useRef<HTMLDivElement>(null);
@@ -83,10 +84,6 @@ export default function FetchRequestDetail() {
enabled: !!id,
});
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => {
@@ -150,15 +147,41 @@ export default function FetchRequestDetail() {
}, [sseEvents]);
const handleRetry = async () => {
if (!id) return;
if (!id || !patch || retrying) return;
setRetrying(true);
try {
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
await patch(id, { status: "pending" });
refetchRequest();
} catch (err: any) {
setFailNotif(formatApiError(err));
} finally {
setRetrying(false);
}
};
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req?.status as FetchRequestStatus | undefined;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
const displayTitle = useMemo(() => {
if (!resource || !req) return "";
const resolved = Object.fromEntries(
resource.orderedFields.map((field) => {
const value = req[field.name];
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)];
}
return [field.name, value];
}),
);
return applyDisplayFormat(resolved, resource.displayFormat);
}, [resource, req, allResources]);
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
@@ -178,15 +201,6 @@ export default function FetchRequestDetail() {
);
}
const req = fetchRequest as any;
const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req.status as FetchRequestStatus;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
@@ -200,7 +214,7 @@ export default function FetchRequestDetail() {
label={status.replace(/_/g, " ")}
color={statusColors[status]}
/>
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
<Typography variant="h6" fontWeight={600}>{displayTitle}</Typography>
<Chip
label={"path" in (req.source ?? {}) ? "File" : "Email"}
size="small"
@@ -210,14 +224,22 @@ export default function FetchRequestDetail() {
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
{detailFields.map((field) => (
<DetailFieldRenderer
key={field.name}
field={field}
value={req[field.name]}
displayFormat={resource?.displayFormat}
/>
))}
{detailFields.map((field) => {
const value = req[field.name];
let fmt = resource?.displayFormat;
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) fmt = target.displayFormat;
}
return (
<DetailFieldRenderer
key={field.name}
field={field}
value={value}
displayFormat={fmt}
/>
);
})}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
@@ -232,7 +254,7 @@ export default function FetchRequestDetail() {
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={updateMutation.isPending}
disabled={retrying}
>
Retry
</Button>