From 65bbb305e6f807bebef84f40691ea94018c28745 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 5 Jun 2026 03:13:00 +0530 Subject: [PATCH] common fields --- react-openapi/Admin.tsx | 51 ++- react-openapi/components/EnhancedTable.tsx | 17 +- react-openapi/components/FilterBar.tsx | 15 +- react-openapi/components/GenericForm.tsx | 4 +- react-openapi/components/ProfileView.tsx | 8 +- react-openapi/components/ResourceView.tsx | 8 +- .../fields/DefaultFieldComponents.ts | 2 + .../components/fields/FallbackField.tsx | 13 + react-openapi/components/fields/FormField.tsx | 40 +- react-openapi/components/fields/index.ts | 1 + react-openapi/hooks/useResource.ts | 71 +-- react-openapi/index.ts | 20 +- react-openapi/types/overrides.ts | 28 ++ src/FetchRequestDetail.tsx | 9 +- src/FetchRequests.tsx | 407 ++++++++++-------- src/ReportSnapshots.tsx | 259 +++++++---- src/features/fetch-requests/index.ts | 5 - src/features/report-snapshots/index.ts | 5 - src/main.jsx | 4 +- 19 files changed, 576 insertions(+), 391 deletions(-) create mode 100644 react-openapi/components/fields/FallbackField.tsx diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx index 6fc16c4..c50c191 100644 --- a/react-openapi/Admin.tsx +++ b/react-openapi/Admin.tsx @@ -16,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(); @@ -32,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, fieldComponents }: { basePath: string; fieldComponents?: FieldComponents }) { +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(); @@ -74,10 +80,10 @@ function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldCompon if (!currentUser) { return ( - {}} // Disable registration for Admin + register={async () => {}} loading={loading} error={error} onSwitchMode={() => {}} @@ -88,7 +94,7 @@ function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldCompon } return ( - navigate(`/admin/${name}`)} @@ -102,11 +108,11 @@ function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldCompon } /> } /> - + ); } -function ResourceRouteWrapper({ fieldComponents }: { fieldComponents?: FieldComponents }) { +function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) { const { resourceName } = useParams(); const config = React.useContext(ConfigContext); const selectedResource = config?.resources.find((r) => r.name === resourceName); @@ -116,14 +122,25 @@ function ResourceRouteWrapper({ fieldComponents }: { fieldComponents?: FieldComp 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; + fieldComponents: FieldComponents; + Dashboard?: React.ComponentType<{ basePath: string }>; + Layout?: React.ComponentType; + LoginPage?: React.ComponentType; } -export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents = {} }: 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); @@ -153,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/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 067e3a2..5b28b54 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') { @@ -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..f751fe4 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 } from "../types/overrides"; import { getFieldOptions, resolveTemplate } from "../utils/options"; -function FilterAutocomplete({ +export function FilterAutocomplete({ options, value, label, @@ -160,8 +161,14 @@ function renderFilterInput( field: ResourceField, options: string[], value: any, - onChange: (key: string, val: any) => void + onChange: (key: string, val: any) => void, + components?: FilterBarComponents, ) { + const CustomInput = components?.filterInputs?.[fieldName]; + if (CustomInput) { + return onChange("value", val)} options={options} />; + } + const filterType = field.filterType; if (filterType === "number-range") { @@ -208,6 +215,7 @@ export interface FilterBarProps { appliedValues: Record; onApply: (values: Record) => void; onClear: () => void; + components?: FilterBarComponents; } export default function FilterBar({ @@ -217,6 +225,7 @@ export default function FilterBar({ appliedValues, onApply, onClear, + components: filterComponents, }: FilterBarProps) { const [open, setOpen] = React.useState(false); const [draft, setDraft] = React.useState>(() => ({ ...appliedValues })); @@ -294,7 +303,7 @@ export default function FilterBar({ {field.label} {renderFilterInput(fieldName, field, options, raw, (key, val) => - updateDraft(fieldName, key, val) + updateDraft(fieldName, key, val), filterComponents )} ); diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx index fe7d1e6..9a51076 100644 --- a/react-openapi/components/GenericForm.tsx +++ b/react-openapi/components/GenericForm.tsx @@ -22,7 +22,7 @@ interface GenericFormProps { loading?: boolean; readOnly?: boolean; onEditClick?: () => void; - fieldComponents?: FieldComponents; + fieldComponents: FieldComponents; } export default function GenericForm({ @@ -57,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, 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 568bf8f..f3930bf 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -5,7 +5,6 @@ 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'; @@ -13,7 +12,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'; interface ResourceViewProps { config: ResourceConfig; onNavigateToResource?: (resourceName: string, id: string) => void; - fieldComponents?: FieldComponents; + fieldComponents: FieldComponents; } import { GridPaginationModel } from '@mui/x-data-grid'; @@ -117,7 +116,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon 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 }; @@ -202,7 +201,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon ) : ( - navigate(`/admin/${config.name}/edit/${id}`)} - fieldComponents={fieldComponents} /> )} diff --git a/react-openapi/components/fields/DefaultFieldComponents.ts b/react-openapi/components/fields/DefaultFieldComponents.ts index 48c591a..1c783bd 100644 --- a/react-openapi/components/fields/DefaultFieldComponents.ts +++ b/react-openapi/components/fields/DefaultFieldComponents.ts @@ -6,6 +6,7 @@ import DateField from './DateField'; import EnumField from './EnumField'; import RelationField from './RelationField'; import ImageUploadField from './ImageUploadField'; +import FallbackField from './FallbackField'; export const defaultFieldComponents: FieldComponents = { string: TextFieldEntry, @@ -17,4 +18,5 @@ export const defaultFieldComponents: FieldComponents = { enum: EnumField, image: ImageUploadField, relation: RelationField, + default: FallbackField, }; 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 976338a..ce9750f 100644 --- a/react-openapi/components/fields/FormField.tsx +++ b/react-openapi/components/fields/FormField.tsx @@ -1,12 +1,9 @@ import * as React from 'react'; -import { TextField as MuiTextField } from '@mui/material'; import { ResourceField } from '../../types/config'; import { FieldComponentProps, FieldComponents } from '../../types/overrides'; -import { defaultFieldComponents } from './DefaultFieldComponents'; import ObjectField from './ObjectField'; -import ImageUploadField from './ImageUploadField'; -interface FormFieldProps { +export interface FormFieldProps { name: string; field: ResourceField; value: any; @@ -16,18 +13,7 @@ interface FormFieldProps { uploading: boolean; baseUrl: string; relationDataMap?: Record; - components?: FieldComponents; -} - -function FallbackField({ field, value }: FieldComponentProps) { - return ( - - ); + components: FieldComponents; } export default function FormField({ @@ -40,13 +26,8 @@ export default function FormField({ uploading, baseUrl, relationDataMap = {}, - components: componentsProp, + components, }: FormFieldProps) { - const components = React.useMemo( - () => ({ ...defaultFieldComponents, ...componentsProp }), - [componentsProp], - ); - const fieldProps: FieldComponentProps = { name, field, @@ -59,6 +40,8 @@ export default function FormField({ uploading, }; + const childComponents = components; + // 1. Object (recursive) - requires parent FormField for recursion if (field.type === 'object' && field.schema && !field.relation) { const renderChild = (childProps: FieldComponentProps) => ( @@ -72,7 +55,7 @@ export default function FormField({ uploading={childProps.uploading!} baseUrl={childProps.baseUrl!} relationDataMap={childProps.relationDataMap} - components={componentsProp} + components={components} /> ); return ; @@ -80,22 +63,23 @@ export default function FormField({ // 2. Image if (field.type === 'image') { - const ImageField = components.image || ImageUploadField; + const ImageField = components.image; + if (!ImageField) return null; return ; } // 3. Relation if (field.relation && relationDataMap[field.relation]) { - const RelationFieldComp = components.relation || defaultFieldComponents.relation!; + const RelationFieldComp = components.relation; + if (!RelationFieldComp) return null; return ; } // 4. Lookup by field type - const Component = components[field.type]; + const Component = components[field.type] || components.default; if (Component) { return ; } - // 5. Fallback for unknown types - return ; + return null; } diff --git a/react-openapi/components/fields/index.ts b/react-openapi/components/fields/index.ts index 9af5170..8bc1a85 100644 --- a/react-openapi/components/fields/index.ts +++ b/react-openapi/components/fields/index.ts @@ -7,5 +7,6 @@ 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 { defaultFieldComponents } from './DefaultFieldComponents'; export type { ObjectFieldProps } from './ObjectField'; diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index 7805654..9dae5e6 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -1,31 +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 } from "../types/overrides"; +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, options?: { fieldComponents?: FieldComponents }) { +function wrapFormField(merged: FieldComponents) { + return (props: Omit, 'components'>) => + ; +} + +function wrapGenericForm(merged: FieldComponents) { + return (props: Omit, 'fieldComponents'>) => + ; +} + +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, @@ -38,7 +51,6 @@ export function useResource(config: ResourceConfig | undefined, options 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; }, @@ -50,7 +62,6 @@ export function useResource(config: ResourceConfig | undefined, options 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; }, @@ -64,12 +75,10 @@ export function useResource(config: ResourceConfig | undefined, options 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 const id = updatedItem[primaryKey]; queryClient.invalidateQueries({ queryKey: [name, "list"] }); queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); @@ -81,15 +90,13 @@ export function useResource(config: ResourceConfig | undefined, options 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]; + const listId = updatedItem[primaryKey]; queryClient.invalidateQueries({ queryKey: [name, "list"] }); - queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); + queryClient.invalidateQueries({ queryKey: [name, "detail", listId] }); }, }); @@ -111,12 +118,11 @@ export function useResource(config: ResourceConfig | undefined, options 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, @@ -128,7 +134,6 @@ export function useResource(config: ResourceConfig | undefined, options queryKey: [name, "me"], queryFn: async () => { if (!endpoint) return null; - // @ts-ignore const res = await api.get(`${endpoint}/me`); return res.data; }, @@ -140,7 +145,6 @@ export function useResource(config: ResourceConfig | undefined, options 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; }, @@ -150,10 +154,14 @@ export function useResource(config: ResourceConfig | undefined, options }, }); - const components = { - ...defaultFieldComponents, - ...options?.fieldComponents, - }; + const components = React.useMemo(() => { + if (!mergedComponents) return undefined; + return { + ...mergedComponents, + FormField: wrapFormField(mergedComponents), + GenericForm: wrapGenericForm(mergedComponents), + }; + }, [mergedComponents]); return { useList, @@ -169,9 +177,8 @@ export function useResource(config: ResourceConfig | undefined, options }; } -export function useResourceByName(name: string, options?: { fieldComponents?: FieldComponents }) { +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, options); } - diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 604a90d..6f60374 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -2,20 +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 } from "./types/overrides"; +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 { - defaultFieldComponents, - FormField, - TextField, - NumberField, - BooleanField, - DateField, - EnumField, - RelationField, - ObjectField, - ImageUploadField, -} from "./components/fields"; +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/overrides.ts b/react-openapi/types/overrides.ts index b54907c..f376397 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -42,4 +42,32 @@ export type FieldComponent = React.ComponentType; export type FieldComponents = Partial> & { relation?: FieldComponent; image?: FieldComponent; + default?: FieldComponent; }; + +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/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..2c6f006 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?.fields?.format; + const formatOptions: string[] = formatField?.options ?? formatField?.schema?.options as string[] ?? []; + 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?.DateField ? ( + + + + ) : ( + setStartDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + inputProps={{ max: new Date().toISOString().split("T")[0] }} + sx={{ flex: 1 }} + /> + )} + {endDateField && components?.DateField ? ( + + + + ) : ( + setEndDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + inputProps={{ max: new Date().toISOString().split("T")[0] }} + sx={{ flex: 1 }} + /> + )}