Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. (#11)
# Summary Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. ## Changes ### Admin UI Customization * Added support for custom: * Dashboard component * Layout component * Login page component * Introduced `AdminAppProps` and extended `Admin` configuration API. * Renamed internal dashboard implementation to `DefaultDashboard`. ### Field Component Architecture * Extracted field rendering into dedicated field components: * TextField * NumberField * BooleanField * DateField * EnumField * RelationField * ObjectField * FallbackField * DateRangeField * NumberRangeField * Added `defaultFieldComponents` registry. * Refactored `FormField` to resolve components dynamically from a component map instead of hardcoded field type handling. ### Resource Customization * Added `FieldComponents` support across: * Admin * ResourceView * GenericForm * useResource * Introduced wrapped `FormField` and `GenericForm` components generated from configured field overrides. ### Table Customization * Added `EnhancedTableComponents`. * Added support for custom cell renderers per field type. * Enabled custom rendering for both desktop and mobile table layouts. ### Filter Improvements * Exported `FilterAutocomplete`. * Added support for custom date-range and number-range filter components. * Added filter component extension points. * Updated filter option label resolution to support `displayFormat`. ### Display Formatting * Replaced `displayField` usage with `displayFormat`. * Added template-based display rendering support through `resolveTemplate`. * Improved relation display configuration handling. ### TypeScript Improvements * Added TypeScript as a project dependency. * Removed multiple `@ts-ignore` usages. * Added strongly typed Axios wrapper methods with generic response support. * Improved typing across hooks and component interfaces. ### OpenAPI Configuration Validation * Added validation for enum fields without enum values. * Added validation for relation resources missing `referenceOptions.enumOption`. * Improved relation metadata propagation during schema parsing. ### Library Exports * Exported: * Field component types * Override types * EnhancedTable * GenericForm * ResourceView * Field components and defaults * Expanded public API surface for consumers extending the framework. ## Benefits * Enables complete UI customization without modifying framework internals. * Simplifies creation of custom field types and renderers. * Improves type safety and developer experience. * Provides consistent extension points for forms, tables, filters, and admin layouts. * Makes the framework more suitable for reusable library distribution. Reviewed-on: #11 Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com> Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
"vite": "latest"
|
"vite": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4103,6 +4104,19 @@
|
|||||||
"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,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
"vite": "latest"
|
"vite": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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,
|
||||||
@@ -15,8 +16,9 @@ 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 Dashboard({ basePath }: { basePath: string }) {
|
function DefaultDashboard({ basePath }: { basePath: string }) {
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -31,7 +33,6 @@ function Dashboard({ 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",
|
||||||
@@ -61,9 +62,15 @@ function Dashboard({ basePath }: { basePath: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import ProfileView from "./components/ProfileView";
|
interface AdminAppProps {
|
||||||
|
basePath: string;
|
||||||
|
fieldComponents: FieldComponents;
|
||||||
|
Dashboard?: React.ComponentType<{ basePath: string }>;
|
||||||
|
Layout?: React.ComponentType<AdminLayoutProps>;
|
||||||
|
LoginPage?: React.ComponentType<any>;
|
||||||
|
}
|
||||||
|
|
||||||
function AdminApp({ basePath }: { basePath: string }) {
|
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) {
|
||||||
const { currentUser, login, logout, loading, error } = useAuth();
|
const { currentUser, login, logout, loading, error } = useAuth();
|
||||||
const config = React.useContext(ConfigContext);
|
const config = React.useContext(ConfigContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -73,10 +80,10 @@ function AdminApp({ basePath }: { basePath: string }) {
|
|||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<AuthPage
|
<LoginPage
|
||||||
mode="login"
|
mode="login"
|
||||||
login={login}
|
login={login}
|
||||||
register={async () => {}} // Disable registration for Admin
|
register={async () => {}}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={error}
|
error={error}
|
||||||
onSwitchMode={() => {}}
|
onSwitchMode={() => {}}
|
||||||
@@ -87,7 +94,7 @@ function AdminApp({ basePath }: { basePath: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout
|
<Layout
|
||||||
username={currentUser.username}
|
username={currentUser.username}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onSelectResource={(name) => navigate(`/admin/${name}`)}
|
onSelectResource={(name) => navigate(`/admin/${name}`)}
|
||||||
@@ -96,32 +103,44 @@ function AdminApp({ basePath }: { basePath: string }) {
|
|||||||
<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 />} />
|
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AdminLayout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceRouteWrapper() {
|
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) {
|
||||||
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} />;
|
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelectResource: (resourceName: string | null) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
username?: string;
|
||||||
|
resources: import("./types/config").ResourceConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminProps {
|
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 = {} }: AdminProps) {
|
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: 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);
|
||||||
|
|
||||||
@@ -151,16 +170,14 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<UploadProvider>
|
<UploadProvider>
|
||||||
<AdminApp basePath={basePath} />
|
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} />
|
||||||
</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,4 +1,5 @@
|
|||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,25 +31,25 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.get(...args);
|
return _api.get<T, R>(url, config);
|
||||||
},
|
},
|
||||||
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.post(...args);
|
return _api.post<T, R>(url, data, config);
|
||||||
},
|
},
|
||||||
put: (...args: Parameters<AxiosInstance["put"]>) => {
|
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.put(...args);
|
return _api.put<T, R>(url, data, config);
|
||||||
},
|
},
|
||||||
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
|
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.delete(...args);
|
return _api.delete<T, R>(url, config);
|
||||||
},
|
},
|
||||||
patch: (...args: Parameters<AxiosInstance["patch"]>) => {
|
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
|
||||||
if (!_api) throw new Error("API client not initialized");
|
if (!_api) throw new Error("API client not initialized");
|
||||||
return _api.patch(...args);
|
return _api.patch<T, R>(url, data, config);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
|
|||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import 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';
|
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
|
||||||
|
|
||||||
interface EnhancedTableProps {
|
interface EnhancedTableProps {
|
||||||
@@ -44,6 +45,7 @@ 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({
|
||||||
@@ -57,6 +59,7 @@ 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'));
|
||||||
@@ -85,7 +88,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} />
|
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} />
|
||||||
};
|
};
|
||||||
|
|
||||||
if (muiType === 'date' || muiType === 'dateTime') {
|
if (muiType === 'date' || muiType === 'dateTime') {
|
||||||
@@ -97,7 +100,7 @@ export default function EnhancedTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (muiType === 'singleSelect') {
|
if (muiType === 'singleSelect') {
|
||||||
col.valueOptions = toGridValueOptions(getFieldOptions(field));
|
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
|
||||||
}
|
}
|
||||||
|
|
||||||
return col;
|
return col;
|
||||||
@@ -158,6 +161,7 @@ export default function EnhancedTable({
|
|||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onNavigate={onNavigateToResource}
|
onNavigate={onNavigateToResource}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
|
components={tableComponents}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -225,7 +229,7 @@ export default function EnhancedTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) {
|
||||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
const [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];
|
||||||
@@ -261,7 +265,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
|||||||
{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 />
|
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} />
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -289,12 +293,17 @@ function getFormattedDisplayValue(item: any, displayField?: string | string[], e
|
|||||||
return item[displayField] || item.id || JSON.stringify(item);
|
return item[displayField] || item.id || JSON.stringify(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
|
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) {
|
||||||
const value = params.value;
|
const 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;
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ 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";
|
import { getFieldOptions, resolveTemplate } from "../utils/options";
|
||||||
|
|
||||||
function FilterAutocomplete({
|
export function FilterAutocomplete({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
@@ -123,15 +124,10 @@ function extractOptions(
|
|||||||
|
|
||||||
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
||||||
|
|
||||||
const df = field.displayField;
|
// Use displayFormat if defined, otherwise fall back to displayField logic (for backward compatibility)
|
||||||
if (!df) return null;
|
if (field.displayFormat) {
|
||||||
|
return resolveTemplate(field.displayFormat, item);
|
||||||
if (Array.isArray(df)) {
|
|
||||||
const parts = df.map((k) => item[k]).filter((v) => v != null);
|
|
||||||
if (parts.length > 0) return parts.join(" ");
|
|
||||||
}
|
}
|
||||||
const v = item[df];
|
|
||||||
if (v != null) return String(v);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -160,32 +156,24 @@ 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 (
|
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
||||||
<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 (
|
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
|
||||||
<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 : [];
|
||||||
@@ -208,6 +196,8 @@ 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({
|
||||||
@@ -217,6 +207,8 @@ 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 }));
|
||||||
@@ -284,7 +276,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 || field.filterType === "autocomplete" || field.filterType === "multiselect";
|
const needsOptions = 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];
|
||||||
|
|
||||||
@@ -294,7 +286,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)
|
updateDraft(fieldName, key, val), filterComponents, fieldComponents
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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';
|
||||||
@@ -21,6 +22,7 @@ interface GenericFormProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onEditClick?: () => void;
|
onEditClick?: () => void;
|
||||||
|
fieldComponents: FieldComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GenericForm({
|
export default function GenericForm({
|
||||||
@@ -31,6 +33,7 @@ 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);
|
||||||
@@ -54,7 +57,7 @@ export default function GenericForm({
|
|||||||
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!);
|
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents });
|
||||||
return {
|
return {
|
||||||
...getListQueryOptions(),
|
...getListQueryOptions(),
|
||||||
enabled: !!relatedRes,
|
enabled: !!relatedRes,
|
||||||
@@ -117,6 +120,7 @@ export default function GenericForm({
|
|||||||
uploading={uploading}
|
uploading={uploading}
|
||||||
baseUrl={appConfig?.baseUrl || ""}
|
baseUrl={appConfig?.baseUrl || ""}
|
||||||
relationDataMap={relationDataMap}
|
relationDataMap={relationDataMap}
|
||||||
|
components={fieldComponents}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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);
|
||||||
@@ -13,7 +14,6 @@ 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,13 +31,12 @@ export default function ProfileView() {
|
|||||||
};
|
};
|
||||||
}, [resourceConfig, profileConfig.extraFields]);
|
}, [resourceConfig, profileConfig.extraFields]);
|
||||||
|
|
||||||
const { useMe, useUpdateMe } = useResource(resourceConfig);
|
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents });
|
||||||
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))
|
||||||
@@ -76,6 +75,7 @@ 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,9 @@ 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 { 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,6 +12,7 @@ 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';
|
||||||
@@ -96,7 +97,7 @@ function applyClientFilters(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -115,10 +116,10 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
|||||||
|
|
||||||
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
||||||
|
|
||||||
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
|
||||||
|
|
||||||
const queryParams = React.useMemo(() => {
|
const queryParams = React.useMemo(() => {
|
||||||
if (!isServer) return { limit: 10000 };
|
if (!isServer) return { limit: 10 };
|
||||||
return {
|
return {
|
||||||
skip: paginationModel.page * paginationModel.pageSize,
|
skip: paginationModel.page * paginationModel.pageSize,
|
||||||
limit: paginationModel.pageSize,
|
limit: paginationModel.pageSize,
|
||||||
@@ -183,6 +184,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
|||||||
appliedValues={appliedFilters}
|
appliedValues={appliedFilters}
|
||||||
onApply={setAppliedFilters}
|
onApply={setAppliedFilters}
|
||||||
onClear={() => setAppliedFilters({})}
|
onClear={() => setAppliedFilters({})}
|
||||||
|
fieldComponents={components}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EnhancedTable
|
<EnhancedTable
|
||||||
@@ -200,7 +202,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Paper sx={{ p: 4 }}>
|
<Paper sx={{ p: 4 }}>
|
||||||
<GenericForm
|
{components && <components.GenericForm
|
||||||
config={config}
|
config={config}
|
||||||
initialData={isCreate ? null : itemQuery.data}
|
initialData={isCreate ? null : itemQuery.data}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -208,7 +210,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
|
|||||||
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>
|
||||||
|
|||||||
17
react-openapi/components/fields/BooleanField.tsx
Normal file
17
react-openapi/components/fields/BooleanField.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { FormControlLabel, Checkbox } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={!!value}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={field.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
react-openapi/components/fields/DateField.tsx
Normal file
18
react-openapi/components/fields/DateField.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { TextField as MuiTextField } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
const isDatetime = field.type === 'datetime';
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
fullWidth
|
||||||
|
label={field.label}
|
||||||
|
type={isDatetime ? "datetime-local" : "date"}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
react-openapi/components/fields/DateRangeField.tsx
Normal file
30
react-openapi/components/fields/DateRangeField.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Box, TextField as MuiTextField } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function DateRangeField({ value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
const rangeVal = (value as { start?: string; end?: string }) || {};
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<MuiTextField
|
||||||
|
type="date"
|
||||||
|
placeholder="From"
|
||||||
|
size="small"
|
||||||
|
value={rangeVal.start ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ width: 170 }}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<MuiTextField
|
||||||
|
type="date"
|
||||||
|
placeholder="To"
|
||||||
|
size="small"
|
||||||
|
value={rangeVal.end ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ width: 170 }}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
40
react-openapi/components/fields/DefaultFieldComponents.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FieldComponents, FieldComponentProps } from '../../types/overrides';
|
||||||
|
import TextFieldEntry from './TextField';
|
||||||
|
import NumberField from './NumberField';
|
||||||
|
import BooleanField from './BooleanField';
|
||||||
|
import DateField from './DateField';
|
||||||
|
import EnumField from './EnumField';
|
||||||
|
import RelationField from './RelationField';
|
||||||
|
import ImageUploadField from './ImageUploadField';
|
||||||
|
import FallbackField from './FallbackField';
|
||||||
|
import DateRangeField from './DateRangeField';
|
||||||
|
import NumberRangeField from './NumberRangeField';
|
||||||
|
|
||||||
|
const WrappedImageUploadField = (props: FieldComponentProps) =>
|
||||||
|
React.createElement(ImageUploadField, {
|
||||||
|
label: props.field.label,
|
||||||
|
value: props.value || '',
|
||||||
|
onUpload: async (file: File) => {
|
||||||
|
const url = await props.uploadFile?.(file);
|
||||||
|
if (url) props.onChange(url);
|
||||||
|
},
|
||||||
|
uploading: props.uploading,
|
||||||
|
baseUrl: props.baseUrl || '',
|
||||||
|
disabled: props.disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const defaultFieldComponents: FieldComponents = {
|
||||||
|
string: TextFieldEntry,
|
||||||
|
markdown: TextFieldEntry,
|
||||||
|
number: NumberField,
|
||||||
|
boolean: BooleanField,
|
||||||
|
date: DateField,
|
||||||
|
datetime: DateField,
|
||||||
|
enum: EnumField,
|
||||||
|
image: WrappedImageUploadField,
|
||||||
|
relation: RelationField,
|
||||||
|
default: FallbackField,
|
||||||
|
dateRange: DateRangeField,
|
||||||
|
numberRange: NumberRangeField,
|
||||||
|
};
|
||||||
24
react-openapi/components/fields/EnumField.tsx
Normal file
24
react-openapi/components/fields/EnumField.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
|
||||||
|
import { getFieldOptions } from '../../utils/options';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
const options = getFieldOptions(field);
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>{field.label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={value || ''}
|
||||||
|
label={field.label}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem key={opt.key} value={opt.key}>
|
||||||
|
{opt.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
react-openapi/components/fields/FallbackField.tsx
Normal file
13
react-openapi/components/fields/FallbackField.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { TextField } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function FallbackField({ field, value }: FieldComponentProps) {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={field.label}
|
||||||
|
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
import * as React from 'react';
|
import * 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 { getFieldOptions } from '../../utils/options';
|
import { FieldComponentProps, FieldComponents } from '../../types/overrides';
|
||||||
import ImageUploadField from './ImageUploadField';
|
import ObjectField from './ObjectField';
|
||||||
|
|
||||||
interface FormFieldProps {
|
export 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[]>; // Map of relation name to data array
|
relationDataMap?: Record<string, any[]>;
|
||||||
|
components: FieldComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FormField({
|
export default function FormField({
|
||||||
@@ -37,190 +26,60 @@ export default function FormField({
|
|||||||
uploading,
|
uploading,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
relationDataMap = {},
|
relationDataMap = {},
|
||||||
|
components,
|
||||||
}: FormFieldProps) {
|
}: FormFieldProps) {
|
||||||
const label = field.label;
|
const fieldProps: FieldComponentProps = {
|
||||||
|
name,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
baseUrl,
|
||||||
|
relationDataMap,
|
||||||
|
uploadFile,
|
||||||
|
uploading,
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Recursive Rendering for Objects (Not Relations)
|
const childComponents = components;
|
||||||
|
|
||||||
|
// 1. Object (recursive) - requires parent FormField for recursion
|
||||||
if (field.type === 'object' && field.schema && !field.relation) {
|
if (field.type === 'object' && field.schema && !field.relation) {
|
||||||
return (
|
const renderChild = (childProps: FieldComponentProps) => (
|
||||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
<FormField
|
||||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
name={childProps.name}
|
||||||
{label}
|
field={childProps.field}
|
||||||
</Typography>
|
value={childProps.value}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
onChange={childProps.onChange}
|
||||||
{Object.entries(field.schema).map(([subKey, subField]) => (
|
disabled={childProps.disabled}
|
||||||
<FormField
|
uploadFile={childProps.uploadFile}
|
||||||
key={subKey}
|
uploading={childProps.uploading}
|
||||||
name={`${name}.${subKey}`}
|
baseUrl={childProps.baseUrl}
|
||||||
field={subField}
|
relationDataMap={childProps.relationDataMap}
|
||||||
value={value?.[subKey]}
|
components={components}
|
||||||
onChange={(newVal) => {
|
/>
|
||||||
const updated = { ...(value || {}), [subKey]: newVal };
|
|
||||||
onChange(updated);
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
uploadFile={uploadFile}
|
|
||||||
uploading={uploading}
|
|
||||||
baseUrl={baseUrl}
|
|
||||||
relationDataMap={relationDataMap}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
|
return <ObjectField {...fieldProps} renderField={renderChild} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Relation Handling (Select / Multi-Select)
|
// 2. Image
|
||||||
if (field.relation && relationDataMap[field.relation]) {
|
|
||||||
const relationData = relationDataMap[field.relation].data;
|
|
||||||
const isArrayRelation = field.type === 'array';
|
|
||||||
const options = getFieldOptions(field, relationData);
|
|
||||||
const keyField = field.enumOption?.key ?? 'id';
|
|
||||||
|
|
||||||
// Normalize value: API returns whole objects on GET, but form uses key strings
|
|
||||||
const normalizedValue = (() => {
|
|
||||||
if (isArrayRelation && Array.isArray(value)) {
|
|
||||||
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v)));
|
|
||||||
}
|
|
||||||
if (value != null && typeof value === 'object') {
|
|
||||||
return String(value[keyField] ?? '');
|
|
||||||
}
|
|
||||||
return value ?? (isArrayRelation ? [] : "");
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel shrink>{label}</InputLabel>
|
|
||||||
<Select
|
|
||||||
multiple={isArrayRelation}
|
|
||||||
value={normalizedValue}
|
|
||||||
label={label}
|
|
||||||
displayEmpty
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
renderValue={(selected: any) => {
|
|
||||||
if (isArrayRelation) {
|
|
||||||
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
|
||||||
}
|
|
||||||
return options.find(o => o.key === selected)?.value ?? selected;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<MenuItem key={opt.key} value={opt.key}>
|
|
||||||
{opt.value}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Image Handling
|
|
||||||
if (field.type === 'image') {
|
if (field.type === 'image') {
|
||||||
return (
|
const ImageField = components.image;
|
||||||
<ImageUploadField
|
if (!ImageField) return null;
|
||||||
label={label}
|
return <ImageField {...fieldProps} />;
|
||||||
value={value}
|
|
||||||
onUpload={async (file: any) => {
|
|
||||||
const url = await uploadFile(file);
|
|
||||||
if (url) onChange(url);
|
|
||||||
}}
|
|
||||||
uploading={uploading}
|
|
||||||
baseUrl={baseUrl}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Boolean Handling
|
// 3. Relation
|
||||||
if (field.type === 'boolean') {
|
if (field.relation && relationDataMap[field.relation]) {
|
||||||
return (
|
const RelationFieldComp = components.relation;
|
||||||
<FormControlLabel
|
if (!RelationFieldComp) return null;
|
||||||
control={
|
return <RelationFieldComp {...fieldProps} />;
|
||||||
<Checkbox
|
|
||||||
checked={!!value}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Enum Handling
|
// 4. Lookup by field type
|
||||||
if (field.type === 'enum') {
|
const Component = components[field.type] || components.default;
|
||||||
const options = getFieldOptions(field);
|
if (Component) {
|
||||||
return (
|
return <Component {...fieldProps} />;
|
||||||
<FormControl fullWidth>
|
|
||||||
<InputLabel>{label}</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={value || ''}
|
|
||||||
label={label}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<MenuItem key={opt.key} value={opt.key}>
|
|
||||||
{opt.value}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Common Text Fields
|
return null;
|
||||||
if (field.type === 'datetime' || field.type === 'date') {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
type={field.type === 'datetime' ? "datetime-local" : "date"}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'markdown' || field.type === 'string') {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={value || ''}
|
|
||||||
multiline={field.type === 'markdown'}
|
|
||||||
rows={field.type === 'markdown' ? 4 : 1}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.type === 'number') {
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
type="number"
|
|
||||||
value={value === undefined || value === null ? '' : value}
|
|
||||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
|
||||||
disabled={disabled}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={label}
|
|
||||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
16
react-openapi/components/fields/NumberField.tsx
Normal file
16
react-openapi/components/fields/NumberField.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { TextField as MuiTextField } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
fullWidth
|
||||||
|
label={field.label}
|
||||||
|
type="number"
|
||||||
|
value={value === undefined || value === null ? '' : value}
|
||||||
|
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
react-openapi/components/fields/NumberRangeField.tsx
Normal file
28
react-openapi/components/fields/NumberRangeField.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Box, TextField as MuiTextField } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function NumberRangeField({ value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
const rangeVal = (value as { min?: string; max?: string }) || {};
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<MuiTextField
|
||||||
|
type="number"
|
||||||
|
placeholder="Min"
|
||||||
|
size="small"
|
||||||
|
value={rangeVal.min ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<MuiTextField
|
||||||
|
type="number"
|
||||||
|
placeholder="Max"
|
||||||
|
size="small"
|
||||||
|
value={rangeVal.max ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
react-openapi/components/fields/ObjectField.tsx
Normal file
36
react-openapi/components/fields/ObjectField.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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]) =>
|
||||||
|
renderField({
|
||||||
|
name: `${name}.${subKey}`,
|
||||||
|
field: subField,
|
||||||
|
value: value?.[subKey],
|
||||||
|
onChange: (newVal: any) => {
|
||||||
|
const updated = { ...(value || {}), [subKey]: newVal };
|
||||||
|
onChange(updated);
|
||||||
|
},
|
||||||
|
disabled,
|
||||||
|
baseUrl,
|
||||||
|
uploadFile,
|
||||||
|
uploading,
|
||||||
|
relationDataMap,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
react-openapi/components/fields/RelationField.tsx
Normal file
50
react-openapi/components/fields/RelationField.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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) {
|
||||||
|
if (!field.relation || !relationDataMap[field.relation]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relationData = relationDataMap[field.relation];
|
||||||
|
const isArrayRelation = field.type === 'array';
|
||||||
|
const options = getFieldOptions(field, relationData);
|
||||||
|
const keyField = field.enumOption?.key ?? 'id';
|
||||||
|
|
||||||
|
const normalizedValue = (() => {
|
||||||
|
if (isArrayRelation && Array.isArray(value)) {
|
||||||
|
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v)));
|
||||||
|
}
|
||||||
|
if (value != null && typeof value === 'object') {
|
||||||
|
return String(value[keyField] ?? '');
|
||||||
|
}
|
||||||
|
return value ?? (isArrayRelation ? [] : "");
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel shrink>{field.label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiple={isArrayRelation}
|
||||||
|
value={normalizedValue}
|
||||||
|
label={field.label}
|
||||||
|
displayEmpty
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
renderValue={(selected: any) => {
|
||||||
|
if (isArrayRelation) {
|
||||||
|
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
||||||
|
}
|
||||||
|
return options.find(o => o.key === selected)?.value ?? selected;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem key={opt.key} value={opt.key}>
|
||||||
|
{opt.value}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
react-openapi/components/fields/TextField.tsx
Normal file
18
react-openapi/components/fields/TextField.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { TextField as MuiTextField } from '@mui/material';
|
||||||
|
import { FieldComponentProps } from '../../types/overrides';
|
||||||
|
|
||||||
|
export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) {
|
||||||
|
const isMarkdown = field.type === 'markdown';
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
fullWidth
|
||||||
|
label={field.label}
|
||||||
|
value={value || ''}
|
||||||
|
multiline={isMarkdown}
|
||||||
|
rows={isMarkdown ? 4 : 1}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
react-openapi/components/fields/index.ts
Normal file
14
react-openapi/components/fields/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export { default as FormField } from './FormField';
|
||||||
|
export { default as ImageUploadField } from './ImageUploadField';
|
||||||
|
export { default as TextField } from './TextField';
|
||||||
|
export { default as NumberField } from './NumberField';
|
||||||
|
export { default as BooleanField } from './BooleanField';
|
||||||
|
export { default as DateField } from './DateField';
|
||||||
|
export { default as EnumField } from './EnumField';
|
||||||
|
export { default as RelationField } from './RelationField';
|
||||||
|
export { default as ObjectField } from './ObjectField';
|
||||||
|
export { default as FallbackField } from './FallbackField';
|
||||||
|
export { default as DateRangeField } from './DateRangeField';
|
||||||
|
export { default as NumberRangeField } from './NumberRangeField';
|
||||||
|
export { defaultFieldComponents } from './DefaultFieldComponents';
|
||||||
|
export type { ObjectFieldProps } from './ObjectField';
|
||||||
@@ -1,23 +1,39 @@
|
|||||||
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 * as React from "react";
|
import { FieldComponents, FieldComponentProps } from "../types/overrides";
|
||||||
|
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
|
||||||
|
import FormField from "../components/fields/FormField";
|
||||||
|
import GenericForm from "../components/GenericForm";
|
||||||
|
|
||||||
export function useResource<T = any>(config: ResourceConfig | undefined) {
|
function wrapFormField(merged: FieldComponents) {
|
||||||
|
return (props: Omit<React.ComponentProps<typeof FormField>, 'components'>) =>
|
||||||
|
React.createElement(FormField, { ...props, components: merged });
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapGenericForm(merged: FieldComponents) {
|
||||||
|
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
|
||||||
|
React.createElement(GenericForm, { ...props, fieldComponents: merged });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResource<T = any>(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) {
|
||||||
const queryClient = useQueryClient();
|
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 {
|
||||||
@@ -35,7 +51,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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;
|
||||||
},
|
},
|
||||||
@@ -47,7 +62,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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;
|
||||||
},
|
},
|
||||||
@@ -61,12 +75,10 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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) => {
|
onSuccess: (updatedItem: any) => {
|
||||||
// @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] });
|
||||||
@@ -78,15 +90,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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) => {
|
onSuccess: (updatedItem: any) => {
|
||||||
// @ts-ignore
|
const listId = 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", listId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,7 +118,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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 {
|
||||||
@@ -125,7 +134,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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;
|
||||||
},
|
},
|
||||||
@@ -137,7 +145,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
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;
|
||||||
},
|
},
|
||||||
@@ -147,6 +154,15 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const components = React.useMemo(() => {
|
||||||
|
if (!mergedComponents) return undefined;
|
||||||
|
return {
|
||||||
|
...mergedComponents,
|
||||||
|
FormField: wrapFormField(mergedComponents),
|
||||||
|
GenericForm: wrapGenericForm(mergedComponents),
|
||||||
|
};
|
||||||
|
}, [mergedComponents]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useList,
|
useList,
|
||||||
useRead,
|
useRead,
|
||||||
@@ -157,12 +173,12 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
|||||||
useUpdateMe,
|
useUpdateMe,
|
||||||
useDelete,
|
useDelete,
|
||||||
getListQueryOptions,
|
getListQueryOptions,
|
||||||
|
components,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResourceByName<T = any>(name: string) {
|
export function useResourceByName<T = any>(name: string, options?: { fieldComponents: FieldComponents }) {
|
||||||
const config = React.useContext(ConfigContext);
|
const 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);
|
return useResource<T>(resourceConfig, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ 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 } from "./components/FilterBar";
|
export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar";
|
||||||
|
export { default as EnhancedTable } from "./components/EnhancedTable";
|
||||||
|
export { default as GenericForm } from "./components/GenericForm";
|
||||||
|
export { default as ResourceView } from "./components/ResourceView";
|
||||||
|
export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields";
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ export interface EnumOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceField {
|
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;
|
||||||
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
|
import { ResourceField, FieldType } from './config';
|
||||||
|
|
||||||
export interface EnumOption {
|
export interface EnumOption {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldOverride {
|
export interface FieldOverride {
|
||||||
displayField?: string | string[];
|
displayFormat?: 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>;
|
enumLabels?: Record<string, string>;
|
||||||
|
// New optional properties to support custom config extensions
|
||||||
|
path?: string;
|
||||||
|
refers?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceOverride {
|
export interface ResourceOverride {
|
||||||
@@ -20,4 +25,62 @@ export interface ResourceOverride {
|
|||||||
fields?: string[];
|
fields?: string[];
|
||||||
};
|
};
|
||||||
enumOption?: EnumOption;
|
enumOption?: EnumOption;
|
||||||
|
// New optional property for reference‑type resources
|
||||||
|
referenceOptions?: {
|
||||||
|
enumOption?: EnumOption;
|
||||||
|
autoComplete?: boolean;
|
||||||
|
prefetch?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldComponentProps {
|
||||||
|
name: string;
|
||||||
|
field: ResourceField;
|
||||||
|
value: any;
|
||||||
|
onChange: (val: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
relationDataMap?: Record<string, any[]>;
|
||||||
|
uploadFile?: (file: File) => Promise<string | null>;
|
||||||
|
uploading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldComponent = React.ComponentType<FieldComponentProps>;
|
||||||
|
|
||||||
|
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
|
||||||
|
relation?: FieldComponent;
|
||||||
|
image?: FieldComponent;
|
||||||
|
default?: FieldComponent;
|
||||||
|
dateRange?: FieldComponent;
|
||||||
|
numberRange?: FieldComponent;
|
||||||
|
FormField?: React.ComponentType<any>;
|
||||||
|
GenericForm?: React.ComponentType<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CellRendererProps {
|
||||||
|
value: any;
|
||||||
|
row: any;
|
||||||
|
field: ResourceField;
|
||||||
|
fieldKey: string;
|
||||||
|
config: import('./config').ResourceConfig;
|
||||||
|
onNavigate?: (resourceName: string, id: string) => void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CellRenderer = React.ComponentType<CellRendererProps>;
|
||||||
|
|
||||||
|
export interface EnhancedTableComponents {
|
||||||
|
cellRenderers?: Partial<Record<FieldType, CellRenderer>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterBarComponents {
|
||||||
|
filterInputs?: Record<string, React.ComponentType<{
|
||||||
|
field: ResourceField;
|
||||||
|
value: any;
|
||||||
|
onChange: (val: any) => void;
|
||||||
|
options: string[];
|
||||||
|
}>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { FieldType };
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function parseSchemaFields(
|
|||||||
const fields: Record<string, ResourceField> = {};
|
const fields: Record<string, ResourceField> = {};
|
||||||
const { properties, required } = mergeProperties(schema);
|
const { properties, required } = mergeProperties(schema);
|
||||||
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
|
// Resolve oneOf/anyOf by merging all branch properties
|
||||||
@@ -76,6 +77,12 @@ function parseSchemaFields(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const type = mapOpenApiType(resolvedProp);
|
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
|
||||||
@@ -108,24 +115,25 @@ function parseSchemaFields(
|
|||||||
if (relation) {
|
if (relation) {
|
||||||
fields[key].relation = relation;
|
fields[key].relation = relation;
|
||||||
|
|
||||||
// Propagate enumOption from target resource config, or derive from target schema
|
// Propagate enumOption from target resource config, or derive from target schema
|
||||||
const explicitEnumOption = configuration[relation]?.enumOption;
|
const explicitEnumOption = configuration[relation].referenceOptions.enumOption;
|
||||||
if (explicitEnumOption) {
|
console.log('if relation configuration...', configuration['accounts']['referenceOptions'])
|
||||||
fields[key].enumOption = explicitEnumOption;
|
if (explicitEnumOption) {
|
||||||
} else {
|
fields[key].enumOption = explicitEnumOption;
|
||||||
const targetProps = targetSchema.properties || {};
|
} else {
|
||||||
const valueField = Object.entries(targetProps).find(
|
// No explicit enumOption supplied – this is a configuration error.
|
||||||
([name, p]: [string, any]) => name !== 'id' && p.type === 'string'
|
// We abort loading so the problem is visible immediately.
|
||||||
)?.[0];
|
throw new Error(
|
||||||
fields[key].enumOption = {
|
`Missing enumOption for relation "${relation}" on field "${key}". ` +
|
||||||
key: 'id',
|
`Define referenceOptions.enumOption in the configuration for resource "${relation}".`
|
||||||
value: valueField ?? 'id',
|
);
|
||||||
};
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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" && resolvedProp.properties && !relation) {
|
||||||
|
console.log('recursive configuration...', configuration['accounts']['referenceOptions'])
|
||||||
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
|
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +145,7 @@ 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(
|
||||||
@@ -192,6 +201,7 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
|||||||
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);
|
||||||
|
|
||||||
|
console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
|
||||||
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
|
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
|
||||||
|
|
||||||
const resourceOverride = configuration[name] || {};
|
const resourceOverride = configuration[name] || {};
|
||||||
@@ -216,8 +226,9 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
|
|||||||
|
|
||||||
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
|
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
|
||||||
const enums: Record<string, string[]> = {};
|
const enums: Record<string, string[]> = {};
|
||||||
if (api.components?.schemas) {
|
const apiDoc = api as any;
|
||||||
for (const [name, schema] of Object.entries(api.components.schemas) as [string, any]) {
|
if (apiDoc.components?.schemas) {
|
||||||
|
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
|
||||||
if (schema.enum) {
|
if (schema.enum) {
|
||||||
enums[name] = schema.enum;
|
enums[name] = schema.enum;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ export function getFieldOptions(field: ResourceField, relationData?: any[]): Sel
|
|||||||
|
|
||||||
if (field.relation) {
|
if (field.relation) {
|
||||||
const data = relationData ?? [];
|
const data = relationData ?? [];
|
||||||
const enumOption = field.enumOption ?? { key: 'id', value: 'name' };
|
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}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return data.map(item => ({
|
return data.map(item => ({
|
||||||
key: String(item[enumOption.key] ?? ''),
|
key: String(item[enumOption.key] ?? ''),
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ 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";
|
||||||
@@ -37,7 +35,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 { useConfig } from "../react-openapi";
|
import { useResourceByName, useConfig, defaultFieldComponents } 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",
|
||||||
@@ -148,8 +146,9 @@ export default function FetchRequestDetail() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!);
|
const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
|
||||||
const updateMutation = useUpdateFetchRequest();
|
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
|
||||||
|
const updateMutation = usePatch();
|
||||||
const resolveMutation = useResolveAmbiguity();
|
const resolveMutation = useResolveAmbiguity();
|
||||||
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,9 @@ 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,
|
||||||
@@ -25,6 +18,7 @@ import {
|
|||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
TextField,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
@@ -43,10 +37,6 @@ 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 {
|
||||||
@@ -57,7 +47,8 @@ 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 } from "../react-openapi";
|
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
||||||
|
import type { ResourceField } from "../react-openapi";
|
||||||
|
|
||||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||||
pending: "default",
|
pending: "default",
|
||||||
@@ -85,14 +76,14 @@ function formatDate(iso: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDateRange(start?: string, end?: string) {
|
function formatDateRange(start?: string, end?: string) {
|
||||||
if (!start && !end) return "—";
|
if (!start && !end) return "\u2014";
|
||||||
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} → ${e}`;
|
return `${s} \u2192 ${e}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortId(fp: string) {
|
function shortId(fp: string) {
|
||||||
return fp.length > 8 ? fp.slice(0, 8) + "…" : fp;
|
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FetchRequests() {
|
export default function FetchRequests() {
|
||||||
@@ -116,11 +107,13 @@ 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 { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({
|
const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
|
||||||
|
const { data: listData, isLoading, isFetching, refetch } = useList({
|
||||||
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
|
...(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(() => {
|
||||||
@@ -129,11 +122,15 @@ 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 formatOptions: string[] = fetchRes?.fields?.source?.schema?.format?.options as string[] ?? [];
|
const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
|
||||||
|
const formatOptions: string[] = formatField?.options ?? [];
|
||||||
|
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
|
||||||
|
const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
|
||||||
|
const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
|
||||||
|
|
||||||
const createMutation = useCreateFetchRequest();
|
const createMutation = useCreate();
|
||||||
const updateMutation = useUpdateFetchRequest();
|
const updateMutation = usePatch();
|
||||||
const deleteMutation = useDeleteFetchRequest();
|
const deleteMutation = useDelete();
|
||||||
const uploadMutation = useUploadFile();
|
const uploadMutation = useUploadFile();
|
||||||
|
|
||||||
const requests = listData?.data ?? [];
|
const requests = listData?.data ?? [];
|
||||||
@@ -178,7 +175,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 — same fingerprint already exists", severity: "error" });
|
setSnackbar({ message: "Duplicate \u2014 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" });
|
||||||
}
|
}
|
||||||
@@ -265,25 +262,43 @@ export default function FetchRequests() {
|
|||||||
Uploaded as: {uploadedPath}
|
Uploaded as: {uploadedPath}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<FormControl size="small">
|
{formatField && components?.FormField ? (
|
||||||
<InputLabel>Format</InputLabel>
|
<components.FormField
|
||||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
name="format"
|
||||||
{formatOptions.map((opt) => (
|
field={formatField}
|
||||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
value={format}
|
||||||
))}
|
onChange={setFormat}
|
||||||
</Select>
|
/>
|
||||||
</FormControl>
|
) : (
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Format</InputLabel>
|
||||||
|
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||||
|
{formatOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FormControl size="small">
|
{formatField && components?.FormField ? (
|
||||||
<InputLabel>Format</InputLabel>
|
<components.FormField
|
||||||
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
name="format"
|
||||||
{formatOptions.map((opt) => (
|
field={formatField}
|
||||||
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
value={format}
|
||||||
))}
|
onChange={setFormat}
|
||||||
</Select>
|
/>
|
||||||
</FormControl>
|
) : (
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>Format</InputLabel>
|
||||||
|
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
|
||||||
|
{formatOptions.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
<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" />
|
||||||
@@ -299,29 +314,60 @@ export default function FetchRequests() {
|
|||||||
)}
|
)}
|
||||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||||
/>
|
/>
|
||||||
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
{payorUsernameField && components?.FormField ? (
|
||||||
|
<components.FormField
|
||||||
|
name="payor_username"
|
||||||
|
field={payorUsernameField}
|
||||||
|
value={payorUsername}
|
||||||
|
onChange={setPayorUsername}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
<TextField
|
{startDateField && components?.date ? (
|
||||||
label="Start Date"
|
<Box sx={{ flex: 1 }}>
|
||||||
type="date"
|
<components.date
|
||||||
value={startDate}
|
name="start_date"
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
field={startDateField}
|
||||||
size="small"
|
value={startDate}
|
||||||
InputLabelProps={{ shrink: true }}
|
onChange={setStartDate}
|
||||||
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
/>
|
||||||
sx={{ flex: 1 }}
|
</Box>
|
||||||
/>
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
label="End Date"
|
label="Start Date"
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={startDate}
|
||||||
onChange={(e) => setEndDate(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] }}
|
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
|
||||||
|
label="End Date"
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
inputProps={{ max: new Date().toISOString().split("T")[0] }}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -350,12 +396,14 @@ export default function FetchRequests() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField
|
<Autocomplete
|
||||||
label="Account"
|
options={accountOptions}
|
||||||
value={accountFilter}
|
value={accountFilter || null}
|
||||||
onChange={(e) => setAccountFilter(e.target.value)}
|
onChange={(_, val) => setAccountFilter(val ?? "")}
|
||||||
size="small"
|
renderInput={(params) => (
|
||||||
sx={{ minWidth: 160 }}
|
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
|
||||||
|
)}
|
||||||
|
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||||
/>
|
/>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={sourceFilter}
|
value={sourceFilter}
|
||||||
@@ -385,132 +433,143 @@ export default function FetchRequests() {
|
|||||||
No fetch requests yet
|
No fetch requests yet
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
|
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
|
||||||
<Table size="small">
|
<Box sx={{ overflowX: "auto" }}>
|
||||||
<TableHead>
|
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
|
||||||
<TableRow>
|
<Box component="thead">
|
||||||
<TableCell>ID</TableCell>
|
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
<TableCell>Account</TableCell>
|
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
|
||||||
<TableCell>Source</TableCell>
|
<Box
|
||||||
<TableCell>Date Range</TableCell>
|
key={h}
|
||||||
<TableCell>Status</TableCell>
|
component="th"
|
||||||
<TableCell>Retries</TableCell>
|
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
||||||
<TableCell>Created</TableCell>
|
>
|
||||||
<TableCell align="right">Actions</TableCell>
|
{h}
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{[...requests]
|
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
|
||||||
.map((req: FetchRequest) => (
|
|
||||||
<TableRow
|
|
||||||
key={req.id}
|
|
||||||
hover
|
|
||||||
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
|
||||||
sx={{ cursor: "pointer", "&:last-child td": { border: 0 } }}
|
|
||||||
>
|
|
||||||
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
|
||||||
{shortId(req.fingerprint)}
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(req.fingerprint);
|
|
||||||
setSnackbar({ message: "Copied!", severity: "success" });
|
|
||||||
}}
|
|
||||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
|
||||||
>
|
|
||||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell>{req.account_name}</TableCell>
|
</Box>
|
||||||
<TableCell>
|
</Box>
|
||||||
<Chip
|
<Box component="tbody">
|
||||||
label={"path" in req.source ? "File" : "Email"}
|
{[...requests]
|
||||||
size="small"
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
variant="outlined"
|
.map((req: FetchRequest) => (
|
||||||
color={"path" in req.source ? "primary" : "secondary"}
|
<Box
|
||||||
/>
|
key={req.id}
|
||||||
</TableCell>
|
component="tr"
|
||||||
<TableCell>
|
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
sx={{
|
||||||
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
cursor: "pointer",
|
||||||
</Typography>
|
borderBottom: 1,
|
||||||
</TableCell>
|
borderColor: "divider",
|
||||||
<TableCell>
|
"&:hover": { bgcolor: "action.hover" },
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
"&:last-child": { borderBottom: 0 },
|
||||||
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
}}
|
||||||
<Chip
|
>
|
||||||
icon={statusIcons[req.status] as any}
|
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||||
label={req.status.replace(/_/g, " ")}
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
color={statusColors[req.status]}
|
{shortId(req.fingerprint)}
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{(req.retry_count ?? 0) > 0 ? (
|
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
|
||||||
{req.retry_count}/{RETRY_MAX}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
|
||||||
—
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
|
||||||
{formatDate(req.created_at)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
|
||||||
{req.status === "paused" && (
|
|
||||||
<Tooltip title="Resolve ambiguities">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/fetch-requests/${req.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningAmberIcon fontSize="small" color="warning" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
|
|
||||||
<Tooltip title="Retry">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRetry(req);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ReplayIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip title="Delete">
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDeleteTarget(req);
|
navigator.clipboard.writeText(req.fingerprint);
|
||||||
|
setSnackbar({ message: "Copied!", severity: "success" });
|
||||||
}}
|
}}
|
||||||
|
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||||
>
|
>
|
||||||
<DeleteIcon fontSize="small" />
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
||||||
</TableRow>
|
{req.account_name}
|
||||||
))}
|
</Box>
|
||||||
</TableBody>
|
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||||
</Table>
|
<Chip
|
||||||
</TableContainer>
|
label={"path" in req.source ? "File" : "Email"}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color={"path" in req.source ? "primary" : "secondary"}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||||
|
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
||||||
|
<Chip
|
||||||
|
icon={statusIcons[req.status] as any}
|
||||||
|
label={req.status.replace(/_/g, " ")}
|
||||||
|
color={statusColors[req.status]}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||||
|
{(req.retry_count ?? 0) > 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||||
|
{req.retry_count}/{RETRY_MAX}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
||||||
|
\u2014
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||||||
|
{formatDate(req.created_at)}
|
||||||
|
</Box>
|
||||||
|
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
{req.status === "paused" && (
|
||||||
|
<Tooltip title="Resolve ambiguities">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/fetch-requests/${req.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WarningAmberIcon fontSize="small" color="warning" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
|
||||||
|
<Tooltip title="Retry">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRetry(req);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReplayIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteTarget(req);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
|
|||||||
@@ -4,14 +4,7 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Paper,
|
Paper,
|
||||||
Typography,
|
Typography,
|
||||||
TextField,
|
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -21,20 +14,28 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogContentText,
|
DialogContentText,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Switch,
|
|
||||||
FormControlLabel,
|
|
||||||
Chip,
|
Chip,
|
||||||
} 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 {
|
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
|
||||||
useReportSnapshotsList,
|
import type { ResourceField } from "../react-openapi";
|
||||||
useCreateSnapshot,
|
|
||||||
useDeleteSnapshot,
|
interface ReportSnapshotQuery {
|
||||||
} from "./features/report-snapshots";
|
accounts?: string[];
|
||||||
import type { ReportSnapshot } from "./features/report-snapshots";
|
ignore_self?: boolean;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportSnapshot {
|
||||||
|
id: string;
|
||||||
|
snapshot_id: string;
|
||||||
|
created_at: string;
|
||||||
|
query?: ReportSnapshotQuery;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -51,21 +52,32 @@ export default function ReportSnapshots() {
|
|||||||
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
||||||
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const { data: listData, isLoading, isFetching, refetch } = useReportSnapshotsList();
|
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
|
||||||
const createMutation = useCreateSnapshot();
|
|
||||||
const deleteMutation = useDeleteSnapshot();
|
|
||||||
|
|
||||||
const snapshots = listData?.data ?? [];
|
const { data: listData, isLoading, isFetching, refetch } = useList();
|
||||||
|
const createMutation = useCreate();
|
||||||
|
const deleteMutation = useDelete();
|
||||||
|
|
||||||
|
const config = useConfig();
|
||||||
|
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
|
||||||
|
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
|
||||||
|
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
|
||||||
|
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
|
||||||
|
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
|
||||||
|
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
|
||||||
|
|
||||||
|
const snapshots: ReportSnapshot[] = listData?.data ?? [];
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await createMutation.mutateAsync({
|
const payload: Record<string, any> = {};
|
||||||
ignore_self: ignoreSelf || null,
|
if (ignoreSelf) payload.ignore_self = true;
|
||||||
start_date: startDate ? new Date(startDate).toISOString() : null,
|
if (startDate) payload.start_date = new Date(startDate).toISOString();
|
||||||
end_date: endDate ? new Date(endDate).toISOString() : null,
|
if (endDate) payload.end_date = new Date(endDate).toISOString();
|
||||||
min_amount: minAmount ? parseFloat(minAmount) : null,
|
if (minAmount) payload.min_amount = parseFloat(minAmount);
|
||||||
max_amount: maxAmount ? parseFloat(maxAmount) : null,
|
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
|
||||||
});
|
|
||||||
|
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);
|
||||||
@@ -80,7 +92,7 @@ export default function ReportSnapshots() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setIgnoreSelf(false);
|
setIgnoreSelf(true);
|
||||||
setStartDate("");
|
setStartDate("");
|
||||||
setEndDate("");
|
setEndDate("");
|
||||||
setMinAmount("");
|
setMinAmount("");
|
||||||
@@ -110,49 +122,59 @@ export default function ReportSnapshots() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<FormControlLabel
|
{ignoreSelfField && components?.FormField && (
|
||||||
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
|
<components.FormField
|
||||||
label="Ignore self-transfers"
|
name="ignore_self"
|
||||||
/>
|
field={ignoreSelfField}
|
||||||
|
value={ignoreSelf}
|
||||||
|
onChange={(val: boolean) => setIgnoreSelf(val)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
<TextField
|
{startDateField && components?.datetime && (
|
||||||
label="Start Date"
|
<Box sx={{ flex: 1 }}>
|
||||||
type="datetime-local"
|
<components.datetime
|
||||||
value={startDate}
|
name="start_date"
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
field={startDateField}
|
||||||
size="small"
|
value={startDate}
|
||||||
InputLabelProps={{ shrink: true }}
|
onChange={(val: string) => setStartDate(val)}
|
||||||
sx={{ flex: 1 }}
|
/>
|
||||||
/>
|
</Box>
|
||||||
<TextField
|
)}
|
||||||
label="End Date"
|
{endDateField && components?.datetime && (
|
||||||
type="datetime-local"
|
<Box sx={{ flex: 1 }}>
|
||||||
value={endDate}
|
<components.datetime
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
name="end_date"
|
||||||
size="small"
|
field={endDateField}
|
||||||
InputLabelProps={{ shrink: true }}
|
value={endDate}
|
||||||
sx={{ flex: 1 }}
|
onChange={(val: string) => setEndDate(val)}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
<TextField
|
{minAmountField && components?.FormField && (
|
||||||
label="Min Amount"
|
<Box sx={{ flex: 1 }}>
|
||||||
type="number"
|
<components.FormField
|
||||||
value={minAmount}
|
name="min_amount"
|
||||||
onChange={(e) => setMinAmount(e.target.value)}
|
field={minAmountField}
|
||||||
size="small"
|
value={minAmount}
|
||||||
sx={{ flex: 1 }}
|
onChange={(val: string) => setMinAmount(val)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
</Box>
|
||||||
label="Max Amount"
|
)}
|
||||||
type="number"
|
{maxAmountField && components?.FormField && (
|
||||||
value={maxAmount}
|
<Box sx={{ flex: 1 }}>
|
||||||
onChange={(e) => setMaxAmount(e.target.value)}
|
<components.FormField
|
||||||
size="small"
|
name="max_amount"
|
||||||
sx={{ flex: 1 }}
|
field={maxAmountField}
|
||||||
/>
|
value={maxAmount}
|
||||||
|
onChange={(val: string) => setMaxAmount(val)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -191,20 +213,29 @@ export default function ReportSnapshots() {
|
|||||||
No snapshots yet
|
No snapshots yet
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<TableContainer>
|
<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>Snapshot ID</TableCell>
|
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
|
||||||
<TableCell>Created</TableCell>
|
<Box
|
||||||
<TableCell>Query</TableCell>
|
key={h}
|
||||||
<TableCell align="right">Actions</TableCell>
|
component="th"
|
||||||
</TableRow>
|
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
|
||||||
</TableHead>
|
>
|
||||||
<TableBody>
|
{h}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box component="tbody">
|
||||||
{snapshots.map((snap: ReportSnapshot) => (
|
{snapshots.map((snap: ReportSnapshot) => (
|
||||||
<TableRow key={snap.id}>
|
<Box
|
||||||
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
key={snap.id}
|
||||||
|
component="tr"
|
||||||
|
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
|
||||||
|
>
|
||||||
|
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
{snap.snapshot_id}
|
{snap.snapshot_id}
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -218,9 +249,11 @@ export default function ReportSnapshots() {
|
|||||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</Box>
|
||||||
<TableCell>{formatDate(snap.created_at)}</TableCell>
|
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
|
||||||
<TableCell>
|
{formatDate(snap.created_at)}
|
||||||
|
</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" />}
|
||||||
@@ -229,19 +262,21 @@ 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">—</Typography>
|
<Typography variant="body2" color="text.secondary">\u2014</Typography>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</Box>
|
||||||
<TableCell align="right">
|
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
||||||
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
<DeleteIcon fontSize="small" />
|
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
||||||
</IconButton>
|
<DeleteIcon fontSize="small" />
|
||||||
</TableCell>
|
</IconButton>
|
||||||
</TableRow>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</Box>
|
||||||
</Table>
|
</Box>
|
||||||
</TableContainer>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ 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,8 +2,3 @@ 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 } from '../react-openapi';
|
import { Admin, AppProvider, defaultFieldComponents } 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" />
|
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} />
|
||||||
) : (
|
) : (
|
||||||
<Component />
|
<Component />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ResourceOverride } from "../react-openapi/types/overrides";
|
import { ResourceOverride } from "../react-openapi";
|
||||||
|
|
||||||
export const configuration: Record<string, ResourceOverride> = {
|
export const configuration: Record<string, ResourceOverride> = {
|
||||||
expenses: {
|
expenses: {
|
||||||
@@ -8,20 +8,22 @@ export const configuration: Record<string, ResourceOverride> = {
|
|||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
payee: {
|
payee: {
|
||||||
displayField: "name",
|
displayFormat: "{name}",
|
||||||
filterType: "autocomplete",
|
filterType: "autocomplete",
|
||||||
},
|
},
|
||||||
payor: {
|
payor: {
|
||||||
display: false,
|
display: false,
|
||||||
displayField: "username",
|
displayFormat: "{username}",
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
displayField: "name",
|
displayFormat: "{name}",
|
||||||
filterType: "multiselect",
|
filterType: "multiselect",
|
||||||
|
refers: "accounts"
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
displayField: ["name", "icon"],
|
displayFormat: "{icon} {name}",
|
||||||
filterType: "autocomplete",
|
filterType: "autocomplete",
|
||||||
|
refers: "tags"
|
||||||
},
|
},
|
||||||
occurred_at: {
|
occurred_at: {
|
||||||
filterType: "date-range",
|
filterType: "date-range",
|
||||||
@@ -50,16 +52,37 @@ export const configuration: Record<string, ResourceOverride> = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'fetch-requests': {
|
||||||
|
fields: {
|
||||||
|
format: {
|
||||||
|
path: 'source.format',
|
||||||
|
},
|
||||||
|
// account: {
|
||||||
|
// refers: 'accounts',
|
||||||
|
// },
|
||||||
|
// tags: {
|
||||||
|
// refers: 'tags',
|
||||||
|
// },
|
||||||
|
},
|
||||||
|
},
|
||||||
accounts: {
|
accounts: {
|
||||||
enumOption: {
|
referenceOptions: {
|
||||||
key: 'id',
|
enumOption: {
|
||||||
value: '{name} - XXXX{number}'
|
key: 'id',
|
||||||
|
value: '{name} - XX{number}',
|
||||||
|
},
|
||||||
|
autoComplete: true,
|
||||||
|
prefetch: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
enumOption: {
|
referenceOptions: {
|
||||||
key: 'id',
|
enumOption: {
|
||||||
value: '{icon} {name}'
|
key: 'id',
|
||||||
|
value: '{icon} {name}',
|
||||||
|
},
|
||||||
|
autoComplete: true,
|
||||||
|
prefetch: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user