Compare commits
1 Commits
openapi-no
...
1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d38b793f4 |
@@ -1,40 +0,0 @@
|
|||||||
# 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
140
.drone.yml
@@ -1,140 +0,0 @@
|
|||||||
---
|
|
||||||
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
24
CONCEPT.md
@@ -1,24 +0,0 @@
|
|||||||
# 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
34
DESIGN.md
@@ -1,34 +0,0 @@
|
|||||||
# 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
33
Dockerfile
@@ -1,33 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
# 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,7 +28,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "latest"
|
"vite": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4104,19 +4103,6 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
"typescript": "^6.0.3",
|
|
||||||
"vite": "latest"
|
"vite": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import ResourceView from "./components/ResourceView";
|
|||||||
import { getAppConfig } from "./config";
|
import { getAppConfig } from "./config";
|
||||||
import { initializeApiClients } from "./api/client";
|
import { initializeApiClients } from "./api/client";
|
||||||
import { AppConfig } from "./types/config";
|
import { AppConfig } from "./types/config";
|
||||||
import { FieldComponents } from "./types/overrides";
|
|
||||||
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Routes,
|
Routes,
|
||||||
@@ -16,9 +15,8 @@ import {
|
|||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
|
|
||||||
import { ConfigContext } from "./providers/ConfigContext";
|
import { ConfigContext } from "./providers/ConfigContext";
|
||||||
import ProfileView from "./components/ProfileView";
|
|
||||||
|
|
||||||
function DefaultDashboard({ basePath }: { basePath: string }) {
|
function Dashboard({ basePath }: { basePath: string }) {
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -33,6 +31,7 @@ function DefaultDashboard({ basePath }: { basePath: string }) {
|
|||||||
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
||||||
Select a resource from the sidebar to manage data.
|
Select a resource from the sidebar to manage data.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -62,15 +61,9 @@ function DefaultDashboard({ basePath }: { basePath: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminAppProps {
|
import ProfileView from "./components/ProfileView";
|
||||||
basePath: string;
|
|
||||||
fieldComponents: FieldComponents;
|
|
||||||
Dashboard?: React.ComponentType<{ basePath: string }>;
|
|
||||||
Layout?: React.ComponentType<AdminLayoutProps>;
|
|
||||||
LoginPage?: React.ComponentType<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) {
|
function AdminApp({ basePath }: { basePath: string }) {
|
||||||
const { currentUser, login, logout, loading, error } = useAuth();
|
const { currentUser, login, logout, loading, error } = useAuth();
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -80,10 +73,10 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
|
|||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<LoginPage
|
<AuthPage
|
||||||
mode="login"
|
mode="login"
|
||||||
login={login}
|
login={login}
|
||||||
register={async () => {}}
|
register={async () => {}} // Disable registration for Admin
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
onSwitchMode={() => {}}
|
onSwitchMode={() => {}}
|
||||||
@@ -94,7 +87,7 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<AdminLayout
|
||||||
username={currentUser.username}
|
username={currentUser.username}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onSelectResource={(name) => navigate(`/admin/${name}`)}
|
onSelectResource={(name) => navigate(`/admin/${name}`)}
|
||||||
@@ -103,44 +96,32 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
<Route path="/" element={<Dashboard basePath={basePath} />} />
|
||||||
<Route path="/profile" element={<ProfileView />} />
|
<Route path="/profile" element={<ProfileView />} />
|
||||||
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
||||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
||||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
||||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) {
|
function ResourceRouteWrapper() {
|
||||||
const { resourceName } = useParams();
|
const { resourceName } = useParams();
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
||||||
|
|
||||||
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
||||||
|
|
||||||
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
|
return <ResourceView config={selectedResource} />;
|
||||||
}
|
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onSelectResource: (resourceName: string | null) => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
username?: string;
|
|
||||||
resources: import("./types/config").ResourceConfig[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminProps {
|
interface AdminProps {
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
resourceOverrides?: Record<string, any>;
|
resourceOverrides?: Record<string, any>;
|
||||||
profileConfig?: 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 = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) {
|
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
|
||||||
const existingConfig = React.useContext(ConfigContext);
|
const existingConfig = React.useContext(ConfigContext);
|
||||||
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
|
||||||
|
|
||||||
@@ -170,14 +151,16 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<UploadProvider>
|
<UploadProvider>
|
||||||
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} />
|
<AdminApp basePath={basePath} />
|
||||||
</UploadProvider>
|
</UploadProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// If we have an existing config, we are already inside a Provider and QueryClient
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback for standalone usage
|
||||||
return (
|
return (
|
||||||
<ConfigContext.Provider value={config}>
|
<ConfigContext.Provider value={config}>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { createApiClient } from "../../react-auth";
|
import { createApiClient } from "../../react-auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,25 +30,25 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => {
|
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.get<T, R>(url, config);
|
return _api.get(...args);
|
||||||
},
|
},
|
||||||
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => {
|
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.post<T, R>(url, data, config);
|
return _api.post(...args);
|
||||||
},
|
},
|
||||||
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => {
|
put: (...args: Parameters<AxiosInstance["put"]>) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.put<T, R>(url, data, config);
|
return _api.put(...args);
|
||||||
},
|
},
|
||||||
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => {
|
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.delete<T, R>(url, config);
|
return _api.delete(...args);
|
||||||
},
|
},
|
||||||
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
|
patch: (...args: Parameters<AxiosInstance["patch"]>) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.patch<T, R>(url, data, config);
|
return _api.patch(...args);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
|
|||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ResourceConfig } from '../types/config';
|
import { ResourceConfig } from '../types/config';
|
||||||
import { EnhancedTableComponents } from '../types/overrides';
|
|
||||||
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
|
|
||||||
|
|
||||||
interface EnhancedTableProps {
|
interface EnhancedTableProps {
|
||||||
config: ResourceConfig;
|
config: ResourceConfig;
|
||||||
@@ -45,7 +43,6 @@ interface EnhancedTableProps {
|
|||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||||
components?: EnhancedTableComponents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EnhancedTable({
|
export default function EnhancedTable({
|
||||||
@@ -59,7 +56,6 @@ export default function EnhancedTable({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
onNavigateToResource,
|
onNavigateToResource,
|
||||||
components: tableComponents,
|
|
||||||
}: EnhancedTableProps) {
|
}: EnhancedTableProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
@@ -88,7 +84,7 @@ export default function EnhancedTable({
|
|||||||
type: muiType,
|
type: muiType,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} />
|
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||||
};
|
};
|
||||||
|
|
||||||
if (muiType === 'date' || muiType === 'dateTime') {
|
if (muiType === 'date' || muiType === 'dateTime') {
|
||||||
@@ -99,8 +95,9 @@ export default function EnhancedTable({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (muiType === 'singleSelect') {
|
if (muiType === 'singleSelect' && field.options) {
|
||||||
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
|
// @ts-ignore
|
||||||
|
col.valueOptions = field.options;
|
||||||
}
|
}
|
||||||
|
|
||||||
return col;
|
return col;
|
||||||
@@ -161,7 +158,6 @@ export default function EnhancedTable({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onNavigate={onNavigateToResource}
|
onNavigate={onNavigateToResource}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
components={tableComponents}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -229,7 +225,7 @@ export default function EnhancedTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) {
|
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
const id = row[config.primaryKey];
|
const id = row[config.primaryKey];
|
||||||
@@ -265,7 +261,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
|
|||||||
{field.label}
|
{field.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
<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 components={components} />
|
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -278,27 +274,30 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFormattedDisplayValue(item: any, displayFormat: string) {
|
function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
|
||||||
if (!item) return "";
|
if (!item) return "";
|
||||||
|
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
|
||||||
|
|
||||||
return resolveTemplate(displayFormat, 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) {
|
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
|
||||||
const value = params.value;
|
const value = params.value;
|
||||||
const isPk = fieldKey === config.primaryKey;
|
const isPk = fieldKey === config.primaryKey;
|
||||||
|
|
||||||
if (field.formatter) return field.formatter(value);
|
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
|
// 1. Single Relation
|
||||||
if (field.relation && value && !Array.isArray(value)) {
|
if (field.relation && value && !Array.isArray(value)) {
|
||||||
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||||
const displayValue = getFormattedDisplayValue(value, field.displayFormat);
|
const displayValue = getFormattedDisplayValue(value, field.displayField);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
@@ -317,8 +316,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
|||||||
|
|
||||||
// 2. Multi-Select (Array of relations or simple strings)
|
// 2. Multi-Select (Array of relations or simple strings)
|
||||||
if (field.type === 'array' && Array.isArray(value)) {
|
if (field.type === 'array' && Array.isArray(value)) {
|
||||||
const enumValue = field.enumOption?.value;
|
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
|
||||||
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayFormat)).join(', ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={tooltipTitle} arrow placement="top">
|
<Tooltip title={tooltipTitle} arrow placement="top">
|
||||||
@@ -326,7 +324,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
|||||||
{value.map((item, idx) => (
|
{value.map((item, idx) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={idx}
|
key={idx}
|
||||||
label={getFormattedDisplayValue(item, field.displayFormat)}
|
label={getFormattedDisplayValue(item, field.displayField)}
|
||||||
size="small"
|
size="small"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
sx={{ maxWidth: 120 }}
|
sx={{ maxWidth: 120 }}
|
||||||
@@ -346,7 +344,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
|||||||
|
|
||||||
// 3. Simple Objects
|
// 3. Simple Objects
|
||||||
if (field.type === 'object' && value) {
|
if (field.type === 'object' && value) {
|
||||||
return getFormattedDisplayValue(value, field.displayFormat) || (isMobile ? 'Object' : JSON.stringify(value));
|
return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'number' && typeof value === 'number') {
|
if (field.type === 'number' && typeof value === 'number') {
|
||||||
@@ -379,14 +377,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === 'datetime') return value ? new Date(value).toLocaleString() : '';
|
if (field.type === 'datetime' || field.type === 'date') 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);
|
|
||||||
return opt?.value ?? value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPk && !isMobile) {
|
if (isPk && !isMobile) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import {
|
|||||||
import DoneIcon from "@mui/icons-material/Done";
|
import DoneIcon from "@mui/icons-material/Done";
|
||||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
import { ResourceField, ResourceMode } from "../types/config";
|
import { ResourceField, ResourceMode } from "../types/config";
|
||||||
import { FilterBarComponents, FieldComponents } from "../types/overrides";
|
|
||||||
import { getFieldOptions, resolveTemplate } from "../utils/options";
|
|
||||||
|
|
||||||
export function FilterAutocomplete({
|
function FilterAutocomplete({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
@@ -112,9 +110,7 @@ function extractOptions(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const values = new Set<string>();
|
const values = new Set<string>();
|
||||||
|
|
||||||
if (field.type === 'enum') {
|
if (field.options) return field.options;
|
||||||
return getFieldOptions(field).map(o => o.value);
|
|
||||||
}
|
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
const pull = (item: any): string | null => {
|
const pull = (item: any): string | null => {
|
||||||
@@ -122,13 +118,18 @@ function extractOptions(
|
|||||||
if (typeof item === "string") return item;
|
if (typeof item === "string") return item;
|
||||||
if (typeof item !== "object") return String(item);
|
if (typeof item !== "object") return String(item);
|
||||||
|
|
||||||
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
const df = field.displayField;
|
||||||
|
if (!df) { debugger; return null; }
|
||||||
|
|
||||||
// Use displayFormat if defined
|
if (Array.isArray(df)) {
|
||||||
if (field.displayFormat) {
|
const parts = df.map((k) => item[k]).filter((v) => v != null);
|
||||||
return resolveTemplate(field.displayFormat, item);
|
if (parts.length > 0) return parts.join(" ");
|
||||||
|
} else {
|
||||||
|
const v = item[df];
|
||||||
|
if (v != null) return String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugger;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,24 +157,32 @@ function renderFilterInput(
|
|||||||
field: ResourceField,
|
field: ResourceField,
|
||||||
options: string[],
|
options: string[],
|
||||||
value: any,
|
value: any,
|
||||||
onChange: (key: string, val: any) => void,
|
onChange: (key: string, val: any) => void
|
||||||
components?: FilterBarComponents,
|
|
||||||
fieldComponents?: FieldComponents,
|
|
||||||
) {
|
) {
|
||||||
const filterType = field.filterType;
|
const filterType = field.filterType;
|
||||||
|
|
||||||
if (filterType === "number-range") {
|
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 }) || {};
|
const rangeVal = (value as { min?: string; max?: string }) || {};
|
||||||
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filterType === "date-range") {
|
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 }) || {};
|
const rangeVal = (value as { start?: string; end?: string }) || {};
|
||||||
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = Array.isArray(value) ? value : [];
|
const selected = Array.isArray(value) ? value : [];
|
||||||
@@ -196,8 +205,6 @@ export interface FilterBarProps {
|
|||||||
appliedValues: Record<string, any>;
|
appliedValues: Record<string, any>;
|
||||||
onApply: (values: Record<string, any>) => void;
|
onApply: (values: Record<string, any>) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
components?: FilterBarComponents;
|
|
||||||
fieldComponents?: FieldComponents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilterBar({
|
export default function FilterBar({
|
||||||
@@ -207,8 +214,6 @@ export default function FilterBar({
|
|||||||
appliedValues,
|
appliedValues,
|
||||||
onApply,
|
onApply,
|
||||||
onClear,
|
onClear,
|
||||||
components: filterComponents,
|
|
||||||
fieldComponents,
|
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
|
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
|
||||||
@@ -276,7 +281,7 @@ export default function FilterBar({
|
|||||||
const field = fields[fieldName];
|
const field = fields[fieldName];
|
||||||
if (!field) return null;
|
if (!field) return null;
|
||||||
|
|
||||||
const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect";
|
const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect";
|
||||||
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
|
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
|
||||||
const raw = draft[fieldName];
|
const raw = draft[fieldName];
|
||||||
|
|
||||||
@@ -286,7 +291,7 @@ export default function FilterBar({
|
|||||||
{field.label}
|
{field.label}
|
||||||
</Box>
|
</Box>
|
||||||
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
|
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
|
||||||
updateDraft(fieldName, key, val), filterComponents, fieldComponents
|
updateDraft(fieldName, key, val)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ResourceConfig } from '../types/config';
|
import { ResourceConfig } from '../types/config';
|
||||||
import { FieldComponents } from '../types/overrides';
|
|
||||||
import { useUpload } from '../providers/UploadProvider';
|
import { useUpload } from '../providers/UploadProvider';
|
||||||
import { useQueries } from '@tanstack/react-query';
|
import { useQueries } from '@tanstack/react-query';
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
@@ -22,7 +21,6 @@ interface GenericFormProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onEditClick?: () => void;
|
onEditClick?: () => void;
|
||||||
fieldComponents: FieldComponents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GenericForm({
|
export default function GenericForm({
|
||||||
@@ -33,7 +31,6 @@ export default function GenericForm({
|
|||||||
loading: saving,
|
loading: saving,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
onEditClick,
|
onEditClick,
|
||||||
fieldComponents,
|
|
||||||
}: GenericFormProps) {
|
}: GenericFormProps) {
|
||||||
initialData = initialData || {};
|
initialData = initialData || {};
|
||||||
const [formData, setFormData] = React.useState(initialData);
|
const [formData, setFormData] = React.useState(initialData);
|
||||||
@@ -45,30 +42,23 @@ export default function GenericForm({
|
|||||||
let relations: string[] = [];
|
let relations: string[] = [];
|
||||||
Object.values(fields).forEach(field => {
|
Object.values(fields).forEach(field => {
|
||||||
if (field.relation) relations.push(field.relation);
|
if (field.relation) relations.push(field.relation);
|
||||||
if (field.refers) relations.push(field.refers);
|
|
||||||
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
|
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
|
||||||
});
|
});
|
||||||
return Array.from(new Set(relations));
|
return Array.from(new Set(relations));
|
||||||
};
|
};
|
||||||
|
|
||||||
const allRelations = React.useMemo(() => {
|
const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]);
|
||||||
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
|
// 2. Parallel fetch for all related resource lists
|
||||||
const queries = useQueries({
|
const queries = useQueries({
|
||||||
queries: allRelations.map(relName => {
|
queries: allRelations.map(relName => {
|
||||||
const relatedRes = appConfig?.resources.find(r => r.name === relName);
|
const relatedRes = appConfig?.resources.find(r => r.name === relName);
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents });
|
const { getListQueryOptions } = useResource(relatedRes!);
|
||||||
const queryOpts = {
|
return {
|
||||||
...getListQueryOptions(),
|
...getListQueryOptions(),
|
||||||
enabled: !!relatedRes,
|
enabled: !!relatedRes,
|
||||||
};
|
};
|
||||||
// console.log('Query for relation', relName, 'resource', relatedRes?.name, 'enabled', !!relatedRes);
|
|
||||||
return queryOpts;
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,11 +67,8 @@ export default function GenericForm({
|
|||||||
const relationDataMap = React.useMemo(() => {
|
const relationDataMap = React.useMemo(() => {
|
||||||
const map: Record<string, any[]> = {};
|
const map: Record<string, any[]> = {};
|
||||||
allRelations.forEach((relName, index) => {
|
allRelations.forEach((relName, index) => {
|
||||||
const queryResult = queries[index];
|
// @ts-ignore
|
||||||
const dataArray = queryResult?.data && Array.isArray(queryResult.data) ? queryResult.data : (queryResult?.data?.data ?? []);
|
map[relName] = queries[index].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;
|
return map;
|
||||||
}, [allRelations, queries]);
|
}, [allRelations, queries]);
|
||||||
@@ -130,7 +117,6 @@ export default function GenericForm({
|
|||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
baseUrl={appConfig?.baseUrl || ""}
|
baseUrl={appConfig?.baseUrl || ""}
|
||||||
relationDataMap={relationDataMap}
|
relationDataMap={relationDataMap}
|
||||||
components={fieldComponents}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
|||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
import GenericForm from './GenericForm';
|
import GenericForm from './GenericForm';
|
||||||
import { ConfigContext } from '../providers/ConfigContext';
|
import { ConfigContext } from '../providers/ConfigContext';
|
||||||
import { defaultFieldComponents } from './fields/DefaultFieldComponents';
|
|
||||||
|
|
||||||
export default function ProfileView() {
|
export default function ProfileView() {
|
||||||
const appConfig = React.useContext(ConfigContext);
|
const appConfig = React.useContext(ConfigContext);
|
||||||
@@ -14,6 +13,7 @@ export default function ProfileView() {
|
|||||||
return <Alert severity="error">Profile configuration not found.</Alert>;
|
return <Alert severity="error">Profile configuration not found.</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a modified config where only extraFields are editable
|
||||||
const editableConfig = React.useMemo(() => {
|
const editableConfig = React.useMemo(() => {
|
||||||
const newFields = { ...resourceConfig.fields };
|
const newFields = { ...resourceConfig.fields };
|
||||||
const extraFields = profileConfig.extraFields || [];
|
const extraFields = profileConfig.extraFields || [];
|
||||||
@@ -31,12 +31,13 @@ export default function ProfileView() {
|
|||||||
};
|
};
|
||||||
}, [resourceConfig, profileConfig.extraFields]);
|
}, [resourceConfig, profileConfig.extraFields]);
|
||||||
|
|
||||||
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents });
|
const { useMe, useUpdateMe } = useResource(resourceConfig);
|
||||||
const { data: profile, isLoading, error } = useMe();
|
const { data: profile, isLoading, error } = useMe();
|
||||||
const updateMutation = useUpdateMe();
|
const updateMutation = useUpdateMe();
|
||||||
|
|
||||||
const handleSave = async (formData: any) => {
|
const handleSave = async (formData: any) => {
|
||||||
try {
|
try {
|
||||||
|
// Only send editable fields to prevent accidental overwrites of read-only data
|
||||||
const extraFields = profileConfig.extraFields || [];
|
const extraFields = profileConfig.extraFields || [];
|
||||||
const dataToSave = Object.keys(formData)
|
const dataToSave = Object.keys(formData)
|
||||||
.filter(key => extraFields.includes(key))
|
.filter(key => extraFields.includes(key))
|
||||||
@@ -75,7 +76,6 @@ export default function ProfileView() {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCancel={() => window.history.back()}
|
onCancel={() => window.history.back()}
|
||||||
loading={updateMutation.isPending}
|
loading={updateMutation.isPending}
|
||||||
fieldComponents={defaultFieldComponents}
|
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import * as React from 'react';
|
|||||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||||
import { ResourceConfig } from '../types/config';
|
import { ResourceConfig } from '../types/config';
|
||||||
import type { ResourceField } from '../types/config';
|
import type { ResourceField } from '../types/config';
|
||||||
import { FieldComponents } from '../types/overrides';
|
|
||||||
import { useResource } from '../hooks/useResource';
|
import { useResource } from '../hooks/useResource';
|
||||||
import { resolveTemplate } from '../utils/options';
|
import GenericForm from './GenericForm';
|
||||||
import EnhancedTable from './EnhancedTable';
|
import EnhancedTable from './EnhancedTable';
|
||||||
import FilterBar from './FilterBar';
|
import FilterBar from './FilterBar';
|
||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||||
@@ -12,16 +11,15 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
|||||||
interface ResourceViewProps {
|
interface ResourceViewProps {
|
||||||
config: ResourceConfig;
|
config: ResourceConfig;
|
||||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||||
fieldComponents: FieldComponents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
import { GridPaginationModel } from '@mui/x-data-grid';
|
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||||
|
|
||||||
function getDisplayString(item: any, field: ResourceField): string {
|
function getFilterDisplayFields(field: ResourceField): string[] {
|
||||||
if (item == null || typeof item !== 'object') return String(item ?? '');
|
if (!field.displayField) return [];
|
||||||
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
return (Array.isArray(field.displayField) ? field.displayField : [field.displayField]).filter(
|
||||||
if (field.displayFormat) return resolveTemplate(field.displayFormat, item);
|
(df): df is string => !!df
|
||||||
throw new Error('cannot get display string')
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyClientFilters(
|
function applyClientFilters(
|
||||||
@@ -61,12 +59,18 @@ function applyClientFilters(
|
|||||||
|
|
||||||
if (Array.isArray(filterValue)) {
|
if (Array.isArray(filterValue)) {
|
||||||
if (field.type === "array" && Array.isArray(itemValue)) {
|
if (field.type === "array" && Array.isArray(itemValue)) {
|
||||||
return itemValue.some((el: any) =>
|
return itemValue.some((el: any) => {
|
||||||
filterValue.includes(getDisplayString(el, field))
|
if (el != null && typeof el === "object") {
|
||||||
);
|
const dispFields = getFilterDisplayFields(field);
|
||||||
|
return dispFields.some((df) => filterValue.includes(String(el[df])));
|
||||||
|
}
|
||||||
|
return filterValue.includes(String(el));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (itemValue && typeof itemValue === "object") {
|
if (itemValue && typeof itemValue === "object") {
|
||||||
return filterValue.includes(getDisplayString(itemValue, field));
|
const dispFields = getFilterDisplayFields(field);
|
||||||
|
const itemDisplay = dispFields.map((df) => itemValue[df]).filter((v) => v != null).join(" ");
|
||||||
|
return filterValue.includes(itemDisplay);
|
||||||
}
|
}
|
||||||
return filterValue.includes(String(itemValue));
|
return filterValue.includes(String(itemValue));
|
||||||
}
|
}
|
||||||
@@ -78,13 +82,18 @@ function applyClientFilters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === "array" && Array.isArray(itemValue)) {
|
if (field.type === "array" && Array.isArray(itemValue)) {
|
||||||
return itemValue.some((el: any) =>
|
return itemValue.some((el: any) => {
|
||||||
getDisplayString(el, field) === String(filterValue)
|
if (el != null && typeof el === "object") {
|
||||||
);
|
const dispFields = getFilterDisplayFields(field);
|
||||||
|
return dispFields.some((df) => String(el[df]) === String(filterValue));
|
||||||
|
}
|
||||||
|
return String(el) === String(filterValue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemValue && typeof itemValue === "object") {
|
if (itemValue && typeof itemValue === "object") {
|
||||||
return getDisplayString(itemValue, field) === String(filterValue);
|
const dispFields = getFilterDisplayFields(field);
|
||||||
|
return dispFields.some((df) => String(itemValue[df]) === String(filterValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(itemValue) === String(filterValue);
|
return String(itemValue) === String(filterValue);
|
||||||
@@ -92,7 +101,7 @@ function applyClientFilters(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
|
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -111,7 +120,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
|
|||||||
|
|
||||||
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
||||||
|
|
||||||
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
|
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||||
|
|
||||||
const queryParams = React.useMemo(() => {
|
const queryParams = React.useMemo(() => {
|
||||||
if (!isServer) return { limit: 10000 };
|
if (!isServer) return { limit: 10000 };
|
||||||
@@ -179,7 +188,6 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
|
|||||||
appliedValues={appliedFilters}
|
appliedValues={appliedFilters}
|
||||||
onApply={setAppliedFilters}
|
onApply={setAppliedFilters}
|
||||||
onClear={() => setAppliedFilters({})}
|
onClear={() => setAppliedFilters({})}
|
||||||
fieldComponents={components}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EnhancedTable
|
<EnhancedTable
|
||||||
@@ -197,7 +205,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Paper sx={{ p: 4 }}>
|
<Paper sx={{ p: 4 }}>
|
||||||
{components && <components.GenericForm
|
<GenericForm
|
||||||
config={config}
|
config={config}
|
||||||
initialData={isCreate ? null : itemQuery.data}
|
initialData={isCreate ? null : itemQuery.data}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -205,7 +213,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
|
|||||||
loading={createMutation.isPending || updateMutation.isPending}
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
readOnly={isView}
|
readOnly={isView}
|
||||||
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
||||||
/>}
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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,19 +1,29 @@
|
|||||||
import * as React from 'react';
|
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 { ResourceField } from '../../types/config';
|
||||||
import { FieldComponentProps, FieldComponents } from '../../types/overrides';
|
import ImageUploadField from './ImageUploadField';
|
||||||
import ObjectField from './ObjectField';
|
|
||||||
|
|
||||||
export interface FormFieldProps {
|
interface FormFieldProps {
|
||||||
name: string;
|
name: string;
|
||||||
field: ResourceField;
|
field: ResourceField;
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (val: any) => void;
|
onChange: (val: any) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
uploadFile?: (file: File) => Promise<string | null>;
|
uploadFile: (file: File) => Promise<string | null>;
|
||||||
uploading?: boolean;
|
uploading: boolean;
|
||||||
baseUrl?: string;
|
baseUrl: string;
|
||||||
relationDataMap?: Record<string, any[]>;
|
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||||
components: FieldComponents;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormField({
|
export default function FormField({
|
||||||
@@ -26,60 +36,189 @@ export default function FormField({
|
|||||||
uploading,
|
uploading,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
relationDataMap = {},
|
relationDataMap = {},
|
||||||
components,
|
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
const fieldProps: FieldComponentProps = {
|
const label = field.label;
|
||||||
name,
|
|
||||||
field,
|
// 1. Recursive Rendering for Objects (Not Relations)
|
||||||
value,
|
if (field.type === 'object' && field.schema && !field.relation) {
|
||||||
onChange,
|
return (
|
||||||
disabled,
|
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||||
baseUrl,
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||||
relationDataMap,
|
{label}
|
||||||
uploadFile,
|
</Typography>
|
||||||
uploading,
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Relation Handling (Select / Multi-Select)
|
||||||
|
if (field.relation && relationDataMap[field.relation]) {
|
||||||
|
const relationData = relationDataMap[field.relation].data;
|
||||||
|
const isArrayRelation = field.type === 'array';
|
||||||
|
|
||||||
|
// Determine how to display the related item
|
||||||
|
const getOptionLabel = (option: any) => {
|
||||||
|
if (!option) return "";
|
||||||
|
if (field.displayField && option[field.displayField]) return option[field.displayField];
|
||||||
|
// Standard naming fields
|
||||||
|
return option.name || option.title || option.label || option.id || JSON.stringify(option);
|
||||||
};
|
};
|
||||||
|
|
||||||
const childComponents = components;
|
const getOptionValue = (option: any) => {
|
||||||
|
// Return the whole object to maintain identity
|
||||||
|
return option;
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Object (recursive) - requires parent FormField for recursion
|
return (
|
||||||
if (field.type === 'object' && field.schema && !field.relation) {
|
<FormControl fullWidth>
|
||||||
const renderChild = (childProps: FieldComponentProps) => (
|
<InputLabel shrink>{label}</InputLabel>
|
||||||
<FormField
|
<Select
|
||||||
name={childProps.name}
|
multiple={isArrayRelation}
|
||||||
field={childProps.field}
|
value={value || (isArrayRelation ? [] : "")}
|
||||||
value={childProps.value}
|
label={label}
|
||||||
onChange={childProps.onChange}
|
displayEmpty
|
||||||
disabled={childProps.disabled}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
uploadFile={childProps.uploadFile}
|
disabled={disabled}
|
||||||
uploading={childProps.uploading}
|
renderValue={(selected: any) => {
|
||||||
baseUrl={childProps.baseUrl}
|
if (isArrayRelation) {
|
||||||
relationDataMap={childProps.relationDataMap}
|
return (selected as any[]).map(getOptionLabel).join(', ');
|
||||||
components={components}
|
}
|
||||||
|
return getOptionLabel(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relationData.map((option) => (
|
||||||
|
<MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
|
||||||
|
{getOptionLabel(option)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Image Handling
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return <ObjectField {...fieldProps} renderField={renderChild} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Image
|
// 4. Boolean Handling
|
||||||
if (field.type === 'image') {
|
if (field.type === 'boolean') {
|
||||||
const ImageField = components.image;
|
return (
|
||||||
if (!ImageField) return null;
|
<FormControlLabel
|
||||||
return <ImageField {...fieldProps} />;
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={!!value}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Relation
|
// 5. Enum Handling
|
||||||
if (field.relation && relationDataMap[field.relation]) {
|
if (field.type === 'enum' && field.options) {
|
||||||
const RelationFieldComp = components.relation;
|
return (
|
||||||
if (!RelationFieldComp) return null;
|
<FormControl fullWidth>
|
||||||
return <RelationFieldComp {...fieldProps} />;
|
<InputLabel>{label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={value || ''}
|
||||||
|
label={label}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{field.options.map((opt: string) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Lookup by field type
|
// 6. Common Text Fields
|
||||||
const Component = components[field.type] || components.default;
|
if (field.type === 'datetime' || field.type === 'date') {
|
||||||
if (Component) {
|
return (
|
||||||
return <Component {...fieldProps} />;
|
<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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,39 +1,23 @@
|
|||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||||
import * as React from "react";
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { ResourceConfig } from "../types/config";
|
import { ResourceConfig } from "../types/config";
|
||||||
import { ConfigContext } from "../providers/ConfigContext";
|
import { ConfigContext } from "../providers/ConfigContext";
|
||||||
import { FieldComponents, FieldComponentProps } from "../types/overrides";
|
import * as React from "react";
|
||||||
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
|
|
||||||
import FormField from "../components/fields/FormField";
|
|
||||||
import GenericForm from "../components/GenericForm";
|
|
||||||
|
|
||||||
function wrapFormField(merged: FieldComponents) {
|
export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||||
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();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Return empty/disabled hooks if config is missing
|
||||||
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
|
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
|
||||||
|
|
||||||
const mergedComponents = React.useMemo(
|
|
||||||
() => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
|
|
||||||
[options?.fieldComponents],
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- READ ALL ---
|
// --- READ ALL ---
|
||||||
const useList = (params?: any) =>
|
const useList = (params?: any) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: [name, "list", params],
|
queryKey: [name, "list", params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!endpoint) return { data: [], total: 0 };
|
if (!endpoint) return { data: [], total: 0 };
|
||||||
|
console.log('params:', params);
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.get<T[]>(endpoint, { params });
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +35,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
queryKey: [name, "detail", id, params],
|
queryKey: [name, "detail", id, params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!id || !endpoint) return null;
|
if (!id || !endpoint) return null;
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined);
|
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -62,6 +47,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async (data: Partial<T>) => {
|
mutationFn: async (data: Partial<T>) => {
|
||||||
if (!endpoint) throw new Error("Endpoint not defined");
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.post<T>(endpoint, data);
|
const res = await api.post<T>(endpoint, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -75,10 +61,12 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||||
if (!endpoint) throw new Error("Endpoint not defined");
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
onSuccess: (updatedItem: any) => {
|
onSuccess: (updatedItem) => {
|
||||||
|
// @ts-ignore
|
||||||
const id = updatedItem[primaryKey];
|
const id = updatedItem[primaryKey];
|
||||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||||
@@ -90,13 +78,15 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||||
if (!endpoint) throw new Error("Endpoint not defined");
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.patch<T>(`${endpoint}/${id}`, data);
|
const res = await api.patch<T>(`${endpoint}/${id}`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
onSuccess: (updatedItem: any) => {
|
onSuccess: (updatedItem) => {
|
||||||
const listId = updatedItem[primaryKey];
|
// @ts-ignore
|
||||||
|
const id = updatedItem[primaryKey];
|
||||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });
|
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,6 +108,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
queryKey: [name, "list", params],
|
queryKey: [name, "list", params],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!endpoint) return { data: [], total: 0 };
|
if (!endpoint) return { data: [], total: 0 };
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.get<T[]>(endpoint, { params });
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||||
return {
|
return {
|
||||||
@@ -134,6 +125,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
queryKey: [name, "me"],
|
queryKey: [name, "me"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!endpoint) return null;
|
if (!endpoint) return null;
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.get<T>(`${endpoint}/me`);
|
const res = await api.get<T>(`${endpoint}/me`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -145,6 +137,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: async (data: Partial<T>) => {
|
mutationFn: async (data: Partial<T>) => {
|
||||||
if (!endpoint) throw new Error("Endpoint not defined");
|
if (!endpoint) throw new Error("Endpoint not defined");
|
||||||
|
// @ts-ignore
|
||||||
const res = await api.put<T>(`${endpoint}/me`, data);
|
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -154,15 +147,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const components = React.useMemo(() => {
|
|
||||||
if (!mergedComponents) return undefined;
|
|
||||||
return {
|
|
||||||
...mergedComponents,
|
|
||||||
FormField: wrapFormField(mergedComponents),
|
|
||||||
GenericForm: wrapGenericForm(mergedComponents),
|
|
||||||
};
|
|
||||||
}, [mergedComponents]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useList,
|
useList,
|
||||||
useRead,
|
useRead,
|
||||||
@@ -173,12 +157,12 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
|
|||||||
useUpdateMe,
|
useUpdateMe,
|
||||||
useDelete,
|
useDelete,
|
||||||
getListQueryOptions,
|
getListQueryOptions,
|
||||||
components,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResourceByName<T = any>(name: string, options?: { fieldComponents: FieldComponents }) {
|
export function useResourceByName<T = any>(name: string) {
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const resourceConfig = config?.resources.find((r) => r.name === name);
|
const resourceConfig = config?.resources.find((r) => r.name === name);
|
||||||
return useResource<T>(resourceConfig, options);
|
return useResource<T>(resourceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,7 @@ export { default as Admin } from "./Admin";
|
|||||||
export { api, auth, initializeApiClients } from "./api/client";
|
export { api, auth, initializeApiClients } from "./api/client";
|
||||||
export { getAppConfig } from "./config";
|
export { getAppConfig } from "./config";
|
||||||
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/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 { AppProvider } from "./providers/AppProvider";
|
||||||
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
export { ConfigContext, useConfig } from "./providers/ConfigContext";
|
||||||
export { useResource, useResourceByName } from "./hooks/useResource";
|
export { useResource, useResourceByName } from "./hooks/useResource";
|
||||||
export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar";
|
export { default as FilterBar } 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";
|
|
||||||
|
|||||||
@@ -10,29 +10,17 @@ export type FieldType =
|
|||||||
| 'object'
|
| 'object'
|
||||||
| 'array';
|
| 'array';
|
||||||
|
|
||||||
export interface SelectOption {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnumOption {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResourceField {
|
export interface ResourceField {
|
||||||
displayFormat: string;
|
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
label: string;
|
label: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
schema?: Record<string, ResourceField>;
|
schema?: Record<string, ResourceField>;
|
||||||
|
displayField?: string | string[];
|
||||||
formatter?: (value: any) => string;
|
formatter?: (value: any) => string;
|
||||||
relation?: string;
|
relation?: string; // Name of the target resource
|
||||||
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
||||||
enumOption?: EnumOption;
|
|
||||||
enumLabels?: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResourceMode = "server" | "client";
|
export type ResourceMode = "server" | "client";
|
||||||
@@ -50,14 +38,12 @@ export interface ResourceConfig {
|
|||||||
mode?: ResourceMode;
|
mode?: ResourceMode;
|
||||||
fields?: string[];
|
fields?: string[];
|
||||||
};
|
};
|
||||||
enumOption?: EnumOption;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
authBaseUrl: string;
|
authBaseUrl: string;
|
||||||
resources: ResourceConfig[];
|
resources: ResourceConfig[];
|
||||||
enums: Record<string, string[]>;
|
|
||||||
profile?: {
|
profile?: {
|
||||||
resource: string;
|
resource: string;
|
||||||
extraFields?: Record<string, any>;
|
extraFields?: Record<string, any>;
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { ResourceField, FieldType } from './config';
|
/**
|
||||||
|
* This file contains application-specific overrides and configuration
|
||||||
export interface EnumOption {
|
* for the generic Admin Panel.
|
||||||
key: string;
|
*/
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FieldOverride {
|
export interface FieldOverride {
|
||||||
displayFormat?: string;
|
displayField?: string | string[];
|
||||||
display?: boolean;
|
display?: boolean;
|
||||||
formatter?: (value: any) => string;
|
formatter?: (value: any) => string;
|
||||||
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
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 {
|
export interface ResourceOverride {
|
||||||
@@ -27,63 +18,4 @@ export interface ResourceOverride {
|
|||||||
mode?: "server" | "client";
|
mode?: "server" | "client";
|
||||||
fields?: string[];
|
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 };
|
|
||||||
|
|||||||
@@ -36,26 +36,6 @@ function mapOpenApiType(prop: any): FieldType {
|
|||||||
/**
|
/**
|
||||||
* Recursively converts OpenAPI schemas to ResourceField map
|
* Recursively converts OpenAPI schemas to ResourceField map
|
||||||
*/
|
*/
|
||||||
function mergeProperties(schema: any): { properties: Record<string, any>; required: string[] } {
|
|
||||||
let properties: Record<string, any> = {};
|
|
||||||
let required: string[] = [];
|
|
||||||
|
|
||||||
if (schema.allOf) {
|
|
||||||
for (const sub of schema.allOf) {
|
|
||||||
const merged = mergeProperties(sub);
|
|
||||||
properties = { ...properties, ...merged.properties };
|
|
||||||
required = [...required, ...merged.required];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (schema.properties) {
|
|
||||||
properties = { ...properties, ...schema.properties };
|
|
||||||
}
|
|
||||||
if (schema.required) {
|
|
||||||
required = [...required, ...schema.required];
|
|
||||||
}
|
|
||||||
return { properties, required };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSchemaFields(
|
function parseSchemaFields(
|
||||||
schema: any,
|
schema: any,
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
@@ -63,26 +43,12 @@ function parseSchemaFields(
|
|||||||
configuration: Record<string, any> = {}
|
configuration: Record<string, any> = {}
|
||||||
): Record<string, ResourceField> {
|
): Record<string, ResourceField> {
|
||||||
const fields: Record<string, ResourceField> = {};
|
const fields: Record<string, ResourceField> = {};
|
||||||
const { properties, required } = mergeProperties(schema);
|
const properties = schema.properties || {};
|
||||||
|
const required = schema.required || [];
|
||||||
const overrides = configuration[resourceName]?.fields || {};
|
const overrides = configuration[resourceName]?.fields || {};
|
||||||
// console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
|
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
||||||
// Resolve oneOf/anyOf by merging all branch properties
|
const type = mapOpenApiType(prop);
|
||||||
let resolvedProp = prop;
|
|
||||||
if (prop.oneOf || prop.anyOf) {
|
|
||||||
const branches = prop.oneOf || prop.anyOf;
|
|
||||||
const merged = mergeProperties({ allOf: branches });
|
|
||||||
resolvedProp = { ...prop, type: 'object', properties: merged.properties, required: merged.required };
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
const override = overrides[key];
|
||||||
|
|
||||||
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
||||||
@@ -91,12 +57,12 @@ function parseSchemaFields(
|
|||||||
fields[key] = {
|
fields[key] = {
|
||||||
type,
|
type,
|
||||||
label:
|
label:
|
||||||
resolvedProp.title ||
|
prop.title ||
|
||||||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||||||
required: required.includes(key),
|
required: required.includes(key),
|
||||||
options: resolvedProp.enum,
|
options: prop.enum,
|
||||||
readOnly:
|
readOnly:
|
||||||
resolvedProp.readOnly ||
|
prop.readOnly ||
|
||||||
key === "created_at" ||
|
key === "created_at" ||
|
||||||
key === "updated_at",
|
key === "updated_at",
|
||||||
...override,
|
...override,
|
||||||
@@ -105,36 +71,20 @@ function parseSchemaFields(
|
|||||||
// STRICT RELATION DETECTION
|
// STRICT RELATION DETECTION
|
||||||
// A field is a relation ONLY if its schema object (or items schema)
|
// A field is a relation ONLY if its schema object (or items schema)
|
||||||
// exactly matches a schema that is defined as a resource.
|
// exactly matches a schema that is defined as a resource.
|
||||||
let targetSchema = resolvedProp;
|
let targetSchema = prop;
|
||||||
if (type === "array" && resolvedProp.items) {
|
if (type === "array" && prop.items) {
|
||||||
targetSchema = resolvedProp.items;
|
targetSchema = prop.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this schema object is registered as a resource
|
// Check if this schema object is registered as a resource
|
||||||
const relation = schemaToResourceMap.get(targetSchema);
|
const relation = schemaToResourceMap.get(targetSchema);
|
||||||
if (relation) {
|
if (relation) {
|
||||||
fields[key].relation = relation;
|
fields[key].relation = relation;
|
||||||
|
|
||||||
// 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)
|
// Recursively parse nested objects (only if not a relation)
|
||||||
if (fields[key].type === "object" && resolvedProp.properties && !relation) {
|
if (fields[key].type === "object" && prop.properties && !relation) {
|
||||||
// console.log('recursive configuration...', configuration['accounts']['referenceOptions'])
|
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap, configuration);
|
||||||
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +95,6 @@ function parseSchemaFields(
|
|||||||
* Scans paths to identify resources and their basic configuration
|
* Scans paths to identify resources and their basic configuration
|
||||||
*/
|
*/
|
||||||
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
|
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.
|
// Use SwaggerParser to dereference the spec.
|
||||||
// Dereferencing preserves object identity for $ref targets.
|
// Dereferencing preserves object identity for $ref targets.
|
||||||
const api = await SwaggerParser.dereference(
|
const api = await SwaggerParser.dereference(
|
||||||
@@ -195,22 +144,21 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
|||||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||||
const listPath = info.listPath || `/${name}`;
|
const listPath = info.listPath || `/${name}`;
|
||||||
const listOp = paths[listPath]?.get;
|
const listOp = paths[listPath]?.get;
|
||||||
// Always create a resource entry even if the list operation or schema is missing.
|
if (!listOp || !info.schemaObj) continue;
|
||||||
// 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 schema = info.schemaObj;
|
||||||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||||||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(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 resourceOverride = configuration[name] || {};
|
||||||
|
|
||||||
const fo = resourceOverride.filterOptions || {};
|
const fo = resourceOverride.filterOptions || {};
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
name,
|
name,
|
||||||
label: schema?.title || label,
|
label: schema.title || label,
|
||||||
pluralLabel: pluralLabel,
|
pluralLabel: pluralLabel,
|
||||||
endpoint: listPath,
|
endpoint: listPath,
|
||||||
primaryKey: "id",
|
primaryKey: "id",
|
||||||
@@ -222,17 +170,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
|||||||
fields: fo.fields,
|
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[]> = {};
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -243,7 +180,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
|||||||
baseUrl: serverBaseUrl,
|
baseUrl: serverBaseUrl,
|
||||||
authBaseUrl: authBaseUrl,
|
authBaseUrl: authBaseUrl,
|
||||||
resources,
|
resources,
|
||||||
enums,
|
|
||||||
profile: profileConfiguration,
|
profile: profileConfiguration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { ResourceField, SelectOption } from "../types/config";
|
|
||||||
|
|
||||||
export function resolveTemplate(template: string, item: any): string {
|
|
||||||
if (/\{(\w+)\}/.test(template)) {
|
|
||||||
return template.replace(/\{(\w+)\}/g, (_, field: string) => String(item[field] ?? ''));
|
|
||||||
}
|
|
||||||
return String(item[template] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
value: field.enumLabels?.[opt] ?? opt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.relation) {
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toGridValueOptions(options: SelectOption[]): { value: string; label: string }[] {
|
|
||||||
return options.map(opt => ({ value: opt.key, label: opt.value }));
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,8 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
|||||||
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
|
||||||
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||||
import {
|
import {
|
||||||
|
useFetchRequest,
|
||||||
|
useUpdateFetchRequest,
|
||||||
useFetchRequestAmbiguities,
|
useFetchRequestAmbiguities,
|
||||||
useResolveAmbiguity,
|
useResolveAmbiguity,
|
||||||
} from "./features/fetch-requests";
|
} from "./features/fetch-requests";
|
||||||
@@ -35,7 +37,7 @@ import type {
|
|||||||
ProgressMessage,
|
ProgressMessage,
|
||||||
} from "./features/fetch-requests";
|
} from "./features/fetch-requests";
|
||||||
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
||||||
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
import { useConfig } from "../react-openapi";
|
||||||
|
|
||||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||||
pending: "default",
|
pending: "default",
|
||||||
@@ -146,9 +148,8 @@ export default function FetchRequestDetail() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
|
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!);
|
||||||
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
|
const updateMutation = useUpdateFetchRequest();
|
||||||
const updateMutation = usePatch();
|
|
||||||
const resolveMutation = useResolveAmbiguity();
|
const resolveMutation = useResolveAmbiguity();
|
||||||
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Paper,
|
Paper,
|
||||||
Typography,
|
Typography,
|
||||||
|
TextField,
|
||||||
Button,
|
Button,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
Chip,
|
Chip,
|
||||||
IconButton,
|
IconButton,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
@@ -18,7 +25,6 @@ import {
|
|||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TextField,
|
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
@@ -37,6 +43,10 @@ import ScheduleIcon from "@mui/icons-material/Schedule";
|
|||||||
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
|
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
|
||||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
import {
|
import {
|
||||||
|
useFetchRequestsList,
|
||||||
|
useCreateFetchRequest,
|
||||||
|
useUpdateFetchRequest,
|
||||||
|
useDeleteFetchRequest,
|
||||||
useUploadFile,
|
useUploadFile,
|
||||||
} from "./features/fetch-requests";
|
} from "./features/fetch-requests";
|
||||||
import type {
|
import type {
|
||||||
@@ -47,8 +57,7 @@ import type {
|
|||||||
} from "./features/fetch-requests";
|
} from "./features/fetch-requests";
|
||||||
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
import { useResourceByName, useConfig } from "../react-openapi";
|
||||||
import type { ResourceField } from "../react-openapi";
|
|
||||||
|
|
||||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||||
pending: "default",
|
pending: "default",
|
||||||
@@ -76,14 +85,14 @@ function formatDate(iso: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRange(start?: string, end?: string) {
|
function formatDateRange(start?: string, end?: string) {
|
||||||
if (!start && !end) return "\u2014";
|
if (!start && !end) return "—";
|
||||||
const s = start ? new Date(start).toLocaleDateString() : "?";
|
const s = start ? new Date(start).toLocaleDateString() : "?";
|
||||||
const e = end ? new Date(end).toLocaleDateString() : "?";
|
const e = end ? new Date(end).toLocaleDateString() : "?";
|
||||||
return `${s} \u2192 ${e}`;
|
return `${s} → ${e}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortId(fp: string) {
|
function shortId(fp: string) {
|
||||||
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
|
return fp.length > 8 ? fp.slice(0, 8) + "…" : fp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FetchRequests() {
|
export default function FetchRequests() {
|
||||||
@@ -107,13 +116,11 @@ export default function FetchRequests() {
|
|||||||
const [accountFilter, setAccountFilter] = React.useState("");
|
const [accountFilter, setAccountFilter] = React.useState("");
|
||||||
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
|
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
|
||||||
|
|
||||||
const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
|
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({
|
||||||
const { data: listData, isLoading, isFetching, refetch } = useList({
|
|
||||||
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
|
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
|
||||||
...(accountFilter ? { account_name: accountFilter } : {}),
|
...(accountFilter ? { account_name: accountFilter } : {}),
|
||||||
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
|
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { useList: useAccountsList } = useResourceByName("accounts");
|
const { useList: useAccountsList } = useResourceByName("accounts");
|
||||||
const { data: accountsData } = useAccountsList();
|
const { data: accountsData } = useAccountsList();
|
||||||
const accountOptions: string[] = React.useMemo(() => {
|
const accountOptions: string[] = React.useMemo(() => {
|
||||||
@@ -122,15 +129,11 @@ export default function FetchRequests() {
|
|||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
|
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
|
||||||
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
|
const formatOptions: string[] = (fetchRes?.fields?.source?.schema?.format?.options as string[]) ?? ["axis", "icici_ocr"];
|
||||||
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 = useCreate();
|
const createMutation = useCreateFetchRequest();
|
||||||
const updateMutation = usePatch();
|
const updateMutation = useUpdateFetchRequest();
|
||||||
const deleteMutation = useDelete();
|
const deleteMutation = useDeleteFetchRequest();
|
||||||
const uploadMutation = useUploadFile();
|
const uploadMutation = useUploadFile();
|
||||||
|
|
||||||
const requests = listData?.data ?? [];
|
const requests = listData?.data ?? [];
|
||||||
@@ -175,7 +178,7 @@ export default function FetchRequests() {
|
|||||||
navigate(`/fetch-requests/${result.id}`);
|
navigate(`/fetch-requests/${result.id}`);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 409) {
|
if (err?.response?.status === 409) {
|
||||||
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
|
setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
|
||||||
} else {
|
} else {
|
||||||
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
|
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
|
||||||
}
|
}
|
||||||
@@ -262,14 +265,6 @@ export default function FetchRequests() {
|
|||||||
Uploaded as: {uploadedPath}
|
Uploaded as: {uploadedPath}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{formatField && components?.FormField ? (
|
|
||||||
<components.FormField
|
|
||||||
name="format"
|
|
||||||
field={formatField}
|
|
||||||
value={format}
|
|
||||||
onChange={setFormat}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormControl size="small">
|
<FormControl size="small">
|
||||||
<InputLabel>Format</InputLabel>
|
<InputLabel>Format</InputLabel>
|
||||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||||
@@ -278,18 +273,9 @@ export default function FetchRequests() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{formatField && components?.FormField ? (
|
|
||||||
<components.FormField
|
|
||||||
name="format"
|
|
||||||
field={formatField}
|
|
||||||
value={format}
|
|
||||||
onChange={setFormat}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FormControl size="small">
|
<FormControl size="small">
|
||||||
<InputLabel>Format</InputLabel>
|
<InputLabel>Format</InputLabel>
|
||||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||||
@@ -298,7 +284,6 @@ export default function FetchRequests() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
|
||||||
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
|
<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="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" />
|
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
|
||||||
@@ -314,28 +299,9 @@ export default function FetchRequests() {
|
|||||||
)}
|
)}
|
||||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||||
/>
|
/>
|
||||||
{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" />
|
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
{startDateField && components?.date ? (
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<components.date
|
|
||||||
name="start_date"
|
|
||||||
field={startDateField}
|
|
||||||
value={startDate}
|
|
||||||
onChange={setStartDate}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Start Date"
|
label="Start Date"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -346,17 +312,6 @@ export default function FetchRequests() {
|
|||||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{endDateField && components?.date ? (
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<components.date
|
|
||||||
name="end_date"
|
|
||||||
field={endDateField}
|
|
||||||
value={endDate}
|
|
||||||
onChange={setEndDate}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<TextField
|
<TextField
|
||||||
label="End Date"
|
label="End Date"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -367,7 +322,6 @@ export default function FetchRequests() {
|
|||||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -391,19 +345,17 @@ export default function FetchRequests() {
|
|||||||
input={<OutlinedInput label="Status" />}
|
input={<OutlinedInput label="Status" />}
|
||||||
renderValue={(selected) => (selected as string[]).join(", ")}
|
renderValue={(selected) => (selected as string[]).join(", ")}
|
||||||
>
|
>
|
||||||
{(config?.enums?.FetchRequestStatus ?? []).map((s: string) => (
|
{["pending", "processing", "paused", "raw_expenses_done", "enriched_done", "completed", "failed"].map((s) => (
|
||||||
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
|
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Autocomplete
|
<TextField
|
||||||
options={accountOptions}
|
label="Account"
|
||||||
value={accountFilter || null}
|
value={accountFilter}
|
||||||
onChange={(_, val) => setAccountFilter(val ?? "")}
|
onChange={(e) => setAccountFilter(e.target.value)}
|
||||||
renderInput={(params) => (
|
size="small"
|
||||||
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
|
sx={{ minWidth: 160 }}
|
||||||
)}
|
|
||||||
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
|
||||||
/>
|
/>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={sourceFilter}
|
value={sourceFilter}
|
||||||
@@ -433,39 +385,31 @@ export default function FetchRequests() {
|
|||||||
No fetch requests yet
|
No fetch requests yet
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
|
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
|
||||||
<Box sx={{ overflowX: "auto" }}>
|
<Table size="small">
|
||||||
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
<TableHead>
|
||||||
<Box component="thead">
|
<TableRow>
|
||||||
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
<TableCell>ID</TableCell>
|
||||||
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
|
<TableCell>Account</TableCell>
|
||||||
<Box
|
<TableCell>Source</TableCell>
|
||||||
key={h}
|
<TableCell>Date Range</TableCell>
|
||||||
component="th"
|
<TableCell>Status</TableCell>
|
||||||
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
<TableCell>Retries</TableCell>
|
||||||
>
|
<TableCell>Created</TableCell>
|
||||||
{h}
|
<TableCell align="right">Actions</TableCell>
|
||||||
</Box>
|
</TableRow>
|
||||||
))}
|
</TableHead>
|
||||||
</Box>
|
<TableBody>
|
||||||
</Box>
|
|
||||||
<Box component="tbody">
|
|
||||||
{[...requests]
|
{[...requests]
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
.map((req: FetchRequest) => (
|
.map((req: FetchRequest) => (
|
||||||
<Box
|
<TableRow
|
||||||
key={req.id}
|
key={req.id}
|
||||||
component="tr"
|
hover
|
||||||
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
||||||
sx={{
|
sx={{ cursor: "pointer", "&:last-child td": { border: 0 } }}
|
||||||
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" }}>
|
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
{shortId(req.fingerprint)}
|
{shortId(req.fingerprint)}
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -480,24 +424,22 @@ export default function FetchRequests() {
|
|||||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
<TableCell>{req.account_name}</TableCell>
|
||||||
{req.account_name}
|
<TableCell>
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
|
||||||
<Chip
|
<Chip
|
||||||
label={"path" in req.source ? "File" : "Email"}
|
label={"path" in req.source ? "File" : "Email"}
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color={"path" in req.source ? "primary" : "secondary"}
|
color={"path" in req.source ? "primary" : "secondary"}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
<TableCell>
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||||
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
<TableCell>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
||||||
<Chip
|
<Chip
|
||||||
@@ -508,22 +450,22 @@ export default function FetchRequests() {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
<TableCell>
|
||||||
{(req.retry_count ?? 0) > 0 ? (
|
{(req.retry_count ?? 0) > 0 ? (
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||||
{req.retry_count}/{RETRY_MAX}
|
{req.retry_count}/{RETRY_MAX}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
||||||
\u2014
|
—
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
<TableCell sx={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||||||
{formatDate(req.created_at)}
|
{formatDate(req.created_at)}
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
<TableCell align="right">
|
||||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
{req.status === "paused" && (
|
{req.status === "paused" && (
|
||||||
<Tooltip title="Resolve ambiguities">
|
<Tooltip title="Resolve ambiguities">
|
||||||
@@ -563,13 +505,12 @@ export default function FetchRequests() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</TableCell>
|
||||||
</Box>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</TableBody>
|
||||||
</Box>
|
</Table>
|
||||||
</Box>
|
</TableContainer>
|
||||||
</Paper>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Paper,
|
Paper,
|
||||||
Typography,
|
Typography,
|
||||||
|
TextField,
|
||||||
Button,
|
Button,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
IconButton,
|
IconButton,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -14,29 +21,20 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
Chip,
|
Chip,
|
||||||
TextField,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||||
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
import {
|
||||||
import type { ResourceField } from "../react-openapi";
|
useReportSnapshotsList,
|
||||||
|
useCreateSnapshot,
|
||||||
interface ReportSnapshotQuery {
|
useDeleteSnapshot,
|
||||||
accounts?: string[];
|
} from "./features/report-snapshots";
|
||||||
ignore_self?: boolean;
|
import type { ReportSnapshot } from "./features/report-snapshots";
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReportSnapshot {
|
|
||||||
id: string;
|
|
||||||
snapshot_id: string;
|
|
||||||
created_at: string;
|
|
||||||
query?: ReportSnapshotQuery;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -53,32 +51,21 @@ export default function ReportSnapshots() {
|
|||||||
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
||||||
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
|
const { data: listData, isLoading, isFetching, refetch } = useReportSnapshotsList();
|
||||||
|
const createMutation = useCreateSnapshot();
|
||||||
|
const deleteMutation = useDeleteSnapshot();
|
||||||
|
|
||||||
const { data: listData, isLoading, isFetching, refetch } = useList();
|
const snapshots = listData?.data ?? [];
|
||||||
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 () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, any> = {};
|
const result = await createMutation.mutateAsync({
|
||||||
if (ignoreSelf) payload.ignore_self = true;
|
ignore_self: ignoreSelf || null,
|
||||||
if (startDate) payload.start_date = new Date(startDate).toISOString();
|
start_date: startDate ? new Date(startDate).toISOString() : null,
|
||||||
if (endDate) payload.end_date = new Date(endDate).toISOString();
|
end_date: endDate ? new Date(endDate).toISOString() : null,
|
||||||
if (minAmount) payload.min_amount = parseFloat(minAmount);
|
min_amount: minAmount ? parseFloat(minAmount) : null,
|
||||||
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
|
max_amount: maxAmount ? parseFloat(maxAmount) : null,
|
||||||
|
});
|
||||||
const result = await createMutation.mutateAsync(payload);
|
|
||||||
const snapshotId = (result as any)?.snapshot_id;
|
const snapshotId = (result as any)?.snapshot_id;
|
||||||
if (snapshotId) {
|
if (snapshotId) {
|
||||||
setCreatedSnapshotId(snapshotId);
|
setCreatedSnapshotId(snapshotId);
|
||||||
@@ -93,7 +80,7 @@ export default function ReportSnapshots() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setIgnoreSelf(true);
|
setIgnoreSelf(false);
|
||||||
setStartDate("");
|
setStartDate("");
|
||||||
setEndDate("");
|
setEndDate("");
|
||||||
setMinAmount("");
|
setMinAmount("");
|
||||||
@@ -123,62 +110,50 @@ export default function ReportSnapshots() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
{ignoreSelfField && components?.FormField && (
|
<FormControlLabel
|
||||||
<components.FormField
|
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
|
||||||
name="ignore_self"
|
label="Ignore self-transfers"
|
||||||
field={ignoreSelfField}
|
|
||||||
value={ignoreSelf}
|
|
||||||
onChange={(val: boolean) => setIgnoreSelf(val)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Start Date"
|
label="Start Date"
|
||||||
type="date"
|
type="datetime-local"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
size="small"
|
size="small"
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="End Date"
|
label="End Date"
|
||||||
type="date"
|
type="datetime-local"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
size="small"
|
size="small"
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
{minAmountField && components?.FormField && (
|
<TextField
|
||||||
<Box sx={{ flex: 1 }}>
|
label="Min Amount"
|
||||||
<components.FormField
|
type="number"
|
||||||
name="min_amount"
|
|
||||||
field={minAmountField}
|
|
||||||
value={minAmount}
|
value={minAmount}
|
||||||
onChange={(val: string) => setMinAmount(val)}
|
onChange={(e) => setMinAmount(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
<TextField
|
||||||
)}
|
label="Max Amount"
|
||||||
{maxAmountField && components?.FormField && (
|
type="number"
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<components.FormField
|
|
||||||
name="max_amount"
|
|
||||||
field={maxAmountField}
|
|
||||||
value={maxAmount}
|
value={maxAmount}
|
||||||
onChange={(val: string) => setMaxAmount(val)}
|
onChange={(e) => setMaxAmount(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -216,29 +191,20 @@ export default function ReportSnapshots() {
|
|||||||
No snapshots yet
|
No snapshots yet
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ overflowX: "auto" }}>
|
<TableContainer>
|
||||||
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
<Table size="small">
|
||||||
<Box component="thead">
|
<TableHead>
|
||||||
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
<TableRow>
|
||||||
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
|
<TableCell>Snapshot ID</TableCell>
|
||||||
<Box
|
<TableCell>Created</TableCell>
|
||||||
key={h}
|
<TableCell>Query</TableCell>
|
||||||
component="th"
|
<TableCell align="right">Actions</TableCell>
|
||||||
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
</TableRow>
|
||||||
>
|
</TableHead>
|
||||||
{h}
|
<TableBody>
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box component="tbody">
|
|
||||||
{snapshots.map((snap: ReportSnapshot) => (
|
{snapshots.map((snap: ReportSnapshot) => (
|
||||||
<Box
|
<TableRow key={snap.id}>
|
||||||
key={snap.id}
|
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||||
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 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
{snap.snapshot_id}
|
{snap.snapshot_id}
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -252,11 +218,9 @@ export default function ReportSnapshots() {
|
|||||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
<TableCell>{formatDate(snap.created_at)}</TableCell>
|
||||||
{formatDate(snap.created_at)}
|
<TableCell>
|
||||||
</Box>
|
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
|
||||||
{snap.query ? (
|
{snap.query ? (
|
||||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||||
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
|
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
|
||||||
@@ -265,21 +229,19 @@ export default function ReportSnapshots() {
|
|||||||
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
|
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="body2" color="text.secondary">\u2014</Typography>
|
<Typography variant="body2" color="text.secondary">—</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</TableCell>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
<TableCell align="right">
|
||||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
|
||||||
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</TableCell>
|
||||||
</Box>
|
</TableRow>
|
||||||
</Box>
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</TableBody>
|
||||||
</Box>
|
</Table>
|
||||||
</Box>
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export type {
|
|||||||
} from "./fetch-requests.models";
|
} from "./fetch-requests.models";
|
||||||
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
|
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
|
||||||
export {
|
export {
|
||||||
|
useFetchRequestsList,
|
||||||
|
useFetchRequest,
|
||||||
|
useCreateFetchRequest,
|
||||||
|
useUpdateFetchRequest,
|
||||||
|
useDeleteFetchRequest,
|
||||||
useUploadFile,
|
useUploadFile,
|
||||||
useFetchRequestAmbiguities,
|
useFetchRequestAmbiguities,
|
||||||
useResolveAmbiguity,
|
useResolveAmbiguity,
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ export type {
|
|||||||
ReportSnapshot,
|
ReportSnapshot,
|
||||||
ReportQuery,
|
ReportQuery,
|
||||||
} from "./report-snapshots.models";
|
} from "./report-snapshots.models";
|
||||||
|
export {
|
||||||
|
useReportSnapshotsList,
|
||||||
|
useCreateSnapshot,
|
||||||
|
useDeleteSnapshot,
|
||||||
|
} from "./useReportSnapshots";
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Dashboard from './Dashboard';
|
|||||||
import FetchRequests from './FetchRequests';
|
import FetchRequests from './FetchRequests';
|
||||||
import FetchRequestDetail from './FetchRequestDetail';
|
import FetchRequestDetail from './FetchRequestDetail';
|
||||||
import ReportSnapshots from './ReportSnapshots';
|
import ReportSnapshots from './ReportSnapshots';
|
||||||
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi';
|
import { Admin, AppProvider } from '../react-openapi';
|
||||||
import { configuration, profileConfiguration } from './openapi-config';
|
import { configuration, profileConfiguration } from './openapi-config';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
import process from 'process';
|
import process from 'process';
|
||||||
@@ -60,7 +60,7 @@ root.render(
|
|||||||
path={path}
|
path={path}
|
||||||
element={
|
element={
|
||||||
path.startsWith("/admin") ? (
|
path.startsWith("/admin") ? (
|
||||||
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} />
|
<Component basePath="/admin" />
|
||||||
) : (
|
) : (
|
||||||
<Component />
|
<Component />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ResourceOverride } from "../react-openapi";
|
import { ResourceOverride } from "../react-openapi/types/overrides";
|
||||||
|
|
||||||
export const configuration: Record<string, ResourceOverride> = {
|
export const configuration: Record<string, ResourceOverride> = {
|
||||||
expenses: {
|
expenses: {
|
||||||
@@ -8,22 +8,20 @@ export const configuration: Record<string, ResourceOverride> = {
|
|||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
payee: {
|
payee: {
|
||||||
displayFormat: "{name}",
|
displayField: "name",
|
||||||
filterType: "autocomplete",
|
filterType: "autocomplete",
|
||||||
},
|
},
|
||||||
payor: {
|
payor: {
|
||||||
display: false,
|
display: false,
|
||||||
displayFormat: "{username}",
|
displayField: "username",
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
displayFormat: "{name}",
|
displayField: "name",
|
||||||
filterType: "multiselect",
|
filterType: "multiselect",
|
||||||
refers: "accounts"
|
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
displayFormat: "{icon} {name}",
|
displayField: ["name", "icon"],
|
||||||
filterType: "autocomplete",
|
filterType: "autocomplete",
|
||||||
refers: "tags"
|
|
||||||
},
|
},
|
||||||
occurred_at: {
|
occurred_at: {
|
||||||
filterType: "date-range",
|
filterType: "date-range",
|
||||||
@@ -52,47 +50,6 @@ 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: {
|
|
||||||
referenceOptions: {
|
|
||||||
enumOption: {
|
|
||||||
key: '_id',
|
|
||||||
value: '{name} - XX{number}',
|
|
||||||
},
|
|
||||||
autoComplete: true,
|
|
||||||
prefetch: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
referenceOptions: {
|
|
||||||
enumOption: {
|
|
||||||
key: '_id',
|
|
||||||
value: '{icon} {name}',
|
|
||||||
},
|
|
||||||
autoComplete: true,
|
|
||||||
prefetch: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const profileConfiguration = {
|
export const profileConfiguration = {
|
||||||
|
|||||||
Reference in New Issue
Block a user