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"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Install Deps" type="js.build_tools.npm"> <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" /> <command value="install" />
<node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" /> <node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" />
<envs /> <envs />

View File

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

1588
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,32 +4,36 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc -b && vite build",
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^12.1.0", "@apidevtools/swagger-parser": "^12.1.0",
"@emotion/react": "latest", "@emotion/react": "^11.13.3",
"@emotion/styled": "latest", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "latest", "@mui/icons-material": "^5.16.7",
"@mui/material": "latest", "@mui/material": "^5.16.7",
"@mui/x-data-grid": "^8.28.2", "@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"axios": "latest", "axios": "^1.7.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"js-yaml": "^4.1.0",
"markdown-to-jsx": "latest", "markdown-to-jsx": "latest",
"marked": "latest", "marked": "latest",
"process": "^0.11.10", "process": "^0.11.10",
"react": "latest", "react": "^18.3.1",
"react-dom": "latest", "react-dom": "^18.3.1",
"react-markdown": "latest", "react-markdown": "latest",
"react-router-dom": "^7.13.2", "react-router-dom": "^6.28.0",
"remark-gfm": "latest" "remark-gfm": "latest"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest", "@types/node": "^25.9.3",
"typescript": "^6.0.3", "@types/react": "^18.3.12",
"vite": "latest" "@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, (res) => res,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
console.log("[authAxios] 401 from %s %s", error.config?.method, error.config?.url);
tokenStore.clear(); tokenStore.clear();
window.dispatchEvent(new CustomEvent("auth:unauthorized"));
} }
return Promise.reject(error); return Promise.reject(error);
} }

View File

@@ -3,6 +3,15 @@ import { tokenStore } from "./token";
import { createApiClient } from "./axios"; import { createApiClient } from "./axios";
import { AuthUser } from "./models"; import { AuthUser } from "./models";
export interface AuthServerConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
interface AuthContextModel { interface AuthContextModel {
currentUser: AuthUser | null; currentUser: AuthUser | null;
token: string | null; token: string | null;
@@ -17,24 +26,26 @@ const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export function AuthProvider({ export function AuthProvider({
children, children,
authBaseUrl, authConfig,
onUnauthorized,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
authBaseUrl: string; authConfig: AuthServerConfig;
onUnauthorized?: () => void;
}) { }) {
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null); const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(tokenStore.get()); const [token, setToken] = useState<string | null>(tokenStore.get());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const auth = createApiClient(authBaseUrl); const auth = createApiClient(authConfig.serverUrl);
const login = async (username: string, password: string) => { const login = async (username: string, password: string) => {
try { try {
setLoading(true); setLoading(true);
setError(null); 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; const { access_token, user } = res.data;
tokenStore.set(access_token); tokenStore.set(access_token);
@@ -52,7 +63,7 @@ export function AuthProvider({
setLoading(true); setLoading(true);
setError(null); setError(null);
await auth.post("/register", { username, password }); await auth.post(authConfig.registerPath, { username, password });
await login(username, password); await login(username, password);
} catch (e: any) { } catch (e: any) {
setError(e.response?.data?.detail ?? "Registration failed"); 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(); tokenStore.clear();
setToken(null); setToken(null);
setCurrentUser(null); setCurrentUser(null);
}; };
const fetchCurrentUser = async () => { 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 { try {
const me = await auth.get("/me"); const me = await auth.get(authConfig.mePath);
console.log("[AuthProvider] fetchCurrentUser SUCCESS", me.data);
setCurrentUser({ ...me.data }); setCurrentUser({ ...me.data });
} catch { } catch (e: any) {
logout(); console.log("[AuthProvider] fetchCurrentUser ERROR", e.message, e.response?.status);
tokenStore.clear();
setToken(null);
setCurrentUser(null);
onUnauthorized?.();
} }
}; };
useEffect(() => { useEffect(() => {
fetchCurrentUser(); console.log("[AuthProvider] useEffect token=%s serverUrl=%s", token, authConfig.serverUrl);
}, [token]); 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 ( return (
<AuthContext.Provider <AuthContext.Provider

View File

@@ -1,6 +1,13 @@
export { AuthProvider, useAuth } from "./contexts"; export { AuthProvider, useAuth } from "./contexts";
export type { AuthServerConfig } from "./contexts";
export { createApiClient } from "./axios"; export { createApiClient } from "./axios";
export { AuthPage } from "./AuthPage"; export { AuthPage } from "./AuthPage";
export { ProfileCreate } from "./ProfileCreate";
export { ProfileEdit } from "./ProfileEdit";
export { ProfileView } from "./ProfileView";
export type { AuthUser } from "./models"; export type { AuthUser } from "./models";
export type { AuthMode } from "./AuthPage"; export type { AuthMode } from "./AuthPage";
export type { ProfileCreateProps } from "./ProfileCreate";
export type { ProfileEditProps } from "./ProfileEdit";
export type { ProfileViewProps } from "./ProfileView";
export { tokenStore } from "./token" 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. 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 ### Property Types That Trigger Special Renderers
| Condition | Form Renderer | List/Detail Renderer | | 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` | `DateField` (date picker) | Typography |
| `format: date-time` | `DateField` (datetime-local picker) | Typography | | `format: date-time` | `DateField` (datetime-local picker) | Typography |
| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` | | `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) | | `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) |
| None of the above (default) | `StringField` (text input) | Typography | | None of the above (default) | `StringField` (text input) | Typography |

View File

@@ -1,5 +1,6 @@
export { AppProvider } from "./src/context/AppProvider"; 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 { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource"; export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; 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 { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi"; export { getApi } from "./src/hooks/useApi";
export { useItemSse } from "./src/hooks/useItemSse"; 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 { 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 React, { useEffect, useState } from "react";
import { Routes, Route, Navigate } from "react-router-dom"; import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
import { Box, CircularProgress } from "@mui/material"; import { Box, CircularProgress, Alert } from "@mui/material";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { Layout } from "./Layout"; import { Layout } from "./Layout";
import { ResourceList } from "./ResourceList"; import { ResourceList } from "./ResourceList";
import { ResourceForm } from "./ResourceForm"; import { ResourceForm } from "./ResourceForm";
import { ResourceDetail } from "./ResourceDetail"; import { ResourceDetail } from "./ResourceDetail";
import { ValidationAlert } from "./ValidationAlert"; import { ValidationAlert } from "./ValidationAlert";
import { getApi } from "../hooks/useApi";
interface AdminProps { export interface AdminProps {
basePath: string; basePath: string;
} }
@@ -30,18 +31,21 @@ export function Admin({ basePath }: AdminProps) {
if (resources.length === 0) { if (resources.length === 0) {
return ( return (
<Box sx={{ p: 4, textAlign: "center" }}> <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> </Box>
); );
} }
const topLevel = resources.filter((r) => !r.parent);
return ( return (
<> <>
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />} {warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
<Layout resources={resources} basePath={basePath}> <Layout resources={topLevel} basePath={basePath}>
<Routes> <Routes>
<Route index element={<Navigate to={`${basePath}/${resources[0].name}`} replace />} /> <Route index element={<Navigate to={`${basePath}/${topLevel[0]?.name ?? "profile"}`} replace />} />
{resources.map((r) => (
{topLevel.map((r) => (
<React.Fragment key={r.name}> <React.Fragment key={r.name}>
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} /> <Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
{!r.streaming && ( {!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, Paper,
Grid, Grid,
CircularProgress, CircularProgress,
Tabs,
Tab,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
@@ -14,12 +16,18 @@ import type { ResourceConfig } from "../types";
import { useResource } from "../context/useResource"; import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
import { DetailFieldRenderer, applyDisplayFormat } from "./fields"; import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { SseStreamView } from "./SseStreamView";
interface ResourceDetailProps { interface ResourceDetailProps {
resource: ResourceConfig; resource: ResourceConfig;
basePath: string; 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) { export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
@@ -27,6 +35,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const { resources: allResources } = useAppContext(); const { resources: allResources } = useAppContext();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tabIndex, setTabIndex] = useState(0);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@@ -57,6 +66,16 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail); 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 ( return (
<Box> <Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
@@ -85,25 +104,47 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
)} )}
</Box> </Box>
<Paper variant="outlined" sx={{ p: 3 }}> {tabs.length > 1 && (
<Grid container spacing={2}> <Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)} sx={{ mb: 1 }}>
{visibleFields.map((field) => { {tabs.map((t) => (
let value = data[field.name]; <Tab key={t.key} label={t.label} />
let fmt = resource.displayFormat; ))}
if (field.fk && typeof value === "object") { </Tabs>
const targetRes = allResources.find((r) => r.name === field.fk!.resource); )}
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") { <TabPanel value={tabIndex} index={0}>
fmt = field.inlineDisplayFormat ?? resource.displayFormat; <Paper variant="outlined" sx={{ p: 3 }}>
} <Grid container spacing={2}>
return ( {visibleFields.map((field) => {
<Grid size={12} key={field.name}> let value = data[field.name];
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} /> let fmt = resource.displayFormat;
</Grid> if (field.fk && typeof value === "object") {
); const targetRes = allResources.find((r) => r.name === field.fk!.resource);
})} fmt = targetRes!.displayFormat;
</Grid> } else if (field.refSchema && !field.fk && typeof value === "object") {
</Paper> 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> </Box>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Box, Typography } from "@mui/material"; import { Box, Typography, Avatar } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer"; import { ListCellRenderer } from "./ListCellRenderer";
@@ -7,9 +7,10 @@ interface DetailFieldProps {
field: FieldConfig; field: FieldConfig;
value: any; value: any;
displayFormat?: string; 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; if (field.hidden?.detail) return null;
return ( 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" }}> <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label} {field.label}
</Typography> </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> </Box>
); );
} }

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { TextField } from "@mui/material"; import { TextField, Chip, Box } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { StringField } from "./renderers/StringField"; import { StringField } from "./renderers/StringField";
import { NumberField } from "./renderers/NumberField"; import { NumberField } from "./renderers/NumberField";
@@ -8,8 +8,10 @@ import { BooleanField } from "./renderers/BooleanField";
import { EnumField } from "./renderers/EnumField"; import { EnumField } from "./renderers/EnumField";
import { FkSelectField } from "./renderers/FkSelectField"; import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { FileUploadField } from "./renderers/FileUploadField";
import { ImageField } from "./renderers/ImageField"; import { ImageField } from "./renderers/ImageField";
import { JsonField } from "./renderers/JsonField"; import { JsonField } from "./renderers/JsonField";
import { DiscriminatorField } from "./renderers/DiscriminatorField";
interface FormFieldProps { interface FormFieldProps {
field: FieldConfig; field: FieldConfig;
@@ -25,6 +27,16 @@ interface FormFieldProps {
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) { export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
if (field.hidden?.form) return null; if (field.hidden?.form) return null;
if (field.upload) {
return (
<FileUploadField
field={field}
value={value}
onChange={onChange}
/>
);
}
if (field.readOnly && field.uiType !== "image") { if (field.readOnly && field.uiType !== "image") {
return ( return (
<StringField field={field} value={value} onChange={onChange} error={error} /> <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) { if (field.refSchema && !field.fk) {
return ( return (
<JsonField <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 ( return (
<StringField <StringField
field={field} field={field}

View File

@@ -1,24 +1,71 @@
import React from "react"; import React, { useState } from "react";
import { Box, Typography, Chip, Avatar } from "@mui/material"; 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 type { FieldConfig } from "../../types";
import { applyDisplayFormat } from "./utils"; import { applyDisplayFormat } from "./utils";
import { InlineRefField } from "./renderers/InlineRefField"; import { InlineRefField } from "./renderers/InlineRefField";
import { extractFields } from "../../transformers/field-config";
import { useAppContext } from "../../context/AppContext";
interface ListCellProps { interface ListCellProps {
field: FieldConfig; field: FieldConfig;
value: any; value: any;
displayFormat?: string; 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) { if (value === null || value === undefined) {
return <Typography variant="body2" color="text.disabled"></Typography>; 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") { 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 (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
if (value.length === 0) { if (value.length === 0) {
return <Typography variant="body2" color="text.disabled"></Typography>; return <Typography variant="body2" color="text.disabled"></Typography>;
@@ -29,14 +76,40 @@ export function ListCellRenderer({ field, value, displayFormat }: ListCellProps)
const label = typeof item === "object" const label = typeof item === "object"
? applyDisplayFormat(item, displayFormat ?? "") ? applyDisplayFormat(item, displayFormat ?? "")
: String(item); : 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> </Box>
); );
} }
if (field.fk && typeof value === "object" && !field.isArray) { 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) { 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" }}> <Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{value.map((item: any, i: number) => { {value.map((item: any, i: number) => {
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item); 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> </Box>
); );

View File

@@ -12,16 +12,20 @@ interface Props {
export function DateField({ field, value, onChange, error }: Props) { export function DateField({ field, value, onChange, error }: Props) {
const inputType = field.format === "date" ? "date" : "datetime-local"; 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 ( return (
<TextField <TextField
fullWidth fullWidth
label={field.label} label={field.label}
type={inputType} type={inputType}
value={value ?? ""} value={normalized ?? ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
error={!!error} error={!!error}
helperText={error ?? field.description} helperText={error || field.description || undefined}
placeholder={field.description} placeholder={field.description || undefined}
size="small" size="small"
disabled={field.readOnly} disabled={field.readOnly}
InputLabelProps={{ shrink: true }} 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 React, { useState, useMemo } from "react";
import { TextField, Autocomplete } from "@mui/material"; import { TextField, Autocomplete, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
interface Props { interface Props {
@@ -12,20 +13,86 @@ interface Props {
} }
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: 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 ( return (
<Autocomplete <Autocomplete
multiple multiple
options={fkOptions ?? []} disableCloseOnSelect
open={open}
onOpen={handleOpen}
onClose={handleClose}
options={sortedOptions}
getOptionLabel={(o) => o.label} getOptionLabel={(o) => o.label}
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []} value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))} onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
onOpen={() => onOpen?.()}
loading={fkLoading} 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) => ( 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" size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
disabled={field.readOnly} 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} /> <input type="file" hidden accept="image/*" onChange={handleUpload} />
</Button> </Button>
)} )}
<FormHelperText>{field.description}</FormHelperText> {field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box> </Box>
); );
} }

View File

@@ -1,38 +1,83 @@
import React from "react"; import React, { useState } from "react";
import { Box, Typography, Chip } from "@mui/material"; import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils"; import { applyDisplayFormat } from "../utils";
import { extractFields } from "../../../transformers/field-config";
import { useAppContext } from "../../../context/AppContext";
interface Props { interface Props {
field: FieldConfig; field: FieldConfig;
value: any; 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") { if (!value || typeof value !== "object") {
return <Typography variant="body2" color="text.disabled"></Typography>; return <Typography variant="body2" color="text.disabled"></Typography>;
} }
if (displayFormat) { const discProp = field.discriminatorProperty;
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>; 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); const schema = field.refSchema ? schemas[field.refSchema] : undefined;
if (entries.length === 0) { let subFields: FieldConfig[] = [];
return <Typography variant="body2" color="text.disabled"></Typography>; 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 ( 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 <Chip
key={key} label={field.label}
label={`${key}: ${String(v)}`} title={tooltip}
size="small" size="small"
color="primary"
variant="outlined" 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 { useAppContext } from "../../../context/AppContext";
import { extractFields } from "../../../transformers/field-config"; import { extractFields } from "../../../transformers/field-config";
import { FormFieldRenderer } from "../FormFieldRenderer"; import { FormFieldRenderer } from "../FormFieldRenderer";
import { applyDisplayFormat } from "../utils";
interface JsonFieldProps { interface JsonFieldProps {
field: FieldConfig; field: FieldConfig;
@@ -78,8 +79,8 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (!open) { if (!open) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return ( return (
<Button variant="outlined" onClick={handleOpen} size="small"> <Button variant="outlined" onClick={handleOpen} size="small" startIcon={field.isArray ? <AddIcon /> : undefined}>
Set {field.label} {field.isArray ? `Add ${field.label}` : `Set ${field.label}`}
</Button> </Button>
); );
} }
@@ -87,14 +88,14 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
if (field.isArray && Array.isArray(value)) { if (field.isArray && Array.isArray(value)) {
if (value.length === 0) { if (value.length === 0) {
return ( return (
<Button variant="outlined" onClick={handleOpen} size="small"> <Button variant="outlined" onClick={handleOpen} size="small" startIcon={<AddIcon />}>
Set {field.label} Add {field.label}
</Button> </Button>
); );
} }
return ( return (
<Chip <Chip
label={`${value.length} item${value.length !== 1 ? "s" : ""}`} label={`${field.label} (${value.length})`}
size="small" size="small"
color="primary" color="primary"
variant="outlined" variant="outlined"
@@ -105,15 +106,13 @@ export function JsonField({ field, value, onChange }: JsonFieldProps) {
} }
if (typeof value === "object") { if (typeof value === "object") {
const summary = field.inlineDisplayFormat const tooltip = field.inlineDisplayFormat
? applyInlineFormat(value, field.inlineDisplayFormat) ? applyDisplayFormat(value, field.inlineDisplayFormat)
: Object.entries(value) : undefined;
.filter(([, v]) => v != null)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(" | ");
return ( return (
<Chip <Chip
label={summary || field.label} label={field.label}
title={tooltip}
size="small" size="small"
color="primary" color="primary"
variant="outlined" variant="outlined"
@@ -244,6 +243,10 @@ function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>):
const refSchemaObj = schemas[f.refSchema!]; const refSchemaObj = schemas[f.refSchema!];
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : []; const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, 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 { } else {
shape[f.name] = null; shape[f.name] = null;
} }
@@ -255,7 +258,7 @@ function initEditValue(value: any, field: FieldConfig, schemas: Record<string, a
if (field.isArray) { if (field.isArray) {
return value ? value.map((item: any) => ({ ...item })) : []; return value ? value.map((item: any) => ({ ...item })) : [];
} }
if (value && typeof value === "object") { if (value != null && value !== "" && typeof value === "object") {
return { ...value }; return { ...value };
} }
return buildDefaultShape( 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} error={!!error}
helperText={error ?? field.description} helperText={error || field.description || undefined}
placeholder={field.description} placeholder={field.description || undefined}
size="small" size="small"
disabled={field.readOnly} disabled={field.readOnly}
inputProps={isFloat ? { step: "any" } : undefined} inputProps={isFloat ? { step: "any" } : undefined}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from "react"; 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 { AppContext } from "./AppContext";
import { loadSpec } from "../spec-loader"; import { loadSpec } from "../spec-loader";
import { validateSpec } from "../spec-validator"; import { validateSpec } from "../spec-validator";
@@ -11,13 +11,58 @@ interface AppProviderProps {
children: React.ReactNode; 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) { export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resources, setResources] = useState<ResourceConfig[]>([]); 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 [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]); const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]); const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
const profileComponents = specConfiguration.profileComponents ?? {};
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -36,6 +81,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
setErrors(errs); setErrors(errs);
setWarnings(warns); setWarnings(warns);
setSchemas(spec.components?.schemas ?? {}); setSchemas(spec.components?.schemas ?? {});
setProfileOperations(extractProfileOperations(spec));
setAuthConfig(extractAuthConfig(spec));
} }
if (errs.length === 0) { if (errs.length === 0) {
@@ -51,7 +98,8 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
} }
} catch (e: any) { } catch (e: any) {
if (!cancelled) { 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 { } finally {
if (!cancelled) { if (!cancelled) {
@@ -71,12 +119,15 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
() => ({ () => ({
config: specConfiguration, config: specConfiguration,
resources, resources,
profileOperations,
profileComponents,
authConfig,
schemas, schemas,
loading, loading,
errors, errors,
warnings, warnings,
}), }),
[specConfiguration, resources, schemas, loading, errors, warnings] [specConfiguration, resources, profileOperations, profileComponents, authConfig, schemas, loading, errors, warnings]
); );
return React.createElement(AppContext.Provider, { value }, children); return React.createElement(AppContext.Provider, { value }, children);

View File

@@ -1,8 +1,10 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react"; 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 type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
import { useAppContext } from "./AppContext"; import { useAppContext } from "./AppContext";
import { getApi } from "../hooks/useApi"; import { getApi } from "../hooks/useApi";
import { sanitizePayload } from "../utils/sanitize-payload";
import { StringField } from "../components/fields/renderers/StringField"; import { StringField } from "../components/fields/renderers/StringField";
import { NumberField } from "../components/fields/renderers/NumberField"; import { NumberField } from "../components/fields/renderers/NumberField";
import { DateField } from "../components/fields/renderers/DateField"; 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 { EnumField } from "../components/fields/renderers/EnumField";
import { FkSelectField } from "../components/fields/renderers/FkSelectField"; import { FkSelectField } from "../components/fields/renderers/FkSelectField";
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField"; import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
import { extractTokens, extractLocalParts, extractDomains, stripNonDigits } from "../utils/filter-utils";
function parseError(e: any): string { function parseError(e: any): string {
if (e.response?.data) { if (e.response?.data) {
@@ -53,8 +56,9 @@ interface UseResourceReturn {
get: (id: string | number, params?: Record<string, any>) => Promise<any>; get: (id: string | number, params?: Record<string, any>) => Promise<any>;
create: (data: any) => Promise<any>; create: (data: any) => Promise<any>;
update: (id: string | number, 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>; remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers) => StreamSubscription; stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
@@ -266,51 +270,297 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
); );
} }
function buildAutocompleteFilter(getDisplayValue: (row: any) => string) { // ── text filter (freeSolo, no dropdown) ──────────────────────
const StringAutocompleteFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => { 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 { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server"; const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const [options, setOptions] = useState<string[]>([]); const [options, setOptions] = useState<string[]>([]);
const fetched = useRef(false); const fetched = useRef(false);
useEffect(() => { useEffect(() => {
if (filterMode === "client" && data) { const extract = (items: any[]) => {
const vals = new Set<string>(); const vals = new Set<string>();
for (const row of data) { for (const row of items) {
const v = getDisplayValue(row); const val = row[field.name];
if (v && v !== "") vals.add(v); 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; fetched.current = true;
} else if (filterMode === "server" && !fetched.current) { } else if (filterMode === "server" && !fetched.current) {
const cacheKey = resourceName + ":" + field.name; (async () => {
if (_stringOptionsCache.has(cacheKey)) { try {
setOptions(_stringOptionsCache.get(cacheKey)!); const api = getApi();
fetched.current = true; const selfRes = resources.find((r) => r.name === resourceName);
} else { if (!selfRes) { fetched.current = true; return; }
(async () => { const params: Record<string, any> = {};
try { if (selfRes.pagination) params.limit = 0;
const api = getApi(); const res = await api.get(selfRes.path, { params });
const selfRes = resources.find((r) => r.name === resourceName); const items = selfRes.pagination
if (!selfRes) { fetched.current = true; return; } ? (Array.isArray(res.data) ? res.data : (res.data.items ?? []))
const params: Record<string, any> = {}; : (Array.isArray(res.data) ? res.data : []);
if (selfRes.pagination) params.limit = 0; setOptions(extract(items));
const res = await api.get(selfRes.path, { params }); fetched.current = true;
let items: any[]; } catch { fetched.current = true; }
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;
}
})();
}
} }
}, [data]); }, [data]);
@@ -322,33 +572,12 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
value={value || null} value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")} onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField {...params} label={labelOverride ?? field.label} size="small" />
{...params}
label={labelOverride ?? field.label}
size="small"
/>
)} )}
/> />
); );
}; };
return StringAutocompleteFilter; return RefFilter;
}
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 ({ value, onChange, labelOverride }) => ( return ({ value, onChange, labelOverride }) => (
@@ -361,14 +590,14 @@ function buildFilterComponent(field: FieldConfig, resourceName: string): React.F
} }
export function useResource(resourceName: string): UseResourceReturn { export function useResource(resourceName: string): UseResourceReturn {
const { resources } = useAppContext(); const { resources, schemas } = useAppContext();
const resource = resources.find((r) => r.name === resourceName); const resource = resources.find((r) => r.name === resourceName);
const [state, setState] = useState<ResourceState>({ loading: false, error: null }); const [state, setState] = useState<ResourceState>({ loading: false, error: null });
const rPath = resource?.path; const rPath = resource?.path;
const rPagination = resource?.pagination; const rPagination = resource?.pagination;
const rUpdateMethod = resource?.updateMethod; const rPatch = resource ? (resource.operations.patch || undefined) : false;
const rStreaming = resource?.streaming; const rStreaming = resource?.streaming;
const rFields = resource?.fields; const rFields = resource?.fields;
@@ -434,6 +663,18 @@ export function useResource(resourceName: string): UseResourceReturn {
[rPath, setLoading, setError] [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( const create = useCallback(
async (data: any): Promise<any> => { async (data: any): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`); if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
@@ -441,7 +682,10 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null); setError(null);
try { try {
const api = getApi(); 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; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -450,7 +694,7 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); setLoading(false);
} }
}, },
[rPath, setLoading, setError] [rPath, rFields, schemas, resolveFk, setLoading, setError]
); );
const update = useCallback( const update = useCallback(
@@ -460,8 +704,10 @@ export function useResource(resourceName: string): UseResourceReturn {
setError(null); setError(null);
try { try {
const api = getApi(); const api = getApi();
const method = rUpdateMethod ?? "put"; const sanitized = rFields && schemas
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, data); ? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.put(`${rPath}/${id}`, sanitized);
return res.data; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -470,7 +716,29 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); 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( const remove = useCallback(
@@ -492,13 +760,19 @@ export function useResource(resourceName: string): UseResourceReturn {
); );
const stream = useCallback( const stream = useCallback(
(handlers: StreamHandlers): StreamSubscription => { (handlers: StreamHandlers, pathParams?: Record<string, string | number>): StreamSubscription => {
if (!rPath || !rStreaming) { if (!rPath || !rStreaming) {
throw new Error(`Resource "${resourceName}" does not support streaming`); throw new Error(`Resource "${resourceName}" does not support streaming`);
} }
const api = getApi(); const api = getApi();
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, ""); 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); const es = new EventSource(url);
es.onopen = () => handlers.onOpen?.(); es.onopen = () => handlers.onOpen?.();
@@ -539,6 +813,7 @@ export function useResource(resourceName: string): UseResourceReturn {
get, get,
create, create,
update, update,
patch: undefined,
remove, remove,
stream: undefined, stream: undefined,
loading: false, 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 axios, { AxiosInstance } from "axios";
import { tokenStore } from "../../../react-auth/token";
let apiClient: AxiosInstance | null = null; 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) { if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized;
return apiClient; return apiClient;
} }
_onUnauthorized = onUnauthorized;
apiClient = axios.create({ apiClient = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -24,11 +27,12 @@ export function initApi(baseUrl: string, getToken?: () => string | null): AxiosI
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(res) => res, (res) => res,
(error) => { (error) => {
if (error.response?.status === 401 && getToken) { if (error.response?.status === 401) {
const currentToken = getToken(); console.log("[useApi] 401 from %s %s - dispatching auth:unauthorized", error.config?.method, error.config?.url);
if (currentToken) { window.dispatchEvent(new CustomEvent("auth:unauthorized"));
tokenStore.clear(); _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); return Promise.reject(error);
} }

View File

@@ -1,5 +1,21 @@
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types"; 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[] { export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
const messages: ValidationMessage[] = []; const messages: ValidationMessage[] = [];
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>; 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" }); messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
} }
for (const [schemaName, schema] of Object.entries(schemas)) { for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
if (!schema || typeof schema !== "object") continue; 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"]) { if (isItemPath || isSubResource) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` }); const responseRef = getResponseSchemaRef(pathObj);
} if (responseRef) {
const schemaName = responseRef.split("/").pop()!;
if (!schema["x-display-format"]) { if (!schemas[schemaName]) {
messages.push({ type: "error", message: `Resource schema "${schemaName}" is missing 'x-display-format'` }); messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
}
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` });
} }
} }
}
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; 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) { if (!schema["x-primary-key"]) {
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` }); 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 (!pathObj?.get) {
if (isSSE) continue; 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 limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset"); const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
if (limitParam || offsetParam) { if (limitParam || offsetParam) {
if (!limitParam?.schema?.default) { 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) { if (!pathObj?.post) {
messages.push({ type: "error", message: `"${resourcePath}" has no POST endpoint — creation not possible` }); messages.push({ type: "error", message: `"${path}" 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` });
}
} }
} }

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 { function resolveRef(ref: string): string | undefined {
return ref.split("/").pop(); 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[] { export function extractFields(schemaName: string, schema: any, schemas: Record<string, any>): FieldConfig[] {
const props = schema.properties ?? {}; const props = schema.properties ?? {};
const requiredFields: string[] = schema.required ?? []; const requiredFields: string[] = schema.required ?? [];
@@ -30,13 +110,27 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
? refSchema["x-display-format"] ? refSchema["x-display-format"]
: undefined; : 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 = { const field: FieldConfig = {
name, name,
label: prop["x-label"], 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"), type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
format: prop.format, format: prop.format,
order: prop["x-order"], order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {}, hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false, filterable: prop["x-filterable"] ?? false,
sortable: prop["x-sortable"] ?? false, sortable: prop["x-sortable"] ?? false,
@@ -46,9 +140,13 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
fk: prop["x-fk"], fk: prop["x-fk"],
uiType: prop["x-ui-type"], uiType: prop["x-ui-type"],
uploadUrl: prop["x-upload-url"], uploadUrl: prop["x-upload-url"],
upload: prop["x-upload"],
refSchema: refSchemaName, refSchema: refSchemaName,
inlineDisplayFormat, inlineDisplayFormat,
isArray: prop.type === "array", isArray: prop.type === "array",
oneOfOptions,
discriminatorProperty,
autocomplete,
}; };
return field; return field;

View File

@@ -11,11 +11,7 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
if (!prop["x-fk"]) continue; if (!prop["x-fk"]) continue;
const fkResource = prop["x-fk"].resource as string; 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; 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({ rels.push({
fieldName: name, fieldName: name,
@@ -23,10 +19,9 @@ export function extractRelationships(schema: any, schemas: Record<string, any>):
resource: fkResource, resource: fkResource,
prefetch, prefetch,
}, },
targetSchemaName, targetSchemaName: fkResource,
}); });
} }
console.log(`[FK] total relationships extracted: ${rels.length}`);
return rels; return rels;
} }

View File

@@ -1,5 +1,5 @@
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types"; import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
import { extractFields } from "./field-config"; import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
import { extractRelationships } from "./relationship-config"; import { extractRelationships } from "./relationship-config";
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null { function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
@@ -47,62 +47,154 @@ const SSE_RECEIVED_FIELD: FieldConfig = {
isArray: false, 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[] { export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
clearValidationErrors();
const schemas = spec.components?.schemas ?? {}; const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {}; const paths = spec.paths ?? {};
const configs: ResourceConfig[] = []; const configs: ResourceConfig[] = [];
const nameMap = new Map<string, ResourceConfig>();
for (const [schemaName, schema] of Object.entries(schemas)) { const sortedPaths = Object.keys(paths).sort(
if (!schema || typeof schema !== "object") continue; (a, b) => getSegments(a).length - getSegments(b).length
);
const resourceName = schema["x-resource"]; for (const path of sortedPaths) {
if (!resourceName || typeof resourceName !== "string") continue; const segments = getSegments(path);
const pathObj = paths[path];
const resourcePath = `/${resourceName}`; // Skip paths where ALL non-parameter operations are profile-only (x-profile: true).
const itemPath = `${resourcePath}/{id}`; // These are handled by Admin.tsx profile routes, not as CRUD resources.
const collectionPathObj = paths[resourcePath]; const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
const itemPathObj = paths[itemPath]; const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
if (allProfile) continue;
const fields = extractFields(schemaName, schema, schemas); const lastSeg = segments[segments.length - 1];
const relationships = extractRelationships(schema, schemas); const isItemPath = /^\{.*\}$/.test(lastSeg);
const hasSSE = collectionPathObj?.get?.["x-sse"] === true; 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 = { const resource: ResourceConfig = {
name: resourceName, name: resourceName,
schemaName, schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName), displayName: formatDisplayName(resourceName),
path: resourcePath, path,
primaryKey: schema["x-primary-key"], primaryKey: schema?.["x-primary-key"] ?? "id",
displayFormat: schema["x-display-format"], displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
listColumns: schema["x-list-columns"], listColumns: schema?.["x-list-columns"] ?? [],
fields, fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: sortFields(fields), orderedFields: [],
operations: { operations: hasSSE
list: hasOperation(collectionPathObj, "get"), ? { list: true, get: false, create: false, update: false, patch: false, delete: false }
get: hasOperation(itemPathObj, "get"), : { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, patch: false, delete: false },
create: hasOperation(collectionPathObj, "post"), updateMethod: "put",
update: hasOperation(itemPathObj, "put") || hasOperation(itemPathObj, "patch"), pagination: hasSSE ? null : detectPagination(pathObj),
delete: hasOperation(itemPathObj, "delete"),
},
updateMethod: hasOperation(itemPathObj, "patch") && !hasOperation(itemPathObj, "put") ? "patch" : "put",
pagination: detectPagination(collectionPathObj),
relationships, relationships,
streaming: hasSSE || undefined, streaming: hasSSE || undefined,
}; };
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) { 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.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at"; resource.primaryKey = "_received_at";
} }
nameMap.set(resourceName, resource);
configs.push(resource); configs.push(resource);
} }
const errors = getValidationErrors();
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
return configs; 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 { export interface SpecConfiguration {
specUrl: string; specUrl: string;
baseApiUrl?: string; baseApiUrl?: string;
title?: string; title?: string;
getToken?: () => string | null; getToken?: () => string | null;
resourceConfig?: Record<string, ResourceConfiguration>; 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 { export interface ValidationMessage {
@@ -40,6 +65,7 @@ export interface ResourceConfig {
get: boolean; get: boolean;
create: boolean; create: boolean;
update: boolean; update: boolean;
patch: boolean;
delete: boolean; delete: boolean;
}; };
updateMethod: "put" | "patch"; updateMethod: "put" | "patch";
@@ -50,6 +76,19 @@ export interface ResourceConfig {
} | null; } | null;
relationships: ResourceRelationship[]; relationships: ResourceRelationship[];
streaming?: boolean; 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 { export interface FieldConfig {
@@ -68,14 +107,13 @@ export interface FieldConfig {
fk?: FKFieldConfig; fk?: FKFieldConfig;
uiType?: string; uiType?: string;
uploadUrl?: string; uploadUrl?: string;
upload?: { type: string; url: string };
refSchema?: string; refSchema?: string;
inlineDisplayFormat?: string; inlineDisplayFormat?: string;
isArray: boolean; isArray: boolean;
} oneOfOptions?: OneOfOption[];
discriminatorProperty?: string;
export interface FKFieldConfig { autocomplete?: "text" | "token" | "email" | "phone";
resource: string;
prefetch: boolean;
} }
export interface OpenApiSpec { 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 { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } 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() { function FetchRequestList() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

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

View File

@@ -236,7 +236,7 @@ export default function Home() {
<Grid container spacing={3}> <Grid container spacing={3}>
{features.map((f) => ( {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} /> <FeatureCard {...f} />
</Grid> </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 { export interface FileSource {
path: string; path: string;
format: string; bank: string;
raw_lines?: string[]; raw_lines?: string[];
txn_blocks?: Record<string, any>; txn_blocks?: Record<string, any>;
txn_dicts?: Record<string, any>[]; txn_dicts?: Record<string, any>[];
@@ -18,7 +18,7 @@ export interface FileSource {
} }
export interface EmailSource { export interface EmailSource {
format: string; bank: string;
from_email?: string; from_email?: string;
subject?: string; subject?: string;
raw_terms?: string[]; raw_terms?: string[];
@@ -26,9 +26,12 @@ export interface EmailSource {
txn_dicts_count?: number; txn_dicts_count?: number;
} }
export type PipelineType = "heuristic" | "llm" | "llama_parser";
export interface FetchRequestCreate { export interface FetchRequestCreate {
source: FileSource | EmailSource; source: FileSource | EmailSource;
account_name: string; account_name: string;
pipeline?: PipelineType;
payor_username?: string; payor_username?: string;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;

View File

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

View File

@@ -4,21 +4,20 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { import {
BrowserRouter, BrowserRouter,
Routes, Routes,
Route Route,
useNavigate
} from "react-router-dom"; } from "react-router-dom";
import { import {
Box, Box,
CssBaseline, CssBaseline,
CircularProgress,
Toolbar Toolbar
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequests from './FetchRequest/FetchRequestCreate';
import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail';
import { RequireAuth } from './RequireAuth'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi';
import { AppProvider, Admin } from '../react-openapi'; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth";
import { Buffer } from 'buffer';
import process from 'process';
import { AuthProvider } from "../react-auth";
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme'; import AppTheme from './shared-theme/AppTheme';
@@ -26,55 +25,113 @@ import { specConfiguration } from './openapi-config';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
window.Buffer = Buffer;
window.process = process;
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); 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 = [ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", 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", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { 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( root.render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppProvider specConfiguration={specConfiguration}> <BrowserRouter>
<BrowserRouter> <AppWithAuth />
<AuthProvider authBaseUrl={AUTH_BASE}> </BrowserRouter>
<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>
</QueryClientProvider> </QueryClientProvider>
); );

View File

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

View File

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

View File

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