diff --git a/package-lock.json b/package-lock.json index 0c9e631..90d0a28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "latest", + "typescript": "^6.0.3", "vite": "latest" } }, @@ -4103,6 +4104,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", diff --git a/package.json b/package.json index 61dfdc5..73ae1ae 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@vitejs/plugin-react": "latest", + "typescript": "^6.0.3", "vite": "latest" } } diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx index 52fabc8..c50c191 100644 --- a/react-openapi/Admin.tsx +++ b/react-openapi/Admin.tsx @@ -6,6 +6,7 @@ import ResourceView from "./components/ResourceView"; import { getAppConfig } from "./config"; import { initializeApiClients } from "./api/client"; import { AppConfig } from "./types/config"; +import { FieldComponents } from "./types/overrides"; import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import { Routes, @@ -15,8 +16,9 @@ import { } from "react-router-dom"; import { ConfigContext } from "./providers/ConfigContext"; +import ProfileView from "./components/ProfileView"; -function Dashboard({ basePath }: { basePath: string }) { +function DefaultDashboard({ basePath }: { basePath: string }) { const config = React.useContext(ConfigContext); const navigate = useNavigate(); @@ -31,7 +33,6 @@ function Dashboard({ basePath }: { basePath: string }) { Select a resource from the sidebar to manage data. - {visibleResources.map((res) => ( - ; + Layout?: React.ComponentType; + LoginPage?: React.ComponentType; +} -function AdminApp({ basePath }: { basePath: string }) { +function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) { const { currentUser, login, logout, loading, error } = useAuth(); const config = React.useContext(ConfigContext); const navigate = useNavigate(); @@ -73,10 +80,10 @@ function AdminApp({ basePath }: { basePath: string }) { if (!currentUser) { return ( - {}} // Disable registration for Admin + register={async () => {}} loading={loading} error={error} onSwitchMode={() => {}} @@ -87,7 +94,7 @@ function AdminApp({ basePath }: { basePath: string }) { } return ( - navigate(`/admin/${name}`)} @@ -96,32 +103,44 @@ function AdminApp({ basePath }: { basePath: string }) { } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> - + ); } -function ResourceRouteWrapper() { +function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) { const { resourceName } = useParams(); const config = React.useContext(ConfigContext); const selectedResource = config?.resources.find((r) => r.name === resourceName); if (!selectedResource) return Resource not found; - return ; + return ; +} + +interface AdminLayoutProps { + children: React.ReactNode; + onSelectResource: (resourceName: string | null) => void; + onLogout: () => void; + username?: string; + resources: import("./types/config").ResourceConfig[]; } interface AdminProps { basePath?: string; resourceOverrides?: Record; profileConfig?: any; + fieldComponents: FieldComponents; + Dashboard?: React.ComponentType<{ basePath: string }>; + Layout?: React.ComponentType; + LoginPage?: React.ComponentType; } -export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) { +export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) { const existingConfig = React.useContext(ConfigContext); const [config, setConfig] = React.useState(existingConfig); @@ -151,16 +170,14 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro const content = ( - + ); - // If we have an existing config, we are already inside a Provider and QueryClient if (existingConfig) { return content; } - // Fallback for standalone usage return ( {content} diff --git a/react-openapi/api/client.ts b/react-openapi/api/client.ts index d60f5d9..1513d41 100644 --- a/react-openapi/api/client.ts +++ b/react-openapi/api/client.ts @@ -1,4 +1,5 @@ import axios, { AxiosInstance } from "axios"; +import type { AxiosResponse } from "axios"; import { createApiClient } from "../../react-auth"; /** @@ -30,25 +31,25 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance { } export const api = { - get: (...args: Parameters) => { + get: >(url: string, config?: Parameters[1]) => { if (!_api) throw new Error("API client not initialized"); - return _api.get(...args); + return _api.get(url, config); }, - post: (...args: Parameters) => { + post: >(url: string, data?: any, config?: Parameters[2]) => { if (!_api) throw new Error("API client not initialized"); - return _api.post(...args); + return _api.post(url, data, config); }, - put: (...args: Parameters) => { + put: >(url: string, data?: any, config?: Parameters[2]) => { if (!_api) throw new Error("API client not initialized"); - return _api.put(...args); + return _api.put(url, data, config); }, - delete: (...args: Parameters) => { + delete: >(url: string, config?: Parameters[1]) => { if (!_api) throw new Error("API client not initialized"); - return _api.delete(...args); + return _api.delete(url, config); }, - patch: (...args: Parameters) => { + patch: >(url: string, data?: any, config?: Parameters[2]) => { if (!_api) throw new Error("API client not initialized"); - return _api.patch(...args); + return _api.patch(url, data, config); }, }; diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 067e3a2..ddc37ea 100644 --- a/react-openapi/components/EnhancedTable.tsx +++ b/react-openapi/components/EnhancedTable.tsx @@ -31,6 +31,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useNavigate } from 'react-router-dom'; import { ResourceConfig } from '../types/config'; +import { EnhancedTableComponents } from '../types/overrides'; import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options'; interface EnhancedTableProps { @@ -44,6 +45,7 @@ interface EnhancedTableProps { onDelete: (id: string) => void; onCreate: () => void; onNavigateToResource?: (resourceName: string, id: string) => void; + components?: EnhancedTableComponents; } export default function EnhancedTable({ @@ -57,6 +59,7 @@ export default function EnhancedTable({ onDelete, onCreate, onNavigateToResource, + components: tableComponents, }: EnhancedTableProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -85,7 +88,7 @@ export default function EnhancedTable({ type: muiType, flex: 1, minWidth: 150, - renderCell: (params: GridRenderCellParams) => + renderCell: (params: GridRenderCellParams) => }; if (muiType === 'date' || muiType === 'dateTime') { @@ -97,7 +100,7 @@ export default function EnhancedTable({ } if (muiType === 'singleSelect') { - col.valueOptions = toGridValueOptions(getFieldOptions(field)); + (col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field)); } return col; @@ -158,6 +161,7 @@ export default function EnhancedTable({ onDelete={onDelete} onNavigate={onNavigateToResource} navigate={navigate} + components={tableComponents} /> ))} @@ -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); const open = Boolean(anchorEl); const id = row[config.primaryKey]; @@ -261,7 +265,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) { {field.label} - + ))} @@ -289,12 +293,17 @@ function getFormattedDisplayValue(item: any, displayField?: string | string[], e 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 isPk = fieldKey === config.primaryKey; if (field.formatter) return field.formatter(value); + const customRenderer = components?.cellRenderers?.[field.type as string]; + if (customRenderer) { + return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile }); + } + // 1. Single Relation if (field.relation && value && !Array.isArray(value)) { const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value; diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index e2b8a3e..c5e9318 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -11,9 +11,10 @@ import { import DoneIcon from "@mui/icons-material/Done"; import FilterListIcon from "@mui/icons-material/FilterList"; import { ResourceField, ResourceMode } from "../types/config"; +import { FilterBarComponents, FieldComponents } from "../types/overrides"; import { getFieldOptions, resolveTemplate } from "../utils/options"; -function FilterAutocomplete({ +export function FilterAutocomplete({ options, value, label, @@ -123,15 +124,10 @@ function extractOptions( if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item); - const df = field.displayField; - if (!df) return null; - - if (Array.isArray(df)) { - const parts = df.map((k) => item[k]).filter((v) => v != null); - if (parts.length > 0) return parts.join(" "); + // Use displayFormat if defined, otherwise fall back to displayField logic (for backward compatibility) + if (field.displayFormat) { + return resolveTemplate(field.displayFormat, item); } - const v = item[df]; - if (v != null) return String(v); return null; }; @@ -160,32 +156,24 @@ function renderFilterInput( field: ResourceField, options: string[], value: any, - onChange: (key: string, val: any) => void + onChange: (key: string, val: any) => void, + components?: FilterBarComponents, + fieldComponents?: FieldComponents, ) { const filterType = field.filterType; if (filterType === "number-range") { + const RangeComponent = fieldComponents?.numberRange; + if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`); const rangeVal = (value as { min?: string; max?: string }) || {}; - return ( - - onChange("min", e.target.value || undefined)} sx={{ width: 100 }} /> - onChange("max", e.target.value || undefined)} sx={{ width: 100 }} /> - - ); + return onChange("value", val)} />; } if (filterType === "date-range") { + const RangeComponent = fieldComponents?.dateRange; + if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`); const rangeVal = (value as { start?: string; end?: string }) || {}; - return ( - - onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> - onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> - - ); + return onChange("value", val)} />; } const selected = Array.isArray(value) ? value : []; @@ -208,6 +196,8 @@ export interface FilterBarProps { appliedValues: Record; onApply: (values: Record) => void; onClear: () => void; + components?: FilterBarComponents; + fieldComponents?: FieldComponents; } export default function FilterBar({ @@ -217,6 +207,8 @@ export default function FilterBar({ appliedValues, onApply, onClear, + components: filterComponents, + fieldComponents, }: FilterBarProps) { const [open, setOpen] = React.useState(false); const [draft, setDraft] = React.useState>(() => ({ ...appliedValues })); @@ -284,7 +276,7 @@ export default function FilterBar({ const field = fields[fieldName]; if (!field) return null; - const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect"; + const needsOptions = field.filterType === "autocomplete" || field.filterType === "multiselect"; const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : []; const raw = draft[fieldName]; @@ -294,7 +286,7 @@ export default function FilterBar({ {field.label} {renderFilterInput(fieldName, field, options, raw, (key, val) => - updateDraft(fieldName, key, val) + updateDraft(fieldName, key, val), filterComponents, fieldComponents )} ); diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx index 8931f07..9a51076 100644 --- a/react-openapi/components/GenericForm.tsx +++ b/react-openapi/components/GenericForm.tsx @@ -7,6 +7,7 @@ import { CircularProgress, } from '@mui/material'; import { ResourceConfig } from '../types/config'; +import { FieldComponents } from '../types/overrides'; import { useUpload } from '../providers/UploadProvider'; import { useQueries } from '@tanstack/react-query'; import { useResource } from '../hooks/useResource'; @@ -21,6 +22,7 @@ interface GenericFormProps { loading?: boolean; readOnly?: boolean; onEditClick?: () => void; + fieldComponents: FieldComponents; } export default function GenericForm({ @@ -31,6 +33,7 @@ export default function GenericForm({ loading: saving, readOnly = false, onEditClick, + fieldComponents, }: GenericFormProps) { initialData = initialData || {}; const [formData, setFormData] = React.useState(initialData); @@ -54,7 +57,7 @@ export default function GenericForm({ queries: allRelations.map(relName => { const relatedRes = appConfig?.resources.find(r => r.name === relName); // eslint-disable-next-line react-hooks/rules-of-hooks - const { getListQueryOptions } = useResource(relatedRes!); + const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents }); return { ...getListQueryOptions(), enabled: !!relatedRes, @@ -117,6 +120,7 @@ export default function GenericForm({ uploading={uploading} baseUrl={appConfig?.baseUrl || ""} relationDataMap={relationDataMap} + components={fieldComponents} /> ))} diff --git a/react-openapi/components/ProfileView.tsx b/react-openapi/components/ProfileView.tsx index ac76e61..7ebc57d 100644 --- a/react-openapi/components/ProfileView.tsx +++ b/react-openapi/components/ProfileView.tsx @@ -3,6 +3,7 @@ import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material'; import { useResource } from '../hooks/useResource'; import GenericForm from './GenericForm'; import { ConfigContext } from '../providers/ConfigContext'; +import { defaultFieldComponents } from './fields/DefaultFieldComponents'; export default function ProfileView() { const appConfig = React.useContext(ConfigContext); @@ -13,11 +14,10 @@ export default function ProfileView() { return Profile configuration not found.; } - // Create a modified config where only extraFields are editable const editableConfig = React.useMemo(() => { const newFields = { ...resourceConfig.fields }; const extraFields = profileConfig.extraFields || []; - + Object.keys(newFields).forEach(key => { newFields[key] = { ...newFields[key], @@ -31,13 +31,12 @@ export default function ProfileView() { }; }, [resourceConfig, profileConfig.extraFields]); - const { useMe, useUpdateMe } = useResource(resourceConfig); + const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents }); const { data: profile, isLoading, error } = useMe(); const updateMutation = useUpdateMe(); const handleSave = async (formData: any) => { try { - // Only send editable fields to prevent accidental overwrites of read-only data const extraFields = profileConfig.extraFields || []; const dataToSave = Object.keys(formData) .filter(key => extraFields.includes(key)) @@ -76,6 +75,7 @@ export default function ProfileView() { onSave={handleSave} onCancel={() => window.history.back()} loading={updateMutation.isPending} + fieldComponents={defaultFieldComponents} /> diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index f03885b..e270713 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -2,9 +2,9 @@ import * as React from 'react'; import { Box, Paper, CircularProgress } from '@mui/material'; import { ResourceConfig } from '../types/config'; import type { ResourceField } from '../types/config'; +import { FieldComponents } from '../types/overrides'; import { useResource } from '../hooks/useResource'; import { resolveTemplate } from '../utils/options'; -import GenericForm from './GenericForm'; import EnhancedTable from './EnhancedTable'; import FilterBar from './FilterBar'; import { useParams, useLocation, useNavigate } from 'react-router-dom'; @@ -12,6 +12,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'; interface ResourceViewProps { config: ResourceConfig; onNavigateToResource?: (resourceName: string, id: string) => void; + fieldComponents: FieldComponents; } import { GridPaginationModel } from '@mui/x-data-grid'; @@ -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 location = useLocation(); const navigate = useNavigate(); @@ -115,10 +116,10 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV const [appliedFilters, setAppliedFilters] = React.useState>({}); - const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config); + const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents }); const queryParams = React.useMemo(() => { - if (!isServer) return { limit: 10000 }; + if (!isServer) return { limit: 10 }; return { skip: paginationModel.page * paginationModel.pageSize, limit: paginationModel.pageSize, @@ -183,6 +184,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV appliedValues={appliedFilters} onApply={setAppliedFilters} onClear={() => setAppliedFilters({})} + fieldComponents={components} /> )} ) : ( - navigate(`/admin/${config.name}/edit/${id}`)} - /> + />} )} diff --git a/react-openapi/components/fields/BooleanField.tsx b/react-openapi/components/fields/BooleanField.tsx new file mode 100644 index 0000000..1deb5e3 --- /dev/null +++ b/react-openapi/components/fields/BooleanField.tsx @@ -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 ( + onChange(e.target.checked)} + disabled={disabled} + /> + } + label={field.label} + /> + ); +} diff --git a/react-openapi/components/fields/DateField.tsx b/react-openapi/components/fields/DateField.tsx new file mode 100644 index 0000000..04b1f22 --- /dev/null +++ b/react-openapi/components/fields/DateField.tsx @@ -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 ( + onChange(e.target.value)} + disabled={disabled} + required={field.required} + /> + ); +} diff --git a/react-openapi/components/fields/DateRangeField.tsx b/react-openapi/components/fields/DateRangeField.tsx new file mode 100644 index 0000000..b8458f2 --- /dev/null +++ b/react-openapi/components/fields/DateRangeField.tsx @@ -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 ( + + onChange({ ...rangeVal, start: e.target.value || undefined })} + InputLabelProps={{ shrink: true }} + sx={{ width: 170 }} + disabled={disabled} + /> + onChange({ ...rangeVal, end: e.target.value || undefined })} + InputLabelProps={{ shrink: true }} + sx={{ width: 170 }} + disabled={disabled} + /> + + ); +} diff --git a/react-openapi/components/fields/DefaultFieldComponents.ts b/react-openapi/components/fields/DefaultFieldComponents.ts new file mode 100644 index 0000000..c124dba --- /dev/null +++ b/react-openapi/components/fields/DefaultFieldComponents.ts @@ -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, +}; diff --git a/react-openapi/components/fields/EnumField.tsx b/react-openapi/components/fields/EnumField.tsx new file mode 100644 index 0000000..0633f8a --- /dev/null +++ b/react-openapi/components/fields/EnumField.tsx @@ -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 ( + + {field.label} + + + ); +} diff --git a/react-openapi/components/fields/FallbackField.tsx b/react-openapi/components/fields/FallbackField.tsx new file mode 100644 index 0000000..f5d6dd8 --- /dev/null +++ b/react-openapi/components/fields/FallbackField.tsx @@ -0,0 +1,13 @@ +import { TextField } from '@mui/material'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function FallbackField({ field, value }: FieldComponentProps) { + return ( + + ); +} diff --git a/react-openapi/components/fields/FormField.tsx b/react-openapi/components/fields/FormField.tsx index b1915b6..ff06784 100644 --- a/react-openapi/components/fields/FormField.tsx +++ b/react-openapi/components/fields/FormField.tsx @@ -1,30 +1,19 @@ import * as React from 'react'; -import { - TextField, - FormControl, - InputLabel, - Select, - MenuItem, - FormControlLabel, - Checkbox, - Typography, - Box, - Divider, -} from '@mui/material'; import { ResourceField } from '../../types/config'; -import { getFieldOptions } from '../../utils/options'; -import ImageUploadField from './ImageUploadField'; +import { FieldComponentProps, FieldComponents } from '../../types/overrides'; +import ObjectField from './ObjectField'; -interface FormFieldProps { +export interface FormFieldProps { name: string; field: ResourceField; value: any; onChange: (val: any) => void; disabled?: boolean; - uploadFile: (file: File) => Promise; - uploading: boolean; - baseUrl: string; - relationDataMap?: Record; // Map of relation name to data array + uploadFile?: (file: File) => Promise; + uploading?: boolean; + baseUrl?: string; + relationDataMap?: Record; + components: FieldComponents; } export default function FormField({ @@ -37,190 +26,60 @@ export default function FormField({ uploading, baseUrl, relationDataMap = {}, + components, }: FormFieldProps) { - const label = field.label; + const fieldProps: FieldComponentProps = { + name, + field, + value, + onChange, + disabled, + baseUrl, + relationDataMap, + uploadFile, + uploading, + }; - // 1. Recursive Rendering for Objects (Not Relations) + const childComponents = components; + + // 1. Object (recursive) - requires parent FormField for recursion if (field.type === 'object' && field.schema && !field.relation) { - return ( - - - {label} - - - {Object.entries(field.schema).map(([subKey, subField]) => ( - { - const updated = { ...(value || {}), [subKey]: newVal }; - onChange(updated); - }} - disabled={disabled} - uploadFile={uploadFile} - uploading={uploading} - baseUrl={baseUrl} - relationDataMap={relationDataMap} - /> - ))} - - + const renderChild = (childProps: FieldComponentProps) => ( + ); + return ; } - // 2. Relation Handling (Select / Multi-Select) - if (field.relation && relationDataMap[field.relation]) { - const relationData = relationDataMap[field.relation].data; - const isArrayRelation = field.type === 'array'; - const options = getFieldOptions(field, relationData); - const keyField = field.enumOption?.key ?? 'id'; - - // Normalize value: API returns whole objects on GET, but form uses key strings - const normalizedValue = (() => { - if (isArrayRelation && Array.isArray(value)) { - return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v))); - } - if (value != null && typeof value === 'object') { - return String(value[keyField] ?? ''); - } - return value ?? (isArrayRelation ? [] : ""); - })(); - - return ( - - {label} - - - ); - } - - // 3. Image Handling + // 2. Image if (field.type === 'image') { - return ( - { - const url = await uploadFile(file); - if (url) onChange(url); - }} - uploading={uploading} - baseUrl={baseUrl} - disabled={disabled} - /> - ); + const ImageField = components.image; + if (!ImageField) return null; + return ; } - // 4. Boolean Handling - if (field.type === 'boolean') { - return ( - onChange(e.target.checked)} - disabled={disabled} - /> - } - label={label} - /> - ); + // 3. Relation + if (field.relation && relationDataMap[field.relation]) { + const RelationFieldComp = components.relation; + if (!RelationFieldComp) return null; + return ; } - // 5. Enum Handling - if (field.type === 'enum') { - const options = getFieldOptions(field); - return ( - - {label} - - - ); + // 4. Lookup by field type + const Component = components[field.type] || components.default; + if (Component) { + return ; } - // 6. Common Text Fields - if (field.type === 'datetime' || field.type === 'date') { - return ( - onChange(e.target.value)} - disabled={disabled} - required={field.required} - /> - ); - } - - if (field.type === 'markdown' || field.type === 'string') { - return ( - onChange(e.target.value)} - disabled={disabled} - required={field.required} - /> - ); - } - - if (field.type === 'number') { - return ( - onChange(e.target.value === '' ? '' : Number(e.target.value))} - disabled={disabled} - required={field.required} - /> - ); - } - - return ( - - ); + return null; } diff --git a/react-openapi/components/fields/NumberField.tsx b/react-openapi/components/fields/NumberField.tsx new file mode 100644 index 0000000..677bf1a --- /dev/null +++ b/react-openapi/components/fields/NumberField.tsx @@ -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 ( + onChange(e.target.value === '' ? '' : Number(e.target.value))} + disabled={disabled} + required={field.required} + /> + ); +} diff --git a/react-openapi/components/fields/NumberRangeField.tsx b/react-openapi/components/fields/NumberRangeField.tsx new file mode 100644 index 0000000..ecaa36f --- /dev/null +++ b/react-openapi/components/fields/NumberRangeField.tsx @@ -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 ( + + onChange({ ...rangeVal, min: e.target.value || undefined })} + sx={{ width: 100 }} + disabled={disabled} + /> + onChange({ ...rangeVal, max: e.target.value || undefined })} + sx={{ width: 100 }} + disabled={disabled} + /> + + ); +} diff --git a/react-openapi/components/fields/ObjectField.tsx b/react-openapi/components/fields/ObjectField.tsx new file mode 100644 index 0000000..19b7dc1 --- /dev/null +++ b/react-openapi/components/fields/ObjectField.tsx @@ -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 ( + + + {field.label} + + + {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, + }) + )} + + + ); +} diff --git a/react-openapi/components/fields/RelationField.tsx b/react-openapi/components/fields/RelationField.tsx new file mode 100644 index 0000000..0c28a02 --- /dev/null +++ b/react-openapi/components/fields/RelationField.tsx @@ -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 ( + + {field.label} + + + ); +} diff --git a/react-openapi/components/fields/TextField.tsx b/react-openapi/components/fields/TextField.tsx new file mode 100644 index 0000000..1dd6df0 --- /dev/null +++ b/react-openapi/components/fields/TextField.tsx @@ -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 ( + onChange(e.target.value)} + disabled={disabled} + required={field.required} + /> + ); +} diff --git a/react-openapi/components/fields/index.ts b/react-openapi/components/fields/index.ts new file mode 100644 index 0000000..9baa425 --- /dev/null +++ b/react-openapi/components/fields/index.ts @@ -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'; diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index f2a2a21..5f0bf28 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -1,28 +1,44 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; +import * as React from "react"; import { api } from "../api/client"; import { ResourceConfig } from "../types/config"; import { ConfigContext } from "../providers/ConfigContext"; -import * as React from "react"; +import { FieldComponents, FieldComponentProps } from "../types/overrides"; +import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents"; +import FormField from "../components/fields/FormField"; +import GenericForm from "../components/GenericForm"; -export function useResource(config: ResourceConfig | undefined) { +function wrapFormField(merged: FieldComponents) { + return (props: Omit, 'components'>) => + React.createElement(FormField, { ...props, components: merged }); +} + +function wrapGenericForm(merged: FieldComponents) { + return (props: Omit, 'fieldComponents'>) => + React.createElement(GenericForm, { ...props, fieldComponents: merged }); +} + +export function useResource(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) { const queryClient = useQueryClient(); - - // Return empty/disabled hooks if config is missing + const { name = '', endpoint = '', primaryKey = 'id' } = config || {}; + const mergedComponents = React.useMemo( + () => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined, + [options?.fieldComponents], + ); + // --- READ ALL --- - const useList = (params?: any) => + const useList = (params?: any) => useQuery({ queryKey: [name, "list", params], queryFn: async () => { if (!endpoint) return { data: [], total: 0 }; - console.log('params:', params); - // @ts-ignore const res = await api.get(endpoint, { params }); const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; - return { - data: res.data, - total: isNaN(total as any) ? undefined : total + return { + data: res.data, + total: isNaN(total as any) ? undefined : total }; }, enabled: !!endpoint, @@ -35,7 +51,6 @@ export function useResource(config: ResourceConfig | undefined) { queryKey: [name, "detail", id, params], queryFn: async () => { if (!id || !endpoint) return null; - // @ts-ignore const res = await api.get(`${endpoint}/${id}`, params ? { params } : undefined); return res.data; }, @@ -47,7 +62,6 @@ export function useResource(config: ResourceConfig | undefined) { useMutation({ mutationFn: async (data: Partial) => { if (!endpoint) throw new Error("Endpoint not defined"); - // @ts-ignore const res = await api.post(endpoint, data); return res.data; }, @@ -61,12 +75,10 @@ export function useResource(config: ResourceConfig | undefined) { useMutation({ mutationFn: async ({ id, data }: { id: string; data: Partial }) => { if (!endpoint) throw new Error("Endpoint not defined"); - // @ts-ignore const res = await api.put(`${endpoint}/${id}`, data); return res.data; }, - onSuccess: (updatedItem) => { - // @ts-ignore + onSuccess: (updatedItem: any) => { const id = updatedItem[primaryKey]; queryClient.invalidateQueries({ queryKey: [name, "list"] }); queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); @@ -78,15 +90,13 @@ export function useResource(config: ResourceConfig | undefined) { useMutation({ mutationFn: async ({ id, data }: { id: string; data: Partial }) => { if (!endpoint) throw new Error("Endpoint not defined"); - // @ts-ignore const res = await api.patch(`${endpoint}/${id}`, data); return res.data; }, - onSuccess: (updatedItem) => { - // @ts-ignore - const id = updatedItem[primaryKey]; + onSuccess: (updatedItem: any) => { + const listId = updatedItem[primaryKey]; queryClient.invalidateQueries({ queryKey: [name, "list"] }); - queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); + queryClient.invalidateQueries({ queryKey: [name, "detail", listId] }); }, }); @@ -108,12 +118,11 @@ export function useResource(config: ResourceConfig | undefined) { queryKey: [name, "list", params], queryFn: async () => { if (!endpoint) return { data: [], total: 0 }; - // @ts-ignore const res = await api.get(endpoint, { params }); const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; - return { - data: res.data, - total: isNaN(total as any) ? undefined : total + return { + data: res.data, + total: isNaN(total as any) ? undefined : total }; }, enabled: !!endpoint, @@ -125,7 +134,6 @@ export function useResource(config: ResourceConfig | undefined) { queryKey: [name, "me"], queryFn: async () => { if (!endpoint) return null; - // @ts-ignore const res = await api.get(`${endpoint}/me`); return res.data; }, @@ -137,7 +145,6 @@ export function useResource(config: ResourceConfig | undefined) { useMutation({ mutationFn: async (data: Partial) => { if (!endpoint) throw new Error("Endpoint not defined"); - // @ts-ignore const res = await api.put(`${endpoint}/me`, data); return res.data; }, @@ -147,6 +154,15 @@ export function useResource(config: ResourceConfig | undefined) { }, }); + const components = React.useMemo(() => { + if (!mergedComponents) return undefined; + return { + ...mergedComponents, + FormField: wrapFormField(mergedComponents), + GenericForm: wrapGenericForm(mergedComponents), + }; + }, [mergedComponents]); + return { useList, useRead, @@ -157,12 +173,12 @@ export function useResource(config: ResourceConfig | undefined) { useUpdateMe, useDelete, getListQueryOptions, + components, }; } -export function useResourceByName(name: string) { +export function useResourceByName(name: string, options?: { fieldComponents: FieldComponents }) { const config = React.useContext(ConfigContext); const resourceConfig = config?.resources.find((r) => r.name === name); - return useResource(resourceConfig); + return useResource(resourceConfig, options); } - diff --git a/react-openapi/index.ts b/react-openapi/index.ts index e4012f3..6f60374 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -2,7 +2,12 @@ export { default as Admin } from "./Admin"; export { api, auth, initializeApiClients } from "./api/client"; export { getAppConfig } from "./config"; export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; +export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides"; export { AppProvider } from "./providers/AppProvider"; export { ConfigContext, useConfig } from "./providers/ConfigContext"; export { useResource, useResourceByName } from "./hooks/useResource"; -export { default as FilterBar } from "./components/FilterBar"; +export { default as FilterBar, FilterAutocomplete } from "./components/FilterBar"; +export { default as EnhancedTable } from "./components/EnhancedTable"; +export { default as GenericForm } from "./components/GenericForm"; +export { default as ResourceView } from "./components/ResourceView"; +export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields"; diff --git a/react-openapi/types/config.ts b/react-openapi/types/config.ts index 347ab7e..cbea504 100644 --- a/react-openapi/types/config.ts +++ b/react-openapi/types/config.ts @@ -21,13 +21,13 @@ export interface EnumOption { } export interface ResourceField { + displayFormat: string; type: FieldType; label: string; required?: boolean; options?: string[]; readOnly?: boolean; schema?: Record; - displayField?: string | string[]; formatter?: (value: any) => string; relation?: string; filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 89267be..67a9ae2 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -1,14 +1,19 @@ +import { ResourceField, FieldType } from './config'; + export interface EnumOption { key: string; value: string; } export interface FieldOverride { - displayField?: string | string[]; + displayFormat?: string; display?: boolean; formatter?: (value: any) => string; filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; enumLabels?: Record; + // New optional properties to support custom config extensions + path?: string; + refers?: string; } export interface ResourceOverride { @@ -20,4 +25,62 @@ export interface ResourceOverride { fields?: string[]; }; enumOption?: EnumOption; + // New optional property for reference‑type resources + referenceOptions?: { + enumOption?: EnumOption; + autoComplete?: boolean; + prefetch?: boolean; + }; } + +export interface FieldComponentProps { + name: string; + field: ResourceField; + value: any; + onChange: (val: any) => void; + disabled?: boolean; + error?: string; + baseUrl?: string; + relationDataMap?: Record; + uploadFile?: (file: File) => Promise; + uploading?: boolean; +} + +export type FieldComponent = React.ComponentType; + +export type FieldComponents = Partial> & { + relation?: FieldComponent; + image?: FieldComponent; + default?: FieldComponent; + dateRange?: FieldComponent; + numberRange?: FieldComponent; + FormField?: React.ComponentType; + GenericForm?: React.ComponentType; +}; + +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; + +export interface EnhancedTableComponents { + cellRenderers?: Partial>; +} + +export interface FilterBarComponents { + filterInputs?: Record void; + options: string[]; + }>>; +} + +export type { FieldType }; diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts index 355c5f6..03cf6c0 100644 --- a/react-openapi/utils/openapi_loader.ts +++ b/react-openapi/utils/openapi_loader.ts @@ -65,6 +65,7 @@ function parseSchemaFields( const fields: Record = {}; const { properties, required } = mergeProperties(schema); const overrides = configuration[resourceName]?.fields || {}; + console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions']) for (const [key, prop] of Object.entries(properties) as [string, any]) { // Resolve oneOf/anyOf by merging all branch properties @@ -76,6 +77,12 @@ function parseSchemaFields( } const type = mapOpenApiType(resolvedProp); + if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) { + throw new Error( + `OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` + + `Add an "enum" array with at least one value to the OpenAPI schema definition.` + ); + } const override = overrides[key]; // Explicitly skip 'id' as it's the primary key and handled elsewhere @@ -108,24 +115,25 @@ function parseSchemaFields( if (relation) { fields[key].relation = relation; - // Propagate enumOption from target resource config, or derive from target schema - const explicitEnumOption = configuration[relation]?.enumOption; - if (explicitEnumOption) { - fields[key].enumOption = explicitEnumOption; - } else { - const targetProps = targetSchema.properties || {}; - const valueField = Object.entries(targetProps).find( - ([name, p]: [string, any]) => name !== 'id' && p.type === 'string' - )?.[0]; - fields[key].enumOption = { - key: 'id', - value: valueField ?? 'id', - }; - } + // Propagate enumOption from target resource config, or derive from target schema + const explicitEnumOption = configuration[relation].referenceOptions.enumOption; + console.log('if relation configuration...', configuration['accounts']['referenceOptions']) + if (explicitEnumOption) { + fields[key].enumOption = explicitEnumOption; + } else { + // No explicit enumOption supplied – this is a configuration error. + // We abort loading so the problem is visible immediately. + throw new Error( + `Missing enumOption for relation "${relation}" on field "${key}". ` + + `Define referenceOptions.enumOption in the configuration for resource "${relation}".` + ); + } + } // Recursively parse nested objects (only if not a relation) if (fields[key].type === "object" && resolvedProp.properties && !relation) { + console.log('recursive configuration...', configuration['accounts']['referenceOptions']) fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration); } } @@ -137,6 +145,7 @@ function parseSchemaFields( * Scans paths to identify resources and their basic configuration */ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record = {}, profileConfiguration: any = {}): Promise { + console.log('init configuration...', configuration['accounts']['referenceOptions']) // Use SwaggerParser to dereference the spec. // Dereferencing preserves object identity for $ref targets. const api = await SwaggerParser.dereference( @@ -192,6 +201,7 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco const label = name.charAt(0).toUpperCase() + name.slice(1, -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 resourceOverride = configuration[name] || {}; @@ -216,8 +226,9 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco // Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.) const enums: Record = {}; - if (api.components?.schemas) { - for (const [name, schema] of Object.entries(api.components.schemas) as [string, any]) { + const apiDoc = api as any; + if (apiDoc.components?.schemas) { + for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) { if (schema.enum) { enums[name] = schema.enum; } diff --git a/react-openapi/utils/options.ts b/react-openapi/utils/options.ts index 13ab92a..f995ab1 100644 --- a/react-openapi/utils/options.ts +++ b/react-openapi/utils/options.ts @@ -17,7 +17,13 @@ export function getFieldOptions(field: ResourceField, relationData?: any[]): Sel if (field.relation) { 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 => ({ key: String(item[enumOption.key] ?? ''), diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index b55ad77..03e6678 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -26,8 +26,6 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; import { - useFetchRequest, - useUpdateFetchRequest, useFetchRequestAmbiguities, useResolveAmbiguity, } from "./features/fetch-requests"; @@ -37,7 +35,7 @@ import type { ProgressMessage, } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; -import { useConfig } from "../react-openapi"; +import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; const statusColors: Record = { pending: "default", @@ -148,8 +146,9 @@ export default function FetchRequestDetail() { const navigate = useNavigate(); const config = useConfig(); - const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!); - const updateMutation = useUpdateFetchRequest(); + const { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); + const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!); + const updateMutation = usePatch(); const resolveMutation = useResolveAmbiguity(); const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index 2abfc6f..3895c2f 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -4,16 +4,9 @@ import { Container, Paper, Typography, - TextField, Button, ToggleButtonGroup, ToggleButton, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, Chip, IconButton, CircularProgress, @@ -25,6 +18,7 @@ import { DialogContentText, DialogActions, Tooltip, + TextField, Select, MenuItem, InputLabel, @@ -43,10 +37,6 @@ import ScheduleIcon from "@mui/icons-material/Schedule"; import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { - useFetchRequestsList, - useCreateFetchRequest, - useUpdateFetchRequest, - useDeleteFetchRequest, useUploadFile, } from "./features/fetch-requests"; import type { @@ -57,7 +47,8 @@ import type { } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { useNavigate } from "react-router-dom"; -import { useResourceByName, useConfig } from "../react-openapi"; +import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; +import type { ResourceField } from "../react-openapi"; const statusColors: Record = { pending: "default", @@ -85,14 +76,14 @@ function formatDate(iso: string) { } function formatDateRange(start?: string, end?: string) { - if (!start && !end) return "—"; + if (!start && !end) return "\u2014"; const s = start ? new Date(start).toLocaleDateString() : "?"; const e = end ? new Date(end).toLocaleDateString() : "?"; - return `${s} → ${e}`; + return `${s} \u2192 ${e}`; } function shortId(fp: string) { - return fp.length > 8 ? fp.slice(0, 8) + "…" : fp; + return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp; } export default function FetchRequests() { @@ -116,11 +107,13 @@ export default function FetchRequests() { const [accountFilter, setAccountFilter] = React.useState(""); const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); - const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({ + const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); + const { data: listData, isLoading, isFetching, refetch } = useList({ ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), ...(accountFilter ? { account_name: accountFilter } : {}), ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), }); + const { useList: useAccountsList } = useResourceByName("accounts"); const { data: accountsData } = useAccountsList(); const accountOptions: string[] = React.useMemo(() => { @@ -129,11 +122,15 @@ export default function FetchRequests() { const config = useConfig(); const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests"); - const formatOptions: string[] = fetchRes?.fields?.source?.schema?.format?.options as string[] ?? []; + const formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format; + const formatOptions: string[] = formatField?.options ?? []; + const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date; + const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date; + const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username; - const createMutation = useCreateFetchRequest(); - const updateMutation = useUpdateFetchRequest(); - const deleteMutation = useDeleteFetchRequest(); + const createMutation = useCreate(); + const updateMutation = usePatch(); + const deleteMutation = useDelete(); const uploadMutation = useUploadFile(); const requests = listData?.data ?? []; @@ -178,7 +175,7 @@ export default function FetchRequests() { navigate(`/fetch-requests/${result.id}`); } catch (err: any) { if (err?.response?.status === 409) { - setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" }); + setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" }); } else { setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" }); } @@ -265,25 +262,43 @@ export default function FetchRequests() { Uploaded as: {uploadedPath} )} - - Format - - + {formatField && components?.FormField ? ( + + ) : ( + + Format + + + )} ) : ( <> - - Format - - + {formatField && components?.FormField ? ( + + ) : ( + + Format + + + )} setFromEmail(e.target.value)} size="small" /> setSubject(e.target.value)} size="small" /> 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" } }} /> - setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" /> + {payorUsernameField && components?.FormField ? ( + + ) : ( + setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" /> + )} - setStartDate(e.target.value)} - size="small" - InputLabelProps={{ shrink: true }} - inputProps={{ max: new Date().toISOString().split("T")[0] }} - sx={{ flex: 1 }} - /> - setEndDate(e.target.value)} - size="small" - InputLabelProps={{ shrink: true }} - inputProps={{ max: new Date().toISOString().split("T")[0] }} - sx={{ flex: 1 }} - /> + {startDateField && components?.date ? ( + + + + ) : ( + setStartDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + inputProps={{ max: new Date().toISOString().split("T")[0] }} + sx={{ flex: 1 }} + /> + )} + {endDateField && components?.date ? ( + + + + ) : ( + setEndDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + inputProps={{ max: new Date().toISOString().split("T")[0] }} + sx={{ flex: 1 }} + /> + )}