### Summary of Changes:
1. **Spec-Driven Enums**:
- Updated `openapi_loader.ts` to collect all standalone enum schemas (e.g., `FetchRequestStatus`) into the `AppConfig.enums` map.
- Implemented `mergeProperties` and `oneOf`/`anyOf` resolution in `openapi_loader.ts` to ensure complex schemas like `FetchRequest` (using `allOf`) and `source` (using `oneOf`) are correctly parsed.
2. **Customizable Labeling**:
- Added `enumOption` (template-based) and `enumLabels` (mapping-based) to the config and field types.
- Implemented `resolveTemplate` in `utils/options.ts` to handle placeholders like `'{name} {number}'` or plain field names.
3. **UI Integration**:
- **`FormField.tsx`**: Updated relation and enum selects to use `getFieldOptions()` for correct key/value pairs and labels. Added value normalization to extract keys from API objects.
- **`EnhancedTable.tsx`**: Updated `valueOptions` to use key/value pairs for `singleSelect` and updated `FieldRenderer` to show the human-readable label for enums.
- **`FilterBar.tsx`**: Updated `extractOptions` to use spec-driven labels for enum filters.
- **`ResourceView.tsx`**: Centralized filter matching logic into a `getDisplayString` helper, ensuring filter comparisons use the same templates as the UI labels.
4. **App Fixes**:
- `FetchRequests.tsx` and `FetchRequestDetail.tsx` now derive status and format options from the OpenAPI spec via `useConfig()` instead of using hardcoded arrays.
Reviewed-on: #10
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
227 lines
6.0 KiB
TypeScript
227 lines
6.0 KiB
TypeScript
import * as React from 'react';
|
|
import {
|
|
TextField,
|
|
FormControl,
|
|
InputLabel,
|
|
Select,
|
|
MenuItem,
|
|
FormControlLabel,
|
|
Checkbox,
|
|
Typography,
|
|
Box,
|
|
Divider,
|
|
} from '@mui/material';
|
|
import { ResourceField } from '../../types/config';
|
|
import { getFieldOptions } from '../../utils/options';
|
|
import ImageUploadField from './ImageUploadField';
|
|
|
|
interface FormFieldProps {
|
|
name: string;
|
|
field: ResourceField;
|
|
value: any;
|
|
onChange: (val: any) => void;
|
|
disabled?: boolean;
|
|
uploadFile: (file: File) => Promise<string | null>;
|
|
uploading: boolean;
|
|
baseUrl: string;
|
|
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
|
}
|
|
|
|
export default function FormField({
|
|
name,
|
|
field,
|
|
value,
|
|
onChange,
|
|
disabled,
|
|
uploadFile,
|
|
uploading,
|
|
baseUrl,
|
|
relationDataMap = {},
|
|
}: FormFieldProps) {
|
|
const label = field.label;
|
|
|
|
// 1. Recursive Rendering for Objects (Not Relations)
|
|
if (field.type === 'object' && field.schema && !field.relation) {
|
|
return (
|
|
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
|
{label}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{Object.entries(field.schema).map(([subKey, subField]) => (
|
|
<FormField
|
|
key={subKey}
|
|
name={`${name}.${subKey}`}
|
|
field={subField}
|
|
value={value?.[subKey]}
|
|
onChange={(newVal) => {
|
|
const updated = { ...(value || {}), [subKey]: newVal };
|
|
onChange(updated);
|
|
}}
|
|
disabled={disabled}
|
|
uploadFile={uploadFile}
|
|
uploading={uploading}
|
|
baseUrl={baseUrl}
|
|
relationDataMap={relationDataMap}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<FormControl fullWidth>
|
|
<InputLabel shrink>{label}</InputLabel>
|
|
<Select
|
|
multiple={isArrayRelation}
|
|
value={normalizedValue}
|
|
label={label}
|
|
displayEmpty
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
renderValue={(selected: any) => {
|
|
if (isArrayRelation) {
|
|
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
|
|
}
|
|
return options.find(o => o.key === selected)?.value ?? selected;
|
|
}}
|
|
>
|
|
{options.map((opt) => (
|
|
<MenuItem key={opt.key} value={opt.key}>
|
|
{opt.value}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
// 3. Image Handling
|
|
if (field.type === 'image') {
|
|
return (
|
|
<ImageUploadField
|
|
label={label}
|
|
value={value}
|
|
onUpload={async (file: any) => {
|
|
const url = await uploadFile(file);
|
|
if (url) onChange(url);
|
|
}}
|
|
uploading={uploading}
|
|
baseUrl={baseUrl}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 4. Boolean Handling
|
|
if (field.type === 'boolean') {
|
|
return (
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={!!value}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
disabled={disabled}
|
|
/>
|
|
}
|
|
label={label}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 5. Enum Handling
|
|
if (field.type === 'enum') {
|
|
const options = getFieldOptions(field);
|
|
return (
|
|
<FormControl fullWidth>
|
|
<InputLabel>{label}</InputLabel>
|
|
<Select
|
|
value={value || ''}
|
|
label={label}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
>
|
|
{options.map((opt) => (
|
|
<MenuItem key={opt.key} value={opt.key}>
|
|
{opt.value}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
);
|
|
}
|
|
|
|
// 6. Common Text Fields
|
|
if (field.type === 'datetime' || field.type === 'date') {
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
label={label}
|
|
type={field.type === 'datetime' ? "datetime-local" : "date"}
|
|
InputLabelProps={{ shrink: true }}
|
|
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
required={field.required}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.type === 'markdown' || field.type === 'string') {
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
label={label}
|
|
value={value || ''}
|
|
multiline={field.type === 'markdown'}
|
|
rows={field.type === 'markdown' ? 4 : 1}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
required={field.required}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.type === 'number') {
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
label={label}
|
|
type="number"
|
|
value={value === undefined || value === null ? '' : value}
|
|
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
|
disabled={disabled}
|
|
required={field.required}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TextField
|
|
fullWidth
|
|
label={label}
|
|
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
|
disabled
|
|
/>
|
|
);
|
|
}
|