Refactor the React OpenAPI admin framework to support fully customizable field rendering and UI composition. #11
14
package-lock.json
generated
14
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
"vite": "latest"
|
"vite": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user