From a54250b53d6e95ff542cf1bf896966c6f6c0bd9e Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 5 Jun 2026 02:53:26 +0530 Subject: [PATCH] common fields --- react-openapi/Admin.tsx | 20 +- react-openapi/components/GenericForm.tsx | 4 + react-openapi/components/ResourceView.tsx | 5 +- .../components/fields/BooleanField.tsx | 17 ++ react-openapi/components/fields/DateField.tsx | 18 ++ .../fields/DefaultFieldComponents.ts | 20 ++ react-openapi/components/fields/EnumField.tsx | 24 ++ react-openapi/components/fields/FormField.tsx | 271 +++++------------- .../components/fields/NumberField.tsx | 16 ++ .../components/fields/ObjectField.tsx | 36 +++ .../components/fields/RelationField.tsx | 50 ++++ react-openapi/components/fields/TextField.tsx | 18 ++ react-openapi/components/fields/index.ts | 11 + react-openapi/hooks/useResource.ts | 15 +- react-openapi/index.ts | 13 + react-openapi/types/overrides.ts | 22 ++ 16 files changed, 349 insertions(+), 211 deletions(-) create mode 100644 react-openapi/components/fields/BooleanField.tsx create mode 100644 react-openapi/components/fields/DateField.tsx create mode 100644 react-openapi/components/fields/DefaultFieldComponents.ts create mode 100644 react-openapi/components/fields/EnumField.tsx create mode 100644 react-openapi/components/fields/NumberField.tsx create mode 100644 react-openapi/components/fields/ObjectField.tsx create mode 100644 react-openapi/components/fields/RelationField.tsx create mode 100644 react-openapi/components/fields/TextField.tsx create mode 100644 react-openapi/components/fields/index.ts diff --git a/react-openapi/Admin.tsx b/react-openapi/Admin.tsx index 52fabc8..6fc16c4 100644 --- a/react-openapi/Admin.tsx +++ b/react-openapi/Admin.tsx @@ -6,6 +6,7 @@ import ResourceView from "./components/ResourceView"; import { getAppConfig } from "./config"; import { initializeApiClients } from "./api/client"; import { AppConfig } from "./types/config"; +import { FieldComponents } from "./types/overrides"; import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import { Routes, @@ -63,7 +64,7 @@ function Dashboard({ basePath }: { basePath: string }) { import ProfileView from "./components/ProfileView"; -function AdminApp({ basePath }: { basePath: string }) { +function AdminApp({ basePath, fieldComponents }: { basePath: string; fieldComponents?: FieldComponents }) { const { currentUser, login, logout, loading, error } = useAuth(); const config = React.useContext(ConfigContext); const navigate = useNavigate(); @@ -96,32 +97,33 @@ function AdminApp({ basePath }: { basePath: string }) { } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> ); } -function ResourceRouteWrapper() { +function ResourceRouteWrapper({ fieldComponents }: { fieldComponents?: FieldComponents }) { const { resourceName } = useParams(); const config = React.useContext(ConfigContext); const selectedResource = config?.resources.find((r) => r.name === resourceName); if (!selectedResource) return Resource not found; - return ; + return ; } interface AdminProps { basePath?: string; resourceOverrides?: Record; profileConfig?: any; + fieldComponents?: FieldComponents; } -export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) { +export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents = {} }: AdminProps) { const existingConfig = React.useContext(ConfigContext); const [config, setConfig] = React.useState(existingConfig); @@ -151,7 +153,7 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro const content = ( - + ); diff --git a/react-openapi/components/GenericForm.tsx b/react-openapi/components/GenericForm.tsx index 8931f07..fe7d1e6 100644 --- a/react-openapi/components/GenericForm.tsx +++ b/react-openapi/components/GenericForm.tsx @@ -7,6 +7,7 @@ import { CircularProgress, } from '@mui/material'; import { ResourceConfig } from '../types/config'; +import { FieldComponents } from '../types/overrides'; import { useUpload } from '../providers/UploadProvider'; import { useQueries } from '@tanstack/react-query'; import { useResource } from '../hooks/useResource'; @@ -21,6 +22,7 @@ interface GenericFormProps { loading?: boolean; readOnly?: boolean; onEditClick?: () => void; + fieldComponents?: FieldComponents; } export default function GenericForm({ @@ -31,6 +33,7 @@ export default function GenericForm({ loading: saving, readOnly = false, onEditClick, + fieldComponents, }: GenericFormProps) { initialData = initialData || {}; const [formData, setFormData] = React.useState(initialData); @@ -117,6 +120,7 @@ export default function GenericForm({ uploading={uploading} baseUrl={appConfig?.baseUrl || ""} relationDataMap={relationDataMap} + components={fieldComponents} /> ))} diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index f03885b..568bf8f 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Box, Paper, CircularProgress } from '@mui/material'; import { ResourceConfig } from '../types/config'; import type { ResourceField } from '../types/config'; +import { FieldComponents } from '../types/overrides'; import { useResource } from '../hooks/useResource'; import { resolveTemplate } from '../utils/options'; import GenericForm from './GenericForm'; @@ -12,6 +13,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'; interface ResourceViewProps { config: ResourceConfig; onNavigateToResource?: (resourceName: string, id: string) => void; + fieldComponents?: FieldComponents; } import { GridPaginationModel } from '@mui/x-data-grid'; @@ -96,7 +98,7 @@ function applyClientFilters( ); } -export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) { +export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) { const { id } = useParams(); const location = useLocation(); const navigate = useNavigate(); @@ -208,6 +210,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV loading={createMutation.isPending || updateMutation.isPending} readOnly={isView} onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)} + fieldComponents={fieldComponents} /> )} diff --git a/react-openapi/components/fields/BooleanField.tsx b/react-openapi/components/fields/BooleanField.tsx new file mode 100644 index 0000000..1deb5e3 --- /dev/null +++ b/react-openapi/components/fields/BooleanField.tsx @@ -0,0 +1,17 @@ +import { FormControlLabel, Checkbox } from '@mui/material'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) { + return ( + onChange(e.target.checked)} + disabled={disabled} + /> + } + label={field.label} + /> + ); +} diff --git a/react-openapi/components/fields/DateField.tsx b/react-openapi/components/fields/DateField.tsx new file mode 100644 index 0000000..04b1f22 --- /dev/null +++ b/react-openapi/components/fields/DateField.tsx @@ -0,0 +1,18 @@ +import { TextField as MuiTextField } from '@mui/material'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) { + const isDatetime = field.type === 'datetime'; + return ( + onChange(e.target.value)} + disabled={disabled} + required={field.required} + /> + ); +} diff --git a/react-openapi/components/fields/DefaultFieldComponents.ts b/react-openapi/components/fields/DefaultFieldComponents.ts new file mode 100644 index 0000000..48c591a --- /dev/null +++ b/react-openapi/components/fields/DefaultFieldComponents.ts @@ -0,0 +1,20 @@ +import { FieldComponents } from '../../types/overrides'; +import TextFieldEntry from './TextField'; +import NumberField from './NumberField'; +import BooleanField from './BooleanField'; +import DateField from './DateField'; +import EnumField from './EnumField'; +import RelationField from './RelationField'; +import ImageUploadField from './ImageUploadField'; + +export const defaultFieldComponents: FieldComponents = { + string: TextFieldEntry, + markdown: TextFieldEntry, + number: NumberField, + boolean: BooleanField, + date: DateField, + datetime: DateField, + enum: EnumField, + image: ImageUploadField, + relation: RelationField, +}; diff --git a/react-openapi/components/fields/EnumField.tsx b/react-openapi/components/fields/EnumField.tsx new file mode 100644 index 0000000..0633f8a --- /dev/null +++ b/react-openapi/components/fields/EnumField.tsx @@ -0,0 +1,24 @@ +import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; +import { getFieldOptions } from '../../utils/options'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) { + const options = getFieldOptions(field); + return ( + + {field.label} + + + ); +} diff --git a/react-openapi/components/fields/FormField.tsx b/react-openapi/components/fields/FormField.tsx index b1915b6..976338a 100644 --- a/react-openapi/components/fields/FormField.tsx +++ b/react-openapi/components/fields/FormField.tsx @@ -1,18 +1,9 @@ import * as React from 'react'; -import { - TextField, - FormControl, - InputLabel, - Select, - MenuItem, - FormControlLabel, - Checkbox, - Typography, - Box, - Divider, -} from '@mui/material'; +import { TextField as MuiTextField } from '@mui/material'; import { ResourceField } from '../../types/config'; -import { getFieldOptions } from '../../utils/options'; +import { FieldComponentProps, FieldComponents } from '../../types/overrides'; +import { defaultFieldComponents } from './DefaultFieldComponents'; +import ObjectField from './ObjectField'; import ImageUploadField from './ImageUploadField'; interface FormFieldProps { @@ -24,7 +15,19 @@ interface FormFieldProps { uploadFile: (file: File) => Promise; uploading: boolean; baseUrl: string; - relationDataMap?: Record; // Map of relation name to data array + relationDataMap?: Record; + components?: FieldComponents; +} + +function FallbackField({ field, value }: FieldComponentProps) { + return ( + + ); } export default function FormField({ @@ -37,190 +40,62 @@ export default function FormField({ uploading, baseUrl, relationDataMap = {}, + components: componentsProp, }: FormFieldProps) { - const label = field.label; - - // 1. Recursive Rendering for Objects (Not Relations) - if (field.type === 'object' && field.schema && !field.relation) { - return ( - - - {label} - - - {Object.entries(field.schema).map(([subKey, subField]) => ( - { - const updated = { ...(value || {}), [subKey]: newVal }; - onChange(updated); - }} - disabled={disabled} - uploadFile={uploadFile} - uploading={uploading} - baseUrl={baseUrl} - relationDataMap={relationDataMap} - /> - ))} - - - ); - } - - // 2. Relation Handling (Select / Multi-Select) - if (field.relation && relationDataMap[field.relation]) { - const relationData = relationDataMap[field.relation].data; - const isArrayRelation = field.type === 'array'; - const options = getFieldOptions(field, relationData); - const keyField = field.enumOption?.key ?? 'id'; - - // Normalize value: API returns whole objects on GET, but form uses key strings - const normalizedValue = (() => { - if (isArrayRelation && Array.isArray(value)) { - return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v))); - } - if (value != null && typeof value === 'object') { - return String(value[keyField] ?? ''); - } - return value ?? (isArrayRelation ? [] : ""); - })(); - - return ( - - {label} - - - ); - } - - // 3. Image Handling - if (field.type === 'image') { - return ( - { - const url = await uploadFile(file); - if (url) onChange(url); - }} - uploading={uploading} - baseUrl={baseUrl} - disabled={disabled} - /> - ); - } - - // 4. Boolean Handling - if (field.type === 'boolean') { - return ( - onChange(e.target.checked)} - disabled={disabled} - /> - } - label={label} - /> - ); - } - - // 5. Enum Handling - if (field.type === 'enum') { - const options = getFieldOptions(field); - return ( - - {label} - - - ); - } - - // 6. Common Text Fields - if (field.type === 'datetime' || field.type === 'date') { - return ( - onChange(e.target.value)} - disabled={disabled} - required={field.required} - /> - ); - } - - if (field.type === 'markdown' || field.type === 'string') { - return ( - onChange(e.target.value)} - disabled={disabled} - required={field.required} - /> - ); - } - - if (field.type === 'number') { - return ( - onChange(e.target.value === '' ? '' : Number(e.target.value))} - disabled={disabled} - required={field.required} - /> - ); - } - - return ( - + const components = React.useMemo( + () => ({ ...defaultFieldComponents, ...componentsProp }), + [componentsProp], ); + + const fieldProps: FieldComponentProps = { + name, + field, + value, + onChange, + disabled, + baseUrl, + relationDataMap, + uploadFile, + uploading, + }; + + // 1. Object (recursive) - requires parent FormField for recursion + if (field.type === 'object' && field.schema && !field.relation) { + const renderChild = (childProps: FieldComponentProps) => ( + + ); + return ; + } + + // 2. Image + if (field.type === 'image') { + const ImageField = components.image || ImageUploadField; + return ; + } + + // 3. Relation + if (field.relation && relationDataMap[field.relation]) { + const RelationFieldComp = components.relation || defaultFieldComponents.relation!; + return ; + } + + // 4. Lookup by field type + const Component = components[field.type]; + if (Component) { + return ; + } + + // 5. Fallback for unknown types + return ; } diff --git a/react-openapi/components/fields/NumberField.tsx b/react-openapi/components/fields/NumberField.tsx new file mode 100644 index 0000000..677bf1a --- /dev/null +++ b/react-openapi/components/fields/NumberField.tsx @@ -0,0 +1,16 @@ +import { TextField as MuiTextField } from '@mui/material'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) { + return ( + onChange(e.target.value === '' ? '' : Number(e.target.value))} + disabled={disabled} + required={field.required} + /> + ); +} diff --git a/react-openapi/components/fields/ObjectField.tsx b/react-openapi/components/fields/ObjectField.tsx new file mode 100644 index 0000000..19b7dc1 --- /dev/null +++ b/react-openapi/components/fields/ObjectField.tsx @@ -0,0 +1,36 @@ +import { Box, Typography } from '@mui/material'; +import { FieldComponentProps } from '../../types/overrides'; + +export interface ObjectFieldProps extends FieldComponentProps { + renderField: (props: FieldComponentProps) => React.ReactNode; +} + +export default function ObjectField({ name, field, value, onChange, disabled, baseUrl, uploadFile, uploading, relationDataMap, renderField }: ObjectFieldProps) { + if (!field.schema) return null; + + return ( + + + {field.label} + + + {Object.entries(field.schema).map(([subKey, subField]) => + renderField({ + name: `${name}.${subKey}`, + field: subField, + value: value?.[subKey], + onChange: (newVal: any) => { + const updated = { ...(value || {}), [subKey]: newVal }; + onChange(updated); + }, + disabled, + baseUrl, + uploadFile, + uploading, + relationDataMap, + }) + )} + + + ); +} diff --git a/react-openapi/components/fields/RelationField.tsx b/react-openapi/components/fields/RelationField.tsx new file mode 100644 index 0000000..d381428 --- /dev/null +++ b/react-openapi/components/fields/RelationField.tsx @@ -0,0 +1,50 @@ +import { FormControl, InputLabel, Select, MenuItem } from '@mui/material'; +import { getFieldOptions } from '../../utils/options'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function RelationField({ field, value, onChange, disabled, relationDataMap = {} }: FieldComponentProps) { + if (!field.relation || !relationDataMap[field.relation]) { + return null; + } + + const relationData = relationDataMap[field.relation].data; + const isArrayRelation = field.type === 'array'; + const options = getFieldOptions(field, relationData); + const keyField = field.enumOption?.key ?? 'id'; + + const normalizedValue = (() => { + if (isArrayRelation && Array.isArray(value)) { + return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v))); + } + if (value != null && typeof value === 'object') { + return String(value[keyField] ?? ''); + } + return value ?? (isArrayRelation ? [] : ""); + })(); + + return ( + + {field.label} + + + ); +} diff --git a/react-openapi/components/fields/TextField.tsx b/react-openapi/components/fields/TextField.tsx new file mode 100644 index 0000000..1dd6df0 --- /dev/null +++ b/react-openapi/components/fields/TextField.tsx @@ -0,0 +1,18 @@ +import { TextField as MuiTextField } from '@mui/material'; +import { FieldComponentProps } from '../../types/overrides'; + +export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) { + const isMarkdown = field.type === 'markdown'; + return ( + onChange(e.target.value)} + disabled={disabled} + required={field.required} + /> + ); +} diff --git a/react-openapi/components/fields/index.ts b/react-openapi/components/fields/index.ts new file mode 100644 index 0000000..9af5170 --- /dev/null +++ b/react-openapi/components/fields/index.ts @@ -0,0 +1,11 @@ +export { default as FormField } from './FormField'; +export { default as ImageUploadField } from './ImageUploadField'; +export { default as TextField } from './TextField'; +export { default as NumberField } from './NumberField'; +export { default as BooleanField } from './BooleanField'; +export { default as DateField } from './DateField'; +export { default as EnumField } from './EnumField'; +export { default as RelationField } from './RelationField'; +export { default as ObjectField } from './ObjectField'; +export { defaultFieldComponents } from './DefaultFieldComponents'; +export type { ObjectFieldProps } from './ObjectField'; diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index f2a2a21..7805654 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -4,7 +4,10 @@ import { ResourceConfig } from "../types/config"; import { ConfigContext } from "../providers/ConfigContext"; import * as React from "react"; -export function useResource(config: ResourceConfig | undefined) { +import { FieldComponents } from "../types/overrides"; +import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents"; + +export function useResource(config: ResourceConfig | undefined, options?: { fieldComponents?: FieldComponents }) { const queryClient = useQueryClient(); // Return empty/disabled hooks if config is missing @@ -147,6 +150,11 @@ export function useResource(config: ResourceConfig | undefined) { }, }); + const components = { + ...defaultFieldComponents, + ...options?.fieldComponents, + }; + return { useList, useRead, @@ -157,12 +165,13 @@ export function useResource(config: ResourceConfig | undefined) { useUpdateMe, useDelete, getListQueryOptions, + components, }; } -export function useResourceByName(name: string) { +export function useResourceByName(name: string, options?: { fieldComponents?: FieldComponents }) { const config = React.useContext(ConfigContext); const resourceConfig = config?.resources.find((r) => r.name === name); - return useResource(resourceConfig); + return useResource(resourceConfig, options); } diff --git a/react-openapi/index.ts b/react-openapi/index.ts index e4012f3..604a90d 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -2,7 +2,20 @@ 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 { 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"; diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 89267be..b54907c 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -1,3 +1,5 @@ +import { ResourceField, FieldType } from './config'; + export interface EnumOption { key: string; value: string; @@ -21,3 +23,23 @@ export interface ResourceOverride { }; enumOption?: EnumOption; } + +export interface FieldComponentProps { + name: string; + field: ResourceField; + value: any; + onChange: (val: any) => void; + disabled?: boolean; + error?: string; + baseUrl?: string; + relationDataMap?: Record; + uploadFile?: (file: File) => Promise; + uploading?: boolean; +} + +export type FieldComponent = React.ComponentType; + +export type FieldComponents = Partial> & { + relation?: FieldComponent; + image?: FieldComponent; +};