# Summary Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. ## Changes ### Admin UI Customization * Added support for custom: * Dashboard component * Layout component * Login page component * Introduced `AdminAppProps` and extended `Admin` configuration API. * Renamed internal dashboard implementation to `DefaultDashboard`. ### Field Component Architecture * Extracted field rendering into dedicated field components: * TextField * NumberField * BooleanField * DateField * EnumField * RelationField * ObjectField * FallbackField * DateRangeField * NumberRangeField * Added `defaultFieldComponents` registry. * Refactored `FormField` to resolve components dynamically from a component map instead of hardcoded field type handling. ### Resource Customization * Added `FieldComponents` support across: * Admin * ResourceView * GenericForm * useResource * Introduced wrapped `FormField` and `GenericForm` components generated from configured field overrides. ### Table Customization * Added `EnhancedTableComponents`. * Added support for custom cell renderers per field type. * Enabled custom rendering for both desktop and mobile table layouts. ### Filter Improvements * Exported `FilterAutocomplete`. * Added support for custom date-range and number-range filter components. * Added filter component extension points. * Updated filter option label resolution to support `displayFormat`. ### Display Formatting * Replaced `displayField` usage with `displayFormat`. * Added template-based display rendering support through `resolveTemplate`. * Improved relation display configuration handling. ### TypeScript Improvements * Added TypeScript as a project dependency. * Removed multiple `@ts-ignore` usages. * Added strongly typed Axios wrapper methods with generic response support. * Improved typing across hooks and component interfaces. ### OpenAPI Configuration Validation * Added validation for enum fields without enum values. * Added validation for relation resources missing `referenceOptions.enumOption`. * Improved relation metadata propagation during schema parsing. ### Library Exports * Exported: * Field component types * Override types * EnhancedTable * GenericForm * ResourceView * Field components and defaults * Expanded public API surface for consumers extending the framework. ## Benefits * Enables complete UI customization without modifying framework internals. * Simplifies creation of custom field types and renderers. * Improves type safety and developer experience. * Provides consistent extension points for forms, tables, filters, and admin layouts. * Makes the framework more suitable for reusable library distribution. Reviewed-on: #11 Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com> Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
import * as React from 'react';
|
|
import { Box, Paper, CircularProgress } from '@mui/material';
|
|
import { ResourceConfig } from '../types/config';
|
|
import type { ResourceField } from '../types/config';
|
|
import { FieldComponents } from '../types/overrides';
|
|
import { useResource } from '../hooks/useResource';
|
|
import { resolveTemplate } from '../utils/options';
|
|
import EnhancedTable from './EnhancedTable';
|
|
import FilterBar from './FilterBar';
|
|
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
|
|
|
interface ResourceViewProps {
|
|
config: ResourceConfig;
|
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
|
fieldComponents: FieldComponents;
|
|
}
|
|
|
|
import { GridPaginationModel } from '@mui/x-data-grid';
|
|
|
|
function getDisplayString(item: any, field: ResourceField): string {
|
|
if (item == null || typeof item !== 'object') return String(item ?? '');
|
|
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
|
|
const df = field.displayField;
|
|
if (!df) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
|
|
if (Array.isArray(df)) {
|
|
const parts = df.map((k: string) => item[k]).filter((v: any) => v != null);
|
|
return parts.length > 0 ? parts.join(' ') : '';
|
|
}
|
|
return String(item[df] ?? '');
|
|
}
|
|
|
|
function applyClientFilters(
|
|
data: any[],
|
|
filters: Record<string, any>,
|
|
fields: Record<string, ResourceField>
|
|
): any[] {
|
|
const entries = Object.entries(filters).filter(([_, v]) => {
|
|
if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) return false;
|
|
if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false;
|
|
return true;
|
|
});
|
|
|
|
if (entries.length === 0) return data;
|
|
|
|
return data.filter((item) =>
|
|
entries.every(([fieldName, filterValue]) => {
|
|
const field = fields[fieldName];
|
|
if (!field) return true;
|
|
|
|
const itemValue = item[fieldName];
|
|
|
|
if (typeof filterValue === "object" && !Array.isArray(filterValue)) {
|
|
if (field.type === "number") {
|
|
if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false;
|
|
if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false;
|
|
return true;
|
|
}
|
|
if (field.type === "datetime" || field.type === "date") {
|
|
const itemTime = new Date(itemValue).getTime();
|
|
if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false;
|
|
if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false;
|
|
return true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (Array.isArray(filterValue)) {
|
|
if (field.type === "array" && Array.isArray(itemValue)) {
|
|
return itemValue.some((el: any) =>
|
|
filterValue.includes(getDisplayString(el, field))
|
|
);
|
|
}
|
|
if (itemValue && typeof itemValue === "object") {
|
|
return filterValue.includes(getDisplayString(itemValue, field));
|
|
}
|
|
return filterValue.includes(String(itemValue));
|
|
}
|
|
|
|
if (!filterValue) return true;
|
|
|
|
if (field.type === "boolean") {
|
|
return String(itemValue) === filterValue;
|
|
}
|
|
|
|
if (field.type === "array" && Array.isArray(itemValue)) {
|
|
return itemValue.some((el: any) =>
|
|
getDisplayString(el, field) === String(filterValue)
|
|
);
|
|
}
|
|
|
|
if (itemValue && typeof itemValue === "object") {
|
|
return getDisplayString(itemValue, field) === String(filterValue);
|
|
}
|
|
|
|
return String(itemValue) === String(filterValue);
|
|
})
|
|
);
|
|
}
|
|
|
|
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
|
|
const { id } = useParams();
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
const isCreate = location.pathname.endsWith('/create');
|
|
const isEdit = location.pathname.includes('/edit/');
|
|
const isView = !!id && !isEdit;
|
|
const isList = !id && !isCreate;
|
|
|
|
const isServer = config.filterOptions?.mode !== "client";
|
|
|
|
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
|
|
page: 0,
|
|
pageSize: 10,
|
|
});
|
|
|
|
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
|
|
|
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
|
|
|
|
const queryParams = React.useMemo(() => {
|
|
if (!isServer) return { limit: 10 };
|
|
return {
|
|
skip: paginationModel.page * paginationModel.pageSize,
|
|
limit: paginationModel.pageSize,
|
|
};
|
|
}, [isServer, paginationModel]);
|
|
|
|
const listQuery = useList(queryParams);
|
|
const itemQuery = useRead(id || "");
|
|
|
|
const rawData = listQuery.data?.data || [];
|
|
const totalCount = listQuery.data?.total;
|
|
|
|
const filteredData = React.useMemo(
|
|
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
|
|
[isServer, rawData, appliedFilters, config.fields]
|
|
);
|
|
|
|
const createMutation = useCreate();
|
|
const updateMutation = useUpdate();
|
|
const deleteMutation = useDelete();
|
|
|
|
const handleEdit = (item: any) => {
|
|
navigate(`/admin/${config.name}/edit/${item[config.primaryKey]}`);
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
navigate(`/admin/${config.name}/create`);
|
|
};
|
|
|
|
const handleSave = async (formData: any) => {
|
|
try {
|
|
if (isEdit) {
|
|
await updateMutation.mutateAsync({ id: id!, data: formData });
|
|
} else {
|
|
await createMutation.mutateAsync(formData);
|
|
}
|
|
navigate(`/admin/${config.name}`);
|
|
} catch (err) {
|
|
console.error('Save failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (itemId: string) => {
|
|
if (window.confirm('Are you sure you want to delete this item?')) {
|
|
await deleteMutation.mutateAsync(itemId);
|
|
}
|
|
};
|
|
|
|
if (isList && listQuery.isLoading) return <CircularProgress />;
|
|
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
|
|
|
|
return (
|
|
<Box>
|
|
{isList ? (
|
|
<Box>
|
|
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
|
|
<FilterBar
|
|
fields={config.fields}
|
|
filterableFields={config.filterOptions.fields}
|
|
mode={config.filterOptions?.mode || "server"}
|
|
data={rawData}
|
|
appliedValues={appliedFilters}
|
|
onApply={setAppliedFilters}
|
|
onClear={() => setAppliedFilters({})}
|
|
fieldComponents={components}
|
|
/>
|
|
)}
|
|
<EnhancedTable
|
|
config={config}
|
|
data={filteredData}
|
|
total={isServer ? totalCount : filteredData.length}
|
|
paginationModel={isServer ? paginationModel : undefined}
|
|
onPaginationModelChange={isServer ? setPaginationModel : undefined}
|
|
loading={listQuery.isFetching}
|
|
onEdit={handleEdit}
|
|
onDelete={handleDelete}
|
|
onCreate={handleCreate}
|
|
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Paper sx={{ p: 4 }}>
|
|
{components && <components.GenericForm
|
|
config={config}
|
|
initialData={isCreate ? null : itemQuery.data}
|
|
onSave={handleSave}
|
|
onCancel={() => navigate(`/admin/${config.name}`)}
|
|
loading={createMutation.isPending || updateMutation.isPending}
|
|
readOnly={isView}
|
|
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
|
/>}
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|