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
12 changed files with 55 additions and 24 deletions
Showing only changes of commit d46213b96b - Show all commits

14
package-lock.json generated
View File

@@ -28,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
} }
}, },
@@ -4103,6 +4104,19 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@@ -28,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
} }
} }

View File

@@ -100,7 +100,7 @@ export default function EnhancedTable({
} }
if (muiType === 'singleSelect') { if (muiType === 'singleSelect') {
col.valueOptions = toGridValueOptions(getFieldOptions(field)); (col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
} }
return col; return col;

View File

@@ -130,6 +130,7 @@ function extractOptions(
if (Array.isArray(df)) { if (Array.isArray(df)) {
const parts = df.map((k) => item[k]).filter((v) => v != null); const parts = df.map((k) => item[k]).filter((v) => v != null);
if (parts.length > 0) return parts.join(" "); if (parts.length > 0) return parts.join(" ");
return null;
} }
const v = item[df]; const v = item[df];
if (v != null) return String(v); if (v != null) return String(v);

View File

@@ -201,7 +201,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
</Box> </Box>
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
<components.GenericForm {components && <components.GenericForm
config={config} config={config}
initialData={isCreate ? null : itemQuery.data} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
@@ -209,7 +209,7 @@ 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}`)}
/> />}
</Paper> </Paper>
)} )}
</Box> </Box>

View File

@@ -1,4 +1,5 @@
import { FieldComponents } from '../../types/overrides'; import * as React from 'react';
import { FieldComponents, FieldComponentProps } from '../../types/overrides';
import TextFieldEntry from './TextField'; import TextFieldEntry from './TextField';
import NumberField from './NumberField'; import NumberField from './NumberField';
import BooleanField from './BooleanField'; import BooleanField from './BooleanField';
@@ -8,6 +9,19 @@ import RelationField from './RelationField';
import ImageUploadField from './ImageUploadField'; import ImageUploadField from './ImageUploadField';
import FallbackField from './FallbackField'; import FallbackField from './FallbackField';
const WrappedImageUploadField = (props: FieldComponentProps) =>
React.createElement(ImageUploadField, {
label: props.field.label,
value: props.value || '',
onUpload: async (file: File) => {
const url = await props.uploadFile?.(file);
if (url) props.onChange(url);
},
uploading: props.uploading,
baseUrl: props.baseUrl || '',
disabled: props.disabled,
});
export const defaultFieldComponents: FieldComponents = { export const defaultFieldComponents: FieldComponents = {
string: TextFieldEntry, string: TextFieldEntry,
markdown: TextFieldEntry, markdown: TextFieldEntry,
@@ -16,7 +30,7 @@ export const defaultFieldComponents: FieldComponents = {
date: DateField, date: DateField,
datetime: DateField, datetime: DateField,
enum: EnumField, enum: EnumField,
image: ImageUploadField, image: WrappedImageUploadField,
relation: RelationField, relation: RelationField,
default: FallbackField, default: FallbackField,
}; };

View File

@@ -9,9 +9,9 @@ export interface FormFieldProps {
value: any; value: any;
onChange: (val: any) => void; onChange: (val: any) => void;
disabled?: boolean; disabled?: boolean;
uploadFile: (file: File) => Promise<string | null>; uploadFile?: (file: File) => Promise<string | null>;
uploading: boolean; uploading?: boolean;
baseUrl: string; baseUrl?: string;
relationDataMap?: Record<string, any[]>; relationDataMap?: Record<string, any[]>;
components: FieldComponents; components: FieldComponents;
} }
@@ -51,9 +51,9 @@ export default function FormField({
value={childProps.value} value={childProps.value}
onChange={childProps.onChange} onChange={childProps.onChange}
disabled={childProps.disabled} disabled={childProps.disabled}
uploadFile={childProps.uploadFile!} uploadFile={childProps.uploadFile}
uploading={childProps.uploading!} uploading={childProps.uploading}
baseUrl={childProps.baseUrl!} baseUrl={childProps.baseUrl}
relationDataMap={childProps.relationDataMap} relationDataMap={childProps.relationDataMap}
components={components} components={components}
/> />

View File

@@ -7,7 +7,7 @@ export default function RelationField({ field, value, onChange, disabled, relati
return null; return null;
} }
const relationData = relationDataMap[field.relation].data; const relationData = relationDataMap[field.relation];
const isArrayRelation = field.type === 'array'; const isArrayRelation = field.type === 'array';
const options = getFieldOptions(field, relationData); const options = getFieldOptions(field, relationData);
const keyField = field.enumOption?.key ?? 'id'; const keyField = field.enumOption?.key ?? 'id';

View File

@@ -78,7 +78,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
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: any) => {
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] });
@@ -93,7 +93,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
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: any) => {
const listId = updatedItem[primaryKey]; const listId = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] }); queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", listId] }); queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });

View File

@@ -222,8 +222,9 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.) // Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
const enums: Record<string, string[]> = {}; const enums: Record<string, string[]> = {};
if (api.components?.schemas) { const apiDoc = api as any;
for (const [name, schema] of Object.entries(api.components.schemas) as [string, any]) { if (apiDoc.components?.schemas) {
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
if (schema.enum) { if (schema.enum) {
enums[name] = schema.enum; enums[name] = schema.enum;
} }

View File

@@ -326,9 +326,9 @@ export default function FetchRequests() {
)} )}
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.DateField ? ( {startDateField && components?.date ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.DateField <components.date
name="start_date" name="start_date"
field={startDateField} field={startDateField}
value={startDate} value={startDate}
@@ -347,9 +347,9 @@ export default function FetchRequests() {
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
)} )}
{endDateField && components?.DateField ? ( {endDateField && components?.date ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.DateField <components.date
name="end_date" name="end_date"
field={endDateField} field={endDateField}
value={endDate} value={endDate}

View File

@@ -140,9 +140,9 @@ export default function ReportSnapshots() {
)} )}
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.DateField ? ( {startDateField && components?.datetime ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.DateField <components.datetime
name="start_date" name="start_date"
field={startDateField} field={startDateField}
value={startDate} value={startDate}
@@ -160,9 +160,9 @@ export default function ReportSnapshots() {
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
)} )}
{endDateField && components?.DateField ? ( {endDateField && components?.datetime ? (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<components.DateField <components.datetime
name="end_date" name="end_date"
field={endDateField} field={endDateField}
value={endDate} value={endDate}