updated sse supporting react-openapi

This commit is contained in:
2026-06-18 20:32:34 +05:30
parent 0a668cf98d
commit 154b15fe51
28 changed files with 2132 additions and 440 deletions

7
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"remark-gfm": "latest" "remark-gfm": "latest"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
@@ -1632,6 +1633,12 @@
"@types/unist": "*" "@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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",

View File

@@ -27,6 +27,7 @@
"remark-gfm": "latest" "remark-gfm": "latest"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "latest" "vite": "latest"

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

@@ -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 (
<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`. |
### 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' }
```

View File

@@ -2,4 +2,10 @@ export { AppProvider } from "./src/context/AppProvider";
export { Admin } from "./src/components/Admin"; export { Admin } from "./src/components/Admin";
export { useAppContext } from "./src/context/AppContext"; export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource"; export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { 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"; export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types";

View File

@@ -44,9 +44,13 @@ export function Admin({ basePath }: AdminProps) {
{resources.map((r) => ( {resources.map((r) => (
<React.Fragment key={r.name}> <React.Fragment key={r.name}>
<Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} /> <Route path={r.name} element={<ResourceList resource={r} basePath={basePath} />} />
<Route path={`${r.name}/new`} element={<ResourceForm resource={r} basePath={basePath} mode="create" />} /> {!r.streaming && (
<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" />} /> <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> </React.Fragment>
))} ))}
</Routes> </Routes>

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

@@ -23,7 +23,7 @@ interface ResourceDetailProps {
export function ResourceDetail({ resource, basePath }: ResourceDetailProps) { export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const crud = useResource(resource); const crud = useResource(resource.name);
const { resources: allResources } = useAppContext(); const { resources: allResources } = useAppContext();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -28,7 +28,7 @@ interface ResourceFormProps {
export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) { export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams(); const { id } = useParams();
const crud = useResource(resource); const crud = useResource(resource.name);
const { resources: allResources } = useAppContext(); const { resources: allResources } = useAppContext();
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
@@ -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 ( return (
<Box> <Box>

View File

@@ -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 { useNavigate } from "react-router-dom";
import { import {
Box, Box,
@@ -14,42 +14,127 @@ import {
TableRow, TableRow,
TablePagination, TablePagination,
Paper, Paper,
TextField,
InputAdornment,
TableSortLabel, TableSortLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
} from "@mui/material"; } from "@mui/material";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import SearchIcon from "@mui/icons-material/Search";
import type { ResourceConfig, FieldConfig } from "../types"; import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource"; import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext"; 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 { interface ResourceListProps {
resource: ResourceConfig; resource: ResourceConfig;
basePath: string; 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) { export function ResourceList({ resource, basePath }: ResourceListProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const crud = useResource(resource); const { components, ...crud } = useResource(resource.name);
const { resources: allResources } = useAppContext(); const { resources: allResources, config } = useAppContext();
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20); const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20);
const [search, setSearch] = useState("");
const [sortField, setSortField] = useState<string | null>(null); const [sortField, setSortField] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); 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 const visibleColumns = resource.listColumns
.map((colName) => resource.fields.find((f) => f.name === colName)) .map((colName) => resource.fields.find((f) => f.name === colName))
.filter((f): f is FieldConfig => !!f && !f.hidden?.list); .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<string, any> = {}; const params: Record<string, any> = {};
if (resource.pagination) { if (resource.pagination) {
params[resource.pagination.limitParam] = rowsPerPage; params[resource.pagination.limitParam] = rowsPerPage;
@@ -58,19 +143,76 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
if (sortField) { if (sortField) {
params.sort = sortDir === "desc" ? `-${sortField}` : 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); const result = await crud.list(params);
setData(result.items ?? []); setData(result.items ?? []);
setTotal(result.total ?? result.items?.length ?? 0); 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<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(() => { useEffect(() => {
fetchData(); if (isStreaming) return;
}, [fetchData]); 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) => { const handleDelete = async (id: string | number) => {
if (!window.confirm("Are you sure you want to delete this item?")) return; if (!window.confirm("Are you sure you want to delete this item?")) return;
await crud.remove(id); await crud.remove(id);
fetchData(); if (isClientMode) {
clientFetchAll();
} else {
serverFetchData();
}
}; };
const handleSort = (field: string) => { 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 ( return (
<Box> <Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}> <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Typography variant="h5" fontWeight={700}> <Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
{resource.schemaName} <Typography variant="h5" fontWeight={700}>
</Typography> {resource.displayName}
{resource.operations.create && ( </Typography>
<Button {isStreaming && <SseConnectionStatus resourceName={resource.name} />}
variant="contained" </Box>
startIcon={<AddIcon />} <Box sx={{ display: "flex", gap: 1 }}>
onClick={() => navigate(`${basePath}/${resource.name}/new`)} {isStreaming && data.length > 0 && (
> <Button variant="outlined" size="small" onClick={() => { setData([]); setTotal(0); clearSseCache(resource.name); }}>
Create Clear ({data.length})
</Button> </Button>
)} )}
{resource.operations.create && !isStreaming && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
>
Create
</Button>
)}
</Box>
</Box> </Box>
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "center" }}> {!isStreaming && (
<TextField <FilterBar
size="small" resourceName={resource.name}
placeholder="Search..." filters={filters}
value={search} onFilterChange={handleFilterChange}
onChange={(e) => setSearch(e.target.value)} onClear={() => setFilters({})}
InputProps={{ data={data}
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
sx={{ minWidth: 280 }}
/> />
</Box> )}
<TableContainer component={Paper} variant="outlined"> <TableContainer component={Paper} variant="outlined">
<Table size="small"> <Table size="small">
@@ -135,27 +284,33 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
)} )}
</TableCell> </TableCell>
))} ))}
<TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell> {hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data.length === 0 ? ( {displayData.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={visibleColumns.length + 1} align="center"> <TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}> <Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
No records found {isStreaming ? "Waiting for events\u2026" : "No records found"}
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.map((row) => { displayData.map((row, idx) => {
const rowId = row[resource.primaryKey]; const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey];
return ( return (
<TableRow <TableRow
key={rowId} key={rowId}
hover hover
sx={{ cursor: "pointer" }} sx={{ cursor: "pointer" }}
onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)} onClick={() => {
if (isStreaming) {
setDetailRow(row);
} else {
navigate(`${basePath}/${resource.name}/${rowId}`);
}
}}
> >
{visibleColumns.map((col) => { {visibleColumns.map((col) => {
let value = row[col.name]; let value = row[col.name];
@@ -172,29 +327,31 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
</TableCell> </TableCell>
); );
})} })}
<TableCell align="right" onClick={(e) => e.stopPropagation()}> {hasActions && (
{resource.operations.get && ( <TableCell align="right" onClick={(e) => e.stopPropagation()}>
<Tooltip title="View"> {resource.operations.get && !isStreaming && (
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}> <Tooltip title="View">
<VisibilityIcon fontSize="small" /> <IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
</IconButton> <VisibilityIcon fontSize="small" />
</Tooltip> </IconButton>
)} </Tooltip>
{resource.operations.update && ( )}
<Tooltip title="Edit"> {resource.operations.update && (
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> <Tooltip title="Edit">
<EditIcon fontSize="small" /> <IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
</IconButton> <EditIcon fontSize="small" />
</Tooltip> </IconButton>
)} </Tooltip>
{resource.operations.delete && ( )}
<Tooltip title="Delete"> {resource.operations.delete && (
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error"> <Tooltip title="Delete">
<DeleteIcon fontSize="small" /> <IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
</IconButton> <DeleteIcon fontSize="small" />
</Tooltip> </IconButton>
)} </Tooltip>
</TableCell> )}
</TableCell>
)}
</TableRow> </TableRow>
); );
}) })
@@ -203,10 +360,10 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
</Table> </Table>
</TableContainer> </TableContainer>
{resource.pagination && ( {!isStreaming && (resource.pagination || isClientMode) && (
<TablePagination <TablePagination
component="div" component="div"
count={total} count={displayTotal}
page={page} page={page}
onPageChange={(_, p) => setPage(p)} onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage} rowsPerPage={rowsPerPage}
@@ -217,6 +374,34 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
rowsPerPageOptions={[10, 20, 50, 100]} 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}
/>
</Grid>
))}
</Grid>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailRow(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View File

@@ -67,7 +67,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} /> <CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={r.schemaName} primary={r.displayName}
primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }} primaryTypographyProps={{ fontWeight: active ? 700 : 500, fontSize: 14 }}
/> />
</ListItemButton> </ListItemButton>

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,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<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),
});
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

@@ -1,14 +1,44 @@
import React from "react"; 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"; import type { FieldConfig } from "../../../types";
interface Props { interface Props {
field: FieldConfig; field: FieldConfig;
value: any; value: any;
onChange: (value: any) => void; 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 (
<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 ( return (
<FormControl component="fieldset" fullWidth size="small"> <FormControl component="fieldset" fullWidth size="small">
<FormControlLabel <FormControlLabel

View File

@@ -1,147 +0,0 @@
import { useState, useCallback } from "react";
import type { ResourceConfig, ParsedListResponse } from "../types";
import { getApi } from "../hooks/useApi";
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;
}
interface UseResourceReturn {
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
get: (id: string | number) => Promise<any>;
create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useResource(resource: ResourceConfig): UseResourceReturn {
const [state, setState] = useState<ResourceState>({ 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<string, any>): Promise<ParsedListResponse> => {
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<any> => {
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<any> => {
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<any> => {
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<void> => {
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 };
}

View File

@@ -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<string, React.FC<FilterComponentProps>>;
list: (params?: Record<string, any>) => Promise<ParsedListResponse>;
get: (id: string | number) => Promise<any>;
create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers) => 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 ?? "")}
/>
);
}
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<FilterComponentProps> = ({ value, onChange, data, labelOverride }) => {
const { resources, config } = useAppContext();
const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server";
const [options, setOptions] = useState<string[]>([]);
const fetched = useRef(false);
useEffect(() => {
if (filterMode === "client" && data) {
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<string, any> = {};
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 (
<Autocomplete
freeSolo
size="small"
options={options}
value={value || null}
onInputChange={(_, newVal) => onChange(newVal ?? "")}
renderInput={(params) => (
<TextField
{...params}
label={labelOverride ?? field.label}
size="small"
/>
)}
/>
);
};
return StringAutocompleteFilter;
}
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 } = useAppContext();
const resource = resources.find((r) => r.name === resourceName);
if (!resource) {
throw new Error(`Resource "${resourceName}" not found`);
}
const [state, setState] = useState<ResourceState>({ 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<string, any>): Promise<ParsedListResponse> => {
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<any> => {
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<any> => {
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<any> => {
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<void> => {
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<string, React.FC<FilterComponentProps>> = {};
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 };
}

View File

@@ -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` }); 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 listParams = collectionPath?.get?.parameters ?? [];
const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit"); const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit");
const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset"); const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset");

View File

@@ -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 { extractFields } from "./field-config";
import { extractRelationships } from "./relationship-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[] { export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
const schemas = spec.components?.schemas ?? {}; const schemas = spec.components?.schemas ?? {};
const paths = spec.paths ?? {}; const paths = spec.paths ?? {};
@@ -46,10 +65,12 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
const fields = extractFields(schemaName, schema, schemas); const fields = extractFields(schemaName, schema, schemas);
const relationships = extractRelationships(schema, schemas); const relationships = extractRelationships(schema, schemas);
const hasSSE = collectionPathObj?.get?.["x-sse"] === true;
const resource: ResourceConfig = { const resource: ResourceConfig = {
name: resourceName, name: resourceName,
schemaName, schemaName,
displayName: formatDisplayName(resourceName),
path: resourcePath, path: resourcePath,
primaryKey: schema["x-primary-key"], primaryKey: schema["x-primary-key"],
displayFormat: schema["x-display-format"], displayFormat: schema["x-display-format"],
@@ -65,10 +86,21 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
}, },
pagination: detectPagination(collectionPathObj), pagination: detectPagination(collectionPathObj),
relationships, 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); configs.push(resource);
} }
return configs; return configs;
} }

View File

@@ -1,8 +1,17 @@
export type FilterMode = "client" | "server";
export interface ResourceConfiguration {
filterOptions?: {
mode?: FilterMode;
};
}
export interface SpecConfiguration { export interface SpecConfiguration {
specUrl: string; specUrl: string;
baseApiUrl?: string; baseApiUrl?: string;
title?: string; title?: string;
getToken?: () => string | null; getToken?: () => string | null;
resourceConfig?: Record<string, ResourceConfiguration>;
} }
export interface ValidationMessage { export interface ValidationMessage {
@@ -19,6 +28,7 @@ export interface ResourceRelationship {
export interface ResourceConfig { export interface ResourceConfig {
name: string; name: string;
schemaName: string; schemaName: string;
displayName: string;
path: string; path: string;
primaryKey: string; primaryKey: string;
displayFormat: string; displayFormat: string;
@@ -38,6 +48,7 @@ export interface ResourceConfig {
defaultLimit: number; defaultLimit: number;
} | null; } | null;
relationships: ResourceRelationship[]; relationships: ResourceRelationship[];
streaming?: boolean;
} }
export interface FieldConfig { export interface FieldConfig {

View File

@@ -23,7 +23,7 @@ import {
useReport, useReport,
prepareReport, prepareReport,
} from "./features/report"; } from "./features/report";
import { useResourceByName } from "../react-openapi"; import { useReportSnapshotsList } from "./features/report-snapshots";
function formatSnapshotDate(iso: string) { function formatSnapshotDate(iso: string) {
const d = new Date(iso); const d = new Date(iso);
@@ -56,13 +56,13 @@ export default function Dashboard() {
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null); const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
const { data: snapshotsData } = useResourceByName("reports").useList(); const { data: snapshotsData } = useReportSnapshotsList();
const snapshotOptions = React.useMemo(() => { const snapshotOptions = React.useMemo(() => {
const options: { label: string; value: string | null }[] = [ const options: { label: string; value: string | null }[] = [
{ label: "Latest (auto)", value: null }, { label: "Latest (auto)", value: null },
]; ];
if (snapshotsData?.data) { if (snapshotsData?.items) {
for (const snap of snapshotsData.data) { for (const snap of snapshotsData.items) {
options.push({ options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`, label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id, value: snap.snapshot_id,

View File

@@ -35,7 +35,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 } 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",
@@ -144,11 +145,18 @@ function isMathValid(candidate: { amount: number; balance: number }, prevBalance
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 { 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 } = 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!);
@@ -193,8 +201,8 @@ export default function FetchRequestDetail() {
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]); }, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => { React.useEffect(() => {
if (!id || !config?.baseUrl) return; if (!id || !config?.baseApiUrl) return;
const url = `${config.baseUrl}/fetch-requests/${id}/events`; const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url); const es = new EventSource(url);
sseRef.current = es; sseRef.current = es;
@@ -243,7 +251,7 @@ export default function FetchRequestDetail() {
es.close(); es.close();
sseRef.current = null; sseRef.current = null;
}; };
}, [id, config?.baseUrl]); }, [id, config?.baseApiUrl]);
React.useEffect(() => { React.useEffect(() => {
if (feedRef.current) { if (feedRef.current) {

View File

@@ -47,8 +47,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 { useAppContext, useResource, FormFieldRenderer } 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,6 +71,16 @@ const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />, failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
}; };
const STATUS_OPTIONS: FetchRequestStatus[] = [
"pending",
"processing",
"paused",
"raw_expenses_done",
"enriched_done",
"completed",
"failed",
];
function formatDate(iso: string) { function formatDate(iso: string) {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleString(); return d.toLocaleString();
@@ -107,33 +118,48 @@ 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 { resources } = useAppContext();
const { data: listData, isLoading, isFetching, refetch } = useList({ const fetchRequestsRes = resources.find(r => r.name === "fetch-requests")!;
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), const { list, create, update, remove } = useResource(fetchRequestsRes);
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), 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 accountsResource = resources.find(r => r.name === "accounts");
const { data: accountsData } = useAccountsList(); 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(() => { 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 formatField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "format");
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests"); const formatOptions: string[] = formatField?.enumValues ?? [];
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format; const startDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "start_date");
const formatOptions: string[] = formatField?.options ?? []; const endDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "end_date");
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date; const payorUsernameField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.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({
const updateMutation = usePatch(); mutationFn: (data: any) => create(data),
const deleteMutation = useDelete(); });
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 uploadMutation = useUploadFile();
const requests = listData?.data ?? []; const requests = listData?.items ?? [];
const handleUpload = async () => { const handleUpload = async () => {
if (!file) return; if (!file) return;
@@ -262,9 +288,8 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath} Uploaded as: {uploadedPath}
</Alert> </Alert>
)} )}
{formatField && components?.FormField ? ( {formatField ? (
<components.FormField <FormFieldRenderer
name="format"
field={formatField} field={formatField}
value={format} value={format}
onChange={setFormat} onChange={setFormat}
@@ -282,9 +307,8 @@ export default function FetchRequests() {
</> </>
) : ( ) : (
<> <>
{formatField && components?.FormField ? ( {formatField ? (
<components.FormField <FormFieldRenderer
name="format"
field={formatField} field={formatField}
value={format} value={format}
onChange={setFormat} onChange={setFormat}
@@ -314,9 +338,8 @@ export default function FetchRequests() {
)} )}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }} sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/> />
{payorUsernameField && components?.FormField ? ( {payorUsernameField ? (
<components.FormField <FormFieldRenderer
name="payor_username"
field={payorUsernameField} field={payorUsernameField}
value={payorUsername} value={payorUsername}
onChange={setPayorUsername} onChange={setPayorUsername}
@@ -326,10 +349,9 @@ export default function FetchRequests() {
)} )}
<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
name="start_date"
field={startDateField} field={startDateField}
value={startDate} value={startDate}
onChange={setStartDate} onChange={setStartDate}
@@ -347,10 +369,9 @@ export default function FetchRequests() {
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
)} )}
{endDateField && components?.date ? ( {endDateField ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.date <FormFieldRenderer
name="end_date"
field={endDateField} field={endDateField}
value={endDate} value={endDate}
onChange={setEndDate} onChange={setEndDate}
@@ -391,7 +412,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: string) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem> <MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))} ))}
</Select> </Select>

View File

@@ -21,8 +21,9 @@ import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle"; import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi";
import type { ResourceField } from "../react-openapi"; import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
interface ReportSnapshotQuery { interface ReportSnapshotQuery {
accounts?: string[]; accounts?: string[];
@@ -53,21 +54,32 @@ export default function ReportSnapshots() {
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null); const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(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 { data: listData, isLoading, isFetching, refetch } = useQuery({
const createMutation = useCreate(); queryKey: ["reports", "list"],
const deleteMutation = useDelete(); 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 ignoreSelfField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "ignore_self");
const reportsRes = config?.resources.find((r: any) => r.name === "reports"); const startDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "start_date");
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self; const endDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "end_date");
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date; const minAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "min_amount");
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date; const maxAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "max_amount");
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
const snapshots: ReportSnapshot[] = listData?.data ?? []; const snapshots: ReportSnapshot[] = listData?.items ?? [];
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -123,14 +135,13 @@ export default function ReportSnapshots() {
</Typography> </Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ignoreSelfField && components?.FormField && ( {ignoreSelfField ? (
<components.FormField <FormFieldRenderer
name="ignore_self"
field={ignoreSelfField} field={ignoreSelfField}
value={ignoreSelf} value={ignoreSelf}
onChange={(val: boolean) => setIgnoreSelf(val)} onChange={(val: boolean) => setIgnoreSelf(val)}
/> />
)} ) : null}
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
@@ -158,26 +169,24 @@ export default function ReportSnapshots() {
</Box> </Box>
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
{minAmountField && components?.FormField && ( {minAmountField ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.FormField <FormFieldRenderer
name="min_amount"
field={minAmountField} field={minAmountField}
value={minAmount} value={minAmount}
onChange={(val: string) => setMinAmount(val)} onChange={(val: string) => setMinAmount(val)}
/> />
</Box> </Box>
)} ) : null}
{maxAmountField && components?.FormField && ( {maxAmountField ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.FormField <FormFieldRenderer
name="max_amount"
field={maxAmountField} field={maxAmountField}
value={maxAmount} value={maxAmount}
onChange={(val: string) => setMaxAmount(val)} onChange={(val: string) => setMaxAmount(val)}
/> />
</Box> </Box>
)} ) : null}
</Box> </Box>
<Button <Button

View File

@@ -1,5 +1,4 @@
import { useResourceByName } from "../../../react-openapi"; import { useAppContext, useResource, getApi } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models"; import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
@@ -8,28 +7,51 @@ export function useFetchRequestsList(params?: {
account_name?: string; account_name?: string;
source_type?: string; source_type?: string;
}) { }) {
const { useList } = useResourceByName("fetch-requests"); const { resources } = useAppContext();
return useList(params); const resource = resources.find(r => r.name === "fetch-requests")!;
const { list } = useResource(resource);
return useQuery({
queryKey: ["fetch-requests", "list", params],
queryFn: () => list(params),
});
} }
export function useFetchRequest(id: string) { export function useFetchRequest(id: string) {
const { useRead } = useResourceByName("fetch-requests"); const { resources } = useAppContext();
return useRead(id); const resource = resources.find(r => r.name === "fetch-requests")!;
const { get } = useResource(resource);
return useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id),
enabled: !!id,
});
} }
export function useCreateFetchRequest() { export function useCreateFetchRequest() {
const { useCreate } = useResourceByName("fetch-requests"); const { resources } = useAppContext();
return useCreate(); const resource = resources.find(r => r.name === "fetch-requests")!;
const { create } = useResource(resource);
return useMutation({
mutationFn: (data: any) => create(data),
});
} }
export function useUpdateFetchRequest() { export function useUpdateFetchRequest() {
const { usePatch } = useResourceByName("fetch-requests"); const { resources } = useAppContext();
return usePatch(); const resource = resources.find(r => r.name === "fetch-requests")!;
const { update } = useResource(resource);
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
} }
export function useDeleteFetchRequest() { export function useDeleteFetchRequest() {
const { useDelete } = useResourceByName("fetch-requests"); const { resources } = useAppContext();
return useDelete(); const resource = resources.find(r => r.name === "fetch-requests")!;
const { remove } = useResource(resource);
return useMutation({
mutationFn: (id: string) => remove(id),
});
} }
export function useUploadFile() { export function useUploadFile() {
@@ -37,6 +59,7 @@ export function useUploadFile() {
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer); const binary = new Uint8Array(arrayBuffer);
const api = getApi();
const res = await api.post("/uploads", binary, { const res = await api.post("/uploads", binary, {
headers: { headers: {
"Content-Type": file.type, "Content-Type": file.type,
@@ -52,6 +75,7 @@ export function useFetchRequestAmbiguities(fetchRequestId: string) {
return useQuery({ return useQuery({
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"], queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => { queryFn: async () => {
const api = getApi();
const res = await api.get( const res = await api.get(
`/fetch-requests/${fetchRequestId}/ambiguities` `/fetch-requests/${fetchRequestId}/ambiguities`
); );
@@ -72,6 +96,7 @@ export function useResolveAmbiguity() {
ambiguityId: string; ambiguityId: string;
payload: ResolveAmbiguityPayload; payload: ResolveAmbiguityPayload;
}) => { }) => {
const api = getApi();
const res = await api.post( const res = await api.post(
`/ambiguities/${ambiguityId}/resolve`, `/ambiguities/${ambiguityId}/resolve`,
payload payload

View File

@@ -2,3 +2,8 @@ export type {
ReportSnapshot, ReportSnapshot,
ReportQuery, ReportQuery,
} from "./report-snapshots.models"; } from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -1,16 +1,34 @@
import { useResourceByName } from "../../../react-openapi"; import { useAppContext, useResource } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export function useReportSnapshotsList() { export function useReportSnapshotsList() {
const { useList } = useResourceByName("reports"); const { resources } = useAppContext();
return useList(); const resource = resources.find(r => r.name === "reports")!;
const { list } = useResource(resource);
return useQuery({
queryKey: ["reports", "list"],
queryFn: () => list(),
});
} }
export function useCreateSnapshot() { export function useCreateSnapshot() {
const { useCreate } = useResourceByName("reports"); const { resources } = useAppContext();
return useCreate(); const resource = resources.find(r => r.name === "reports")!;
const { create } = useResource(resource);
return useMutation({
mutationFn: (data: any) => create(data),
});
} }
export function useDeleteSnapshot() { export function useDeleteSnapshot() {
const { useDelete } = useResourceByName("reports"); const queryClient = useQueryClient();
return useDelete(); const { resources } = useAppContext();
const resource = resources.find(r => r.name === "reports")!;
const { remove } = useResource(resource);
return useMutation({
mutationFn: (id: string) => remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reports", "list"] });
},
});
} }

View File

@@ -1,4 +1,5 @@
import { useResourceByName } from "../../../react-openapi"; import { useAppContext, useResource } from "../../../react-openapi";
import { useQuery } from "@tanstack/react-query";
export interface ReportParams { export interface ReportParams {
snapshot_id?: string; snapshot_id?: string;
@@ -9,13 +10,13 @@ export interface ReportParams {
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports"); const { resources } = useAppContext();
const resource = resources.find(r => r.name === "reports")!;
const { get } = useResource(resource);
return useRead( return useQuery({
params.snapshot_id ? params.snapshot_id : "latest", queryKey: ["reports", "read", params],
{ queryFn: () =>
...params, get(params.snapshot_id ? params.snapshot_id : "latest"),
periods: params.periods, });
}
);
} }

View File

@@ -15,8 +15,7 @@ import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests'; import FetchRequests from './FetchRequests';
import FetchRequestDetail from './FetchRequestDetail'; import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots'; import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi'; import { AppProvider, Admin } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import process from 'process'; import process from 'process';
import { AuthProvider } from "../react-auth"; import { AuthProvider } from "../react-auth";
@@ -31,6 +30,14 @@ const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
const API_BASE = import.meta.env.VITE_API_BASE_URL;
const specConfig = {
specUrl: `${API_BASE}/openapi.yaml`,
baseApiUrl: API_BASE,
title: 'Khata Admin',
getToken: () => localStorage.getItem('token'),
};
const routerMapping = [ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
@@ -43,7 +50,7 @@ const routerMapping = [
]; ];
root.render( root.render(
<AppProvider resourceOverrides={configuration} profileConfig={profileConfiguration}> <AppProvider specConfiguration={specConfig}>
<BrowserRouter> <BrowserRouter>
<AuthProvider authBaseUrl={AUTH_BASE}> <AuthProvider authBaseUrl={AUTH_BASE}>
<AppTheme> <AppTheme>
@@ -60,7 +67,7 @@ root.render(
path={path} path={path}
element={ element={
path.startsWith("/admin") ? ( path.startsWith("/admin") ? (
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} /> <Component basePath="/admin" />
) : ( ) : (
<Component /> <Component />
) )

View File

@@ -1,103 +0,0 @@
import { ResourceOverride } from "../react-openapi";
export const configuration: Record<string, ResourceOverride> = {
expenses: {
filterOptions: {
mode: "client",
fields: ["account", "payee", "tags", "occurred_at", "amount"],
},
fields: {
payee: {
displayFormat: "{name}",
filterType: "autocomplete",
},
payor: {
display: false,
displayFormat: "{username}",
},
account: {
displayFormat: "{name}",
filterType: "multiselect",
refers: "accounts"
},
tags: {
displayFormat: "{icon} {name}",
filterType: "autocomplete",
refers: "tags"
},
occurred_at: {
filterType: "date-range",
formatter: (val: string) => {
const date = new Date(val);
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
const year = date.getFullYear();
const suffix = (day: number) => {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
return `${day}${suffix(day)} ${month} ${year}`;
}
},
amount: {
filterType: "number-range",
},
created_at: {
display: false
}
},
},
'fetch-requests': {
fields: {
format: {
path: 'source.format',
},
start_date: {
type: 'date',
label: 'Start Date',
},
end_date: {
type: 'date',
label: 'End Date',
},
// account: {
// refers: 'accounts',
// },
// tags: {
// refers: 'tags',
// },
},
},
accounts: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{name} - XX{number}',
},
autoComplete: true,
prefetch: true,
}
},
tags: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{icon} {name}',
},
autoComplete: true,
prefetch: true,
}
},
};
export const profileConfiguration = {
"extraFields": ['name'],
"resource": "payors",
// not in use
"hidden": true,
};