34 Commits

Author SHA1 Message Date
77db5e1f05 pass onUnauthorized to AuthProvider instead of separate event listener 2026-07-19 03:18:36 +05:30
d2347de932 redirect to login on auth:unauthorized event from AppContent 2026-07-19 03:12:35 +05:30
1a8f98d4a2 move BrowserRouter above AppProvider, wire onUnauthorized redirect to /login 2026-07-19 01:04:42 +05:30
804617ff27 update .run configs to point at khata-ui package.json 2026-07-18 20:20:22 +05:30
00dd507fc4 wire spec-driven auth: remove VITE_AUTH_BASE_URL, reorder providers
- main.jsx: remove VITE_AUTH_BASE_URL env var; add AppContent that
  reads authConfig from useAppContext() and passes to AuthProvider;
  reorder: AppProvider -> AuthProvider -> BrowserRouter
- sync react-openapi: AuthConfig type, extractAuthConfig, loading fix
- sync react-auth: authConfig prop, config-driven paths, server logout
2026-07-18 19:48:47 +05:30
e87a2d55ab fixes 2026-07-18 13:52:52 +05:30
5d34c51e98 wire profile components, login/register routes, spec-driven auth
- src/main.jsx: add /login, /register, /profile/* routes; wire
  profileComponents from react-auth; pass basePath to Admin
- src/Header.tsx: profile button nav to /profile/me; Login to /login
- src/openapi-config.ts: wire getToken and profileComponents
- react-auth local: sync ProfileCreate, ProfileEdit, ProfileView
- react-openapi local: sync all auth + x-profile changes
  (AppProvider, Admin, ProfileRoutes, SideMenu, types, hooks, etc.)
2026-07-17 21:00:47 +05:30
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
617f6bea6c new FetchRequest 2026-07-12 00:01:53 +05:30
e1ae4f0ebe removed unused pages 2026-07-11 17:23:42 +05:30
04aa16b564 fetch request fixes 2026-07-11 17:18:42 +05:30
99cc5afb7c fetch request fixes 2026-07-11 17:11:27 +05:30
afddb3cfbc removed dashboard and reports 2026-07-11 17:10:26 +05:30
b75505de25 added useItemSse 2026-07-11 17:09:50 +05:30
46e666c6a5 fix for nested x-display-format 2026-07-11 03:38:25 +05:30
f5bc7adc37 MULTI schema handling 2026-06-19 20:03:23 +05:30
9a80a52fd5 form single line per field 2026-06-19 19:18:41 +05:30
ac7c3d6313 fixes 2026-06-19 16:53:12 +05:30
5892bca44d require auth 2026-06-19 00:28:23 +05:30
8c824d5239 passing specConfiguration to validator 2026-06-18 23:57:32 +05:30
f345dafb46 payee display fixes 2026-06-18 23:53:44 +05:30
b0c5332d77 expenses pagination fixes 2026-06-18 23:37:56 +05:30
12e5f113b8 khata compliant with new react-openapi 2026-06-18 23:20:57 +05:30
cbac57dc36 updated sse supporting react-openapi README.md 2026-06-18 20:42:23 +05:30
154b15fe51 updated sse supporting react-openapi 2026-06-18 20:32:34 +05:30
0a668cf98d updated react-openapi 2026-06-17 21:03:08 +05:30
cd89eb4c88 fix: add keys to ObjectField children and guard relation data in getFieldOptions 2026-06-13 13:14:47 +05:30
0907e071b5 fix: show date fields in report snapshots using native date inputs 2026-06-12 16:39:40 +05:30
d58f2a0744 fix: use date component for report snapshot start/end dates 2026-06-12 16:29:55 +05:30
8300e43e14 feat: add date field overrides for fetch-requests and allow type/label in overrides 2026-06-10 00:40:19 +05:30
386297dc1e fix: date rendering now shows only date for date fields 2026-06-10 00:29:19 +05:30
138 changed files with 8504 additions and 6525 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>

24
CONCEPT.md Normal file
View File

@@ -0,0 +1,24 @@
# Concept Overview
The application is a **metadatadriven admin UI** built on top of an OpenAPI description. By describing each resource in a small JSON config (type `ResourceConfig`), the UI automatically generates:
1. **Data tables** (with pagination, sorting, and actions) `EnhancedTable`.
2. **Dynamic filters** `FilterBar` creates appropriate filter widgets (autocomplete, numberrange, daterange) based on field metadata.
3. **Forms for create/edit** A generic form component can render inputs for every `ResourceField`, handling relations via the `displayFormat` template.
4. **Authentication layer** `reactauth` supplies a central `AuthProvider`, a `useAuth` hook, and route guarding, ensuring only authenticated users reach the admin pages.
### Core Principles
- **Declarative configuration**: Adding a new resource is just a JSON entry; no handcoded tables or forms.
- **Templatebased display**: `displayFormat` (e.g. `"{{firstName}} {{lastName}}"`) defines how related objects are shown across the UI, eliminating the need for separate `displayField` props.
- **Extensible UI**: Consumers can plug custom components (`components` prop) to override cell renderers, filter widgets, or action buttons without altering core logic.
- **Unified state**: TanStack Query caches server data, while `reactauth` manages JWTs and user info. Both are provided via React context for easy access.
- **Responsive design**: The UI automatically switches to a cardbased layout on mobile, preserving functionality with a consistent look.
### Migration Goal for Lovable
The current repo implements these ideas with a solid foundation but could benefit from:
- **Improved UI/UX** (e.g., better loading states, richer snackbars, darkmode toggle).
- **More robust error handling** (centralized toast system, retry logic on auth failures).
- **Enhanced theming** (customizable palette, brand colors).
- **Accessibility** (ARIA roles, keyboard navigation).
By reusing the existing `ResourceConfig` schema and `displayFormat` logic, the Lovable implementation can focus on UI polish and advanced handling while keeping the powerful codegeneration approach intact.

34
DESIGN.md Normal file
View File

@@ -0,0 +1,34 @@
# Design Overview
## ReactAuth
- **Purpose**: Centralize authentication flows (login, logout, token refresh) for the UI.
- **Key Concepts**
- **AuthProvider** React context that stores `user`, `accessToken`, and `isAuthenticated`.
- **useAuth hook** Exposes `login`, `logout`, `refreshToken`, and state values.
- **Route Guard** HOC/Component (`ProtectedRoute`) that redirects unauthenticated users to the login page.
- **UI**: Simple MUI forms, error handling with snackbars, and a loading spinner while the auth request is pending.
- **Extensibility**: Plugin point for additional providers (OAuth, SSO) via a `providers` map.
## ReactOpenAPI
- **Purpose**: Generate UI components directly from an OpenAPI spec (tables, filters, forms).
- **Core Modules**
- `ResourceConfig` & `ResourceField` Typed definitions that describe each endpoint and its fields, including `displayFormat` for rendering.
- `EnhancedTable` Datagrid component that renders rows according to the config, supports relation rendering, sorting, pagination, and custom cell renderers.
- `FilterBar` Dynamically builds filter controls (autocomplete, numberrange, daterange) based on the same config.
- **Data Flow**
1. Load OpenAPI spec → transform to `ResourceConfig` objects.
2. `useQuery` (TanStack) fetches data.
3. UI components consume the config to render tables and filter UI without handwritten column definitions.
- **Design Goals**
- **Zero boilerplate** Adding a new resource only requires a JSON config.
- **Consistency** All tables share pagination, actions, and styling.
- **Extensibility** Override components via `components` prop.
## src (Root Application)
- **Entry Point** `main.tsx` mounts the React app with `BrowserRouter` and wraps it with `AuthProvider`.
- **Routing** Routes are defined perresource (`/admin/:resource`, `/admin/:resource/edit/:id`). `ProtectedRoute` ensures auth.
- **State Management** TanStack Query handles server state; React Context handles auth state.
- **Theming** MUI theming with light/dark mode toggle (future enhancement).
---
These design notes serve as a concise reference for developers preparing a richer UI/UX implementation on the **lovable** platform.

49
IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,49 @@
# Implementation Details
## ReactAuth
- **File Structure**
- `src/auth/AuthContext.tsx` Provides `AuthContext` and `AuthProvider`.
- `src/auth/useAuth.ts` Custom hook returning context values and actions.
- `src/auth/ProtectedRoute.tsx` Wrapper component that checks `isAuthenticated` and redirects.
- `src/auth/api.ts` Thin wrapper around `axios` for login, logout, refresh.
- **Logic**
1. On `login`, POST credentials → store `accessToken` & user info in context and `localStorage`.
2. An `axios` interceptor attaches the token to every request.
3. `refreshToken` runs on 401 responses; it attempts a silent refresh and updates the context.
4. `logout` clears context and storage, navigating back to `/login`.
- **UI Components**
- `LoginForm` MUI `TextField`s, validation, and submit handling.
- `AuthLoading` Fullscreen spinner displayed while session restoration runs on app boot.
## ReactOpenAPI
- **Core Files**
- `src/react-openapi/types/config.ts` Already defines `ResourceField` with `displayFormat`.
- `src/react-openapi/utils/options.ts` Helper `resolveTemplate` parses `{{field}}` placeholders using the item data.
- `src/react-openapi/components/EnhancedTable.tsx` Renders a MUI `DataGrid`. Uses `getFormattedDisplayValue` to compute readable labels for relation fields based on `displayFormat`.
- `src/react-openapi/components/FilterBar.tsx` Generates filter inputs; extracts option labels using the same `displayFormat` logic.
- **Data Fetching**
- `useResource(resourceName)` TanStack `useQuery` hook that builds the endpoint URL from `config.endpoint` and fetches data via the shared Axios instance.
- **Customization**
- `components` prop passed to `EnhancedTable`/`FilterBar` allows overriding cell renderers, filter widgets, and action buttons.
- **Error Handling**
- Centralized error toast (`useToast`) displays API errors.
- Table shows “No data” state when an empty array is returned.
## src (Application Core)
- **src/main.tsx** Sets up MUI theme, React Router, `AuthProvider`, and `QueryClientProvider`.
- **src/App.tsx** Defines routes:
```tsx
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route element={<ProtectedRoute />}>
<Route path="/admin/:resource" element={<ResourceList />} />
<Route path="/admin/:resource/edit/:id" element={<ResourceForm />} />
</Route>
</Routes>
```
- **src/pages/ResourceList.tsx** Reads `resource` from URL, loads its `ResourceConfig`, calls `useResource`, and renders `EnhancedTable` + `FilterBar`.
- **src/pages/ResourceForm.tsx** Dynamically builds a form based on `ResourceField` definitions, using `displayFormat` for default values.
- **State Management** TanStack Query caches paginated results; `AuthProvider` ensures all API calls include a valid JWT.
- **Theming** `ThemeProvider` toggles light/dark mode via a context hook that persists the preference in `localStorage`.
These implementation notes detail the concrete file layout, data flow, and core logic that power the UI generated from OpenAPI specifications while maintaining authenticated access. They can be directly adapted for the **lovable** platform to provide a richer UI and better handling of auth and data rendering.

172
REFRACTOR_GUIDE.md Normal file
View File

@@ -0,0 +1,172 @@
# Refactor Guide Deep Dive into the KhataUI Codebase
> This document walks through the entire repository, explains the current architecture, and provides a stepbystep refactor plan that will improve maintainability, type safety, and UI/UX while preserving the existing functional behavior.
---
## 1. Repository Layout (highlevel)
```
khata-ui/
├─ react-openapi/ # Core UI generated from OpenAPI configs
│ ├─ components/ # UI pieces: EnhancedTable, FilterBar, etc.
│ ├─ types/ # TypeScript interfaces (config, overrides)
│ └─ utils/ # Helper utilities (options, template resolution)
├─ src/ # Application entry point and pages
│ ├─ auth/ # Authentication context, hooks, and protected routes
│ ├─ pages/ # Dynamic resources (list, form)
│ └─ main.tsx # React root, providers, theming
├─ public/ # Static assets (favicon, index.html)
├─ index.html
├─ package.json
└─ tsconfig.json
```
### Key Concepts
| Area | Responsibility |
|------|-----------------|
| **Auth** | Central JWT handling, `AuthProvider`, `useAuth`, route guarding. |
| **OpenAPIdriven UI** | Describes each resource via `ResourceConfig`/`ResourceField`. Generates tables, filters, and forms automatically. |
| **Data Layer** | TanStack Query (`useQuery`) fetches data; Axios instance carries auth token via interceptor. |
| **Theming** | MUI theme with light/dark mode toggle (future). |
| **Extensibility** | `components` prop on `EnhancedTable` / `FilterBar` lets callers inject custom cell renderers, filter widgets, or action buttons. |
---
## 2. Detailed Module Walkthrough
### 2.1 `react-openapi/types/config.ts`
```ts
export interface ResourceField {
displayFormat: string; // <- single source of truth for rendering
type: FieldType;
label: string;
required?: boolean;
options?: string[];
readOnly?: boolean;
schema?: Record<string, ResourceField>;
formatter?: (value: any) => string;
relation?: string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
}
```
- `displayFormat` replaces the legacy `displayField`. It can be a **template string** (`"{{first}} {{last}}"`) or an **array of keys** for concatenation.
- All UI components now rely exclusively on this field.
### 2.2 `react-openapi/utils/options.ts`
- `resolveTemplate(format: string, item: any)` interpolates `{{key}}` placeholders.
- `getFieldOptions`, `toGridValueOptions` convert enum definitions into MUIcompatible arrays.
- **Refactor idea**: Move the `displayFormat` resolution logic from `EnhancedTable`/`FilterBar` into a dedicated helper (`formatDisplay(item, field)`), reducing duplication.
### 2.3 `react-openapi/components/EnhancedTable.tsx`
- **Core responsibilities**
1. Build column definitions from `config.fields`.
2. Render each cell via `FieldRenderer`.
3. Provide serverside or clientside pagination.
4. Add a static "Actions" column.
- **Key functions**
- `getFormattedDisplayValue(item, displayFormat?, enumValue?)` now uses `resolveTemplate` and falls back to generic fields.
- `FieldRenderer` decides how to render a cell based on `field.type`, `field.relation`, custom renderers, and `displayFormat`.
- **Duplication**: Both `EnhancedTable` and `FilterBar` perform very similar `displayFormat` extraction. Extracting this into a shared utility will shrink the component size and make testing easier.
### 2.4 `react-openapi/components/FilterBar.tsx`
- Generates filter controls for each **filterable** field.
- Uses `extractOptions` to populate autocomplete lists, falling back to `displayFormat` for label generation.
- **Opportunity**: Replace the inline `pull` helper with the shared formatter from `utils/options`.
### 2.5 Authentication (`src/auth`)
- `AuthContext.tsx` provides `user`, `accessToken`, `isAuthenticated` plus actions.
- `useAuth.ts` thin wrapper exposing the context values.
- `ProtectedRoute.tsx` guards routes, redirects to `/login` when unauthenticated.
- `api.ts` thin Axios wrapper (`login`, `logout`, `refresh`).
- **Refactor suggestions**
- Consolidate token storage (localStorage ↔ sessionStorage) behind a small `tokenStore` service.
- Add automatic token refresh using an interceptor that retries the original request.
- Provide a hook (`useAuthorizedQuery`) that injects the auth token into TanStack Query automatically.
### 2.6 Application Core (`src/pages`, `src/main.tsx`)
- `ResourceList.tsx` reads `resource` param, loads the related `ResourceConfig` from a central map, fetches data, and renders `EnhancedTable` + `FilterBar`.
- `ResourceForm.tsx` builds a dynamic form based on `ResourceField` definitions; uses `displayFormat` for default values on relation fields.
- `main.tsx` wraps the app with `AuthProvider`, `QueryClientProvider`, and MUI `ThemeProvider`.
- **Future work**: Extract the “resource loader” into a hook (`useResourceConfig(resourceName)`) that also validates the config at runtime.
---
## 3. Refactor Roadmap StepbyStep
### Phase1 Consolidate Formatting Logic
1. **Create utility** `src/react-openapi/utils/formatDisplay.ts`
```ts
export const formatDisplay = (item: any, field: ResourceField, enumValue?: string) => {
if (enumValue) return resolveTemplate(enumValue, item);
const fmt = field.displayFormat;
if (!fmt) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
if (Array.isArray(fmt)) {
return fmt.map(k => item[k]).filter(Boolean).join(' ');
}
return resolveTemplate(fmt, item) || item.id || JSON.stringify(item);
};
```
2. Replace *all* inline calls to `getFormattedDisplayValue` in `EnhancedTable` and `FilterBar` with `formatDisplay`.
3. Remove `getFormattedDisplayValue` from `EnhancedTable.tsx` (or keep it as a thin wrapper for backward compatibility).
4. Update imports accordingly.
5. Run TypeScript check no errors.
### Phase2 Decouple UI from Config Loading
- Introduce **`configLoader.ts`** under `src/react-openapi/utils` that reads a JSON file (or fetches a remote spec) and produces a `Record<string, ResourceConfig>`.
- Replace hardcoded imports in `src/pages/ResourceList.tsx` with a call to `useResourceConfig(resourceName)`.
- Add runtime validation (e.g., using `zod`) to ensure required fields (`displayFormat`, `type`, `label`) are present; surface errors via a toast.
### Phase3 Centralize Error & Loading UI
- Create `src/components/LoadingSpinner.tsx` and `src/components/ErrorToast.tsx`.
- Wrap all datafetching hooks (`useResource`, `useAuth` actions) with a HOC that automatically displays these components.
- Migrate the scattered `if (loading) …` checks into the new components.
### Phase4 Theming & Dark Mode
1. Add a `ThemeContext` that stores `mode: 'light' | 'dark'` and persists the preference.
2. Expose a toggle button (e.g., in the topright corner of `App.tsx`).
3. Update component styles to use themeaware colors (via `theme.palette`), ensuring the `Chip` variants already respect the palette.
### Phase5 Testing & CI
- **Unit tests** using `vitest` for:
- `formatDisplay` utility (various template & array cases).
- `AuthProvider` behavior (login, logout, token refresh).
- **Component tests** (`@testing-library/react`) for `EnhancedTable` and `FilterBar` verifying that `displayFormat` rendering matches expectations.
- Add a GitHub Actions workflow that runs `npm run lint && npx tsc --noEmit && vitest run` on each PR.
### Phase6 Documentation (the files you will publish)
- **DESIGN.md** highlevel architecture (already present).
- **IMPLEMENTATION.md** detailed filebyfile breakdown (already present).
- **CONCEPT.md** why the metadatadriven approach works (already present).
- **REFRACTOR_GUIDE.md** the detailed guide you are reading now (this file).
- Keep these files in the repo root; they can be exported to the **lovable** platform directly.
---
## 4. Migration Checklist (what to verify after refactor)
- [ ] All UI components compile with TypeScript (`npx tsc --noEmit`).
- [ ] No runtime references to `displayField` remain (search `\.displayField`).
- [ ] `formatDisplay` correctly resolves:
- Template strings with multiple placeholders.
- Array of keys.
- Fallback to generic fields.
- [ ] Auth flow works (login ➜ token stored ➜ API requests succeed, protected routes guarded).
- [ ] Pagination works both client and serverside.
- [ ] Mobile layout (card view) still renders correctly.
- [ ] Darkmode toggle persists across reloads.
- [ ] Lint passes (`npm run lint` if configured) and tests pass.
---
## 5. Potential Future Enhancements
| Feature | Benefit | Rough Implementation |
|---------|---------|----------------------|
| **Bulk actions** (delete, export) | Improves admin productivity | Add a toolbar with selection model in `EnhancedTable`. |
| **Inline editing** | Faster data tweaks | Replace `onEdit` dialog with celllevel edit mode using MUI `TextField`. |
| **GraphQL fallback** | Flexibility for backends | Abstract data fetching behind an adapter interface (`useDataProvider`). |
| **Internationalisation** | Multilanguage UI | Wrap all static strings with `i18n.t()` and provide locale files. |
| **Performance profiling** | Identify render bottlenecks | Use React Profiler and memoize expensive formatters (`useMemo`). |
---
### Closing Note
The current codebase already demonstrates a powerful pattern: **declare once, render everywhere**. By consolidating the display logic, adding a small utility layer, and strengthening the authentication and theming foundations, the project will become easier to extend, test, and handoff to the **lovable** UI platform while retaining its lowcode advantage.

1595
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,31 +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": {
"@vitejs/plugin-react": "latest", "@types/js-yaml": "^4.0.9",
"typescript": "^6.0.3", "@types/node": "^25.9.3",
"vite": "latest" "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.1"
} }
} }

View File

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

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

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

View File

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

View File

@@ -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,24 @@ const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export function AuthProvider({ export function AuthProvider({
children, children,
authBaseUrl, authConfig,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
authBaseUrl: string; authConfig: AuthServerConfig;
}) { }) {
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 +61,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,7 +70,12 @@ 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);
@@ -70,10 +84,12 @@ export function AuthProvider({
const fetchCurrentUser = async () => { const fetchCurrentUser = async () => {
if (!token) return; if (!token) return;
try { try {
const me = await auth.get("/me"); const me = await auth.get(authConfig.mePath);
setCurrentUser({ ...me.data }); setCurrentUser({ ...me.data });
} catch { } catch {
logout(); tokenStore.clear();
setToken(null);
setCurrentUser(null);
} }
}; };

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

@@ -1,186 +0,0 @@
import * as React from "react";
import { useAuth, AuthPage } from "../react-auth";
import { UploadProvider } from "./providers/UploadProvider";
import AdminLayout from "./components/AdminLayout";
import ResourceView from "./components/ResourceView";
import { getAppConfig } from "./config";
import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config";
import { FieldComponents } from "./types/overrides";
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import {
Routes,
Route,
useNavigate,
useParams,
} from "react-router-dom";
import { ConfigContext } from "./providers/ConfigContext";
import ProfileView from "./components/ProfileView";
function DefaultDashboard({ basePath }: { basePath: string }) {
const config = React.useContext(ConfigContext);
const navigate = useNavigate();
const resources = config?.resources || [];
const visibleResources = resources.filter((res) => !res.hidden);
return (
<Box>
<Typography variant="h4" gutterBottom>
Welcome to the Admin Panel
</Typography>
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data.
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 3,
mt: 4,
}}
>
{visibleResources.map((res) => (
<Paper
key={res.name}
sx={{
p: 3,
textAlign: "center",
cursor: 'pointer',
transition: 'transform 0.2s',
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
}}
onClick={() => navigate(`/admin/${res.name}`)}
>
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
</Paper>
))}
</Box>
</Box>
);
}
interface AdminAppProps {
basePath: string;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) {
const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext);
const navigate = useNavigate();
const resources = config?.resources || [];
const visibleResources = resources.filter((res) => !res.hidden);
if (!currentUser) {
return (
<LoginPage
mode="login"
login={login}
register={async () => {}}
loading={loading}
error={error}
onSwitchMode={() => {}}
onBack={() => {}}
currentUser={null}
/>
);
}
return (
<Layout
username={currentUser.username}
onLogout={logout}
onSelectResource={(name) => navigate(`/admin/${name}`)}
resources={visibleResources}
>
<Routes>
<Route path="/" element={<Dashboard basePath={basePath} />} />
<Route path="/profile" element={<ProfileView />} />
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
</Routes>
</Layout>
);
}
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) {
const { resourceName } = useParams();
const config = React.useContext(ConfigContext);
const selectedResource = config?.resources.find((r) => r.name === resourceName);
if (!selectedResource) return <Typography>Resource not found</Typography>;
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
}
interface AdminLayoutProps {
children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void;
onLogout: () => void;
username?: string;
resources: import("./types/config").ResourceConfig[];
}
interface AdminProps {
basePath?: string;
resourceOverrides?: Record<string, any>;
profileConfig?: any;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) {
const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
React.useEffect(() => {
if (!existingConfig) {
getAppConfig(resourceOverrides, profileConfig).then((cfg) => {
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
setConfig(cfg);
});
}
}, [resourceOverrides, profileConfig, existingConfig]);
if (!config) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress />
</Box>
);
}
const content = (
<UploadProvider>
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} />
</UploadProvider>
);
if (existingConfig) {
return content;
}
return (
<ConfigContext.Provider value={config}>
{content}
</ConfigContext.Provider>
);
}

965
react-openapi/README.md Normal file
View File

@@ -0,0 +1,965 @@
# react-openapi
Auto-generates an admin panel (CRUD, datatable, relationship management) from an OpenAPI 3.1.0 specification at runtime.
## How It Works
1. You provide a `specConfiguration` object with the URL to your OpenAPI YAML spec and an auth callback.
2. `AppProvider` fetches the spec, validates it against ``react-openapi``'s required extensions, and builds an internal resource configuration.
3. The `Admin` component renders routes (list, detail, create, edit) for each resource detected in the spec.
4. All API calls go through an Axios client that injects the Bearer token from your callback.
## Installation
```bash
npm install react-openapi
# peer dependencies (install these in your app):
npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml
```
## Quick Start
```tsx
import { AppProvider, Admin } from "react-openapi";
import type { SpecConfiguration } from "react-openapi";
const specConfig: SpecConfiguration = {
specUrl: "/api/openapi.yaml",
baseApiUrl: "https://api.example.com/v1",
title: "Vet Clinic Admin",
getToken: () => localStorage.getItem("token"),
};
function App() {
return (
<AppProvider specConfiguration={specConfig}>
<Admin basePath="/admin" />
</AppProvider>
);
}
```
## Exported API
### Components
| Export | Purpose |
|------------------------|----------------------------------------------------------------------------------------------------------------|
| `AppProvider` | Fetches the spec, validates it, initializes the API client, and provides context. Wrap your app with this. |
| `Admin` | Renders the admin layout (sidebar + routes) for all resources. Takes `basePath`. |
| `ListCellRenderer` | Renders a cell value in a list/table column. Respects FK display formats, inline refs, enums, booleans. |
| `DetailFieldRenderer` | Renders a read-only field value in a detail view (label + value pair). |
| `FormFieldRenderer` | Renders an editable form field based on `FieldConfig`. Dispatches to `FkSelectField`, `BooleanField`, etc. |
| `SseStreamView` | Renders a live SSE stream card with connection status, event counter, latest event preview, and snackbar. |
| `SseConnectionStatus` | Renders a green/red dot with "Connected"/"Disconnected" for an SSE resource. Reactive across all components. |
### Hooks
| Export | Purpose |
|-------------------------------|-------------------------------------------------------------------------------------------------------------------|
| `useAppContext()` | Returns `{ config, resources, schemas, loading, errors, warnings }`. Access all resource configs. |
| `useResource(resourceName)` | Returns `{ resource, components, list, get, create, update, remove, stream?, loading, error }`. CRUD + streaming. |
The `stream` property is only present for SSE resources (`resource.streaming === true`). It accepts `{ onEvent, onError?, onOpen? }` and returns `{ close }`.
### Utilities
| Export | Purpose |
|---------------------|---------------------------------------------------------------------|
| `getApi()` | Returns the configured Axios instance. Useful for custom API calls. |
| `applyDisplayFormat`| Formats an object using a template string like `"{name} - #{id}"`. |
### Types
| Export | Purpose |
|------------------------|----------------------------------------------------------|
| `SpecConfiguration` | `{ specUrl, baseApiUrl?, title?, getToken? }` |
| `ResourceConfig` | Internal representation of a resource after spec parsing |
| `FieldConfig` | Internal representation of a field after spec parsing |
| `FKFieldConfig` | `{ resource, prefetch }` |
| `ResourceRelationship` | `{ fieldName, config, targetSchemaName }` |
| `FilterComponentProps` | `{ value, onChange, data?, labelOverride? }` |
### `ResourceConfig` Properties
| Property | Type | Description |
|----------------|-----------------------------------|---------------------------------------------------------------------------|
| `name` | `string` | URL-friendly resource name (e.g. `"pets"`, `"calls"`) |
| `schemaName` | `string` | OpenAPI schema key (e.g. `"Pet"`, `"Call"`) |
| `displayName` | `string` | Human-readable name for UI labels (e.g. `"Pets"`, `"Calls"`, `"Two Parts"`) |
| `path` | `string` | API base path (e.g. `"/pets"`, `"/calls"`) |
| `streaming` | `boolean` (optional) | `true` if this resource uses SSE streaming instead of REST CRUD |
### SSE Streaming (Server-Sent Events)
Resources with `x-sse: true` on their OpenAPI spec path object are treated as streaming resources. Instead of CRUD, they expose:
- **`stream()`** — accepts `{ onEvent, onError?, onOpen? }` and returns `{ close }`.
- **Connection status** — reactive across `SseConnectionStatus` and `SseStreamView`.
- **Auto-reconnect** — SSE automatically reconnects on connection loss (browser-native).
**OpenAPI extension:**
```yaml
/pets/events:
x-sse: true
get:
summary: Subscribe to Pet events
responses:
200:
description: SSE stream of Pet change events
content:
text/event-stream:
schema:
type: object
```
When `x-sse: true` is set, the resource is excluded from sidebars, list/detail routes, and CRUD hooks. It only appears in the `streaming` resource collection.
**Custom UI example:**
```tsx
import { useAppContext, SseStreamView } from 'react-openapi';
function MyStreamPage() {
const { resources } = useAppContext();
const res = resources.find(r => r.name === 'calls');
if (!res) return null;
return <SseStreamView resource={res} />;
}
```
### Loading Custom Components from Resources
Pass per-resource overrides via `specConfiguration`:
```tsx
<AppProvider config={config} resourceComponents={{
pets: { list: PetList, detail: PetDetail, form: PetForm },
calls: { detail: CallStream }, // SSE: only detail is used
}} />
```
Within resource components, use `components` from `useResource()`:
```tsx
const { components } = useResource('pets');
const row = data.map(item => <components.ListCell field={field} value={item[field.name]} />);
```
## OpenAPI Spec Authoring Guide
``react-openapi`` uses custom `x-` extensions embedded in your OpenAPI 3.1.0 spec. This section is the **single source of truth** for writing a compatible spec.
### Quick Reference
```yaml
openapi: 3.1.0
info:
title: My API
version: 1.0.0
servers:
- url: https://api.example.com/v1
components:
schemas:
# --- Error schemas ---
ErrorBody:
type: object
properties:
detail:
type: string
required: [detail]
HTTPValidationError:
type: object
properties:
detail:
type: array
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
properties:
loc:
type: array
items:
type: string
msg:
type: string
type:
type: string
required: [loc, msg, type]
# --- Non-resource schema (skipped by validator) ---
Metadata:
type: object
properties:
createdOn:
type: string
format: date-time
updatedOn:
type: string
format: date-time
# --- Resource schema ---
Pet:
type: object
x-resource: pets # REQUIRED - maps to /pets path
x-primary-key: id # REQUIRED
x-display-format: "{name} - #{id}" # REQUIRED
x-list-columns: [name, species, age] # REQUIRED
properties:
id:
type: integer
readOnly: true
x-order: 0 # REQUIRED on every property
x-hidden: { form: true, list: true }
x-label: "ID" # REQUIRED on every property
name:
type: string
x-order: 1
x-label: "Pet Name"
x-description: "Name of the pet" # optional
x-filterable: true # optional
x-sortable: true # optional
species:
type: string
enum: [dog, cat, bird] # renders as select dropdown
x-order: 2
x-label: "Species"
weight:
type: number
format: float
x-order: 3
x-label: "Weight"
birthDate:
type: string
format: date # renders as date picker
x-order: 4
x-label: "Date of Birth"
photo:
type: string
format: binary
x-ui-type: image # renders as file upload
x-upload-url: /pets/{id}/photo # POST endpoint for upload
x-order: 5
x-label: "Photo"
owner:
$ref: '#/components/schemas/Parent'
x-fk: # renders as FK dropdown
resource: parents
prefetch: true # load all options on mount
x-order: 6
x-label: "Owner"
siblings:
type: array
items:
$ref: '#/components/schemas/Pet'
x-fk: # renders as multi-select
resource: pets
prefetch: false # lazy-load on focus
x-order: 7
x-label: "Siblings"
metadata:
$ref: '#/components/schemas/Metadata'
# NO x-fk — renders as inline read-only display
x-order: 8
x-label: "Metadata"
required: [id, name, owner]
# --- Error response schemas (REQUIRED for spec validity) ---
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
Forbidden:
description: Insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
ValidationError:
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
InternalServerError:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorBody'
paths:
# --- Collection paths ---
/pets:
get:
summary: List pets (paginated)
parameters:
- in: query
name: limit
schema: { type: integer, default: 20 } # default is REQUIRED if pagination params exist
- in: query
name: offset
schema: { type: integer, default: 0 }
responses:
'200':
description: Paginated list of pets
content:
application/json:
schema:
type: object
properties:
total:
type: integer
items:
type: array
items:
$ref: '#/components/schemas/Pet'
# --- Error responses (RECOMMENDED on every operation) ---
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
post:
summary: Create a pet
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'201':
description: Pet created
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
# --- Item paths ---
/pets/{id}:
get:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Single pet
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
put:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
responses:
'200':
description: Pet updated
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
delete:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'204':
description: Pet deleted
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
```
### Schema-Level Extensions
These go on the schema object itself and mark it as a **resource** that generates the admin UI.
#### `x-resource` (REQUIRED)
```yaml
Pet:
type: object
x-resource: pets # maps to the /pets path in paths
```
- Value must be a **string** matching the collection path segment (e.g., `pets` → `/pets`).
- Schemas **without** `x-resource` are skipped entirely — no CRUD routes, no extension validation. Use this for shared schemas like `Metadata`, `ErrorBody`, etc.
- **Validator error** if the path `/pets` does not exist in `paths`.
#### `x-primary-key` (REQUIRED)
```yaml
x-primary-key: id
```
- The name of the property that uniquely identifies each record.
- Used as the `:id` path parameter in routes and API calls.
- Must reference a property that exists in `properties`.
- **Validator error** if missing.
#### `x-display-format` (REQUIRED)
```yaml
x-display-format: "{name}"
x-display-format: "Dr. {name}"
x-display-format: "Apt #{id} - {date}"
```
- Template string with `{propertyName}` placeholders.
- Used to display a human-readable label for a record (list rows, detail title, FK dropdown options).
- Property names in `{braces}` are replaced with the record's property values at render time.
- **Validator error** if missing.
#### `x-list-columns` (REQUIRED)
```yaml
x-list-columns: [name, species, age, owner]
```
- Array of property names to display as columns in the datatable.
- Each name must exist in `properties`.
- Column order follows the array order.
- The display value for each cell is determined by the `x-display-format` of the **property's target resource** (for FK fields) or the **parent resource** (for direct fields).
- **Validator error** if missing or if any column name references a non-existent property.
### Property-Level Extensions
These go on individual properties inside `properties`.
#### `x-label` (REQUIRED on every property)
```yaml
x-label: "Pet Name"
```
- Human-readable label used in form fields, table headers, and detail views.
- **Validator error** if missing on any property of a resource schema.
#### `x-order` (REQUIRED on every property)
```yaml
x-order: 1
```
- Integer determining field ordering in forms and detail views.
- Lower values appear first. Ties are broken by alphabetical property name.
- **Validator error** if missing or null on any property of a resource schema.
#### `x-description` (optional)
```yaml
x-description: "Name of the pet"
```
- Shown as helper text below form fields and as placeholder text.
- Falls back to `x-label` if not provided.
#### `x-hidden` (optional)
```yaml
x-hidden: { form: true, list: true, detail: true }
x-hidden: { form: true } # only hide in form
```
- Controls visibility in each view. Valid keys: `form`, `list`, `detail`.
- Any missing key defaults to `false` (visible).
- Useful for hiding auto-generated fields like `id` from forms.
#### `x-filterable` (optional)
```yaml
x-filterable: true
```
- Marks the field as filterable in the datatable (UI integration TBD — reserved for future use).
- Default: `false`.
#### `x-sortable` (optional)
```yaml
x-sortable: true
```
- Adds sort controls to the datatable column header.
- When clicked, sends `?sort=fieldName` (ascending) or `?sort=-fieldName` (descending) to the list endpoint.
- Default: `false`.
#### `x-fk` (optional)
```yaml
owner:
$ref: '#/components/schemas/Parent'
x-fk:
resource: parents
prefetch: true # load all options on mount
```
Marks a `$ref` property as a foreign key. The form renders a **dropdown** (single) or **multi-select** (if `type: array`, `items: $ref`).
| Sub-field | Type | Default | Description |
|------------|---------|------------|-------------------------------------------------------------------------------------------|
| `resource` | string | (required) | The `x-resource` value of the target schema. Uses the resource name, not the schema name. |
| `prefetch` | boolean | `false` | If `true`, fetch all FK options on mount. If `false`, fetch on focus (lazy). |
**How FK options are fetched:**
1. The library calls `GET /{targetResource}?limit=0` (for paginated targets — `limit=0` is a server convention meaning "return all") or `GET /{targetResource}` (for non-paginated).
2. The response is parsed strictly by the target resource's pagination config.
3. Each item is formatted using the target resource's `x-display-format`.
**Validator rules:**
- The `resource` value must match some schema's `x-resource` in the spec.
- The target schema must have `x-display-format` and `x-primary-key`.
#### `$ref` without `x-fk` — Inline Display
```yaml
metadata:
$ref: '#/components/schemas/Metadata'
# no x-fk here
```
A `$ref` property without `x-fk` renders as **read-only inline display**:
- In **list** and **detail** views: uses `InlineRefField` which shows key-value chips (or the referenced resource's `x-display-format` if the target is a resource schema).
- In **forms**: shows a disabled `TextField` with `JSON.stringify(value, null, 2)`.
The validator emits an `info` message for these to remind you they won't be editable.
#### `x-ui-type` (optional)
```yaml
photo:
type: string
format: binary
x-ui-type: image
x-upload-url: /pets/{id}/photo
```
Currently, supports only `"image"`. Changes the form field to a file upload component.
- If the record has an `id` and `x-upload-url` is set: POSTs the file to `x-upload-url` (with `{id}` replaced by the record's ID), then updates the field value with the response URL.
- If no `id` or no `uploadUrl`: falls back to base64 data-URL via `FileReader`.
#### `x-upload-url` (optional, used with `x-ui-type: image`)
```yaml
x-upload-url: /pets/{id}/photo
```
POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID.
#### `x-upload` (optional)
```yaml
path:
type: string
x-upload:
type: file # one of: image, pdf, html, csv, file
url: /uploads # POST endpoint that accepts raw binary + Content-Disposition header
```
Changes the form field to a file upload component. The file is POSTed as raw binary (`application/octet-stream`) to the specified `url` with a `Content-Disposition: attachment; filename="..."` header. The response must contain a `saved_as` field, which is stored as the field value.
| `type` | Display |
|------------|--------------------------------------------------|
| `image` | `<Avatar src="/uploads/{value}" />` |
| `pdf` | Clickable chip linking to `/uploads/{value}` |
| `html` | Clickable chip linking to `/uploads/{value}` |
| `csv` | Clickable chip linking to `/uploads/{value}` |
| `file` | Clickable chip linking to `/uploads/{value}` |
The chip includes a delete (X) button to replace the file.
### Property Types That Trigger Special Renderers
| Condition | Form Renderer | List/Detail Renderer |
|--------------------------------------|-------------------------------------------|----------------------------------------------|
| `x-fk` exists, `type` is NOT `array` | `FkSelectField` (dropdown) | `ListCellRenderer` with `applyDisplayFormat` |
| `x-fk` exists, `type: array` | `FkMultiSelectField` (Autocomplete multi) | `ListCellRenderer` with chips |
| `enum` is defined | `EnumField` (select) | `Chip` |
| `type: boolean` | `BooleanField` (Switch) | Chip with "Yes"/"No" |
| `type: integer` or `type: number` | `NumberField` | Typography |
| `format: date` | `DateField` (date picker) | Typography |
| `format: date-time` | `DateField` (datetime-local picker) | Typography |
| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` |
| `x-upload` exists | `FileUploadField` (upload button + chip/Avatar) | Chip or Avatar |
| `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) |
| None of the above (default) | `StringField` (text input) | Typography |
### Path Conventions
#### Collection Path
Format: `/{x-resource}` (e.g., `/pets`)
| Operation | Required? | Purpose |
|-----------|-----------|-----------------------------------------|
| `GET` | Yes | List endpoint — populates the datatable |
| `POST` | Yes | Create endpoint |
#### Item Path
Format: `/{x-resource}/{id}` (e.g., `/pets/{id}`)
| Operation | Required? | Purpose |
|-----------|-----------|---------------|
| `GET` | Yes | Detail view |
| `PUT` | Yes | Edit form |
| `DELETE` | Yes | Delete action |
### Response Shapes
The library expects **strict** response shapes that match the spec. No flexible fallbacks.
#### Paginated List (GET with limit/offset params)
```json
{
"total": 42,
"items": [{ "id": 1, "name": "Fido" }, ...]
}
```
- `total` is optional; falls back to `items.length` if absent.
- `items` is **required** and must be an array.
- The library **throws** if the response is not `{ total, items }` with an array `items`.
#### Non-Paginated List (GET without limit/offset params)
```json
[{ "id": 1, "name": "Vaccination" }, ...]
```
- Response must be a **plain array**.
- The library **throws** if the response is not an array.
#### Single Item (GET /{id})
```json
{ "id": 1, "name": "Fido", ... }
```
- Returns the resource object directly.
- No special validation beyond standard API error handling.
#### Create / Update (POST / PUT)
```json
{ "id": 1, "name": "Fido", ... }
```
- Returns the created/updated resource object directly.
#### Delete
- Returns HTTP 204 with no body.
### Error Response Format
The library's `parseError()` handles two formats:
**FastAPI `ValidationError` (422):**
```json
[
{ "loc": ["body", "name"], "msg": "field required", "type": "value_error" }
]
```
→ Rendered as: `"field required"`
**Generic `ErrorBody`:**
```json
{ "detail": "Not found" }
```
→ Rendered as: `"Not found"`
## Pagination Behavior
### Detecting Pagination
A resource is considered paginated if its collection GET path has **both** a `limit` and `offset` query parameter. The `limit` parameter **must** have a `schema.default` value:
```yaml
parameters:
- in: query
name: limit
schema: { type: integer, default: 20 } # default is REQUIRED
- in: query
name: offset
schema: { type: integer, default: 0 }
```
If only one of `limit`/`offset` is present, pagination is **not** detected (the path is treated as non-paginated).
### Datatable Pagination
The datatable renders an MUI `TablePagination` component when `resource.pagination` is non-null. It sends `?limit={rowsPerPage}&offset={page * rowsPerPage}` with each list request.
### FK Options — Paginated Targets
When fetching FK options for a paginated target resource, the library sends `?limit=0`. This is a **server convention** meaning "return all records" — not documented in the spec. The server must interpret `limit=0` as "no limit."
## Spec Validation
The validator (`spec-validator.ts`) runs at mount time and categorizes issues as:
| Level | Meaning | Impact |
|-----------|------------------------------------------|------------------------------------------------------|
| `error` | Missing required extension | Admin panel shows error screen — **will not render** |
| `warning` | Missing optional configuration | Admin panel renders, warning shown in snackbar |
| `info` | Informational note (e.g., non-FK `$ref`) | Shown alongside warnings |
### Complete Validation Rules
For each schema with `x-resource`:
1. **`x-primary-key`** must be present.
2. **`x-display-format`** must be present.
3. **`x-list-columns`** must be an array, and every entry must reference a real property.
4. Every property must have **`x-label`**.
5. Every property must have **`x-order`**.
6. If a property uses `$ref` without `x-fk` → `info` message.
7. If a property uses `x-fk`, the referenced `resource` must exist as another schema's `x-resource`, and that target must have `x-display-format` and `x-primary-key`.
8. The `x-resource` path must exist in `paths`.
9. The collection path must have **GET** (list) and **POST** (create).
10. The item path `/{resource}/{id}` must exist with **GET**, **PUT**, and **DELETE**.
11. If the collection GET has pagination params (limit/offset), the `limit` param must have a `schema.default`.
## Auth
Authentication is handled via the `getToken` callback in `SpecConfiguration`:
```ts
const specConfig: SpecConfiguration = {
specUrl: "/api/openapi.yaml",
getToken: () => localStorage.getItem("token"), // called on every request
};
```
- The token is injected as `Authorization: Bearer {token}` on every API call.
- If a 401 response is received and `getToken` returns a token, the token is cleared from localStorage (for token refresh flows — customize in your wrapper).
## Design Decisions
- **Spec loaded at runtime** — not built into the bundle. A single built UI works with any backend serving the spec at a known URL.
- **Schema-to-path mapping is explicit** — the `x-resource` value maps directly to the path segment. No magic pluralization or guessing.
- **No fallbacks for required extensions** — if the spec is missing `x-label`, `x-order`, `x-primary-key`, `x-display-format`, or `x-list-columns`, the library **throws at transform time** rather than silently defaulting.
- **FK options use `?limit=0`** for paginated resources — server convention, not spec-documented.
- **Response parsing is strict** — `{ total, items }` for paginated, `[]` for non-paginated. No fallback chains.
## Example: Complete Minimal Spec
```yaml
openapi: 3.1.0
info:
title: Minimal API
version: 1.0.0
servers:
- url: https://api.example.com/v1
components:
schemas:
ErrorBody:
type: object
properties:
detail: { type: string }
required: [detail]
HTTPValidationError:
type: object
properties:
detail:
type: array
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
properties:
loc:
type: array
items: { type: string }
msg: { type: string }
type: { type: string }
required: [loc, msg, type]
Widget:
type: object
x-resource: widgets
x-primary-key: id
x-display-format: "{name}"
x-list-columns: [name, status]
properties:
id:
type: integer
readOnly: true
x-order: 0
x-hidden: { form: true, list: true }
x-label: "ID"
name:
type: string
x-order: 1
x-label: "Widget Name"
status:
type: string
enum: [active, inactive, archived]
x-order: 2
x-label: "Status"
required: [id, name]
responses:
Unauthorized:
description: Not authenticated
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
Forbidden:
description: Insufficient permissions
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
ValidationError:
description: Validation Error
content:
application/json:
schema: { $ref: '#/components/schemas/HTTPValidationError' }
InternalServerError:
description: Internal server error
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorBody' }
paths:
/widgets:
get:
summary: List widgets
responses:
'200':
description: List of widgets
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
post:
summary: Create a widget
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
responses:
'201':
description: Widget created
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
/widgets/{id}:
get:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'200':
description: Single widget
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
put:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
responses:
'200':
description: Widget updated
content:
application/json:
schema:
$ref: '#/components/schemas/Widget'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
delete:
parameters:
- name: id
in: path
required: true
schema: { type: integer }
responses:
'204':
description: Widget deleted
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/ValidationError' }
'500': { $ref: '#/components/responses/InternalServerError' }
```

View File

@@ -1,70 +0,0 @@
import axios, { AxiosInstance } from "axios";
import type { AxiosResponse } from "axios";
import { createApiClient } from "../../react-auth";
/**
* We expose a singleton-like getter/setter for the API clients
*/
let _api: AxiosInstance | null = null;
let _auth: AxiosInstance | null = null;
function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
instance.defaults.paramsSerializer = {
serialize: (params) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => {
searchParams.append(key, String(v)); // NO []
});
} else if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
},
};
return instance;
}
export const api = {
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => {
if (!_api) throw new Error("API client not initialized");
return _api.get<T, R>(url, config);
},
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.post<T, R>(url, data, config);
},
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.put<T, R>(url, data, config);
},
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => {
if (!_api) throw new Error("API client not initialized");
return _api.delete<T, R>(url, config);
},
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.patch<T, R>(url, data, config);
},
};
export const auth = {
post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_auth) throw new Error("Auth client not initialized");
return _auth.post(...args);
},
get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_auth) throw new Error("Auth client not initialized");
return _auth.get(...args);
},
};
export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
_api = withParamsSerializer(createApiClient(baseUrl));
_auth = withParamsSerializer(createApiClient(authBaseUrl));
}

View File

@@ -1,265 +0,0 @@
import * as React from 'react';
import {
Box,
Drawer,
List,
Divider,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
IconButton,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
import TableViewIcon from '@mui/icons-material/TableView';
import DashboardIcon from '@mui/icons-material/Dashboard';
import MenuIcon from '@mui/icons-material/Menu';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { ResourceConfig } from '../types/config';
import { useLocation, useNavigate } from 'react-router-dom';
const drawerWidth = 240;
const collapsedWidth = 64;
interface AdminLayoutProps {
children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void;
onLogout: () => void;
username?: string;
resources: ResourceConfig[];
}
export default function AdminLayout({
children,
onSelectResource,
resources,
}: AdminLayoutProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const location = useLocation();
const navigate = useNavigate();
const [isCollapsed, setIsCollapsed] = React.useState(false);
const [mobileOpen, setMobileOpen] = React.useState(false);
const activeResourceName = location.pathname.split('/admin')[1] || null;
// AUTO-TOGGLE LOGIC (unchanged)
React.useEffect(() => {
if (isMobile) {
setIsCollapsed(false);
setMobileOpen(false);
} else {
if (location.pathname === '/admin' || location.pathname === '') {
setIsCollapsed(false);
} else {
setIsCollapsed(true);
}
}
}, [location.pathname, isMobile]);
const currentWidth = isMobile
? drawerWidth
: isCollapsed
? collapsedWidth
: drawerWidth;
const handleDrawerToggle = () => {
setMobileOpen((prev) => !prev);
};
const handleSidebarToggle = () => {
setIsCollapsed((prev) => !prev);
};
const drawerContent = (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{!isMobile && (
<>
<Box
sx={{
display: 'flex',
justifyContent: isCollapsed ? 'center' : 'flex-end',
p: 1,
}}
>
<IconButton onClick={handleSidebarToggle}>
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</IconButton>
</Box>
<Divider />
</>
)}
{/* Mobile spacing (replaces Toolbar) */}
{isMobile && (
<Box sx={{ height: (theme) => theme.spacing(7) }} />
)}
<List>
<ListItem disablePadding>
<Tooltip
title={isCollapsed && !isMobile ? 'Dashboard' : ''}
placement="right"
>
<ListItemButton
selected={location.pathname === '/admin'}
onClick={() => navigate('/admin')}
sx={{
minHeight: 48,
justifyContent:
isCollapsed && !isMobile ? 'center' : 'initial',
px: 2.5,
}}
>
<ListItemIcon
sx={{
minWidth: 0,
mr: isCollapsed && !isMobile ? 0 : 3,
justifyContent: 'center',
}}
>
<DashboardIcon
color={
location.pathname === '/admin'
? 'primary'
: 'inherit'
}
/>
</ListItemIcon>
{(!isCollapsed || isMobile) && (
<ListItemText primary="Dashboard" />
)}
</ListItemButton>
</Tooltip>
</ListItem>
</List>
<Divider />
<List sx={{ flexGrow: 1 }}>
{resources.map((res) => (
<ListItem key={res.name} disablePadding>
<Tooltip
title={
isCollapsed && !isMobile ? res.pluralLabel : ''
}
placement="right"
>
<ListItemButton
selected={activeResourceName === res.name}
onClick={() => onSelectResource(res.name)}
sx={{
minHeight: 48,
justifyContent:
isCollapsed && !isMobile
? 'center'
: 'initial',
px: 2.5,
}}
>
<ListItemIcon
sx={{
minWidth: 0,
mr: isCollapsed && !isMobile ? 0 : 3,
justifyContent: 'center',
}}
>
<TableViewIcon
color={
activeResourceName === res.name
? 'primary'
: 'inherit'
}
/>
</ListItemIcon>
{(!isCollapsed || isMobile) && (
<ListItemText primary={res.pluralLabel} />
)}
</ListItemButton>
</Tooltip>
</ListItem>
))}
</List>
</Box>
);
return (
<Box sx={{ display: 'flex' }}>
{/* NAV */}
<Box
component="nav"
sx={{ width: { md: currentWidth }, flexShrink: { md: 0 } }}
>
{isMobile ? (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': {
width: drawerWidth,
},
}}
>
{drawerContent}
</Drawer>
) : (
<Drawer
variant="permanent"
open
sx={{
display: { xs: 'none', md: 'block' },
width: currentWidth,
flexShrink: 0,
whiteSpace: 'nowrap',
[`& .MuiDrawer-paper`]: {
width: currentWidth,
overflowX: 'hidden',
transition: theme.transitions.create('width'),
},
}}
>
{drawerContent}
</Drawer>
)}
</Box>
{/* MAIN */}
<Box
component="main"
sx={{
flexGrow: 1,
p: { xs: 2, md: 3 },
width: {
xs: '100%',
md: `calc(100% - ${currentWidth}px)`,
},
}}
>
{/* Control row (replaces AppBar) */}
{isMobile && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
mb: 2,
height: (theme) => theme.spacing(7),
}}
>
<IconButton onClick={handleDrawerToggle}>
<MenuIcon />
</IconButton>
</Box>
)}
{children}
</Box>
</Box>
);
}

View File

@@ -1,402 +0,0 @@
import * as React from 'react';
import { alpha } from '@mui/material/styles';
import {
Box,
Typography,
Button,
IconButton,
Tooltip,
Card,
CardContent,
CardActions,
Grid,
Menu,
MenuItem,
useMediaQuery,
useTheme,
Divider,
Chip,
Stack,
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRenderCellParams,
GridPaginationModel,
} from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config';
import { EnhancedTableComponents } from '../types/overrides';
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
interface EnhancedTableProps {
config: ResourceConfig;
data: any[];
total?: number;
paginationModel?: GridPaginationModel;
onPaginationModelChange?: (model: GridPaginationModel) => void;
loading?: boolean;
onEdit: (item: any) => void;
onDelete: (id: string) => void;
onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void;
components?: EnhancedTableComponents;
}
export default function EnhancedTable({
config,
data,
total,
paginationModel: externalPaginationModel,
onPaginationModelChange: externalOnPaginationModelChange,
loading = false,
onEdit,
onDelete,
onCreate,
onNavigateToResource,
components: tableComponents,
}: EnhancedTableProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate();
const isServer = config.filterOptions?.mode !== "client";
const [internalPaginationModel, setInternalPaginationModel] = React.useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const paginationModel = isServer ? externalPaginationModel : internalPaginationModel;
const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel;
const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
if (field.type === 'number') muiType = 'number';
if (field.type === 'boolean') muiType = 'boolean';
if (field.type === 'date') muiType = 'date';
if (field.type === 'datetime') muiType = 'dateTime';
if (field.type === 'enum') muiType = 'singleSelect';
const col: GridColDef = {
field: key,
headerName: field.label,
type: muiType,
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} />
};
if (muiType === 'date' || muiType === 'dateTime') {
col.valueGetter = (value: any) => {
if (!value) return null;
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
};
}
if (muiType === 'singleSelect') {
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
}
return col;
});
cols.push({
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 120,
getActions: (params) => [
<GridActionsCellItem
icon={<VisibilityIcon />}
label="View"
onClick={() => navigate(`/admin/${config.name}/${params.id}`)}
/>,
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
onClick={() => navigate(`/admin/${config.name}/edit/${params.id}`)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
onClick={() => onDelete(params.id as string)}
/>,
],
});
return cols;
}, [config, onDelete, navigate, onNavigateToResource]);
const mobilePageSize = 10;
const [mobilePage, setMobilePage] = React.useState(0);
const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1;
const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize);
React.useEffect(() => {
if (mobilePage >= mobileTotalPages) setMobilePage(0);
}, [data.length, mobilePage, mobileTotalPages]);
if (isMobile) {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate} size="small">
Add
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mobileData.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow
row={row}
config={config}
onEdit={onEdit}
onDelete={onDelete}
onNavigate={onNavigateToResource}
navigate={navigate}
components={tableComponents}
/>
</Box>
))}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<Button size="small" disabled={mobilePage === 0} onClick={() => setMobilePage(mobilePage - 1)}>
Previous
</Button>
<Typography variant="body2" sx={{ alignSelf: 'center', px: 1 }}>
Page {mobilePage + 1} of {mobileTotalPages}
</Typography>
<Button size="small" disabled={mobilePage >= mobileTotalPages - 1} onClick={() => setMobilePage(mobilePage + 1)}>
Next
</Button>
</Box>
</Box>
);
}
return (
<Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label}
</Button>
</Box>
<DataGrid
rows={data || []}
columns={columns}
autoHeight
paginationMode={isServer ? 'server' : 'client'}
{...(isServer ? {
rowCount: (() => {
if (total !== undefined) return total;
const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10;
if (data.length < pageSize) {
return page * pageSize + data.length;
}
return (page + 2) * pageSize;
})(),
} : {})}
loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
onPaginationModelChange={onPaginationModelChange}
getRowId={(row) => {
const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
for (const key of fallbackKeys) {
if (row[key] !== undefined && row[key] !== null) return row[key];
}
return `temp-id-${data.indexOf(row)}`;
}}
disableRowSelectionOnClick
pageSizeOptions={[10, 25, 50]}
sx={{
border: 'none',
'& .MuiDataGrid-cell:focus': { outline: 'none' },
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
}}
/>
</Box>
);
}
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const id = row[config.primaryKey];
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Card variant="outlined" sx={{ borderRadius: 2 }}>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
#{id}
</Typography>
<IconButton size="small" onClick={handleClick}>
<MoreVertIcon fontSize="small" />
</IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/${id}`); }}>View</MenuItem>
<MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/edit/${id}`); }}>Edit</MenuItem>
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
</Menu>
</Box>
<Divider sx={{ mb: 2 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
<Box key={key}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label}
</Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} />
</Typography>
</Box>
))}
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
<Button size="small" onClick={() => navigate(`/admin/${config.name}/${id}`)}>View Details</Button>
</CardActions>
</Card>
);
}
function getFormattedDisplayValue(item: any, displayFormat: string) {
if (!item) return "";
return resolveTemplate(displayFormat, item);
}
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) {
const value = params.value;
const isPk = fieldKey === config.primaryKey;
if (field.formatter) return field.formatter(value);
const customRenderer = components?.cellRenderers?.[field.type as string];
if (customRenderer) {
return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile });
}
// 1. Single Relation
if (field.relation && value && !Array.isArray(value)) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = getFormattedDisplayValue(value, field.displayFormat);
return (
<Chip
label={displayValue}
size="small"
variant="outlined"
color="primary"
onClick={(e) => {
e.stopPropagation();
if (relationId) onNavigate?.(field.relation!, String(relationId));
}}
sx={{ cursor: 'pointer' }}
/>
);
}
// 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) {
const enumValue = field.enumOption?.value;
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', ');
return (
<Tooltip title={tooltipTitle} arrow placement="top">
<Stack direction="row" spacing={0.5} sx={{ overflow: 'hidden', flexWrap: 'nowrap' }}>
{value.map((item, idx) => (
<Chip
key={idx}
label={getFormattedDisplayValue(item, field.displayFormat)}
size="small"
variant="filled"
sx={{ maxWidth: 120 }}
onClick={(e) => {
e.stopPropagation();
if (field.relation) {
const id = typeof item === 'object' ? (item.id || item._id) : item;
if (id) onNavigate?.(field.relation!, String(id));
}
}}
/>
))}
</Stack>
</Tooltip>
);
}
// 3. Simple Objects
if (field.type === 'object' && value) {
return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value));
}
if (field.type === 'number' && typeof value === 'number') {
const isNegative = value < 0;
const color = isNegative ? 'error' : 'success';
return (
<Chip
label={value.toLocaleString()}
size="small"
color={color}
variant="filled"
sx={{
fontWeight: 'bold',
minWidth: 60,
// Soft background with bold text for a premium feel
bgcolor: (theme) => alpha(theme.palette[color].main, 0.15),
color: (theme) => theme.palette[color].dark,
'& .MuiChip-label': { px: 1.5 }
}}
/>
);
}
if (field.type === 'boolean') {
return value ? (
<Chip label="Yes" size="small" color="success" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
) : (
<Chip label="No" size="small" color="default" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
);
}
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (field.type === 'enum') {
const opt = getFieldOptions(field).find(o => o.key === value);
return opt?.value ?? value;
}
if (isPk && !isMobile) {
return (
<Chip
label={value}
size="small"
color="primary"
onClick={(e) => { e.stopPropagation(); navigate(`/admin/${config.name}/${params.row[config.primaryKey]}`); }}
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
/>
);
}
return value;
}

View File

@@ -1,308 +0,0 @@
import * as React from "react";
import {
Box,
Button,
Chip,
Paper,
TextField,
Autocomplete,
Typography,
} from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config";
import { FilterBarComponents, FieldComponents } from "../types/overrides";
import { getFieldOptions, resolveTemplate } from "../utils/options";
export function FilterAutocomplete({
options,
value,
label,
onChange,
}: {
options: string[];
value: string[];
label: string;
onChange: (val: string[]) => void;
}) {
const listboxRef = React.useRef<HTMLUListElement>(null);
const scrollPosRef = React.useRef(0);
const [open, setOpen] = React.useState(false);
const [frozenValue, setFrozenValue] = React.useState<string[]>(value);
const toggleDropdown = () => {
setOpen(prev => {
const next = !prev;
setFrozenValue(value);
return next;
});
};
const sortedOptions = React.useMemo(() => {
const sel = new Set(frozenValue);
const picked: string[] = [];
const rest: string[] = [];
for (const o of options) {
if (sel.has(o)) picked.push(o);
else rest.push(o);
}
return [...picked, ...rest];
}, [options, frozenValue]);
return (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
open={open}
onOpen={toggleDropdown}
onClose={toggleDropdown}
options={sortedOptions}
value={value}
getOptionKey={(option) => option}
onChange={(_, val) => onChange(val.length > 0 ? val : [])}
ListboxProps={{
ref: listboxRef,
onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; },
}}
renderOption={(props, option, { selected }) => {
const { key, ...rest } = props;
return (
<li key={key} {...rest}>
{selected ? <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"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>;
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>
)}
</>
);
}}
renderInput={(params) => <TextField {...params} placeholder={`Add ${label}...`} />}
sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }}
/>
);
}
function extractOptions(
fieldName: string,
field: ResourceField,
data: any[]
): string[] {
const values = new Set<string>();
if (field.type === 'enum') {
return getFieldOptions(field).map(o => o.value);
}
if (!data) return [];
const pull = (item: any): string | null => {
if (item == null) return null;
if (typeof item === "string") return item;
if (typeof item !== "object") return String(item);
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
// Use displayFormat if defined
if (field.displayFormat) {
return resolveTemplate(field.displayFormat, item);
}
return null;
};
for (const row of data) {
const v = row[fieldName];
if (v == null) continue;
if (Array.isArray(v)) {
for (const el of v) {
const label = pull(el);
if (label) values.add(label);
}
} else {
const label = pull(v);
if (label) values.add(label);
}
}
// console.log('extracted', fieldName, Array.from(values).sort())
return Array.from(values).sort();
}
function renderFilterInput(
fieldName: string,
field: ResourceField,
options: string[],
value: any,
onChange: (key: string, val: any) => void,
components?: FilterBarComponents,
fieldComponents?: FieldComponents,
) {
const filterType = field.filterType;
if (filterType === "number-range") {
const RangeComponent = fieldComponents?.numberRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { min?: string; max?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
if (filterType === "date-range") {
const RangeComponent = fieldComponents?.dateRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { start?: string; end?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
const selected = Array.isArray(value) ? value : [];
return (
<FilterAutocomplete
options={options}
value={selected}
label={field.label}
onChange={(val) => onChange("value", val.length > 0 ? val : undefined)}
/>
);
}
export interface FilterBarProps {
fields: Record<string, ResourceField>;
filterableFields: string[];
mode: ResourceMode;
data?: any[];
appliedValues: Record<string, any>;
onApply: (values: Record<string, any>) => void;
onClear: () => void;
components?: FilterBarComponents;
fieldComponents?: FieldComponents;
}
export default function FilterBar({
fields,
filterableFields,
data,
appliedValues,
onApply,
onClear,
components: filterComponents,
fieldComponents,
}: FilterBarProps) {
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
React.useEffect(() => {
if (!open) setDraft({ ...appliedValues });
}, [appliedValues, open]);
if (!filterableFields || filterableFields.length === 0) return null;
const activeCount = Object.keys(appliedValues).filter((k) => {
const v = appliedValues[k];
if (v == null || v === "") return false;
if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false;
return true;
}).length;
const handleApply = () => onApply({ ...draft });
const handleClear = () => {
setDraft({});
onClear();
};
const updateDraft = (fieldName: string, key: string, val: any) => {
setDraft((prev) => {
if (key === "value") {
return { ...prev, [fieldName]: val };
}
const existing = prev[fieldName] || {};
return { ...prev, [fieldName]: { ...existing, [key]: val } };
});
};
return (
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
onClick={() => setOpen((o) => !o)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FilterListIcon fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight={600}>
{open ? "Hide Filters" : "Show Filters"}
</Typography>
</Box>
{activeCount > 0 && (
<Typography variant="caption" color="primary" fontWeight={600}>
{activeCount} active
</Typography>
)}
</Box>
{open && (
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
{filterableFields.map((fieldName) => {
const field = fields[fieldName];
if (!field) return null;
const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect";
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
const raw = draft[fieldName];
return (
<Box key={fieldName} sx={{ display: "flex", flexDirection: "column", flex: { xs: '0 0 100%', sm: 1 }, minWidth: { sm: 200 } }}>
<Box sx={{ typography: "caption", mb: 0.5, color: "text.secondary" }}>
{field.label}
</Box>
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val), filterComponents, fieldComponents
)}
</Box>
);
})}
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<Button variant="contained" onClick={handleApply}>
Apply
</Button>
<Button variant="outlined" onClick={handleClear}>
Clear
</Button>
</Box>
</Box>
)}
</Paper>
);
}

View File

@@ -1,143 +0,0 @@
import * as React from 'react';
import {
Box,
Button,
Typography,
Divider,
CircularProgress,
} from '@mui/material';
import { ResourceConfig } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useUpload } from '../providers/UploadProvider';
import { useQueries } from '@tanstack/react-query';
import { useResource } from '../hooks/useResource';
import FormField from './fields/FormField';
import { ConfigContext } from '../providers/ConfigContext';
interface GenericFormProps {
config: ResourceConfig;
initialData?: any;
onSave: (data: any) => Promise<void>;
onCancel: () => void;
loading?: boolean;
readOnly?: boolean;
onEditClick?: () => void;
fieldComponents: FieldComponents;
}
export default function GenericForm({
config,
initialData = {},
onSave,
onCancel,
loading: saving,
readOnly = false,
onEditClick,
fieldComponents,
}: GenericFormProps) {
initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData);
const { uploadFile, uploading } = useUpload();
const appConfig = React.useContext(ConfigContext);
// 1. Identify all unique relations in the schema (including nested ones)
const getRelationFields = (fields: Record<string, any>): string[] => {
let relations: string[] = [];
Object.values(fields).forEach(field => {
if (field.relation) relations.push(field.relation);
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
});
return Array.from(new Set(relations));
};
const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]);
// 2. Parallel fetch for all related resource lists
const queries = useQueries({
queries: allRelations.map(relName => {
const relatedRes = appConfig?.resources.find(r => r.name === relName);
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents });
return {
...getListQueryOptions(),
enabled: !!relatedRes,
};
}),
});
const isLoadingRelations = queries.some(q => q.isLoading);
const relationDataMap = React.useMemo(() => {
const map: Record<string, any[]> = {};
allRelations.forEach((relName, index) => {
// @ts-ignore
map[relName] = queries[index].data || [];
});
return map;
}, [allRelations, queries]);
const handleChange = (key: string, value: any) => {
if (readOnly) return;
setFormData((prev: any) => ({ ...prev, [key]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (readOnly) return;
onSave(formData);
};
const getTitle = () => {
if (readOnly) return `View ${config.label}`;
return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`;
};
if (isLoadingRelations) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 8, gap: 2 }}>
<CircularProgress />
<Typography variant="body2" color="text.secondary">Loading relationships...</Typography>
</Box>
);
}
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="h5">
{getTitle()}
</Typography>
<Divider />
{Object.entries(config.fields).map(([key, field]) => (
<FormField
key={key}
name={key}
field={field}
value={formData[key]}
onChange={(val: any) => handleChange(key, val)}
disabled={readOnly || field.readOnly}
uploadFile={uploadFile}
uploading={uploading}
baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap}
components={fieldComponents}
/>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
<Button variant="outlined" onClick={onCancel} disabled={saving}>
{readOnly ? 'Back to List' : 'Cancel'}
</Button>
{readOnly ? (
<Button variant="contained" color="primary" onClick={onEditClick}>
Edit {config.label}
</Button>
) : (
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
Save {config.label}
</Button>
)}
</Box>
</Box>
);
}

View File

@@ -1,83 +0,0 @@
import * as React from 'react';
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm';
import { ConfigContext } from '../providers/ConfigContext';
import { defaultFieldComponents } from './fields/DefaultFieldComponents';
export default function ProfileView() {
const appConfig = React.useContext(ConfigContext);
const profileConfig = appConfig?.profile;
const resourceConfig = appConfig?.resources.find(r => r.name === profileConfig?.resource);
if (!profileConfig || !resourceConfig) {
return <Alert severity="error">Profile configuration not found.</Alert>;
}
const editableConfig = React.useMemo(() => {
const newFields = { ...resourceConfig.fields };
const extraFields = profileConfig.extraFields || [];
Object.keys(newFields).forEach(key => {
newFields[key] = {
...newFields[key],
readOnly: !extraFields.includes(key),
};
});
return {
...resourceConfig,
fields: newFields,
};
}, [resourceConfig, profileConfig.extraFields]);
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents });
const { data: profile, isLoading, error } = useMe();
const updateMutation = useUpdateMe();
const handleSave = async (formData: any) => {
try {
const extraFields = profileConfig.extraFields || [];
const dataToSave = Object.keys(formData)
.filter(key => extraFields.includes(key))
.reduce((obj: any, key) => {
obj[key] = formData[key];
return obj;
}, {});
await updateMutation.mutateAsync(dataToSave);
} catch (err) {
console.error('Profile update failed:', err);
}
};
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
if (error) {
return <Alert severity="error">Failed to load profile data.</Alert>;
}
return (
<Box sx={{ maxWidth: 800, mx: 'auto', mt: 4 }}>
<Typography variant="h4" gutterBottom>
My Profile
</Typography>
<Paper sx={{ p: 4, mt: 2 }}>
<GenericForm
config={editableConfig}
initialData={profile}
onSave={handleSave}
onCancel={() => window.history.back()}
loading={updateMutation.isPending}
fieldComponents={defaultFieldComponents}
/>
</Paper>
</Box>
);
}

View File

@@ -1,213 +0,0 @@
import * as React from 'react';
import { Box, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config';
import type { ResourceField } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useResource } from '../hooks/useResource';
import { resolveTemplate } from '../utils/options';
import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps {
config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void;
fieldComponents: FieldComponents;
}
import { GridPaginationModel } from '@mui/x-data-grid';
function getDisplayString(item: any, field: ResourceField): string {
if (item == null || typeof item !== 'object') return String(item ?? '');
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
if (field.displayFormat) return resolveTemplate(field.displayFormat, item);
throw new Error('cannot get display string')
}
function applyClientFilters(
data: any[],
filters: Record<string, any>,
fields: Record<string, ResourceField>
): any[] {
const entries = Object.entries(filters).filter(([_, v]) => {
if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) return false;
if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false;
return true;
});
if (entries.length === 0) return data;
return data.filter((item) =>
entries.every(([fieldName, filterValue]) => {
const field = fields[fieldName];
if (!field) return true;
const itemValue = item[fieldName];
if (typeof filterValue === "object" && !Array.isArray(filterValue)) {
if (field.type === "number") {
if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false;
if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false;
return true;
}
if (field.type === "datetime" || field.type === "date") {
const itemTime = new Date(itemValue).getTime();
if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false;
if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false;
return true;
}
return true;
}
if (Array.isArray(filterValue)) {
if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) =>
filterValue.includes(getDisplayString(el, field))
);
}
if (itemValue && typeof itemValue === "object") {
return filterValue.includes(getDisplayString(itemValue, field));
}
return filterValue.includes(String(itemValue));
}
if (!filterValue) return true;
if (field.type === "boolean") {
return String(itemValue) === filterValue;
}
if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) =>
getDisplayString(el, field) === String(filterValue)
);
}
if (itemValue && typeof itemValue === "object") {
return getDisplayString(itemValue, field) === String(filterValue);
}
return String(itemValue) === String(filterValue);
})
);
}
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
const { id } = useParams();
const location = useLocation();
const navigate = useNavigate();
const isCreate = location.pathname.endsWith('/create');
const isEdit = location.pathname.includes('/edit/');
const isView = !!id && !isEdit;
const isList = !id && !isCreate;
const isServer = config.filterOptions?.mode !== "client";
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10000 };
return {
skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize,
};
}, [isServer, paginationModel]);
const listQuery = useList(queryParams);
const itemQuery = useRead(id || "");
const rawData = listQuery.data?.data || [];
const totalCount = listQuery.data?.total;
const filteredData = React.useMemo(
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
[isServer, rawData, appliedFilters, config.fields]
);
const createMutation = useCreate();
const updateMutation = useUpdate();
const deleteMutation = useDelete();
const handleEdit = (item: any) => {
navigate(`/admin/${config.name}/edit/${item[config.primaryKey]}`);
};
const handleCreate = () => {
navigate(`/admin/${config.name}/create`);
};
const handleSave = async (formData: any) => {
try {
if (isEdit) {
await updateMutation.mutateAsync({ id: id!, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
navigate(`/admin/${config.name}`);
} catch (err) {
console.error('Save failed:', err);
}
};
const handleDelete = async (itemId: string) => {
if (window.confirm('Are you sure you want to delete this item?')) {
await deleteMutation.mutateAsync(itemId);
}
};
if (isList && listQuery.isLoading) return <CircularProgress />;
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
return (
<Box>
{isList ? (
<Box>
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
<FilterBar
fields={config.fields}
filterableFields={config.filterOptions.fields}
mode={config.filterOptions?.mode || "server"}
data={rawData}
appliedValues={appliedFilters}
onApply={setAppliedFilters}
onClear={() => setAppliedFilters({})}
fieldComponents={components}
/>
)}
<EnhancedTable
config={config}
data={filteredData}
total={isServer ? totalCount : filteredData.length}
paginationModel={isServer ? paginationModel : undefined}
onPaginationModelChange={isServer ? setPaginationModel : undefined}
loading={listQuery.isFetching}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/>
</Box>
) : (
<Paper sx={{ p: 4 }}>
{components && <components.GenericForm
config={config}
initialData={isCreate ? null : itemQuery.data}
onSave={handleSave}
onCancel={() => navigate(`/admin/${config.name}`)}
loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/>}
</Paper>
)}
</Box>
);
}

View File

@@ -1,17 +0,0 @@
import { FormControlLabel, Checkbox } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={field.label}
/>
);
}

View File

@@ -1,18 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) {
const isDatetime = field.type === 'datetime';
return (
<MuiTextField
fullWidth
label={field.label}
type={isDatetime ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,30 +0,0 @@
import { Box, TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function DateRangeField({ value, onChange, disabled }: FieldComponentProps) {
const rangeVal = (value as { start?: string; end?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="date"
placeholder="From"
size="small"
value={rangeVal.start ?? ""}
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
<MuiTextField
type="date"
placeholder="To"
size="small"
value={rangeVal.end ?? ""}
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -1,40 +0,0 @@
import * as React from 'react';
import { FieldComponents, FieldComponentProps } from '../../types/overrides';
import TextFieldEntry from './TextField';
import NumberField from './NumberField';
import BooleanField from './BooleanField';
import DateField from './DateField';
import EnumField from './EnumField';
import RelationField from './RelationField';
import ImageUploadField from './ImageUploadField';
import FallbackField from './FallbackField';
import DateRangeField from './DateRangeField';
import NumberRangeField from './NumberRangeField';
const WrappedImageUploadField = (props: FieldComponentProps) =>
React.createElement(ImageUploadField, {
label: props.field.label,
value: props.value || '',
onUpload: async (file: File) => {
const url = await props.uploadFile?.(file);
if (url) props.onChange(url);
},
uploading: props.uploading,
baseUrl: props.baseUrl || '',
disabled: props.disabled,
});
export const defaultFieldComponents: FieldComponents = {
string: TextFieldEntry,
markdown: TextFieldEntry,
number: NumberField,
boolean: BooleanField,
date: DateField,
datetime: DateField,
enum: EnumField,
image: WrappedImageUploadField,
relation: RelationField,
default: FallbackField,
dateRange: DateRangeField,
numberRange: NumberRangeField,
};

View File

@@ -1,24 +0,0 @@
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { getFieldOptions } from '../../utils/options';
import { FieldComponentProps } from '../../types/overrides';
export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) {
const options = getFieldOptions(field);
return (
<FormControl fullWidth>
<InputLabel>{field.label}</InputLabel>
<Select
value={value || ''}
label={field.label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,13 +0,0 @@
import { TextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function FallbackField({ field, value }: FieldComponentProps) {
return (
<TextField
fullWidth
label={field.label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
}

View File

@@ -1,85 +0,0 @@
import * as React from 'react';
import { ResourceField } from '../../types/config';
import { FieldComponentProps, FieldComponents } from '../../types/overrides';
import ObjectField from './ObjectField';
export interface FormFieldProps {
name: string;
field: ResourceField;
value: any;
onChange: (val: any) => void;
disabled?: boolean;
uploadFile?: (file: File) => Promise<string | null>;
uploading?: boolean;
baseUrl?: string;
relationDataMap?: Record<string, any[]>;
components: FieldComponents;
}
export default function FormField({
name,
field,
value,
onChange,
disabled,
uploadFile,
uploading,
baseUrl,
relationDataMap = {},
components,
}: FormFieldProps) {
const fieldProps: FieldComponentProps = {
name,
field,
value,
onChange,
disabled,
baseUrl,
relationDataMap,
uploadFile,
uploading,
};
const childComponents = components;
// 1. Object (recursive) - requires parent FormField for recursion
if (field.type === 'object' && field.schema && !field.relation) {
const renderChild = (childProps: FieldComponentProps) => (
<FormField
name={childProps.name}
field={childProps.field}
value={childProps.value}
onChange={childProps.onChange}
disabled={childProps.disabled}
uploadFile={childProps.uploadFile}
uploading={childProps.uploading}
baseUrl={childProps.baseUrl}
relationDataMap={childProps.relationDataMap}
components={components}
/>
);
return <ObjectField {...fieldProps} renderField={renderChild} />;
}
// 2. Image
if (field.type === 'image') {
const ImageField = components.image;
if (!ImageField) return null;
return <ImageField {...fieldProps} />;
}
// 3. Relation
if (field.relation && relationDataMap[field.relation]) {
const RelationFieldComp = components.relation;
if (!RelationFieldComp) return null;
return <RelationFieldComp {...fieldProps} />;
}
// 4. Lookup by field type
const Component = components[field.type] || components.default;
if (Component) {
return <Component {...fieldProps} />;
}
return null;
}

View File

@@ -1,60 +0,0 @@
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
interface ImageUploadFieldProps {
label?: string;
value: string;
uploading?: boolean;
onUpload: (file: File) => void;
size?: number;
baseUrl: string;
disabled?: boolean;
}
export default function ImageUploadField({
label = "Upload Image",
value,
uploading = false,
onUpload,
size = 64,
baseUrl,
disabled = false,
}: ImageUploadFieldProps) {
const imgSrc = value
? baseUrl.replace(/\/+$/, "") +
"/" +
value.replace(/^\/+/, "")
: "";
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Avatar
src={imgSrc}
sx={{ width: size, height: size, borderRadius: 2 }}
/>
{!disabled && (
<Button
variant="outlined"
component="label"
disabled={uploading}
startIcon={uploading && <CircularProgress size={16} />}
>
{uploading ? "Uploading..." : "Choose File"}
<input
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
}}
/>
</Button>
)}
</Box>
</Box>
);
}

View File

@@ -1,16 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) {
return (
<MuiTextField
fullWidth
label={field.label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,28 +0,0 @@
import { Box, TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function NumberRangeField({ value, onChange, disabled }: FieldComponentProps) {
const rangeVal = (value as { min?: string; max?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="number"
placeholder="Min"
size="small"
value={rangeVal.min ?? ""}
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
<MuiTextField
type="number"
placeholder="Max"
size="small"
value={rangeVal.max ?? ""}
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -1,36 +0,0 @@
import { Box, Typography } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export interface ObjectFieldProps extends FieldComponentProps {
renderField: (props: FieldComponentProps) => React.ReactNode;
}
export default function ObjectField({ name, field, value, onChange, disabled, baseUrl, uploadFile, uploading, relationDataMap, renderField }: ObjectFieldProps) {
if (!field.schema) return null;
return (
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
{field.label}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(field.schema).map(([subKey, subField]) =>
renderField({
name: `${name}.${subKey}`,
field: subField,
value: value?.[subKey],
onChange: (newVal: any) => {
const updated = { ...(value || {}), [subKey]: newVal };
onChange(updated);
},
disabled,
baseUrl,
uploadFile,
uploading,
relationDataMap,
})
)}
</Box>
</Box>
);
}

View File

@@ -1,50 +0,0 @@
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { getFieldOptions } from '../../utils/options';
import { FieldComponentProps } from '../../types/overrides';
export default function RelationField({ field, value, onChange, disabled, relationDataMap = {} }: FieldComponentProps) {
if (!field.relation || !relationDataMap[field.relation]) {
return null;
}
const relationData = relationDataMap[field.relation];
const isArrayRelation = field.type === 'array';
const options = getFieldOptions(field, relationData);
const keyField = field.enumOption?.key ?? 'id';
const normalizedValue = (() => {
if (isArrayRelation && Array.isArray(value)) {
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v)));
}
if (value != null && typeof value === 'object') {
return String(value[keyField] ?? '');
}
return value ?? (isArrayRelation ? [] : "");
})();
return (
<FormControl fullWidth>
<InputLabel shrink>{field.label}</InputLabel>
<Select
multiple={isArrayRelation}
value={normalizedValue}
label={field.label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
}
return options.find(o => o.key === selected)?.value ?? selected;
}}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,18 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) {
const isMarkdown = field.type === 'markdown';
return (
<MuiTextField
fullWidth
label={field.label}
value={value || ''}
multiline={isMarkdown}
rows={isMarkdown ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,14 +0,0 @@
export { default as FormField } from './FormField';
export { default as ImageUploadField } from './ImageUploadField';
export { default as TextField } from './TextField';
export { default as NumberField } from './NumberField';
export { default as BooleanField } from './BooleanField';
export { default as DateField } from './DateField';
export { default as EnumField } from './EnumField';
export { default as RelationField } from './RelationField';
export { default as ObjectField } from './ObjectField';
export { default as FallbackField } from './FallbackField';
export { default as DateRangeField } from './DateRangeField';
export { default as NumberRangeField } from './NumberRangeField';
export { defaultFieldComponents } from './DefaultFieldComponents';
export type { ObjectFieldProps } from './ObjectField';

View File

@@ -1,21 +0,0 @@
import { AppConfig } from "./types/config";
import { loadConfigFromOpenApi } from "./utils/openapi_loader";
export async function getAppConfig(
resourceOverrides: Record<string, any> = {},
profileConfig: any = {}
): Promise<AppConfig> {
// @ts-ignore
const baseUrl = import.meta.env.VITE_API_BASE_URL
// @ts-ignore
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL
const config = await loadConfigFromOpenApi(baseUrl, resourceOverrides, profileConfig);
// You can still apply overrides here
return {
...config,
authBaseUrl: authBaseUrl,
baseUrl: baseUrl,
};
}

View File

@@ -1,184 +0,0 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import * as React from "react";
import { api } from "../api/client";
import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext";
import { FieldComponents, FieldComponentProps } from "../types/overrides";
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
import FormField from "../components/fields/FormField";
import GenericForm from "../components/GenericForm";
function wrapFormField(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof FormField>, 'components'>) =>
React.createElement(FormField, { ...props, components: merged });
}
function wrapGenericForm(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
React.createElement(GenericForm, { ...props, fieldComponents: merged });
}
export function useResource<T = any>(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) {
const queryClient = useQueryClient();
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
const mergedComponents = React.useMemo(
() => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
[options?.fieldComponents],
);
// --- READ ALL ---
const useList = (params?: any) =>
useQuery({
queryKey: [name, "list", params],
queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return {
data: res.data,
total: isNaN(total as any) ? undefined : total
};
},
enabled: !!endpoint,
placeholderData: keepPreviousData,
});
// --- READ ONE ---
const useRead = (id: string, params?: any | null) =>
useQuery({
queryKey: [name, "detail", id, params],
queryFn: async () => {
if (!id || !endpoint) return null;
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined);
return res.data;
},
enabled: !!id && !!endpoint,
});
// --- CREATE ---
const useCreate = () =>
useMutation({
mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.post<T>(endpoint, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [name, "list"] });
},
});
// --- UPDATE ---
const useUpdate = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem: any) => {
const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
},
});
// --- PATCH ---
const usePatch = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.patch<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem: any) => {
const listId = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });
},
});
// --- DELETE ---
const useDelete = () =>
useMutation({
mutationFn: async (id: string) => {
if (!endpoint) throw new Error("Endpoint not defined");
await api.delete(`${endpoint}/${id}`);
return id;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [name, "list"] });
},
});
// --- HELPERS FOR useQueries ---
const getListQueryOptions = (params?: any) => ({
queryKey: [name, "list", params],
queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return {
data: res.data,
total: isNaN(total as any) ? undefined : total
};
},
enabled: !!endpoint,
});
// --- READ ME ---
const useMe = () =>
useQuery({
queryKey: [name, "me"],
queryFn: async () => {
if (!endpoint) return null;
const res = await api.get<T>(`${endpoint}/me`);
return res.data;
},
enabled: !!endpoint,
});
// --- UPDATE ME ---
const useUpdateMe = () =>
useMutation({
mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.put<T>(`${endpoint}/me`, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [name, "me"] });
queryClient.invalidateQueries({ queryKey: [name, "list"] });
},
});
const components = React.useMemo(() => {
if (!mergedComponents) return undefined;
return {
...mergedComponents,
FormField: wrapFormField(mergedComponents),
GenericForm: wrapGenericForm(mergedComponents),
};
}, [mergedComponents]);
return {
useList,
useRead,
useMe,
useCreate,
useUpdate,
usePatch,
useUpdateMe,
useDelete,
getListQueryOptions,
components,
};
}
export function useResourceByName<T = any>(name: string, options?: { fieldComponents: FieldComponents }) {
const config = React.useContext(ConfigContext);
const resourceConfig = config?.resources.find((r) => r.name === name);
return useResource<T>(resourceConfig, options);
}

View File

@@ -1,13 +1,15 @@
export { default as Admin } from "./Admin"; export { AppProvider } from "./src/context/AppProvider";
export { api, auth, initializeApiClients } from "./api/client"; export { Admin, ProfileRoutes } from "./src/components/Admin";
export { getAppConfig } from "./config"; export type { AdminProps, ProfileRoute } from "./src/components/Admin";
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; export { useAppContext } from "./src/context/AppContext";
export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides"; export { useResource } from "./src/context/useResource";
export { AppProvider } from "./providers/AppProvider"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { ConfigContext, useConfig } from "./providers/ConfigContext"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
export { useResource, useResourceByName } from "./hooks/useResource"; export { SseStreamView } from "./src/components/SseStreamView";
export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar"; export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { default as EnhancedTable } from "./components/EnhancedTable"; export { getApi } from "./src/hooks/useApi";
export { default as GenericForm } from "./components/GenericForm"; export { useItemSse } from "./src/hooks/useItemSse";
export { default as ResourceView } from "./components/ResourceView"; export { sanitizePayload } from "./src/utils/sanitize-payload";
export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields"; export type { FkResolver } from "./src/utils/sanitize-payload";
export type { FilterComponentProps } from "./src/context/useResource";
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,70 +0,0 @@
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfigContext } from "./ConfigContext";
import { getAppConfig } from "../config";
import { initializeApiClients } from "../api/client";
import { AppConfig } from "../types/config";
import { Box, CircularProgress } from "@mui/material";
const defaultQueryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
interface AppProviderProps {
children: React.ReactNode;
resourceOverrides?: Record<string, any>;
profileConfig?: any;
queryClient?: QueryClient;
}
export function AppProvider({
children,
resourceOverrides = {},
profileConfig = {},
queryClient = defaultQueryClient,
}: AppProviderProps) {
const [config, setConfig] = React.useState<AppConfig | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
getAppConfig(resourceOverrides, profileConfig)
.then((cfg) => {
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
setConfig(cfg);
setLoading(false);
})
.catch((err) => {
console.error("Failed to load OpenAPI configuration:", err);
setLoading(false);
});
}, [resourceOverrides, profileConfig]);
if (loading) {
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress />
</Box>
);
}
return (
<QueryClientProvider client={queryClient}>
<ConfigContext.Provider value={config}>
{children}
</ConfigContext.Provider>
</QueryClientProvider>
);
}

View File

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

View File

@@ -1,52 +0,0 @@
import React, { createContext, useContext, useState } from "react";
import { api } from "../api/client";
export interface UploadContextModel {
uploadFile: (file: File) => Promise<string | null>;
uploading: boolean;
error: string | null;
}
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = async (file: File): Promise<string | null> => {
setUploading(true);
setError(null);
try {
const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer);
const res = await api.post("/uploads", binary, {
headers: {
"Content-Type": file.type,
"Content-Disposition": `attachment; filename="${file.name}"`,
},
});
return res.data.url as string;
} catch (err: any) {
console.error("File upload failed:", err);
setError(err.response?.data?.detail || "Failed to upload file");
return null;
} finally {
setUploading(false);
}
};
return (
<UploadContext.Provider value={{ uploadFile, uploading, error }}>
{children}
</UploadContext.Provider>
);
};
export const useUpload = (): UploadContextModel => {
const ctx = useContext(UploadContext);
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
return ctx;
};

View File

@@ -0,0 +1,235 @@
import React, { useEffect, useState } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { Box, CircularProgress, Alert } from "@mui/material";
import { useAppContext } from "../context/AppContext";
import { Layout } from "./Layout";
import { ResourceList } from "./ResourceList";
import { ResourceForm } from "./ResourceForm";
import { ResourceDetail } from "./ResourceDetail";
import { ValidationAlert } from "./ValidationAlert";
import { getApi } from "../hooks/useApi";
export interface AdminProps {
basePath: string;
}
export function Admin({ basePath }: AdminProps) {
const { resources, loading, errors, warnings } = useAppContext();
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
</Box>
);
}
if (errors.length > 0) {
return <ValidationAlert errors={errors} warnings={warnings} />;
}
if (resources.length === 0) {
return (
<Box sx={{ p: 4, textAlign: "center" }}>
No resources found in the OpenAPI spec.
</Box>
);
}
const topLevel = resources.filter((r) => !r.parent);
return (
<>
{warnings.length > 0 && <ValidationAlert errors={[]} warnings={warnings} />}
<Layout resources={topLevel} basePath={basePath}>
<Routes>
<Route index element={<Navigate to={`${basePath}/${topLevel[0]?.name ?? "profile"}`} replace />} />
{topLevel.map((r) => (
<React.Fragment key={r.name}>
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
{!r.streaming && (
<>
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} />
<Route path={`${r.name}/:id`} element={<ResourceDetail resource={r} basePath={basePath} />} />
<Route path={`${r.name}/:id/edit`} element={<ResourceForm resource={r} basePath={basePath} mode="edit" />} />
</>
)}
</React.Fragment>
))}
</Routes>
</Layout>
</>
);
}
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");
return (
<Routes>
<Route path=":username" element={<Navigate to="me" replace />} />
{ops.map((op) => {
const basePath = friendlyProfilePath(op.path);
let Component: React.ComponentType<any> | undefined;
let mode: "view" | "create" | "edit" | undefined;
if (op.method === "GET" && profileComponents.view) {
Component = profileComponents.view;
mode = "view";
} else if (op.method === "POST" && profileComponents.create) {
Component = profileComponents.create;
mode = "create";
} else if ((op.method === "PUT" || op.method === "PATCH") && profileComponents.edit) {
Component = profileComponents.edit;
mode = "edit";
}
if (!Component || !mode) return null;
const routePath = mode === "view" ? basePath : `${basePath}/edit`;
return (
<Route
key={op.operationId}
path={routePath}
element={
<ProfileComponentWrapper
Component={Component}
mode={mode}
operation={op}
getOperation={getOp}
/>
}
/>
);
})}
</Routes>
);
}
function ProfileComponentWrapper({
Component,
mode,
operation,
getOperation,
}: {
Component: React.ComponentType<any>;
mode: "view" | "create" | "edit";
operation: { path: string; method: string };
getOperation?: { path: string };
}) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(mode !== "create");
const [error, setError] = useState<string | null>(null);
const fetchPath = getOperation?.path ?? operation.path;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const res = await getApi().get(fetchPath);
setData(res.data);
} catch (e: any) {
setError(e.response?.data?.detail ?? e.message ?? "Failed to load profile");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (mode !== "create") {
fetchData();
}
}, [fetchPath, mode]);
const handleSubmit = async (formData: Record<string, any>) => {
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 });
}
// Navigate back to the base profile view path
const base = friendlyProfilePath(operation.path);
window.history.pushState(null, "", `/${base}`);
window.location.reload();
} catch (e: any) {
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 = () => {
window.history.pushState(null, "", `/profile/me/edit`);
window.location.reload();
};
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

@@ -0,0 +1,68 @@
import React from "react";
import { Box, Button } from "@mui/material";
import { useResource, FilterComponentProps } from "../context/useResource";
interface FilterBarProps {
resourceName: string;
filters: Record<string, string>;
onFilterChange: (fieldName: string, value: string) => void;
onClear: () => void;
data?: any[];
}
export function FilterBar({ resourceName, filters, onFilterChange, onClear, data }: FilterBarProps) {
const { resource, components } = useResource(resourceName);
const filterable = resource.fields.filter((f) => f.filterable);
const hasActiveFilters = Object.values(filters).some((v) => v !== "");
if (filterable.length === 0) return null;
return (
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2, alignItems: "flex-start" }}>
{filterable.map((field) => {
const Component = components[field.name] as React.FC<FilterComponentProps>;
const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
if (isRange) {
return (
<Box key={field.name} sx={{ minWidth: 260, display: "flex", gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 120 }}>
<Component
labelOverride={`${field.label} From`}
value={filters[field.name + "_from"] ?? ""}
onChange={(v) => onFilterChange(field.name + "_from", v)}
data={data}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 120 }}>
<Component
labelOverride={`${field.label} To`}
value={filters[field.name + "_to"] ?? ""}
onChange={(v) => onFilterChange(field.name + "_to", v)}
data={data}
/>
</Box>
</Box>
);
}
return (
<Box key={field.name} sx={{ minWidth: 180 }}>
<Component
value={filters[field.name] ?? ""}
onChange={(v) => onFilterChange(field.name, v)}
data={data}
/>
</Box>
);
})}
{hasActiveFilters && (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Button size="small" variant="outlined" onClick={onClear}>
Clear
</Button>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,42 @@
import React from "react";
import { Box, Toolbar, IconButton, Typography } from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import { SideMenu } from "./SideMenu";
import type { ResourceConfig } from "../types";
interface LayoutProps {
resources: ResourceConfig[];
basePath: string;
children: React.ReactNode;
}
export function Layout({ resources, basePath, children }: LayoutProps) {
const [mobileOpen, setMobileOpen] = React.useState(false);
return (
<Box sx={{ display: "flex", minHeight: "calc(100vh - 128px)" }}>
<SideMenu
resources={resources}
basePath={basePath}
mobileOpen={mobileOpen}
onClose={() => setMobileOpen(false)}
/>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar>
<IconButton
color="inherit"
edge="start"
onClick={() => setMobileOpen(true)}
sx={{ mr: 2, display: { md: "none" } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap sx={{ display: { md: "none" } }}>
Admin Panel
</Typography>
</Toolbar>
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Typography,
Button,
Paper,
Grid,
CircularProgress,
Tabs,
Tab,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import EditIcon from "@mui/icons-material/Edit";
import type { ResourceConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { SseStreamView } from "./SseStreamView";
interface ResourceDetailProps {
resource: ResourceConfig;
basePath: string;
}
function TabPanel({ children, value, index }: { children: React.ReactNode; value: number; index: number }) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate();
const { id } = useParams();
const crud = useResource(resource.name);
const { resources: allResources } = useAppContext();
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [tabIndex, setTabIndex] = useState(0);
useEffect(() => {
if (id) {
setLoading(true);
crud
.get(id)
.then(setData)
.catch(() => navigate(`${basePath}/${resource.name}`))
.finally(() => setLoading(false));
}
}, [id]);
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 8 }}>
<CircularProgress />
</Box>
);
}
if (!data) {
return (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
Record not found
</Typography>
);
}
const visibleFields = resource.orderedFields.filter((f) => !f.hidden?.detail);
const tabs = [{ label: "Details", key: "details" }];
if (resource.subResources) {
for (const subName of resource.subResources) {
const sub = allResources.find((r) => r.name === subName);
if (sub) {
tabs.push({ label: sub.displayName, key: subName });
}
}
}
return (
<Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back
</Button>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
{applyDisplayFormat(
Object.fromEntries(
resource.orderedFields.map((field) => {
const value = data[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];
})
),
resource.displayFormat
)}
</Typography>
{resource.operations.update && (
<Button variant="contained" startIcon={<EditIcon />} onClick={() => navigate(`${basePath}/${resource.name}/${id}/edit`)}>
Edit
</Button>
)}
</Box>
{tabs.length > 1 && (
<Tabs value={tabIndex} onChange={(_, v) => setTabIndex(v)} sx={{ mb: 1 }}>
{tabs.map((t) => (
<Tab key={t.key} label={t.label} />
))}
</Tabs>
)}
<TabPanel value={tabIndex} index={0}>
<Paper variant="outlined" sx={{ p: 3 }}>
<Grid container spacing={2}>
{visibleFields.map((field) => {
let value = data[field.name];
let fmt = resource.displayFormat;
if (field.fk && typeof value === "object") {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (field.refSchema && !field.fk && typeof value === "object") {
fmt = field.inlineDisplayFormat ?? resource.displayFormat;
}
return (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<DetailFieldRenderer field={field} value={value} displayFormat={fmt} basePath={basePath} />
</Grid>
);
})}
</Grid>
</Paper>
</TabPanel>
{tabs.slice(1).map((t, i) => {
const sub = allResources.find((r) => r.name === t.key)!;
const pathParam = sub.parent?.pathParam ?? "id";
return (
<TabPanel key={t.key} value={tabIndex} index={i + 1}>
{sub.streaming ? (
<SseStreamView resource={sub} pathParams={{ [pathParam]: Number(id!) }} />
) : null}
</TabPanel>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,302 @@
import React, { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Box,
Typography,
Button,
Paper,
Grid,
CircularProgress,
Alert,
Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SaveIcon from "@mui/icons-material/Save";
import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { getApi } from "../hooks/useApi";
import { FormFieldRenderer } from "./fields";
import { extractFields } from "../transformers/field-config";
interface ResourceFormProps {
resource: ResourceConfig;
basePath: string;
mode: "create" | "edit";
}
export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
const navigate = useNavigate();
const { id } = useParams();
const crud = useResource(resource.name);
const { resources: allResources } = useAppContext();
const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({
open: false,
message: "",
severity: "success",
});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
const { schemas } = useAppContext();
useEffect(() => {
console.log(`[ResourceForm] mounted resource="${resource.name}" mode=${mode} id=${id}`);
console.log(`[ResourceForm] relationships:`, resource.relationships.map(r => `{field=${r.fieldName} target=${r.config.resource} prefetch=${r.config.prefetch}}`));
}, []);
useEffect(() => {
if (mode === "create") {
const initial: Record<string, any> = {};
resource.orderedFields.forEach((f) => {
if (f.refSchema && !f.fk && formData[f.name] === undefined) {
const refSchemaObj = schemas[f.refSchema!];
if (refSchemaObj) {
const nestedFields = extractFields(f.refSchema!, refSchemaObj, schemas);
initial[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas);
} else {
initial[f.name] = null;
}
}
});
if (Object.keys(initial).length > 0) {
setFormData((prev) => ({ ...prev, ...initial }));
}
}
}, [mode, resource.name]);
const loadFkOptions = useCallback(async (fieldName: string, fk: { resource: string; prefetch: boolean }) => {
console.log(`[loadFkOptions] CALLED field="${fieldName}" resource="${fk.resource}" prefetch=${fk.prefetch}`);
setFkLoading((prev) => ({ ...prev, [fieldName]: true }));
try {
const targetRes = allResources.find((r) => r.name === fk.resource);
if (!targetRes) {
console.log(`[loadFkOptions] targetRes NOT FOUND for "${fk.resource}"`);
return;
}
console.log(`[loadFkOptions] targetRes found: path="${targetRes.path}" pagination=${!!targetRes.pagination}`);
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) {
params.limit = 0;
}
console.log(`[loadFkOptions] fetching GET ${targetRes.path}`, params);
const res = await api.get(targetRes.path, { params });
console.log(`[loadFkOptions] response status=${res.status} data type=${typeof res.data} isArray=${Array.isArray(res.data)}`);
let items: any[];
if (targetRes.pagination) {
if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) {
console.log(`[loadFkOptions] paginated parse FAILED: data=`, res.data);
throw new Error(`Expected paginated response from ${targetRes.path}`);
}
items = res.data.items;
console.log(`[loadFkOptions] paginated: total=${res.data.total} items.length=${items.length}`);
} else {
if (!Array.isArray(res.data)) {
console.log(`[loadFkOptions] non-paginated parse FAILED: data=`, res.data);
throw new Error(`Expected array response from ${targetRes.path}`);
}
items = res.data;
console.log(`[loadFkOptions] non-paginated: items.length=${items.length}`);
}
const opts = items.map((item: any) => ({
value: item[targetRes.primaryKey],
label: applyFormat(item, targetRes.displayFormat),
}));
console.log(`[loadFkOptions] computed ${opts.length} options for field "${fieldName}"`, opts.slice(0, 3));
setFkOptions((prev) => ({ ...prev, [fieldName]: opts }));
} catch (e) {
console.log(`[loadFkOptions] ERROR field="${fieldName}":`, e);
} finally {
setFkLoading((prev) => ({ ...prev, [fieldName]: false }));
}
}, [allResources]);
useEffect(() => {
console.log(`[prefetch effect] ${resource.relationships.length} relationships, checking prefetch...`);
resource.relationships.forEach((rel) => {
console.log(`[prefetch effect] field="${rel.fieldName}" prefetch=${rel.config.prefetch} -> ${rel.config.prefetch ? "WILL FETCH" : "skipped (onFocus)"}`);
if (rel.config.prefetch) {
loadFkOptions(rel.fieldName, rel.config);
}
});
}, [resource.relationships, loadFkOptions]);
useEffect(() => {
if (mode === "edit" && id) {
crud.get(id).then((data) => {
const resolved = { ...(data ?? {}) };
resource.relationships.forEach((rel) => {
const val = resolved[rel.fieldName];
if (val != null) {
const targetRes = allResources.find((r) => r.name === rel.config.resource);
if (targetRes) {
if (Array.isArray(val)) {
resolved[rel.fieldName] = val.map((item: any) => item[targetRes.primaryKey]);
} else if (typeof val === "object") {
resolved[rel.fieldName] = val[targetRes.primaryKey];
}
}
if (!rel.config.prefetch) {
loadFkOptions(rel.fieldName, rel.config);
}
}
});
setFormData(resolved);
});
}
}, [mode, id, loadFkOptions, resource.relationships]);
const loadFkOnFocus = (fieldName: string) => {
console.log(`[loadFkOnFocus] CALLED field="${fieldName}"`);
const rel = resource.relationships.find((r) => r.fieldName === fieldName);
if (rel) {
console.log(`[loadFkOnFocus] found rel: prefetch=${rel.config.prefetch} fkOptions[${fieldName}]=${fkOptions[fieldName] ? "exists" : "undefined"}`);
} else {
console.log(`[loadFkOnFocus] NO RELATIONSHIP found for field="${fieldName}"`);
}
if (rel && !rel.config.prefetch && !fkOptions[fieldName]) {
console.log(`[loadFkOnFocus] conditions met -> calling loadFkOptions`);
loadFkOptions(fieldName, rel.config);
} else {
console.log(`[loadFkOnFocus] NOT calling loadFkOptions: rel=${!!rel} !prefetch=${rel && !rel.config.prefetch} !hasOptions=${!fkOptions[fieldName]}`);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validationErrors: Record<string, string> = {};
resource.orderedFields
.filter((f) => f.required && !f.readOnly && f.name !== resource.primaryKey)
.forEach((f) => {
if (formData[f.name] === undefined || formData[f.name] === null || formData[f.name] === "") {
validationErrors[f.name] = `${f.label} is required`;
}
});
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setSaving(true);
try {
if (mode === "create") {
await crud.create(formData);
setSnackbar({ open: true, message: "Created successfully", severity: "success" });
navigate(`${basePath}/${resource.name}`);
} else {
await crud.update(id!, formData);
setSnackbar({ open: true, message: "Updated successfully", severity: "success" });
navigate(`${basePath}/${resource.name}/${id}`);
}
} catch (e: any) {
setSnackbar({ open: true, message: e.message ?? "Operation failed", severity: "error" });
} finally {
setSaving(false);
}
};
const handleChange = (fieldName: string, value: any) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
if (errors[fieldName]) {
setErrors((prev) => {
const copy = { ...prev };
delete copy[fieldName];
return copy;
});
}
};
const title = mode === "create" ? `Create ${resource.displayName}` : `Edit ${resource.displayName}`;
return (
<Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back
</Button>
<Typography variant="h5" fontWeight={700}>
{title}
</Typography>
</Box>
<Paper variant="outlined" sx={{ p: 3 }}>
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={2}>
{resource.orderedFields
.filter((f) => !(f.name === resource.primaryKey && mode === "edit"))
.filter((f) => !f.hidden?.form)
.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer
field={field}
value={formData[field.name]}
onChange={(val) => handleChange(field.name, val)}
error={errors[field.name]}
fkOptions={fkOptions[field.name]}
fkLoading={fkLoading[field.name]}
recordId={id}
onFkOpen={loadFkOnFocus}
/>
</Grid>
))}
</Grid>
<Box sx={{ mt: 3, display: "flex", gap: 2 }}>
<Button
type="submit"
variant="contained"
startIcon={saving ? <CircularProgress size={18} color="inherit" /> : <SaveIcon />}
disabled={saving}
>
{mode === "create" ? "Create" : "Save Changes"}
</Button>
<Button variant="outlined" onClick={() => navigate(`${basePath}/${resource.name}`)}>
Cancel
</Button>
</Box>
</Box>
</Paper>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
>
<Alert severity={snackbar.severity} onClose={() => setSnackbar((s) => ({ ...s, open: false }))}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}
function applyFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
}
function buildInitialShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
const shape: Record<string, any> = {};
for (const f of fields) {
if (f.refSchema && !f.fk) {
const refSchemaObj = schemas[f.refSchema!];
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
shape[f.name] = f.isArray ? [] : buildInitialShape(nestedFields, schemas);
} else {
shape[f.name] = null;
}
}
return shape;
}

View File

@@ -0,0 +1,408 @@
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Typography,
Button,
IconButton,
Tooltip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Paper,
TableSortLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility";
import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
import { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./fields";
import { FilterBar } from "./FilterBar";
import { readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource";
import { SseConnectionStatus } from "./SseConnectionStatus";
interface ResourceListProps {
resource: ResourceConfig;
basePath: string;
}
function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean {
for (const field of fields) {
if (!field.filterable) continue;
const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
if (isRange) {
const from = filters[field.name + "_from"];
const to = filters[field.name + "_to"];
if (from || to) {
const cell = row[field.name];
if (cell == null) return false;
if (field.type === "integer" || field.type === "number") {
if (from && Number(cell) < Number(from)) return false;
if (to && Number(cell) > Number(to)) return false;
} else {
if (from && String(cell) < String(from)) return false;
if (to && String(cell) > String(to)) return false;
}
}
continue;
}
const val = filters[field.name];
if (!val) continue;
const cell = row[field.name];
if (cell == null) return false;
let str: string;
if (field.fk && typeof cell === "object" && cell !== null) {
const targetRes = allResources.find((r) => r.name === field.fk!.resource);
if (targetRes) {
const items = Array.isArray(cell) ? cell : [cell];
str = items.map((item: any) => applyDisplayFormat(item, targetRes.displayFormat)).join(" ");
} else {
str = String(cell);
}
} else {
str = String(cell);
}
const filterParts = val.split(",").filter(Boolean);
if (!filterParts.some((part) => str.toLowerCase().includes(part.toLowerCase()))) return false;
}
return true;
}
export function ResourceList({ resource, basePath }: ResourceListProps) {
const navigate = useNavigate();
const { components, ...crud } = useResource(resource.name);
const { resources: allResources, config } = useAppContext();
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20);
const [sortField, setSortField] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<Record<string, string>>({});
const [detailRow, setDetailRow] = useState<any | null>(null);
const isStreaming = resource.streaming === true;
const hasActions = resource.operations.get || resource.operations.update || resource.operations.delete;
const filterMode = config.resourceConfig?.[resource.name]?.filterOptions?.mode ?? "client";
const isClientMode = filterMode === "client" && !isStreaming;
const visibleColumns = resource.listColumns
.map((colName) => resource.fields.find((f) => f.name === colName))
.filter((f): f is FieldConfig => !!f && !f.hidden?.list);
useEffect(() => {
setFilters({});
}, [resource.name]);
useEffect(() => {
if (!isStreaming || !crud.stream) return;
setData(readSseCache(resource.name));
setSseConnected(resource.name, false);
const sub = crud.stream({
onEvent: (evt) => {
const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() };
const updated = appendSseCache(resource.name, enriched);
setData(updated);
},
onOpen: () => setSseConnected(resource.name, true),
onError: () => setSseConnected(resource.name, false),
});
return () => {
setSseConnected(resource.name, false);
sub.close();
};
}, [isStreaming, crud.stream, resource.name]);
const serverFetchData = useCallback(async () => {
const params: Record<string, any> = {};
if (resource.pagination) {
params[resource.pagination.limitParam] = rowsPerPage;
params[resource.pagination.offsetParam] = page * rowsPerPage;
}
if (sortField) {
params.sort = sortDir === "desc" ? `-${sortField}` : sortField;
}
for (const [key, val] of Object.entries(filters)) {
if (val) params[key] = val;
}
const result = await crud.list(params);
setData(result.items ?? []);
setTotal(result.total ?? result.items?.length ?? 0);
}, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir, filters]);
const clientFetchAll = useCallback(async () => {
const params: Record<string, any> = {};
if (resource.pagination) {
params[resource.pagination.limitParam] = 0;
}
const result = await crud.list(params);
setData(result.items ?? []);
setTotal(result.items?.length ?? 0);
}, [crud.list, resource.pagination]);
useEffect(() => {
if (isStreaming) return;
if (isClientMode) {
clientFetchAll();
} else {
serverFetchData();
}
}, [isStreaming, isClientMode, clientFetchAll, serverFetchData]);
useEffect(() => {
if (isClientMode) {
setPage(0);
}
}, [filters, isClientMode]);
const filteredData = useMemo(() => {
if (!isClientMode) return data;
let items = data.filter((row) => matchRow(row, filters, resource.fields, allResources));
if (sortField) {
items = [...items].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
if (aVal == null) return 1;
if (bVal == null) return -1;
if (aVal < bVal) return sortDir === "asc" ? -1 : 1;
if (aVal > bVal) return sortDir === "asc" ? 1 : -1;
return 0;
});
}
const start = page * rowsPerPage;
return items.slice(start, start + rowsPerPage);
}, [data, isClientMode, filters, sortField, sortDir, page, rowsPerPage, resource.fields, allResources]);
const clientTotal = useMemo(() => {
if (!isClientMode) return total;
return data.filter((row) => matchRow(row, filters, resource.fields, allResources)).length;
}, [data, isClientMode, filters, resource.fields, allResources]);
const displayData = isClientMode ? filteredData : data;
const displayTotal = isClientMode ? clientTotal : total;
const handleDelete = async (id: string | number) => {
if (!window.confirm("Are you sure you want to delete this item?")) return;
await crud.remove(id);
if (isClientMode) {
clientFetchAll();
} else {
serverFetchData();
}
};
const handleSort = (field: string) => {
if (sortField === field) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDir("asc");
}
};
const handleFilterChange = (fieldName: string, value: string) => {
setFilters((prev) => ({ ...prev, [fieldName]: value }));
};
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Typography variant="h5" fontWeight={700}>
{resource.displayName}
</Typography>
{isStreaming && <SseConnectionStatus resourceName={resource.name} />}
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
{isStreaming && data.length > 0 && (
<Button variant="outlined" size="small" onClick={() => { setData([]); setTotal(0); clearSseCache(resource.name); }}>
Clear ({data.length})
</Button>
)}
{resource.operations.create && !isStreaming && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
>
Create
</Button>
)}
</Box>
</Box>
{!isStreaming && (
<FilterBar
resourceName={resource.name}
filters={filters}
onFilterChange={handleFilterChange}
onClear={() => setFilters({})}
data={data}
/>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
{visibleColumns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
{col.sortable ? (
<TableSortLabel
active={sortField === col.name}
direction={sortField === col.name ? sortDir : "asc"}
onClick={() => handleSort(col.name)}
>
{col.label}
</TableSortLabel>
) : (
col.label
)}
</TableCell>
))}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
{isStreaming ? "Waiting for events\u2026" : "No records found"}
</Typography>
</TableCell>
</TableRow>
) : (
displayData.map((row, idx) => {
const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey];
return (
<TableRow
key={rowId}
hover
sx={{ cursor: "pointer" }}
onClick={() => {
if (isStreaming) {
setDetailRow(row);
} else {
navigate(`${basePath}/${resource.name}/${rowId}`);
}
}}
>
{visibleColumns.map((col) => {
let value = row[col.name];
let fmt = resource.displayFormat;
if (col.fk) {
const targetRes = allResources.find((r) => r.name === col.fk!.resource);
fmt = targetRes!.displayFormat;
} else if (col.refSchema && !col.fk && col.inlineDisplayFormat) {
fmt = col.inlineDisplayFormat;
}
return (
<TableCell key={col.name}>
<ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
</TableCell>
);
})}
{hasActions && (
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{resource.operations.get && !isStreaming && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{!isStreaming && (resource.pagination || isClientMode) && (
<TablePagination
component="div"
count={displayTotal}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(parseInt(e.target.value, 10));
setPage(0);
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
)}
{!isStreaming && displayData.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block", textAlign: "right" }}>
{displayTotal} record{displayTotal !== 1 ? "s" : ""}
</Typography>
)}
<Dialog open={!!detailRow} onClose={() => setDetailRow(null)} maxWidth="sm" fullWidth>
<DialogTitle>{resource.displayName} Event</DialogTitle>
<DialogContent dividers>
{detailRow && (
<Grid container spacing={2} sx={{ mt: 0.5 }}>
{visibleColumns.map((col) => (
<Grid key={col.name} item xs={12} sm={6}>
<DetailFieldRenderer
field={col}
value={detailRow[col.name]}
displayFormat={resource.displayFormat}
basePath={basePath}
/>
</Grid>
))}
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailRow(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,110 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import {
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
Box,
useMediaQuery,
useTheme,
} from "@mui/material";
import CircleIcon from "@mui/icons-material/Circle";
import type { ResourceConfig } from "../types";
interface SideMenuProps {
resources: ResourceConfig[];
basePath: string;
mobileOpen: boolean;
onClose: () => void;
}
const drawerWidth = 260;
export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuProps) {
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const colors = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
];
const content = (
<Box>
<Toolbar>
<Typography variant="h6" fontWeight={700} noWrap>
Admin Panel
</Typography>
</Toolbar>
<List sx={{ px: 1 }}>
{resources.map((r, i) => {
const listPath = `${basePath}/${r.name}`;
const active = location.pathname.startsWith(listPath);
return (
<ListItemButton
key={r.name}
selected={active}
onClick={() => {
navigate(listPath);
if (isMobile) onClose();
}}
sx={{
borderRadius: 2,
mb: 0.5,
"&.Mui-selected": {
bgcolor: `${colors[i % colors.length]}15`,
"&:hover": { bgcolor: `${colors[i % colors.length]}20` },
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
</ListItemIcon>
<ListItemText
primary={r.displayName}
primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }}
/>
</ListItemButton>
);
})}
</List>
</Box>
);
if (isMobile) {
return (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
"& .MuiDrawer-paper": { boxSizing: "border-box", width: drawerWidth },
}}
>
{content}
</Drawer>
);
}
return (
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
"& .MuiDrawer-paper": { width: drawerWidth, boxSizing: "border-box" },
}}
>
{content}
</Drawer>
);
}
export { drawerWidth };

View File

@@ -0,0 +1,34 @@
import React from "react";
import { Box } from "@mui/material";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { useSseConnected } from "../context/useResource";
interface SseConnectionStatusProps {
resourceName: string;
}
export function SseConnectionStatus({ resourceName }: SseConnectionStatusProps) {
const connected = useSseConnected(resourceName);
return (
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 1,
py: 0.25,
borderRadius: 1,
border: 1,
borderColor: connected ? "#4caf50" : "#f44336",
color: connected ? "#4caf50" : "#f44336",
fontSize: "0.75rem",
fontWeight: 600,
}}
>
<FiberManualRecordIcon sx={{ fontSize: 10 }} />
{connected ? "Connected" : "Disconnected"}
</Box>
);
}

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from "react";
import {
Box, Typography, Paper, Chip, Snackbar,
} from "@mui/material";
import type { ResourceConfig } from "../types";
import { useResource, readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource";
import { applyDisplayFormat } from "./fields";
import { SseConnectionStatus } from "./SseConnectionStatus";
interface SseStreamViewProps {
resource: ResourceConfig;
pathParams?: Record<string, string | number>;
}
export function SseStreamView({ resource, pathParams }: SseStreamViewProps) {
const { stream } = useResource(resource.name);
const [events, setEvents] = useState<any[]>(() => readSseCache(resource.name));
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMsg, setSnackbarMsg] = useState("");
useEffect(() => {
if (!stream) return;
setSseConnected(resource.name, false);
const sub = stream({
onEvent: (evt) => {
const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() };
const updated = appendSseCache(resource.name, enriched);
setEvents([...updated]);
setSnackbarMsg(applyDisplayFormat(evt, resource.displayFormat));
setSnackbarOpen(true);
},
onOpen: () => setSseConnected(resource.name, true),
onError: () => setSseConnected(resource.name, false),
}, pathParams);
return () => {
setSseConnected(resource.name, false);
sub.close();
};
}, [resource.name]);
const eventCount = events.length;
const latestEvent = events[events.length - 1] ?? null;
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2.5 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Typography variant="subtitle1" fontWeight={700}>
{resource.displayName}
</Typography>
<SseConnectionStatus resourceName={resource.name} />
</Box>
<Chip
label={eventCount > 0 ? `${eventCount} event${eventCount !== 1 ? "s" : ""}` : "No events"}
size="small"
variant="outlined"
color={eventCount > 0 ? "primary" : "default"}
/>
</Box>
{latestEvent ? (
<Box
sx={{
bgcolor: "grey.50",
borderRadius: 1,
p: 2,
border: "1px solid",
borderColor: "divider",
fontFamily: "monospace",
fontSize: "0.875rem",
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: "block" }}>
Latest event (#{latestEvent._seq})
</Typography>
<Typography>
{applyDisplayFormat(latestEvent, resource.displayFormat)}
</Typography>
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: "center" }}>
Waiting for events&hellip;
</Typography>
)}
<Snackbar
open={snackbarOpen}
autoHideDuration={2000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMsg}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
/>
</Paper>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Box, Typography, Alert, Snackbar, List, ListItem, ListItemIcon, ListItemText } from "@mui/material";
import ErrorIcon from "@mui/icons-material/Error";
import WarningIcon from "@mui/icons-material/Warning";
import type { ValidationMessage } from "../types";
interface ValidationAlertProps {
errors: ValidationMessage[];
warnings: ValidationMessage[];
}
export function ValidationAlert({ errors, warnings }: ValidationAlertProps) {
const [warningOpen, setWarningOpen] = React.useState(warnings.length > 0);
if (errors.length > 0) {
return (
<Box sx={{ p: 4, maxWidth: 700, mx: "auto", mt: 8 }}>
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
OpenAPI Spec Validation Failed
</Typography>
<Typography variant="body2">
The spec has {errors.length} error{errors.length > 1 ? "s" : ""}. Fix them before the admin panel can render.
</Typography>
</Alert>
<List dense>
{errors.map((e, i) => (
<ListItem key={i}>
<ListItemIcon sx={{ minWidth: 36 }}>
<ErrorIcon color="error" fontSize="small" />
</ListItemIcon>
<ListItemText primary={e.message} />
</ListItem>
))}
</List>
{warnings.length > 0 && (
<>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1, color: "text.secondary" }}>
Warnings ({warnings.length})
</Typography>
<List dense>
{warnings.map((w, i) => (
<ListItem key={i}>
<ListItemIcon sx={{ minWidth: 36 }}>
<WarningIcon color="warning" fontSize="small" />
</ListItemIcon>
<ListItemText primary={w.message} />
</ListItem>
))}
</List>
</>
)}
</Box>
);
}
return (
<Snackbar
open={warningOpen}
autoHideDuration={8000}
onClose={() => setWarningOpen(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Box>
{warnings.map((w, i) => (
<Alert key={i} severity="warning" sx={{ mb: 1 }} onClose={() => setWarningOpen(false)}>
{w.message}
</Alert>
))}
</Box>
</Snackbar>
);
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import { Box, Typography } from "@mui/material";
import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer";
interface DetailFieldProps {
field: FieldConfig;
value: any;
displayFormat?: string;
basePath?: string;
}
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
if (field.hidden?.detail) return null;
return (
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label}
</Typography>
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
</Box>
);
}

View File

@@ -0,0 +1,176 @@
import React from "react";
import { TextField, Chip, Box } from "@mui/material";
import type { FieldConfig } from "../../types";
import { StringField } from "./renderers/StringField";
import { NumberField } from "./renderers/NumberField";
import { DateField } from "./renderers/DateField";
import { BooleanField } from "./renderers/BooleanField";
import { EnumField } from "./renderers/EnumField";
import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { FileUploadField } from "./renderers/FileUploadField";
import { ImageField } from "./renderers/ImageField";
import { JsonField } from "./renderers/JsonField";
import { DiscriminatorField } from "./renderers/DiscriminatorField";
interface FormFieldProps {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
fkOptions?: { value: any; label: string }[];
fkLoading?: boolean;
recordId?: string | number;
onFkOpen?: (fieldName: string) => void;
}
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
if (field.hidden?.form) return null;
if (field.upload) {
return (
<FileUploadField
field={field}
value={value}
onChange={onChange}
/>
);
}
if (field.readOnly && field.uiType !== "image") {
return (
<StringField field={field} value={value} onChange={onChange} error={error} />
);
}
if (field.uiType === "image") {
return (
<ImageField
field={field}
value={value}
onChange={onChange}
id={recordId}
uploadUrl={field.uploadUrl}
/>
);
}
if (field.fk) {
console.log(`[FormFieldRenderer] FK field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} isArray=${field.isArray}`);
if (field.isArray) {
return (
<FkMultiSelectField
field={field}
value={value}
onChange={onChange}
fkOptions={fkOptions}
fkLoading={fkLoading}
onOpen={() => onFkOpen?.(field.name)}
/>
);
}
return (
<FkSelectField
field={field}
value={value}
onChange={onChange}
error={error}
fkOptions={fkOptions}
onOpen={() => onFkOpen?.(field.name)}
/>
);
}
if (field.enumValues) {
return (
<EnumField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.type === "boolean") {
return <BooleanField field={field} value={value} onChange={onChange} />;
}
if (field.type === "integer" || field.type === "number") {
return (
<NumberField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.format === "date" || field.format === "date-time") {
return (
<DateField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.oneOfOptions) {
return (
<DiscriminatorField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.refSchema && !field.fk) {
return (
<JsonField
field={field}
value={value}
onChange={onChange}
/>
);
}
if (field.type === "array" && !field.fk) {
return (
<Box>
<Chip
label={`${field.label} (${Array.isArray(value) ? value.length : 0})`}
size="small"
color="default"
variant="outlined"
/>
</Box>
);
}
if (field.type === "object" && !field.fk) {
return (
<Box>
<Chip
label={field.label}
size="small"
color="default"
variant="outlined"
/>
</Box>
);
}
return (
<StringField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}

View File

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

View File

@@ -0,0 +1,5 @@
export { FormFieldRenderer } from "./FormFieldRenderer";
export { ListCellRenderer } from "./ListCellRenderer";
export { DetailFieldRenderer } from "./DetailFieldRenderer";
export { applyDisplayFormat } from "./utils";
export { JsonField } from "./renderers/JsonField";

View File

@@ -0,0 +1,57 @@
import React from "react";
import { Box, FormControl, FormControlLabel, Switch, FormHelperText, ToggleButton, ToggleButtonGroup } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import CancelIcon from "@mui/icons-material/Cancel";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
nullable?: boolean;
}
export function BooleanField({ field, value, onChange, nullable }: Props) {
if (nullable) {
const strValue = String(value ?? "");
return (
<Box>
<Box sx={{ fontSize: "0.75rem", color: "text.secondary", mb: 0.5, fontWeight: 600 }}>
{field.label}
</Box>
<ToggleButtonGroup
value={strValue}
exclusive
onChange={(_, v) => onChange(v ?? "")}
size="small"
>
<ToggleButton value="" sx={{ color: "text.disabled", borderColor: "divider" }}>
<Box sx={{ width: 16, height: 16, borderRadius: "50%", bgcolor: "action.disabledBackground" }} />
</ToggleButton>
<ToggleButton value="true" sx={{ color: "success.main", borderColor: "success.main" }}>
<CheckCircleIcon fontSize="small" />
</ToggleButton>
<ToggleButton value="false" sx={{ color: "error.main", borderColor: "error.main" }}>
<CancelIcon fontSize="small" />
</ToggleButton>
</ToggleButtonGroup>
</Box>
);
}
return (
<FormControl component="fieldset" fullWidth size="small">
<FormControlLabel
control={
<Switch
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={field.readOnly}
/>
}
label={field.label}
/>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</FormControl>
);
}

View File

@@ -0,0 +1,34 @@
import React from "react";
import { TextField } from "@mui/material";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function DateField({ field, value, onChange, error }: Props) {
const inputType = field.format === "date" ? "date" : "datetime-local";
const normalized = field.format === "date-time" && typeof value === "string"
? value.replace(/\.\d+Z$/, "").replace(/Z$/, "")
: value;
return (
<TextField
fullWidth
label={field.label}
type={inputType}
value={normalized ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
InputLabelProps={{ shrink: true }}
/>
);
}

View File

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

View File

@@ -0,0 +1,34 @@
import React from "react";
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function EnumField({ field, value, onChange, error }: Props) {
return (
<FormControl fullWidth size="small" error={!!error}>
<InputLabel>{field.label}</InputLabel>
<Select
value={value ?? ""}
label={field.label}
onChange={(e) => onChange(e.target.value)}
disabled={field.readOnly}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{(field.enumValues ?? []).map((opt) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</FormControl>
);
}

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

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

View File

@@ -0,0 +1,38 @@
import React from "react";
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
fkOptions?: { value: any; label: string }[];
onOpen?: () => void;
}
export function FkSelectField({ field, value, onChange, error, fkOptions, onOpen }: Props) {
console.log(`[FkSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} value=${value}`);
return (
<FormControl fullWidth size="small" error={!!error}>
<InputLabel>{field.label}</InputLabel>
<Select
value={value ?? ""}
label={field.label}
onChange={(e) => onChange(e.target.value)}
onOpen={() => onOpen?.()}
disabled={field.readOnly}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{(fkOptions ?? []).map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</FormControl>
);
}

View File

@@ -0,0 +1,60 @@
import React from "react";
import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
import Button from "@mui/material/Button";
import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
id?: string | number;
uploadUrl?: string;
}
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!id || !uploadUrl) {
const reader = new FileReader();
reader.onload = () => onChange(reader.result);
reader.readAsDataURL(file);
return;
}
const formData = new FormData();
formData.append("file", file);
try {
const api = getApi();
const url = uploadUrl.replace("{id}", String(id));
const res = await api.post(url, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
onChange(res.data.url ?? res.data);
} catch {
const reader = new FileReader();
reader.onload = () => onChange(reader.result);
reader.readAsDataURL(file);
}
};
return (
<Box>
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
{field.label}
</Typography>
{value ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Button variant="outlined" component="label" size="small">
Upload {field.label}
<input type="file" hidden accept="image/*" onChange={handleUpload} />
</Button>
)}
{field.description && <FormHelperText>{field.description}</FormHelperText>}
</Box>
);
}

View File

@@ -0,0 +1,83 @@
import React, { useState } from "react";
import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils";
import { extractFields } from "../../../transformers/field-config";
import { useAppContext } from "../../../context/AppContext";
interface Props {
field: FieldConfig;
value: any;
}
const displayLabels: Record<string, string> = {
basic: "Basic",
heart_rate: "Heart Rate",
dental: "Dental",
vaccine: "Vaccine",
preop: "PreOp",
surgery: "Surgery",
};
export function InlineRefField({ field, value }: Props) {
const [open, setOpen] = useState(false);
const { schemas } = useAppContext();
if (!value || typeof value !== "object") {
return <Typography variant="body2" color="text.disabled"></Typography>;
}
const discProp = field.discriminatorProperty;
const discValue = discProp ? value[discProp] : undefined;
const discChip = discValue ? displayLabels[discValue] ?? discValue : undefined;
const tooltip = field.inlineDisplayFormat
? applyDisplayFormat(value, field.inlineDisplayFormat)
: undefined;
const schema = field.refSchema ? schemas[field.refSchema] : undefined;
let subFields: FieldConfig[] = [];
if (field.oneOfOptions && field.discriminatorProperty) {
const activeOption = field.oneOfOptions.find((o) => o.value === value[field.discriminatorProperty!]);
if (activeOption) subFields = activeOption.fields;
} else if (schema && field.refSchema) {
subFields = extractFields(field.refSchema, schema, schemas);
}
return (
<>
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap", alignItems: "center" }}>
{discChip && <Chip label={discChip} size="small" color="primary" variant="outlined" />}
<Chip
label={field.label}
title={tooltip}
size="small"
color="primary"
variant="outlined"
onClick={() => setOpen(true)}
sx={{ cursor: "pointer" }}
/>
</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

@@ -0,0 +1,270 @@
import React, { useState } from "react";
import {
Button,
Chip,
Box,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Divider,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import type { FieldConfig } from "../../../types";
import { useAppContext } from "../../../context/AppContext";
import { extractFields } from "../../../transformers/field-config";
import { FormFieldRenderer } from "../FormFieldRenderer";
import { applyDisplayFormat } from "../utils";
interface JsonFieldProps {
field: FieldConfig;
value: any;
onChange: (val: any) => void;
}
export function JsonField({ field, value, onChange }: JsonFieldProps) {
const { schemas } = useAppContext();
const [open, setOpen] = useState(false);
const refSchema = field.refSchema ? schemas[field.refSchema] : null;
const subFields = refSchema
? extractFields(field.refSchema!, refSchema, schemas)
: [];
const [editValue, setEditValue] = useState<any>(null);
const handleOpen = () => {
setEditValue(initEditValue(value, field, schemas));
setOpen(true);
};
const handleSave = () => {
onChange(editValue);
setOpen(false);
};
const handleCancel = () => {
setEditValue(null);
setOpen(false);
};
const handleClear = () => {
onChange(null);
setOpen(false);
};
const handleAddItem = () => {
setEditValue((prev: any[]) => [...(prev || []), buildDefaultShape(subFields, schemas)]);
};
const handleRemoveItem = (index: number) => {
setEditValue((prev: any[]) => prev.filter((_: any, i: number) => i !== index));
};
const handleItemFieldChange = (index: number, fieldName: string, val: any) => {
setEditValue((prev: any[]) => {
const next = [...prev];
next[index] = { ...next[index], [fieldName]: val };
return next;
});
};
const handleFieldChange = (fieldName: string, val: any) => {
setEditValue((prev: any) => ({ ...prev, [fieldName]: val }));
};
if (!open) {
if (value === null || value === undefined) {
return (
<Button variant="outlined" onClick={handleOpen} size="small" startIcon={field.isArray ? <AddIcon /> : undefined}>
{field.isArray ? `Add ${field.label}` : `Set ${field.label}`}
</Button>
);
}
if (field.isArray && Array.isArray(value)) {
if (value.length === 0) {
return (
<Button variant="outlined" onClick={handleOpen} size="small" startIcon={<AddIcon />}>
Add {field.label}
</Button>
);
}
return (
<Chip
label={`${field.label} (${value.length})`}
size="small"
color="primary"
variant="outlined"
onClick={handleOpen}
onDelete={handleClear}
/>
);
}
if (typeof value === "object") {
const tooltip = field.inlineDisplayFormat
? applyDisplayFormat(value, field.inlineDisplayFormat)
: undefined;
return (
<Chip
label={field.label}
title={tooltip}
size="small"
color="primary"
variant="outlined"
onClick={handleOpen}
onDelete={handleClear}
/>
);
}
}
return (
<Dialog fullScreen open={open} onClose={handleCancel}>
<DialogTitle>{field.label}</DialogTitle>
<DialogContent dividers>
{field.isArray ? (
<ArrayEditor
items={editValue ?? []}
subFields={subFields}
onAddItem={handleAddItem}
onRemoveItem={handleRemoveItem}
onFieldChange={handleItemFieldChange}
schemas={schemas}
/>
) : (
<ObjectEditor
value={editValue}
subFields={subFields}
onFieldChange={handleFieldChange}
schemas={schemas}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClear} color="error">
Clear
</Button>
<Button onClick={handleCancel}>Cancel</Button>
<Button onClick={handleSave} variant="contained">
Save
</Button>
</DialogActions>
</Dialog>
);
}
function ObjectEditor({
value,
subFields,
onFieldChange,
}: {
value: any;
subFields: FieldConfig[];
onFieldChange: (name: string, val: any) => void;
schemas: Record<string, any>;
}) {
return (
<Box>
{subFields.map((subField) => (
<Box key={subField.name} sx={{ mb: 2 }}>
<FormFieldRenderer
field={subField}
value={value?.[subField.name]}
onChange={(val) => onFieldChange(subField.name, val)}
/>
</Box>
))}
</Box>
);
}
function ArrayEditor({
items,
subFields,
onAddItem,
onRemoveItem,
onFieldChange,
schemas,
}: {
items: any[];
subFields: FieldConfig[];
onAddItem: () => void;
onRemoveItem: (index: number) => void;
onFieldChange: (index: number, name: string, val: any) => void;
schemas: Record<string, any>;
}) {
return (
<Box>
{items.length === 0 && (
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
No items added yet.
</Typography>
)}
{items.map((item, index) => (
<Box key={index} sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
Item {index + 1}
</Typography>
<IconButton size="small" color="error" onClick={() => onRemoveItem(index)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ pl: 2 }}>
{subFields.map((subField) => (
<Box key={subField.name} sx={{ mb: 2 }}>
<FormFieldRenderer
field={subField}
value={item?.[subField.name]}
onChange={(val) => onFieldChange(index, subField.name, val)}
/>
</Box>
))}
</Box>
<Divider sx={{ mt: 2 }} />
</Box>
))}
<Button startIcon={<AddIcon />} onClick={onAddItem} variant="outlined" size="small">
Add Item
</Button>
</Box>
);
}
function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
const shape: Record<string, any> = {};
for (const f of fields) {
if (f.refSchema && !f.fk) {
const refSchemaObj = schemas[f.refSchema!];
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas);
} else if (f.isArray) {
shape[f.name] = [];
} else if (f.type === "object") {
shape[f.name] = {};
} else {
shape[f.name] = null;
}
}
return shape;
}
function initEditValue(value: any, field: FieldConfig, schemas: Record<string, any>): any {
if (field.isArray) {
return value ? value.map((item: any) => ({ ...item })) : [];
}
if (value != null && value !== "" && typeof value === "object") {
return { ...value };
}
return buildDefaultShape(
field.refSchema ? extractFields(field.refSchema, schemas[field.refSchema], schemas) : [],
schemas
);
}

View File

@@ -0,0 +1,37 @@
import React from "react";
import { TextField } from "@mui/material";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function NumberField({ field, value, onChange, error }: Props) {
const isFloat = field.type === "number" || field.format === "float";
return (
<TextField
fullWidth
label={field.label}
type="number"
value={value ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange("");
} else {
onChange(isFloat ? parseFloat(raw) : parseInt(raw, 10));
}
}}
error={!!error}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
inputProps={isFloat ? { step: "any" } : undefined}
/>
);
}

View File

@@ -0,0 +1,29 @@
import React from "react";
import { TextField } from "@mui/material";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any) => void;
error?: string;
}
export function StringField({ field, value, onChange, error }: Props) {
const inputType = field.format === "email" ? "email" : "text";
return (
<TextField
fullWidth
label={field.label}
type={inputType}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
error={!!error}
helperText={error || field.description || undefined}
placeholder={field.description || undefined}
size="small"
disabled={field.readOnly}
/>
);
}

View File

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

View File

@@ -0,0 +1,24 @@
import { createContext, useContext } from "react";
import type { AuthConfig, ProfileComponents, ProfileOperation, ResourceConfig, SpecConfiguration, ValidationMessage } from "../types";
export interface AppContextValue {
config: SpecConfiguration;
resources: ResourceConfig[];
profileOperations: ProfileOperation[];
profileComponents: ProfileComponents;
authConfig: AuthConfig;
schemas: Record<string, any>;
loading: boolean;
errors: ValidationMessage[];
warnings: ValidationMessage[];
}
export const AppContext = createContext<AppContextValue | null>(null);
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error("useAppContext must be used within an AppProvider");
}
return ctx;
}

View File

@@ -0,0 +1,134 @@
import React, { useEffect, useState, useMemo } from "react";
import type { AuthConfig, ProfileOperation, SpecConfiguration, ResourceConfig, ValidationMessage } from "../types";
import { AppContext } from "./AppContext";
import { loadSpec } from "../spec-loader";
import { validateSpec } from "../spec-validator";
import { buildResourceConfigs } from "../transformers/resource-config";
import { initApi } from "../hooks/useApi";
interface AppProviderProps {
specConfiguration: SpecConfiguration;
children: React.ReactNode;
}
function extractAuthConfig(spec: any): AuthConfig {
const bearer: Record<string, any> =
spec?.components?.securitySchemes?.bearerAuth ?? {};
return {
serverUrl: bearer["x-server-url"] ?? "",
loginPath: bearer["x-login-path"] ?? "/login",
registerPath: bearer["x-register-path"] ?? "/register",
logoutPath: bearer["x-logout-path"] ?? "/logout",
mePath: bearer["x-me-path"] ?? "/me",
introspectPath: bearer["x-introspect-path"] ?? "/introspect",
};
}
function extractProfileOperations(spec: any): ProfileOperation[] {
const ops: ProfileOperation[] = [];
const paths = spec.paths ?? {};
for (const [path, methods] of Object.entries<Record<string, any>>(paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (method.startsWith("x-")) continue;
if (operation["x-profile"] === true) {
ops.push({
path,
method: method.toUpperCase(),
operationId: operation.operationId ?? "",
summary: operation.summary,
});
}
}
}
return ops;
}
const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "",
loginPath: "/login",
registerPath: "/register",
logoutPath: "/logout",
mePath: "/me",
introspectPath: "/introspect",
};
export function AppProvider({ specConfiguration, children }: AppProviderProps) {
const [loading, setLoading] = useState(true);
const [resources, setResources] = useState<ResourceConfig[]>([]);
const [profileOperations, setProfileOperations] = useState<ProfileOperation[]>([]);
const [authConfig, setAuthConfig] = useState<AuthConfig>(DEFAULT_AUTH_CONFIG);
const [schemas, setSchemas] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<ValidationMessage[]>([]);
const [warnings, setWarnings] = useState<ValidationMessage[]>([]);
const profileComponents = specConfiguration.profileComponents ?? {};
useEffect(() => {
let cancelled = false;
async function init() {
try {
setLoading(true);
const spec = await loadSpec(specConfiguration.specUrl);
const allMessages = validateSpec(spec, specConfiguration);
const errs = allMessages.filter((m) => m.type === "error");
const warns = allMessages.filter((m) => m.type === "warning");
if (!cancelled) {
setErrors(errs);
setWarnings(warns);
setSchemas(spec.components?.schemas ?? {});
setProfileOperations(extractProfileOperations(spec));
setAuthConfig(extractAuthConfig(spec));
}
if (errs.length === 0) {
const configs = buildResourceConfigs(spec);
if (!cancelled) {
setResources(configs);
}
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";
if (baseUrl) {
initApi(baseUrl, specConfiguration.getToken);
}
}
} catch (e: any) {
if (!cancelled) {
const lines = (e.message ?? "Failed to load spec").split("\n");
setErrors(lines.map((msg: string) => ({ type: "error" as const, message: msg })));
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
init();
return () => {
cancelled = true;
};
}, [specConfiguration.specUrl]);
const value = useMemo(
() => ({
config: specConfiguration,
resources,
profileOperations,
profileComponents,
authConfig,
schemas,
loading,
errors,
warnings,
}),
[specConfiguration, resources, profileOperations, profileComponents, authConfig, schemas, loading, errors, warnings]
);
return React.createElement(AppContext.Provider, { value }, children);
}

View File

@@ -0,0 +1,802 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { Autocomplete, TextField, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
import { useAppContext } from "./AppContext";
import { getApi } from "../hooks/useApi";
import { sanitizePayload } from "../utils/sanitize-payload";
import { StringField } from "../components/fields/renderers/StringField";
import { NumberField } from "../components/fields/renderers/NumberField";
import { DateField } from "../components/fields/renderers/DateField";
import { BooleanField } from "../components/fields/renderers/BooleanField";
import { EnumField } from "../components/fields/renderers/EnumField";
import { FkSelectField } from "../components/fields/renderers/FkSelectField";
import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField";
import { extractTokens, extractLocalParts, extractDomains, stripNonDigits } from "../utils/filter-utils";
function parseError(e: any): string {
if (e.response?.data) {
const data = e.response.data;
if (Array.isArray(data)) {
return data.map((err: any) => err.msg ?? String(err)).join("; ");
}
if (typeof data.detail === "string") {
return data.detail;
}
}
return e.message ?? "An error occurred";
}
interface ResourceState {
loading: boolean;
error: string | null;
}
export interface FilterComponentProps {
value: string;
onChange: (v: string) => void;
data?: any[];
labelOverride?: string;
}
interface StreamHandlers {
onEvent: (data: any) => void;
onError?: (evt: Event) => void;
onOpen?: () => void;
}
interface StreamSubscription {
close: () => void;
}
interface UseResourceReturn {
resource: ResourceConfig;
components: Record<string, React.FC<FilterComponentProps>>;
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
get: (id: string | number, params?: Record<string, any>) => Promise<any>;
create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
loading: boolean;
error: string | null;
}
const _fkOptionsCache = new Map<string, { value: any; label: string }[]>();
const _stringOptionsCache = new Map<string, string[]>();
const _sseEventCache = new Map<string, any[]>();
let _sseSeq = 0;
export function readSseCache(resourceName: string): any[] {
return _sseEventCache.get(resourceName) ?? [];
}
export function appendSseCache(resourceName: string, event: any): any[] {
const events = _sseEventCache.get(resourceName) ?? [];
events.push(event);
if (events.length > 100) events.splice(0, events.length - 100);
_sseEventCache.set(resourceName, events);
return events;
}
export function clearSseCache(resourceName: string): void {
_sseEventCache.delete(resourceName);
}
export function nextSseSeq(): number {
return ++_sseSeq;
}
const _sseConnection = new Map<string, boolean>();
const _sseListeners = new Map<string, Set<() => void>>();
export function setSseConnected(resourceName: string, connected: boolean): void {
if (_sseConnection.get(resourceName) === connected) return;
_sseConnection.set(resourceName, connected);
_sseListeners.get(resourceName)?.forEach((cb) => cb());
}
export function getSseConnected(resourceName: string): boolean {
return _sseConnection.get(resourceName) ?? false;
}
export function useSseConnected(resourceName: string): boolean {
const [connected, setConnected] = useState(() => getSseConnected(resourceName));
useEffect(() => {
const cb = () => setConnected(getSseConnected(resourceName));
const listeners = _sseListeners.get(resourceName) ?? new Set();
listeners.add(cb);
_sseListeners.set(resourceName, listeners);
return () => {
listeners.delete(cb);
if (listeners.size === 0) _sseListeners.delete(resourceName);
};
}, [resourceName]);
return connected;
}
function extractDataOptions(data: any[], fieldName: string): string[] {
const values = new Set<string>();
for (const row of data) {
const v = row[fieldName];
if (v != null && v !== "") {
values.add(String(v));
}
}
return [...values].sort();
}
function applyDisplayFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
}
function buildFilterComponent(field: FieldConfig, resourceName: string): React.FC<FilterComponentProps> {
if (field.type === "boolean") {
return ({ value, onChange, labelOverride }) => (
<BooleanField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
value={value}
onChange={(v) => onChange(v ?? "")}
nullable
/>
);
}
if (field.fk) {
const FkFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const targetRes = resources.find((r) => r.name === field.fk!.resource);
const [options, setOptions] = useState<{ value: any; label: string }[]>([]);
const fetched = useRef(false);
useEffect(() => {
if (filterMode === "client" && data && targetRes) {
const seen = new Set<string>();
const opts: { value: any; label: string }[] = [];
for (const row of data) {
const items = Array.isArray(row[field.name]) ? row[field.name] : [row[field.name]];
for (const item of items) {
if (item == null) continue;
const label = applyDisplayFormat(item, targetRes.displayFormat);
if (!seen.has(label)) {
seen.add(label);
opts.push({ value: label, label });
}
}
}
opts.sort((a, b) => a.label.localeCompare(b.label));
setOptions(opts);
fetched.current = true;
} else if (filterMode === "server" && targetRes && !fetched.current) {
const cacheKey = targetRes.name;
if (_fkOptionsCache.has(cacheKey)) {
setOptions(_fkOptionsCache.get(cacheKey)!);
fetched.current = true;
} else {
(async () => {
try {
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) params.limit = 0;
const res = await api.get(targetRes.path, { params });
let items: any[];
if (targetRes.pagination) {
items = res.data.items ?? [];
} else {
items = Array.isArray(res.data) ? res.data : [];
}
const opts = items.map((item: any) => {
const label = applyDisplayFormat(item, targetRes.displayFormat);
return { value: label, label };
});
_fkOptionsCache.set(cacheKey, opts);
setOptions(opts);
fetched.current = true;
} catch {
fetched.current = true;
}
})();
}
}
}, [filterMode, data, targetRes]);
if (field.isArray) {
const selected = value ? value.split(",").filter(Boolean) : [];
return (
<FkMultiSelectField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
value={selected}
onChange={(v: any[]) => onChange(v.join(","))}
fkOptions={options}
/>
);
}
return (
<FkSelectField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
value={value}
onChange={(v: any) => onChange(v ?? "")}
fkOptions={options}
/>
);
};
return FkFilter;
}
if (field.enumValues) {
const EnumFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const dataOptions = useMemo(() => {
if (!data) return [];
return extractDataOptions(data, field.name);
}, [data]);
const merged = useMemo(
() => [...new Set([...(field.enumValues ?? []), ...dataOptions])],
[dataOptions]
);
return (
<EnumField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label, enumValues: merged }}
value={value}
onChange={(v) => onChange(v ?? "")}
/>
);
};
return EnumFilter;
}
if (field.type === "integer" || field.type === "number") {
return ({ value, onChange, labelOverride }) => (
<NumberField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
value={value}
onChange={(v) => onChange(v === "" ? "" : String(v))}
/>
);
}
if (field.format === "date" || field.format === "date-time") {
return ({ value, onChange, labelOverride }) => (
<DateField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
value={value}
onChange={(v) => onChange(v ?? "")}
/>
);
}
// ── text filter (freeSolo, no dropdown) ──────────────────────
function buildTextFilter() {
const TextFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
return (
<Autocomplete
freeSolo
size="small"
options={[]}
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
/>
);
};
return TextFilter;
}
// ── token filter (multi-select, suggestions from current page) ─
function buildTokenFilter() {
const TokenFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
const pageTokens = useMemo(() => data ? extractTokens(data, field.name) : [], [data]);
const selected = value ? value.split(",").filter(Boolean) : [];
const sortedOptions = useMemo(() => {
const sel = new Set(selected);
const picked: string[] = [];
const rest: string[] = [];
for (const t of pageTokens) {
(sel.has(t) ? picked : rest).push(t);
}
return [...picked, ...rest];
}, [pageTokens, selected]);
const displayOptions = open ? frozenOpts : sortedOptions;
return (
<Autocomplete
multiple
freeSolo
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={open}
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
onClose={(_, reason) => {
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
}}
inputValue={inputValue}
onInputChange={(_, v, reason) => {
if (reason !== "reset") setInputValue(v);
}}
options={displayOptions}
value={selected}
onChange={(_, newVal) => onChange(newVal.join(","))}
filterOptions={(opts, { inputValue }) => {
if (!inputValue) return [];
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
}}
renderOption={(props, option, { selected: isSelected }) => {
const { key, ...rest } = props as any;
return (
<li key={key} {...rest}>
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
return TokenFilter;
}
// ── email filter (two-step local→domain, no server fetch) ───
function buildEmailFilter() {
const COMMON_DOMAINS = ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "protonmail.com", "aol.com", "mail.com", "zoho.com", "yandex.com"];
const EmailFilter: React.FC<FilterComponentProps> = ({ value, onChange, labelOverride }) => {
const [step, setStep] = useState<"local" | "domain">("local");
const [pendingLocal, setPendingLocal] = useState<string | null>(null);
const selected = value ? value.split(",").filter(Boolean) : [];
const handleChange = (_: any, newVal: string[], reason: string, details: any) => {
if (reason === "removeOption") {
if (step === "domain") { setStep("local"); setPendingLocal(null); }
onChange(newVal.join(","));
return;
}
if (reason !== "selectOption" || !details?.option) return;
if (step === "local") {
setPendingLocal(String(details.option));
setStep("domain");
} else if (step === "domain" && pendingLocal) {
const email = `${pendingLocal}@${String(details.option)}`;
onChange([...selected, email].join(","));
setStep("local"); setPendingLocal(null);
}
};
return (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={step === "domain"}
onClose={() => { if (step === "domain") { setStep("local"); setPendingLocal(null); } }}
options={step === "domain" ? COMMON_DOMAINS : []}
value={selected}
onChange={handleChange}
inputValue={pendingLocal ? `${pendingLocal}@` : undefined}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 18 ? `${tag.slice(0, 16)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={labelOverride ?? field.label}
placeholder={step === "domain" && pendingLocal ? "Select email domain..." : undefined}
size="small"
/>
)}
/>
);
};
return EmailFilter;
}
// ── phone filter (multi-select, digits only, suggestions from current page) ─
function buildPhoneFilter() {
const PhoneFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const [inputValue, setInputValue] = useState("");
const [open, setOpen] = useState(false);
const [frozenOpts, setFrozenOpts] = useState<string[]>([]);
const pageTokens = useMemo(() => {
if (!data) return [];
const tokens = new Set<string>();
for (const row of data) {
const val = row[field.name];
if (val != null && val !== "") {
const digits = stripNonDigits(String(val));
if (digits) tokens.add(digits);
}
}
return [...tokens].sort();
}, [data]);
const selected = value ? value.split(",").filter(Boolean) : [];
const sortedOptions = useMemo(() => {
const sel = new Set(selected);
const picked: string[] = [];
const rest: string[] = [];
for (const t of pageTokens) {
(sel.has(t) ? picked : rest).push(t);
}
return [...picked, ...rest];
}, [pageTokens, selected]);
const displayOptions = open ? frozenOpts : sortedOptions;
return (
<Autocomplete
multiple
freeSolo
size="small"
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
open={open}
onOpen={() => { setFrozenOpts(sortedOptions); setOpen(true); }}
onClose={(_, reason) => {
if (reason === "escape" || reason === "blur") { setOpen(false); setInputValue(""); }
}}
inputValue={inputValue}
onInputChange={(_, v, reason) => {
if (reason !== "reset") setInputValue(v);
}}
options={displayOptions}
value={selected}
onChange={(_, newVal) => onChange(newVal.map((v) => stripNonDigits(v)).filter(Boolean).join(","))}
filterOptions={(opts, { inputValue }) => {
if (!inputValue) return [];
return opts.filter((o) => o.toLowerCase().includes(inputValue.toLowerCase()));
}}
renderOption={(props, option, { selected: isSelected }) => {
const { key, ...rest } = props as any;
return (
<li key={key} {...rest}>
{isSelected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
return PhoneFilter;
}
// ── routing ──────────────────────────────────────────────────
if (field.autocomplete) {
switch (field.autocomplete) {
case "text": return buildTextFilter();
case "token": return buildTokenFilter();
case "email": return buildEmailFilter();
case "phone": return buildPhoneFilter();
}
}
if (field.refSchema && field.inlineDisplayFormat) {
const RefFilter: React.FC<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const [options, setOptions] = useState<string[]>([]);
const fetched = useRef(false);
useEffect(() => {
const extract = (items: any[]) => {
const vals = new Set<string>();
for (const row of items) {
const val = row[field.name];
if (val == null || typeof val !== "object") continue;
const v = field.inlineDisplayFormat!.replace(/\{(\w+)\}/g, (_: string, key: string) => String(val[key] ?? ""));
if (v) vals.add(v);
}
return [...vals].sort();
};
if (filterMode === "client" && data) {
setOptions(extract(data));
fetched.current = true;
} else if (filterMode === "server" && !fetched.current) {
(async () => {
try {
const api = getApi();
const selfRes = resources.find((r) => r.name === resourceName);
if (!selfRes) { fetched.current = true; return; }
const params: Record<string, any> = {};
if (selfRes.pagination) params.limit = 0;
const res = await api.get(selfRes.path, { params });
const items = selfRes.pagination
? (Array.isArray(res.data) ? res.data : (res.data.items ?? []))
: (Array.isArray(res.data) ? res.data : []);
setOptions(extract(items));
fetched.current = true;
} catch { fetched.current = true; }
})();
}
}, [data]);
return (
<Autocomplete
freeSolo
size="small"
options={options}
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
<TextField {...params} label={labelOverride ?? field.label} size="small" />
)}
/>
);
};
return RefFilter;
}
return ({ value, onChange, labelOverride }) => (
<StringField
field={{ ...field, readOnly: false, description: "", label: labelOverride ?? field.label }}
value={value}
onChange={(v) => onChange(v ?? "")}
/>
);
}
export function useResource(resourceName: string): UseResourceReturn {
const { resources, schemas } = useAppContext();
const resource = resources.find((r) => r.name === resourceName);
const [state, setState] = useState<ResourceState>({ loading: false, error: null });
const rPath = resource?.path;
const rPagination = resource?.pagination;
const rUpdateMethod = resource?.updateMethod;
const rStreaming = resource?.streaming;
const rFields = resource?.fields;
const setLoading = useCallback((loading: boolean) => {
setState((s) => ({ ...s, loading }));
}, []);
const setError = useCallback((error: string | null) => {
setState((s) => ({ ...s, error }));
}, []);
const list = useCallback(
async (params?: Record<string, any>): Promise<ParsedListResponse> => {
if (!rPath) return { items: [] };
setLoading(true);
setError(null);
try {
const api = getApi();
const res = await api.get(rPath, { params });
const data = res.data;
if (rPagination) {
if (Array.isArray(data)) {
return { items: data };
}
if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
throw new Error(`Expected paginated response { total, items } from ${rPath}`);
}
return { items: data.items, total: data.total ?? data.items.length };
}
if (!Array.isArray(data)) {
throw new Error(`Expected array response from ${rPath}`);
}
return { items: data };
} catch (e: any) {
const msg = parseError(e);
setError(msg);
return { items: [] };
} finally {
setLoading(false);
}
},
[rPath, rPagination, setLoading, setError]
);
const get = useCallback(
async (id: string | number, params?: Record<string, any>): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
setLoading(true);
setError(null);
try {
const api = getApi();
const res = await api.get(`${rPath}/${id}`, { params });
return res.data;
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, setLoading, setError]
);
const resolveFk = useCallback(async (resourceName: string, id: any) => {
const targetRes = resources.find((r) => r.name === resourceName);
if (!targetRes) return id;
try {
const api = getApi();
const res = await api.get(`${targetRes.path}/${id}`);
return res.data;
} catch {
return id;
}
}, [resources]);
const create = useCallback(
async (data: any): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
setLoading(true);
setError(null);
try {
const api = getApi();
const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.post(rPath, sanitized);
return res.data;
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, rFields, schemas, resolveFk, setLoading, setError]
);
const update = 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 method = rUpdateMethod ?? "put";
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, sanitized);
return res.data;
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, rFields, schemas, resolveFk, rUpdateMethod, setLoading, setError]
);
const remove = useCallback(
async (id: string | number): Promise<void> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
setLoading(true);
setError(null);
try {
const api = getApi();
await api.delete(`${rPath}/${id}`);
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, setLoading, setError]
);
const stream = useCallback(
(handlers: StreamHandlers, pathParams?: Record<string, string | number>): StreamSubscription => {
if (!rPath || !rStreaming) {
throw new Error(`Resource "${resourceName}" does not support streaming`);
}
const api = getApi();
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
let resolvedPath = rPath;
if (pathParams) {
for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(`{${key}}`, String(value));
}
}
const url = baseUrl + resolvedPath;
const es = new EventSource(url);
es.onopen = () => handlers.onOpen?.();
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
handlers.onEvent(data);
} catch {
// ignore malformed JSON payloads
}
};
es.onerror = (e) => {
handlers.onError?.(e);
};
return { close: () => es.close() };
},
[rPath, rStreaming, resourceName]
);
const components = useMemo(
() => {
const map: Record<string, React.FC<FilterComponentProps>> = {};
if (!rFields) return map;
for (const field of rFields) {
map[field.name] = buildFilterComponent(field, resourceName);
}
return map;
},
[rFields, resourceName]
);
if (!resource) {
return {
resource: null as unknown as ResourceConfig,
components,
list,
get,
create,
update,
remove,
stream: undefined,
loading: false,
error: null,
};
}
return { resource, components, list, get, create, update, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
}

View File

@@ -0,0 +1,31 @@
import axios, { AxiosInstance } from "axios";
let apiClient: AxiosInstance | null = null;
export function initApi(baseUrl: string, getToken?: () => string | null): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) {
return apiClient;
}
apiClient = axios.create({
baseURL: baseUrl,
headers: { "Content-Type": "application/json" },
});
apiClient.interceptors.request.use((config) => {
const token = getToken?.();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
return apiClient;
}
export function getApi(): AxiosInstance {
if (!apiClient) {
throw new Error("API client not initialized. Make sure AppProvider is mounted.");
}
return apiClient;
}

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { getApi } from "./useApi";
interface ItemSseHandlers {
onEvent: (data: any) => void;
onOpen?: () => void;
onError?: (evt: Event) => void;
}
interface ItemSseResult {
connected: boolean;
events: any[];
}
export function useItemSse(url: string | null, handlers: ItemSseHandlers): ItemSseResult {
const [connected, setConnected] = useState(false);
const [events, setEvents] = useState<any[]>([]);
const handlersRef = useRef(handlers);
handlersRef.current = handlers;
useEffect(() => {
if (!url) return;
const api = getApi();
const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
const fullUrl = baseUrl + url;
const es = new EventSource(fullUrl);
es.onopen = () => {
setConnected(true);
handlersRef.current.onOpen?.();
};
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
setEvents((prev) => [...prev, data]);
handlersRef.current.onEvent(data);
} catch {
// ignore malformed JSON
}
};
es.onerror = (e) => {
setConnected(false);
handlersRef.current.onError?.(e);
};
return () => {
es.close();
setConnected(false);
};
}, [url]);
const clearEvents = useCallback(() => setEvents([]), []);
return { connected, events };
}

View File

@@ -0,0 +1,17 @@
import * as yaml from "js-yaml";
import type { OpenApiSpec } from "./types";
export async function loadSpec(url: string): Promise<OpenApiSpec> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch spec: ${response.status} ${response.statusText}`);
}
const text = await response.text();
const parsed = yaml.load(text);
if (!parsed || typeof parsed !== "object") {
throw new Error("Spec is empty or not an object");
}
return parsed as OpenApiSpec;
}

View File

@@ -0,0 +1,140 @@
import type { OpenApiSpec, ValidationMessage, SpecConfiguration } from "./types";
function getSegments(path: string): string[] {
return path.split("/").filter(Boolean);
}
function getResponseSchemaRef(pathObj: any): string | undefined {
const response = pathObj?.get?.responses?.["200"] ?? pathObj?.get?.responses?.["201"]
?? pathObj?.post?.responses?.["200"] ?? pathObj?.post?.responses?.["201"];
const content = response?.content;
if (!content) return;
for (const mediaType of Object.values(content) as any[]) {
if (mediaType?.schema?.$ref) return mediaType.schema.$ref;
if (mediaType?.schema?.items?.$ref) return mediaType.schema.items.$ref;
if (mediaType?.schema?.properties?.items?.items?.$ref) return mediaType.schema.properties.items.items.$ref;
}
}
export function validateSpec(spec: OpenApiSpec, specConfig?: SpecConfiguration): ValidationMessage[] {
const messages: ValidationMessage[] = [];
const schemas = (spec.components?.schemas ?? {}) as Record<string, any>;
const paths = spec.paths ?? {};
if (!spec.openapi) {
messages.push({ type: "error", message: "Missing 'openapi' version field" });
}
if (!spec.info?.title) {
messages.push({ type: "error", message: "Missing 'info.title'" });
}
if (!spec.servers?.[0]?.url && !specConfig?.baseApiUrl) {
messages.push({ type: "warning", message: "No 'servers[0].url' defined — provide 'baseApiUrl' in specConfiguration" });
}
for (const [path, pathObj] of Object.entries(paths) as [string, any][]) {
if (!pathObj || typeof pathObj !== "object") continue;
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;
const hasSSE = pathObj?.get?.["x-sse"] === true;
if (hasSSE) continue;
// 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 (isItemPath || isSubResource) {
const responseRef = getResponseSchemaRef(pathObj);
if (responseRef) {
const schemaName = responseRef.split("/").pop()!;
if (!schemas[schemaName]) {
messages.push({ type: "error", message: `Path "${path}" references schema "${schemaName}" which does not exist in components/schemas` });
}
}
continue;
}
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 (!schema["x-primary-key"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-primary-key'` });
}
if (!schema["x-display-format"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-display-format'` });
}
if (!schema["x-list-columns"]) {
messages.push({ type: "error", message: `Schema "${schemaName}" is missing 'x-list-columns'` });
}
if (Array.isArray(schema["x-list-columns"])) {
const props = schema.properties ?? {};
for (const col of schema["x-list-columns"]) {
if (!props[col]) {
messages.push({ type: "error", message: `"${schemaName}.x-list-columns" references "${col}" but no such property exists` });
}
}
}
const props = schema.properties ?? {};
for (const [propName, _raw] of Object.entries(props)) {
const prop = _raw as any;
if (!prop || typeof prop !== "object") continue;
if (!prop["x-label"]) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-label'` });
}
if (prop["x-order"] === undefined || prop["x-order"] === null) {
messages.push({ type: "error", message: `Property "${schemaName}.${propName}" is missing 'x-order'` });
}
if (prop["$ref"] && !prop["x-fk"]) {
const refName = (prop["$ref"] as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" uses $ref to "${refName}" without x-fk — will render inline` });
}
if (prop.type === "array" && prop.items?.$ref && !prop["x-fk"]) {
const refName = (prop.items.$ref as string).split("/").pop();
messages.push({ type: "info", message: `"${schemaName}.${propName}" is an array of $ref to "${refName}" without x-fk — will render inline` });
}
if (prop["x-fk"]) {
const fkResource = prop["x-fk"].resource as string;
const fkPaths = Object.keys(paths).filter((p) => !/^\{.*\}$/.test(getSegments(p).pop() ?? ""));
const targetExists = fkPaths.some((p) => getSegments(p).pop() === fkResource);
if (!targetExists) {
messages.push({ type: "error", message: `"${schemaName}.${propName}" x-fk references resource "${fkResource}" but no path matches that resource name` });
}
}
}
}
if (!pathObj?.get) {
messages.push({ type: "error", message: `"${path}" has no GET list endpoint — datatable cannot be populated` });
}
const listParams = pathObj?.get?.parameters ?? [];
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");
if (limitParam || offsetParam) {
if (!limitParam?.schema?.default) {
messages.push({ type: "error", message: `"${path}.get" has pagination params but 'limit' schema is missing 'default'` });
}
}
if (!pathObj?.post) {
messages.push({ type: "error", message: `"${path}" has no POST endpoint — creation not possible` });
}
}
return messages;
}

View File

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

View File

@@ -0,0 +1,27 @@
import type { FKFieldConfig, ResourceRelationship } from "../types";
export function extractRelationships(schema: any, schemas: Record<string, any>): ResourceRelationship[] {
const props = schema.properties ?? {};
const rels: ResourceRelationship[] = [];
for (const [name, _raw] of Object.entries(props)) {
const prop = _raw as any;
if (!prop || typeof prop !== "object") continue;
if (!prop["x-fk"]) continue;
const fkResource = prop["x-fk"].resource as string;
const prefetch = prop["x-fk"].prefetch ?? false;
rels.push({
fieldName: name,
config: {
resource: fkResource,
prefetch,
},
targetSchemaName: fkResource,
});
}
return rels;
}

View File

@@ -0,0 +1,200 @@
import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
import { extractFields, clearValidationErrors, getValidationErrors } from "./field-config";
import { extractRelationships } from "./relationship-config";
function detectPagination(pathObj: any): { limitParam: string; offsetParam: string; defaultLimit: number } | null {
const params = pathObj?.get?.parameters ?? [];
const limit = params.find((p: any) => p.in === "query" && p.name === "limit");
const offset = params.find((p: any) => p.in === "query" && p.name === "offset");
if (limit && offset) {
return {
limitParam: "limit",
offsetParam: "offset",
defaultLimit: limit.schema.default,
};
}
return null;
}
function hasOperation(pathObj: any, method: string): boolean {
return !!pathObj?.[method];
}
function sortFields(fields: FieldConfig[]): FieldConfig[] {
return [...fields].sort((a, b) => {
const orderDiff = a.order - b.order;
if (orderDiff !== 0) return orderDiff;
return a.name.localeCompare(b.name);
});
}
function formatDisplayName(name: string): string {
return name.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
}
const SSE_RECEIVED_FIELD: FieldConfig = {
name: "_received_at",
label: "Received",
description: "Timestamp when the event was received",
type: "string",
format: "date-time",
order: 0,
hidden: {},
filterable: false,
sortable: true,
readOnly: true,
required: 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[] {
clearValidationErrors();
const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {};
const configs: ResourceConfig[] = [];
const nameMap = new Map<string, ResourceConfig>();
const sortedPaths = Object.keys(paths).sort(
(a, b) => getSegments(a).length - getSegments(b).length
);
for (const path of sortedPaths) {
const segments = getSegments(path);
const pathObj = paths[path];
// Skip paths where ALL non-parameter operations are profile-only (x-profile: true).
// These are handled by Admin.tsx profile routes, not as CRUD resources.
const pathMethods = Object.entries(pathObj).filter(([k]) => !k.startsWith("x-") && k !== "parameters");
const allProfile = pathMethods.length > 0 && pathMethods.every(([, op]: any) => op["x-profile"] === true);
if (allProfile) continue;
const lastSeg = segments[segments.length - 1];
const isItemPath = /^\{.*\}$/.test(lastSeg);
const paramIdx = segments.findIndex(
(s, i) => i < segments.length - 1 && /^\{.*\}$/.test(s)
);
if (isItemPath) {
const parentName = segments[segments.length - 2];
const parent = nameMap.get(parentName);
if (!parent) continue;
if (hasOperation(pathObj, "get")) parent.operations.get = true;
if (hasOperation(pathObj, "put") || hasOperation(pathObj, "patch")) parent.operations.update = true;
if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
if (hasOperation(pathObj, "patch") && !hasOperation(pathObj, "put")) parent.updateMethod = "patch";
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, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, delete: false },
updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj),
relationships: [],
streaming: hasSSE || undefined,
parent: { resource: parentName, pathParam: pathParamName },
};
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) {
resource.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at";
}
const parent = nameMap.get(parentName);
if (parent) {
parent.subResources = parent.subResources ?? [];
parent.subResources.push(resourceName);
}
nameMap.set(resourceName, resource);
configs.push(resource);
continue;
}
const resourceName = lastSeg;
const responseRef = getResponseSchemaRef(pathObj);
const schemaName = responseRef ? resolveRef(responseRef) : undefined;
const schema = schemaName ? schemas[schemaName] : undefined;
const fields = schema ? extractFields(schemaName!, schema, schemas) : [];
const relationships = schema ? extractRelationships(schema, schemas) : [];
const hasSSE = pathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = {
name: resourceName,
schemaName: schemaName ?? resourceName,
displayName: formatDisplayName(resourceName),
path,
primaryKey: schema?.["x-primary-key"] ?? "id",
displayFormat: schema?.["x-display-format"] ?? `{${resourceName}}`,
listColumns: schema?.["x-list-columns"] ?? [],
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [],
operations: hasSSE
? { list: true, get: false, create: false, update: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, delete: false },
updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj),
relationships,
streaming: hasSSE || undefined,
};
resource.orderedFields = sortFields(resource.fields);
if (hasSSE) {
resource.listColumns = ["_received_at", ...resource.listColumns];
resource.primaryKey = "_received_at";
}
nameMap.set(resourceName, resource);
configs.push(resource);
}
const errors = getValidationErrors();
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
return configs;
}

134
react-openapi/src/types.ts Normal file
View File

@@ -0,0 +1,134 @@
export type FilterMode = "client" | "server";
export interface ResourceConfiguration {
filterOptions?: {
mode?: FilterMode;
};
}
export interface ProfileComponents {
create?: React.ComponentType<any>;
edit?: React.ComponentType<any>;
view?: React.ComponentType<any>;
}
export interface SpecConfiguration {
specUrl: string;
baseApiUrl?: string;
title?: string;
getToken?: () => string | null;
resourceConfig?: Record<string, ResourceConfiguration>;
profileComponents?: ProfileComponents;
}
/** Auth server config extracted from securitySchemes extensions. */
export interface AuthConfig {
serverUrl: string;
loginPath: string;
registerPath: string;
logoutPath: string;
mePath: string;
introspectPath: string;
}
/** Represents a single operation marked with `x-profile: true` in the spec. */
export interface ProfileOperation {
path: string;
method: string;
operationId: string;
summary?: string;
}
export interface ValidationMessage {
type: "error" | "warning" | "info";
message: string;
}
export interface ResourceRelationship {
fieldName: string;
config: FKFieldConfig;
targetSchemaName: string;
}
export interface ResourceConfig {
name: string;
schemaName: string;
displayName: string;
path: string;
primaryKey: string;
displayFormat: string;
listColumns: string[];
fields: FieldConfig[];
orderedFields: FieldConfig[];
operations: {
list: boolean;
get: boolean;
create: boolean;
update: boolean;
delete: boolean;
};
updateMethod: "put" | "patch";
pagination: {
limitParam: string;
offsetParam: string;
defaultLimit: number;
} | null;
relationships: ResourceRelationship[];
streaming?: boolean;
parent?: { resource: string; pathParam: string };
subResources?: string[];
}
export interface FKFieldConfig {
resource: string;
prefetch: boolean;
}
export interface OneOfOption {
value: string;
label: string;
fields: FieldConfig[];
}
export interface FieldConfig {
name: string;
label: string;
description: string;
type: string;
format?: string;
order: number;
hidden: { form?: boolean; list?: boolean; detail?: boolean };
filterable: boolean;
sortable: boolean;
readOnly: boolean;
required: boolean;
enumValues?: string[];
fk?: FKFieldConfig;
uiType?: string;
uploadUrl?: string;
upload?: { type: string; url: string };
refSchema?: string;
inlineDisplayFormat?: string;
isArray: boolean;
oneOfOptions?: OneOfOption[];
discriminatorProperty?: string;
autocomplete?: "text" | "token" | "email" | "phone";
}
export interface OpenApiSpec {
openapi: string;
info: {
title: string;
version: string;
};
servers?: { url: string }[];
components?: {
schemas?: Record<string, any>;
};
paths?: Record<string, any>;
}
export interface ParsedListResponse {
total?: number;
items?: any[];
}

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

@@ -1,65 +0,0 @@
export type FieldType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'datetime'
| 'markdown'
| 'enum'
| 'image'
| 'object'
| 'array';
export interface SelectOption {
key: string;
value: string;
}
export interface EnumOption {
key: string;
value: string;
}
export interface ResourceField {
displayFormat: string;
type: FieldType;
label: string;
required?: boolean;
options?: string[];
readOnly?: boolean;
schema?: Record<string, ResourceField>;
formatter?: (value: any) => string;
relation?: string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
}
export type ResourceMode = "server" | "client";
export interface ResourceConfig {
name: string;
label: string;
pluralLabel: string;
endpoint: string;
primaryKey: string;
fields: Record<string, ResourceField>;
pagination?: boolean;
hidden?: boolean;
filterOptions?: {
mode?: ResourceMode;
fields?: string[];
};
enumOption?: EnumOption;
}
export interface AppConfig {
baseUrl: string;
authBaseUrl: string;
resources: ResourceConfig[];
enums: Record<string, string[]>;
profile?: {
resource: string;
extraFields?: Record<string, any>;
};
}

View File

@@ -1,86 +0,0 @@
import { ResourceField, FieldType } from './config';
export interface EnumOption {
key: string;
value: string;
}
export interface FieldOverride {
displayFormat?: string;
display?: boolean;
formatter?: (value: any) => string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumLabels?: Record<string, string>;
// New optional properties to support custom config extensions
path?: string;
refers?: string;
}
export interface ResourceOverride {
fields?: Record<string, FieldOverride>;
pagination?: boolean;
hidden?: boolean;
filterOptions?: {
mode?: "server" | "client";
fields?: string[];
};
enumOption?: EnumOption;
// New optional property for referencetype resources
referenceOptions?: {
enumOption?: EnumOption;
autoComplete?: boolean;
prefetch?: boolean;
};
}
export interface FieldComponentProps {
name: string;
field: ResourceField;
value: any;
onChange: (val: any) => void;
disabled?: boolean;
error?: string;
baseUrl?: string;
relationDataMap?: Record<string, any[]>;
uploadFile?: (file: File) => Promise<string | null>;
uploading?: boolean;
}
export type FieldComponent = React.ComponentType<FieldComponentProps>;
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
relation?: FieldComponent;
image?: FieldComponent;
default?: FieldComponent;
dateRange?: FieldComponent;
numberRange?: FieldComponent;
FormField?: React.ComponentType<any>;
GenericForm?: React.ComponentType<any>;
};
export interface CellRendererProps {
value: any;
row: any;
field: ResourceField;
fieldKey: string;
config: import('./config').ResourceConfig;
onNavigate?: (resourceName: string, id: string) => void;
isMobile?: boolean;
}
export type CellRenderer = React.ComponentType<CellRendererProps>;
export interface EnhancedTableComponents {
cellRenderers?: Partial<Record<FieldType, CellRenderer>>;
}
export interface FilterBarComponents {
filterInputs?: Record<string, React.ComponentType<{
field: ResourceField;
value: any;
onChange: (val: any) => void;
options: string[];
}>>;
}
export type { FieldType };

View File

@@ -1,249 +0,0 @@
import SwaggerParser from "@apidevtools/swagger-parser";
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
/**
* Maps OpenAPI property types to our internal FieldType
*/
function mapOpenApiType(prop: any): FieldType {
const type = prop.type;
const format = prop.format;
if (format === "date-time") return "datetime";
if (format === "date") return "date";
if (prop.enum) return "enum";
if (
type === "string" &&
(prop.description?.toLowerCase().includes("image") ||
prop.name?.toLowerCase().includes("icon"))
)
return "image";
switch (type) {
case "integer":
case "number":
return "number";
case "boolean":
return "boolean";
case "object":
return "object";
case "array":
return "array";
default:
return "string";
}
}
/**
* Recursively converts OpenAPI schemas to ResourceField map
*/
function mergeProperties(schema: any): { properties: Record<string, any>; required: string[] } {
let properties: Record<string, any> = {};
let required: string[] = [];
if (schema.allOf) {
for (const sub of schema.allOf) {
const merged = mergeProperties(sub);
properties = { ...properties, ...merged.properties };
required = [...required, ...merged.required];
}
}
if (schema.properties) {
properties = { ...properties, ...schema.properties };
}
if (schema.required) {
required = [...required, ...schema.required];
}
return { properties, required };
}
function parseSchemaFields(
schema: any,
resourceName: string,
schemaToResourceMap: Map<any, string>,
configuration: Record<string, any> = {}
): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {};
const { properties, required } = mergeProperties(schema);
const overrides = configuration[resourceName]?.fields || {};
console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
for (const [key, prop] of Object.entries(properties) as [string, any]) {
// Resolve oneOf/anyOf by merging all branch properties
let resolvedProp = prop;
if (prop.oneOf || prop.anyOf) {
const branches = prop.oneOf || prop.anyOf;
const merged = mergeProperties({ allOf: branches });
resolvedProp = { ...prop, type: 'object', properties: merged.properties, required: merged.required };
}
const type = mapOpenApiType(resolvedProp);
if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) {
throw new Error(
`OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` +
`Add an "enum" array with at least one value to the OpenAPI schema definition.`
);
}
const override = overrides[key];
// Explicitly skip 'id' as it's the primary key and handled elsewhere
if (key === "id" || override?.display === false) continue;
fields[key] = {
type,
label:
resolvedProp.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key),
options: resolvedProp.enum,
readOnly:
resolvedProp.readOnly ||
key === "created_at" ||
key === "updated_at",
...override,
};
// STRICT RELATION DETECTION
// A field is a relation ONLY if its schema object (or items schema)
// exactly matches a schema that is defined as a resource.
let targetSchema = resolvedProp;
if (type === "array" && resolvedProp.items) {
targetSchema = resolvedProp.items;
}
// Check if this schema object is registered as a resource
const relation = schemaToResourceMap.get(targetSchema);
if (relation) {
fields[key].relation = relation;
// Propagate enumOption from target resource config, or derive from target schema
const explicitEnumOption = configuration[relation].referenceOptions.enumOption;
console.log('if relation configuration...', configuration['accounts']['referenceOptions'])
if (explicitEnumOption) {
fields[key].enumOption = explicitEnumOption;
} else {
// No explicit enumOption supplied this is a configuration error.
// We abort loading so the problem is visible immediately.
throw new Error(
`Missing enumOption for relation "${relation}" on field "${key}". ` +
`Define referenceOptions.enumOption in the configuration for resource "${relation}".`
);
}
}
// Recursively parse nested objects (only if not a relation)
if (fields[key].type === "object" && resolvedProp.properties && !relation) {
console.log('recursive configuration...', configuration['accounts']['referenceOptions'])
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
}
}
return fields;
}
/**
* Scans paths to identify resources and their basic configuration
*/
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
console.log('init configuration...', configuration['accounts']['referenceOptions'])
// Use SwaggerParser to dereference the spec.
// Dereferencing preserves object identity for $ref targets.
const api = await SwaggerParser.dereference(
new URL("/openapi.json", baseUrl).href
);
const resources: ResourceConfig[] = [];
const paths = api.paths || {};
// Group paths by base resource name
const resourcePaths: Record<string, any> = {};
for (const path of Object.keys(paths)) {
const base = path.split("/")[1];
if (!base) continue;
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
const methods = Object.keys(paths[path] || {});
resourcePaths[base].methods.push(...methods);
// Identify the list endpoint for this resource
if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) {
resourcePaths[base].listPath = path;
}
}
// 1. Identify which schema objects correspond to which resources
const schemaToResourceMap = new Map<any, string>();
for (const [name, info] of Object.entries(resourcePaths)) {
const listPath = info.listPath || `/${name}`;
const listOp = paths[listPath]?.get;
if (!listOp) continue;
// @ts-ignore
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
let schemaObj = responseSchema;
if (responseSchema?.type === "array" && responseSchema.items) {
schemaObj = responseSchema.items;
}
if (schemaObj) {
schemaToResourceMap.set(schemaObj, name);
resourcePaths[name].schemaObj = schemaObj;
}
}
// 2. Generate ResourceConfig for each identified resource
for (const [name, info] of Object.entries(resourcePaths)) {
const listPath = info.listPath || `/${name}`;
const listOp = paths[listPath]?.get;
if (!listOp || !info.schemaObj) continue;
const schema = info.schemaObj;
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
const resourceOverride = configuration[name] || {};
const fo = resourceOverride.filterOptions || {};
resources.push({
name,
label: schema.title || label,
pluralLabel: pluralLabel,
endpoint: listPath,
primaryKey: "id",
fields,
pagination: resourceOverride.pagination,
hidden: resourceOverride.hidden,
filterOptions: {
mode: fo.mode || "server",
fields: fo.fields,
},
});
}
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
const enums: Record<string, string[]> = {};
const apiDoc = api as any;
if (apiDoc.components?.schemas) {
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
if (schema.enum) {
enums[name] = schema.enum;
}
}
}
// @ts-ignore
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
// @ts-ignore
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || ""
return {
baseUrl: serverBaseUrl,
authBaseUrl: authBaseUrl,
resources,
enums,
profile: profileConfiguration,
};
}

View File

@@ -1,39 +0,0 @@
import { ResourceField, SelectOption } from "../types/config";
export function resolveTemplate(template: string, item: any): string {
if (/\{(\w+)\}/.test(template)) {
return template.replace(/\{(\w+)\}/g, (_, field: string) => String(item[field] ?? ''));
}
return String(item[template] ?? '');
}
export function getFieldOptions(field: ResourceField, relationData?: any[]): SelectOption[] {
if (field.type === 'enum') {
return (field.options ?? []).map(opt => ({
key: opt,
value: field.enumLabels?.[opt] ?? opt,
}));
}
if (field.relation) {
const data = relationData ?? [];
const enumOption = field.enumOption;
if (!enumOption) {
throw new Error(
`Missing enumOption for relation "${field.relation}" on field "${field}". ` +
`Define referenceOptions.enumOption in the configuration for resource "${field.relation}".`
);
}
return data.map(item => ({
key: String(item[enumOption.key]),
value: resolveTemplate(enumOption.value, item),
}));
}
return [];
}
export function toGridValueOptions(options: SelectOption[]): { value: string; label: string }[] {
return options.map(opt => ({ value: opt.key, label: opt.value }));
}

View File

@@ -1,339 +0,0 @@
import * as React from "react";
import {
Box,
Container,
CircularProgress,
Alert,
TextField,
Paper,
Autocomplete,
Button
} from "@mui/material";
import DashboardView from "./components/Dashboard";
import {
DashboardState,
DashboardStateSetters,
DashboardFlow,
} from "./components/Dashboard";
import { configuration } from "./dashboard-config";
import {
useReport,
prepareReport,
} from "./features/report";
import { useResourceByName } from "../react-openapi";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function Dashboard() {
const [state, setState] = React.useState<DashboardState>({
flow: "outflows",
periodType: "rolling",
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
const [payeeInput, setPayeeInput] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState<string[]>([]);
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
const { data: snapshotsData } = useResourceByName("reports").useList();
const snapshotOptions = React.useMemo(() => {
const options: { label: string; value: string | null }[] = [
{ label: "Latest (auto)", value: null },
];
if (snapshotsData?.data) {
for (const snap of snapshotsData.data) {
options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id,
});
}
}
return options;
}, [snapshotsData]);
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
const report = useReport({
snapshot_id: selectedSnapshotId ?? undefined,
periods: ["daily", "weekly", "monthly", "all"],
flow: state.flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
tags: appliedTags.length > 0 ? appliedTags : undefined,
});
React.useEffect(() => {
if (report.data) {
setLoadedPayees(prev => {
const pSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
});
});
});
return Array.from(pSet).sort();
});
setLoadedTags(prev => {
const tSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
});
});
});
return Array.from(tSet).sort();
});
}
}, [report.data]);
const toggleFlow =
React.useCallback(() => {
setState((prev) => ({
...prev,
flow:
prev.flow ===
"outflows"
? "inflows"
: "outflows",
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
}, []);
const setFlow =
React.useCallback(
(
flow: DashboardFlow
) => {
setState((prev) => ({
...prev,
flow,
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
},
[]
);
const togglePeriodType =
React.useCallback(() => {
setState((prev) => ({
...prev,
periodType:
prev.periodType ===
"rolling"
? "calendar"
: "rolling",
}));
}, []);
const toggleComparison =
React.useCallback(() => {
setState((prev) => ({
...prev,
comparison:
!prev.comparison,
}));
}, []);
const setSelectedPeriodId =
React.useCallback(
(
selectedPeriodId: DashboardState["selectedPeriodId"]
) => {
setState((prev) => ({
...prev,
selectedPeriodId,
}));
},
[]
);
const setSelectedGroupKey =
React.useCallback(
(
selectedGroupKey: DashboardState["selectedGroupKey"]
) => {
setState((prev) => ({
...prev,
selectedGroupKey,
}));
},
[]
);
const stateSetters: DashboardStateSetters =
React.useMemo(
() => ({
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
}),
[
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
]
);
const isLoading = report.isLoading;
const error = report.error;
if (isLoading && !report.data) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{String(error)}</Alert>
</Container>
);
}
if (!report.data) {
return null;
}
const data = prepareReport(report.data);
return (
<Box>
<Container>
<Paper
sx={{
mt: 4,
p: 2,
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "flex-end" },
borderRadius: 4,
mb: -2 // pull up to be closer to the dashboard container below
}}
elevation={0}
variant="outlined"
>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Payee
</Box>
<Autocomplete
multiple
freeSolo
options={loadedPayees}
value={payeeInput}
onChange={(_, val) => setPayeeInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Tags
</Box>
<Autocomplete
multiple
freeSolo
options={loadedTags}
value={tagsInput}
onChange={(_, val) => setTagsInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add tags..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Snapshot
</Box>
<Autocomplete
options={snapshotOptions}
value={selectedSnapshotOption}
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.value === v.value}
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Button
variant="contained"
size="large"
onClick={() => {
setAppliedPayees(payeeInput);
setAppliedTags(tagsInput);
}}
disabled={isLoading}
sx={{ height: 40, borderRadius: 2 }}
>
Apply
</Button>
</Paper>
</Container>
<DashboardView
config={configuration}
data={data}
state={state}
stateSetters={stateSetters}
isFetching={report.isFetching}
/>
</Box>
);
}

View File

@@ -0,0 +1,194 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box, Container, Typography, Paper, Button, Alert,
CircularProgress,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
const CREATE_FIELDS = ["account", "format", "start_date", "end_date", "source"];
function FetchRequestList() {
const navigate = useNavigate();
const { list, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [rows, setRows] = useState<any[] | null>(null);
const columns = useMemo(() => {
if (!resource) return [];
return resource.listColumns
.map((n) => resource.fields.find((f) => f.name === n))
.filter(Boolean) as FieldConfig[];
}, [resource]);
useEffect(() => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
}, [resource?.name]);
if (!rows) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={24} />
</Box>
);
}
if (rows.length === 0) {
return <Typography variant="body2" color="text.secondary">No fetch requests found.</Typography>;
}
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{rows.map((row, i) => {
const displayFormat = resource?.displayFormat ?? "";
return (
<Paper
key={row.id ?? i}
variant="outlined"
sx={{ p: 2, cursor: "pointer", "&:hover": { borderColor: "primary.main" } }}
onClick={() => navigate(`/fetch-requests/${row.id}`)}
>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
{columns.map((col) => (
<Box key={col.name} sx={{ minWidth: 120 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{col.label}
</Typography>
<ListCellRenderer
field={col}
value={row[col.name]}
displayFormat={col.fk
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat)
: displayFormat}
/>
</Box>
))}
</Box>
</Paper>
);
})}
</Box>
);
}
export default function FetchRequestCreate() {
const { resources: allResources } = useAppContext();
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
const { create } = useResource("fetch-requests");
const [formData, setFormData] = useState<Record<string, any>>({});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ severity: "success" | "error"; message: string } | null>(null);
useEffect(() => {
if (!resource) return;
resource.relationships.forEach((rel) => {
const targetRes = allResources.find((r) => r.name === rel.config.resource);
if (!targetRes) return;
(async () => {
try {
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) params.limit = 0;
const res = await api.get(targetRes.path, { params });
const items = targetRes.pagination
? (res.data.items ?? [])
: (Array.isArray(res.data) ? res.data : []);
const opts = items.map((item: any) => ({
value: item[targetRes.primaryKey],
label: applyDisplayFormat(item, targetRes.displayFormat),
}));
setFkOptions((prev) => ({ ...prev, [rel.fieldName]: opts }));
} catch (e) {
console.warn(`Failed to load FK options for ${rel.fieldName}:`, e);
}
})();
});
}, [resource]);
const formFields = useMemo(() => {
if (!resource) return [];
return resource.orderedFields.filter((f) => CREATE_FIELDS.includes(f.name));
}, [resource]);
const handleChange = (fieldName: string, value: any) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setResult(null);
};
const handleSubmit = async () => {
setLoading(true);
setResult(null);
try {
const created = await create(formData);
const display = applyDisplayFormat(created, resource!.displayFormat);
setResult({ severity: "success", message: `Created: ${display}` });
setFormData({});
} catch (e: any) {
const detail = e?.response?.data?.detail;
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");
setResult({ severity: "error", message: `Failed: ${msg}` });
} finally {
setLoading(false);
}
};
if (!resource) {
return (
<Alert severity="info">
The <strong>fetch-requests</strong> resource was not found in this spec.
</Alert>
);
}
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Typography variant="h5" fontWeight={800} gutterBottom>
Fetch Requests
</Typography>
<Paper variant="outlined" sx={{ p: 3, mb: 4, borderRadius: 2, position: "relative", overflow: "hidden", "&::before": { content: '""', position: "absolute", left: 0, top: 0, bottom: 0, width: 4, bgcolor: "primary.main" } }}>
<Box sx={{ ml: 0.5, mb: 2.5 }}>
<Typography variant="subtitle1" fontWeight={700}>
New Fetch Request
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 480 }}>
{formFields.map((field) => (
<FormFieldRenderer
key={field.name}
field={field}
value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2, gap: 1 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 1 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Paper>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Recent Fetch Requests
</Typography>
<FetchRequestList />
</Container>
);
}

View File

@@ -0,0 +1,338 @@
import React, { useMemo, useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
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 { useResource, useItemSse, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests";
import { PipelineStepper } from "./components/PipelineStepper";
import { AmbiguityResolver } from "./components/AmbiguityResolver";
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 }} />,
};
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, resource } = useResource("fetch-requests");
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [failNotif, setFailNotif] = useState<string | null>(null);
const feedRef = useRef<HTMLDivElement>(null);
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 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();
}
if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
},
});
useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
const displayEvents = 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" } });
refetchRequest();
} catch (err: any) {
setFailNotif(formatApiError(err));
}
};
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 retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req.status as FetchRequestStatus;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
<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[status] as any}
label={status.replace(/_/g, " ")}
color={statusColors[status]}
/>
<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 }}>
{detailFields.map((field) => (
<DetailFieldRenderer
key={field.name}
field={field}
value={req[field.name]}
displayFormat={resource?.displayFormat}
/>
))}
</Box>
<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>
</Box>
{status === "failed" && !isRetryExhausted && (
<Button
variant="outlined"
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={updateMutation.isPending}
>
Retry
</Button>
)}
</Box>
</Paper>
{status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made.
</Alert>
)}
<PipelineStepper
fetchRequest={req}
sseEvents={sseEvents}
stepStats={stepStats}
liveParsedCount={liveParsedCount ?? 0}
/>
<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>
<AmbiguityResolver fetchRequestId={id!} />
<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

@@ -0,0 +1,121 @@
import React from "react";
import {
Box,
Paper,
Typography,
Button,
Alert,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { useFetchRequestAmbiguities, useResolveAmbiguity } from "../../features/fetch-requests";
interface AmbiguityResolverProps {
fetchRequestId: string;
}
export function AmbiguityResolver({ fetchRequestId }: AmbiguityResolverProps) {
const { data: ambiguities, refetch } = useFetchRequestAmbiguities(fetchRequestId);
const resolveMutation = useResolveAmbiguity();
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
await resolveMutation.mutateAsync({
ambiguityId: ambiguity.id,
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
});
refetch();
};
if (!ambiguities || ambiguities.length === 0) return null;
const allResolved = ambiguities.every((a: any) => a.status === "resolved");
return (
<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>
);
}

View File

@@ -0,0 +1,209 @@
import React, { useMemo } from "react";
import {
Box,
Paper,
Typography,
Stepper,
Step,
StepLabel,
LinearProgress,
} from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import CircularProgress from "@mui/material/CircularProgress";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../../features/fetch-requests";
const STEP_LABELS = ["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 STEP_LABELS.length;
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return STEP_LABELS.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 computeStepMessages(
fetchRequest: any,
stepStats: Record<string, number>,
liveParsedCount: number,
txnBlockCount: number,
): Record<number, string> {
const msgs: Record<number, string> = {};
const source = fetchRequest?.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;
}
interface PipelineStepperProps {
fetchRequest: any;
sseEvents: SSEEvent[];
stepStats: Record<string, number>;
liveParsedCount: number;
}
export function PipelineStepper({ fetchRequest, sseEvents, stepStats, liveParsedCount }: PipelineStepperProps) {
const status = (fetchRequest?.status ?? "pending") as FetchRequestStatus;
const seenSteps = 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 txnBlockCount = useMemo(() => {
const blocks = fetchRequest?.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 txnDictCount = useMemo(() => {
const source = fetchRequest?.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 displayParsedCount = useMemo(() => {
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
const source = fetchRequest?.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 activeStep = computeActiveStep(status, seenSteps);
const progressPercent = computeProgressPercent(status, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount);
const stepMessages = computeStepMessages(fetchRequest, stepStats, liveParsedCount, txnBlockCount);
return (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{STEP_LABELS.map((label, index) => {
const isCompleted = index < activeStep;
const isActive = index === activeStep;
const isPaused = status === "paused" && isActive;
const isFailed = 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>
<Box sx={{ mt: 3, mb: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">Overall Progress</Typography>
{["processing", "paused"].includes(status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions
</Typography>
)}
</Box>
<LinearProgress
variant="determinate"
value={progressPercent}
color={status === "failed" ? "error" : 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>
</Paper>
);
}

View File

@@ -12,7 +12,6 @@ import {
Stepper, Stepper,
Step, Step,
StepLabel, StepLabel,
StepIcon,
LinearProgress, LinearProgress,
IconButton, IconButton,
Snackbar, Snackbar,
@@ -35,7 +34,8 @@ import type {
ProgressMessage, ProgressMessage,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; import { useAppContext, useResource, useItemSse } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default", pending: "default",
@@ -57,6 +57,8 @@ const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
failed: <ErrorIcon sx={{ fontSize: 16 }} />, failed: <ErrorIcon sx={{ fontSize: 16 }} />,
}; };
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeProgressPercent( function computeProgressPercent(
status: FetchRequestStatus, status: FetchRequestStatus,
liveCount: number, liveCount: number,
@@ -85,8 +87,6 @@ function computeProgressPercent(
return Math.round(Math.min(100, pct)); return Math.round(Math.min(100, pct));
} }
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number { function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return stepLabels.length; if (status === "completed") return stepLabels.length;
@@ -124,87 +124,34 @@ function sseIcon(status: SSEEvent["status"]) {
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />; case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />; case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />; case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return ( case "progress": return <FiberManualRecordIcon sx={{ fontSize: 14, color: "info.main" }} />;
<FiberManualRecordIcon
sx={{ fontSize: 14, color: "info.main" }}
/>
);
} }
} }
function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) {
return (
candidate.balance === prevBalance + candidate.amount ||
candidate.balance === prevBalance - candidate.amount ||
Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 ||
Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01
);
}
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 config = useConfig(); const { get, update } = useResource("fetch-requests");
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!); queryKey: ["fetch-requests", "detail", id],
const updateMutation = usePatch(); queryFn: () => get(id!),
enabled: !!id,
});
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const resolveMutation = useResolveAmbiguity(); const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
const [sseConnected, setSseConnected] = React.useState(false);
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({}); 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 [failNotif, setFailNotif] = React.useState<string | null>(null);
const sseRef = React.useRef<EventSource | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null); const feedRef = React.useRef<HTMLDivElement>(null);
const txnBlockCount = React.useMemo(() => { const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const blocks = (fetchRequest as any)?.source?.txn_blocks; const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
if (!blocks) return 0; onEvent: (parsed: SSEEvent) => {
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}, [fetchRequest]);
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]);
React.useEffect(() => {
if (!id || !config?.baseUrl) return;
const url = `${config.baseUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
es.onopen = () => setSseConnected(true);
es.onerror = () => setSseConnected(false);
es.onmessage = (event) => {
try {
const parsed: SSEEvent = JSON.parse(event.data);
setSseEvents((prev) => [...prev, parsed]);
if (parsed.status === "progress" && parsed.message.count !== undefined) { if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count); if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! })); if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
@@ -234,16 +181,8 @@ export default function FetchRequestDetail() {
if (parsed.status === "completed" || parsed.step === "resume_extract") { if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest(); refetchRequest();
} }
} catch { },
// ignore malformed events });
}
};
return () => {
es.close();
sseRef.current = null;
};
}, [id, config?.baseUrl]);
React.useEffect(() => { React.useEffect(() => {
if (feedRef.current) { if (feedRef.current) {
@@ -251,27 +190,14 @@ export default function FetchRequestDetail() {
} }
}, [sseEvents]); }, [sseEvents]);
const displayEvents = React.useMemo(() => { const txnBlockCount = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]); const blocks = (fetchRequest as any)?.source?.txn_blocks;
const lastProgressIdx: Record<string, number> = {}; if (!blocks) return 0;
for (let i = sseEvents.length - 1; i >= 0; i--) { return Object.values(blocks).reduce(
const e = sseEvents[i]; (sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) { 0,
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),
); );
} }, [fetchRequest]);
return true;
});
}, [sseEvents]);
const seenSteps = React.useMemo(() => { const seenSteps = React.useMemo(() => {
const steps = new Set<string>(); const steps = new Set<string>();
@@ -301,6 +227,29 @@ export default function FetchRequestDetail() {
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]); }, [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( const progressPercent = React.useMemo(
() => computeProgressPercent( () => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending", (fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
@@ -313,6 +262,28 @@ export default function FetchRequestDetail() {
[fetchRequest, 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 () => { const handleRetry = async () => {
if (!id) return; if (!id) return;
try { try {
@@ -353,11 +324,8 @@ export default function FetchRequestDetail() {
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps); const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
const retryCount = req.retry_count ?? 0; const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX; const isRetryExhausted = retryCount >= RETRY_MAX;
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
const hasAmbiguities = ambiguities && ambiguities.length > 0; const hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0; const allResolved = hasAmbiguities && ambiguities.every((a: any) => a.status === "resolved");
const ambiguitiesLoading = !ambiguities;
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
@@ -365,6 +333,7 @@ export default function FetchRequestDetail() {
Back to Fetch Requests Back to Fetch Requests
</Button> </Button>
{/* Header Card */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip <Chip
@@ -385,7 +354,7 @@ export default function FetchRequestDetail() {
<Box> <Box>
<Typography variant="caption" color="text.secondary">Date Range</Typography> <Typography variant="caption" color="text.secondary">Date Range</Typography>
<Typography variant="body2"> <Typography variant="body2">
{(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() : "?"} {req.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} {req.end_date ? new Date(req.end_date).toLocaleDateString() : "?"}
</Typography> </Typography>
</Box> </Box>
<Box> <Box>
@@ -400,11 +369,10 @@ export default function FetchRequestDetail() {
)} )}
</Box> </Box>
{/* Progress Bar */}
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">Overall Progress</Typography>
Overall Progress
</Typography>
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && ( {["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main"> <Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions Validated: {displayParsedCount} transactions
@@ -422,6 +390,7 @@ export default function FetchRequestDetail() {
</Typography> </Typography>
</Box> </Box>
{/* Retry Counter */}
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}> <Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -448,18 +417,19 @@ export default function FetchRequestDetail() {
</Box> </Box>
</Paper> </Paper>
{/* Error Alerts */}
{req.status === "failed" && req.error_message && ( {req.status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}> <Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message} {req.error_message}
</Alert> </Alert>
)} )}
{isRetryExhausted && req.status === "failed" && ( {isRetryExhausted && req.status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}> <Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made. Max retries reached no further retry attempts will be made.
</Alert> </Alert>
)} )}
{/* Pipeline Stepper */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom> <Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress Pipeline Progress
@@ -484,17 +454,15 @@ export default function FetchRequestDetail() {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>; icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
} }
const stepMsg = stepMessages[index];
return ( return (
<Step key={label}> <Step key={label}>
<StepLabel <StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>} StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
> >
<Typography variant="body2" fontWeight={600}>{label}</Typography> <Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMsg && ( {stepMessages[index] && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}> <Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMsg} {stepMessages[index]}
</Typography> </Typography>
)} )}
</StepLabel> </StepLabel>
@@ -504,6 +472,7 @@ export default function FetchRequestDetail() {
</Stepper> </Stepper>
</Paper> </Paper>
{/* SSE Event Feed */}
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}> <Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
@@ -561,15 +530,13 @@ export default function FetchRequestDetail() {
</Typography> </Typography>
)} )}
</Box> </Box>
<Typography variant="caption" color="text.disabled">
{new Date().toLocaleTimeString()}
</Typography>
</Box> </Box>
)) ))
)} )}
</Box> </Box>
</Paper> </Paper>
{/* Ambiguity Resolution */}
{hasAmbiguities && ( {hasAmbiguities && (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom> <Typography variant="subtitle1" fontWeight={600} gutterBottom>
@@ -659,6 +626,7 @@ export default function FetchRequestDetail() {
</Box> </Box>
</Paper> </Paper>
)} )}
<Snackbar <Snackbar
open={!!failNotif} open={!!failNotif}
autoHideDuration={6000} autoHideDuration={6000}

View File

@@ -25,6 +25,12 @@ import {
FormControl, FormControl,
OutlinedInput, OutlinedInput,
Autocomplete, Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material"; } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
@@ -36,9 +42,7 @@ import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule"; import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty"; import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { import { useUploadFile } from "./features/fetch-requests";
useUploadFile,
} from "./features/fetch-requests";
import type { import type {
FetchRequest, FetchRequest,
FetchRequestStatus, FetchRequestStatus,
@@ -47,8 +51,9 @@ import type {
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; import { useResource, FormFieldRenderer, applyDisplayFormat } from "../react-openapi";
import type { ResourceField } 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"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default", pending: "default",
@@ -70,17 +75,15 @@ const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />, failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
}; };
function formatDate(iso: string) { const STATUS_OPTIONS: FetchRequestStatus[] = [
const d = new Date(iso); "pending",
return d.toLocaleString(); "processing",
} "paused",
"raw_expenses_done",
function formatDateRange(start?: string, end?: string) { "enriched_done",
if (!start && !end) return "\u2014"; "completed",
const s = start ? new Date(start).toLocaleDateString() : "?"; "failed",
const e = end ? new Date(end).toLocaleDateString() : "?"; ];
return `${s} \u2192 ${e}`;
}
function shortId(fp: string) { function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp; return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
@@ -107,33 +110,40 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState(""); const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); const { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests");
const { data: listData, isLoading, isFetching, refetch } = useList({
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
queryFn: () => list({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}), ...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
}),
}); });
const { useList: useAccountsList } = useResourceByName("accounts"); const { list: listAccounts } = useResource("accounts");
const { data: accountsData } = useAccountsList(); const { data: accountsData } = useQuery({
queryKey: ["accounts", "list"],
queryFn: () => listAccounts(),
});
const accountOptions: string[] = React.useMemo(() => { const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean); return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]); }, [accountsData]);
const config = useConfig(); const fields = fetchRes?.orderedFields ?? [];
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests"); const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format; const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const formatOptions: string[] = formatField?.options ?? []; const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date; const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
const createMutation = useCreate(); const createMutation = useMutation({ mutationFn: (data: any) => create(data) });
const updateMutation = usePatch(); const updateMutation = useMutation({
const deleteMutation = useDelete(); mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
const deleteMutation = useMutation({ mutationFn: (id: string) => remove(id) });
const uploadMutation = useUploadFile(); const uploadMutation = useUploadFile();
const requests = listData?.data ?? []; const requests = listData?.items ?? [];
const handleUpload = async () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
@@ -175,7 +185,7 @@ export default function FetchRequests() {
navigate(`/fetch-requests/${result.id}`); navigate(`/fetch-requests/${result.id}`);
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 409) { if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" }); setSnackbar({ message: "Duplicate same fingerprint already exists", severity: "error" });
} else { } else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" }); setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
} }
@@ -214,14 +224,13 @@ export default function FetchRequests() {
setDeleteTarget(null); setDeleteTarget(null);
}; };
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom> <Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline Fetch Request Pipeline
</Typography> </Typography>
{/* Create Form */}
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined"> <Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom> <Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request New Fetch Request
@@ -262,42 +271,14 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath} Uploaded as: {uploadedPath}
</Alert> </Alert>
)} )}
{formatField && components?.FormField ? ( {formatField && (
<components.FormField <FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
name="format"
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)} )}
</> </>
) : ( ) : (
<> <>
{formatField && components?.FormField ? ( {formatField && (
<components.FormField <FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
name="format"
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)} )}
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" /> <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="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
@@ -312,61 +293,22 @@ export default function FetchRequests() {
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Account Name" size="small" required /> <TextField {...params} label="Account Name" size="small" required />
)} )}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/> />
{payorUsernameField && components?.FormField ? (
<components.FormField {payorUsernameField && (
name="payor_username" <FormFieldRenderer field={payorUsernameField} value={payorUsername} onChange={setPayorUsername} />
field={payorUsernameField}
value={payorUsername}
onChange={setPayorUsername}
/>
) : (
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
)} )}
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.date ? ( {startDateField && (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.date <FormFieldRenderer field={startDateField} value={startDate} onChange={setStartDate} />
name="start_date"
field={startDateField}
value={startDate}
onChange={setStartDate}
/>
</Box> </Box>
) : (
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)} )}
{endDateField && components?.date ? ( {endDateField && (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.date <FormFieldRenderer field={endDateField} value={endDate} onChange={setEndDate} />
name="end_date"
field={endDateField}
value={endDate}
onChange={setEndDate}
/>
</Box> </Box>
) : (
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)} )}
</Box> </Box>
@@ -380,6 +322,7 @@ export default function FetchRequests() {
</Box> </Box>
</Paper> </Paper>
{/* Filters */}
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined"> <Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 200 }}> <FormControl size="small" sx={{ minWidth: 200 }}>
@@ -391,7 +334,7 @@ export default function FetchRequests() {
input={<OutlinedInput label="Status" />} input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")} renderValue={(selected) => (selected as string[]).join(", ")}
> >
{(config?.enums?.FetchRequestStatus ?? []).map((s: string) => ( {STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem> <MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))} ))}
</Select> </Select>
@@ -403,7 +346,7 @@ export default function FetchRequests() {
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} /> <TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)} )}
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }} sx={{ minWidth: 160 }}
/> />
<ToggleButtonGroup <ToggleButtonGroup
value={sourceFilter} value={sourceFilter}
@@ -411,11 +354,9 @@ export default function FetchRequests() {
onChange={(_, val) => val && setSourceFilter(val)} onChange={(_, val) => val && setSourceFilter(val)}
size="small" size="small"
> >
{sourceTypeOptions.map((opt) => ( <ToggleButton value="all">All</ToggleButton>
<ToggleButton key={opt} value={opt}> <ToggleButton value="file">File</ToggleButton>
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"} <ToggleButton value="email">Email</ToggleButton>
</ToggleButton>
))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}> <IconButton onClick={() => refetch()} disabled={isFetching}>
@@ -424,6 +365,7 @@ export default function FetchRequests() {
</Box> </Box>
</Paper> </Paper>
{/* List Table */}
{isLoading ? ( {isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress /> <CircularProgress />
@@ -433,41 +375,35 @@ export default function FetchRequests() {
No fetch requests yet No fetch requests yet
</Box> </Box>
) : ( ) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}> <TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Box sx={{ overflowX: "auto" }}> <Table size="small">
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}> <TableHead>
<Box component="thead"> <TableRow>
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}> <TableCell>ID</TableCell>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => ( <TableCell>Account</TableCell>
<Box <TableCell>Source</TableCell>
key={h} <TableCell>Date Range</TableCell>
component="th" <TableCell>Status</TableCell>
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }} <TableCell>Retries</TableCell>
> <TableCell>Created</TableCell>
{h} <TableCell align="right">Actions</TableCell>
</Box> </TableRow>
))} </TableHead>
</Box> <TableBody>
</Box>
<Box component="tbody">
{[...requests] {[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => ( .map((req: FetchRequest) => (
<Box <TableRow
key={req.id} key={req.id}
component="tr" hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/fetch-requests/${req.id}`)} onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{
cursor: "pointer",
borderBottom: 1,
borderColor: "divider",
"&:hover": { bgcolor: "action.hover" },
"&:last-child": { borderBottom: 0 },
}}
> >
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}> <TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{shortId(req.fingerprint)} {shortId(req.fingerprint)}
</Typography>
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { onClick={(e) => {
@@ -480,25 +416,22 @@ export default function FetchRequests() {
<ContentCopyIcon sx={{ fontSize: 14 }} /> <ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
</Box> </Box>
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}> <TableCell>{req.account_name}</TableCell>
{req.account_name} <TableCell>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Chip <Chip
label={"path" in req.source ? "File" : "Email"} label={"path" in req.source ? "File" : "Email"}
size="small" size="small"
variant="outlined" variant="outlined"
color={"path" in req.source ? "primary" : "secondary"} color={"path" in req.source ? "primary" : "secondary"}
/> />
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}> <Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)} {(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> </Typography>
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}> <Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip <Chip
icon={statusIcons[req.status] as any} icon={statusIcons[req.status] as any}
@@ -507,69 +440,45 @@ export default function FetchRequests() {
size="small" size="small"
/> />
</Tooltip> </Tooltip>
</Box> </TableCell>
</Box> <TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{(req.retry_count ?? 0) > 0 ? (
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}> <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{req.retry_count}/{RETRY_MAX} {(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
</Typography> </Typography>
) : ( </TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}> <TableCell>
\u2014 <Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{new Date(req.created_at).toLocaleString()}
</Typography> </Typography>
)} </TableCell>
</Box> <TableCell align="right">
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}> <Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && ( {req.status === "paused" && (
<Tooltip title="Resolve ambiguities"> <Tooltip title="Resolve ambiguities">
<IconButton <IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }}>
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/fetch-requests/${req.id}`);
}}
>
<WarningAmberIcon fontSize="small" color="warning" /> <WarningAmberIcon fontSize="small" color="warning" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && ( {req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry"> <Tooltip title="Retry">
<IconButton <IconButton size="small" onClick={(e) => { e.stopPropagation(); handleRetry(req); }}>
size="small"
onClick={(e) => {
e.stopPropagation();
handleRetry(req);
}}
>
<ReplayIcon fontSize="small" /> <ReplayIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
<Tooltip title="Delete"> <Tooltip title="Delete">
<IconButton <IconButton size="small" onClick={(e) => { e.stopPropagation(); setDeleteTarget(req); }}>
size="small"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(req);
}}
>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </TableCell>
</Box> </TableRow>
))} ))}
</Box> </TableBody>
</Box> </Table>
</Box> </TableContainer>
</Paper>
)} )}
<Snackbar <Snackbar

View File

@@ -101,9 +101,7 @@ export default function Header({
}} }}
> >
{[ {[
{ label: "Dashboard", path: "/dashboard" },
{ label: "Fetch", path: "/fetch-requests" }, { label: "Fetch", path: "/fetch-requests" },
{ label: "Reports", path: "/reports" },
].map(({ label, path }) => ( ].map(({ label, path }) => (
<Button <Button
key={path} key={path}
@@ -136,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}
@@ -153,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,308 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Chip,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
interface ReportSnapshotQuery {
accounts?: string[];
ignore_self?: boolean;
start_date?: string;
end_date?: string;
}
interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportSnapshotQuery;
}
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
export default function ReportSnapshots() {
const [ignoreSelf, setIgnoreSelf] = React.useState(true);
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [minAmount, setMinAmount] = React.useState("");
const [maxAmount, setMaxAmount] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList();
const createMutation = useCreate();
const deleteMutation = useDelete();
const config = useConfig();
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
const snapshots: ReportSnapshot[] = listData?.data ?? [];
const handleCreate = async () => {
try {
const payload: Record<string, any> = {};
if (ignoreSelf) payload.ignore_self = true;
if (startDate) payload.start_date = new Date(startDate).toISOString();
if (endDate) payload.end_date = new Date(endDate).toISOString();
if (minAmount) payload.min_amount = parseFloat(minAmount);
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
const result = await createMutation.mutateAsync(payload);
const snapshotId = (result as any)?.snapshot_id;
if (snapshotId) {
setCreatedSnapshotId(snapshotId);
setSnackbar({ message: `Snapshot created: ${snapshotId}`, severity: "success" });
} else {
setSnackbar({ message: "Snapshot created", severity: "success" });
}
resetForm();
} catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create snapshot", severity: "error" });
}
};
const resetForm = () => {
setIgnoreSelf(true);
setStartDate("");
setEndDate("");
setMinAmount("");
setMaxAmount("");
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.snapshot_id);
setSnackbar({ message: "Snapshot deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete snapshot", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Report Snapshots
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Generate New Snapshot
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ignoreSelfField && components?.FormField && (
<components.FormField
name="ignore_self"
field={ignoreSelfField}
value={ignoreSelf}
onChange={(val: boolean) => setIgnoreSelf(val)}
/>
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.datetime && (
<Box sx={{ flex: 1 }}>
<components.datetime
name="start_date"
field={startDateField}
value={startDate}
onChange={(val: string) => setStartDate(val)}
/>
</Box>
)}
{endDateField && components?.datetime && (
<Box sx={{ flex: 1 }}>
<components.datetime
name="end_date"
field={endDateField}
value={endDate}
onChange={(val: string) => setEndDate(val)}
/>
</Box>
)}
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
{minAmountField && components?.FormField && (
<Box sx={{ flex: 1 }}>
<components.FormField
name="min_amount"
field={minAmountField}
value={minAmount}
onChange={(val: string) => setMinAmount(val)}
/>
</Box>
)}
{maxAmountField && components?.FormField && (
<Box sx={{ flex: 1 }}>
<components.FormField
name="max_amount"
field={maxAmountField}
value={maxAmount}
onChange={(val: string) => setMaxAmount(val)}
/>
</Box>
)}
</Box>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
</Button>
{createdSnapshotId && (
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
</Alert>
)}
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Existing Snapshots
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : snapshots.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No snapshots yet
</Box>
) : (
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{snapshots.map((snap: ReportSnapshot) => (
<Box
key={snap.id}
component="tr"
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(snap.snapshot_id);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{formatDate(snap.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box>
) : (
<Typography variant="body2" color="text.secondary">\u2014</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</Paper>
<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 Snapshot?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the report snapshot.
</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>
);
}

26
src/RequireAuth.tsx Normal file
View File

@@ -0,0 +1,26 @@
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

@@ -1,61 +0,0 @@
import * as React from "react";
import {
ReportData,
GroupKey,
} from "../../features/report";
export type DashboardFlow = "outflows" | "inflows";
export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null;
export interface DashboardState {
flow: DashboardFlow;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean;
}
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleFlow: () => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection {
id: string;
title: string;
component: React.ComponentType<any>;
summary?: string;
settings?: Record<string, any>;
}
export interface DashboardConfig {
sections: DashboardSection[];
}
export interface DashboardViewProps {
config: DashboardConfig;
data: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
}
export interface ColorScheme {
primary: string;
surface: string;
text: string;
}
export interface ComponentProps extends DashboardSection {
reportData: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
colorScheme: ColorScheme;
}

Some files were not shown because too many files have changed in this diff Show More