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

# Summary

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

## Changes

### Admin UI Customization

* Added support for custom:

  * Dashboard component
  * Layout component
  * Login page component
* Introduced `AdminAppProps` and extended `Admin` configuration API.
* Renamed internal dashboard implementation to `DefaultDashboard`.

### Field Component Architecture

* Extracted field rendering into dedicated field components:

  * TextField
  * NumberField
  * BooleanField
  * DateField
  * EnumField
  * RelationField
  * ObjectField
  * FallbackField
  * DateRangeField
  * NumberRangeField
* Added `defaultFieldComponents` registry.
* Refactored `FormField` to resolve components dynamically from a component map instead of hardcoded field type handling.

### Resource Customization

* Added `FieldComponents` support across:

  * Admin
  * ResourceView
  * GenericForm
  * useResource
* Introduced wrapped `FormField` and `GenericForm` components generated from configured field overrides.

### Table Customization

* Added `EnhancedTableComponents`.
* Added support for custom cell renderers per field type.
* Enabled custom rendering for both desktop and mobile table layouts.

### Filter Improvements

* Exported `FilterAutocomplete`.
* Added support for custom date-range and number-range filter components.
* Added filter component extension points.
* Updated filter option label resolution to support `displayFormat`.

### Display Formatting

* Replaced `displayField` usage with `displayFormat`.
* Added template-based display rendering support through `resolveTemplate`.
* Improved relation display configuration handling.

### TypeScript Improvements

* Added TypeScript as a project dependency.
* Removed multiple `@ts-ignore` usages.
* Added strongly typed Axios wrapper methods with generic response support.
* Improved typing across hooks and component interfaces.

### OpenAPI Configuration Validation

* Added validation for enum fields without enum values.
* Added validation for relation resources missing `referenceOptions.enumOption`.
* Improved relation metadata propagation during schema parsing.

### Library Exports

* Exported:

  * Field component types
  * Override types
  * EnhancedTable
  * GenericForm
  * ResourceView
  * Field components and defaults
* Expanded public API surface for consumers extending the framework.

## Benefits

* Enables complete UI customization without modifying framework internals.
* Simplifies creation of custom field types and renderers.
* Improves type safety and developer experience.
* Provides consistent extension points for forms, tables, filters, and admin layouts.
* Makes the framework more suitable for reusable library distribution.

Reviewed-on: #11
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
2026-06-07 12:35:52 +00:00
committed by aetos
parent e6ce62a166
commit 7bd946ec7a
35 changed files with 1028 additions and 618 deletions

View File

@@ -6,6 +6,7 @@ import ResourceView from "./components/ResourceView";
import { getAppConfig } from "./config";
import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config";
import { FieldComponents } from "./types/overrides";
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import {
Routes,
@@ -15,8 +16,9 @@ import {
} from "react-router-dom";
import { ConfigContext } from "./providers/ConfigContext";
import ProfileView from "./components/ProfileView";
function Dashboard({ basePath }: { basePath: string }) {
function DefaultDashboard({ basePath }: { basePath: string }) {
const config = React.useContext(ConfigContext);
const navigate = useNavigate();
@@ -31,7 +33,6 @@ function Dashboard({ basePath }: { basePath: string }) {
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data.
</Typography>
<Box
sx={{
display: "grid",
@@ -41,10 +42,10 @@ function Dashboard({ basePath }: { basePath: string }) {
}}
>
{visibleResources.map((res) => (
<Paper
key={res.name}
sx={{
p: 3,
<Paper
key={res.name}
sx={{
p: 3,
textAlign: "center",
cursor: 'pointer',
transition: 'transform 0.2s',
@@ -61,9 +62,15 @@ function Dashboard({ basePath }: { basePath: string }) {
);
}
import ProfileView from "./components/ProfileView";
interface AdminAppProps {
basePath: string;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
function AdminApp({ basePath }: { basePath: string }) {
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) {
const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext);
const navigate = useNavigate();
@@ -73,10 +80,10 @@ function AdminApp({ basePath }: { basePath: string }) {
if (!currentUser) {
return (
<AuthPage
<LoginPage
mode="login"
login={login}
register={async () => {}} // Disable registration for Admin
register={async () => {}}
loading={loading}
error={error}
onSwitchMode={() => {}}
@@ -87,7 +94,7 @@ function AdminApp({ basePath }: { basePath: string }) {
}
return (
<AdminLayout
<Layout
username={currentUser.username}
onLogout={logout}
onSelectResource={(name) => navigate(`/admin/${name}`)}
@@ -96,32 +103,44 @@ function AdminApp({ basePath }: { basePath: string }) {
<Routes>
<Route path="/" element={<Dashboard basePath={basePath} />} />
<Route path="/profile" element={<ProfileView />} />
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} />
</Routes>
</AdminLayout>
</Layout>
);
}
function ResourceRouteWrapper() {
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) {
const { resourceName } = useParams();
const config = React.useContext(ConfigContext);
const selectedResource = config?.resources.find((r) => r.name === resourceName);
if (!selectedResource) return <Typography>Resource not found</Typography>;
return <ResourceView config={selectedResource} />;
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />;
}
interface AdminLayoutProps {
children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void;
onLogout: () => void;
username?: string;
resources: import("./types/config").ResourceConfig[];
}
interface AdminProps {
basePath?: string;
resourceOverrides?: Record<string, any>;
profileConfig?: any;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) {
const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
@@ -151,16 +170,14 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
const content = (
<UploadProvider>
<AdminApp basePath={basePath} />
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} />
</UploadProvider>
);
// If we have an existing config, we are already inside a Provider and QueryClient
if (existingConfig) {
return content;
}
// Fallback for standalone usage
return (
<ConfigContext.Provider value={config}>
{content}