updated sse supporting react-openapi
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
862
react-openapi/README.md
Normal 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' }
|
||||||
|
```
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
68
react-openapi/src/components/FilterBar.tsx
Normal file
68
react-openapi/src/components/FilterBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
34
react-openapi/src/components/SseConnectionStatus.tsx
Normal file
34
react-openapi/src/components/SseConnectionStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
react-openapi/src/components/SseStreamView.tsx
Normal file
96
react-openapi/src/components/SseStreamView.tsx
Normal 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…
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snackbarOpen}
|
||||||
|
autoHideDuration={2000}
|
||||||
|
onClose={() => setSnackbarOpen(false)}
|
||||||
|
message={snackbarMsg}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
509
react-openapi/src/context/useResource.tsx
Normal file
509
react-openapi/src/context/useResource.tsx
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ export type {
|
|||||||
ReportSnapshot,
|
ReportSnapshot,
|
||||||
ReportQuery,
|
ReportQuery,
|
||||||
} from "./report-snapshots.models";
|
} from "./report-snapshots.models";
|
||||||
|
export {
|
||||||
|
useReportSnapshotsList,
|
||||||
|
useCreateSnapshot,
|
||||||
|
useDeleteSnapshot,
|
||||||
|
} from "./useReportSnapshots";
|
||||||
@@ -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"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/main.jsx
15
src/main.jsx
@@ -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 />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user