updated react-openapi
This commit is contained in:
23
react-openapi/src/components/fields/DetailFieldRenderer.tsx
Normal file
23
react-openapi/src/components/fields/DetailFieldRenderer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { ListCellRenderer } from "./ListCellRenderer";
|
||||
|
||||
interface DetailFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function DetailFieldRenderer({ field, value, displayFormat }: DetailFieldProps) {
|
||||
if (field.hidden?.detail) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
127
react-openapi/src/components/fields/FormFieldRenderer.tsx
Normal file
127
react-openapi/src/components/fields/FormFieldRenderer.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { StringField } from "./renderers/StringField";
|
||||
import { NumberField } from "./renderers/NumberField";
|
||||
import { DateField } from "./renderers/DateField";
|
||||
import { BooleanField } from "./renderers/BooleanField";
|
||||
import { EnumField } from "./renderers/EnumField";
|
||||
import { FkSelectField } from "./renderers/FkSelectField";
|
||||
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
|
||||
import { ImageField } from "./renderers/ImageField";
|
||||
import { JsonField } from "./renderers/JsonField";
|
||||
|
||||
interface FormFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
fkLoading?: boolean;
|
||||
recordId?: string | number;
|
||||
onFkOpen?: (fieldName: string) => void;
|
||||
}
|
||||
|
||||
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
|
||||
if (field.hidden?.form) return null;
|
||||
|
||||
if (field.readOnly && field.uiType !== "image") {
|
||||
return (
|
||||
<StringField field={field} value={value} onChange={onChange} error={error} />
|
||||
);
|
||||
}
|
||||
|
||||
if (field.uiType === "image") {
|
||||
return (
|
||||
<ImageField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
id={recordId}
|
||||
uploadUrl={field.uploadUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk) {
|
||||
console.log(`[FormFieldRenderer] FK field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} isArray=${field.isArray}`);
|
||||
if (field.isArray) {
|
||||
return (
|
||||
<FkMultiSelectField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fkOptions={fkOptions}
|
||||
fkLoading={fkLoading}
|
||||
onOpen={() => onFkOpen?.(field.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FkSelectField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
fkOptions={fkOptions}
|
||||
onOpen={() => onFkOpen?.(field.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
return (
|
||||
<EnumField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return <BooleanField field={field} value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
if (field.type === "integer" || field.type === "number") {
|
||||
return (
|
||||
<NumberField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.format === "date" || field.format === "date-time") {
|
||||
return (
|
||||
<DateField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.refSchema && !field.fk) {
|
||||
return (
|
||||
<JsonField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StringField
|
||||
field={field}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
react-openapi/src/components/fields/ListCellRenderer.tsx
Normal file
66
react-openapi/src/components/fields/ListCellRenderer.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Chip, Avatar } from "@mui/material";
|
||||
import type { FieldConfig } from "../../types";
|
||||
import { applyDisplayFormat } from "./utils";
|
||||
import { InlineRefField } from "./renderers/InlineRefField";
|
||||
|
||||
interface ListCellProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function ListCellRenderer({ field, value, displayFormat }: ListCellProps) {
|
||||
if (value === null || value === undefined) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
if (field.refSchema && !field.fk && !field.isArray && typeof value === "object") {
|
||||
return <InlineRefField field={field} value={value} displayFormat={displayFormat} />;
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value) && field.refSchema && !field.fk) {
|
||||
if (value.length === 0) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{value.map((item: any, i: number) => {
|
||||
const label = typeof item === "object"
|
||||
? applyDisplayFormat(item, displayFormat ?? "")
|
||||
: String(item);
|
||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.fk && typeof value === "object" && !field.isArray) {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value) && field.fk) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{value.map((item: any, i: number) => {
|
||||
const label = typeof item === "object" ? applyDisplayFormat(item, displayFormat ?? "") : String(item);
|
||||
return <Chip key={i} label={label} size="small" variant="outlined" />;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.enumValues) {
|
||||
return <Chip label={value} size="small" />;
|
||||
}
|
||||
|
||||
if (field.uiType === "image" && value) {
|
||||
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return <Chip label={value ? "Yes" : "No"} size="small" color={value ? "success" : "default"} />;
|
||||
}
|
||||
|
||||
return <Typography variant="body2">{String(value)}</Typography>;
|
||||
}
|
||||
5
react-openapi/src/components/fields/index.ts
Normal file
5
react-openapi/src/components/fields/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { FormFieldRenderer } from "./FormFieldRenderer";
|
||||
export { ListCellRenderer } from "./ListCellRenderer";
|
||||
export { DetailFieldRenderer } from "./DetailFieldRenderer";
|
||||
export { applyDisplayFormat } from "./utils";
|
||||
export { JsonField } from "./renderers/JsonField";
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { FormControl, FormControlLabel, Switch, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}
|
||||
|
||||
export function BooleanField({ field, value, onChange }: Props) {
|
||||
return (
|
||||
<FormControl component="fieldset" fullWidth size="small">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
}
|
||||
label={field.label}
|
||||
/>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
30
react-openapi/src/components/fields/renderers/DateField.tsx
Normal file
30
react-openapi/src/components/fields/renderers/DateField.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function DateField({ field, value, onChange, error }: Props) {
|
||||
const inputType = field.format === "date" ? "date" : "datetime-local";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
react-openapi/src/components/fields/renderers/EnumField.tsx
Normal file
34
react-openapi/src/components/fields/renderers/EnumField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function EnumField({ field, value, onChange, error }: Props) {
|
||||
return (
|
||||
<FormControl fullWidth size="small" error={!!error}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{(field.enumValues ?? []).map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { TextField, Autocomplete } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
fkLoading?: boolean;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function FkMultiSelectField({ field, value, onChange, fkOptions, fkLoading, onOpen }: Props) {
|
||||
console.log(`[FkMultiSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} value=${JSON.stringify(value)}`);
|
||||
return (
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={fkOptions ?? []}
|
||||
getOptionLabel={(o) => o.label}
|
||||
value={fkOptions?.filter((o) => (value ?? []).includes(o.value)) ?? []}
|
||||
onChange={(_, newVal) => onChange(newVal.map((v) => v.value))}
|
||||
onOpen={() => onOpen?.()}
|
||||
loading={fkLoading}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label={field.label} helperText={field.description} size="small" />
|
||||
)}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
fkOptions?: { value: any; label: string }[];
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export function FkSelectField({ field, value, onChange, error, fkOptions, onOpen }: Props) {
|
||||
console.log(`[FkSelectField] render field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} value=${value}`);
|
||||
return (
|
||||
<FormControl fullWidth size="small" error={!!error}>
|
||||
<InputLabel>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
label={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onOpen={() => onOpen?.()}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{(fkOptions ?? []).map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{field.description && <FormHelperText>{field.description}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
60
react-openapi/src/components/fields/renderers/ImageField.tsx
Normal file
60
react-openapi/src/components/fields/renderers/ImageField.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { getApi } from "../../../hooks/useApi";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
id?: string | number;
|
||||
uploadUrl?: string;
|
||||
}
|
||||
|
||||
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!id || !uploadUrl) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const api = getApi();
|
||||
const url = uploadUrl.replace("{id}", String(id));
|
||||
const res = await api.post(url, formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
onChange(res.data.url ?? res.data);
|
||||
} catch {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(reader.result);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} sx={{ mb: 0.5 }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
{value ? (
|
||||
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||
) : (
|
||||
<Button variant="outlined" component="label" size="small">
|
||||
Upload {field.label}
|
||||
<input type="file" hidden accept="image/*" onChange={handleUpload} />
|
||||
</Button>
|
||||
)}
|
||||
<FormHelperText>{field.description}</FormHelperText>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Chip } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { applyDisplayFormat } from "../utils";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
displayFormat?: string;
|
||||
}
|
||||
|
||||
export function InlineRefField({ field, value, displayFormat }: Props) {
|
||||
if (!value || typeof value !== "object") {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
if (displayFormat) {
|
||||
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat)}</Typography>;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined);
|
||||
if (entries.length === 0) {
|
||||
return <Typography variant="body2" color="text.disabled">—</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||
{entries.map(([key, v]) => (
|
||||
<Chip
|
||||
key={key}
|
||||
label={`${key}: ${String(v)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
270
react-openapi/src/components/fields/renderers/JsonField.tsx
Normal file
270
react-openapi/src/components/fields/renderers/JsonField.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Box,
|
||||
Typography,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
IconButton,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
import { useAppContext } from "../../../context/AppContext";
|
||||
import { extractFields } from "../../../transformers/field-config";
|
||||
import { FormFieldRenderer } from "../FormFieldRenderer";
|
||||
|
||||
interface JsonFieldProps {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
}
|
||||
|
||||
export function JsonField({ field, value, onChange }: JsonFieldProps) {
|
||||
const { schemas } = useAppContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const refSchema = field.refSchema ? schemas[field.refSchema] : null;
|
||||
const subFields = refSchema
|
||||
? extractFields(field.refSchema!, refSchema, schemas)
|
||||
: [];
|
||||
|
||||
const [editValue, setEditValue] = useState<any>(null);
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditValue(initEditValue(value, field, schemas));
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(editValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
setEditValue((prev: any[]) => [...(prev || []), buildDefaultShape(subFields, schemas)]);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
setEditValue((prev: any[]) => prev.filter((_: any, i: number) => i !== index));
|
||||
};
|
||||
|
||||
const handleItemFieldChange = (index: number, fieldName: string, val: any) => {
|
||||
setEditValue((prev: any[]) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [fieldName]: val };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, val: any) => {
|
||||
setEditValue((prev: any) => ({ ...prev, [fieldName]: val }));
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
||||
Set {field.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.isArray && Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return (
|
||||
<Button variant="outlined" onClick={handleOpen} size="small">
|
||||
Set {field.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
label={`${value.length} item${value.length !== 1 ? "s" : ""}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
onDelete={handleClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
const summary = field.inlineDisplayFormat
|
||||
? applyInlineFormat(value, field.inlineDisplayFormat)
|
||||
: Object.entries(value)
|
||||
.filter(([, v]) => v != null)
|
||||
.map(([k, v]) => `${k}: ${String(v)}`)
|
||||
.join(" | ");
|
||||
return (
|
||||
<Chip
|
||||
label={summary || field.label}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={handleOpen}
|
||||
onDelete={handleClear}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fullScreen open={open} onClose={handleCancel}>
|
||||
<DialogTitle>{field.label}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{field.isArray ? (
|
||||
<ArrayEditor
|
||||
items={editValue ?? []}
|
||||
subFields={subFields}
|
||||
onAddItem={handleAddItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onFieldChange={handleItemFieldChange}
|
||||
schemas={schemas}
|
||||
/>
|
||||
) : (
|
||||
<ObjectEditor
|
||||
value={editValue}
|
||||
subFields={subFields}
|
||||
onFieldChange={handleFieldChange}
|
||||
schemas={schemas}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClear} color="error">
|
||||
Clear
|
||||
</Button>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectEditor({
|
||||
value,
|
||||
subFields,
|
||||
onFieldChange,
|
||||
}: {
|
||||
value: any;
|
||||
subFields: FieldConfig[];
|
||||
onFieldChange: (name: string, val: any) => void;
|
||||
schemas: Record<string, any>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{subFields.map((subField) => (
|
||||
<Box key={subField.name} sx={{ mb: 2 }}>
|
||||
<FormFieldRenderer
|
||||
field={subField}
|
||||
value={value?.[subField.name]}
|
||||
onChange={(val) => onFieldChange(subField.name, val)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayEditor({
|
||||
items,
|
||||
subFields,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
onFieldChange,
|
||||
schemas,
|
||||
}: {
|
||||
items: any[];
|
||||
subFields: FieldConfig[];
|
||||
onAddItem: () => void;
|
||||
onRemoveItem: (index: number) => void;
|
||||
onFieldChange: (index: number, name: string, val: any) => void;
|
||||
schemas: Record<string, any>;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
{items.length === 0 && (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mb: 2 }}>
|
||||
No items added yet.
|
||||
</Typography>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ flex: 1 }}>
|
||||
Item {index + 1}
|
||||
</Typography>
|
||||
<IconButton size="small" color="error" onClick={() => onRemoveItem(index)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ pl: 2 }}>
|
||||
{subFields.map((subField) => (
|
||||
<Box key={subField.name} sx={{ mb: 2 }}>
|
||||
<FormFieldRenderer
|
||||
field={subField}
|
||||
value={item?.[subField.name]}
|
||||
onChange={(val) => onFieldChange(index, subField.name, val)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
))}
|
||||
<Button startIcon={<AddIcon />} onClick={onAddItem} variant="outlined" size="small">
|
||||
Add Item
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultShape(fields: FieldConfig[], schemas: Record<string, any>): Record<string, any> {
|
||||
const shape: Record<string, any> = {};
|
||||
for (const f of fields) {
|
||||
if (f.refSchema && !f.fk) {
|
||||
const refSchemaObj = schemas[f.refSchema!];
|
||||
const nestedFields = refSchemaObj ? extractFields(f.refSchema!, refSchemaObj, schemas) : [];
|
||||
shape[f.name] = f.isArray ? [] : buildDefaultShape(nestedFields, schemas);
|
||||
} else {
|
||||
shape[f.name] = null;
|
||||
}
|
||||
}
|
||||
return shape;
|
||||
}
|
||||
|
||||
function initEditValue(value: any, field: FieldConfig, schemas: Record<string, any>): any {
|
||||
if (field.isArray) {
|
||||
return value ? value.map((item: any) => ({ ...item })) : [];
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return { ...value };
|
||||
}
|
||||
return buildDefaultShape(
|
||||
field.refSchema ? extractFields(field.refSchema, schemas[field.refSchema], schemas) : [],
|
||||
schemas
|
||||
);
|
||||
}
|
||||
|
||||
function applyInlineFormat(obj: any, format: string): string {
|
||||
if (!obj || typeof obj !== "object") return String(obj ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function NumberField({ field, value, onChange, error }: Props) {
|
||||
const isFloat = field.type === "number" || field.format === "float";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type="number"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === "") {
|
||||
onChange("");
|
||||
} else {
|
||||
onChange(isFloat ? parseFloat(raw) : parseInt(raw, 10));
|
||||
}
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
inputProps={isFloat ? { step: "any" } : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { TextField } from "@mui/material";
|
||||
import type { FieldConfig } from "../../../types";
|
||||
|
||||
interface Props {
|
||||
field: FieldConfig;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function StringField({ field, value, onChange, error }: Props) {
|
||||
const inputType = field.format === "email" ? "email" : "text";
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={field.label}
|
||||
type={inputType}
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
error={!!error}
|
||||
helperText={error ?? field.description}
|
||||
placeholder={field.description}
|
||||
size="small"
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
react-openapi/src/components/fields/utils.ts
Normal file
4
react-openapi/src/components/fields/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function applyDisplayFormat(item: any, format: string): string {
|
||||
if (!item || typeof item !== "object") return String(item ?? "");
|
||||
return format.replace(/\{(\w+)\}/g, (_, key) => String(item[key] ?? ""));
|
||||
}
|
||||
Reference in New Issue
Block a user