diff --git a/package-lock.json b/package-lock.json
index 90d0a28..8902cb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,7 @@
"remark-gfm": "latest"
},
"devDependencies": {
+ "@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest"
@@ -1632,6 +1633,12 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
diff --git a/package.json b/package.json
index 73ae1ae..337a52d 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"remark-gfm": "latest"
},
"devDependencies": {
+ "@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest"
diff --git a/react-openapi/README.md b/react-openapi/README.md
new file mode 100644
index 0000000..bb449fb
--- /dev/null
+++ b/react-openapi/README.md
@@ -0,0 +1,862 @@
+# 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 (
+
+
+
+ );
+}
+```
+
+## 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`. |
+
+### Hooks
+
+| Export | Purpose |
+|-------------------------|---------------------------------------------------------------------------------------------------------|
+| `useAppContext()` | Returns `{ config, resources, loading, errors, warnings }`. Access all resource configs. |
+| `useResource(resource)` | Returns `{ list, get, create, update, remove, loading, error }` — CRUD methods for a specific resource. |
+
+### 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 }` |
+
+## 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.
+
+### 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` |
+| `$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' }
+```
diff --git a/react-openapi/index.ts b/react-openapi/index.ts
index a26a3c6..d75ee63 100644
--- a/react-openapi/index.ts
+++ b/react-openapi/index.ts
@@ -2,4 +2,10 @@ export { AppProvider } from "./src/context/AppProvider";
export { Admin } from "./src/components/Admin";
export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource";
+export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
+export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
+export { SseStreamView } from "./src/components/SseStreamView";
+export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
+export { getApi } from "./src/hooks/useApi";
+export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types";
diff --git a/react-openapi/src/components/Admin.tsx b/react-openapi/src/components/Admin.tsx
index 206d40b..0d58bae 100644
--- a/react-openapi/src/components/Admin.tsx
+++ b/react-openapi/src/components/Admin.tsx
@@ -44,9 +44,13 @@ export function Admin({ basePath }: AdminProps) {
{resources.map((r) => (
} />
- } />
- } />
- } />
+ {!r.streaming && (
+ <>
+ } />
+ } />
+ } />
+ >
+ )}
))}
diff --git a/react-openapi/src/components/FilterBar.tsx b/react-openapi/src/components/FilterBar.tsx
new file mode 100644
index 0000000..6e03c22
--- /dev/null
+++ b/react-openapi/src/components/FilterBar.tsx
@@ -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;
+ 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 (
+
+ {filterable.map((field) => {
+ const Component = components[field.name] as React.FC;
+ const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time";
+
+ if (isRange) {
+ return (
+
+
+ onFilterChange(field.name + "_from", v)}
+ data={data}
+ />
+
+
+ onFilterChange(field.name + "_to", v)}
+ data={data}
+ />
+
+
+ );
+ }
+
+ return (
+
+ onFilterChange(field.name, v)}
+ data={data}
+ />
+
+ );
+ })}
+ {hasActiveFilters && (
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/react-openapi/src/components/ResourceDetail.tsx b/react-openapi/src/components/ResourceDetail.tsx
index 8b4b9c9..39eea3c 100644
--- a/react-openapi/src/components/ResourceDetail.tsx
+++ b/react-openapi/src/components/ResourceDetail.tsx
@@ -23,7 +23,7 @@ interface ResourceDetailProps {
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate();
const { id } = useParams();
- const crud = useResource(resource);
+ const crud = useResource(resource.name);
const { resources: allResources } = useAppContext();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
diff --git a/react-openapi/src/components/ResourceForm.tsx b/react-openapi/src/components/ResourceForm.tsx
index 281afb3..65fa0ca 100644
--- a/react-openapi/src/components/ResourceForm.tsx
+++ b/react-openapi/src/components/ResourceForm.tsx
@@ -28,7 +28,7 @@ interface ResourceFormProps {
export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
const navigate = useNavigate();
const { id } = useParams();
- const crud = useResource(resource);
+ const crud = useResource(resource.name);
const { resources: allResources } = useAppContext();
const [formData, setFormData] = useState>({});
const [errors, setErrors] = useState>({});
@@ -218,7 +218,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
}
};
- const title = mode === "create" ? `Create ${resource.schemaName}` : `Edit ${resource.schemaName}`;
+ const title = mode === "create" ? `Create ${resource.displayName}` : `Edit ${resource.displayName}`;
return (
diff --git a/react-openapi/src/components/ResourceList.tsx b/react-openapi/src/components/ResourceList.tsx
index 60ee283..d92a04c 100644
--- a/react-openapi/src/components/ResourceList.tsx
+++ b/react-openapi/src/components/ResourceList.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback } from "react";
+import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
@@ -14,42 +14,127 @@ import {
TableRow,
TablePagination,
Paper,
- TextField,
- InputAdornment,
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 SearchIcon from "@mui/icons-material/Search";
import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
-import { ListCellRenderer, applyDisplayFormat } from "./fields";
+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, 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 crud = useResource(resource);
- const { resources: allResources } = useAppContext();
+ const { components, ...crud } = useResource(resource.name);
+ const { resources: allResources, config } = useAppContext();
const [data, setData] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20);
- const [search, setSearch] = useState("");
const [sortField, setSortField] = useState(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
+ const [filters, setFilters] = useState>({});
+ const [detailRow, setDetailRow] = useState(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);
- const fetchData = useCallback(async () => {
+ 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 = {};
if (resource.pagination) {
params[resource.pagination.limitParam] = rowsPerPage;
@@ -58,19 +143,76 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
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]);
+ }, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir, filters]);
+
+ const clientFetchAll = useCallback(async () => {
+ const params: Record = {};
+ 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(() => {
- fetchData();
- }, [fetchData]);
+ 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);
- fetchData();
+ if (isClientMode) {
+ clientFetchAll();
+ } else {
+ serverFetchData();
+ }
};
const handleSort = (field: string) => {
@@ -82,39 +224,46 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
}
};
+ const handleFilterChange = (fieldName: string, value: string) => {
+ setFilters((prev) => ({ ...prev, [fieldName]: value }));
+ };
+
return (
-
- {resource.schemaName}
-
- {resource.operations.create && (
- }
- onClick={() => navigate(`${basePath}/${resource.name}/new`)}
- >
- Create
-
- )}
+
+
+ {resource.displayName}
+
+ {isStreaming && }
+
+
+ {isStreaming && data.length > 0 && (
+
+ )}
+ {resource.operations.create && !isStreaming && (
+ }
+ onClick={() => navigate(`${basePath}/${resource.name}/new`)}
+ >
+ Create
+
+ )}
+
-
- setSearch(e.target.value)}
- InputProps={{
- startAdornment: (
-
-
-
- ),
- }}
- sx={{ minWidth: 280 }}
+ {!isStreaming && (
+ setFilters({})}
+ data={data}
/>
-
+ )}
@@ -135,27 +284,33 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
)}
))}
- Actions
+ {hasActions && Actions}
- {data.length === 0 ? (
+ {displayData.length === 0 ? (
-
+
- No records found
+ {isStreaming ? "Waiting for events\u2026" : "No records found"}
) : (
- data.map((row) => {
- const rowId = row[resource.primaryKey];
+ displayData.map((row, idx) => {
+ const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey];
return (
navigate(`${basePath}/${resource.name}/${rowId}`)}
+ onClick={() => {
+ if (isStreaming) {
+ setDetailRow(row);
+ } else {
+ navigate(`${basePath}/${resource.name}/${rowId}`);
+ }
+ }}
>
{visibleColumns.map((col) => {
let value = row[col.name];
@@ -172,29 +327,31 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
);
})}
- e.stopPropagation()}>
- {resource.operations.get && (
-
- navigate(`${basePath}/${resource.name}/${rowId}`)}>
-
-
-
- )}
- {resource.operations.update && (
-
- navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
-
-
-
- )}
- {resource.operations.delete && (
-
- handleDelete(rowId)} color="error">
-
-
-
- )}
-
+ {hasActions && (
+ e.stopPropagation()}>
+ {resource.operations.get && !isStreaming && (
+
+ navigate(`${basePath}/${resource.name}/${rowId}`)}>
+
+
+
+ )}
+ {resource.operations.update && (
+
+ navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
+
+
+
+ )}
+ {resource.operations.delete && (
+
+ handleDelete(rowId)} color="error">
+
+
+
+ )}
+
+ )}
);
})
@@ -203,10 +360,10 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
- {resource.pagination && (
+ {!isStreaming && (resource.pagination || isClientMode) && (
setPage(p)}
rowsPerPage={rowsPerPage}
@@ -217,6 +374,34 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
rowsPerPageOptions={[10, 20, 50, 100]}
/>
)}
+
+ {!isStreaming && displayData.length > 0 && (
+
+ {displayTotal} record{displayTotal !== 1 ? "s" : ""}
+
+ )}
+
+
);
}
diff --git a/react-openapi/src/components/SideMenu.tsx b/react-openapi/src/components/SideMenu.tsx
index d0bfb7e..197f674 100644
--- a/react-openapi/src/components/SideMenu.tsx
+++ b/react-openapi/src/components/SideMenu.tsx
@@ -67,7 +67,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
diff --git a/react-openapi/src/components/SseConnectionStatus.tsx b/react-openapi/src/components/SseConnectionStatus.tsx
new file mode 100644
index 0000000..e4e20cf
--- /dev/null
+++ b/react-openapi/src/components/SseConnectionStatus.tsx
@@ -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 (
+
+
+ {connected ? "Connected" : "Disconnected"}
+
+ );
+}
\ No newline at end of file
diff --git a/react-openapi/src/components/SseStreamView.tsx b/react-openapi/src/components/SseStreamView.tsx
new file mode 100644
index 0000000..509d533
--- /dev/null
+++ b/react-openapi/src/components/SseStreamView.tsx
@@ -0,0 +1,96 @@
+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;
+}
+
+export function SseStreamView({ resource }: SseStreamViewProps) {
+ const { stream } = useResource(resource.name);
+ const [events, setEvents] = useState(() => 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),
+ });
+
+ return () => {
+ setSseConnected(resource.name, false);
+ sub.close();
+ };
+ }, [resource.name]);
+
+ const eventCount = events.length;
+ const latestEvent = events[events.length - 1] ?? null;
+
+ return (
+
+
+
+
+ {resource.displayName}
+
+
+
+ 0 ? `${eventCount} event${eventCount !== 1 ? "s" : ""}` : "No events"}
+ size="small"
+ variant="outlined"
+ color={eventCount > 0 ? "primary" : "default"}
+ />
+
+
+ {latestEvent ? (
+
+
+ Latest event (#{latestEvent._seq})
+
+
+ {applyDisplayFormat(latestEvent, resource.displayFormat)}
+
+
+ ) : (
+
+ Waiting for events…
+
+ )}
+
+ setSnackbarOpen(false)}
+ message={snackbarMsg}
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/react-openapi/src/components/fields/renderers/BooleanField.tsx b/react-openapi/src/components/fields/renderers/BooleanField.tsx
index 45afc1a..6290062 100644
--- a/react-openapi/src/components/fields/renderers/BooleanField.tsx
+++ b/react-openapi/src/components/fields/renderers/BooleanField.tsx
@@ -1,14 +1,44 @@
import React from "react";
-import { FormControl, FormControlLabel, Switch, FormHelperText } from "@mui/material";
+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 }: Props) {
+export function BooleanField({ field, value, onChange, nullable }: Props) {
+ if (nullable) {
+ const strValue = String(value ?? "");
+ return (
+
+
+ {field.label}
+
+ onChange(v ?? "")}
+ size="small"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
return (
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;
-}
-
-interface UseResourceReturn {
- list: (params?: Record) => Promise;
- get: (id: string | number) => Promise;
- create: (data: any) => Promise;
- update: (id: string | number, data: any) => Promise;
- remove: (id: string | number) => Promise;
- loading: boolean;
- error: string | null;
-}
-
-export function useResource(resource: ResourceConfig): UseResourceReturn {
- const [state, setState] = useState({ loading: false, error: null });
-
- 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): Promise => {
- setLoading(true);
- setError(null);
- try {
- const api = getApi();
- const res = await api.get(resource.path, { params });
- const data = res.data;
-
- if (resource.pagination) {
- if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
- throw new Error(`Expected paginated response { total, items } from ${resource.path}`);
- }
- return { items: data.items, total: data.total ?? data.items.length };
- }
-
- if (!Array.isArray(data)) {
- throw new Error(`Expected array response from ${resource.path}`);
- }
- return { items: data };
- } catch (e: any) {
- const msg = parseError(e);
- setError(msg);
- return { items: [] };
- } finally {
- setLoading(false);
- }
- },
- [resource.path, resource.pagination, setLoading, setError]
- );
-
- const get = useCallback(
- async (id: string | number): Promise => {
- setLoading(true);
- setError(null);
- try {
- const api = getApi();
- const res = await api.get(`${resource.path}/${id}`);
- return res.data;
- } catch (e: any) {
- setError(parseError(e));
- throw e;
- } finally {
- setLoading(false);
- }
- },
- [resource.path, setLoading, setError]
- );
-
- const create = useCallback(
- async (data: any): Promise => {
- setLoading(true);
- setError(null);
- try {
- const api = getApi();
- const res = await api.post(resource.path, data);
- return res.data;
- } catch (e: any) {
- setError(parseError(e));
- throw e;
- } finally {
- setLoading(false);
- }
- },
- [resource.path, setLoading, setError]
- );
-
- const update = useCallback(
- async (id: string | number, data: any): Promise => {
- setLoading(true);
- setError(null);
- try {
- const api = getApi();
- const res = await api.put(`${resource.path}/${id}`, data);
- return res.data;
- } catch (e: any) {
- setError(parseError(e));
- throw e;
- } finally {
- setLoading(false);
- }
- },
- [resource.path, setLoading, setError]
- );
-
- const remove = useCallback(
- async (id: string | number): Promise => {
- setLoading(true);
- setError(null);
- try {
- const api = getApi();
- await api.delete(`${resource.path}/${id}`);
- } catch (e: any) {
- setError(parseError(e));
- throw e;
- } finally {
- setLoading(false);
- }
- },
- [resource.path, setLoading, setError]
- );
-
- return { list, get, create, update, remove, loading: state.loading, error: state.error };
-}
diff --git a/react-openapi/src/context/useResource.tsx b/react-openapi/src/context/useResource.tsx
new file mode 100644
index 0000000..0502cd3
--- /dev/null
+++ b/react-openapi/src/context/useResource.tsx
@@ -0,0 +1,509 @@
+import { useState, useCallback, useMemo, useEffect, useRef } from "react";
+import { Autocomplete, TextField } from "@mui/material";
+import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types";
+import { useAppContext } from "./AppContext";
+import { getApi } from "../hooks/useApi";
+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";
+
+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>;
+ list: (params?: Record) => Promise;
+ get: (id: string | number) => Promise;
+ create: (data: any) => Promise;
+ update: (id: string | number, data: any) => Promise;
+ remove: (id: string | number) => Promise;
+ stream?: (handlers: StreamHandlers) => StreamSubscription;
+ loading: boolean;
+ error: string | null;
+}
+
+const _fkOptionsCache = new Map();
+const _stringOptionsCache = new Map();
+const _sseEventCache = new Map();
+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();
+const _sseListeners = new Map 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();
+ 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 {
+ if (field.type === "boolean") {
+ return ({ value, onChange, labelOverride }) => (
+ onChange(v ?? "")}
+ nullable
+ />
+ );
+ }
+
+ if (field.fk) {
+ const FkFilter: React.FC = ({ 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();
+ 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 = {};
+ 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 (
+ onChange(v.join(","))}
+ fkOptions={options}
+ />
+ );
+ }
+
+ return (
+ onChange(v ?? "")}
+ fkOptions={options}
+ />
+ );
+ };
+ return FkFilter;
+ }
+
+ if (field.enumValues) {
+ const EnumFilter: React.FC = ({ 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 (
+ onChange(v ?? "")}
+ />
+ );
+ };
+ return EnumFilter;
+ }
+
+ if (field.type === "integer" || field.type === "number") {
+ return ({ value, onChange, labelOverride }) => (
+ onChange(v === "" ? "" : String(v))}
+ />
+ );
+ }
+
+ if (field.format === "date" || field.format === "date-time") {
+ return ({ value, onChange, labelOverride }) => (
+ onChange(v ?? "")}
+ />
+ );
+ }
+
+ if (
+ !field.fk &&
+ !field.enumValues &&
+ field.type !== "boolean" &&
+ field.type !== "integer" &&
+ field.type !== "number" &&
+ field.format !== "date" &&
+ field.format !== "date-time" &&
+ !field.refSchema
+ ) {
+ const StringAutocompleteFilter: React.FC = ({ value, onChange, data, labelOverride }) => {
+ const { resources, config } = useAppContext();
+ const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
+ const [options, setOptions] = useState([]);
+ const fetched = useRef(false);
+
+ useEffect(() => {
+ if (filterMode === "client" && data) {
+ setOptions(extractDataOptions(data, field.name));
+ fetched.current = true;
+ } else if (filterMode === "server" && !fetched.current) {
+ const cacheKey = resourceName + ":" + field.name;
+ if (_stringOptionsCache.has(cacheKey)) {
+ setOptions(_stringOptionsCache.get(cacheKey)!);
+ fetched.current = true;
+ } else {
+ (async () => {
+ try {
+ const api = getApi();
+ const selfRes = resources.find((r) => r.name === resourceName);
+ if (!selfRes) { fetched.current = true; return; }
+ const params: Record = {};
+ if (selfRes.pagination) params.limit = 0;
+ const res = await api.get(selfRes.path, { params });
+ let items: any[];
+ if (selfRes.pagination) {
+ items = res.data.items ?? [];
+ } else {
+ items = Array.isArray(res.data) ? res.data : [];
+ }
+ const values = [...new Set(items.map((r: any) => String(r[field.name] ?? "")).filter(Boolean))].sort();
+ _stringOptionsCache.set(cacheKey, values);
+ setOptions(values);
+ fetched.current = true;
+ } catch {
+ fetched.current = true;
+ }
+ })();
+ }
+ }
+ }, [data]);
+
+ return (
+ onChange(newVal ?? "")}
+ renderInput={(params) => (
+
+ )}
+ />
+ );
+ };
+ return StringAutocompleteFilter;
+ }
+
+ return ({ value, onChange, labelOverride }) => (
+ onChange(v ?? "")}
+ />
+ );
+}
+
+export function useResource(resourceName: string): UseResourceReturn {
+ const { resources } = useAppContext();
+ const resource = resources.find((r) => r.name === resourceName);
+ if (!resource) {
+ throw new Error(`Resource "${resourceName}" not found`);
+ }
+
+ const [state, setState] = useState({ loading: false, error: null });
+
+ 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): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const api = getApi();
+ const res = await api.get(resource.path, { params });
+ const data = res.data;
+
+ if (resource.pagination) {
+ if (!data || typeof data !== "object" || !Array.isArray(data.items)) {
+ throw new Error(`Expected paginated response { total, items } from ${resource.path}`);
+ }
+ return { items: data.items, total: data.total ?? data.items.length };
+ }
+
+ if (!Array.isArray(data)) {
+ throw new Error(`Expected array response from ${resource.path}`);
+ }
+ return { items: data };
+ } catch (e: any) {
+ const msg = parseError(e);
+ setError(msg);
+ return { items: [] };
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resource.path, resource.pagination, setLoading, setError]
+ );
+
+ const get = useCallback(
+ async (id: string | number): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const api = getApi();
+ const res = await api.get(`${resource.path}/${id}`);
+ return res.data;
+ } catch (e: any) {
+ setError(parseError(e));
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resource.path, setLoading, setError]
+ );
+
+ const create = useCallback(
+ async (data: any): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const api = getApi();
+ const res = await api.post(resource.path, data);
+ return res.data;
+ } catch (e: any) {
+ setError(parseError(e));
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resource.path, setLoading, setError]
+ );
+
+ const update = useCallback(
+ async (id: string | number, data: any): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const api = getApi();
+ const res = await api.put(`${resource.path}/${id}`, data);
+ return res.data;
+ } catch (e: any) {
+ setError(parseError(e));
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resource.path, setLoading, setError]
+ );
+
+ const remove = useCallback(
+ async (id: string | number): Promise => {
+ setLoading(true);
+ setError(null);
+ try {
+ const api = getApi();
+ await api.delete(`${resource.path}/${id}`);
+ } catch (e: any) {
+ setError(parseError(e));
+ throw e;
+ } finally {
+ setLoading(false);
+ }
+ },
+ [resource.path, setLoading, setError]
+ );
+
+ const stream = useCallback(
+ (handlers: StreamHandlers): StreamSubscription => {
+ if (!resource.streaming) {
+ throw new Error(`Resource "${resourceName}" does not support streaming`);
+ }
+ const api = getApi();
+ const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, "");
+ const url = baseUrl + resource.path;
+ 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() };
+ },
+ [resource.path, resource.streaming, resourceName]
+ );
+
+ const components = useMemo(
+ () => {
+ const map: Record> = {};
+ for (const field of resource.fields) {
+ map[field.name] = buildFilterComponent(field, resourceName);
+ }
+ return map;
+ },
+ [resource.fields, resourceName]
+ );
+
+ return { resource, components, list, get, create, update, remove, stream: resource.streaming ? stream : undefined, loading: state.loading, error: state.error };
+}
\ No newline at end of file
diff --git a/react-openapi/src/spec-validator.ts b/react-openapi/src/spec-validator.ts
index 6844b43..be643b6 100644
--- a/react-openapi/src/spec-validator.ts
+++ b/react-openapi/src/spec-validator.ts
@@ -96,6 +96,9 @@ export function validateSpec(spec: OpenApiSpec): ValidationMessage[] {
messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` });
}
+ const isSSE = collectionPath?.get?.["x-sse"] === true;
+ if (isSSE) continue;
+
const listParams = collectionPath?.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");
diff --git a/react-openapi/src/transformers/resource-config.ts b/react-openapi/src/transformers/resource-config.ts
index 2852466..9c23b5f 100644
--- a/react-openapi/src/transformers/resource-config.ts
+++ b/react-openapi/src/transformers/resource-config.ts
@@ -1,4 +1,4 @@
-import type { OpenApiSpec, ResourceConfig, FieldConfig, ResourceRelationship } from "../types";
+import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types";
import { extractFields } from "./field-config";
import { extractRelationships } from "./relationship-config";
@@ -28,6 +28,25 @@ function sortFields(fields: FieldConfig[]): FieldConfig[] {
});
}
+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,
+};
+
export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {};
@@ -46,10 +65,12 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
const fields = extractFields(schemaName, schema, schemas);
const relationships = extractRelationships(schema, schemas);
+ const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = {
name: resourceName,
schemaName,
+ displayName: formatDisplayName(resourceName),
path: resourcePath,
primaryKey: schema["x-primary-key"],
displayFormat: schema["x-display-format"],
@@ -65,10 +86,21 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
},
pagination: detectPagination(collectionPathObj),
relationships,
+ streaming: hasSSE || undefined,
};
+ if (hasSSE) {
+ resource.operations = { list: true, get: false, create: false, update: false, delete: false };
+ resource.pagination = null;
+ resource.relationships = [];
+ resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))];
+ resource.orderedFields = sortFields(resource.fields);
+ resource.listColumns = ["_received_at", ...resource.listColumns];
+ resource.primaryKey = "_received_at";
+ }
+
configs.push(resource);
}
return configs;
-}
+}
\ No newline at end of file
diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts
index dcb7d97..161f074 100644
--- a/react-openapi/src/types.ts
+++ b/react-openapi/src/types.ts
@@ -1,8 +1,17 @@
+export type FilterMode = "client" | "server";
+
+export interface ResourceConfiguration {
+ filterOptions?: {
+ mode?: FilterMode;
+ };
+}
+
export interface SpecConfiguration {
specUrl: string;
baseApiUrl?: string;
title?: string;
getToken?: () => string | null;
+ resourceConfig?: Record;
}
export interface ValidationMessage {
@@ -19,6 +28,7 @@ export interface ResourceRelationship {
export interface ResourceConfig {
name: string;
schemaName: string;
+ displayName: string;
path: string;
primaryKey: string;
displayFormat: string;
@@ -38,6 +48,7 @@ export interface ResourceConfig {
defaultLimit: number;
} | null;
relationships: ResourceRelationship[];
+ streaming?: boolean;
}
export interface FieldConfig {
diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx
index 70e4a68..a4a7349 100644
--- a/src/Dashboard.tsx
+++ b/src/Dashboard.tsx
@@ -23,7 +23,7 @@ import {
useReport,
prepareReport,
} from "./features/report";
-import { useResourceByName } from "../react-openapi";
+import { useReportSnapshotsList } from "./features/report-snapshots";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
@@ -56,13 +56,13 @@ export default function Dashboard() {
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState(null);
- const { data: snapshotsData } = useResourceByName("reports").useList();
+ const { data: snapshotsData } = useReportSnapshotsList();
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) {
+ if (snapshotsData?.items) {
+ for (const snap of snapshotsData.items) {
options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id,
diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx
index 03e6678..9137026 100644
--- a/src/FetchRequestDetail.tsx
+++ b/src/FetchRequestDetail.tsx
@@ -35,7 +35,8 @@ import type {
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
-import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
+import { useAppContext, useResource } from "../react-openapi";
+import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record = {
pending: "default",
@@ -144,11 +145,18 @@ function isMathValid(candidate: { amount: number; balance: number }, prevBalance
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
- const config = useConfig();
+ const { resources, config } = useAppContext();
+ const fetchRequestResource = resources.find(r => r.name === "fetch-requests")!;
+ const { get, update } = useResource(fetchRequestResource);
- const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
- const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
- const updateMutation = usePatch();
+ const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
+ queryKey: ["fetch-requests", "detail", id],
+ queryFn: () => get(id!),
+ enabled: !!id,
+ });
+ const updateMutation = useMutation({
+ mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
+ });
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
@@ -193,8 +201,8 @@ export default function FetchRequestDetail() {
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => {
- if (!id || !config?.baseUrl) return;
- const url = `${config.baseUrl}/fetch-requests/${id}/events`;
+ if (!id || !config?.baseApiUrl) return;
+ const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
@@ -243,7 +251,7 @@ export default function FetchRequestDetail() {
es.close();
sseRef.current = null;
};
- }, [id, config?.baseUrl]);
+ }, [id, config?.baseApiUrl]);
React.useEffect(() => {
if (feedRef.current) {
diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx
index 3895c2f..0c08ed2 100644
--- a/src/FetchRequests.tsx
+++ b/src/FetchRequests.tsx
@@ -47,8 +47,9 @@ import type {
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
-import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
-import type { ResourceField } from "../react-openapi";
+import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi";
+import type { FieldConfig } from "../react-openapi";
+import { useMutation, useQuery } from "@tanstack/react-query";
const statusColors: Record = {
pending: "default",
@@ -70,6 +71,16 @@ const statusIcons: Record = {
failed: ,
};
+const STATUS_OPTIONS: FetchRequestStatus[] = [
+ "pending",
+ "processing",
+ "paused",
+ "raw_expenses_done",
+ "enriched_done",
+ "completed",
+ "failed",
+];
+
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
@@ -107,33 +118,48 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
- const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
- const { data: listData, isLoading, isFetching, refetch } = useList({
- ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
- ...(accountFilter ? { account_name: accountFilter } : {}),
- ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
+ const { resources } = useAppContext();
+ const fetchRequestsRes = resources.find(r => r.name === "fetch-requests")!;
+ const { list, create, update, remove } = useResource(fetchRequestsRes);
+
+ const { data: listData, isLoading, isFetching, refetch } = useQuery({
+ queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
+ queryFn: () => list({
+ ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
+ ...(accountFilter ? { account_name: accountFilter } : {}),
+ ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
+ }),
});
- const { useList: useAccountsList } = useResourceByName("accounts");
- const { data: accountsData } = useAccountsList();
+ const accountsResource = resources.find(r => r.name === "accounts");
+ const { list: listAccounts } = accountsResource ? useResource(accountsResource) : { list: async () => ({ items: [] }) };
+ const { data: accountsData } = useQuery({
+ queryKey: ["accounts", "list"],
+ queryFn: () => listAccounts(),
+ enabled: !!accountsResource,
+ });
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]);
- const config = useConfig();
- const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
- const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
- const formatOptions: string[] = formatField?.options ?? [];
- const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
- const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
- const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
+ const formatField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "format");
+ const formatOptions: string[] = formatField?.enumValues ?? [];
+ const startDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "start_date");
+ const endDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "end_date");
+ const payorUsernameField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "payor_username");
- const createMutation = useCreate();
- const updateMutation = usePatch();
- const deleteMutation = useDelete();
+ const createMutation = useMutation({
+ mutationFn: (data: any) => create(data),
+ });
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
+ });
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => remove(id),
+ });
const uploadMutation = useUploadFile();
- const requests = listData?.data ?? [];
+ const requests = listData?.items ?? [];
const handleUpload = async () => {
if (!file) return;
@@ -262,9 +288,8 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath}
)}
- {formatField && components?.FormField ? (
-
) : (
<>
- {formatField && components?.FormField ? (
-
- {payorUsernameField && components?.FormField ? (
-
- {startDateField && components?.date ? (
+ {startDateField ? (
-
)}
- {endDateField && components?.date ? (
+ {endDateField ? (
- }
renderValue={(selected) => (selected as string[]).join(", ")}
>
- {(config?.enums?.FetchRequestStatus ?? []).map((s: string) => (
+ {STATUS_OPTIONS.map((s: string) => (
))}
diff --git a/src/ReportSnapshots.tsx b/src/ReportSnapshots.tsx
index e0a8944..077dfde 100644
--- a/src/ReportSnapshots.tsx
+++ b/src/ReportSnapshots.tsx
@@ -21,8 +21,9 @@ 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";
+import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi";
+import type { FieldConfig } from "../react-openapi";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
interface ReportSnapshotQuery {
accounts?: string[];
@@ -53,21 +54,32 @@ export default function ReportSnapshots() {
const [deleteTarget, setDeleteTarget] = React.useState(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState(null);
- const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
+ const { resources } = useAppContext();
+ const reportsResource = resources.find(r => r.name === "reports")!;
+ const { list, create, remove } = useResource(reportsResource);
- const { data: listData, isLoading, isFetching, refetch } = useList();
- const createMutation = useCreate();
- const deleteMutation = useDelete();
+ const { data: listData, isLoading, isFetching, refetch } = useQuery({
+ queryKey: ["reports", "list"],
+ queryFn: () => list(),
+ });
+ const createMutation = useMutation({
+ mutationFn: (data: any) => create(data),
+ });
+ const queryClient = useQueryClient();
+ const deleteMutation = useMutation({
+ mutationFn: (id: string) => remove(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
+ },
+ });
- 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 ignoreSelfField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "ignore_self");
+ const startDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "start_date");
+ const endDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "end_date");
+ const minAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "min_amount");
+ const maxAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "max_amount");
- const snapshots: ReportSnapshot[] = listData?.data ?? [];
+ const snapshots: ReportSnapshot[] = listData?.items ?? [];
const handleCreate = async () => {
try {
@@ -123,14 +135,13 @@ export default function ReportSnapshots() {
- {ignoreSelfField && components?.FormField && (
- setIgnoreSelf(val)}
/>
- )}
+ ) : null}
@@ -158,26 +169,24 @@ export default function ReportSnapshots() {
- {minAmountField && components?.FormField && (
+ {minAmountField ? (
- setMinAmount(val)}
/>
- )}
- {maxAmountField && components?.FormField && (
+ ) : null}
+ {maxAmountField ? (
- setMaxAmount(val)}
/>
- )}
+ ) : null}