9 Commits

Author SHA1 Message Date
51762f8d18 loader fixes 2026-08-01 17:19:58 +05:30
002b22ff0e entity-wise-enrichment (#14)
## What's changed

Show an image preview in the detail view when a field's `uiType` is `image`, instead of rendering it through the generic list cell renderer.

- Added `Avatar` from MUI and render a 120x120 rounded preview for `image` fields in `DetailFieldRenderer`
- All other field types keep their existing rendering via `ListCellRenderer`

## Files changed

- `react-openapi/src/components/fields/DetailFieldRenderer.tsx`

Reviewed-on: #14
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-08-01 10:28:00 +00:00
885ccdcfa7 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>
2026-07-30 18:49:56 +00:00
8795894c2c fixes 2026-07-26 15:06:17 +05:30
28cf6ccacf auth-fixes (#12)
## Summary

Wire spec-driven auth into the frontend. Auth config (server URL, paths) is extracted from the served OpenAPI spec by `AppProvider`. `AuthProvider` receives the config as a prop. 401 responses from both the main API and the auth server dispatch an `auth:unauthorized` event that triggers redirect to `/login`. Fix `ProfileRoutes` URL duplication bug.

## Changes

### Auth config from spec
- **`main.jsx`** — `AppProvider` wraps everything, loads spec, exposes `authConfig`. `AuthProvider` receives `authConfig` from `useAppContext()`. `onUnauthorized` passed to both `AppProvider` and `AuthProvider` wires `navigate("/login")`.

### 401 handling
- **`AppProvider.tsx`** — remove `onUnauthorized` prop (handled by `AuthProvider`'s event listener instead, avoiding double-navigation).
- **`useApi.ts`** — 401 response interceptor dispatches `auth:unauthorized` CustomEvent on `window`.

### Profile routing fix
- **`Admin.tsx:ProfileRoutes`** — replace nested `<Routes>` (which caused `/profile/me/me` URL duplication with React Router v6) with `useLocation()`/`useNavigate()` conditional rendering. Only allows `/profile/me` and `/profile/me/edit`.
- **`Admin.tsx:ProfileComponentWrapper`** — replace `pushState() + reload()` with React Router `navigate()` for both `onEdit` and `handleSubmit`.

### Debug logging
- Temporary console logs at every navigation point for diagnosing remaining issues.

Reviewed-on: #12
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-07-19 14:47:30 +00:00
6c720c390c fixes 2026-07-17 14:31:39 +05:30
83ecdcb500 upload functionality 2026-07-13 20:09:31 +05:30
f3135b8247 new react-openapi 2026-07-13 16:15:26 +05:30
72e7e843a4 new react-openapi 2026-07-13 04:35:58 +05:30
59 changed files with 3212 additions and 2269 deletions

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Install Deps" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<package-json value="$PROJECT_DIR$/../khata-ui/package.json" />
<command value="install" />
<node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" />
<envs />

View File

@@ -1,12 +1,15 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<package-json value="$PROJECT_DIR$/../khata-ui/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" />
<envs />
<EXTENSION ID="com.intellij.lang.javascript.buildTools.npm.rc.StartBrowserRunConfigurationExtension">
<browser name="98ca6316-2f89-46d9-a9e5-fa9e2b0625b3" />
</EXTENSION>
<method v="2" />
</configuration>
</component>

1588
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,32 +4,36 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "tsc -b && vite build",
"serve": "vite preview"
},
"dependencies": {
"@apidevtools/swagger-parser": "^12.1.0",
"@emotion/react": "latest",
"@emotion/styled": "latest",
"@mui/icons-material": "latest",
"@mui/material": "latest",
"@mui/x-data-grid": "^8.28.2",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1",
"axios": "latest",
"axios": "^1.7.7",
"buffer": "^6.0.3",
"js-yaml": "^4.1.0",
"markdown-to-jsx": "latest",
"marked": "latest",
"process": "^0.11.10",
"react": "latest",
"react-dom": "latest",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "latest",
"react-router-dom": "^7.13.2",
"react-router-dom": "^6.28.0",
"remark-gfm": "latest"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest"
"@types/node": "^25.9.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.1"
}
}

View File

@@ -0,0 +1,122 @@
import * as React from "react";
import {
Box,
TextField,
Button,
Typography,
CircularProgress,
} from "@mui/material";
export interface ProfileCreateProps {
defaultUsername?: string;
defaultEmail?: string;
onSubmit: (data: { name: string; email: string }) => Promise<void>;
onBack?: () => void;
loading?: boolean;
error?: string | null;
}
export function ProfileCreate({
defaultUsername,
defaultEmail,
onSubmit,
onBack,
loading = false,
error = null,
}: ProfileCreateProps) {
const [name, setName] = React.useState("");
const [email, setEmail] = React.useState(defaultEmail ?? "");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit({ name: name || defaultUsername || "", email });
};
return (
<Box
sx={{
maxWidth: 480,
mx: "auto",
mt: 4,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: "background.paper",
}}
>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Complete Your Profile
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{defaultUsername && (
<span>
Welcome <strong>{defaultUsername}</strong>!{" "}
</span>
)}
Fill in your details to get started.
</Typography>
<form onSubmit={handleSubmit}>
{defaultUsername && (
<TextField
fullWidth
label="Username"
value={defaultUsername}
margin="normal"
disabled
/>
)}
<TextField
fullWidth
label="Full Name"
margin="normal"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultUsername ?? "Your name"}
required
autoFocus={!defaultUsername}
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={defaultEmail ?? "you@example.com"}
required
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Box sx={{ display: "flex", gap: 2, mt: 3 }}>
{onBack && (
<Button
fullWidth
variant="outlined"
onClick={onBack}
disabled={loading}
>
Back
</Button>
)}
<Button
fullWidth
type="submit"
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : "Create Profile"}
</Button>
</Box>
</form>
</Box>
);
}

111
react-auth/ProfileEdit.tsx Normal file
View File

@@ -0,0 +1,111 @@
import * as React from "react";
import {
Box,
TextField,
Button,
Typography,
CircularProgress,
} from "@mui/material";
export interface ProfileEditProps {
name: string;
username: string;
email: string;
onSubmit: (data: { name: string; email: string }) => Promise<void>;
onBack?: () => void;
loading?: boolean;
error?: string | null;
}
export function ProfileEdit({
name: initialName,
username,
email: initialEmail,
onSubmit,
onBack,
loading = false,
error = null,
}: ProfileEditProps) {
const [name, setName] = React.useState(initialName);
const [email, setEmail] = React.useState(initialEmail);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
await onSubmit({ name, email });
};
return (
<Box
sx={{
maxWidth: 480,
mx: "auto",
mt: 4,
p: 4,
borderRadius: 3,
boxShadow: 3,
bgcolor: "background.paper",
}}
>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Edit Profile
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
value={username}
margin="normal"
disabled
/>
<TextField
fullWidth
label="Full Name"
margin="normal"
value={name}
onChange={(e) => setName(e.target.value)}
required
autoFocus
/>
<TextField
fullWidth
label="Email"
type="email"
margin="normal"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{error && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error}
</Typography>
)}
<Box sx={{ display: "flex", gap: 2, mt: 3 }}>
{onBack && (
<Button
fullWidth
variant="outlined"
onClick={onBack}
disabled={loading}
>
Cancel
</Button>
)}
<Button
fullWidth
type="submit"
variant="contained"
disabled={loading}
>
{loading ? <CircularProgress size={24} /> : "Save Changes"}
</Button>
</Box>
</form>
</Box>
);
}

View File

@@ -0,0 +1,85 @@
import * as React from "react";
import {
Box,
Typography,
Button,
Avatar,
Paper,
CircularProgress,
} from "@mui/material";
export interface ProfileViewProps {
name: string;
username: string;
email: string;
onEdit?: () => void;
loading?: boolean;
}
export function ProfileView({
name,
username,
email,
onEdit,
loading = false,
}: ProfileViewProps) {
const initials = (name || username)
.split(" ")
.map((s) => s[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<Paper
sx={{
maxWidth: 480,
mx: "auto",
mt: 4,
p: 4,
borderRadius: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 3, mb: 3 }}>
<Avatar
sx={{
width: 64,
height: 64,
bgcolor: "primary.main",
fontSize: 24,
}}
>
{initials}
</Avatar>
<Box>
<Typography variant="h5" fontWeight="bold">
{name || username}
</Typography>
<Typography variant="body2" color="text.secondary">
@{username}
</Typography>
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="overline" color="text.secondary" display="block">
Email
</Typography>
<Typography variant="body1">{email}</Typography>
</Box>
{onEdit && (
<Button
fullWidth
variant="outlined"
onClick={onEdit}
disabled={loading}
sx={{ mt: 1 }}
>
{loading ? <CircularProgress size={20} /> : "Edit Profile"}
</Button>
)}
</Paper>
);
}

View File

@@ -17,7 +17,9 @@ export function attachAuthInterceptors(client: AxiosInstance) {
(res) => res,
(error) => {
if (error.response?.status === 401) {
console.log("[authAxios] 401 from %s %s", error.config?.method, error.config?.url);
tokenStore.clear();
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
}
return Promise.reject(error);
}

View File

@@ -3,6 +3,15 @@ import { tokenStore } from "./token";
import { createApiClient } from "./axios";
import { AuthUser } from "./models";
export interface AuthServerConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
interface AuthContextModel {
currentUser: AuthUser | null;
token: string | null;
@@ -17,24 +26,26 @@ const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export function AuthProvider({
children,
authBaseUrl,
authConfig,
onUnauthorized,
}: {
children: React.ReactNode;
authBaseUrl: string;
authConfig: AuthServerConfig;
onUnauthorized?: () => void;
}) {
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(tokenStore.get());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const auth = createApiClient(authBaseUrl);
const auth = createApiClient(authConfig.serverUrl);
const login = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
const res = await auth.post("/login", { username, password });
const res = await auth.post(authConfig.loginPath, { username, password });
const { access_token, user } = res.data;
tokenStore.set(access_token);
@@ -52,7 +63,7 @@ export function AuthProvider({
setLoading(true);
setError(null);
await auth.post("/register", { username, password });
await auth.post(authConfig.registerPath, { username, password });
await login(username, password);
} catch (e: any) {
setError(e.response?.data?.detail ?? "Registration failed");
@@ -61,25 +72,55 @@ export function AuthProvider({
}
};
const logout = () => {
const logout = async () => {
try {
await auth.post(authConfig.logoutPath);
} catch {
// Server logout is best-effort; clear token locally regardless
}
tokenStore.clear();
setToken(null);
setCurrentUser(null);
};
const fetchCurrentUser = async () => {
if (!token) return;
if (!token) { console.log("[AuthProvider] fetchCurrentUser SKIP no token"); return; }
console.log("[AuthProvider] fetchCurrentUser calling %s%s", authConfig.serverUrl, authConfig.mePath);
try {
const me = await auth.get("/me");
const me = await auth.get(authConfig.mePath);
console.log("[AuthProvider] fetchCurrentUser SUCCESS", me.data);
setCurrentUser({ ...me.data });
} catch {
logout();
} catch (e: any) {
console.log("[AuthProvider] fetchCurrentUser ERROR", e.message, e.response?.status);
tokenStore.clear();
setToken(null);
setCurrentUser(null);
onUnauthorized?.();
}
};
useEffect(() => {
fetchCurrentUser();
}, [token]);
console.log("[AuthProvider] useEffect token=%s serverUrl=%s", token, authConfig.serverUrl);
if (authConfig.serverUrl) {
fetchCurrentUser();
}
}, [token, authConfig.serverUrl]);
useEffect(() => {
const handler = () => {
console.log("[AuthProvider] auth:unauthorized event received");
tokenStore.clear();
setToken(null);
setCurrentUser(null);
onUnauthorized?.();
};
console.log("[AuthProvider] adding auth:unauthorized listener");
window.addEventListener("auth:unauthorized", handler);
return () => {
console.log("[AuthProvider] removing auth:unauthorized listener");
window.removeEventListener("auth:unauthorized", handler);
};
}, [onUnauthorized]);
return (
<AuthContext.Provider

View File

@@ -1,6 +1,13 @@
export { AuthProvider, useAuth } from "./contexts";
export type { AuthServerConfig } from "./contexts";
export { createApiClient } from "./axios";
export { AuthPage } from "./AuthPage";
export { ProfileCreate } from "./ProfileCreate";
export { ProfileEdit } from "./ProfileEdit";
export { ProfileView } from "./ProfileView";
export type { AuthUser } from "./models";
export type { AuthMode } from "./AuthPage";
export type { ProfileCreateProps } from "./ProfileCreate";
export type { ProfileEditProps } from "./ProfileEdit";
export type { ProfileViewProps } from "./ProfileView";
export { tokenStore } from "./token"

View File

@@ -590,6 +590,28 @@ x-upload-url: /pets/{id}/photo
POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID.
#### `x-upload` (optional)
```yaml
path:
type: string
x-upload:
type: file # one of: image, pdf, html, csv, file
url: /uploads # POST endpoint that accepts raw binary + Content-Disposition header
```
Changes the form field to a file upload component. The file is POSTed as raw binary (`application/octet-stream`) to the specified `url` with a `Content-Disposition: attachment; filename="..."` header. The response must contain a `saved_as` field, which is stored as the field value.
| `type` | Display |
|------------|--------------------------------------------------|
| `image` | `<Avatar src="/uploads/{value}" />` |
| `pdf` | Clickable chip linking to `/uploads/{value}` |
| `html` | Clickable chip linking to `/uploads/{value}` |
| `csv` | Clickable chip linking to `/uploads/{value}` |
| `file` | Clickable chip linking to `/uploads/{value}` |
The chip includes a delete (X) button to replace the file.
### Property Types That Trigger Special Renderers
| Condition | Form Renderer | List/Detail Renderer |
@@ -602,6 +624,7 @@ POST endpoint for file upload. The `{id}` placeholder is replaced with the curre
| `format: date` | `DateField` (date picker) | Typography |
| `format: date-time` | `DateField` (datetime-local picker) | Typography |
| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` |
| `x-upload` exists | `FileUploadField` (upload button + chip/Avatar) | Chip or Avatar |
| `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) |
| None of the above (default) | `StringField` (text input) | Typography |

View File

@@ -1,5 +1,6 @@
export { AppProvider } from "./src/context/AppProvider";
export { Admin } from "./src/components/Admin";
export { Admin, ProfileRoutes } from "./src/components/Admin";
export type { AdminProps, ProfileRoute } from "./src/components/Admin";
export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
@@ -8,5 +9,7 @@ export { SseStreamView } from "./src/components/SseStreamView";
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi";
export { useItemSse } from "./src/hooks/useItemSse";
export { sanitizePayload } from "./src/utils/sanitize-payload";
export type { FkResolver } from "./src/utils/sanitize-payload";
export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types";

View File

@@ -0,0 +1,20 @@
{
"name": "react-openapi",
"private": true,
"version": "0.0.1",
"type": "module",
"main": "index.ts",
"types": "index.ts",
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"@mui/material": "^5.16.7",
"@mui/icons-material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"axios": "^1.7.7",
"js-yaml": "^4.1.0"
}
}

View File

@@ -1,14 +1,15 @@
import React from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { Box, CircularProgress } from "@mui/material";
import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import { Box, CircularProgress, Alert } from "@mui/material";
import { useAppContext } from "../context/AppContext";
import { Layout } from "./Layout";
import { ResourceList } from "./ResourceList";
import { ResourceForm } from "./ResourceForm";
import { ResourceDetail } from "./ResourceDetail";
import { ValidationAlert } from "./ValidationAlert";
import { getApi } from "../hooks/useApi";
interface AdminProps {
export interface AdminProps {
basePath: string;
}
@@ -30,18 +31,21 @@ export function Admin({ basePath }: AdminProps) {
if (resources.length === 0) {
return (
<Box sx={{ p: 4, textAlign: "center" }}>
No resources found in the OpenAPI spec with x-resource defined.
No resources found in the OpenAPI spec.
</Box>
);
}
const topLevel = resources.filter((r) => !r.parent);
return (
<>
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
<Layout resources={resources} basePath={basePath}>
<Layout resources={topLevel} basePath={basePath}>
<Routes>
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} />
{resources.map((r) => (
<Route index element={<Navigate to={`${basePath}/${topLevel[0]?.name ?? "profile"}`} replace />} />
{topLevel.map((r) => (
<React.Fragment key={r.name}>
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
{!r.streaming && (
@@ -58,3 +62,181 @@ export function Admin({ basePath }: AdminProps) {
</>
);
}
export interface ProfileRoute {
path: string;
method: string;
operationId: string;
summary?: string;
}
/** Derive a friendly profile route path from the operation's spec path.
* e.g. "/users/me" → "me", "/users/me/" → "me" */
function friendlyProfilePath(specPath: string): string {
return specPath.replace(/^\/[^/]+\/?/, "").replace(/\/+$/, "");
}
export function ProfileRoutes() {
const { profileOperations, profileComponents } = useAppContext();
const ops = profileOperations;
const getOp = ops.find((o) => o.method === "GET");
const location = useLocation();
const navigate = useNavigate();
console.log("[ProfileRoutes] render pathname=%s ops=%o", location.pathname, ops.map(o => o.path));
// Redirect /:username to /me (only "me" and "me/edit" are valid)
const match = location.pathname.match(/^\/profile\/(.+)$/);
const username = match ? match[1] : "";
console.log("[ProfileRoutes] match=%o username=%s", match && match[1], username);
if (username !== "me" && username !== "me/edit") {
console.log("[ProfileRoutes] redirecting to /profile/me");
navigate("/profile/me", { replace: true });
return null;
}
const isEdit = username === "me/edit";
const mode = isEdit ? "edit" as const : "view" as const;
console.log("[ProfileRoutes] mode=%s", mode);
const op = ops.find((o) => {
if (isEdit && (o.method === "PUT" || o.method === "PATCH")) return true;
if (!isEdit && o.method === "GET") return true;
return false;
});
if (!op) return null;
let Component: React.ComponentType<any> | undefined;
if (mode === "view" && profileComponents.view) {
Component = profileComponents.view;
} else if (mode === "edit" && profileComponents.edit) {
Component = profileComponents.edit;
}
if (!Component) return null;
return (
<ProfileComponentWrapper
Component={Component}
mode={mode}
operation={op}
getOperation={getOp}
/>
);
}
function ProfileComponentWrapper({
Component,
mode,
operation,
getOperation,
}: {
Component: React.ComponentType<any>;
mode: "view" | "create" | "edit";
operation: { path: string; method: string };
getOperation?: { path: string };
}) {
const navigate = useNavigate();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(mode !== "create");
const [error, setError] = useState<string | null>(null);
console.log("[ProfileComponentWrapper] render mode=%s fetchPath=%s", mode, getOperation?.path ?? operation.path);
const fetchPath = getOperation?.path ?? operation.path;
const fetchData = async () => {
console.log("[ProfileComponentWrapper] fetchData START path=%s", fetchPath);
setLoading(true);
setError(null);
try {
const res = await getApi().get(fetchPath);
console.log("[ProfileComponentWrapper] fetchData SUCCESS", res.data);
setData(res.data);
} catch (e: any) {
console.log("[ProfileComponentWrapper] fetchData ERROR", e.message, e.response?.status);
setError(e.response?.data?.detail ?? e.message ?? "Failed to load profile");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (mode !== "create") {
console.log("[ProfileComponentWrapper] useEffect calling fetchData");
fetchData();
}
}, [fetchPath, mode]);
const handleSubmit = async (formData: Record<string, any>) => {
console.log("[ProfileComponentWrapper] handleSubmit START");
setLoading(true);
setError(null);
try {
if (mode === "create") {
await getApi().post(operation.path, formData);
} else {
await getApi()({ method: operation.method.toLowerCase(), url: operation.path, data: formData });
}
console.log("[ProfileComponentWrapper] handleSubmit SUCCESS, navigating to /profile/me");
const base = friendlyProfilePath(operation.path);
navigate(`/profile/${base}`, { replace: true });
} catch (e: any) {
console.log("[ProfileComponentWrapper] handleSubmit ERROR", e.message, e.response?.status);
setError(e.response?.data?.detail ?? e.message ?? "Operation failed");
} finally {
setLoading(false);
}
};
if (loading && !data && mode !== "create") {
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 4 }}>
<CircularProgress />
</Box>
);
}
if (error && !data && mode !== "create") {
return (
<Box sx={{ maxWidth: 480, mx: "auto", mt: 4 }}>
<Alert severity="error">{error}</Alert>
</Box>
);
}
const props: Record<string, any> = {};
if (mode === "view") {
if (data) {
props.name = data.name;
props.username = data.username;
props.email = data.email;
}
props.onEdit = () => navigate("/profile/me/edit");
props.loading = loading;
}
if (mode === "create") {
props.defaultUsername = data?.username;
props.defaultEmail = data?.email;
props.onBack = () => window.history.back();
props.onSubmit = handleSubmit;
props.loading = loading;
props.error = error;
}
if (mode === "edit") {
if (data) {
props.name = data.name;
props.username = data.username;
props.email = data.email;
}
props.onBack = () => window.history.back();
props.onSubmit = handleSubmit;
props.loading = loading;
props.error = error;
}
return <Component {...props} />;
}

View File

@@ -7,6 +7,8 @@ import {
Paper,
Grid,
CircularProgress,
Tabs,
Tab,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import EditIcon from "@mui/icons-material/Edit";
@@ -14,12 +16,18 @@ import type { ResourceConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { SseStreamView } from "./SseStreamView";
interface ResourceDetailProps {
resource: ResourceConfig;
basePath: string;
}
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate();
const { id } = useParams();
@@ -27,6 +35,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const { resources: allResources } = useAppContext();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [tabIndex, setTabIndex] = useState(0);
useEffect(() => {
if (id) {
@@ -57,6 +66,16 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
const tabs = [{ label: "Details", key: "details" }];
if (resource.subResources) {
for (const subName of resource.subResources) {
const sub = allResources.find((r) => r.name === subName);
if (sub) {
tabs.push({ label: sub.displayName, key: subName });
}
}
}
return (
<Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
@@ -85,25 +104,47 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
)}
</Box>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={2}>
{visibleFields.map((field) => {
let value = data[field.name];
let fmt = resource.displayFormat;
if (field.fk && typeof value === "object") {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") {
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
}
return (
<Grid size={12} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} />
</Grid>
);
})}
</Grid>
</Paper>
{tabs.length > 1 && (
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)} sx={{ mb: 1 }}>
{tabs.map((t) => (
<Tab key={t.key} label={t.label} />
))}
</Tabs>
)}
<TabPanel value={tabIndex} index={0}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={2}>
{visibleFields.map((field) => {
let value = data[field.name];
let fmt = resource.displayFormat;
if (field.fk && typeof value === "object") {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") {
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
}
return (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} basePath={basePath} />
</Grid>
);
})}
</Grid>
</Paper>
</TabPanel>
{tabs.slice(1).map((t, i) => {
const sub = allResources.find((r) => r.name === t.key)!;
const pathParam = sub.parent?.pathParam ?? "id";
return (
<TabPanel key={t.key} value={tabIndex} index={i + 1}>
{sub.streaming ? (
<SseStreamView resource={sub} pathParams={{ [pathParam]: id! }} />
) : null}
</TabPanel>
);
})}
</Box>
);
}

View File

@@ -106,7 +106,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
}
const opts = items.map((item: any) => ({
value: resolvePk(item, targetRes.primaryKey),
value: item[targetRes.primaryKey],
label: applyFormat(item, targetRes.displayFormat),
}));
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
@@ -139,9 +139,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
const targetRes = allResources.find((r) => r.name === rel.config.resource);
if (targetRes) {
if (Array.isArray(val)) {
resolved[rel.fieldName] = val.map((item: any) => resolvePk(item, targetRes.primaryKey));
resolved[rel.fieldName] = val.map((item: any) => item[targetRes.primaryKey]);
} else if (typeof val === "object") {
resolved[rel.fieldName] = resolvePk(val, targetRes.primaryKey);
resolved[rel.fieldName] = val[targetRes.primaryKey];
}
}
if (!rel.config.prefetch) {
@@ -236,8 +236,9 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
<Grid container spacing={2}>
{resource.orderedFields
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
.filter((f) => !f.hidden?.form)
.map((field) => (
<Grid size={12} key={field.name}>
<Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer
field={field}
value={formData[field.name]}
@@ -281,11 +282,6 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
);
}
function resolvePk(item: any, pk: string): any {
const v = item?.[pk];
return v != null ? v : item?.[`_${pk}`];
}
function applyFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));

View File

@@ -323,7 +323,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
}
return (
<TableCell key={col.name}>
<ListCellRenderer field={col} value={value} displayFormat={fmt} />
<ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
</TableCell>
);
})}
@@ -387,12 +387,13 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
{detailRow && (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{visibleColumns.map((col) => (
<Grid key={col.name} size={{ xs: 12, sm: 6 }}>
<DetailFieldRenderer
field={col}
value={detailRow[col.name]}
displayFormat={resource.displayFormat}
/>
<Grid key={col.name} item xs={12} sm={6}>
<DetailFieldRenderer
field={col}
value={detailRow[col.name]}
displayFormat={resource.displayFormat}
basePath={basePath}
/>
</Grid>
))}
</Grid>

View File

@@ -9,9 +9,10 @@ import { SseConnectionStatus } from "./SseConnectionStatus";
interface SseStreamViewProps {
resource: ResourceConfig;
pathParams?: Record<string, string | number>;
}
export function SseStreamView({ resource }: SseStreamViewProps) {
export function SseStreamView({ resource, pathParams }: SseStreamViewProps) {
const { stream } = useResource(resource.name);
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
const [snackbarOpen, setSnackbarOpen] = useState(false);
@@ -31,7 +32,7 @@ export function SseStreamView({ resource }: SseStreamViewProps) {
},
onOpen: () => setSseConnected(resource.name, true),
onError: () => setSseConnected(resource.name, false),
});
}, pathParams);
return () => {
setSseConnected(resource.name, false);

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Box, Typography } from "@mui/material";
import { Box, Typography, Avatar } from "@mui/material";
import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer";
@@ -7,9 +7,10 @@ interface DetailFieldProps {
field: FieldConfig;
value: any;
displayFormat?: string;
basePath?: string;
}
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) {
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
if (field.hidden?.detail) return null;
return (
@@ -17,7 +18,11 @@ export function DetailFieldRenderer({ field, value, displayFormat }: DetailField
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label}
</Typography>
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
{field.uiType === "image" ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
)}
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { TextField } from "@mui/material";
import { TextField, Chip, Box } from "@mui/material";
import type { FieldConfig } from "../../types";
import { StringField } from "./renderers/StringField";
import { NumberField } from "./renderers/NumberField";
@@ -8,8 +8,10 @@ import { BooleanField } from "./renderers/BooleanField";
import { EnumField } from "./renderers/EnumField";
import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { FileUploadField } from "./renderers/FileUploadField";
import { ImageField } from "./renderers/ImageField";
import { JsonField } from "./renderers/JsonField";
import { DiscriminatorField } from "./renderers/DiscriminatorField";
interface FormFieldProps {
field: FieldConfig;
@@ -25,6 +27,16 @@ interface FormFieldProps {
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
if (field.hidden?.form) return null;
if (field.upload) {
return (
<FileUploadField
field={field}
value={value}
onChange={onChange}
/>
);
}
if (field.readOnly && field.uiType !== "image") {
return (
<StringField field={field} value={value} onChange={onChange} error={error} />
@@ -106,6 +118,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
);
}
if (field.oneOfOptions) {
return (
<DiscriminatorField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.refSchema && !field.fk) {
return (
<JsonField
@@ -116,6 +139,32 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
);
}
if (field.type === "array" && !field.fk) {
return (
<Box>
<Chip
label={`${field.label} (${Array.isArray(value) ? value.length : 0})`}
size="small"
color="default"
variant="outlined"
/>
</Box>
);
}
if (field.type === "object" && !field.fk) {
return (
<Box>
<Chip
label={field.label}
size="small"
color="default"
variant="outlined"
/>
</Box>
);
}
return (
<StringField
field={field}

View File

@@ -1,24 +1,71 @@
import React from "react";
import { Box, Typography, Chip, Avatar } from "@mui/material";
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../types";
import { applyDisplayFormat } from "./utils";
import { InlineRefField } from "./renderers/InlineRefField";
import { extractFields } from "../../transformers/field-config";
import { useAppContext } from "../../context/AppContext";
interface ListCellProps {
field: FieldConfig;
value: any;
displayFormat?: string;
basePath?: string;
}
export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) {
export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
const navigate = useNavigate();
const { schemas } = useAppContext();
const [inlineItem, setInlineItem] = useState<any>(null);
if (value === null || value === undefined) {
return <Typography variant="body2" color="text.disabled"></Typography>;
}
const handleFkClick = (e: React.MouseEvent, fkValue: any) => {
e.stopPropagation();
if (!basePath || !field.fk || typeof fkValue !== "object") return;
const id = fkValue?.id;
if (id != null) {
navigate(`${basePath}/${field.fk.resource}/${id}`);
}
};
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />;
return <InlineRefField field={field} value={value} />;
}
const renderInlineItemFields = (itemValue: any) => {
const schema = field.refSchema ? schemas[field.refSchema] : undefined;
let fields: FieldConfig[] = [];
if (field.oneOfOptions && field.discriminatorProperty) {
const opt = field.oneOfOptions.find((o) => o.value === itemValue?.[field.discriminatorProperty!]);
if (opt) fields = opt.fields;
} else if (schema && field.refSchema) {
fields = extractFields(field.refSchema, schema, schemas);
}
return (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{fields.map((sf) => {
const fv = itemValue?.[sf.name];
return (
<Grid key={sf.name} item xs={12} sm={6}>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{sf.label}
</Typography>
<Typography variant="body2">
{fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : String(fv)}
</Typography>
</Box>
</Grid>
);
})}
</Grid>
);
};
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
if (value.length === 0) {
return <Typography variant="body2" color="text.disabled"></Typography>;
@@ -29,14 +76,40 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
const label = typeof item === "object"
? applyDisplayFormat(item, displayFormat ?? "")
: String(item);
return <Chip key={i} label={label} size="small" variant="outlined" />;
return (
<Chip
key={i}
label={label}
size="small"
variant="outlined"
onClick={(e) => { e.stopPropagation(); setInlineItem(item); }}
sx={{ cursor: "pointer" }}
/>
);
})}
<Dialog open={!!inlineItem} onClose={() => setInlineItem(null)} maxWidth="sm" fullWidth>
<DialogTitle>{field.label}</DialogTitle>
<DialogContent dividers>
{inlineItem && renderInlineItemFields(inlineItem)}
</DialogContent>
<DialogActions>
<Button onClick={() => setInlineItem(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
);
}
if (field.fk && typeof value === "object" && !field.isArray) {
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
return (
<Chip
label={applyDisplayFormat(value, displayFormat ?? "")}
size="small"
variant="outlined"
onClick={(e) => handleFkClick(e, value)}
sx={basePath ? { cursor: "pointer" } : undefined}
/>
);
}
if (field.isArray && Array.isArray(value) && field.fk) {
@@ -44,7 +117,16 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{value.map((item: any, i: number) => {
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
return <Chip key={i} label={label} size="small" variant="outlined" />;
return (
<Chip
key={i}
label={label}
size="small"
variant="outlined"
onClick={(e) => handleFkClick(e, item)}
sx={basePath ? { cursor: "pointer" } : undefined}
/>
);
})}
</Box>
);

View File

@@ -12,16 +12,20 @@ interface Props {
export function DateField({ field, value, onChange, error }: Props) {
const inputType = field.format === "date" ? "date" : "datetime-local";
const normalized = field.format === "date-time" && typeof value === "string"
? value.replace(/\.\d+Z$/, "").replace(/Z$/, "")
: value;
return (
<TextField
fullWidth
label={field.label}
type={inputType}
value={value ?? ""}
value={normalized ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
helperText={error ?? field.description}
placeholder={field.description}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
InputLabelProps={{ shrink: true }}

View File

@@ -0,0 +1,74 @@
import React, { useCallback } from "react";
import { Box, FormControl, InputLabel, Select, MenuItem, Typography } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { FormFieldRenderer } from "../FormFieldRenderer";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function DiscriminatorField({ field, value, onChange, error }: Props) {
const options = field.oneOfOptions ?? [];
const discProp = field.discriminatorProperty ?? "type";
const currentType = value?.[discProp] ?? "";
function defaultFieldValue(field: FieldConfig): any {
if (field.enumValues) return field.enumValues[0];
if (field.isArray) return [];
if (field.type === "object") return {};
if (field.type === "number" || field.type === "integer") return 0;
return null;
}
const handleTypeChange = useCallback((e: any) => {
const newType = e.target.value;
const option = options.find((o) => o.value === newType);
const newValue: Record<string, any> = { [discProp]: newType };
if (option) {
for (const f of option.fields) {
newValue[f.name] = defaultFieldValue(f);
}
}
onChange(newValue);
}, [discProp, onChange, options]);
const handleFieldChange = useCallback((fieldName: string, fieldValue: any) => {
onChange({ ...(value ?? {}), [fieldName]: fieldValue });
}, [onChange, value]);
const activeFields = options.find((o) => o.value === currentType)?.fields ?? [];
return (
<Box>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel>{field.label}</InputLabel>
<Select
value={currentType}
label={field.label}
onChange={handleTypeChange}
error={!!error}
>
<MenuItem value="" disabled>Select type</MenuItem>
{options.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
))}
</Select>
</FormControl>
{currentType && activeFields.length > 0 && (
<Box sx={{ pl: 2, borderLeft: "2px solid", borderColor: "divider" }}>
{activeFields.map((f) => (
<FormFieldRenderer
key={f.name}
field={f}
value={value?.[f.name]}
onChange={(v) => handleFieldChange(f.name, v)}
/>
))}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
}
const acceptMap: Record<string, string> = {
image: "image/*",
pdf: ".pdf",
csv: ".csv",
html: ".html,.htm",
};
export function FileUploadField({ field, value, onChange }: Props) {
const uploadConfig = field.upload!;
const inputRef = React.useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer);
const api = getApi();
const res = await api.post(uploadConfig.url, binary, {
headers: {
"Content-Type": file.type,
"Content-Disposition": `attachment; filename="${file.name}"`,
},
});
onChange(res.data.saved_as);
if (inputRef.current) inputRef.current.value = "";
};
const handleReplace = () => {
onChange(null);
setTimeout(() => inputRef.current?.click(), 0);
};
const accept = acceptMap[uploadConfig.type] ?? undefined;
return (
<Box>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{field.label}
</Typography>
{value ? (
uploadConfig.type === "image" ? (
<Avatar src={`/uploads/${value}`} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Chip
label={value}
component="a"
href={`/uploads/${value}`}
clickable
onDelete={handleReplace}
/>
)
) : (
<Button variant="outlined" component="label" size="small">
Upload {field.label}
<input type="file" hidden accept={accept} onChange={handleUpload} ref={inputRef} />
</Button>
)}
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box>
);
}

View File

@@ -1,5 +1,6 @@
import React from "react";
import { TextField, Autocomplete } from "@mui/material";
import React, { useState, useMemo } from "react";
import { TextField, Autocomplete, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { FieldConfig } from "../../../types";
interface Props {
@@ -12,20 +13,86 @@ interface Props {
}
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) {
console.log(`[FkMultiSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} value=${JSON.stringify(value)}`);
const [open, setOpen] = useState(false);
const [frozenValue, setFrozenValue] = useState<any[]>([]);
const handleOpen = () => {
onOpen?.();
setFrozenValue(value ?? []);
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const sortedOptions = useMemo(() => {
const sel = new Set(frozenValue);
const picked: { value: any; label: string }[] = [];
const rest: { value: any; label: string }[] = [];
for (const opt of fkOptions ?? []) {
(sel.has(opt.value) ? picked : rest).push(opt);
}
return [...picked, ...rest];
}, [fkOptions, frozenValue]);
return (
<Autocomplete
multiple
options={fkOptions ?? []}
disableCloseOnSelect
open={open}
onOpen={handleOpen}
onClose={handleClose}
options={sortedOptions}
getOptionLabel={(o) => o.label}
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
onOpen={() => onOpen?.()}
loading={fkLoading}
renderOption={(props, option, { selected }) => (
<li {...props}>
{selected ? (
<DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} />
) : (
<Box sx={{ width: 22, mr: 1 }} />
)}
{option.label}
</li>
)}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return (
<Chip
key={key}
{...tagProps}
label={tag.label.length > 10 ? `${tag.label.slice(0, 8)}..` : tag.label}
size="small"
onClick={open ? handleClose : handleOpen}
sx={{ cursor: "pointer" }}
/>
);
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={open ? handleClose : handleOpen}
sx={{ cursor: "pointer" }}
/>
)}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={field.label} helperText={field.description} size="small" />
<TextField {...params} label={field.label} helperText={field.description || undefined} size="small" />
)}
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
disabled={field.readOnly}
/>
);

View File

@@ -54,7 +54,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
<input type="file" hidden accept="image/*" onChange={handleUpload} />
</Button>
)}
<FormHelperText>{field.description}</FormHelperText>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box>
);
}

View File

@@ -1,38 +1,83 @@
import React from "react";
import { Box, Typography, Chip } from "@mui/material";
import React, { useState } from "react";
import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils";
import { extractFields } from "../../../transformers/field-config";
import { useAppContext } from "../../../context/AppContext";
interface Props {
field: FieldConfig;
value: any;
displayFormat?: string;
}
export function InlineRefField({ field, value, displayFormat }: Props) {
const displayLabels: Record<string, string> = {
basic: "Basic",
heart_rate: "Heart Rate",
dental: "Dental",
vaccine: "Vaccine",
preop: "PreOp",
surgery: "Surgery",
};
export function InlineRefField({ field, value }: Props) {
const [open, setOpen] = useState(false);
const { schemas } = useAppContext();
if (!value || typeof value !== "object") {
return <Typography variant="body2" color="text.disabled"></Typography>;
}
if (displayFormat) {
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>;
}
const discProp = field.discriminatorProperty;
const discValue = discProp ? value[discProp] : undefined;
const discChip = discValue ? displayLabels[discValue] ?? discValue : undefined;
const tooltip = field.inlineDisplayFormat
? applyDisplayFormat(value, field.inlineDisplayFormat)
: undefined;
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined);
if (entries.length === 0) {
return <Typography variant="body2" color="text.disabled"></Typography>;
const schema = field.refSchema ? schemas[field.refSchema] : undefined;
let subFields: FieldConfig[] = [];
if (field.oneOfOptions && field.discriminatorProperty) {
const activeOption = field.oneOfOptions.find((o) => o.value === value[field.discriminatorProperty!]);
if (activeOption) subFields = activeOption.fields;
} else if (schema && field.refSchema) {
subFields = extractFields(field.refSchema, schema, schemas);
}
return (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{entries.map(([key, v]) => (
<>
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
{discChip && <Chip label={discChip} size="small" color="primary" variant="outlined" />}
<Chip
key={key}
label={`${key}: ${String(v)}`}
label={field.label}
title={tooltip}
size="small"
color="primary"
variant="outlined"
onClick={() => setOpen(true)}
sx={{ cursor: "pointer" }}
/>
))}
</Box>
</Box>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{field.label}</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{subFields.map((sf) => (
<Grid key={sf.name} item xs={12} sm={6}>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{sf.label}
</Typography>
<Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : String(value[sf.name])}</Typography>
</Box>
</Grid>
))}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</>
);
}

View File

@@ -17,6 +17,7 @@ import type { FieldConfig } from "../../../types";
import { useAppContext } from "../../../context/AppContext";
import { extractFields } from "../../../transformers/field-config";
import { FormFieldRenderer } from "../FormFieldRenderer";
import { applyDisplayFormat } from "../utils";
interface JsonFieldProps {
field: FieldConfig;
@@ -78,8 +79,8 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (!open) {
if (value === null || value === undefined) {
return (
<Button variant="outlined" onClick={handleOpen} size="small">
Set {field.label}
<Button variant="outlined" onClick={handleOpen} size="small" startIcon={field.isArray ? <AddIcon /> : undefined}>
{field.isArray ? `Add ${field.label}` : `Set ${field.label}`}
</Button>
);
}
@@ -87,14 +88,14 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (field.isArray && Array.isArray(value)) {
if (value.length === 0) {
return (
<Button variant="outlined" onClick={handleOpen} size="small">
Set {field.label}
<Button variant="outlined" onClick={handleOpen} size="small" startIcon={<AddIcon />}>
Add {field.label}
</Button>
);
}
return (
<Chip
label={`${value.length} item${value.length !== 1 ? "s" : ""}`}
label={`${field.label} (${value.length})`}
size="small"
color="primary"
variant="outlined"
@@ -105,15 +106,13 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
}
if (typeof value === "object") {
const summary = field.inlineDisplayFormat
? applyInlineFormat(value, field.inlineDisplayFormat)
: Object.entries(value)
.filter(([, v]) => v != null)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(" | ");
const tooltip = field.inlineDisplayFormat
? applyDisplayFormat(value, field.inlineDisplayFormat)
: undefined;
return (
<Chip
label={summary || field.label}
label={field.label}
title={tooltip}
size="small"
color="primary"
variant="outlined"
@@ -244,6 +243,10 @@ function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>):
const refSchemaObj = schemas[f.refSchema!];
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas);
} else if (f.isArray) {
shape[f.name] = [];
} else if (f.type === "object") {
shape[f.name] = {};
} else {
shape[f.name] = null;
}
@@ -255,7 +258,7 @@ function initEditValue(value: any, field: FieldConfig, schemas: Record<string, a
if (field.isArray) {
return value ? value.map((item: any) => ({ ...item })) : [];
}
if (value && typeof value === "object") {
if (value != null && value !== "" && typeof value === "object") {
return { ...value };
}
return buildDefaultShape(
@@ -264,7 +267,4 @@ function initEditValue(value: any, field: FieldConfig, schemas: Record<string, a
);
}
function applyInlineFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
}

View File

@@ -27,8 +27,8 @@ export function NumberField({ field, value, onChange, error }: Props) {
}
}}
error={!!error}
helperText={error ?? field.description}
placeholder={field.description}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
inputProps={isFloat ? { step: "any" } : undefined}

View File

@@ -20,8 +20,8 @@ export function StringField({ field, value, onChange, error }: Props) {
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
helperText={error ?? field.description}
placeholder={field.description}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
/>

View File

@@ -1,9 +1,12 @@
import { createContext, useContext } from "react";
import type { ResourceConfig, SpecConfiguration, ValidationMessage } from "../types";
import type { AuthConfig, ProfileComponents, ProfileOperation, ResourceConfig, SpecConfiguration, ValidationMessage } from "../types";
export interface AppContextValue {
config: SpecConfiguration;
resources: ResourceConfig[];
profileOperations: ProfileOperation[];
profileComponents: ProfileComponents;
authConfig: AuthConfig;
schemas: Record<string, any>;
loading: boolean;
errors: ValidationMessage[];

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from "react";
import type { SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import type { AuthConfig, ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import { AppContext } from "./AppContext";
import { loadSpec } from "../spec-loader";
import { validateSpec } from "../spec-validator";
@@ -11,13 +11,58 @@ interface AppProviderProps {
children: React.ReactNode;
}
function extractAuthConfig(spec: any): AuthConfig {
const bearer: Record<string, any> =
spec?.components?.securitySchemes?.bearerAuth ?? {};
return {
serverUrl: bearer["x-server-url"] ?? "",
loginPath: bearer["x-login-path"] ?? "/login",
registerPath: bearer["x-register-path"] ?? "/register",
logoutPath: bearer["x-logout-path"] ?? "/logout",
mePath: bearer["x-me-path"] ?? "/me",
introspectPath: bearer["x-introspect-path"] ?? "/introspect",
};
}
function extractProfileOperations(spec: any): ProfileOperation[] {
const ops: ProfileOperation[] = [];
const paths = spec.paths ?? {};
for (const [path, methods] of Object.entries<Record<string, any>>(paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (method.startsWith("x-")) continue;
if (operation["x-profile"] === true) {
ops.push({
path,
method: method.toUpperCase(),
operationId: operation.operationId ?? "",
summary: operation.summary,
});
}
}
}
return ops;
}
const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "",
loginPath: "/login",
registerPath: "/register",
logoutPath: "/logout",
mePath: "/me",
introspectPath: "/introspect",
};
export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const [loading, setLoading] = useState(true);
const [resources, setResources] = useState<ResourceConfig[]>([]);
const [profileOperations, setProfileOperations] = useState<ProfileOperation[]>([]);
const [authConfig, setAuthConfig] = useState<AuthConfig>(DEFAULT_AUTH_CONFIG);
const [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
const profileComponents = specConfiguration.profileComponents ?? {};
useEffect(() => {
let cancelled = false;
@@ -36,6 +81,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
setErrors(errs);
setWarnings(warns);
setSchemas(spec.components?.schemas ?? {});
setProfileOperations(extractProfileOperations(spec));
setAuthConfig(extractAuthConfig(spec));
}
if (errs.length === 0) {
@@ -51,7 +98,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
}
} catch (e: any) {
if (!cancelled) {
setErrors([{ type: "error", message: e.message ?? "Failed to load spec" }]);
const lines = (e.message ?? "Failed to load spec").split("\n");
setErrors(lines.map((msg: string) => ({ type: "error" as const, message: msg })));
}
} finally {
if (!cancelled) {
@@ -71,12 +119,15 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
() => ({
config: specConfiguration,
resources,
profileOperations,
profileComponents,
authConfig,
schemas,
loading,
errors,
warnings,
}),
[specConfiguration, resources, schemas, loading, errors, warnings]
[specConfiguration, resources, profileOperations, profileComponents, authConfig, schemas, loading, errors, warnings]
);
return React.createElement(AppContext.Provider, { value }, children);

View File

@@ -1,8 +1,10 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { Autocomplete, TextField } from "@mui/material";
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";
@@ -10,6 +12,7 @@ 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) {
@@ -53,8 +56,9 @@ interface UseResourceReturn {
get: (id: string | number, params?: Record<string, any>) => Promise<any>;
create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>;
patch?: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers) => StreamSubscription;
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
loading: boolean;
error: string | null;
}
@@ -266,51 +270,297 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
);
}
function buildAutocompleteFilter(getDisplayValue: (row: any) => string) {
const StringAutocompleteFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
// ── text filter (freeSolo, no dropdown) ──────────────────────
function buildTextFilter() {
const TextFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
return (
<Autocomplete
freeSolo
size="small"
options={[]}
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
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<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
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 (
<Autocomplete
multiple
freeSolo
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={open}
onOpen={() => { 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 (
<li key={key} {...rest}>
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
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<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
const [step, setStep] = useState<"local" | "domain">("local");
const [pendingLocal, setPendingLocal] = useState<string | null>(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 (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={step === "domain"}
onClose={() => { 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 <Chip key={key} {...tagProps} label={tag.length > 18 ? `${tag.slice(0, 16)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={labelOverride ?? field.label}
placeholder={step === "domain" && pendingLocal ? "Select email domain..." : undefined}
size="small"
/>
)}
/>
);
};
return EmailFilter;
}
// ── phone filter (multi-select, digits only, suggestions from current page) ─
function buildPhoneFilter() {
const PhoneFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
const pageTokens = useMemo(() => {
if (!data) return [];
const tokens = new Set<string>();
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 (
<Autocomplete
multiple
freeSolo
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={open}
onOpen={() => { 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 (
<li key={key} {...rest}>
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
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<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const [options, setOptions] = useState<string[]>([]);
const fetched = useRef(false);
useEffect(() => {
if (filterMode === "client" && data) {
const extract = (items: any[]) => {
const vals = new Set<string>();
for (const row of data) {
const v = getDisplayValue(row);
if (v && v !== "") vals.add(v);
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);
}
setOptions([...vals].sort());
return [...vals].sort();
};
if (filterMode === "client" && data) {
setOptions(extract(data));
fetched.current = true;
} else if (filterMode === "server" && !fetched.current) {
const cacheKey = resourceName + ":" + field.name;
if (_stringOptionsCache.has(cacheKey)) {
setOptions(_stringOptionsCache.get(cacheKey)!);
fetched.current = true;
} else {
(async () => {
try {
const api = getApi();
const selfRes = resources.find((r) => r.name === resourceName);
if (!selfRes) { fetched.current = true; return; }
const params: Record<string, any> = {};
if (selfRes.pagination) params.limit = 0;
const res = await api.get(selfRes.path, { params });
let items: any[];
if (selfRes.pagination) {
items = Array.isArray(res.data) ? res.data : (res.data.items ?? []);
} else {
items = Array.isArray(res.data) ? res.data : [];
}
const values = [...new Set(items.map((r: any) => getDisplayValue(r)).filter(Boolean))].sort();
_stringOptionsCache.set(cacheKey, values);
setOptions(values);
fetched.current = true;
} catch {
fetched.current = true;
}
})();
}
(async () => {
try {
const api = getApi();
const selfRes = resources.find((r) => r.name === resourceName);
if (!selfRes) { fetched.current = true; return; }
const params: Record<string, any> = {};
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]);
@@ -322,33 +572,12 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
<TextField
{...params}
label={labelOverride ?? field.label}
size="small"
/>
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
return StringAutocompleteFilter;
}
const isSimpleField =
!field.fk && !field.enumValues &&
field.type !== "boolean" && field.type !== "integer" && field.type !== "number" &&
field.format !== "date" && field.format !== "date-time";
if (isSimpleField && !field.refSchema) {
return buildAutocompleteFilter((row) => String(row[field.name] ?? ""));
}
if (field.refSchema && field.inlineDisplayFormat) {
return buildAutocompleteFilter((row) => {
const val = row[field.name];
if (val == null || typeof val !== "object") return "";
return field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_, key) => String(val[key] ?? ""));
});
return RefFilter;
}
return ({ value, onChange, labelOverride }) => (
@@ -361,14 +590,14 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
}
export function useResource(resourceName: string): UseResourceReturn {
const { resources } = useAppContext();
const { resources, schemas } = useAppContext();
const resource = resources.find((r) => r.name === resourceName);
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
const rPath = resource?.path;
const rPagination = resource?.pagination;
const rUpdateMethod = resource?.updateMethod;
const rPatch = resource ? (resource.operations.patch || undefined) : false;
const rStreaming = resource?.streaming;
const rFields = resource?.fields;
@@ -434,6 +663,18 @@ export function useResource(resourceName: string): UseResourceReturn {
[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<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
@@ -441,7 +682,10 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null);
try {
const api = getApi();
const res = await api.post(rPath, data);
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));
@@ -450,7 +694,7 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false);
}
},
[rPath, setLoading, setError]
[rPath, rFields, schemas, resolveFk, setLoading, setError]
);
const update = useCallback(
@@ -460,8 +704,10 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null);
try {
const api = getApi();
const method = rUpdateMethod ?? "put";
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, data);
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));
@@ -470,7 +716,29 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false);
}
},
[rPath, rUpdateMethod, setLoading, setError]
[rPath, rFields, schemas, resolveFk, setLoading, setError]
);
const _patch = useCallback(
async (id: string | number, data: any): Promise<any> => {
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(
@@ -492,13 +760,19 @@ export function useResource(resourceName: string): UseResourceReturn {
);
const stream = useCallback(
(handlers: StreamHandlers): StreamSubscription => {
(handlers: StreamHandlers, pathParams?: Record<string, string | number>): StreamSubscription => {
if (!rPath || !rStreaming) {
throw new Error(`Resource "${resourceName}" does not support streaming`);
}
const api = getApi();
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
const url = baseUrl + rPath;
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?.();
@@ -539,6 +813,7 @@ export function useResource(resourceName: string): UseResourceReturn {
get,
create,
update,
patch: undefined,
remove,
stream: undefined,
loading: false,
@@ -546,5 +821,5 @@ export function useResource(resourceName: string): UseResourceReturn {
};
}
return { resource, components, list, get, create, update, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
return { resource, components, list, get, create, update, patch: rPatch ? _patch : undefined, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
}

View File

@@ -1,13 +1,16 @@
import axios, { AxiosInstance } from "axios";
import { tokenStore } from "../../../react-auth/token";
let apiClient: AxiosInstance | null = null;
let _onUnauthorized: (() => void) | undefined;
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance {
export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized;
return apiClient;
}
_onUnauthorized = onUnauthorized;
apiClient = axios.create({
baseURL: baseUrl,
headers: { "Content-Type": "application/json" },
@@ -24,11 +27,12 @@ export function initApi(baseUrl: string, getToken?: () => string | null): AxiosI
apiClient.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401 && getToken) {
const currentToken = getToken();
if (currentToken) {
tokenStore.clear();
}
if (error.response?.status === 401) {
console.log("[useApi] 401 from %s %s - dispatching auth:unauthorized", error.config?.method, error.config?.url);
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
_onUnauthorized?.();
} else {
console.log("[useApi] non-401 error %s from %s %s", error.response?.status, error.config?.method, error.config?.url);
}
return Promise.reject(error);
}

View File

@@ -1,5 +1,21 @@
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
function getSegments(path: string): string[] {
return path.split("/").filter(Boolean);
}
function getResponseSchemaRef(pathObj: any): string | undefined {
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"]
?? pathObj?.post?.responses?.["200"] ?? pathObj?.post?.responses?.["201"];
const content = response?.content;
if (!content) return;
for (const mediaType of Object.values(content) as any[]) {
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
}
}
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
const messages: ValidationMessage[] = [];
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
@@ -17,114 +33,106 @@ export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration):
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
}
for (const [schemaName, schema] of Object.entries(schemas)) {
if (!schema || typeof schema !== "object") continue;
for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
if (!pathObj || typeof pathObj !== "object") continue;
const isResource = typeof schema["x-resource"] === "string";
const segments = getSegments(path);
const lastSeg = segments[segments.length - 1];
const isItemPath = /^\{.*\}$/.test(lastSeg);
const paramIdx = segments.findIndex((s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s));
const isSubResource = paramIdx >= 0 && !isItemPath;
if (!isResource) continue;
const hasSSE = pathObj?.get?.["x-sse"] === true;
if (hasSSE) continue;
const resourcePath = `/${schema["x-resource"]}`;
// Skip paths where all operations are profile-only (x-profile: true).
const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
if (allProfile) continue;
if (!schema["x-primary-key"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
}
if (!schema["x-display-format"]) {
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` });
}
if (!schema["x-list-columns"]) {
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-list-columns'` });
}
if (Array.isArray(schema["x-list-columns"])) {
const props = schema.properties ?? {};
for (const col of schema["x-list-columns"]) {
if (!props[col]) {
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
if (isItemPath || isSubResource) {
const responseRef = getResponseSchemaRef(pathObj);
if (responseRef) {
const schemaName = responseRef.split("/").pop()!;
if (!schemas[schemaName]) {
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
}
}
}
const props = schema.properties ?? {};
for (const [propName, _raw] of Object.entries(props)) {
const prop = _raw as any;
if (!prop || typeof prop !== "object") continue;
if (!prop["x-label"]) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
}
if (prop["x-order"] === undefined || prop["x-order"] === null) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
}
if (prop["$ref"] && !prop["x-fk"]) {
const refName = (prop["$ref"] as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
}
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
const refName = (prop.items.$ref as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
}
if (prop["x-fk"]) {
const fkResource = prop["x-fk"].resource as string;
const targetSchema = Object.entries(schemas as Record<string, any>).find(([, s]) => s?.["x-resource"] === fkResource);
if (!targetSchema) {
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no schema has x-resource="${fkResource}"` });
} else {
const [, target] = targetSchema;
if (!target["x-display-format"]) {
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-display-format'` });
}
if (!target["x-primary-key"]) {
messages.push({ type: "error", message: `FK target "${fkResource}" (referenced by "${schemaName}.${propName}") is missing 'x-primary-key'` });
}
}
}
}
if (!paths[resourcePath]) {
messages.push({ type: "error", message: `x-resource "${schema["x-resource"]}" points to path "${resourcePath}" but no such path exists` });
continue;
}
const collectionPath = paths[resourcePath] as any;
const responseRef = getResponseSchemaRef(pathObj);
if (responseRef) {
const schemaName = responseRef.split("/").pop()!;
const schema = schemas[schemaName];
if (!schema) {
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
continue;
}
if (!collectionPath?.get) {
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
if (!schema["x-primary-key"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
}
if (!schema["x-display-format"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-display-format'` });
}
if (!schema["x-list-columns"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-list-columns'` });
}
if (Array.isArray(schema["x-list-columns"])) {
const props = schema.properties ?? {};
for (const col of schema["x-list-columns"]) {
if (!props[col]) {
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
}
}
}
const props = schema.properties ?? {};
for (const [propName, _raw] of Object.entries(props)) {
const prop = _raw as any;
if (!prop || typeof prop !== "object") continue;
if (!prop["x-label"]) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
}
if (prop["x-order"] === undefined || prop["x-order"] === null) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
}
if (prop["$ref"] && !prop["x-fk"]) {
const refName = (prop["$ref"] as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
}
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
const refName = (prop.items.$ref as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
}
if (prop["x-fk"]) {
const fkResource = prop["x-fk"].resource as string;
const fkPaths = Object.keys(paths).filter((p) => !/^\{.*\}$/.test(getSegments(p).pop() ?? ""));
const targetExists = fkPaths.some((p) => getSegments(p).pop() === fkResource);
if (!targetExists) {
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no path matches that resource name` });
}
}
}
}
const isSSE = collectionPath?.get?.["x-sse"] === true;
if (isSSE) continue;
if (!pathObj?.get) {
messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` });
}
const listParams = collectionPath?.get?.parameters ?? [];
const listParams = pathObj?.get?.parameters ?? [];
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
if (limitParam || offsetParam) {
if (!limitParam?.schema?.default) {
messages.push({ type: "error", message: `"${resourcePath}.get" has pagination params but 'limit' schema is missing 'default'` });
messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` });
}
}
if (!collectionPath?.post) {
messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` });
}
const itemPath = paths[`${resourcePath}/{id}`] as any;
if (!itemPath) {
messages.push({ type: "error", message: `No path "${resourcePath}/{id}" found — detail/update/delete not possible` });
} else {
if (!itemPath?.get) {
messages.push({ type: "error", message: `"${resourcePath}/{id}" has no GET endpoint — detail view not possible` });
}
if (!itemPath?.put) {
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no PUT endpoint — update not available` });
}
if (!itemPath?.delete) {
messages.push({ type: "info", message: `"${resourcePath}/{id}" has no DELETE endpoint — deletion not available` });
}
if (!pathObj?.post) {
messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` });
}
}

View File

@@ -1,9 +1,89 @@
import type { FieldConfig } from "../types";
import type { FieldConfig, OneOfOption } from "../types";
const _validationErrors: string[] = [];
export function clearValidationErrors(): void {
_validationErrors.length = 0;
}
export function getValidationErrors(): string[] {
return [..._validationErrors];
}
function resolveRef(ref: string): string | undefined {
return ref.split("/").pop();
}
function resolveAllOf(schema: any, schemas: Record<string, any>): any {
if (!schema || !schema.allOf) return schema;
const merged: any = { type: "object", properties: {}, required: [] };
for (const entry of schema.allOf) {
const resolved = entry.$ref
? resolveAllOf(schemas[resolveRef(entry.$ref)!] ?? {}, schemas)
: entry;
if (resolved.properties) {
Object.assign(merged.properties, resolved.properties);
}
if (resolved.required) {
merged.required.push(...resolved.required);
}
}
return merged;
}
function extractOneOfOptions(schema: any, schemas: Record<string, any>, discriminatorProperty: string): OneOfOption[] {
if (!schema.oneOf) return [];
const result = schema.oneOf.flatMap((option: any) => {
if (!option.$ref) return [];
const variantName = resolveRef(option.$ref);
if (!variantName) return [];
const variantSchema = schemas[variantName];
if (!variantSchema) return [];
const merged = resolveAllOf(variantSchema, schemas);
const props = merged.properties ?? {};
const requiredFields: string[] = merged.required ?? [];
const discriminatorProp = props[discriminatorProperty];
if (!discriminatorProp?.enum?.[0]) return [];
const value = discriminatorProp.enum[0];
const label = variantName.replace(/Note$/, "").replace(/([A-Z])/g, " $1").trim() || value;
const fields: FieldConfig[] = Object.entries(props)
.filter(([k]) => k !== discriminatorProperty)
.filter(([, p]: [string, any]) => p && typeof p === "object")
.map(([name, prop]: [string, any]) => {
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
const isPlainString = !prop["x-fk"] && !prop.enum && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
if (!autocomplete && isPlainString && prop["x-filterable"]) {
autocomplete = "text";
}
if (autocomplete && !prop["x-filterable"]) {
_validationErrors.push(`[field-config] field "${name}" in oneOf variant has x-autocomplete but is not x-filterable`);
}
return {
name,
label: prop["x-label"] ?? name,
description: prop["x-description"] ?? "",
type: prop.type ?? "string",
format: prop.format,
order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false,
sortable: prop["x-sortable"] ?? false,
readOnly: prop.readOnly ?? false,
required: requiredFields.includes(name),
enumValues: prop.enum,
fk: prop["x-fk"],
uiType: prop["x-ui-type"],
uploadUrl: prop["x-upload-url"],
upload: prop["x-upload"],
isArray: prop.type === "array",
autocomplete,
};
});
return [{ value, label, fields }];
});
return result;
}
export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
const props = schema.properties ?? {};
const requiredFields: string[] = schema.required ?? [];
@@ -30,13 +110,27 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
? refSchema["x-display-format"]
: undefined;
const isDiscriminatedUnion = isRef && refSchema?.oneOf && refSchema?.discriminator;
const discriminatorProperty = isDiscriminatedUnion ? refSchema.discriminator.propertyName : undefined;
const oneOfOptions = isDiscriminatedUnion ? extractOneOfOptions(refSchema, schemas, discriminatorProperty!) : undefined;
let autocomplete = prop["x-autocomplete"] as "text" | "token" | "email" | "phone" | undefined;
const isPlainString = !prop["x-fk"] && !prop.enum && !isRef && prop.type === "string" && prop.format !== "date" && prop.format !== "date-time" && prop.format !== "binary";
if (!autocomplete && isPlainString && prop["x-filterable"]) {
autocomplete = "text";
console.warn(`[field-config] missing x-autocomplete on "${name}" in schema "${schemaName}", defaulting to "text"`);
}
if (autocomplete && !prop["x-filterable"]) {
_validationErrors.push(`[field-config] field "${name}" in schema "${schemaName}" has x-autocomplete but is not x-filterable`);
}
const field: FieldConfig = {
name,
label: prop["x-label"],
description: prop["x-description"] ?? prop["x-label"] ?? name,
description: prop["x-description"] ?? "",
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
format: prop.format,
order: prop["x-order"],
order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false,
sortable: prop["x-sortable"] ?? false,
@@ -46,9 +140,13 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
fk: prop["x-fk"],
uiType: prop["x-ui-type"],
uploadUrl: prop["x-upload-url"],
upload: prop["x-upload"],
refSchema: refSchemaName,
inlineDisplayFormat,
isArray: prop.type === "array",
oneOfOptions,
discriminatorProperty,
autocomplete,
};
return field;

View File

@@ -11,11 +11,7 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
if (!prop["x-fk"]) continue;
const fkResource = prop["x-fk"].resource as string;
const targetEntry = Object.entries(schemas).find(([, s]) => s?.["x-resource"] === fkResource);
const targetSchemaName = targetEntry ? targetEntry[0] : fkResource;
const prefetch = prop["x-fk"].prefetch ?? false;
console.log(`[FK] extracted relationship: field="${name}" target="${fkResource}" prefetch=${prefetch} rawPrefetch=${prop["x-fk"].prefetch}`);
rels.push({
fieldName: name,
@@ -23,10 +19,9 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
resource: fkResource,
prefetch,
},
targetSchemaName,
targetSchemaName: fkResource,
});
}
console.log(`[FK] total relationships extracted: ${rels.length}`);
return rels;
}

View File

@@ -1,5 +1,5 @@
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
import { extractFields } from "./field-config";
import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
import { extractRelationships } from "./relationship-config";
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
@@ -47,62 +47,154 @@ const SSE_RECEIVED_FIELD: FieldConfig = {
isArray: false,
};
function getSegments(path: string): string[] {
return path.split("/").filter(Boolean);
}
function getResponseSchemaRef(pathObj: any): string | undefined {
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"];
const content = response?.content;
if (!content) return;
for (const mediaType of Object.values(content) as any[]) {
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
}
}
function resolveRef(ref: string): string {
return ref.split("/").pop()!;
}
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
clearValidationErrors();
const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {};
const configs: ResourceConfig[] = [];
const nameMap = new Map<string, ResourceConfig>();
for (const [schemaName, schema] of Object.entries(schemas)) {
if (!schema || typeof schema !== "object") continue;
const sortedPaths = Object.keys(paths).sort(
(a, b) => getSegments(a).length - getSegments(b).length
);
const resourceName = schema["x-resource"];
if (!resourceName || typeof resourceName !== "string") continue;
for (const path of sortedPaths) {
const segments = getSegments(path);
const pathObj = paths[path];
const resourcePath = `/${resourceName}`;
const itemPath = `${resourcePath}/{id}`;
const collectionPathObj = paths[resourcePath];
const itemPathObj = paths[itemPath];
// Skip paths where ALL non-parameter operations are profile-only (x-profile: true).
// These are handled by Admin.tsx profile routes, not as CRUD resources.
const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
if (allProfile) continue;
const fields = extractFields(schemaName, schema, schemas);
const relationships = extractRelationships(schema, schemas);
const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
const lastSeg = segments[segments.length - 1];
const isItemPath = /^\{.*\}$/.test(lastSeg);
const paramIdx = segments.findIndex(
(s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s)
);
if (isItemPath) {
const parentName = segments[segments.length - 2];
const parent = nameMap.get(parentName);
if (!parent) continue;
if (hasOperation(pathObj, "get")) parent.operations.get = true;
if (hasOperation(pathObj, "put")) parent.operations.update = true;
if (hasOperation(pathObj, "patch")) parent.operations.patch = true;
if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
continue;
}
if (paramIdx >= 0) {
const resourceName = lastSeg;
const parentName = segments[paramIdx - 1];
const pathParamName = segments[paramIdx].replace(/[{}]/g, "");
const responseRef = getResponseSchemaRef(pathObj);
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
const schema = schemaName ? schemas[schemaName] : undefined;
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
const hasSSE = pathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = {
name: resourceName,
schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName),
path,
primaryKey: schema?.["x-primary-key"] ?? "_received_at",
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
listColumns: schema?.["x-list-columns"] ?? [],
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [],
operations: hasSSE
? { list: true, get: false, create: false, update: false, patch: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, patch: false, delete: false },
updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj),
relationships: [],
streaming: hasSSE || undefined,
parent: { resource: parentName, pathParam: pathParamName },
};
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) {
resource.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at";
}
const parent = nameMap.get(parentName);
if (parent) {
parent.subResources = parent.subResources ?? [];
parent.subResources.push(resourceName);
}
nameMap.set(resourceName, resource);
configs.push(resource);
continue;
}
const resourceName = lastSeg;
const responseRef = getResponseSchemaRef(pathObj);
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
const schema = schemaName ? schemas[schemaName] : undefined;
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
const relationships = schema ? extractRelationships(schema, schemas) : [];
const hasSSE = pathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = {
name: resourceName,
schemaName,
schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName),
path: resourcePath,
primaryKey: schema["x-primary-key"],
displayFormat: schema["x-display-format"],
listColumns: schema["x-list-columns"],
fields,
orderedFields: sortFields(fields),
operations: {
list: hasOperation(collectionPathObj, "get"),
get: hasOperation(itemPathObj, "get"),
create: hasOperation(collectionPathObj, "post"),
update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"),
delete: hasOperation(itemPathObj, "delete"),
},
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
pagination: detectPagination(collectionPathObj),
path,
primaryKey: schema?.["x-primary-key"] ?? "id",
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
listColumns: schema?.["x-list-columns"] ?? [],
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [],
operations: hasSSE
? { list: true, get: false, create: false, update: false, patch: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, patch: false, delete: false },
updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj),
relationships,
streaming: hasSSE || undefined,
};
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) {
resource.operations = { list: true, get: false, create: false, update: false, delete: false };
resource.updateMethod = "put";
resource.pagination = null;
resource.relationships = [];
resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
resource.orderedFields = sortFields(resource.fields);
resource.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at";
}
nameMap.set(resourceName, resource);
configs.push(resource);
}
const errors = getValidationErrors();
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
return configs;
}

View File

@@ -6,12 +6,37 @@ export interface ResourceConfiguration {
};
}
export interface ProfileComponents {
create?: React.ComponentType<any>;
edit?: React.ComponentType<any>;
view?: React.ComponentType<any>;
}
export interface SpecConfiguration {
specUrl: string;
baseApiUrl?: string;
title?: string;
getToken?: () => string | null;
resourceConfig?: Record<string, ResourceConfiguration>;
profileComponents?: ProfileComponents;
}
/** Auth server config extracted from securitySchemes extensions. */
export interface AuthConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
/** Represents a single operation marked with `x-profile: true` in the spec. */
export interface ProfileOperation {
path: string;
method: string;
operationId: string;
summary?: string;
}
export interface ValidationMessage {
@@ -40,6 +65,7 @@ export interface ResourceConfig {
get: boolean;
create: boolean;
update: boolean;
patch: boolean;
delete: boolean;
};
updateMethod: "put" | "patch";
@@ -50,6 +76,19 @@ export interface ResourceConfig {
} | null;
relationships: ResourceRelationship[];
streaming?: boolean;
parent?: { resource: string; pathParam: string };
subResources?: string[];
}
export interface FKFieldConfig {
resource: string;
prefetch: boolean;
}
export interface OneOfOption {
value: string;
label: string;
fields: FieldConfig[];
}
export interface FieldConfig {
@@ -68,14 +107,13 @@ export interface FieldConfig {
fk?: FKFieldConfig;
uiType?: string;
uploadUrl?: string;
upload?: { type: string; url: string };
refSchema?: string;
inlineDisplayFormat?: string;
isArray: boolean;
}
export interface FKFieldConfig {
resource: string;
prefetch: boolean;
oneOfOptions?: OneOfOption[];
discriminatorProperty?: string;
autocomplete?: "text" | "token" | "email" | "phone";
}
export interface OpenApiSpec {

View File

@@ -0,0 +1,46 @@
export function tokenize(text: string): string[] {
return text.split(/[,\s.]+/).filter(Boolean);
}
export function stripNonDigits(text: string): string {
return text.replace(/\D/g, "");
}
export function extractTokens(data: any[], fieldName: string): string[] {
const all = new Set<string>();
for (const row of data) {
const val = row[fieldName];
if (val != null && val !== "") {
for (const t of tokenize(String(val))) {
all.add(t);
}
}
}
return [...all].sort();
}
export function extractLocalParts(data: any[], fieldName: string): string[] {
const parts = new Set<string>();
for (const row of data) {
const val = row[fieldName];
if (val != null && val !== "") {
const m = String(val).match(/^([^@]+)@/);
if (m) parts.add(m[1]);
}
}
return [...parts].sort();
}
export function extractDomains(data: any[], fieldName: string, localPart: string): string[] {
const domains = new Set<string>();
const escaped = localPart.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped}@(.+)$`);
for (const row of data) {
const val = row[fieldName];
if (val != null && val !== "") {
const m = String(val).match(re);
if (m) domains.add(m[1]);
}
}
return [...domains].sort();
}

View File

@@ -0,0 +1,67 @@
import type { FieldConfig } from "../types";
import { extractFields } from "../transformers/field-config";
export type FkResolver = (resourceName: string, fkValue: any) => Promise<any>;
export async function sanitizePayload(
data: any,
fields: FieldConfig[],
schemas: Record<string, any>,
resolveFk?: FkResolver,
): Promise<any> {
if (data == null || typeof data !== "object") return data;
if (Array.isArray(data)) {
return Promise.all(data.map((item) => {
if (item != null && typeof item === "object") {
return sanitizePayload(item, fields, schemas, resolveFk);
}
return item;
}));
}
const result: Record<string, any> = { ...data };
const fieldMap = new Map(fields.map((f) => [f.name, f]));
for (const key of Object.keys(result)) {
const field = fieldMap.get(key);
if (!field) continue;
const val = result[key];
if (field.fk) {
if (val == null || val === "") {
result[key] = null;
} else if (resolveFk) {
if (field.isArray && Array.isArray(val)) {
result[key] = await Promise.all(val.map((v: any) => resolveFk(field.fk!.resource, v)));
} else {
result[key] = await resolveFk(field.fk!.resource, val);
}
}
continue;
}
if (field.refSchema && !field.fk) {
if (val == null || val === "") {
result[key] = field.isArray ? [] : {};
} else if (typeof val === "object") {
const refSchemaObj = schemas[field.refSchema!];
const nestedFields = refSchemaObj
? extractFields(field.refSchema!, refSchemaObj, schemas)
: [];
result[key] = await sanitizePayload(val, nestedFields, schemas, resolveFk);
}
} else if (field.isArray) {
if (val == null || val === "") {
result[key] = [];
}
} else if (field.type === "object") {
if (val == null || val === "") {
result[key] = {};
}
}
}
return result;
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "index.ts"]
}

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>

View File

@@ -1,642 +0,0 @@
import * as React from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Stepper,
Step,
StepLabel,
LinearProgress,
IconButton,
Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
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 {
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./features/fetch-requests";
import type {
FetchRequestStatus,
SSEEvent,
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useAppContext, useResource, useItemSse } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} />,
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
};
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeProgressPercent(
status: FetchRequestStatus,
liveCount: number,
seenSteps: Set<string>,
stepStats: Record<string, number>,
txnBlockCount: number,
txnDictCount: number,
): number {
if (status === "pending") return 0;
if (status === "completed") return 100;
let pct = 0;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
pct += Math.min(1, current / txnBlockCount) * 20;
}
if (txnDictCount > 0) {
pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50;
pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20;
}
return Math.round(Math.min(100, pct));
}
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return stepLabels.length;
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return stepLabels.length;
if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3;
if (seenSteps.has("enrich/completed")) return 3;
if (seenSteps.has("enrich")) return 2;
if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2;
if (seenSteps.has("txn_dicts")) return 1;
if (seenSteps.has("txn_blocks/completed")) return 1;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0;
if (status === "processing" || status === "paused") return 0;
return -1;
}
function formatProgressMessage(msg: ProgressMessage): string {
if (msg.lines !== undefined) return `${msg.lines} lines`;
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
if (msg.count !== undefined) return `${msg.count} items`;
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
if (msg.error) return msg.error.slice(0, 80);
return "";
}
function sseIcon(status: SSEEvent["status"]) {
switch (status) {
case "started": return <CircularProgress size={14} />;
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return <FiberManualRecordIcon sx={{ fontSize: 14, color: "info.main" }} />;
}
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, update } = useResource("fetch-requests");
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id!),
enabled: !!id,
});
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [failNotif, setFailNotif] = React.useState<string | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null);
const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => {
if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
}
if (parsed.status === "completed" && parsed.message.count !== undefined) {
const stats: Record<string, number> = {};
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
if (Object.keys(stats).length) {
setStepStats((prev) => ({ ...prev, ...stats }));
}
}
if (parsed.status === "paused") {
refetchRequest();
refetchAmbiguities();
}
if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
},
});
React.useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
const txnBlockCount = React.useMemo(() => {
const blocks = (fetchRequest as any)?.source?.txn_blocks;
if (!blocks) return 0;
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}, [fetchRequest]);
const seenSteps = React.useMemo(() => {
const steps = new Set<string>();
for (const evt of sseEvents) {
steps.add(evt.step);
if (evt.status === "completed") steps.add(`${evt.step}/completed`);
if (evt.status === "failed") steps.add(`${evt.step}/failed`);
if (evt.status === "started") steps.add(`${evt.step}/started`);
if (evt.status === "progress") steps.add(`${evt.step}/progress`);
}
return steps;
}, [sseEvents]);
const displayParsedCount = React.useMemo(() => {
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
const source = (fetchRequest as any)?.source;
const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
if (persistedCount > 0) return persistedCount;
const dicts = source?.txn_dicts;
if (Array.isArray(dicts) && dicts.length > 0) return dicts.length;
return 0;
}, [liveParsedCount, fetchRequest]);
const txnDictCount = React.useMemo(() => {
const source = (fetchRequest as any)?.source;
if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts;
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]);
const stepMessages = React.useMemo(() => {
const msgs: Record<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
const progressPercent = React.useMemo(
() => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
displayParsedCount,
seenSteps,
stepStats,
txnBlockCount,
txnDictCount,
),
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
);
const displayEvents = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
for (let i = sseEvents.length - 1; i >= 0; i--) {
const e = sseEvents[i];
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
);
}
return true;
});
}, [sseEvents]);
const handleRetry = async () => {
if (!id) return;
try {
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
} catch (err: any) {
setFailNotif(formatApiError(err));
}
};
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
await resolveMutation.mutateAsync({
ambiguityId: ambiguity.id,
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
});
refetchAmbiguities();
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
<CircularProgress />
</Box>
);
}
if (fetchError || !fetchRequest) {
return (
<Container sx={{ mt: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back
</Button>
<Alert severity="error">Failed to load fetch request</Alert>
</Container>
);
}
const req = fetchRequest as any;
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && ambiguities.every((a: any) => a.status === "resolved");
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
{/* Header Card */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
icon={statusIcons[req.status as FetchRequestStatus] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status as FetchRequestStatus]}
/>
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="caption" color="text.secondary">Date Range</Typography>
<Typography variant="body2">
{req.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} {req.end_date ? new Date(req.end_date).toLocaleDateString() : "?"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Created</Typography>
<Typography variant="body2">{new Date(req.created_at).toLocaleString()}</Typography>
</Box>
{req.completed_at && (
<Box>
<Typography variant="caption" color="text.secondary">Completed</Typography>
<Typography variant="body2">{new Date(req.completed_at).toLocaleString()}</Typography>
</Box>
)}
</Box>
{/* Progress Bar */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Overall Progress</Typography>
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions
</Typography>
)}
</Box>
<LinearProgress
variant="determinate"
value={progressPercent}
color={req.status === "failed" ? "error" : req.status === "completed" ? "success" : "primary"}
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
{progressPercent}%
</Typography>
</Box>
{/* Retry Counter */}
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
Retries: {retryCount}/{RETRY_MAX}
</Typography>
<LinearProgress
variant="determinate"
value={(retryCount / RETRY_MAX) * 100}
color={isRetryExhausted ? "error" : "primary"}
sx={{ mt: 0.5, borderRadius: 1, height: 6 }}
/>
</Box>
{req.status === "failed" && !isRetryExhausted && (
<Button
variant="outlined"
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={updateMutation.isPending}
>
Retry
</Button>
)}
</Box>
</Paper>
{/* Error Alerts */}
{req.status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && req.status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made.
</Alert>
)}
{/* Pipeline Stepper */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{stepLabels.map((label, index) => {
const isCompleted = index < activeStep;
const isActive = index === activeStep;
const isPaused = req.status === "paused" && isActive;
const isFailed = req.status === "failed" && isActive;
let icon: React.ReactNode;
if (isCompleted) {
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
} else if (isFailed) {
icon = <ErrorIcon sx={{ color: "error.main" }} />;
} else if (isPaused) {
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
} else if (isActive) {
icon = <CircularProgress size={20} />;
} else {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
}
return (
<Step key={label}>
<StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
>
<Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMessages[index] && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMessages[index]}
</Typography>
)}
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
{/* SSE Event Feed */}
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
Progress Events
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
p: 2,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{displayEvents.length === 0 ? (
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
Waiting for events...
</Typography>
) : (
displayEvents.map((evt, i) => (
<Box
key={i}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
p: 1,
borderRadius: 2,
bgcolor: "action.hover",
}}
>
{sseIcon(evt.status)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{evt.step.replace(/_/g, " ")}
</Typography>
{evt.message && formatProgressMessage(evt.message) && (
<Typography variant="caption" color="text.secondary">
{formatProgressMessage(evt.message)}
</Typography>
)}
</Box>
</Box>
))
)}
</Box>
</Paper>
{/* Ambiguity Resolution */}
{hasAmbiguities && (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Ambiguity Resolution
</Typography>
{allResolved ? (
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
All ambiguities resolved pipeline will resume on next poll cycle
</Alert>
) : (
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
Pipeline paused resolve ambiguities to continue
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ambiguities.map((ambiguity: any) => {
const isResolved = ambiguity.status === "resolved";
return (
<Paper
key={ambiguity.id}
sx={{
p: 2,
borderRadius: 3,
border: 1,
borderColor: isResolved ? "success.main" : "divider",
opacity: isResolved ? 0.8 : 1,
}}
variant="outlined"
>
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
{ambiguity.line}
</Box>
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_amount}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_balance}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
<Typography variant="body2">{ambiguity.prev_balance}</Typography>
</Box>
</Box>
{isResolved ? (
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
Resolved: {ambiguity.chosen?.amount} / {ambiguity.chosen?.balance}
</Alert>
) : (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ambiguity.candidates.map((candidate: any, ci: number) => {
const isCredit = candidate.amount > 0;
const isDebit = candidate.amount < 0;
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
return (
<Button
key={ci}
variant="outlined"
size="small"
onClick={() => handleResolve(ambiguity, candidate)}
disabled={resolveMutation.isPending}
sx={{
borderColor: cColor,
color: cColor,
"&:hover": cColor ? { borderColor: cColor } : undefined,
}}
>
{candidate.amount} / {candidate.balance}
</Button>
);
})}
</Box>
)}
</Paper>
);
})}
</Box>
</Paper>
)}
<Snackbar
open={!!failNotif}
autoHideDuration={6000}
onClose={() => setFailNotif(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
{failNotif}
</Alert>
</Snackbar>
</Container>
);
}

View File

@@ -1,509 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
ToggleButtonGroup,
ToggleButton,
Chip,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Tooltip,
TextField,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { useUploadFile } from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
FileSource,
EmailSource,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResource, FormFieldRenderer, applyDisplayFormat } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <ScheduleIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} sx={{ mr: 0.5 }} />,
paused: <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />,
raw_expenses_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
enriched_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />,
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
};
const STATUS_OPTIONS: FetchRequestStatus[] = [
"pending",
"processing",
"paused",
"raw_expenses_done",
"enriched_done",
"completed",
"failed",
];
function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
}
export default function FetchRequests() {
const navigate = useNavigate();
const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos");
const [format, setFormat] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [uploadedPath, setUploadedPath] = React.useState<string | null>(null);
const [fromEmail, setFromEmail] = React.useState("");
const [subject, setSubject] = React.useState("");
const [rawTerms, setRawTerms] = React.useState("");
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests");
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
queryFn: () => list({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
}),
});
const { list: listAccounts } = useResource("accounts");
const { data: accountsData } = useQuery({
queryKey: ["accounts", "list"],
queryFn: () => listAccounts(),
});
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const fields = fetchRes?.orderedFields ?? [];
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const createMutation = useMutation({ mutationFn: (data: any) => create(data) });
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
const deleteMutation = useMutation({ mutationFn: (id: string) => remove(id) });
const uploadMutation = useUploadFile();
const requests = listData?.items ?? [];
const handleUpload = async () => {
if (!file) return;
const result = await uploadMutation.mutateAsync(file);
if (result?.saved_as) {
setUploadedPath(result.saved_as);
if (!format) setFormat(file.name.split(".").pop() || "");
setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" });
}
};
const handleCreate = async () => {
if (!accountName) return;
let source: FileSource | EmailSource;
if (sourceType === "file") {
if (!uploadedPath || !format) return;
source = { path: uploadedPath, format } as FileSource;
} else {
if (!format) return;
const emailSource: EmailSource = { format };
if (fromEmail) emailSource.from_email = fromEmail;
if (subject) emailSource.subject = subject;
if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean);
source = emailSource;
}
try {
const result = await createMutation.mutateAsync({
source,
account_name: accountName,
payor_username: payorUsername,
...(startDate ? { start_date: new Date(startDate).toISOString() } : {}),
...(endDate ? { end_date: new Date(endDate).toISOString() } : {}),
});
setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm();
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) {
if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
}
}
};
const resetForm = () => {
setAccountName("");
setFormat("");
setFile(null);
setUploadedPath(null);
setFromEmail("");
setSubject("");
setRawTerms("");
setStartDate("");
setEndDate("");
};
const handleRetry = async (req: FetchRequest) => {
try {
await updateMutation.mutateAsync({ id: req.id, data: { status: "pending" } });
setSnackbar({ message: "Retrying fetch request", severity: "success" });
} catch {
setSnackbar({ message: "Failed to retry", severity: "error" });
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
setSnackbar({ message: "Fetch request deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
{/* Create Form */}
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request
</Typography>
<ToggleButtonGroup
value={sourceType}
exclusive
onChange={(_, val) => val && setSourceType(val)}
sx={{ mb: 3 }}
size="small"
>
<ToggleButton value="file">File Upload</ToggleButton>
<ToggleButton value="email">Email Fetch</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{sourceType === "file" ? (
<>
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
Choose File
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</Button>
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
{file ? file.name : "No file selected"}
</Typography>
<Button
variant="contained"
onClick={handleUpload}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</Button>
</Box>
{uploadedPath && (
<Alert severity="success" sx={{ py: 0 }}>
Uploaded as: {uploadedPath}
</Alert>
)}
{formatField && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
</>
) : (
<>
{formatField && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
</>
)}
<Autocomplete
options={accountOptions}
value={accountName || null}
onChange={(_, val) => setAccountName(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account Name" size="small" required />
)}
/>
{payorUsernameField && (
<FormFieldRenderer field={payorUsernameField} value={payorUsername} onChange={setPayorUsername} />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer field={startDateField} value={startDate} onChange={setStartDate} />
</Box>
)}
{endDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer field={endDateField} value={endDate} onChange={setEndDate} />
</Box>
)}
</Box>
<Button
variant="contained"
onClick={handleCreate}
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
>
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
</Button>
</Box>
</Paper>
{/* Filters */}
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Status</InputLabel>
<Select
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as string[])}
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
options={accountOptions}
value={accountFilter || null}
onChange={(_, val) => setAccountFilter(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)}
sx={{ minWidth: 160 }}
/>
<ToggleButtonGroup
value={sourceFilter}
exclusive
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="file">File</ToggleButton>
<ToggleButton value="email">Email</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* List Table */}
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet
</Box>
) : (
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Account</TableCell>
<TableCell>Source</TableCell>
<TableCell>Date Range</TableCell>
<TableCell>Status</TableCell>
<TableCell>Retries</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<TableRow
key={req.id}
hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/fetch-requests/${req.id}`)}
>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{shortId(req.fingerprint)}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</TableCell>
<TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
</Typography>
</TableCell>
<TableCell>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip
icon={statusIcons[req.status] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small"
/>
</Tooltip>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{new Date(req.created_at).toLocaleString()}
</Typography>
</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && (
<Tooltip title="Resolve ambiguities">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }}>
<WarningAmberIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
)}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleRetry(req); }}>
<ReplayIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setDeleteTarget(req); }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Fetch Request?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the fetch request and all associated data.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -134,7 +134,7 @@ export default function Header({
</Button>
<Button
color="inherit"
onClick={() => navigate("/admin/profile")}
onClick={() => navigate("/profile/me")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
{currentUser.username}
@@ -151,7 +151,7 @@ export default function Header({
<Button
color="inherit"
variant="outlined"
onClick={() => navigate("/admin")}
onClick={() => navigate("/login")}
sx={{ textTransform: "none" }}
>
Login

View File

@@ -236,7 +236,7 @@ export default function Home() {
<Grid container spacing={3}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<Grid key={f.title} item xs={12} sm={6} md={3}>
<FeatureCard {...f} />
</Grid>
))}

View File

@@ -1,26 +0,0 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useAuth, AuthPage } from "../react-auth";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { currentUser, loading, error, login, register } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = React.useState<"login" | "register">("login");
if (currentUser) {
return <>{children}</>;
}
return (
<AuthPage
mode={mode}
onBack={() => navigate("/")}
onSwitchMode={() => setMode(mode === "login" ? "register" : "login")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}

View File

@@ -9,7 +9,7 @@ export type FetchRequestStatus =
export interface FileSource {
path: string;
format: string;
bank: string;
raw_lines?: string[];
txn_blocks?: Record<string, any>;
txn_dicts?: Record<string, any>[];
@@ -18,7 +18,7 @@ export interface FileSource {
}
export interface EmailSource {
format: string;
bank: string;
from_email?: string;
subject?: string;
raw_terms?: string[];
@@ -26,9 +26,12 @@ export interface EmailSource {
txn_dicts_count?: number;
}
export type PipelineType = "heuristic" | "llm" | "llama_parser";
export interface FetchRequestCreate {
source: FileSource | EmailSource;
account_name: string;
pipeline?: PipelineType;
payor_username?: string;
start_date?: string;
end_date?: string;

View File

@@ -14,6 +14,7 @@ export type {
SSEEventStep,
SSEEventStatus,
ProgressMessage,
PipelineType,
} from "./fetch-requests.models";
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export {

View File

@@ -4,21 +4,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
BrowserRouter,
Routes,
Route
Route,
useNavigate
} from "react-router-dom";
import {
Box,
CssBaseline,
CircularProgress,
Toolbar
} from "@mui/material";
import Home from './Home';
import FetchRequests from './FetchRequest/FetchRequestCreate';
import FetchRequestDetail from './FetchRequest/FetchRequestDetail';
import { RequireAuth } from './RequireAuth';
import { AppProvider, Admin } from '../react-openapi';
import { Buffer } from 'buffer';
import process from 'process';
import { AuthProvider } from "../react-auth";
import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi';
import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth";
import Header from './Header';
import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme';
@@ -26,55 +25,113 @@ import { specConfiguration } from './openapi-config';
const queryClient = new QueryClient();
window.Buffer = Buffer;
window.process = process;
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
// Wire profile components from react-auth into the spec-driven admin
specConfiguration.profileComponents = {
create: ProfileCreate,
edit: ProfileEdit,
view: ProfileView,
};
function LoginPage() {
const { login, register, loading, error, currentUser } = useAuth();
const navigate = useNavigate();
return (
<AuthPage
mode="login"
onBack={() => navigate("/")}
onSwitchMode={() => navigate("/register")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}
function RegisterPage() {
const { login, register, loading, error, currentUser } = useAuth();
const navigate = useNavigate();
return (
<AuthPage
mode="register"
onBack={() => navigate("/")}
onSwitchMode={() => navigate("/login")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}
const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" },
{ path: "/login", component: LoginPage, headerTitle: "Login" },
{ path: "/register", component: RegisterPage, headerTitle: "Register" },
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
{ path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" },
];
/** Reads authConfig from AppProvider context and passes it to AuthProvider. */
function AppContent() {
const { authConfig, loading } = useAppContext();
const navigate = useNavigate();
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" }}>
<CircularProgress />
</Box>
);
}
return (
<AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}>
<AppTheme>
<CssBaseline enableColorScheme />
<Header routerMapping={routerMapping} />
<Box sx={{ pb: 8 }}>
<Toolbar />
<Routes>
{routerMapping.map(({ path, component: Component }) => (
<Route
key={path}
path={path}
element={<Component basePath={path.replace(/\/\*$/, "")} />}
/>
))}
</Routes>
</Box>
<Footer />
</AppTheme>
</AuthProvider>
);
}
function AppWithAuth() {
const navigate = useNavigate();
return (
<AppProvider specConfiguration={specConfiguration} onUnauthorized={() => navigate("/login")}>
<AppContent />
</AppProvider>
);
}
root.render(
<QueryClientProvider client={queryClient}>
<AppProvider specConfiguration={specConfiguration}>
<BrowserRouter>
<AuthProvider authBaseUrl={AUTH_BASE}>
<AppTheme>
<CssBaseline enableColorScheme />
<Header routerMapping={routerMapping} />
<Box sx={{ pb: 8 }}>
<Toolbar />
<Routes>
{routerMapping.map(({ path, component: Component }) => (
<Route
key={path}
path={path}
element={
path.startsWith("/admin") ? (
<RequireAuth><Component basePath="/admin" /></RequireAuth>
) : (
<Component />
)
}
/>
))}
</Routes>
</Box>
<Footer />
</AppTheme>
</AuthProvider>
</BrowserRouter>
</AppProvider>
<BrowserRouter>
<AppWithAuth />
</BrowserRouter>
</QueryClientProvider>
);

View File

@@ -1,5 +1,5 @@
import type { SpecConfiguration } from "../react-openapi";
// import { tokenStore } from "../react-auth";
import { tokenStore } from "../react-auth";
const apiBase = import.meta.env.VITE_API_BASE_URL;
@@ -7,10 +7,10 @@ export const specConfiguration: SpecConfiguration = {
specUrl: `${apiBase}/openapi.json`,
baseApiUrl: apiBase,
title: "Khata",
getToken: () => tokenStore.get(),
resourceConfig: {
expenses: {
filterOptions: { mode: "client" },
},
},
// getToken: () => tokenStore.get(),
};

View File

@@ -63,8 +63,8 @@ export default function AppTheme({
);
const theme = React.useMemo(
() =>
createTheme({
() => {
const base = createTheme({
...getDesignTokens(mode),
semantic,
@@ -75,7 +75,11 @@ export default function AppTheme({
...navigationCustomizations,
...surfacesCustomizations,
},
}),
});
(base as any).applyStyles = (m: "light" | "dark", styles: any) =>
base.palette.mode === m ? styles : {};
return base;
},
[mode, semantic]
);

View File

@@ -4,7 +4,6 @@ import { SvgIconProps } from '@mui/material/SvgIcon';
import { buttonBaseClasses } from '@mui/material/ButtonBase';
import { dividerClasses } from '@mui/material/Divider';
import { menuItemClasses } from '@mui/material/MenuItem';
import { selectClasses } from '@mui/material/Select';
import { tabClasses } from '@mui/material/Tab';
import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
import { gray, brand } from '../themePrimitives';
@@ -73,7 +72,7 @@ export const navigationCustomizations: Components<Theme> = {
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none',
},
[`&.${selectClasses.focused}`]: {
'&.Mui-focused': {
outlineOffset: 0,
borderColor: gray[400],
},
@@ -91,7 +90,7 @@ export const navigationCustomizations: Components<Theme> = {
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none',
},
[`&.${selectClasses.focused}`]: {
'&.Mui-focused': {
outlineOffset: 0,
borderColor: 'hsl(210, 55%, 55%)',
},

11
src/shared-theme/theme-augment.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import "@mui/material/styles";
declare module "@mui/material/styles" {
interface Theme {
vars?: Record<string, any>;
applyStyles<T>(mode: "light" | "dark", styles: T): T | {};
}
interface ThemeOptions {
vars?: Record<string, any>;
}
}

View File

@@ -1,4 +1,5 @@
import { createTheme, alpha, PaletteMode, Shadows } from '@mui/material/styles';
import { createTheme, alpha, Shadows } from '@mui/material/styles';
import type { PaletteMode } from '@mui/material';
declare module '@mui/material/Paper' {
interface PaperPropsVariantOverrides {