From 154b15fe517c064fe2aa9efeb0fab7becb814d2b Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 18 Jun 2026 20:32:34 +0530 Subject: [PATCH] updated sse supporting react-openapi --- package-lock.json | 7 + package.json | 1 + react-openapi/README.md | 862 ++++++++++++++++++ react-openapi/index.ts | 6 + react-openapi/src/components/Admin.tsx | 10 +- react-openapi/src/components/FilterBar.tsx | 68 ++ .../src/components/ResourceDetail.tsx | 2 +- react-openapi/src/components/ResourceForm.tsx | 4 +- react-openapi/src/components/ResourceList.tsx | 329 +++++-- react-openapi/src/components/SideMenu.tsx | 2 +- .../src/components/SseConnectionStatus.tsx | 34 + .../src/components/SseStreamView.tsx | 96 ++ .../fields/renderers/BooleanField.tsx | 34 +- react-openapi/src/context/useResource.ts | 147 --- react-openapi/src/context/useResource.tsx | 509 +++++++++++ react-openapi/src/spec-validator.ts | 3 + .../src/transformers/resource-config.ts | 36 +- react-openapi/src/types.ts | 11 + src/Dashboard.tsx | 8 +- src/FetchRequestDetail.tsx | 24 +- src/FetchRequests.tsx | 95 +- src/ReportSnapshots.tsx | 61 +- .../fetch-requests/useFetchRequests.ts | 49 +- src/features/report-snapshots/index.ts | 5 + .../report-snapshots/useReportSnapshots.ts | 32 +- src/features/report/useReport.ts | 19 +- src/main.jsx | 15 +- src/openapi-config.ts | 103 --- 28 files changed, 2132 insertions(+), 440 deletions(-) create mode 100644 react-openapi/README.md create mode 100644 react-openapi/src/components/FilterBar.tsx create mode 100644 react-openapi/src/components/SseConnectionStatus.tsx create mode 100644 react-openapi/src/components/SseStreamView.tsx delete mode 100644 react-openapi/src/context/useResource.ts create mode 100644 react-openapi/src/context/useResource.tsx delete mode 100644 src/openapi-config.ts diff --git a/package-lock.json b/package-lock.json index 90d0a28..8902cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "remark-gfm": "latest" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@vitejs/plugin-react": "latest", "typescript": "^6.0.3", "vite": "latest" @@ -1632,6 +1633,12 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 73ae1ae..337a52d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "remark-gfm": "latest" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@vitejs/plugin-react": "latest", "typescript": "^6.0.3", "vite": "latest" diff --git a/react-openapi/README.md b/react-openapi/README.md new file mode 100644 index 0000000..bb449fb --- /dev/null +++ b/react-openapi/README.md @@ -0,0 +1,862 @@ +# react-openapi + +Auto-generates an admin panel (CRUD, datatable, relationship management) from an OpenAPI 3.1.0 specification at runtime. + +## How It Works + +1. You provide a `specConfiguration` object with the URL to your OpenAPI YAML spec and an auth callback. +2. `AppProvider` fetches the spec, validates it against ``react-openapi``'s required extensions, and builds an internal resource configuration. +3. The `Admin` component renders routes (list, detail, create, edit) for each resource detected in the spec. +4. All API calls go through an Axios client that injects the Bearer token from your callback. + +## Installation + +```bash +npm install react-openapi +# peer dependencies (install these in your app): +npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml +``` + +## Quick Start + +```tsx +import { AppProvider, Admin } from "react-openapi"; +import type { SpecConfiguration } from "react-openapi"; + +const specConfig: SpecConfiguration = { + specUrl: "/api/openapi.yaml", + baseApiUrl: "https://api.example.com/v1", + title: "Vet Clinic Admin", + getToken: () => localStorage.getItem("token"), +}; + +function App() { + return ( + + + + ); +} +``` + +## Exported API + +### Components + +| Export | Purpose | +|---------------|------------------------------------------------------------------------------------------------------------| +| `AppProvider` | Fetches the spec, validates it, initializes the API client, and provides context. Wrap your app with this. | +| `Admin` | Renders the admin layout (sidebar + routes) for all resources. Takes `basePath`. | + +### Hooks + +| Export | Purpose | +|-------------------------|---------------------------------------------------------------------------------------------------------| +| `useAppContext()` | Returns `{ config, resources, loading, errors, warnings }`. Access all resource configs. | +| `useResource(resource)` | Returns `{ list, get, create, update, remove, loading, error }` — CRUD methods for a specific resource. | + +### Types + +| Export | Purpose | +|------------------------|----------------------------------------------------------| +| `SpecConfiguration` | `{ specUrl, baseApiUrl?, title?, getToken? }` | +| `ResourceConfig` | Internal representation of a resource after spec parsing | +| `FieldConfig` | Internal representation of a field after spec parsing | +| `FKFieldConfig` | `{ resource, prefetch }` | +| `ResourceRelationship` | `{ fieldName, config, targetSchemaName }` | + +## OpenAPI Spec Authoring Guide + +``react-openapi`` uses custom `x-` extensions embedded in your OpenAPI 3.1.0 spec. This section is the **single source of truth** for writing a compatible spec. + +### Quick Reference + +```yaml +openapi: 3.1.0 +info: + title: My API + version: 1.0.0 + +servers: + - url: https://api.example.com/v1 + +components: + schemas: + + # --- Error schemas --- + ErrorBody: + type: object + properties: + detail: + type: string + required: [detail] + + HTTPValidationError: + type: object + properties: + detail: + type: array + items: + $ref: '#/components/schemas/ValidationError' + + ValidationError: + type: object + properties: + loc: + type: array + items: + type: string + msg: + type: string + type: + type: string + required: [loc, msg, type] + + # --- Non-resource schema (skipped by validator) --- + Metadata: + type: object + properties: + createdOn: + type: string + format: date-time + updatedOn: + type: string + format: date-time + + # --- Resource schema --- + Pet: + type: object + x-resource: pets # REQUIRED - maps to /pets path + x-primary-key: id # REQUIRED + x-display-format: "{name} - #{id}" # REQUIRED + x-list-columns: [name, species, age] # REQUIRED + properties: + id: + type: integer + readOnly: true + x-order: 0 # REQUIRED on every property + x-hidden: { form: true, list: true } + x-label: "ID" # REQUIRED on every property + name: + type: string + x-order: 1 + x-label: "Pet Name" + x-description: "Name of the pet" # optional + x-filterable: true # optional + x-sortable: true # optional + species: + type: string + enum: [dog, cat, bird] # renders as select dropdown + x-order: 2 + x-label: "Species" + weight: + type: number + format: float + x-order: 3 + x-label: "Weight" + birthDate: + type: string + format: date # renders as date picker + x-order: 4 + x-label: "Date of Birth" + photo: + type: string + format: binary + x-ui-type: image # renders as file upload + x-upload-url: /pets/{id}/photo # POST endpoint for upload + x-order: 5 + x-label: "Photo" + owner: + $ref: '#/components/schemas/Parent' + x-fk: # renders as FK dropdown + resource: parents + prefetch: true # load all options on mount + x-order: 6 + x-label: "Owner" + siblings: + type: array + items: + $ref: '#/components/schemas/Pet' + x-fk: # renders as multi-select + resource: pets + prefetch: false # lazy-load on focus + x-order: 7 + x-label: "Siblings" + metadata: + $ref: '#/components/schemas/Metadata' + # NO x-fk — renders as inline read-only display + x-order: 8 + x-label: "Metadata" + required: [id, name, owner] + + # --- Error response schemas (REQUIRED for spec validity) --- + responses: + Unauthorized: + description: Not authenticated + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + Forbidden: + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + ValidationError: + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + +paths: + # --- Collection paths --- + /pets: + get: + summary: List pets (paginated) + parameters: + - in: query + name: limit + schema: { type: integer, default: 20 } # default is REQUIRED if pagination params exist + - in: query + name: offset + schema: { type: integer, default: 0 } + responses: + '200': + description: Paginated list of pets + content: + application/json: + schema: + type: object + properties: + total: + type: integer + items: + type: array + items: + $ref: '#/components/schemas/Pet' + # --- Error responses (RECOMMENDED on every operation) --- + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + summary: Create a pet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Pet created + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + + # --- Item paths --- + /pets/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + '200': + description: Single pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + put: + parameters: + - name: id + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '200': + description: Pet updated + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + delete: + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + '204': + description: Pet deleted + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } +``` + +### Schema-Level Extensions + +These go on the schema object itself and mark it as a **resource** that generates the admin UI. + +#### `x-resource` (REQUIRED) + +```yaml +Pet: + type: object + x-resource: pets # maps to the /pets path in paths +``` + +- Value must be a **string** matching the collection path segment (e.g., `pets` → `/pets`). +- Schemas **without** `x-resource` are skipped entirely — no CRUD routes, no extension validation. Use this for shared schemas like `Metadata`, `ErrorBody`, etc. +- **Validator error** if the path `/pets` does not exist in `paths`. + +#### `x-primary-key` (REQUIRED) + +```yaml +x-primary-key: id +``` + +- The name of the property that uniquely identifies each record. +- Used as the `:id` path parameter in routes and API calls. +- Must reference a property that exists in `properties`. +- **Validator error** if missing. + +#### `x-display-format` (REQUIRED) + +```yaml +x-display-format: "{name}" +x-display-format: "Dr. {name}" +x-display-format: "Apt #{id} - {date}" +``` + +- Template string with `{propertyName}` placeholders. +- Used to display a human-readable label for a record (list rows, detail title, FK dropdown options). +- Property names in `{braces}` are replaced with the record's property values at render time. +- **Validator error** if missing. + +#### `x-list-columns` (REQUIRED) + +```yaml +x-list-columns: [name, species, age, owner] +``` + +- Array of property names to display as columns in the datatable. +- Each name must exist in `properties`. +- Column order follows the array order. +- The display value for each cell is determined by the `x-display-format` of the **property's target resource** (for FK fields) or the **parent resource** (for direct fields). +- **Validator error** if missing or if any column name references a non-existent property. + +### Property-Level Extensions + +These go on individual properties inside `properties`. + +#### `x-label` (REQUIRED on every property) + +```yaml +x-label: "Pet Name" +``` + +- Human-readable label used in form fields, table headers, and detail views. +- **Validator error** if missing on any property of a resource schema. + +#### `x-order` (REQUIRED on every property) + +```yaml +x-order: 1 +``` + +- Integer determining field ordering in forms and detail views. +- Lower values appear first. Ties are broken by alphabetical property name. +- **Validator error** if missing or null on any property of a resource schema. + +#### `x-description` (optional) + +```yaml +x-description: "Name of the pet" +``` + +- Shown as helper text below form fields and as placeholder text. +- Falls back to `x-label` if not provided. + +#### `x-hidden` (optional) + +```yaml +x-hidden: { form: true, list: true, detail: true } +x-hidden: { form: true } # only hide in form +``` + +- Controls visibility in each view. Valid keys: `form`, `list`, `detail`. +- Any missing key defaults to `false` (visible). +- Useful for hiding auto-generated fields like `id` from forms. + +#### `x-filterable` (optional) + +```yaml +x-filterable: true +``` + +- Marks the field as filterable in the datatable (UI integration TBD — reserved for future use). +- Default: `false`. + +#### `x-sortable` (optional) + +```yaml +x-sortable: true +``` + +- Adds sort controls to the datatable column header. +- When clicked, sends `?sort=fieldName` (ascending) or `?sort=-fieldName` (descending) to the list endpoint. +- Default: `false`. + +#### `x-fk` (optional) + +```yaml +owner: + $ref: '#/components/schemas/Parent' + x-fk: + resource: parents + prefetch: true # load all options on mount +``` + +Marks a `$ref` property as a foreign key. The form renders a **dropdown** (single) or **multi-select** (if `type: array`, `items: $ref`). + +| Sub-field | Type | Default | Description | +|------------|---------|------------|-------------------------------------------------------------------------------------------| +| `resource` | string | (required) | The `x-resource` value of the target schema. Uses the resource name, not the schema name. | +| `prefetch` | boolean | `false` | If `true`, fetch all FK options on mount. If `false`, fetch on focus (lazy). | + +**How FK options are fetched:** +1. The library calls `GET /{targetResource}?limit=0` (for paginated targets — `limit=0` is a server convention meaning "return all") or `GET /{targetResource}` (for non-paginated). +2. The response is parsed strictly by the target resource's pagination config. +3. Each item is formatted using the target resource's `x-display-format`. + +**Validator rules:** +- The `resource` value must match some schema's `x-resource` in the spec. +- The target schema must have `x-display-format` and `x-primary-key`. + +#### `$ref` without `x-fk` — Inline Display + +```yaml +metadata: + $ref: '#/components/schemas/Metadata' + # no x-fk here +``` + +A `$ref` property without `x-fk` renders as **read-only inline display**: +- In **list** and **detail** views: uses `InlineRefField` which shows key-value chips (or the referenced resource's `x-display-format` if the target is a resource schema). +- In **forms**: shows a disabled `TextField` with `JSON.stringify(value, null, 2)`. + +The validator emits an `info` message for these to remind you they won't be editable. + +#### `x-ui-type` (optional) + +```yaml +photo: + type: string + format: binary + x-ui-type: image + x-upload-url: /pets/{id}/photo +``` + +Currently, supports only `"image"`. Changes the form field to a file upload component. + +- If the record has an `id` and `x-upload-url` is set: POSTs the file to `x-upload-url` (with `{id}` replaced by the record's ID), then updates the field value with the response URL. +- If no `id` or no `uploadUrl`: falls back to base64 data-URL via `FileReader`. + +#### `x-upload-url` (optional, used with `x-ui-type: image`) + +```yaml +x-upload-url: /pets/{id}/photo +``` + +POST endpoint for file upload. The `{id}` placeholder is replaced with the current record ID. + +### Property Types That Trigger Special Renderers + +| Condition | Form Renderer | List/Detail Renderer | +|--------------------------------------|-------------------------------------------|----------------------------------------------| +| `x-fk` exists, `type` is NOT `array` | `FkSelectField` (dropdown) | `ListCellRenderer` with `applyDisplayFormat` | +| `x-fk` exists, `type: array` | `FkMultiSelectField` (Autocomplete multi) | `ListCellRenderer` with chips | +| `enum` is defined | `EnumField` (select) | `Chip` | +| `type: boolean` | `BooleanField` (Switch) | Chip with "Yes"/"No" | +| `type: integer` or `type: number` | `NumberField` | Typography | +| `format: date` | `DateField` (date picker) | Typography | +| `format: date-time` | `DateField` (datetime-local picker) | Typography | +| `x-ui-type: image` | `ImageField` (upload button + preview) | `Avatar` | +| `$ref` without `x-fk` | Disabled TextField with JSON | `InlineRefField` (chips or displayFormat) | +| None of the above (default) | `StringField` (text input) | Typography | + +### Path Conventions + +#### Collection Path + +Format: `/{x-resource}` (e.g., `/pets`) + +| Operation | Required? | Purpose | +|-----------|-----------|-----------------------------------------| +| `GET` | Yes | List endpoint — populates the datatable | +| `POST` | Yes | Create endpoint | + +#### Item Path + +Format: `/{x-resource}/{id}` (e.g., `/pets/{id}`) + +| Operation | Required? | Purpose | +|-----------|-----------|---------------| +| `GET` | Yes | Detail view | +| `PUT` | Yes | Edit form | +| `DELETE` | Yes | Delete action | + +### Response Shapes + +The library expects **strict** response shapes that match the spec. No flexible fallbacks. + +#### Paginated List (GET with limit/offset params) + +```json +{ + "total": 42, + "items": [{ "id": 1, "name": "Fido" }, ...] +} +``` + +- `total` is optional; falls back to `items.length` if absent. +- `items` is **required** and must be an array. +- The library **throws** if the response is not `{ total, items }` with an array `items`. + +#### Non-Paginated List (GET without limit/offset params) + +```json +[{ "id": 1, "name": "Vaccination" }, ...] +``` + +- Response must be a **plain array**. +- The library **throws** if the response is not an array. + +#### Single Item (GET /{id}) + +```json +{ "id": 1, "name": "Fido", ... } +``` + +- Returns the resource object directly. +- No special validation beyond standard API error handling. + +#### Create / Update (POST / PUT) + +```json +{ "id": 1, "name": "Fido", ... } +``` + +- Returns the created/updated resource object directly. + +#### Delete + +- Returns HTTP 204 with no body. + +### Error Response Format + +The library's `parseError()` handles two formats: + +**FastAPI `ValidationError` (422):** +```json +[ + { "loc": ["body", "name"], "msg": "field required", "type": "value_error" } +] +``` +→ Rendered as: `"field required"` + +**Generic `ErrorBody`:** +```json +{ "detail": "Not found" } +``` +→ Rendered as: `"Not found"` + +## Pagination Behavior + +### Detecting Pagination + +A resource is considered paginated if its collection GET path has **both** a `limit` and `offset` query parameter. The `limit` parameter **must** have a `schema.default` value: + +```yaml +parameters: + - in: query + name: limit + schema: { type: integer, default: 20 } # default is REQUIRED + - in: query + name: offset + schema: { type: integer, default: 0 } +``` + +If only one of `limit`/`offset` is present, pagination is **not** detected (the path is treated as non-paginated). + +### Datatable Pagination + +The datatable renders an MUI `TablePagination` component when `resource.pagination` is non-null. It sends `?limit={rowsPerPage}&offset={page * rowsPerPage}` with each list request. + +### FK Options — Paginated Targets + +When fetching FK options for a paginated target resource, the library sends `?limit=0`. This is a **server convention** meaning "return all records" — not documented in the spec. The server must interpret `limit=0` as "no limit." + +## Spec Validation + +The validator (`spec-validator.ts`) runs at mount time and categorizes issues as: + +| Level | Meaning | Impact | +|-----------|------------------------------------------|------------------------------------------------------| +| `error` | Missing required extension | Admin panel shows error screen — **will not render** | +| `warning` | Missing optional configuration | Admin panel renders, warning shown in snackbar | +| `info` | Informational note (e.g., non-FK `$ref`) | Shown alongside warnings | + +### Complete Validation Rules + +For each schema with `x-resource`: + +1. **`x-primary-key`** must be present. +2. **`x-display-format`** must be present. +3. **`x-list-columns`** must be an array, and every entry must reference a real property. +4. Every property must have **`x-label`**. +5. Every property must have **`x-order`**. +6. If a property uses `$ref` without `x-fk` → `info` message. +7. If a property uses `x-fk`, the referenced `resource` must exist as another schema's `x-resource`, and that target must have `x-display-format` and `x-primary-key`. +8. The `x-resource` path must exist in `paths`. +9. The collection path must have **GET** (list) and **POST** (create). +10. The item path `/{resource}/{id}` must exist with **GET**, **PUT**, and **DELETE**. +11. If the collection GET has pagination params (limit/offset), the `limit` param must have a `schema.default`. + +## Auth + +Authentication is handled via the `getToken` callback in `SpecConfiguration`: + +```ts +const specConfig: SpecConfiguration = { + specUrl: "/api/openapi.yaml", + getToken: () => localStorage.getItem("token"), // called on every request +}; +``` + +- The token is injected as `Authorization: Bearer {token}` on every API call. +- If a 401 response is received and `getToken` returns a token, the token is cleared from localStorage (for token refresh flows — customize in your wrapper). + +## Design Decisions + +- **Spec loaded at runtime** — not built into the bundle. A single built UI works with any backend serving the spec at a known URL. +- **Schema-to-path mapping is explicit** — the `x-resource` value maps directly to the path segment. No magic pluralization or guessing. +- **No fallbacks for required extensions** — if the spec is missing `x-label`, `x-order`, `x-primary-key`, `x-display-format`, or `x-list-columns`, the library **throws at transform time** rather than silently defaulting. +- **FK options use `?limit=0`** for paginated resources — server convention, not spec-documented. +- **Response parsing is strict** — `{ total, items }` for paginated, `[]` for non-paginated. No fallback chains. + +## Example: Complete Minimal Spec + +```yaml +openapi: 3.1.0 +info: + title: Minimal API + version: 1.0.0 +servers: + - url: https://api.example.com/v1 +components: + schemas: + ErrorBody: + type: object + properties: + detail: { type: string } + required: [detail] + HTTPValidationError: + type: object + properties: + detail: + type: array + items: + $ref: '#/components/schemas/ValidationError' + ValidationError: + type: object + properties: + loc: + type: array + items: { type: string } + msg: { type: string } + type: { type: string } + required: [loc, msg, type] + Widget: + type: object + x-resource: widgets + x-primary-key: id + x-display-format: "{name}" + x-list-columns: [name, status] + properties: + id: + type: integer + readOnly: true + x-order: 0 + x-hidden: { form: true, list: true } + x-label: "ID" + name: + type: string + x-order: 1 + x-label: "Widget Name" + status: + type: string + enum: [active, inactive, archived] + x-order: 2 + x-label: "Status" + required: [id, name] + responses: + Unauthorized: + description: Not authenticated + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorBody' } + Forbidden: + description: Insufficient permissions + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorBody' } + NotFound: + description: Resource not found + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorBody' } + ValidationError: + description: Validation Error + content: + application/json: + schema: { $ref: '#/components/schemas/HTTPValidationError' } + InternalServerError: + description: Internal server error + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorBody' } +paths: + /widgets: + get: + summary: List widgets + responses: + '200': + description: List of widgets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Widget' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + summary: Create a widget + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + responses: + '201': + description: Widget created + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + /widgets/{id}: + get: + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + '200': + description: Single widget + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + put: + parameters: + - name: id + in: path + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + responses: + '200': + description: Widget updated + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } + delete: + parameters: + - name: id + in: path + required: true + schema: { type: integer } + responses: + '204': + description: Widget deleted + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/ValidationError' } + '500': { $ref: '#/components/responses/InternalServerError' } +``` diff --git a/react-openapi/index.ts b/react-openapi/index.ts index a26a3c6..d75ee63 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -2,4 +2,10 @@ export { AppProvider } from "./src/context/AppProvider"; export { Admin } from "./src/components/Admin"; export { useAppContext } from "./src/context/AppContext"; export { useResource } from "./src/context/useResource"; +export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; +export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; +export { SseStreamView } from "./src/components/SseStreamView"; +export { SseConnectionStatus } from "./src/components/SseConnectionStatus"; +export { getApi } from "./src/hooks/useApi"; +export type { FilterComponentProps } from "./src/context/useResource"; export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship } from "./src/types"; diff --git a/react-openapi/src/components/Admin.tsx b/react-openapi/src/components/Admin.tsx index 206d40b..0d58bae 100644 --- a/react-openapi/src/components/Admin.tsx +++ b/react-openapi/src/components/Admin.tsx @@ -44,9 +44,13 @@ export function Admin({ basePath }: AdminProps) { {resources.map((r) => ( } /> - } /> - } /> - } /> + {!r.streaming && ( + <> + } /> + } /> + } /> + + )} ))} diff --git a/react-openapi/src/components/FilterBar.tsx b/react-openapi/src/components/FilterBar.tsx new file mode 100644 index 0000000..6e03c22 --- /dev/null +++ b/react-openapi/src/components/FilterBar.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Box, Button } from "@mui/material"; +import { useResource, FilterComponentProps } from "../context/useResource"; + +interface FilterBarProps { + resourceName: string; + filters: Record; + onFilterChange: (fieldName: string, value: string) => void; + onClear: () => void; + data?: any[]; +} + +export function FilterBar({ resourceName, filters, onFilterChange, onClear, data }: FilterBarProps) { + const { resource, components } = useResource(resourceName); + const filterable = resource.fields.filter((f) => f.filterable); + const hasActiveFilters = Object.values(filters).some((v) => v !== ""); + + if (filterable.length === 0) return null; + + return ( + + {filterable.map((field) => { + const Component = components[field.name] as React.FC; + const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time"; + + if (isRange) { + return ( + + + onFilterChange(field.name + "_from", v)} + data={data} + /> + + + onFilterChange(field.name + "_to", v)} + data={data} + /> + + + ); + } + + return ( + + onFilterChange(field.name, v)} + data={data} + /> + + ); + })} + {hasActiveFilters && ( + + + + )} + + ); +} \ No newline at end of file diff --git a/react-openapi/src/components/ResourceDetail.tsx b/react-openapi/src/components/ResourceDetail.tsx index 8b4b9c9..39eea3c 100644 --- a/react-openapi/src/components/ResourceDetail.tsx +++ b/react-openapi/src/components/ResourceDetail.tsx @@ -23,7 +23,7 @@ interface ResourceDetailProps { export function ResourceDetail({ resource, basePath }: ResourceDetailProps) { const navigate = useNavigate(); const { id } = useParams(); - const crud = useResource(resource); + const crud = useResource(resource.name); const { resources: allResources } = useAppContext(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); diff --git a/react-openapi/src/components/ResourceForm.tsx b/react-openapi/src/components/ResourceForm.tsx index 281afb3..65fa0ca 100644 --- a/react-openapi/src/components/ResourceForm.tsx +++ b/react-openapi/src/components/ResourceForm.tsx @@ -28,7 +28,7 @@ interface ResourceFormProps { export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) { const navigate = useNavigate(); const { id } = useParams(); - const crud = useResource(resource); + const crud = useResource(resource.name); const { resources: allResources } = useAppContext(); const [formData, setFormData] = useState>({}); const [errors, setErrors] = useState>({}); @@ -218,7 +218,7 @@ export function ResourceForm({ resource, basePath, mode }: ResourceFormProps) { } }; - const title = mode === "create" ? `Create ${resource.schemaName}` : `Edit ${resource.schemaName}`; + const title = mode === "create" ? `Create ${resource.displayName}` : `Edit ${resource.displayName}`; return ( diff --git a/react-openapi/src/components/ResourceList.tsx b/react-openapi/src/components/ResourceList.tsx index 60ee283..d92a04c 100644 --- a/react-openapi/src/components/ResourceList.tsx +++ b/react-openapi/src/components/ResourceList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Box, @@ -14,42 +14,127 @@ import { TableRow, TablePagination, Paper, - TextField, - InputAdornment, TableSortLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Grid, } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import VisibilityIcon from "@mui/icons-material/Visibility"; -import SearchIcon from "@mui/icons-material/Search"; import type { ResourceConfig, FieldConfig } from "../types"; import { useResource } from "../context/useResource"; import { useAppContext } from "../context/AppContext"; -import { ListCellRenderer, applyDisplayFormat } from "./fields"; +import { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./fields"; +import { FilterBar } from "./FilterBar"; +import { readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource"; +import { SseConnectionStatus } from "./SseConnectionStatus"; interface ResourceListProps { resource: ResourceConfig; basePath: string; } +function matchRow(row: any, filters: Record, fields: FieldConfig[], allResources: ResourceConfig[]): boolean { + for (const field of fields) { + if (!field.filterable) continue; + + const isRange = field.type === "integer" || field.type === "number" || field.format === "date" || field.format === "date-time"; + + if (isRange) { + const from = filters[field.name + "_from"]; + const to = filters[field.name + "_to"]; + if (from || to) { + const cell = row[field.name]; + if (cell == null) return false; + if (field.type === "integer" || field.type === "number") { + if (from && Number(cell) < Number(from)) return false; + if (to && Number(cell) > Number(to)) return false; + } else { + if (from && String(cell) < String(from)) return false; + if (to && String(cell) > String(to)) return false; + } + } + continue; + } + + const val = filters[field.name]; + if (!val) continue; + + const cell = row[field.name]; + if (cell == null) return false; + + let str: string; + if (field.fk && typeof cell === "object" && cell !== null) { + const targetRes = allResources.find((r) => r.name === field.fk!.resource); + if (targetRes) { + const items = Array.isArray(cell) ? cell : [cell]; + str = items.map((item: any) => applyDisplayFormat(item, targetRes.displayFormat)).join(" "); + } else { + str = String(cell); + } + } else { + str = String(cell); + } + const filterParts = val.split(",").filter(Boolean); + if (!filterParts.some((part) => str.toLowerCase().includes(part.toLowerCase()))) return false; + } + return true; +} + export function ResourceList({ resource, basePath }: ResourceListProps) { const navigate = useNavigate(); - const crud = useResource(resource); - const { resources: allResources } = useAppContext(); + const { components, ...crud } = useResource(resource.name); + const { resources: allResources, config } = useAppContext(); const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(resource.pagination?.defaultLimit ?? 20); - const [search, setSearch] = useState(""); const [sortField, setSortField] = useState(null); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [filters, setFilters] = useState>({}); + const [detailRow, setDetailRow] = useState(null); + + const isStreaming = resource.streaming === true; + const hasActions = resource.operations.get || resource.operations.update || resource.operations.delete; + + const filterMode = config.resourceConfig?.[resource.name]?.filterOptions?.mode ?? "client"; + const isClientMode = filterMode === "client" && !isStreaming; const visibleColumns = resource.listColumns .map((colName) => resource.fields.find((f) => f.name === colName)) .filter((f): f is FieldConfig => !!f && !f.hidden?.list); - const fetchData = useCallback(async () => { + useEffect(() => { + setFilters({}); + }, [resource.name]); + + useEffect(() => { + if (!isStreaming || !crud.stream) return; + + setData(readSseCache(resource.name)); + setSseConnected(resource.name, false); + + const sub = crud.stream({ + onEvent: (evt) => { + const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() }; + const updated = appendSseCache(resource.name, enriched); + setData(updated); + }, + onOpen: () => setSseConnected(resource.name, true), + onError: () => setSseConnected(resource.name, false), + }); + + return () => { + setSseConnected(resource.name, false); + sub.close(); + }; + }, [isStreaming, crud.stream, resource.name]); + + const serverFetchData = useCallback(async () => { const params: Record = {}; if (resource.pagination) { params[resource.pagination.limitParam] = rowsPerPage; @@ -58,19 +143,76 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { if (sortField) { params.sort = sortDir === "desc" ? `-${sortField}` : sortField; } + for (const [key, val] of Object.entries(filters)) { + if (val) params[key] = val; + } const result = await crud.list(params); setData(result.items ?? []); setTotal(result.total ?? result.items?.length ?? 0); - }, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir]); + }, [crud.list, resource.pagination, rowsPerPage, page, sortField, sortDir, filters]); + + const clientFetchAll = useCallback(async () => { + const params: Record = {}; + if (resource.pagination) { + params[resource.pagination.limitParam] = 0; + } + const result = await crud.list(params); + setData(result.items ?? []); + setTotal(result.items?.length ?? 0); + }, [crud.list, resource.pagination]); useEffect(() => { - fetchData(); - }, [fetchData]); + if (isStreaming) return; + if (isClientMode) { + clientFetchAll(); + } else { + serverFetchData(); + } + }, [isStreaming, isClientMode, clientFetchAll, serverFetchData]); + + useEffect(() => { + if (isClientMode) { + setPage(0); + } + }, [filters, isClientMode]); + + const filteredData = useMemo(() => { + if (!isClientMode) return data; + + let items = data.filter((row) => matchRow(row, filters, resource.fields, allResources)); + + if (sortField) { + items = [...items].sort((a, b) => { + const aVal = a[sortField]; + const bVal = b[sortField]; + if (aVal == null) return 1; + if (bVal == null) return -1; + if (aVal < bVal) return sortDir === "asc" ? -1 : 1; + if (aVal > bVal) return sortDir === "asc" ? 1 : -1; + return 0; + }); + } + + const start = page * rowsPerPage; + return items.slice(start, start + rowsPerPage); + }, [data, isClientMode, filters, sortField, sortDir, page, rowsPerPage, resource.fields, allResources]); + + const clientTotal = useMemo(() => { + if (!isClientMode) return total; + return data.filter((row) => matchRow(row, filters, resource.fields, allResources)).length; + }, [data, isClientMode, filters, resource.fields, allResources]); + + const displayData = isClientMode ? filteredData : data; + const displayTotal = isClientMode ? clientTotal : total; const handleDelete = async (id: string | number) => { if (!window.confirm("Are you sure you want to delete this item?")) return; await crud.remove(id); - fetchData(); + if (isClientMode) { + clientFetchAll(); + } else { + serverFetchData(); + } }; const handleSort = (field: string) => { @@ -82,39 +224,46 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { } }; + const handleFilterChange = (fieldName: string, value: string) => { + setFilters((prev) => ({ ...prev, [fieldName]: value })); + }; + return ( - - {resource.schemaName} - - {resource.operations.create && ( - - )} + + + {resource.displayName} + + {isStreaming && } + + + {isStreaming && data.length > 0 && ( + + )} + {resource.operations.create && !isStreaming && ( + + )} + - - setSearch(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ minWidth: 280 }} + {!isStreaming && ( + setFilters({})} + data={data} /> - + )} @@ -135,27 +284,33 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { )} ))} - Actions + {hasActions && Actions} - {data.length === 0 ? ( + {displayData.length === 0 ? ( - + - No records found + {isStreaming ? "Waiting for events\u2026" : "No records found"} ) : ( - data.map((row) => { - const rowId = row[resource.primaryKey]; + displayData.map((row, idx) => { + const rowId = isStreaming ? `evt-${row._seq ?? idx}` : row[resource.primaryKey]; return ( navigate(`${basePath}/${resource.name}/${rowId}`)} + onClick={() => { + if (isStreaming) { + setDetailRow(row); + } else { + navigate(`${basePath}/${resource.name}/${rowId}`); + } + }} > {visibleColumns.map((col) => { let value = row[col.name]; @@ -172,29 +327,31 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { ); })} - e.stopPropagation()}> - {resource.operations.get && ( - - navigate(`${basePath}/${resource.name}/${rowId}`)}> - - - - )} - {resource.operations.update && ( - - navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> - - - - )} - {resource.operations.delete && ( - - handleDelete(rowId)} color="error"> - - - - )} - + {hasActions && ( + e.stopPropagation()}> + {resource.operations.get && !isStreaming && ( + + navigate(`${basePath}/${resource.name}/${rowId}`)}> + + + + )} + {resource.operations.update && ( + + navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> + + + + )} + {resource.operations.delete && ( + + handleDelete(rowId)} color="error"> + + + + )} + + )} ); }) @@ -203,10 +360,10 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
- {resource.pagination && ( + {!isStreaming && (resource.pagination || isClientMode) && ( setPage(p)} rowsPerPage={rowsPerPage} @@ -217,6 +374,34 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { rowsPerPageOptions={[10, 20, 50, 100]} /> )} + + {!isStreaming && displayData.length > 0 && ( + + {displayTotal} record{displayTotal !== 1 ? "s" : ""} + + )} + + setDetailRow(null)} maxWidth="sm" fullWidth> + {resource.displayName} Event + + {detailRow && ( + + {visibleColumns.map((col) => ( + + + + ))} + + )} + + + + +
); } diff --git a/react-openapi/src/components/SideMenu.tsx b/react-openapi/src/components/SideMenu.tsx index d0bfb7e..197f674 100644 --- a/react-openapi/src/components/SideMenu.tsx +++ b/react-openapi/src/components/SideMenu.tsx @@ -67,7 +67,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP diff --git a/react-openapi/src/components/SseConnectionStatus.tsx b/react-openapi/src/components/SseConnectionStatus.tsx new file mode 100644 index 0000000..e4e20cf --- /dev/null +++ b/react-openapi/src/components/SseConnectionStatus.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Box } from "@mui/material"; +import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; +import { useSseConnected } from "../context/useResource"; + +interface SseConnectionStatusProps { + resourceName: string; +} + +export function SseConnectionStatus({ resourceName }: SseConnectionStatusProps) { + const connected = useSseConnected(resourceName); + + return ( + + + {connected ? "Connected" : "Disconnected"} + + ); +} \ No newline at end of file diff --git a/react-openapi/src/components/SseStreamView.tsx b/react-openapi/src/components/SseStreamView.tsx new file mode 100644 index 0000000..509d533 --- /dev/null +++ b/react-openapi/src/components/SseStreamView.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from "react"; +import { + Box, Typography, Paper, Chip, Snackbar, +} from "@mui/material"; +import type { ResourceConfig } from "../types"; +import { useResource, readSseCache, appendSseCache, clearSseCache, nextSseSeq, setSseConnected } from "../context/useResource"; +import { applyDisplayFormat } from "./fields"; +import { SseConnectionStatus } from "./SseConnectionStatus"; + +interface SseStreamViewProps { + resource: ResourceConfig; +} + +export function SseStreamView({ resource }: SseStreamViewProps) { + const { stream } = useResource(resource.name); + const [events, setEvents] = useState(() => readSseCache(resource.name)); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMsg, setSnackbarMsg] = useState(""); + + useEffect(() => { + if (!stream) return; + setSseConnected(resource.name, false); + + const sub = stream({ + onEvent: (evt) => { + const enriched = { ...evt, _received_at: new Date().toISOString(), _seq: nextSseSeq() }; + const updated = appendSseCache(resource.name, enriched); + setEvents([...updated]); + setSnackbarMsg(applyDisplayFormat(evt, resource.displayFormat)); + setSnackbarOpen(true); + }, + onOpen: () => setSseConnected(resource.name, true), + onError: () => setSseConnected(resource.name, false), + }); + + return () => { + setSseConnected(resource.name, false); + sub.close(); + }; + }, [resource.name]); + + const eventCount = events.length; + const latestEvent = events[events.length - 1] ?? null; + + return ( + + + + + {resource.displayName} + + + + 0 ? `${eventCount} event${eventCount !== 1 ? "s" : ""}` : "No events"} + size="small" + variant="outlined" + color={eventCount > 0 ? "primary" : "default"} + /> + + + {latestEvent ? ( + + + Latest event (#{latestEvent._seq}) + + + {applyDisplayFormat(latestEvent, resource.displayFormat)} + + + ) : ( + + Waiting for events… + + )} + + setSnackbarOpen(false)} + message={snackbarMsg} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + /> + + ); +} \ No newline at end of file diff --git a/react-openapi/src/components/fields/renderers/BooleanField.tsx b/react-openapi/src/components/fields/renderers/BooleanField.tsx index 45afc1a..6290062 100644 --- a/react-openapi/src/components/fields/renderers/BooleanField.tsx +++ b/react-openapi/src/components/fields/renderers/BooleanField.tsx @@ -1,14 +1,44 @@ import React from "react"; -import { FormControl, FormControlLabel, Switch, FormHelperText } from "@mui/material"; +import { Box, FormControl, FormControlLabel, Switch, FormHelperText, ToggleButton, ToggleButtonGroup } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CancelIcon from "@mui/icons-material/Cancel"; import type { FieldConfig } from "../../../types"; interface Props { field: FieldConfig; value: any; onChange: (value: any) => void; + nullable?: boolean; } -export function BooleanField({ field, value, onChange }: Props) { +export function BooleanField({ field, value, onChange, nullable }: Props) { + if (nullable) { + const strValue = String(value ?? ""); + return ( + + + {field.label} + + onChange(v ?? "")} + size="small" + > + + + + + + + + + + + + ); + } + return ( err.msg ?? String(err)).join("; "); - } - if (typeof data.detail === "string") { - return data.detail; - } - } - return e.message ?? "An error occurred"; -} - -interface ResourceState { - loading: boolean; - error: string | null; -} - -interface UseResourceReturn { - list: (params?: Record) => Promise; - get: (id: string | number) => Promise; - create: (data: any) => Promise; - update: (id: string | number, data: any) => Promise; - remove: (id: string | number) => Promise; - loading: boolean; - error: string | null; -} - -export function useResource(resource: ResourceConfig): UseResourceReturn { - const [state, setState] = useState({ loading: false, error: null }); - - const setLoading = useCallback((loading: boolean) => { - setState((s) => ({ ...s, loading })); - }, []); - - const setError = useCallback((error: string | null) => { - setState((s) => ({ ...s, error })); - }, []); - - const list = useCallback( - async (params?: Record): Promise => { - setLoading(true); - setError(null); - try { - const api = getApi(); - const res = await api.get(resource.path, { params }); - const data = res.data; - - if (resource.pagination) { - if (!data || typeof data !== "object" || !Array.isArray(data.items)) { - throw new Error(`Expected paginated response { total, items } from ${resource.path}`); - } - return { items: data.items, total: data.total ?? data.items.length }; - } - - if (!Array.isArray(data)) { - throw new Error(`Expected array response from ${resource.path}`); - } - return { items: data }; - } catch (e: any) { - const msg = parseError(e); - setError(msg); - return { items: [] }; - } finally { - setLoading(false); - } - }, - [resource.path, resource.pagination, setLoading, setError] - ); - - const get = useCallback( - async (id: string | number): Promise => { - setLoading(true); - setError(null); - try { - const api = getApi(); - const res = await api.get(`${resource.path}/${id}`); - return res.data; - } catch (e: any) { - setError(parseError(e)); - throw e; - } finally { - setLoading(false); - } - }, - [resource.path, setLoading, setError] - ); - - const create = useCallback( - async (data: any): Promise => { - setLoading(true); - setError(null); - try { - const api = getApi(); - const res = await api.post(resource.path, data); - return res.data; - } catch (e: any) { - setError(parseError(e)); - throw e; - } finally { - setLoading(false); - } - }, - [resource.path, setLoading, setError] - ); - - const update = useCallback( - async (id: string | number, data: any): Promise => { - setLoading(true); - setError(null); - try { - const api = getApi(); - const res = await api.put(`${resource.path}/${id}`, data); - return res.data; - } catch (e: any) { - setError(parseError(e)); - throw e; - } finally { - setLoading(false); - } - }, - [resource.path, setLoading, setError] - ); - - const remove = useCallback( - async (id: string | number): Promise => { - setLoading(true); - setError(null); - try { - const api = getApi(); - await api.delete(`${resource.path}/${id}`); - } catch (e: any) { - setError(parseError(e)); - throw e; - } finally { - setLoading(false); - } - }, - [resource.path, setLoading, setError] - ); - - return { list, get, create, update, remove, loading: state.loading, error: state.error }; -} diff --git a/react-openapi/src/context/useResource.tsx b/react-openapi/src/context/useResource.tsx new file mode 100644 index 0000000..0502cd3 --- /dev/null +++ b/react-openapi/src/context/useResource.tsx @@ -0,0 +1,509 @@ +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { Autocomplete, TextField } from "@mui/material"; +import type { ResourceConfig, ParsedListResponse, FieldConfig } from "../types"; +import { useAppContext } from "./AppContext"; +import { getApi } from "../hooks/useApi"; +import { StringField } from "../components/fields/renderers/StringField"; +import { NumberField } from "../components/fields/renderers/NumberField"; +import { DateField } from "../components/fields/renderers/DateField"; +import { BooleanField } from "../components/fields/renderers/BooleanField"; +import { EnumField } from "../components/fields/renderers/EnumField"; +import { FkSelectField } from "../components/fields/renderers/FkSelectField"; +import { FkMultiSelectField } from "../components/fields/renderers/FkMultiSelectField"; + +function parseError(e: any): string { + if (e.response?.data) { + const data = e.response.data; + if (Array.isArray(data)) { + return data.map((err: any) => err.msg ?? String(err)).join("; "); + } + if (typeof data.detail === "string") { + return data.detail; + } + } + return e.message ?? "An error occurred"; +} + +interface ResourceState { + loading: boolean; + error: string | null; +} + +export interface FilterComponentProps { + value: string; + onChange: (v: string) => void; + data?: any[]; + labelOverride?: string; +} + +interface StreamHandlers { + onEvent: (data: any) => void; + onError?: (evt: Event) => void; + onOpen?: () => void; +} + +interface StreamSubscription { + close: () => void; +} + +interface UseResourceReturn { + resource: ResourceConfig; + components: Record>; + list: (params?: Record) => Promise; + get: (id: string | number) => Promise; + create: (data: any) => Promise; + update: (id: string | number, data: any) => Promise; + remove: (id: string | number) => Promise; + stream?: (handlers: StreamHandlers) => StreamSubscription; + loading: boolean; + error: string | null; +} + +const _fkOptionsCache = new Map(); +const _stringOptionsCache = new Map(); +const _sseEventCache = new Map(); +let _sseSeq = 0; + +export function readSseCache(resourceName: string): any[] { + return _sseEventCache.get(resourceName) ?? []; +} + +export function appendSseCache(resourceName: string, event: any): any[] { + const events = _sseEventCache.get(resourceName) ?? []; + events.push(event); + if (events.length > 100) events.splice(0, events.length - 100); + _sseEventCache.set(resourceName, events); + return events; +} + +export function clearSseCache(resourceName: string): void { + _sseEventCache.delete(resourceName); +} + +export function nextSseSeq(): number { + return ++_sseSeq; +} + +const _sseConnection = new Map(); +const _sseListeners = new Map void>>(); + +export function setSseConnected(resourceName: string, connected: boolean): void { + if (_sseConnection.get(resourceName) === connected) return; + _sseConnection.set(resourceName, connected); + _sseListeners.get(resourceName)?.forEach((cb) => cb()); +} + +export function getSseConnected(resourceName: string): boolean { + return _sseConnection.get(resourceName) ?? false; +} + +export function useSseConnected(resourceName: string): boolean { + const [connected, setConnected] = useState(() => getSseConnected(resourceName)); + + useEffect(() => { + const cb = () => setConnected(getSseConnected(resourceName)); + const listeners = _sseListeners.get(resourceName) ?? new Set(); + listeners.add(cb); + _sseListeners.set(resourceName, listeners); + return () => { + listeners.delete(cb); + if (listeners.size === 0) _sseListeners.delete(resourceName); + }; + }, [resourceName]); + + return connected; +} + +function extractDataOptions(data: any[], fieldName: string): string[] { + const values = new Set(); + for (const row of data) { + const v = row[fieldName]; + if (v != null && v !== "") { + values.add(String(v)); + } + } + return [...values].sort(); +} + +function applyDisplayFormat(obj: any, format: string): string { + if (!obj || typeof obj !== "object") return String(obj ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); +} + +function buildFilterComponent(field: FieldConfig, resourceName: string): React.FC { + if (field.type === "boolean") { + return ({ value, onChange, labelOverride }) => ( + onChange(v ?? "")} + nullable + /> + ); + } + + if (field.fk) { + const FkFilter: React.FC = ({ value, onChange, data, labelOverride }) => { + const { resources, config } = useAppContext(); + const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server"; + const targetRes = resources.find((r) => r.name === field.fk!.resource); + const [options, setOptions] = useState<{ value: any; label: string }[]>([]); + const fetched = useRef(false); + + useEffect(() => { + if (filterMode === "client" && data && targetRes) { + const seen = new Set(); + const opts: { value: any; label: string }[] = []; + for (const row of data) { + const items = Array.isArray(row[field.name]) ? row[field.name] : [row[field.name]]; + for (const item of items) { + if (item == null) continue; + const label = applyDisplayFormat(item, targetRes.displayFormat); + if (!seen.has(label)) { + seen.add(label); + opts.push({ value: label, label }); + } + } + } + opts.sort((a, b) => a.label.localeCompare(b.label)); + setOptions(opts); + fetched.current = true; + } else if (filterMode === "server" && targetRes && !fetched.current) { + const cacheKey = targetRes.name; + if (_fkOptionsCache.has(cacheKey)) { + setOptions(_fkOptionsCache.get(cacheKey)!); + fetched.current = true; + } else { + (async () => { + try { + const api = getApi(); + const params: Record = {}; + if (targetRes.pagination) params.limit = 0; + const res = await api.get(targetRes.path, { params }); + let items: any[]; + if (targetRes.pagination) { + items = res.data.items ?? []; + } else { + items = Array.isArray(res.data) ? res.data : []; + } + const opts = items.map((item: any) => { + const label = applyDisplayFormat(item, targetRes.displayFormat); + return { value: label, label }; + }); + _fkOptionsCache.set(cacheKey, opts); + setOptions(opts); + fetched.current = true; + } catch { + fetched.current = true; + } + })(); + } + } + }, [filterMode, data, targetRes]); + + if (field.isArray) { + const selected = value ? value.split(",").filter(Boolean) : []; + return ( + onChange(v.join(","))} + fkOptions={options} + /> + ); + } + + return ( + onChange(v ?? "")} + fkOptions={options} + /> + ); + }; + return FkFilter; + } + + if (field.enumValues) { + const EnumFilter: React.FC = ({ value, onChange, data, labelOverride }) => { + const dataOptions = useMemo(() => { + if (!data) return []; + return extractDataOptions(data, field.name); + }, [data]); + const merged = useMemo( + () => [...new Set([...(field.enumValues ?? []), ...dataOptions])], + [dataOptions] + ); + return ( + onChange(v ?? "")} + /> + ); + }; + return EnumFilter; + } + + if (field.type === "integer" || field.type === "number") { + return ({ value, onChange, labelOverride }) => ( + onChange(v === "" ? "" : String(v))} + /> + ); + } + + if (field.format === "date" || field.format === "date-time") { + return ({ value, onChange, labelOverride }) => ( + onChange(v ?? "")} + /> + ); + } + + if ( + !field.fk && + !field.enumValues && + field.type !== "boolean" && + field.type !== "integer" && + field.type !== "number" && + field.format !== "date" && + field.format !== "date-time" && + !field.refSchema + ) { + const StringAutocompleteFilter: React.FC = ({ value, onChange, data, labelOverride }) => { + const { resources, config } = useAppContext(); + const filterMode = config.resourceConfig?.[resourceName]?.filterOptions?.mode ?? "server"; + const [options, setOptions] = useState([]); + const fetched = useRef(false); + + useEffect(() => { + if (filterMode === "client" && data) { + setOptions(extractDataOptions(data, field.name)); + fetched.current = true; + } else if (filterMode === "server" && !fetched.current) { + const cacheKey = resourceName + ":" + field.name; + if (_stringOptionsCache.has(cacheKey)) { + setOptions(_stringOptionsCache.get(cacheKey)!); + fetched.current = true; + } else { + (async () => { + try { + const api = getApi(); + const selfRes = resources.find((r) => r.name === resourceName); + if (!selfRes) { fetched.current = true; return; } + const params: Record = {}; + if (selfRes.pagination) params.limit = 0; + const res = await api.get(selfRes.path, { params }); + let items: any[]; + if (selfRes.pagination) { + items = res.data.items ?? []; + } else { + items = Array.isArray(res.data) ? res.data : []; + } + const values = [...new Set(items.map((r: any) => String(r[field.name] ?? "")).filter(Boolean))].sort(); + _stringOptionsCache.set(cacheKey, values); + setOptions(values); + fetched.current = true; + } catch { + fetched.current = true; + } + })(); + } + } + }, [data]); + + return ( + onChange(newVal ?? "")} + renderInput={(params) => ( + + )} + /> + ); + }; + return StringAutocompleteFilter; + } + + return ({ value, onChange, labelOverride }) => ( + onChange(v ?? "")} + /> + ); +} + +export function useResource(resourceName: string): UseResourceReturn { + const { resources } = useAppContext(); + const resource = resources.find((r) => r.name === resourceName); + if (!resource) { + throw new Error(`Resource "${resourceName}" not found`); + } + + const [state, setState] = useState({ loading: false, error: null }); + + const setLoading = useCallback((loading: boolean) => { + setState((s) => ({ ...s, loading })); + }, []); + + const setError = useCallback((error: string | null) => { + setState((s) => ({ ...s, error })); + }, []); + + const list = useCallback( + async (params?: Record): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.get(resource.path, { params }); + const data = res.data; + + if (resource.pagination) { + if (!data || typeof data !== "object" || !Array.isArray(data.items)) { + throw new Error(`Expected paginated response { total, items } from ${resource.path}`); + } + return { items: data.items, total: data.total ?? data.items.length }; + } + + if (!Array.isArray(data)) { + throw new Error(`Expected array response from ${resource.path}`); + } + return { items: data }; + } catch (e: any) { + const msg = parseError(e); + setError(msg); + return { items: [] }; + } finally { + setLoading(false); + } + }, + [resource.path, resource.pagination, setLoading, setError] + ); + + const get = useCallback( + async (id: string | number): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.get(`${resource.path}/${id}`); + return res.data; + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const create = useCallback( + async (data: any): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.post(resource.path, data); + return res.data; + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const update = useCallback( + async (id: string | number, data: any): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + const res = await api.put(`${resource.path}/${id}`, data); + return res.data; + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const remove = useCallback( + async (id: string | number): Promise => { + setLoading(true); + setError(null); + try { + const api = getApi(); + await api.delete(`${resource.path}/${id}`); + } catch (e: any) { + setError(parseError(e)); + throw e; + } finally { + setLoading(false); + } + }, + [resource.path, setLoading, setError] + ); + + const stream = useCallback( + (handlers: StreamHandlers): StreamSubscription => { + if (!resource.streaming) { + throw new Error(`Resource "${resourceName}" does not support streaming`); + } + const api = getApi(); + const baseUrl = (api.defaults.baseURL ?? "").replace(/\/+$/, ""); + const url = baseUrl + resource.path; + const es = new EventSource(url); + + es.onopen = () => handlers.onOpen?.(); + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + handlers.onEvent(data); + } catch { + // ignore malformed JSON payloads + } + }; + es.onerror = (e) => { + handlers.onError?.(e); + }; + + return { close: () => es.close() }; + }, + [resource.path, resource.streaming, resourceName] + ); + + const components = useMemo( + () => { + const map: Record> = {}; + for (const field of resource.fields) { + map[field.name] = buildFilterComponent(field, resourceName); + } + return map; + }, + [resource.fields, resourceName] + ); + + return { resource, components, list, get, create, update, remove, stream: resource.streaming ? stream : undefined, loading: state.loading, error: state.error }; +} \ No newline at end of file diff --git a/react-openapi/src/spec-validator.ts b/react-openapi/src/spec-validator.ts index 6844b43..be643b6 100644 --- a/react-openapi/src/spec-validator.ts +++ b/react-openapi/src/spec-validator.ts @@ -96,6 +96,9 @@ export function validateSpec(spec: OpenApiSpec): ValidationMessage[] { messages.push({ type: "error", message: `"${resourcePath}" has no GET list endpoint — datatable cannot be populated` }); } + const isSSE = collectionPath?.get?.["x-sse"] === true; + if (isSSE) continue; + const listParams = collectionPath?.get?.parameters ?? []; const limitParam = listParams.find((p: any) => p.in === "query" && p.name === "limit"); const offsetParam = listParams.find((p: any) => p.in === "query" && p.name === "offset"); diff --git a/react-openapi/src/transformers/resource-config.ts b/react-openapi/src/transformers/resource-config.ts index 2852466..9c23b5f 100644 --- a/react-openapi/src/transformers/resource-config.ts +++ b/react-openapi/src/transformers/resource-config.ts @@ -1,4 +1,4 @@ -import type { OpenApiSpec, ResourceConfig, FieldConfig, ResourceRelationship } from "../types"; +import type { OpenApiSpec, ResourceConfig, FieldConfig } from "../types"; import { extractFields } from "./field-config"; import { extractRelationships } from "./relationship-config"; @@ -28,6 +28,25 @@ function sortFields(fields: FieldConfig[]): FieldConfig[] { }); } +function formatDisplayName(name: string): string { + return name.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" "); +} + +const SSE_RECEIVED_FIELD: FieldConfig = { + name: "_received_at", + label: "Received", + description: "Timestamp when the event was received", + type: "string", + format: "date-time", + order: 0, + hidden: {}, + filterable: false, + sortable: true, + readOnly: true, + required: false, + isArray: false, +}; + export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { const schemas = spec.components?.schemas ?? {}; const paths = spec.paths ?? {}; @@ -46,10 +65,12 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { const fields = extractFields(schemaName, schema, schemas); const relationships = extractRelationships(schema, schemas); + const hasSSE = collectionPathObj?.get?.["x-sse"] === true; const resource: ResourceConfig = { name: resourceName, schemaName, + displayName: formatDisplayName(resourceName), path: resourcePath, primaryKey: schema["x-primary-key"], displayFormat: schema["x-display-format"], @@ -65,10 +86,21 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] { }, pagination: detectPagination(collectionPathObj), relationships, + streaming: hasSSE || undefined, }; + if (hasSSE) { + resource.operations = { list: true, get: false, create: false, update: false, delete: false }; + resource.pagination = null; + resource.relationships = []; + resource.fields = [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))]; + resource.orderedFields = sortFields(resource.fields); + resource.listColumns = ["_received_at", ...resource.listColumns]; + resource.primaryKey = "_received_at"; + } + configs.push(resource); } return configs; -} +} \ No newline at end of file diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts index dcb7d97..161f074 100644 --- a/react-openapi/src/types.ts +++ b/react-openapi/src/types.ts @@ -1,8 +1,17 @@ +export type FilterMode = "client" | "server"; + +export interface ResourceConfiguration { + filterOptions?: { + mode?: FilterMode; + }; +} + export interface SpecConfiguration { specUrl: string; baseApiUrl?: string; title?: string; getToken?: () => string | null; + resourceConfig?: Record; } export interface ValidationMessage { @@ -19,6 +28,7 @@ export interface ResourceRelationship { export interface ResourceConfig { name: string; schemaName: string; + displayName: string; path: string; primaryKey: string; displayFormat: string; @@ -38,6 +48,7 @@ export interface ResourceConfig { defaultLimit: number; } | null; relationships: ResourceRelationship[]; + streaming?: boolean; } export interface FieldConfig { diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 70e4a68..a4a7349 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -23,7 +23,7 @@ import { useReport, prepareReport, } from "./features/report"; -import { useResourceByName } from "../react-openapi"; +import { useReportSnapshotsList } from "./features/report-snapshots"; function formatSnapshotDate(iso: string) { const d = new Date(iso); @@ -56,13 +56,13 @@ export default function Dashboard() { const [selectedSnapshotId, setSelectedSnapshotId] = React.useState(null); - const { data: snapshotsData } = useResourceByName("reports").useList(); + const { data: snapshotsData } = useReportSnapshotsList(); const snapshotOptions = React.useMemo(() => { const options: { label: string; value: string | null }[] = [ { label: "Latest (auto)", value: null }, ]; - if (snapshotsData?.data) { - for (const snap of snapshotsData.data) { + if (snapshotsData?.items) { + for (const snap of snapshotsData.items) { options.push({ label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`, value: snap.snapshot_id, diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 03e6678..9137026 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -35,7 +35,8 @@ import type { ProgressMessage, } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; -import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; +import { useAppContext, useResource } from "../react-openapi"; +import { useQuery, useMutation } from "@tanstack/react-query"; const statusColors: Record = { pending: "default", @@ -144,11 +145,18 @@ function isMathValid(candidate: { amount: number; balance: number }, prevBalance export default function FetchRequestDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const config = useConfig(); + const { resources, config } = useAppContext(); + const fetchRequestResource = resources.find(r => r.name === "fetch-requests")!; + const { get, update } = useResource(fetchRequestResource); - const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); - const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!); - const updateMutation = usePatch(); + const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ + queryKey: ["fetch-requests", "detail", id], + queryFn: () => get(id!), + enabled: !!id, + }); + const updateMutation = useMutation({ + mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data), + }); const resolveMutation = useResolveAmbiguity(); const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); @@ -193,8 +201,8 @@ export default function FetchRequestDetail() { }, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]); React.useEffect(() => { - if (!id || !config?.baseUrl) return; - const url = `${config.baseUrl}/fetch-requests/${id}/events`; + if (!id || !config?.baseApiUrl) return; + const url = `${config.baseApiUrl}/fetch-requests/${id}/events`; const es = new EventSource(url); sseRef.current = es; @@ -243,7 +251,7 @@ export default function FetchRequestDetail() { es.close(); sseRef.current = null; }; - }, [id, config?.baseUrl]); + }, [id, config?.baseApiUrl]); React.useEffect(() => { if (feedRef.current) { diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index 3895c2f..0c08ed2 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -47,8 +47,9 @@ import type { } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { useNavigate } from "react-router-dom"; -import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; -import type { ResourceField } from "../react-openapi"; +import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi"; +import type { FieldConfig } from "../react-openapi"; +import { useMutation, useQuery } from "@tanstack/react-query"; const statusColors: Record = { pending: "default", @@ -70,6 +71,16 @@ const statusIcons: Record = { failed: , }; +const STATUS_OPTIONS: FetchRequestStatus[] = [ + "pending", + "processing", + "paused", + "raw_expenses_done", + "enriched_done", + "completed", + "failed", +]; + function formatDate(iso: string) { const d = new Date(iso); return d.toLocaleString(); @@ -107,33 +118,48 @@ export default function FetchRequests() { const [accountFilter, setAccountFilter] = React.useState(""); const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); - const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); - const { data: listData, isLoading, isFetching, refetch } = useList({ - ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), - ...(accountFilter ? { account_name: accountFilter } : {}), - ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), + const { resources } = useAppContext(); + const fetchRequestsRes = resources.find(r => r.name === "fetch-requests")!; + const { list, create, update, remove } = useResource(fetchRequestsRes); + + const { data: listData, isLoading, isFetching, refetch } = useQuery({ + queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }], + queryFn: () => list({ + ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), + ...(accountFilter ? { account_name: accountFilter } : {}), + ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), + }), }); - const { useList: useAccountsList } = useResourceByName("accounts"); - const { data: accountsData } = useAccountsList(); + const accountsResource = resources.find(r => r.name === "accounts"); + const { list: listAccounts } = accountsResource ? useResource(accountsResource) : { list: async () => ({ items: [] }) }; + const { data: accountsData } = useQuery({ + queryKey: ["accounts", "list"], + queryFn: () => listAccounts(), + enabled: !!accountsResource, + }); const accountOptions: string[] = React.useMemo(() => { - return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean); + return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean); }, [accountsData]); - const config = useConfig(); - const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests"); - const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format; - const formatOptions: string[] = formatField?.options ?? []; - const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date; - const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date; - const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username; + const formatField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "format"); + const formatOptions: string[] = formatField?.enumValues ?? []; + const startDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "start_date"); + const endDateField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "end_date"); + const payorUsernameField: FieldConfig | undefined = fetchRequestsRes?.orderedFields.find(f => f.name === "payor_username"); - const createMutation = useCreate(); - const updateMutation = usePatch(); - const deleteMutation = useDelete(); + const createMutation = useMutation({ + mutationFn: (data: any) => create(data), + }); + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data), + }); + const deleteMutation = useMutation({ + mutationFn: (id: string) => remove(id), + }); const uploadMutation = useUploadFile(); - const requests = listData?.data ?? []; + const requests = listData?.items ?? []; const handleUpload = async () => { if (!file) return; @@ -262,9 +288,8 @@ export default function FetchRequests() { Uploaded as: {uploadedPath} )} - {formatField && components?.FormField ? ( - ) : ( <> - {formatField && components?.FormField ? ( - - {payorUsernameField && components?.FormField ? ( - - {startDateField && components?.date ? ( + {startDateField ? ( - )} - {endDateField && components?.date ? ( + {endDateField ? ( - } renderValue={(selected) => (selected as string[]).join(", ")} > - {(config?.enums?.FetchRequestStatus ?? []).map((s: string) => ( + {STATUS_OPTIONS.map((s: string) => ( {s.replace(/_/g, " ")} ))} diff --git a/src/ReportSnapshots.tsx b/src/ReportSnapshots.tsx index e0a8944..077dfde 100644 --- a/src/ReportSnapshots.tsx +++ b/src/ReportSnapshots.tsx @@ -21,8 +21,9 @@ import DeleteIcon from "@mui/icons-material/Delete"; import AddCircleIcon from "@mui/icons-material/AddCircle"; import RefreshIcon from "@mui/icons-material/Refresh"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; -import type { ResourceField } from "../react-openapi"; +import { useAppContext, useResource, FormFieldRenderer } from "../react-openapi"; +import type { FieldConfig } from "../react-openapi"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; interface ReportSnapshotQuery { accounts?: string[]; @@ -53,21 +54,32 @@ export default function ReportSnapshots() { const [deleteTarget, setDeleteTarget] = React.useState(null); const [createdSnapshotId, setCreatedSnapshotId] = React.useState(null); - const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents }); + const { resources } = useAppContext(); + const reportsResource = resources.find(r => r.name === "reports")!; + const { list, create, remove } = useResource(reportsResource); - const { data: listData, isLoading, isFetching, refetch } = useList(); - const createMutation = useCreate(); - const deleteMutation = useDelete(); + const { data: listData, isLoading, isFetching, refetch } = useQuery({ + queryKey: ["reports", "list"], + queryFn: () => list(), + }); + const createMutation = useMutation({ + mutationFn: (data: any) => create(data), + }); + const queryClient = useQueryClient(); + const deleteMutation = useMutation({ + mutationFn: (id: string) => remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reports", "list"] }); + }, + }); - const config = useConfig(); - const reportsRes = config?.resources.find((r: any) => r.name === "reports"); - const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self; - const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date; - const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date; - const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount; - const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount; + const ignoreSelfField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "ignore_self"); + const startDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "start_date"); + const endDateField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "end_date"); + const minAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "min_amount"); + const maxAmountField: FieldConfig | undefined = reportsResource?.orderedFields.find(f => f.name === "max_amount"); - const snapshots: ReportSnapshot[] = listData?.data ?? []; + const snapshots: ReportSnapshot[] = listData?.items ?? []; const handleCreate = async () => { try { @@ -123,14 +135,13 @@ export default function ReportSnapshots() { - {ignoreSelfField && components?.FormField && ( - setIgnoreSelf(val)} /> - )} + ) : null} @@ -158,26 +169,24 @@ export default function ReportSnapshots() { - {minAmountField && components?.FormField && ( + {minAmountField ? ( - setMinAmount(val)} /> - )} - {maxAmountField && components?.FormField && ( + ) : null} + {maxAmountField ? ( - setMaxAmount(val)} /> - )} + ) : null}