Compare commits
13 Commits
1.1.1
...
b198f6748e
| Author | SHA1 | Date | |
|---|---|---|---|
| b198f6748e | |||
| 8e2bd70e5f | |||
| dd7c2246df | |||
| f2edf7ade7 | |||
| cd89eb4c88 | |||
| 0907e071b5 | |||
| d58f2a0744 | |||
| 8300e43e14 | |||
| 386297dc1e | |||
| a227c14e0a | |||
| 58df11c623 | |||
| 9771816cf9 | |||
| 7bd946ec7a |
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
||||
# Node modules
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
build
|
||||
dist
|
||||
out
|
||||
.next
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE / Editor folders
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# Temporary files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
140
.drone.yml
Normal file
140
.drone.yml
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: arm64
|
||||
|
||||
workspace:
|
||||
path: /drone/src
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
||||
|
||||
steps:
|
||||
- name: fetch-tags
|
||||
image: docker:24
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- git fetch --tags
|
||||
- |
|
||||
# Get latest Git tag and trim newline
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
|
||||
echo "Latest Git tag fetched: $LATEST_TAG"
|
||||
|
||||
# Save to file for downstream steps
|
||||
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
|
||||
|
||||
# Read back for verification
|
||||
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||
echo "Image tag read from file: $IMAGE_TAG"
|
||||
|
||||
# Validate
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "❌ No git tags found! Cannot continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: check-remote-image
|
||||
image: docker:24
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||
|
||||
- echo "Checking if apps/khata:$IMAGE_TAG exists on remote Docker..."
|
||||
- echo "Existing Docker tags for apps/khata:"
|
||||
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^apps/khata" || echo "(none)"
|
||||
- |
|
||||
if docker image inspect apps/khata:$IMAGE_TAG > /dev/null 2>&1; then
|
||||
echo "✅ Docker image apps/khata:$IMAGE_TAG already exists — skipping build"
|
||||
exit 78
|
||||
else
|
||||
echo "⚙️ Docker image apps/khata:$IMAGE_TAG not found — proceeding to build..."
|
||||
fi
|
||||
|
||||
- name: build-image
|
||||
image: docker:24
|
||||
environment:
|
||||
API_BASE_URL:
|
||||
from_secret: API_BASE_URL
|
||||
AUTH_BASE_URL:
|
||||
from_secret: AUTH_BASE_URL
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||
|
||||
- echo "🔨 Building Docker image apps/khata:$IMAGE_TAG ..."
|
||||
- |
|
||||
docker build --network=host \
|
||||
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
||||
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
|
||||
-t apps/khata:$IMAGE_TAG \
|
||||
-t apps/khata:latest \
|
||||
/drone/src
|
||||
|
||||
- name: push-image
|
||||
image: docker:24
|
||||
environment:
|
||||
REGISTRY_HOST:
|
||||
from_secret: REGISTRY_HOST
|
||||
REGISTRY_USER:
|
||||
from_secret: REGISTRY_USER
|
||||
REGISTRY_PASS:
|
||||
from_secret: REGISTRY_PASS
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||
|
||||
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
|
||||
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
|
||||
- echo "🏷️ Tagging images with registry prefix..."
|
||||
- docker tag apps/khata:$IMAGE_TAG $REGISTRY_HOST/apps/khata:$IMAGE_TAG
|
||||
- docker tag apps/khata:$IMAGE_TAG $REGISTRY_HOST/apps/khata:latest
|
||||
- echo "📤 Pushing apps/khata:$IMAGE_TAG ..."
|
||||
- docker push $REGISTRY_HOST/apps/khata:$IMAGE_TAG
|
||||
- echo "📤 Pushing apps/khata:latest ..."
|
||||
- docker push $REGISTRY_HOST/apps/khata:latest
|
||||
|
||||
- name: stop-old
|
||||
image: docker:24
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- echo "🛑 Stopping old container..."
|
||||
- docker rm -f khata || true
|
||||
|
||||
- name: run-container
|
||||
image: docker:24
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||
|
||||
- echo "🚀 Starting container apps/khata:$IMAGE_TAG ..."
|
||||
- |
|
||||
docker run -d \
|
||||
--name khata \
|
||||
-p 3002:3000 \
|
||||
-e NODE_ENV=production \
|
||||
--restart always \
|
||||
apps/khata:$IMAGE_TAG
|
||||
|
||||
# Trigger rules
|
||||
trigger:
|
||||
event:
|
||||
- tag
|
||||
24
CONCEPT.md
Normal file
24
CONCEPT.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Concept Overview
|
||||
|
||||
The application is a **metadata‑driven admin UI** built on top of an OpenAPI description. By describing each resource in a small JSON config (type `ResourceConfig`), the UI automatically generates:
|
||||
|
||||
1. **Data tables** (with pagination, sorting, and actions) – `EnhancedTable`.
|
||||
2. **Dynamic filters** – `FilterBar` creates appropriate filter widgets (autocomplete, number‑range, date‑range) based on field metadata.
|
||||
3. **Forms for create/edit** – A generic form component can render inputs for every `ResourceField`, handling relations via the `displayFormat` template.
|
||||
4. **Authentication layer** – `react‑auth` supplies a central `AuthProvider`, a `useAuth` hook, and route guarding, ensuring only authenticated users reach the admin pages.
|
||||
|
||||
### Core Principles
|
||||
- **Declarative configuration**: Adding a new resource is just a JSON entry; no hand‑coded tables or forms.
|
||||
- **Template‑based display**: `displayFormat` (e.g. `"{{firstName}} {{lastName}}"`) defines how related objects are shown across the UI, eliminating the need for separate `displayField` props.
|
||||
- **Extensible UI**: Consumers can plug custom components (`components` prop) to override cell renderers, filter widgets, or action buttons without altering core logic.
|
||||
- **Unified state**: TanStack Query caches server data, while `react‑auth` manages JWTs and user info. Both are provided via React context for easy access.
|
||||
- **Responsive design**: The UI automatically switches to a card‑based layout on mobile, preserving functionality with a consistent look.
|
||||
|
||||
### Migration Goal for Lovable
|
||||
The current repo implements these ideas with a solid foundation but could benefit from:
|
||||
- **Improved UI/UX** (e.g., better loading states, richer snackbars, dark‑mode toggle).
|
||||
- **More robust error handling** (centralized toast system, retry logic on auth failures).
|
||||
- **Enhanced theming** (customizable palette, brand colors).
|
||||
- **Accessibility** (ARIA roles, keyboard navigation).
|
||||
|
||||
By re‑using the existing `ResourceConfig` schema and `displayFormat` logic, the Lovable implementation can focus on UI polish and advanced handling while keeping the powerful code‑generation approach intact.
|
||||
34
DESIGN.md
Normal file
34
DESIGN.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Design Overview
|
||||
|
||||
## React‑Auth
|
||||
- **Purpose**: Centralize authentication flows (login, logout, token refresh) for the UI.
|
||||
- **Key Concepts**
|
||||
- **AuthProvider** – React context that stores `user`, `accessToken`, and `isAuthenticated`.
|
||||
- **useAuth hook** – Exposes `login`, `logout`, `refreshToken`, and state values.
|
||||
- **Route Guard** – HOC/Component (`ProtectedRoute`) that redirects unauthenticated users to the login page.
|
||||
- **UI**: Simple MUI forms, error handling with snackbars, and a loading spinner while the auth request is pending.
|
||||
- **Extensibility**: Plug‑in point for additional providers (OAuth, SSO) via a `providers` map.
|
||||
|
||||
## React‑OpenAPI
|
||||
- **Purpose**: Generate UI components directly from an OpenAPI spec (tables, filters, forms).
|
||||
- **Core Modules**
|
||||
- `ResourceConfig` & `ResourceField` – Typed definitions that describe each endpoint and its fields, including `displayFormat` for rendering.
|
||||
- `EnhancedTable` – Data‑grid component that renders rows according to the config, supports relation rendering, sorting, pagination, and custom cell renderers.
|
||||
- `FilterBar` – Dynamically builds filter controls (autocomplete, number‑range, date‑range) based on the same config.
|
||||
- **Data Flow**
|
||||
1. Load OpenAPI spec → transform to `ResourceConfig` objects.
|
||||
2. `useQuery` (TanStack) fetches data.
|
||||
3. UI components consume the config to render tables and filter UI without hand‑written column definitions.
|
||||
- **Design Goals**
|
||||
- **Zero boilerplate** – Adding a new resource only requires a JSON config.
|
||||
- **Consistency** – All tables share pagination, actions, and styling.
|
||||
- **Extensibility** – Override components via `components` prop.
|
||||
|
||||
## src (Root Application)
|
||||
- **Entry Point** – `main.tsx` mounts the React app with `BrowserRouter` and wraps it with `AuthProvider`.
|
||||
- **Routing** – Routes are defined per‑resource (`/admin/:resource`, `/admin/:resource/edit/:id`). `ProtectedRoute` ensures auth.
|
||||
- **State Management** – TanStack Query handles server state; React Context handles auth state.
|
||||
- **Theming** – MUI theming with light/dark mode toggle (future enhancement).
|
||||
|
||||
---
|
||||
These design notes serve as a concise reference for developers preparing a richer UI/UX implementation on the **lovable** platform.
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (or yarn.lock)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_AUTH_BASE_URL
|
||||
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
|
||||
|
||||
# Stage 2: Static file server (BusyBox)
|
||||
FROM busybox:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only build frontend files
|
||||
COPY --from=builder /app/dist /app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
CMD ["busybox", "httpd", "-f", "-p", "3000"]
|
||||
49
IMPLEMENTATION.md
Normal file
49
IMPLEMENTATION.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Implementation Details
|
||||
|
||||
## React‑Auth
|
||||
- **File Structure**
|
||||
- `src/auth/AuthContext.tsx` – Provides `AuthContext` and `AuthProvider`.
|
||||
- `src/auth/useAuth.ts` – Custom hook returning context values and actions.
|
||||
- `src/auth/ProtectedRoute.tsx` – Wrapper component that checks `isAuthenticated` and redirects.
|
||||
- `src/auth/api.ts` – Thin wrapper around `axios` for login, logout, refresh.
|
||||
- **Logic**
|
||||
1. On `login`, POST credentials → store `accessToken` & user info in context and `localStorage`.
|
||||
2. An `axios` interceptor attaches the token to every request.
|
||||
3. `refreshToken` runs on 401 responses; it attempts a silent refresh and updates the context.
|
||||
4. `logout` clears context and storage, navigating back to `/login`.
|
||||
- **UI Components**
|
||||
- `LoginForm` – MUI `TextField`s, validation, and submit handling.
|
||||
- `AuthLoading` – Full‑screen spinner displayed while session restoration runs on app boot.
|
||||
|
||||
## React‑OpenAPI
|
||||
- **Core Files**
|
||||
- `src/react-openapi/types/config.ts` – Already defines `ResourceField` with `displayFormat`.
|
||||
- `src/react-openapi/utils/options.ts` – Helper `resolveTemplate` parses `{{field}}` placeholders using the item data.
|
||||
- `src/react-openapi/components/EnhancedTable.tsx` – Renders a MUI `DataGrid`. Uses `getFormattedDisplayValue` to compute readable labels for relation fields based on `displayFormat`.
|
||||
- `src/react-openapi/components/FilterBar.tsx` – Generates filter inputs; extracts option labels using the same `displayFormat` logic.
|
||||
- **Data Fetching**
|
||||
- `useResource(resourceName)` – TanStack `useQuery` hook that builds the endpoint URL from `config.endpoint` and fetches data via the shared Axios instance.
|
||||
- **Customization**
|
||||
- `components` prop passed to `EnhancedTable`/`FilterBar` allows overriding cell renderers, filter widgets, and action buttons.
|
||||
- **Error Handling**
|
||||
- Centralized error toast (`useToast`) displays API errors.
|
||||
- Table shows “No data” state when an empty array is returned.
|
||||
|
||||
## src (Application Core)
|
||||
- **src/main.tsx** – Sets up MUI theme, React Router, `AuthProvider`, and `QueryClientProvider`.
|
||||
- **src/App.tsx** – Defines routes:
|
||||
```tsx
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginForm />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/admin/:resource" element={<ResourceList />} />
|
||||
<Route path="/admin/:resource/edit/:id" element={<ResourceForm />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
```
|
||||
- **src/pages/ResourceList.tsx** – Reads `resource` from URL, loads its `ResourceConfig`, calls `useResource`, and renders `EnhancedTable` + `FilterBar`.
|
||||
- **src/pages/ResourceForm.tsx** – Dynamically builds a form based on `ResourceField` definitions, using `displayFormat` for default values.
|
||||
- **State Management** – TanStack Query caches paginated results; `AuthProvider` ensures all API calls include a valid JWT.
|
||||
- **Theming** – `ThemeProvider` toggles light/dark mode via a context hook that persists the preference in `localStorage`.
|
||||
|
||||
These implementation notes detail the concrete file layout, data flow, and core logic that power the UI generated from OpenAPI specifications while maintaining authenticated access. They can be directly adapted for the **lovable** platform to provide a richer UI and better handling of auth and data rendering.
|
||||
172
REFRACTOR_GUIDE.md
Normal file
172
REFRACTOR_GUIDE.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Refactor Guide – Deep Dive into the Khata‑UI Codebase
|
||||
|
||||
> This document walks through the entire repository, explains the current architecture, and provides a step‑by‑step refactor plan that will improve maintainability, type safety, and UI/UX while preserving the existing functional behavior.
|
||||
|
||||
---
|
||||
|
||||
## 1. Repository Layout (high‑level)
|
||||
```
|
||||
khata-ui/
|
||||
├─ react-openapi/ # Core UI generated from OpenAPI configs
|
||||
│ ├─ components/ # UI pieces: EnhancedTable, FilterBar, etc.
|
||||
│ ├─ types/ # TypeScript interfaces (config, overrides)
|
||||
│ └─ utils/ # Helper utilities (options, template resolution)
|
||||
├─ src/ # Application entry point and pages
|
||||
│ ├─ auth/ # Authentication context, hooks, and protected routes
|
||||
│ ├─ pages/ # Dynamic resources (list, form)
|
||||
│ └─ main.tsx # React root, providers, theming
|
||||
├─ public/ # Static assets (favicon, index.html)
|
||||
├─ index.html
|
||||
├─ package.json
|
||||
└─ tsconfig.json
|
||||
```
|
||||
|
||||
### Key Concepts
|
||||
| Area | Responsibility |
|
||||
|------|-----------------|
|
||||
| **Auth** | Central JWT handling, `AuthProvider`, `useAuth`, route guarding. |
|
||||
| **OpenAPI‑driven UI** | Describes each resource via `ResourceConfig`/`ResourceField`. Generates tables, filters, and forms automatically. |
|
||||
| **Data Layer** | TanStack Query (`useQuery`) fetches data; Axios instance carries auth token via interceptor. |
|
||||
| **Theming** | MUI theme with light/dark mode toggle (future). |
|
||||
| **Extensibility** | `components` prop on `EnhancedTable` / `FilterBar` lets callers inject custom cell renderers, filter widgets, or action buttons. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Module Walk‑through
|
||||
### 2.1 `react-openapi/types/config.ts`
|
||||
```ts
|
||||
export interface ResourceField {
|
||||
displayFormat: string; // <- single source of truth for rendering
|
||||
type: FieldType;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
readOnly?: boolean;
|
||||
schema?: Record<string, ResourceField>;
|
||||
formatter?: (value: any) => string;
|
||||
relation?: string;
|
||||
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
||||
enumOption?: EnumOption;
|
||||
enumLabels?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
- `displayFormat` replaces the legacy `displayField`. It can be a **template string** (`"{{first}} {{last}}"`) or an **array of keys** for concatenation.
|
||||
- All UI components now rely exclusively on this field.
|
||||
|
||||
### 2.2 `react-openapi/utils/options.ts`
|
||||
- `resolveTemplate(format: string, item: any)` – interpolates `{{key}}` placeholders.
|
||||
- `getFieldOptions`, `toGridValueOptions` convert enum definitions into MUI‑compatible arrays.
|
||||
- **Refactor idea**: Move the `displayFormat` resolution logic from `EnhancedTable`/`FilterBar` into a dedicated helper (`formatDisplay(item, field)`), reducing duplication.
|
||||
|
||||
### 2.3 `react-openapi/components/EnhancedTable.tsx`
|
||||
- **Core responsibilities**
|
||||
1. Build column definitions from `config.fields`.
|
||||
2. Render each cell via `FieldRenderer`.
|
||||
3. Provide server‑side or client‑side pagination.
|
||||
4. Add a static "Actions" column.
|
||||
- **Key functions**
|
||||
- `getFormattedDisplayValue(item, displayFormat?, enumValue?)` – now uses `resolveTemplate` and falls back to generic fields.
|
||||
- `FieldRenderer` – decides how to render a cell based on `field.type`, `field.relation`, custom renderers, and `displayFormat`.
|
||||
- **Duplication**: Both `EnhancedTable` and `FilterBar` perform very similar `displayFormat` extraction. Extracting this into a shared utility will shrink the component size and make testing easier.
|
||||
|
||||
### 2.4 `react-openapi/components/FilterBar.tsx`
|
||||
- Generates filter controls for each **filterable** field.
|
||||
- Uses `extractOptions` to populate autocomplete lists, falling back to `displayFormat` for label generation.
|
||||
- **Opportunity**: Replace the inline `pull` helper with the shared formatter from `utils/options`.
|
||||
|
||||
### 2.5 Authentication (`src/auth`)
|
||||
- `AuthContext.tsx` – provides `user`, `accessToken`, `isAuthenticated` plus actions.
|
||||
- `useAuth.ts` – thin wrapper exposing the context values.
|
||||
- `ProtectedRoute.tsx` – guards routes, redirects to `/login` when unauthenticated.
|
||||
- `api.ts` – thin Axios wrapper (`login`, `logout`, `refresh`).
|
||||
- **Refactor suggestions**
|
||||
- Consolidate token storage (localStorage ↔ sessionStorage) behind a small `tokenStore` service.
|
||||
- Add automatic token refresh using an interceptor that retries the original request.
|
||||
- Provide a hook (`useAuthorizedQuery`) that injects the auth token into TanStack Query automatically.
|
||||
|
||||
### 2.6 Application Core (`src/pages`, `src/main.tsx`)
|
||||
- `ResourceList.tsx` – reads `resource` param, loads the related `ResourceConfig` from a central map, fetches data, and renders `EnhancedTable` + `FilterBar`.
|
||||
- `ResourceForm.tsx` – builds a dynamic form based on `ResourceField` definitions; uses `displayFormat` for default values on relation fields.
|
||||
- `main.tsx` – wraps the app with `AuthProvider`, `QueryClientProvider`, and MUI `ThemeProvider`.
|
||||
- **Future work**: Extract the “resource loader” into a hook (`useResourceConfig(resourceName)`) that also validates the config at runtime.
|
||||
|
||||
---
|
||||
|
||||
## 3. Refactor Roadmap – Step‑by‑Step
|
||||
### Phase 1 – Consolidate Formatting Logic
|
||||
1. **Create utility** `src/react-openapi/utils/formatDisplay.ts`
|
||||
```ts
|
||||
export const formatDisplay = (item: any, field: ResourceField, enumValue?: string) => {
|
||||
if (enumValue) return resolveTemplate(enumValue, item);
|
||||
const fmt = field.displayFormat;
|
||||
if (!fmt) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
|
||||
if (Array.isArray(fmt)) {
|
||||
return fmt.map(k => item[k]).filter(Boolean).join(' ');
|
||||
}
|
||||
return resolveTemplate(fmt, item) || item.id || JSON.stringify(item);
|
||||
};
|
||||
```
|
||||
2. Replace *all* inline calls to `getFormattedDisplayValue` in `EnhancedTable` and `FilterBar` with `formatDisplay`.
|
||||
3. Remove `getFormattedDisplayValue` from `EnhancedTable.tsx` (or keep it as a thin wrapper for backward compatibility).
|
||||
4. Update imports accordingly.
|
||||
5. Run TypeScript check – no errors.
|
||||
|
||||
### Phase 2 – Decouple UI from Config Loading
|
||||
- Introduce **`configLoader.ts`** under `src/react-openapi/utils` that reads a JSON file (or fetches a remote spec) and produces a `Record<string, ResourceConfig>`.
|
||||
- Replace hard‑coded imports in `src/pages/ResourceList.tsx` with a call to `useResourceConfig(resourceName)`.
|
||||
- Add runtime validation (e.g., using `zod`) to ensure required fields (`displayFormat`, `type`, `label`) are present; surface errors via a toast.
|
||||
|
||||
### Phase 3 – Centralize Error & Loading UI
|
||||
- Create `src/components/LoadingSpinner.tsx` and `src/components/ErrorToast.tsx`.
|
||||
- Wrap all data‑fetching hooks (`useResource`, `useAuth` actions) with a HOC that automatically displays these components.
|
||||
- Migrate the scattered `if (loading) …` checks into the new components.
|
||||
|
||||
### Phase 4 – Theming & Dark Mode
|
||||
1. Add a `ThemeContext` that stores `mode: 'light' | 'dark'` and persists the preference.
|
||||
2. Expose a toggle button (e.g., in the top‑right corner of `App.tsx`).
|
||||
3. Update component styles to use theme‑aware colors (via `theme.palette`), ensuring the `Chip` variants already respect the palette.
|
||||
|
||||
### Phase 5 – Testing & CI
|
||||
- **Unit tests** using `vitest` for:
|
||||
- `formatDisplay` utility (various template & array cases).
|
||||
- `AuthProvider` behavior (login, logout, token refresh).
|
||||
- **Component tests** (`@testing-library/react`) for `EnhancedTable` and `FilterBar` verifying that `displayFormat` rendering matches expectations.
|
||||
- Add a GitHub Actions workflow that runs `npm run lint && npx tsc --noEmit && vitest run` on each PR.
|
||||
|
||||
### Phase 6 – Documentation (the files you will publish)
|
||||
- **DESIGN.md** – high‑level architecture (already present).
|
||||
- **IMPLEMENTATION.md** – detailed file‑by‑file breakdown (already present).
|
||||
- **CONCEPT.md** – why the metadata‑driven approach works (already present).
|
||||
- **REFRACTOR_GUIDE.md** – the detailed guide you are reading now (this file).
|
||||
- Keep these files in the repo root; they can be exported to the **lovable** platform directly.
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration Checklist (what to verify after refactor)
|
||||
- [ ] All UI components compile with TypeScript (`npx tsc --noEmit`).
|
||||
- [ ] No runtime references to `displayField` remain (search `\.displayField`).
|
||||
- [ ] `formatDisplay` correctly resolves:
|
||||
- Template strings with multiple placeholders.
|
||||
- Array of keys.
|
||||
- Fallback to generic fields.
|
||||
- [ ] Auth flow works (login ➜ token stored ➜ API requests succeed, protected routes guarded).
|
||||
- [ ] Pagination works both client‑ and server‑side.
|
||||
- [ ] Mobile layout (card view) still renders correctly.
|
||||
- [ ] Dark‑mode toggle persists across reloads.
|
||||
- [ ] Lint passes (`npm run lint` if configured) and tests pass.
|
||||
|
||||
---
|
||||
|
||||
## 5. Potential Future Enhancements
|
||||
| Feature | Benefit | Rough Implementation |
|
||||
|---------|---------|----------------------|
|
||||
| **Bulk actions** (delete, export) | Improves admin productivity | Add a toolbar with selection model in `EnhancedTable`. |
|
||||
| **Inline editing** | Faster data tweaks | Replace `onEdit` dialog with cell‑level edit mode using MUI `TextField`. |
|
||||
| **GraphQL fallback** | Flexibility for back‑ends | Abstract data fetching behind an adapter interface (`useDataProvider`). |
|
||||
| **Internationalisation** | Multi‑language UI | Wrap all static strings with `i18n.t()` and provide locale files. |
|
||||
| **Performance profiling** | Identify render bottlenecks | Use React Profiler and memoize expensive formatters (`useMemo`). |
|
||||
|
||||
---
|
||||
|
||||
### Closing Note
|
||||
The current codebase already demonstrates a powerful pattern: **declare once, render everywhere**. By consolidating the display logic, adding a small utility layer, and strengthening the authentication and theming foundations, the project will become easier to extend, test, and hand‑off to the **lovable** UI platform while retaining its low‑code advantage.
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "latest"
|
||||
}
|
||||
},
|
||||
@@ -4103,6 +4104,19 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import ResourceView from "./components/ResourceView";
|
||||
import { getAppConfig } from "./config";
|
||||
import { initializeApiClients } from "./api/client";
|
||||
import { AppConfig } from "./types/config";
|
||||
import { FieldComponents } from "./types/overrides";
|
||||
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
||||
import {
|
||||
Routes,
|
||||
@@ -15,8 +16,9 @@ import {
|
||||
} from "react-router-dom";
|
||||
|
||||
import { ConfigContext } from "./providers/ConfigContext";
|
||||
import ProfileView from "./components/ProfileView";
|
||||
|
||||
function Dashboard({ basePath }: { basePath: string }) {
|
||||
function DefaultDashboard({ basePath }: { basePath: string }) {
|
||||
const config = React.useContext(ConfigContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -31,7 +33,6 @@ function Dashboard({ basePath }: { basePath: string }) {
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
||||
Select a resource from the sidebar to manage data.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
@@ -41,10 +42,10 @@ function Dashboard({ basePath }: { basePath: string }) {
|
||||
}}
|
||||
>
|
||||
{visibleResources.map((res) => (
|
||||
<Paper
|
||||
key={res.name}
|
||||
sx={{
|
||||
p: 3,
|
||||
<Paper
|
||||
key={res.name}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: "center",
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
@@ -61,9 +62,15 @@ function Dashboard({ basePath }: { basePath: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
import ProfileView from "./components/ProfileView";
|
||||
interface AdminAppProps {
|
||||
basePath: string;
|
||||
fieldComponents: FieldComponents;
|
||||
Dashboard?: React.ComponentType<{ basePath: string }>;
|
||||
Layout?: React.ComponentType<AdminLayoutProps>;
|
||||
LoginPage?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
function AdminApp({ basePath }: { basePath: string }) {
|
||||
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) {
|
||||
const { currentUser, login, logout, loading, error } = useAuth();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const navigate = useNavigate();
|
||||
@@ -73,10 +80,10 @@ function AdminApp({ basePath }: { basePath: string }) {
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<AuthPage
|
||||
<LoginPage
|
||||
mode="login"
|
||||
login={login}
|
||||
register={async () => {}} // Disable registration for Admin
|
||||
register={async () => {}}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSwitchMode={() => {}}
|
||||
@@ -87,7 +94,7 @@ function AdminApp({ basePath }: { basePath: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
<Layout
|
||||
username={currentUser.username}
|
||||
onLogout={logout}
|
||||
onSelectResource={(name) => navigate(`/admin/${name}`)}
|
||||
@@ -96,32 +103,44 @@ function AdminApp({ basePath }: { basePath: string }) {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
||||
<Route path="/profile" element={<ProfileView />} />
|
||||
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceRouteWrapper() {
|
||||
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) {
|
||||
const { resourceName } = useParams();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
||||
|
||||
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
||||
|
||||
return <ResourceView config={selectedResource} />;
|
||||
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
|
||||
}
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSelectResource: (resourceName: string | null) => void;
|
||||
onLogout: () => void;
|
||||
username?: string;
|
||||
resources: import("./types/config").ResourceConfig[];
|
||||
}
|
||||
|
||||
interface AdminProps {
|
||||
basePath?: string;
|
||||
resourceOverrides?: Record<string, any>;
|
||||
profileConfig?: any;
|
||||
fieldComponents: FieldComponents;
|
||||
Dashboard?: React.ComponentType<{ basePath: string }>;
|
||||
Layout?: React.ComponentType<AdminLayoutProps>;
|
||||
LoginPage?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
|
||||
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) {
|
||||
const existingConfig = React.useContext(ConfigContext);
|
||||
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
||||
|
||||
@@ -151,16 +170,14 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
|
||||
|
||||
const content = (
|
||||
<UploadProvider>
|
||||
<AdminApp basePath={basePath} />
|
||||
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} />
|
||||
</UploadProvider>
|
||||
);
|
||||
|
||||
// If we have an existing config, we are already inside a Provider and QueryClient
|
||||
if (existingConfig) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Fallback for standalone usage
|
||||
return (
|
||||
<ConfigContext.Provider value={config}>
|
||||
{content}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { createApiClient } from "../../react-auth";
|
||||
|
||||
/**
|
||||
@@ -30,25 +31,25 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.get(...args);
|
||||
return _api.get<T, R>(url, config);
|
||||
},
|
||||
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.post(...args);
|
||||
return _api.post<T, R>(url, data, config);
|
||||
},
|
||||
put: (...args: Parameters<AxiosInstance["put"]>) => {
|
||||
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.put(...args);
|
||||
return _api.put<T, R>(url, data, config);
|
||||
},
|
||||
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
|
||||
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.delete(...args);
|
||||
return _api.delete<T, R>(url, config);
|
||||
},
|
||||
patch: (...args: Parameters<AxiosInstance["patch"]>) => {
|
||||
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.patch(...args);
|
||||
return _api.patch<T, R>(url, data, config);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { EnhancedTableComponents } from '../types/overrides';
|
||||
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
|
||||
|
||||
interface EnhancedTableProps {
|
||||
@@ -44,6 +45,7 @@ interface EnhancedTableProps {
|
||||
onDelete: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
components?: EnhancedTableComponents;
|
||||
}
|
||||
|
||||
export default function EnhancedTable({
|
||||
@@ -57,6 +59,7 @@ export default function EnhancedTable({
|
||||
onDelete,
|
||||
onCreate,
|
||||
onNavigateToResource,
|
||||
components: tableComponents,
|
||||
}: EnhancedTableProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -85,7 +88,7 @@ export default function EnhancedTable({
|
||||
type: muiType,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} />
|
||||
};
|
||||
|
||||
if (muiType === 'date' || muiType === 'dateTime') {
|
||||
@@ -97,7 +100,7 @@ export default function EnhancedTable({
|
||||
}
|
||||
|
||||
if (muiType === 'singleSelect') {
|
||||
col.valueOptions = toGridValueOptions(getFieldOptions(field));
|
||||
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
|
||||
}
|
||||
|
||||
return col;
|
||||
@@ -158,6 +161,7 @@ export default function EnhancedTable({
|
||||
onDelete={onDelete}
|
||||
onNavigate={onNavigateToResource}
|
||||
navigate={navigate}
|
||||
components={tableComponents}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
@@ -225,7 +229,7 @@ export default function EnhancedTable({
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const id = row[config.primaryKey];
|
||||
@@ -261,7 +265,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} />
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
@@ -274,31 +278,27 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
function getFormattedDisplayValue(item: any, displayField?: string | string[], enumValue?: string) {
|
||||
function getFormattedDisplayValue(item: any, displayFormat: string) {
|
||||
if (!item) return "";
|
||||
if (enumValue) return resolveTemplate(enumValue, item);
|
||||
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
|
||||
|
||||
if (Array.isArray(displayField)) {
|
||||
return displayField
|
||||
.map(key => item[key])
|
||||
.filter(val => val !== undefined && val !== null)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return item[displayField] || item.id || JSON.stringify(item);
|
||||
return resolveTemplate(displayFormat, item);
|
||||
}
|
||||
|
||||
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
|
||||
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) {
|
||||
const value = params.value;
|
||||
const isPk = fieldKey === config.primaryKey;
|
||||
|
||||
if (field.formatter) return field.formatter(value);
|
||||
|
||||
const customRenderer = components?.cellRenderers?.[field.type as string];
|
||||
if (customRenderer) {
|
||||
return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile });
|
||||
}
|
||||
|
||||
// 1. Single Relation
|
||||
if (field.relation && value && !Array.isArray(value)) {
|
||||
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||
const displayValue = getFormattedDisplayValue(value, field.displayField, field.enumOption?.value);
|
||||
const displayValue = getFormattedDisplayValue(value, field.displayFormat);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
@@ -318,7 +318,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
||||
// 2. Multi-Select (Array of relations or simple strings)
|
||||
if (field.type === 'array' && Array.isArray(value)) {
|
||||
const enumValue = field.enumOption?.value;
|
||||
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField, enumValue)).join(', ');
|
||||
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', ');
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipTitle} arrow placement="top">
|
||||
@@ -326,7 +326,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
||||
{value.map((item, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={getFormattedDisplayValue(item, field.displayField, enumValue)}
|
||||
label={getFormattedDisplayValue(item, field.displayFormat)}
|
||||
size="small"
|
||||
variant="filled"
|
||||
sx={{ maxWidth: 120 }}
|
||||
@@ -346,7 +346,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
||||
|
||||
// 3. Simple Objects
|
||||
if (field.type === 'object' && value) {
|
||||
return getFormattedDisplayValue(value, field.displayField, field.enumOption?.value) || (isMobile ? 'Object' : JSON.stringify(value));
|
||||
return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value));
|
||||
}
|
||||
|
||||
if (field.type === 'number' && typeof value === 'number') {
|
||||
@@ -379,7 +379,9 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
|
||||
if (field.type === 'datetime') return value ? new Date(value).toLocaleString() : '';
|
||||
if (field.type === 'date') return value ? new Date(value).toLocaleDateString() : '';
|
||||
|
||||
|
||||
if (field.type === 'enum') {
|
||||
const opt = getFieldOptions(field).find(o => o.key === value);
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
import DoneIcon from "@mui/icons-material/Done";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import { ResourceField, ResourceMode } from "../types/config";
|
||||
import { FilterBarComponents, FieldComponents } from "../types/overrides";
|
||||
import { getFieldOptions, resolveTemplate } from "../utils/options";
|
||||
|
||||
function FilterAutocomplete({
|
||||
export function FilterAutocomplete({
|
||||
options,
|
||||
value,
|
||||
label,
|
||||
@@ -123,15 +124,10 @@ function extractOptions(
|
||||
|
||||
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
||||
|
||||
const df = field.displayField;
|
||||
if (!df) return null;
|
||||
|
||||
if (Array.isArray(df)) {
|
||||
const parts = df.map((k) => item[k]).filter((v) => v != null);
|
||||
if (parts.length > 0) return parts.join(" ");
|
||||
// Use displayFormat if defined
|
||||
if (field.displayFormat) {
|
||||
return resolveTemplate(field.displayFormat, item);
|
||||
}
|
||||
const v = item[df];
|
||||
if (v != null) return String(v);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -160,32 +156,24 @@ function renderFilterInput(
|
||||
field: ResourceField,
|
||||
options: string[],
|
||||
value: any,
|
||||
onChange: (key: string, val: any) => void
|
||||
onChange: (key: string, val: any) => void,
|
||||
components?: FilterBarComponents,
|
||||
fieldComponents?: FieldComponents,
|
||||
) {
|
||||
const filterType = field.filterType;
|
||||
|
||||
if (filterType === "number-range") {
|
||||
const RangeComponent = fieldComponents?.numberRange;
|
||||
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
|
||||
const rangeVal = (value as { min?: string; max?: string }) || {};
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<TextField type="number" placeholder="Min" size="small" value={rangeVal.min ?? ""}
|
||||
onChange={(e) => onChange("min", e.target.value || undefined)} sx={{ width: 100 }} />
|
||||
<TextField type="number" placeholder="Max" size="small" value={rangeVal.max ?? ""}
|
||||
onChange={(e) => onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
|
||||
</Box>
|
||||
);
|
||||
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
||||
}
|
||||
|
||||
if (filterType === "date-range") {
|
||||
const RangeComponent = fieldComponents?.dateRange;
|
||||
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
|
||||
const rangeVal = (value as { start?: string; end?: string }) || {};
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<TextField type="datetime-local" placeholder="From" size="small" value={rangeVal.start ?? ""}
|
||||
onChange={(e) => onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
|
||||
<TextField type="datetime-local" placeholder="To" size="small" value={rangeVal.end ?? ""}
|
||||
onChange={(e) => onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
|
||||
</Box>
|
||||
);
|
||||
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
||||
}
|
||||
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
@@ -208,6 +196,8 @@ export interface FilterBarProps {
|
||||
appliedValues: Record<string, any>;
|
||||
onApply: (values: Record<string, any>) => void;
|
||||
onClear: () => void;
|
||||
components?: FilterBarComponents;
|
||||
fieldComponents?: FieldComponents;
|
||||
}
|
||||
|
||||
export default function FilterBar({
|
||||
@@ -217,6 +207,8 @@ export default function FilterBar({
|
||||
appliedValues,
|
||||
onApply,
|
||||
onClear,
|
||||
components: filterComponents,
|
||||
fieldComponents,
|
||||
}: FilterBarProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
|
||||
@@ -284,7 +276,7 @@ export default function FilterBar({
|
||||
const field = fields[fieldName];
|
||||
if (!field) return null;
|
||||
|
||||
const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect";
|
||||
const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect";
|
||||
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
|
||||
const raw = draft[fieldName];
|
||||
|
||||
@@ -294,7 +286,7 @@ export default function FilterBar({
|
||||
{field.label}
|
||||
</Box>
|
||||
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
|
||||
updateDraft(fieldName, key, val)
|
||||
updateDraft(fieldName, key, val), filterComponents, fieldComponents
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { FieldComponents } from '../types/overrides';
|
||||
import { useUpload } from '../providers/UploadProvider';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
@@ -21,6 +22,7 @@ interface GenericFormProps {
|
||||
loading?: boolean;
|
||||
readOnly?: boolean;
|
||||
onEditClick?: () => void;
|
||||
fieldComponents: FieldComponents;
|
||||
}
|
||||
|
||||
export default function GenericForm({
|
||||
@@ -31,6 +33,7 @@ export default function GenericForm({
|
||||
loading: saving,
|
||||
readOnly = false,
|
||||
onEditClick,
|
||||
fieldComponents,
|
||||
}: GenericFormProps) {
|
||||
initialData = initialData || {};
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
@@ -42,23 +45,30 @@ export default function GenericForm({
|
||||
let relations: string[] = [];
|
||||
Object.values(fields).forEach(field => {
|
||||
if (field.relation) relations.push(field.relation);
|
||||
if (field.refers) relations.push(field.refers);
|
||||
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
|
||||
});
|
||||
return Array.from(new Set(relations));
|
||||
};
|
||||
|
||||
const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]);
|
||||
const allRelations = React.useMemo(() => {
|
||||
const rels = getRelationFields(config.fields);
|
||||
console.log('Form resource', config.name, 'relations discovered:', rels);
|
||||
return rels;
|
||||
}, [config.fields]);
|
||||
|
||||
// 2. Parallel fetch for all related resource lists
|
||||
const queries = useQueries({
|
||||
queries: allRelations.map(relName => {
|
||||
const relatedRes = appConfig?.resources.find(r => r.name === relName);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { getListQueryOptions } = useResource(relatedRes!);
|
||||
return {
|
||||
...getListQueryOptions(),
|
||||
enabled: !!relatedRes,
|
||||
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents });
|
||||
const queryOpts = {
|
||||
...getListQueryOptions(),
|
||||
enabled: !!relatedRes,
|
||||
};
|
||||
console.log('Query for relation', relName, 'resource', relatedRes?.name, 'enabled', !!relatedRes);
|
||||
return queryOpts;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -67,9 +77,12 @@ export default function GenericForm({
|
||||
const relationDataMap = React.useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
allRelations.forEach((relName, index) => {
|
||||
// @ts-ignore
|
||||
map[relName] = queries[index].data || [];
|
||||
});
|
||||
const queryResult = queries[index];
|
||||
const dataArray = queryResult?.data && Array.isArray(queryResult.data) ? queryResult.data : (queryResult?.data?.data ?? []);
|
||||
console.log('Relation query result for', relName, 'raw:', queryResult?.data);
|
||||
console.log('Relation data for', relName, ':', dataArray.slice(0, 1));
|
||||
map[relName] = dataArray;
|
||||
});
|
||||
return map;
|
||||
}, [allRelations, queries]);
|
||||
|
||||
@@ -117,6 +130,7 @@ export default function GenericForm({
|
||||
uploading={uploading}
|
||||
baseUrl={appConfig?.baseUrl || ""}
|
||||
relationDataMap={relationDataMap}
|
||||
components={fieldComponents}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericForm from './GenericForm';
|
||||
import { ConfigContext } from '../providers/ConfigContext';
|
||||
import { defaultFieldComponents } from './fields/DefaultFieldComponents';
|
||||
|
||||
export default function ProfileView() {
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
@@ -13,11 +14,10 @@ export default function ProfileView() {
|
||||
return <Alert severity="error">Profile configuration not found.</Alert>;
|
||||
}
|
||||
|
||||
// Create a modified config where only extraFields are editable
|
||||
const editableConfig = React.useMemo(() => {
|
||||
const newFields = { ...resourceConfig.fields };
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
|
||||
|
||||
Object.keys(newFields).forEach(key => {
|
||||
newFields[key] = {
|
||||
...newFields[key],
|
||||
@@ -31,13 +31,12 @@ export default function ProfileView() {
|
||||
};
|
||||
}, [resourceConfig, profileConfig.extraFields]);
|
||||
|
||||
const { useMe, useUpdateMe } = useResource(resourceConfig);
|
||||
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents });
|
||||
const { data: profile, isLoading, error } = useMe();
|
||||
const updateMutation = useUpdateMe();
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
// Only send editable fields to prevent accidental overwrites of read-only data
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
const dataToSave = Object.keys(formData)
|
||||
.filter(key => extraFields.includes(key))
|
||||
@@ -76,6 +75,7 @@ export default function ProfileView() {
|
||||
onSave={handleSave}
|
||||
onCancel={() => window.history.back()}
|
||||
loading={updateMutation.isPending}
|
||||
fieldComponents={defaultFieldComponents}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -2,9 +2,9 @@ import * as React from 'react';
|
||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import type { ResourceField } from '../types/config';
|
||||
import { FieldComponents } from '../types/overrides';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import { resolveTemplate } from '../utils/options';
|
||||
import GenericForm from './GenericForm';
|
||||
import EnhancedTable from './EnhancedTable';
|
||||
import FilterBar from './FilterBar';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -12,6 +12,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
interface ResourceViewProps {
|
||||
config: ResourceConfig;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
fieldComponents: FieldComponents;
|
||||
}
|
||||
|
||||
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||
@@ -19,13 +20,8 @@ import { GridPaginationModel } from '@mui/x-data-grid';
|
||||
function getDisplayString(item: any, field: ResourceField): string {
|
||||
if (item == null || typeof item !== 'object') return String(item ?? '');
|
||||
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
||||
const df = field.displayField;
|
||||
if (!df) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
|
||||
if (Array.isArray(df)) {
|
||||
const parts = df.map((k: string) => item[k]).filter((v: any) => v != null);
|
||||
return parts.length > 0 ? parts.join(' ') : '';
|
||||
}
|
||||
return String(item[df] ?? '');
|
||||
if (field.displayFormat) return resolveTemplate(field.displayFormat, item);
|
||||
throw new Error('cannot get display string')
|
||||
}
|
||||
|
||||
function applyClientFilters(
|
||||
@@ -96,7 +92,7 @@ function applyClientFilters(
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -115,7 +111,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
|
||||
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
||||
|
||||
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
|
||||
|
||||
const queryParams = React.useMemo(() => {
|
||||
if (!isServer) return { limit: 10000 };
|
||||
@@ -183,6 +179,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
appliedValues={appliedFilters}
|
||||
onApply={setAppliedFilters}
|
||||
onClear={() => setAppliedFilters({})}
|
||||
fieldComponents={components}
|
||||
/>
|
||||
)}
|
||||
<EnhancedTable
|
||||
@@ -200,7 +197,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
</Box>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
{components && <components.GenericForm
|
||||
config={config}
|
||||
initialData={isCreate ? null : itemQuery.data}
|
||||
onSave={handleSave}
|
||||
@@ -208,7 +205,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
readOnly={isView}
|
||||
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
||||
/>
|
||||
/>}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
17
react-openapi/components/fields/BooleanField.tsx
Normal file
17
react-openapi/components/fields/BooleanField.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FormControlLabel, Checkbox } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={field.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
react-openapi/components/fields/DateField.tsx
Normal file
18
react-openapi/components/fields/DateField.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { TextField as MuiTextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||
const isDatetime = field.type === 'datetime';
|
||||
return (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={isDatetime ? "datetime-local" : "date"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
react-openapi/components/fields/DateRangeField.tsx
Normal file
30
react-openapi/components/fields/DateRangeField.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Box, TextField as MuiTextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function DateRangeField({ value, onChange, disabled }: FieldComponentProps) {
|
||||
const rangeVal = (value as { start?: string; end?: string }) || {};
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<MuiTextField
|
||||
type="date"
|
||||
placeholder="From"
|
||||
size="small"
|
||||
value={rangeVal.start ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 170 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MuiTextField
|
||||
type="date"
|
||||
placeholder="To"
|
||||
size="small"
|
||||
value={rangeVal.end ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ width: 170 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
40
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
40
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import { FieldComponents, FieldComponentProps } from '../../types/overrides';
|
||||
import TextFieldEntry from './TextField';
|
||||
import NumberField from './NumberField';
|
||||
import BooleanField from './BooleanField';
|
||||
import DateField from './DateField';
|
||||
import EnumField from './EnumField';
|
||||
import RelationField from './RelationField';
|
||||
import ImageUploadField from './ImageUploadField';
|
||||
import FallbackField from './FallbackField';
|
||||
import DateRangeField from './DateRangeField';
|
||||
import NumberRangeField from './NumberRangeField';
|
||||
|
||||
const WrappedImageUploadField = (props: FieldComponentProps) =>
|
||||
React.createElement(ImageUploadField, {
|
||||
label: props.field.label,
|
||||
value: props.value || '',
|
||||
onUpload: async (file: File) => {
|
||||
const url = await props.uploadFile?.(file);
|
||||
if (url) props.onChange(url);
|
||||
},
|
||||
uploading: props.uploading,
|
||||
baseUrl: props.baseUrl || '',
|
||||
disabled: props.disabled,
|
||||
});
|
||||
|
||||
export const defaultFieldComponents: FieldComponents = {
|
||||
string: TextFieldEntry,
|
||||
markdown: TextFieldEntry,
|
||||
number: NumberField,
|
||||
boolean: BooleanField,
|
||||
date: DateField,
|
||||
datetime: DateField,
|
||||
enum: EnumField,
|
||||
image: WrappedImageUploadField,
|
||||
relation: RelationField,
|
||||
default: FallbackField,
|
||||
dateRange: DateRangeField,
|
||||
numberRange: NumberRangeField,
|
||||
};
|
||||
24
react-openapi/components/fields/EnumField.tsx
Normal file
24
react-openapi/components/fields/EnumField.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
|
||||
import { getFieldOptions } from '../../utils/options';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||
const options = getFieldOptions(field);
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
13
react-openapi/components/fields/FallbackField.tsx
Normal file
13
react-openapi/components/fields/FallbackField.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function FallbackField({ field, value }: FieldComponentProps) {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { ResourceField } from '../../types/config';
|
||||
import { getFieldOptions } from '../../utils/options';
|
||||
import ImageUploadField from './ImageUploadField';
|
||||
import { FieldComponentProps, FieldComponents } from '../../types/overrides';
|
||||
import ObjectField from './ObjectField';
|
||||
|
||||
interface FormFieldProps {
|
||||
export interface FormFieldProps {
|
||||
name: string;
|
||||
field: ResourceField;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
disabled?: boolean;
|
||||
uploadFile: (file: File) => Promise<string | null>;
|
||||
uploading: boolean;
|
||||
baseUrl: string;
|
||||
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||
uploadFile?: (file: File) => Promise<string | null>;
|
||||
uploading?: boolean;
|
||||
baseUrl?: string;
|
||||
relationDataMap?: Record<string, any[]>;
|
||||
components: FieldComponents;
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
@@ -37,190 +26,60 @@ export default function FormField({
|
||||
uploading,
|
||||
baseUrl,
|
||||
relationDataMap = {},
|
||||
components,
|
||||
}: FormFieldProps) {
|
||||
const label = field.label;
|
||||
const fieldProps: FieldComponentProps = {
|
||||
name,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
baseUrl,
|
||||
relationDataMap,
|
||||
uploadFile,
|
||||
uploading,
|
||||
};
|
||||
|
||||
// 1. Recursive Rendering for Objects (Not Relations)
|
||||
const childComponents = components;
|
||||
|
||||
// 1. Object (recursive) - requires parent FormField for recursion
|
||||
if (field.type === 'object' && field.schema && !field.relation) {
|
||||
return (
|
||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(field.schema).map(([subKey, subField]) => (
|
||||
<FormField
|
||||
key={subKey}
|
||||
name={`${name}.${subKey}`}
|
||||
field={subField}
|
||||
value={value?.[subKey]}
|
||||
onChange={(newVal) => {
|
||||
const updated = { ...(value || {}), [subKey]: newVal };
|
||||
onChange(updated);
|
||||
}}
|
||||
disabled={disabled}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
relationDataMap={relationDataMap}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
const renderChild = (childProps: FieldComponentProps) => (
|
||||
<FormField
|
||||
name={childProps.name}
|
||||
field={childProps.field}
|
||||
value={childProps.value}
|
||||
onChange={childProps.onChange}
|
||||
disabled={childProps.disabled}
|
||||
uploadFile={childProps.uploadFile}
|
||||
uploading={childProps.uploading}
|
||||
baseUrl={childProps.baseUrl}
|
||||
relationDataMap={childProps.relationDataMap}
|
||||
components={components}
|
||||
/>
|
||||
);
|
||||
return <ObjectField {...fieldProps} renderField={renderChild} />;
|
||||
}
|
||||
|
||||
// 2. Relation Handling (Select / Multi-Select)
|
||||
if (field.relation && relationDataMap[field.relation]) {
|
||||
const relationData = relationDataMap[field.relation].data;
|
||||
const isArrayRelation = field.type === 'array';
|
||||
const options = getFieldOptions(field, relationData);
|
||||
const keyField = field.enumOption?.key ?? 'id';
|
||||
|
||||
// Normalize value: API returns whole objects on GET, but form uses key strings
|
||||
const normalizedValue = (() => {
|
||||
if (isArrayRelation && Array.isArray(value)) {
|
||||
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v)));
|
||||
}
|
||||
if (value != null && typeof value === 'object') {
|
||||
return String(value[keyField] ?? '');
|
||||
}
|
||||
return value ?? (isArrayRelation ? [] : "");
|
||||
})();
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<Select
|
||||
multiple={isArrayRelation}
|
||||
value={normalizedValue}
|
||||
label={label}
|
||||
displayEmpty
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected: any) => {
|
||||
if (isArrayRelation) {
|
||||
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
||||
}
|
||||
return options.find(o => o.key === selected)?.value ?? selected;
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Image Handling
|
||||
// 2. Image
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<ImageUploadField
|
||||
label={label}
|
||||
value={value}
|
||||
onUpload={async (file: any) => {
|
||||
const url = await uploadFile(file);
|
||||
if (url) onChange(url);
|
||||
}}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
const ImageField = components.image;
|
||||
if (!ImageField) return null;
|
||||
return <ImageField {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 4. Boolean Handling
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
// 3. Relation
|
||||
if (field.relation && relationDataMap[field.relation]) {
|
||||
const RelationFieldComp = components.relation;
|
||||
if (!RelationFieldComp) return null;
|
||||
return <RelationFieldComp {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 5. Enum Handling
|
||||
if (field.type === 'enum') {
|
||||
const options = getFieldOptions(field);
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
// 4. Lookup by field type
|
||||
const Component = components[field.type] || components.default;
|
||||
if (Component) {
|
||||
return <Component {...fieldProps} />;
|
||||
}
|
||||
|
||||
// 6. Common Text Fields
|
||||
if (field.type === 'datetime' || field.type === 'date') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type={field.type === 'datetime' ? "datetime-local" : "date"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'markdown' || field.type === 'string') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value || ''}
|
||||
multiline={field.type === 'markdown'}
|
||||
rows={field.type === 'markdown' ? 4 : 1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? '' : value}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
16
react-openapi/components/fields/NumberField.tsx
Normal file
16
react-openapi/components/fields/NumberField.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TextField as MuiTextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||
return (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? '' : value}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
react-openapi/components/fields/NumberRangeField.tsx
Normal file
28
react-openapi/components/fields/NumberRangeField.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Box, TextField as MuiTextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function NumberRangeField({ value, onChange, disabled }: FieldComponentProps) {
|
||||
const rangeVal = (value as { min?: string; max?: string }) || {};
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<MuiTextField
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
size="small"
|
||||
value={rangeVal.min ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
|
||||
sx={{ width: 100 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MuiTextField
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
size="small"
|
||||
value={rangeVal.max ?? ""}
|
||||
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
|
||||
sx={{ width: 100 }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
42
react-openapi/components/fields/ObjectField.tsx
Normal file
42
react-openapi/components/fields/ObjectField.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export interface ObjectFieldProps extends FieldComponentProps {
|
||||
renderField: (props: FieldComponentProps) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ObjectField({ name, field, value, onChange, disabled, baseUrl, uploadFile, uploading, relationDataMap, renderField }: ObjectFieldProps) {
|
||||
if (!field.schema) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(field.schema).map(([subKey, subField]) =>
|
||||
React.cloneElement(
|
||||
renderField({
|
||||
name: `${name}.${subKey}`,
|
||||
field: subField,
|
||||
value: value?.[subKey],
|
||||
onChange: (newVal: any) => {
|
||||
const updated = { ...(value || {}), [subKey]: newVal };
|
||||
onChange(updated);
|
||||
},
|
||||
disabled,
|
||||
baseUrl,
|
||||
uploadFile,
|
||||
uploading,
|
||||
relationDataMap,
|
||||
}) as React.ReactElement,
|
||||
{ key: subKey }
|
||||
)
|
||||
)}
|
||||
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
67
react-openapi/components/fields/RelationField.tsx
Normal file
67
react-openapi/components/fields/RelationField.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
|
||||
import { getFieldOptions } from '../../utils/options';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function RelationField({ field, value, onChange, disabled, relationDataMap = {} }: FieldComponentProps) {
|
||||
console.log('RelationField render', field.label, 'enumOption:', field.enumOption, 'value prop:', value);
|
||||
const relationName = field.relation ?? (field as any).refers;
|
||||
if (!relationName || !relationDataMap[relationName]) {
|
||||
throw new Error(`Relation data for "${relationName}" is missing – cannot render options for field "${field.label}"`);
|
||||
}
|
||||
|
||||
|
||||
const relationData = relationDataMap[relationName];
|
||||
const isArrayRelation = field.type === 'array';
|
||||
const options = getFieldOptions(field, relationData);
|
||||
console.log('Options for', field.label, 'keys:', options.map(o=>o.key));
|
||||
if (options.length === 0) {
|
||||
throw new Error(`No selectable options available for field "${field.label}" (relation "${relationName}")`);
|
||||
}
|
||||
const keyField = field.enumOption?.key ?? 'id';
|
||||
|
||||
|
||||
const normalizedValue = (() => {
|
||||
if (isArrayRelation && Array.isArray(value)) {
|
||||
return value.map((v: any) => {
|
||||
if (v != null && typeof v === 'object') {
|
||||
return String(v[keyField] ?? '');
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
}
|
||||
if (value != null && typeof value === 'object') {
|
||||
return String(value[keyField] ?? '');
|
||||
}
|
||||
// Primitive (number/string) – coerce to string for Select compatibility
|
||||
return value != null ? String(value) : (isArrayRelation ? [] : "");
|
||||
})();
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel shrink>{field.label}</InputLabel>
|
||||
<Select
|
||||
multiple={isArrayRelation}
|
||||
value={normalizedValue}
|
||||
label={field.label}
|
||||
displayEmpty
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected: any) => {
|
||||
console.log('Select renderValue for', field.label, 'selected:', selected);
|
||||
if (isArrayRelation) {
|
||||
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
||||
}
|
||||
const display = options.find(o => o.key === selected)?.value ?? selected;
|
||||
console.log('Display value for', field.label, ':', display);
|
||||
return display;
|
||||
}}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<MenuItem key={opt.key} value={opt.key}>
|
||||
{opt.value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
18
react-openapi/components/fields/TextField.tsx
Normal file
18
react-openapi/components/fields/TextField.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { TextField as MuiTextField } from '@mui/material';
|
||||
import { FieldComponentProps } from '../../types/overrides';
|
||||
|
||||
export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||
const isMarkdown = field.type === 'markdown';
|
||||
return (
|
||||
<MuiTextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
value={value || ''}
|
||||
multiline={isMarkdown}
|
||||
rows={isMarkdown ? 4 : 1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
react-openapi/components/fields/index.ts
Normal file
14
react-openapi/components/fields/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { default as FormField } from './FormField';
|
||||
export { default as ImageUploadField } from './ImageUploadField';
|
||||
export { default as TextField } from './TextField';
|
||||
export { default as NumberField } from './NumberField';
|
||||
export { default as BooleanField } from './BooleanField';
|
||||
export { default as DateField } from './DateField';
|
||||
export { default as EnumField } from './EnumField';
|
||||
export { default as RelationField } from './RelationField';
|
||||
export { default as ObjectField } from './ObjectField';
|
||||
export { default as FallbackField } from './FallbackField';
|
||||
export { default as DateRangeField } from './DateRangeField';
|
||||
export { default as NumberRangeField } from './NumberRangeField';
|
||||
export { defaultFieldComponents } from './DefaultFieldComponents';
|
||||
export type { ObjectFieldProps } from './ObjectField';
|
||||
@@ -1,28 +1,44 @@
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
import { api } from "../api/client";
|
||||
import { ResourceConfig } from "../types/config";
|
||||
import { ConfigContext } from "../providers/ConfigContext";
|
||||
import * as React from "react";
|
||||
import { FieldComponents, FieldComponentProps } from "../types/overrides";
|
||||
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
|
||||
import FormField from "../components/fields/FormField";
|
||||
import GenericForm from "../components/GenericForm";
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
function wrapFormField(merged: FieldComponents) {
|
||||
return (props: Omit<React.ComponentProps<typeof FormField>, 'components'>) =>
|
||||
React.createElement(FormField, { ...props, components: merged });
|
||||
}
|
||||
|
||||
function wrapGenericForm(merged: FieldComponents) {
|
||||
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
|
||||
React.createElement(GenericForm, { ...props, fieldComponents: merged });
|
||||
}
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Return empty/disabled hooks if config is missing
|
||||
|
||||
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
|
||||
|
||||
const mergedComponents = React.useMemo(
|
||||
() => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
|
||||
[options?.fieldComponents],
|
||||
);
|
||||
|
||||
// --- READ ALL ---
|
||||
const useList = (params?: any) =>
|
||||
const useList = (params?: any) =>
|
||||
useQuery({
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
if (!endpoint) return { data: [], total: 0 };
|
||||
console.log('params:', params);
|
||||
// @ts-ignore
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
};
|
||||
},
|
||||
enabled: !!endpoint,
|
||||
@@ -35,7 +51,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
queryKey: [name, "detail", id, params],
|
||||
queryFn: async () => {
|
||||
if (!id || !endpoint) return null;
|
||||
// @ts-ignore
|
||||
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined);
|
||||
return res.data;
|
||||
},
|
||||
@@ -47,7 +62,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.post<T>(endpoint, data);
|
||||
return res.data;
|
||||
},
|
||||
@@ -61,12 +75,10 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (updatedItem) => {
|
||||
// @ts-ignore
|
||||
onSuccess: (updatedItem: any) => {
|
||||
const id = updatedItem[primaryKey];
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||
@@ -78,15 +90,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.patch<T>(`${endpoint}/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (updatedItem) => {
|
||||
// @ts-ignore
|
||||
const id = updatedItem[primaryKey];
|
||||
onSuccess: (updatedItem: any) => {
|
||||
const listId = updatedItem[primaryKey];
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,12 +118,11 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
if (!endpoint) return { data: [], total: 0 };
|
||||
// @ts-ignore
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
};
|
||||
},
|
||||
enabled: !!endpoint,
|
||||
@@ -125,7 +134,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
queryKey: [name, "me"],
|
||||
queryFn: async () => {
|
||||
if (!endpoint) return null;
|
||||
// @ts-ignore
|
||||
const res = await api.get<T>(`${endpoint}/me`);
|
||||
return res.data;
|
||||
},
|
||||
@@ -137,7 +145,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||
return res.data;
|
||||
},
|
||||
@@ -147,6 +154,15 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
},
|
||||
});
|
||||
|
||||
const components = React.useMemo(() => {
|
||||
if (!mergedComponents) return undefined;
|
||||
return {
|
||||
...mergedComponents,
|
||||
FormField: wrapFormField(mergedComponents),
|
||||
GenericForm: wrapGenericForm(mergedComponents),
|
||||
};
|
||||
}, [mergedComponents]);
|
||||
|
||||
return {
|
||||
useList,
|
||||
useRead,
|
||||
@@ -157,12 +173,12 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
useUpdateMe,
|
||||
useDelete,
|
||||
getListQueryOptions,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
export function useResourceByName<T = any>(name: string) {
|
||||
export function useResourceByName<T = any>(name: string, options?: { fieldComponents: FieldComponents }) {
|
||||
const config = React.useContext(ConfigContext);
|
||||
const resourceConfig = config?.resources.find((r) => r.name === name);
|
||||
return useResource<T>(resourceConfig);
|
||||
return useResource<T>(resourceConfig, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ export { default as Admin } from "./Admin";
|
||||
export { api, auth, initializeApiClients } from "./api/client";
|
||||
export { getAppConfig } from "./config";
|
||||
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config";
|
||||
export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides";
|
||||
export { AppProvider } from "./providers/AppProvider";
|
||||
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
||||
export { useResource, useResourceByName } from "./hooks/useResource";
|
||||
export { default as FilterBar } from "./components/FilterBar";
|
||||
export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar";
|
||||
export { default as EnhancedTable } from "./components/EnhancedTable";
|
||||
export { default as GenericForm } from "./components/GenericForm";
|
||||
export { default as ResourceView } from "./components/ResourceView";
|
||||
export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields";
|
||||
|
||||
@@ -21,13 +21,13 @@ export interface EnumOption {
|
||||
}
|
||||
|
||||
export interface ResourceField {
|
||||
displayFormat: string;
|
||||
type: FieldType;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
readOnly?: boolean;
|
||||
schema?: Record<string, ResourceField>;
|
||||
displayField?: string | string[];
|
||||
formatter?: (value: any) => string;
|
||||
relation?: string;
|
||||
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { ResourceField, FieldType } from './config';
|
||||
|
||||
export interface EnumOption {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FieldOverride {
|
||||
displayField?: string | string[];
|
||||
displayFormat?: string;
|
||||
display?: boolean;
|
||||
formatter?: (value: any) => string;
|
||||
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
||||
enumLabels?: Record<string, string>;
|
||||
// New optional properties to support custom config extensions
|
||||
path?: string;
|
||||
refers?: string;
|
||||
// Added support for overriding the base field type and label
|
||||
type?: FieldType;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ResourceOverride {
|
||||
@@ -20,4 +28,62 @@ export interface ResourceOverride {
|
||||
fields?: string[];
|
||||
};
|
||||
enumOption?: EnumOption;
|
||||
// New optional property for reference‑type resources
|
||||
referenceOptions?: {
|
||||
enumOption?: EnumOption;
|
||||
autoComplete?: boolean;
|
||||
prefetch?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldComponentProps {
|
||||
name: string;
|
||||
field: ResourceField;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
baseUrl?: string;
|
||||
relationDataMap?: Record<string, any[]>;
|
||||
uploadFile?: (file: File) => Promise<string | null>;
|
||||
uploading?: boolean;
|
||||
}
|
||||
|
||||
export type FieldComponent = React.ComponentType<FieldComponentProps>;
|
||||
|
||||
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
|
||||
relation?: FieldComponent;
|
||||
image?: FieldComponent;
|
||||
default?: FieldComponent;
|
||||
dateRange?: FieldComponent;
|
||||
numberRange?: FieldComponent;
|
||||
FormField?: React.ComponentType<any>;
|
||||
GenericForm?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
export interface CellRendererProps {
|
||||
value: any;
|
||||
row: any;
|
||||
field: ResourceField;
|
||||
fieldKey: string;
|
||||
config: import('./config').ResourceConfig;
|
||||
onNavigate?: (resourceName: string, id: string) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export type CellRenderer = React.ComponentType<CellRendererProps>;
|
||||
|
||||
export interface EnhancedTableComponents {
|
||||
cellRenderers?: Partial<Record<FieldType, CellRenderer>>;
|
||||
}
|
||||
|
||||
export interface FilterBarComponents {
|
||||
filterInputs?: Record<string, React.ComponentType<{
|
||||
field: ResourceField;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
options: string[];
|
||||
}>>;
|
||||
}
|
||||
|
||||
export type { FieldType };
|
||||
|
||||
@@ -65,6 +65,7 @@ function parseSchemaFields(
|
||||
const fields: Record<string, ResourceField> = {};
|
||||
const { properties, required } = mergeProperties(schema);
|
||||
const overrides = configuration[resourceName]?.fields || {};
|
||||
console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
|
||||
|
||||
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
||||
// Resolve oneOf/anyOf by merging all branch properties
|
||||
@@ -76,6 +77,12 @@ function parseSchemaFields(
|
||||
}
|
||||
|
||||
const type = mapOpenApiType(resolvedProp);
|
||||
if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) {
|
||||
throw new Error(
|
||||
`OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` +
|
||||
`Add an "enum" array with at least one value to the OpenAPI schema definition.`
|
||||
);
|
||||
}
|
||||
const override = overrides[key];
|
||||
|
||||
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
||||
@@ -108,24 +115,25 @@ function parseSchemaFields(
|
||||
if (relation) {
|
||||
fields[key].relation = relation;
|
||||
|
||||
// Propagate enumOption from target resource config, or derive from target schema
|
||||
const explicitEnumOption = configuration[relation]?.enumOption;
|
||||
if (explicitEnumOption) {
|
||||
fields[key].enumOption = explicitEnumOption;
|
||||
} else {
|
||||
const targetProps = targetSchema.properties || {};
|
||||
const valueField = Object.entries(targetProps).find(
|
||||
([name, p]: [string, any]) => name !== 'id' && p.type === 'string'
|
||||
)?.[0];
|
||||
fields[key].enumOption = {
|
||||
key: 'id',
|
||||
value: valueField ?? 'id',
|
||||
};
|
||||
}
|
||||
// Propagate enumOption from target resource config, or derive from target schema
|
||||
const explicitEnumOption = configuration[relation].referenceOptions.enumOption;
|
||||
console.log('if relation configuration...', configuration['accounts']['referenceOptions'])
|
||||
if (explicitEnumOption) {
|
||||
fields[key].enumOption = explicitEnumOption;
|
||||
} else {
|
||||
// No explicit enumOption supplied – this is a configuration error.
|
||||
// We abort loading so the problem is visible immediately.
|
||||
throw new Error(
|
||||
`Missing enumOption for relation "${relation}" on field "${key}". ` +
|
||||
`Define referenceOptions.enumOption in the configuration for resource "${relation}".`
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Recursively parse nested objects (only if not a relation)
|
||||
if (fields[key].type === "object" && resolvedProp.properties && !relation) {
|
||||
console.log('recursive configuration...', configuration['accounts']['referenceOptions'])
|
||||
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
|
||||
}
|
||||
}
|
||||
@@ -137,6 +145,7 @@ function parseSchemaFields(
|
||||
* Scans paths to identify resources and their basic configuration
|
||||
*/
|
||||
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
|
||||
console.log('init configuration...', configuration['accounts']['referenceOptions'])
|
||||
// Use SwaggerParser to dereference the spec.
|
||||
// Dereferencing preserves object identity for $ref targets.
|
||||
const api = await SwaggerParser.dereference(
|
||||
@@ -183,41 +192,43 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
||||
}
|
||||
|
||||
// 2. Generate ResourceConfig for each identified resource
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
if (!listOp || !info.schemaObj) continue;
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
// Always create a resource entry even if the list operation or schema is missing.
|
||||
// This enables relation look‑ups for resources that only have overrides (e.g., accounts, tags).
|
||||
// If we lack a schema we fall back to an empty field map.
|
||||
const hasList = !!listOp;
|
||||
const schema = info.schemaObj;
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
|
||||
const schema = info.schemaObj;
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
const fields = schema ? parseSchemaFields(schema, name, schemaToResourceMap, configuration) : {};
|
||||
|
||||
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
|
||||
|
||||
const resourceOverride = configuration[name] || {};
|
||||
|
||||
const fo = resourceOverride.filterOptions || {};
|
||||
|
||||
resources.push({
|
||||
name,
|
||||
label: schema.title || label,
|
||||
pluralLabel: pluralLabel,
|
||||
endpoint: listPath,
|
||||
primaryKey: "id",
|
||||
fields,
|
||||
pagination: resourceOverride.pagination,
|
||||
hidden: resourceOverride.hidden,
|
||||
filterOptions: {
|
||||
mode: fo.mode || "server",
|
||||
fields: fo.fields,
|
||||
},
|
||||
});
|
||||
}
|
||||
const resourceOverride = configuration[name] || {};
|
||||
const fo = resourceOverride.filterOptions || {};
|
||||
|
||||
resources.push({
|
||||
name,
|
||||
label: schema?.title || label,
|
||||
pluralLabel: pluralLabel,
|
||||
endpoint: listPath,
|
||||
primaryKey: "id",
|
||||
fields,
|
||||
pagination: resourceOverride.pagination,
|
||||
hidden: resourceOverride.hidden,
|
||||
filterOptions: {
|
||||
mode: fo.mode || "server",
|
||||
fields: fo.fields,
|
||||
},
|
||||
});
|
||||
console.log('Loaded resource:', name, 'endpoint:', listPath, 'fields count:', Object.keys(fields).length);
|
||||
}
|
||||
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
|
||||
const enums: Record<string, string[]> = {};
|
||||
if (api.components?.schemas) {
|
||||
for (const [name, schema] of Object.entries(api.components.schemas) as [string, any]) {
|
||||
const apiDoc = api as any;
|
||||
if (apiDoc.components?.schemas) {
|
||||
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
|
||||
if (schema.enum) {
|
||||
enums[name] = schema.enum;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export function resolveTemplate(template: string, item: any): string {
|
||||
}
|
||||
|
||||
export function getFieldOptions(field: ResourceField, relationData?: any[]): SelectOption[] {
|
||||
console.log('getFieldOptions called for field', field.label, 'type', field.type, 'enumOption', field.enumOption);
|
||||
if (field.type === 'enum') {
|
||||
return (field.options ?? []).map(opt => ({
|
||||
key: opt,
|
||||
@@ -16,13 +17,24 @@ export function getFieldOptions(field: ResourceField, relationData?: any[]): Sel
|
||||
}
|
||||
|
||||
if (field.relation) {
|
||||
const data = relationData ?? [];
|
||||
const enumOption = field.enumOption ?? { key: 'id', value: 'name' };
|
||||
|
||||
return data.map(item => ({
|
||||
key: String(item[enumOption.key] ?? ''),
|
||||
const data = Array.isArray(relationData) ? relationData : [];
|
||||
console.log('Getting options for relation', field.relation, 'data count:', data.length);
|
||||
if (data.length === 0) {
|
||||
throw new Error(`Relation data for "${field.relation}" is missing or empty – cannot build options for field "${field.label}"`);
|
||||
}
|
||||
const enumOption = field.enumOption;
|
||||
if (!enumOption) {
|
||||
throw new Error(
|
||||
`Missing enumOption for relation "${field.relation}" on field "${field}". ` +
|
||||
`Define referenceOptions.enumOption in the configuration for resource "${field.relation}".`
|
||||
);
|
||||
}
|
||||
const result = data.map(item => ({
|
||||
key: String(item[enumOption.key] ?? item.id ?? item._id),
|
||||
value: resolveTemplate(enumOption.value, item),
|
||||
}));
|
||||
console.log('Option map for', field.relation, 'first entry:', data[0], 'result key:', result[0]?.key);
|
||||
return result;
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -26,8 +26,6 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||
import {
|
||||
useFetchRequest,
|
||||
useUpdateFetchRequest,
|
||||
useFetchRequestAmbiguities,
|
||||
useResolveAmbiguity,
|
||||
} from "./features/fetch-requests";
|
||||
@@ -37,7 +35,7 @@ import type {
|
||||
ProgressMessage,
|
||||
} from "./features/fetch-requests";
|
||||
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
||||
import { useConfig } from "../react-openapi";
|
||||
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
||||
|
||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||
pending: "default",
|
||||
@@ -148,8 +146,9 @@ export default function FetchRequestDetail() {
|
||||
const navigate = useNavigate();
|
||||
const config = useConfig();
|
||||
|
||||
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!);
|
||||
const updateMutation = useUpdateFetchRequest();
|
||||
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
|
||||
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
|
||||
const updateMutation = usePatch();
|
||||
const resolveMutation = useResolveAmbiguity();
|
||||
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
||||
|
||||
|
||||
@@ -4,16 +4,9 @@ import {
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
ToggleButtonGroup,
|
||||
ToggleButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
@@ -25,6 +18,7 @@ import {
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Tooltip,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
@@ -43,10 +37,6 @@ import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import {
|
||||
useFetchRequestsList,
|
||||
useCreateFetchRequest,
|
||||
useUpdateFetchRequest,
|
||||
useDeleteFetchRequest,
|
||||
useUploadFile,
|
||||
} from "./features/fetch-requests";
|
||||
import type {
|
||||
@@ -57,7 +47,8 @@ import type {
|
||||
} from "./features/fetch-requests";
|
||||
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useResourceByName, useConfig } from "../react-openapi";
|
||||
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
||||
import type { ResourceField } from "../react-openapi";
|
||||
|
||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||
pending: "default",
|
||||
@@ -85,14 +76,14 @@ function formatDate(iso: string) {
|
||||
}
|
||||
|
||||
function formatDateRange(start?: string, end?: string) {
|
||||
if (!start && !end) return "—";
|
||||
if (!start && !end) return "\u2014";
|
||||
const s = start ? new Date(start).toLocaleDateString() : "?";
|
||||
const e = end ? new Date(end).toLocaleDateString() : "?";
|
||||
return `${s} → ${e}`;
|
||||
return `${s} \u2192 ${e}`;
|
||||
}
|
||||
|
||||
function shortId(fp: string) {
|
||||
return fp.length > 8 ? fp.slice(0, 8) + "…" : fp;
|
||||
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
|
||||
}
|
||||
|
||||
export default function FetchRequests() {
|
||||
@@ -116,11 +107,13 @@ export default function FetchRequests() {
|
||||
const [accountFilter, setAccountFilter] = React.useState("");
|
||||
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
|
||||
|
||||
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({
|
||||
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 { useList: useAccountsList } = useResourceByName("accounts");
|
||||
const { data: accountsData } = useAccountsList();
|
||||
const accountOptions: string[] = React.useMemo(() => {
|
||||
@@ -129,11 +122,15 @@ export default function FetchRequests() {
|
||||
|
||||
const config = useConfig();
|
||||
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
|
||||
const formatOptions: string[] = fetchRes?.fields?.source?.schema?.format?.options as string[] ?? [];
|
||||
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 createMutation = useCreateFetchRequest();
|
||||
const updateMutation = useUpdateFetchRequest();
|
||||
const deleteMutation = useDeleteFetchRequest();
|
||||
const createMutation = useCreate();
|
||||
const updateMutation = usePatch();
|
||||
const deleteMutation = useDelete();
|
||||
const uploadMutation = useUploadFile();
|
||||
|
||||
const requests = listData?.data ?? [];
|
||||
@@ -178,7 +175,7 @@ export default function FetchRequests() {
|
||||
navigate(`/fetch-requests/${result.id}`);
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
|
||||
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
|
||||
} else {
|
||||
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
|
||||
}
|
||||
@@ -265,25 +262,43 @@ export default function FetchRequests() {
|
||||
Uploaded as: {uploadedPath}
|
||||
</Alert>
|
||||
)}
|
||||
<FormControl size="small">
|
||||
<InputLabel>Format</InputLabel>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||
{formatOptions.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{formatField && components?.FormField ? (
|
||||
<components.FormField
|
||||
name="format"
|
||||
field={formatField}
|
||||
value={format}
|
||||
onChange={setFormat}
|
||||
/>
|
||||
) : (
|
||||
<FormControl size="small">
|
||||
<InputLabel>Format</InputLabel>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||
{formatOptions.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Format</InputLabel>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||
{formatOptions.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{formatField && components?.FormField ? (
|
||||
<components.FormField
|
||||
name="format"
|
||||
field={formatField}
|
||||
value={format}
|
||||
onChange={setFormat}
|
||||
/>
|
||||
) : (
|
||||
<FormControl size="small">
|
||||
<InputLabel>Format</InputLabel>
|
||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||
{formatOptions.map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
|
||||
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
|
||||
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
|
||||
@@ -299,29 +314,60 @@ export default function FetchRequests() {
|
||||
)}
|
||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||
/>
|
||||
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
||||
{payorUsernameField && components?.FormField ? (
|
||||
<components.FormField
|
||||
name="payor_username"
|
||||
field={payorUsernameField}
|
||||
value={payorUsername}
|
||||
onChange={setPayorUsername}
|
||||
/>
|
||||
) : (
|
||||
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
{startDateField && components?.date ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<components.date
|
||||
name="start_date"
|
||||
field={startDateField}
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
{endDateField && components?.date ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<components.date
|
||||
name="end_date"
|
||||
field={endDateField}
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
@@ -350,12 +396,14 @@ export default function FetchRequests() {
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Account"
|
||||
value={accountFilter}
|
||||
onChange={(e) => setAccountFilter(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
value={accountFilter || null}
|
||||
onChange={(_, val) => setAccountFilter(val ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
|
||||
)}
|
||||
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
value={sourceFilter}
|
||||
@@ -385,132 +433,143 @@ export default function FetchRequests() {
|
||||
No fetch requests yet
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Account</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Date Range</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Retries</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{[...requests]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((req: FetchRequest) => (
|
||||
<TableRow
|
||||
key={req.id}
|
||||
hover
|
||||
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
||||
sx={{ cursor: "pointer", "&:last-child td": { border: 0 } }}
|
||||
>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{shortId(req.fingerprint)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(req.fingerprint);
|
||||
setSnackbar({ message: "Copied!", severity: "success" });
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
|
||||
<Box
|
||||
key={h}
|
||||
component="th"
|
||||
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{h}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{req.account_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={"path" in req.source ? "File" : "Email"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={"path" in req.source ? "primary" : "secondary"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
||||
<Chip
|
||||
icon={statusIcons[req.status] as any}
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status]}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(req.retry_count ?? 0) > 0 ? (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||
{req.retry_count}/{RETRY_MAX}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||||
{formatDate(req.created_at)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
{req.status === "paused" && (
|
||||
<Tooltip title="Resolve ambiguities">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/fetch-requests/${req.id}`);
|
||||
}}
|
||||
>
|
||||
<WarningAmberIcon fontSize="small" color="warning" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
|
||||
<Tooltip title="Retry">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry(req);
|
||||
}}
|
||||
>
|
||||
<ReplayIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete">
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{[...requests]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((req: FetchRequest) => (
|
||||
<Box
|
||||
key={req.id}
|
||||
component="tr"
|
||||
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
"&:last-child": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{shortId(req.fingerprint)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(req);
|
||||
navigator.clipboard.writeText(req.fingerprint);
|
||||
setSnackbar({ message: "Copied!", severity: "success" });
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
||||
{req.account_name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Chip
|
||||
label={"path" in req.source ? "File" : "Email"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={"path" in req.source ? "primary" : "secondary"}
|
||||
/>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
||||
<Chip
|
||||
icon={statusIcons[req.status] as any}
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status]}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
{(req.retry_count ?? 0) > 0 ? (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||
{req.retry_count}/{RETRY_MAX}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
||||
\u2014
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||||
{formatDate(req.created_at)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
{req.status === "paused" && (
|
||||
<Tooltip title="Resolve ambiguities">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/fetch-requests/${req.id}`);
|
||||
}}
|
||||
>
|
||||
<WarningAmberIcon fontSize="small" color="warning" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
|
||||
<Tooltip title="Retry">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry(req);
|
||||
}}
|
||||
>
|
||||
<ReplayIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(req);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
|
||||
@@ -4,14 +4,7 @@ import {
|
||||
Container,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
@@ -21,20 +14,29 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Chip,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import {
|
||||
useReportSnapshotsList,
|
||||
useCreateSnapshot,
|
||||
useDeleteSnapshot,
|
||||
} from "./features/report-snapshots";
|
||||
import type { ReportSnapshot } from "./features/report-snapshots";
|
||||
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
||||
import type { ResourceField } from "../react-openapi";
|
||||
|
||||
interface ReportSnapshotQuery {
|
||||
accounts?: string[];
|
||||
ignore_self?: boolean;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
interface ReportSnapshot {
|
||||
id: string;
|
||||
snapshot_id: string;
|
||||
created_at: string;
|
||||
query?: ReportSnapshotQuery;
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
const d = new Date(iso);
|
||||
@@ -51,21 +53,32 @@ export default function ReportSnapshots() {
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
||||
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
||||
|
||||
const { data: listData, isLoading, isFetching, refetch } = useReportSnapshotsList();
|
||||
const createMutation = useCreateSnapshot();
|
||||
const deleteMutation = useDeleteSnapshot();
|
||||
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
|
||||
|
||||
const snapshots = listData?.data ?? [];
|
||||
const { data: listData, isLoading, isFetching, refetch } = useList();
|
||||
const createMutation = useCreate();
|
||||
const deleteMutation = useDelete();
|
||||
|
||||
const config = useConfig();
|
||||
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
|
||||
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
|
||||
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
|
||||
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
|
||||
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
|
||||
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
|
||||
|
||||
const snapshots: ReportSnapshot[] = listData?.data ?? [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const result = await createMutation.mutateAsync({
|
||||
ignore_self: ignoreSelf || null,
|
||||
start_date: startDate ? new Date(startDate).toISOString() : null,
|
||||
end_date: endDate ? new Date(endDate).toISOString() : null,
|
||||
min_amount: minAmount ? parseFloat(minAmount) : null,
|
||||
max_amount: maxAmount ? parseFloat(maxAmount) : null,
|
||||
});
|
||||
const payload: Record<string, any> = {};
|
||||
if (ignoreSelf) payload.ignore_self = true;
|
||||
if (startDate) payload.start_date = new Date(startDate).toISOString();
|
||||
if (endDate) payload.end_date = new Date(endDate).toISOString();
|
||||
if (minAmount) payload.min_amount = parseFloat(minAmount);
|
||||
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
const snapshotId = (result as any)?.snapshot_id;
|
||||
if (snapshotId) {
|
||||
setCreatedSnapshotId(snapshotId);
|
||||
@@ -80,7 +93,7 @@ export default function ReportSnapshots() {
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setIgnoreSelf(false);
|
||||
setIgnoreSelf(true);
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setMinAmount("");
|
||||
@@ -110,49 +123,61 @@ export default function ReportSnapshots() {
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
|
||||
label="Ignore self-transfers"
|
||||
/>
|
||||
{ignoreSelfField && components?.FormField && (
|
||||
<components.FormField
|
||||
name="ignore_self"
|
||||
field={ignoreSelfField}
|
||||
value={ignoreSelf}
|
||||
onChange={(val: boolean) => setIgnoreSelf(val)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
size="small"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<TextField
|
||||
label="Min Amount"
|
||||
type="number"
|
||||
value={minAmount}
|
||||
onChange={(e) => setMinAmount(e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Max Amount"
|
||||
type="number"
|
||||
value={maxAmount}
|
||||
onChange={(e) => setMaxAmount(e.target.value)}
|
||||
size="small"
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
{minAmountField && components?.FormField && (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<components.FormField
|
||||
name="min_amount"
|
||||
field={minAmountField}
|
||||
value={minAmount}
|
||||
onChange={(val: string) => setMinAmount(val)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{maxAmountField && components?.FormField && (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<components.FormField
|
||||
name="max_amount"
|
||||
field={maxAmountField}
|
||||
value={maxAmount}
|
||||
onChange={(val: string) => setMaxAmount(val)}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
@@ -191,20 +216,29 @@ export default function ReportSnapshots() {
|
||||
No snapshots yet
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Snapshot ID</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Query</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
|
||||
<Box
|
||||
key={h}
|
||||
component="th"
|
||||
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{h}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{snapshots.map((snap: ReportSnapshot) => (
|
||||
<TableRow key={snap.id}>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box
|
||||
key={snap.id}
|
||||
component="tr"
|
||||
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
|
||||
>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{snap.snapshot_id}
|
||||
<IconButton
|
||||
@@ -218,9 +252,11 @@ export default function ReportSnapshots() {
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(snap.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
||||
{formatDate(snap.created_at)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
{snap.query ? (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
|
||||
@@ -229,19 +265,21 @@ export default function ReportSnapshots() {
|
||||
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">—</Typography>
|
||||
<Typography variant="body2" color="text.secondary">\u2014</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Box>
|
||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -17,11 +17,6 @@ export type {
|
||||
} from "./fetch-requests.models";
|
||||
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
|
||||
export {
|
||||
useFetchRequestsList,
|
||||
useFetchRequest,
|
||||
useCreateFetchRequest,
|
||||
useUpdateFetchRequest,
|
||||
useDeleteFetchRequest,
|
||||
useUploadFile,
|
||||
useFetchRequestAmbiguities,
|
||||
useResolveAmbiguity,
|
||||
|
||||
@@ -2,8 +2,3 @@ export type {
|
||||
ReportSnapshot,
|
||||
ReportQuery,
|
||||
} from "./report-snapshots.models";
|
||||
export {
|
||||
useReportSnapshotsList,
|
||||
useCreateSnapshot,
|
||||
useDeleteSnapshot,
|
||||
} from "./useReportSnapshots";
|
||||
|
||||
@@ -15,7 +15,7 @@ import Dashboard from './Dashboard';
|
||||
import FetchRequests from './FetchRequests';
|
||||
import FetchRequestDetail from './FetchRequestDetail';
|
||||
import ReportSnapshots from './ReportSnapshots';
|
||||
import { Admin, AppProvider } from '../react-openapi';
|
||||
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi';
|
||||
import { configuration, profileConfiguration } from './openapi-config';
|
||||
import { Buffer } from 'buffer';
|
||||
import process from 'process';
|
||||
@@ -60,7 +60,7 @@ root.render(
|
||||
path={path}
|
||||
element={
|
||||
path.startsWith("/admin") ? (
|
||||
<Component basePath="/admin" />
|
||||
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} />
|
||||
) : (
|
||||
<Component />
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ResourceOverride } from "../react-openapi/types/overrides";
|
||||
import { ResourceOverride } from "../react-openapi";
|
||||
|
||||
export const configuration: Record<string, ResourceOverride> = {
|
||||
expenses: {
|
||||
@@ -8,20 +8,22 @@ export const configuration: Record<string, ResourceOverride> = {
|
||||
},
|
||||
fields: {
|
||||
payee: {
|
||||
displayField: "name",
|
||||
displayFormat: "{name}",
|
||||
filterType: "autocomplete",
|
||||
},
|
||||
payor: {
|
||||
display: false,
|
||||
displayField: "username",
|
||||
displayFormat: "{username}",
|
||||
},
|
||||
account: {
|
||||
displayField: "name",
|
||||
displayFormat: "{name}",
|
||||
filterType: "multiselect",
|
||||
refers: "accounts"
|
||||
},
|
||||
tags: {
|
||||
displayField: ["name", "icon"],
|
||||
displayFormat: "{icon} {name}",
|
||||
filterType: "autocomplete",
|
||||
refers: "tags"
|
||||
},
|
||||
occurred_at: {
|
||||
filterType: "date-range",
|
||||
@@ -50,16 +52,45 @@ export const configuration: Record<string, ResourceOverride> = {
|
||||
}
|
||||
},
|
||||
},
|
||||
'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: {
|
||||
enumOption: {
|
||||
key: 'id',
|
||||
value: '{name} - XXXX{number}'
|
||||
referenceOptions: {
|
||||
enumOption: {
|
||||
key: '_id',
|
||||
value: '{name} - XX{number}',
|
||||
},
|
||||
autoComplete: true,
|
||||
prefetch: true,
|
||||
}
|
||||
},
|
||||
tags: {
|
||||
enumOption: {
|
||||
key: 'id',
|
||||
value: '{icon} {name}'
|
||||
referenceOptions: {
|
||||
enumOption: {
|
||||
key: '_id',
|
||||
value: '{icon} {name}',
|
||||
},
|
||||
autoComplete: true,
|
||||
prefetch: true,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user