Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. #11

Merged
aetos merged 15 commits from common-fields into main 2026-06-07 12:35:53 +00:00
19 changed files with 576 additions and 391 deletions
Showing only changes of commit 65bbb305e6 - Show all commits

View File

@@ -16,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();
@@ -32,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",
@@ -62,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, fieldComponents }: { basePath: string; fieldComponents?: FieldComponents }) { 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();
@@ -74,10 +80,10 @@ function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldCompon
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={() => {}}
@@ -88,7 +94,7 @@ function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldCompon
} }
return ( return (
<AdminLayout <Layout
username={currentUser.username} username={currentUser.username}
onLogout={logout} onLogout={logout}
onSelectResource={(name) => navigate(`/admin/${name}`)} onSelectResource={(name) => navigate(`/admin/${name}`)}
@@ -102,11 +108,11 @@ function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldCompon
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
</Routes> </Routes>
</AdminLayout> </Layout>
); );
} }
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents?: FieldComponents }) { 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);
@@ -116,14 +122,25 @@ function ResourceRouteWrapper({ fieldComponents }: { fieldComponents?: FieldComp
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />; 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; fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
} }
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 existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig); const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
@@ -153,16 +170,14 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
const content = ( const content = (
<UploadProvider> <UploadProvider>
<AdminApp basePath={basePath} fieldComponents={fieldComponents} /> <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}

View File

@@ -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') {
@@ -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;

View File

@@ -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 } 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,
@@ -160,8 +161,14 @@ 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,
) { ) {
const CustomInput = components?.filterInputs?.[fieldName];
if (CustomInput) {
return <CustomInput field={field} value={value} onChange={(val) => onChange("value", val)} options={options} />;
}
const filterType = field.filterType; const filterType = field.filterType;
if (filterType === "number-range") { if (filterType === "number-range") {
@@ -208,6 +215,7 @@ 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;
} }
export default function FilterBar({ export default function FilterBar({
@@ -217,6 +225,7 @@ export default function FilterBar({
appliedValues, appliedValues,
onApply, onApply,
onClear, onClear,
components: filterComponents,
}: 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 }));
@@ -294,7 +303,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
)} )}
</Box> </Box>
); );

View File

@@ -22,7 +22,7 @@ interface GenericFormProps {
loading?: boolean; loading?: boolean;
readOnly?: boolean; readOnly?: boolean;
onEditClick?: () => void; onEditClick?: () => void;
fieldComponents?: FieldComponents; fieldComponents: FieldComponents;
} }
export default function GenericForm({ export default function GenericForm({
@@ -57,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,

View File

@@ -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>

View File

@@ -5,7 +5,6 @@ import type { ResourceField } from '../types/config';
import { FieldComponents } from '../types/overrides'; 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';
@@ -13,7 +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; fieldComponents: FieldComponents;
} }
import { GridPaginationModel } from '@mui/x-data-grid'; import { GridPaginationModel } from '@mui/x-data-grid';
@@ -117,7 +116,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({}); const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
const { useList, useRead, useCreate, useUpdate, useDelete } = 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: 10000 };
@@ -202,7 +201,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
</Box> </Box>
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
<GenericForm <components.GenericForm
config={config} config={config}
initialData={isCreate ? null : itemQuery.data} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
@@ -210,7 +209,6 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView} readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)} onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
fieldComponents={fieldComponents}
/> />
</Paper> </Paper>
)} )}

View File

@@ -6,6 +6,7 @@ import DateField from './DateField';
import EnumField from './EnumField'; import EnumField from './EnumField';
import RelationField from './RelationField'; import RelationField from './RelationField';
import ImageUploadField from './ImageUploadField'; import ImageUploadField from './ImageUploadField';
import FallbackField from './FallbackField';
export const defaultFieldComponents: FieldComponents = { export const defaultFieldComponents: FieldComponents = {
string: TextFieldEntry, string: TextFieldEntry,
@@ -17,4 +18,5 @@ export const defaultFieldComponents: FieldComponents = {
enum: EnumField, enum: EnumField,
image: ImageUploadField, image: ImageUploadField,
relation: RelationField, relation: RelationField,
default: FallbackField,
}; };

View 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
/>
);
}

View File

@@ -1,12 +1,9 @@
import * as React from 'react'; import * as React from 'react';
import { TextField as MuiTextField } from '@mui/material';
import { ResourceField } from '../../types/config'; import { ResourceField } from '../../types/config';
import { FieldComponentProps, FieldComponents } from '../../types/overrides'; import { FieldComponentProps, FieldComponents } from '../../types/overrides';
import { defaultFieldComponents } from './DefaultFieldComponents';
import ObjectField from './ObjectField'; import ObjectField from './ObjectField';
import ImageUploadField from './ImageUploadField';
interface FormFieldProps { export interface FormFieldProps {
name: string; name: string;
field: ResourceField; field: ResourceField;
value: any; value: any;
@@ -16,18 +13,7 @@ interface FormFieldProps {
uploading: boolean; uploading: boolean;
baseUrl: string; baseUrl: string;
relationDataMap?: Record<string, any[]>; relationDataMap?: Record<string, any[]>;
components?: FieldComponents; components: FieldComponents;
}
function FallbackField({ field, value }: FieldComponentProps) {
return (
<MuiTextField
fullWidth
label={field.label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
} }
export default function FormField({ export default function FormField({
@@ -40,13 +26,8 @@ export default function FormField({
uploading, uploading,
baseUrl, baseUrl,
relationDataMap = {}, relationDataMap = {},
components: componentsProp, components,
}: FormFieldProps) { }: FormFieldProps) {
const components = React.useMemo(
() => ({ ...defaultFieldComponents, ...componentsProp }),
[componentsProp],
);
const fieldProps: FieldComponentProps = { const fieldProps: FieldComponentProps = {
name, name,
field, field,
@@ -59,6 +40,8 @@ export default function FormField({
uploading, uploading,
}; };
const childComponents = components;
// 1. Object (recursive) - requires parent FormField for recursion // 1. Object (recursive) - requires parent FormField for recursion
if (field.type === 'object' && field.schema && !field.relation) { if (field.type === 'object' && field.schema && !field.relation) {
const renderChild = (childProps: FieldComponentProps) => ( const renderChild = (childProps: FieldComponentProps) => (
@@ -72,7 +55,7 @@ export default function FormField({
uploading={childProps.uploading!} uploading={childProps.uploading!}
baseUrl={childProps.baseUrl!} baseUrl={childProps.baseUrl!}
relationDataMap={childProps.relationDataMap} relationDataMap={childProps.relationDataMap}
components={componentsProp} components={components}
/> />
); );
return <ObjectField {...fieldProps} renderField={renderChild} />; return <ObjectField {...fieldProps} renderField={renderChild} />;
@@ -80,22 +63,23 @@ export default function FormField({
// 2. Image // 2. Image
if (field.type === 'image') { if (field.type === 'image') {
const ImageField = components.image || ImageUploadField; const ImageField = components.image;
if (!ImageField) return null;
return <ImageField {...fieldProps} />; return <ImageField {...fieldProps} />;
} }
// 3. Relation // 3. Relation
if (field.relation && relationDataMap[field.relation]) { if (field.relation && relationDataMap[field.relation]) {
const RelationFieldComp = components.relation || defaultFieldComponents.relation!; const RelationFieldComp = components.relation;
if (!RelationFieldComp) return null;
return <RelationFieldComp {...fieldProps} />; return <RelationFieldComp {...fieldProps} />;
} }
// 4. Lookup by field type // 4. Lookup by field type
const Component = components[field.type]; const Component = components[field.type] || components.default;
if (Component) { if (Component) {
return <Component {...fieldProps} />; return <Component {...fieldProps} />;
} }
// 5. Fallback for unknown types return null;
return <FallbackField {...fieldProps} />;
} }

View File

@@ -7,5 +7,6 @@ export { default as DateField } from './DateField';
export { default as EnumField } from './EnumField'; export { default as EnumField } from './EnumField';
export { default as RelationField } from './RelationField'; export { default as RelationField } from './RelationField';
export { default as ObjectField } from './ObjectField'; export { default as ObjectField } from './ObjectField';
export { default as FallbackField } from './FallbackField';
export { defaultFieldComponents } from './DefaultFieldComponents'; export { defaultFieldComponents } from './DefaultFieldComponents';
export type { ObjectFieldProps } from './ObjectField'; export type { ObjectFieldProps } from './ObjectField';

View File

@@ -1,26 +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 { FieldComponents } from "../types/overrides";
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents"; 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, options?: { fieldComponents?: FieldComponents }) { function wrapFormField(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof FormField>, 'components'>) =>
<FormField {...props} components={merged} />;
}
function wrapGenericForm(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
<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 {
@@ -38,7 +51,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "detail", id, params], queryKey: [name, "detail", id, params],
queryFn: async () => { queryFn: async () => {
if (!id || !endpoint) return null; if (!id || !endpoint) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined); const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined);
return res.data; return res.data;
}, },
@@ -50,7 +62,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.post<T>(endpoint, data); const res = await api.post<T>(endpoint, data);
return res.data; return res.data;
}, },
@@ -64,12 +75,10 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => { mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/${id}`, data); const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data; return res.data;
}, },
onSuccess: (updatedItem) => { onSuccess: (updatedItem) => {
// @ts-ignore
const id = updatedItem[primaryKey]; const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] }); queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
@@ -81,15 +90,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => { mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.patch<T>(`${endpoint}/${id}`, data); const res = await api.patch<T>(`${endpoint}/${id}`, data);
return res.data; return res.data;
}, },
onSuccess: (updatedItem) => { onSuccess: (updatedItem) => {
// @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] });
}, },
}); });
@@ -111,7 +118,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 }; if (!endpoint) return { data: [], total: 0 };
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return { return {
@@ -128,7 +134,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "me"], queryKey: [name, "me"],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return null; if (!endpoint) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/me`); const res = await api.get<T>(`${endpoint}/me`);
return res.data; return res.data;
}, },
@@ -140,7 +145,6 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/me`, data); const res = await api.put<T>(`${endpoint}/me`, data);
return res.data; return res.data;
}, },
@@ -150,10 +154,14 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
}, },
}); });
const components = { const components = React.useMemo(() => {
...defaultFieldComponents, if (!mergedComponents) return undefined;
...options?.fieldComponents, return {
}; ...mergedComponents,
FormField: wrapFormField(mergedComponents),
GenericForm: wrapGenericForm(mergedComponents),
};
}, [mergedComponents]);
return { return {
useList, useList,
@@ -169,9 +177,8 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
}; };
} }
export function useResourceByName<T = any>(name: string, options?: { fieldComponents?: FieldComponents }) { 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, options); return useResource<T>(resourceConfig, options);
} }

View File

@@ -2,20 +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 } from "./types/overrides"; 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 { export { default as EnhancedTable } from "./components/EnhancedTable";
defaultFieldComponents, export { default as GenericForm } from "./components/GenericForm";
FormField, export { default as ResourceView } from "./components/ResourceView";
TextField, export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields";
NumberField,
BooleanField,
DateField,
EnumField,
RelationField,
ObjectField,
ImageUploadField,
} from "./components/fields";

View File

@@ -42,4 +42,32 @@ export type FieldComponent = React.ComponentType<FieldComponentProps>;
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & { export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
relation?: FieldComponent; relation?: FieldComponent;
image?: 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<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 };

View File

@@ -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!);

View File

@@ -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?.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 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?.DateField ? (
label="Start Date" <Box sx={{ flex: 1 }}>
type="date" <components.DateField
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?.DateField ? (
<Box sx={{ flex: 1 }}>
<components.DateField
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
@@ -385,132 +431,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

View File

@@ -6,12 +6,6 @@ import {
Typography, Typography,
TextField, TextField,
Button, Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton, IconButton,
CircularProgress, CircularProgress,
Alert, Alert,
@@ -29,12 +23,22 @@ 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 +55,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 +95,7 @@ export default function ReportSnapshots() {
}; };
const resetForm = () => { const resetForm = () => {
setIgnoreSelf(false); setIgnoreSelf(true);
setStartDate(""); setStartDate("");
setEndDate(""); setEndDate("");
setMinAmount(""); setMinAmount("");
@@ -110,49 +125,102 @@ 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)}
/>
) : (
<FormControlLabel
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
label="Ignore self-transfers"
/>
)}
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
<TextField {startDateField && components?.DateField ? (
label="Start Date" <Box sx={{ flex: 1 }}>
type="datetime-local" <components.DateField
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" <TextField
type="datetime-local" label="Start Date"
value={endDate} type="datetime-local"
onChange={(e) => setEndDate(e.target.value)} value={startDate}
size="small" onChange={(e) => setStartDate(e.target.value)}
InputLabelProps={{ shrink: true }} size="small"
sx={{ flex: 1 }} InputLabelProps={{ shrink: true }}
/> sx={{ flex: 1 }}
/>
)}
{endDateField && components?.DateField ? (
<Box sx={{ flex: 1 }}>
<components.DateField
name="end_date"
field={endDateField}
value={endDate}
onChange={(val: string) => setEndDate(val)}
/>
</Box>
) : (
<TextField
label="End Date"
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
)}
</Box> </Box>
<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" <TextField
value={maxAmount} label="Min Amount"
onChange={(e) => setMaxAmount(e.target.value)} type="number"
size="small" value={minAmount}
sx={{ flex: 1 }} onChange={(e) => setMinAmount(e.target.value)}
/> size="small"
sx={{ flex: 1 }}
/>
)}
{maxAmountField && components?.FormField ? (
<Box sx={{ flex: 1 }}>
<components.FormField
name="max_amount"
field={maxAmountField}
value={maxAmount}
onChange={(val: string) => setMaxAmount(val)}
/>
</Box>
) : (
<TextField
label="Max Amount"
type="number"
value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
)}
</Box> </Box>
<Button <Button
@@ -191,20 +259,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 +295,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 +308,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>

View File

@@ -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,

View File

@@ -2,8 +2,3 @@ export type {
ReportSnapshot, ReportSnapshot,
ReportQuery, ReportQuery,
} from "./report-snapshots.models"; } from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -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 />
) )