a7e3890634
- Tags UI: TagsPage (CRUD, color picker), TagBadge, TagSelector (multi-select, inline creation) - Custom Fields Backend: model, schema, service, routes, migration 0041 - Custom Fields Frontend: CustomFieldsPage (definitions CRUD), CustomFieldRenderer (dynamic field rendering) - Custom Fields: _collect_custom_field_definitions() extended to merge DB definitions with plugin definitions - Notifications Bell: NotificationBell (30s polling, unread badge), NotificationDropdown, NotificationItem - NotificationBell integrated into TopBar - Routes: /tags, /settings/custom-fields registered - Settings nav: Custom Fields entry added - Menu items: Tags added to automation plugin manifest
176 lines
5.1 KiB
TypeScript
176 lines
5.1 KiB
TypeScript
/**
|
|
* Dynamic custom field renderer.
|
|
* Renders the appropriate input element based on field_type.
|
|
*/
|
|
|
|
import React, { useId } from 'react';
|
|
import { Input } from '@/components/ui/Input';
|
|
import { Select } from '@/components/ui/Select';
|
|
import { Badge } from '@/components/ui/Badge';
|
|
import { X } from 'lucide-react';
|
|
import type { CustomFieldDefinition } from '@/api/customFieldDefinitions';
|
|
|
|
export interface CustomFieldRendererProps {
|
|
definition: CustomFieldDefinition;
|
|
value: any;
|
|
onChange: (value: any) => void;
|
|
}
|
|
|
|
export function CustomFieldRenderer({ definition, value, onChange }: CustomFieldRendererProps) {
|
|
const generatedId = useId();
|
|
const fieldId = `cf-${definition.id || generatedId}`;
|
|
const { field_type, options, required } = definition;
|
|
|
|
// --- Boolean: checkbox ---
|
|
if (field_type === 'boolean') {
|
|
return (
|
|
<div className="w-full">
|
|
<label
|
|
htmlFor={fieldId}
|
|
className="flex items-center gap-2 text-sm font-medium text-secondary-700 cursor-pointer"
|
|
>
|
|
<input
|
|
id={fieldId}
|
|
type="checkbox"
|
|
checked={!!value}
|
|
onChange={(e) => onChange(e.target.checked)}
|
|
required={required}
|
|
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<span>
|
|
{definition.label}
|
|
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Multiselect: chips with toggle ---
|
|
if (field_type === 'multiselect') {
|
|
const selectedValues: string[] = Array.isArray(value)
|
|
? value
|
|
: value != null && value !== ''
|
|
? [String(value)]
|
|
: [];
|
|
const availableOptions = options || [];
|
|
|
|
const toggleOption = (opt: string) => {
|
|
if (selectedValues.includes(opt)) {
|
|
onChange(selectedValues.filter((v) => v !== opt));
|
|
} else {
|
|
onChange([...selectedValues, opt]);
|
|
}
|
|
};
|
|
|
|
const removeChip = (opt: string) => {
|
|
onChange(selectedValues.filter((v) => v !== opt));
|
|
};
|
|
|
|
const unselected = availableOptions.filter((o) => !selectedValues.includes(o));
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
|
{definition.label}
|
|
{required && <span className="text-danger-500 ml-1" aria-label="required">*</span>}
|
|
</label>
|
|
{selectedValues.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{selectedValues.map((opt) => (
|
|
<Badge key={opt} variant="primary" className="gap-1">
|
|
{opt}
|
|
<button
|
|
type="button"
|
|
onClick={() => removeChip(opt)}
|
|
className="inline-flex items-center justify-center rounded-full hover:bg-primary-200"
|
|
aria-label={`Remove ${opt}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
{unselected.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{unselected.map((opt) => (
|
|
<button
|
|
key={opt}
|
|
type="button"
|
|
onClick={() => toggleOption(opt)}
|
|
className="px-2.5 py-0.5 rounded-full text-xs font-medium border border-secondary-300 text-secondary-700 hover:bg-secondary-100"
|
|
>
|
|
+ {opt}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : availableOptions.length === 0 ? (
|
|
<p className="text-sm text-secondary-400">Keine Optionen verfügbar</p>
|
|
) : (
|
|
<p className="text-sm text-secondary-400">Alle Optionen ausgewählt</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Select: dropdown ---
|
|
if (field_type === 'select') {
|
|
const selectOptions = (options || []).map((opt) => ({ value: opt, label: opt }));
|
|
return (
|
|
<Select
|
|
id={fieldId}
|
|
label={definition.label}
|
|
required={required}
|
|
options={selectOptions}
|
|
placeholder="— Bitte wählen —"
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// --- Number: numeric input ---
|
|
if (field_type === 'number') {
|
|
return (
|
|
<Input
|
|
id={fieldId}
|
|
label={definition.label}
|
|
required={required}
|
|
type="number"
|
|
value={value ?? ''}
|
|
onChange={(e) => {
|
|
const raw = e.target.value;
|
|
onChange(raw === '' ? null : Number(raw));
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// --- Date: date input ---
|
|
if (field_type === 'date') {
|
|
return (
|
|
<Input
|
|
id={fieldId}
|
|
label={definition.label}
|
|
required={required}
|
|
type="date"
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// --- Text (default) ---
|
|
return (
|
|
<Input
|
|
id={fieldId}
|
|
label={definition.label}
|
|
required={required}
|
|
type="text"
|
|
value={value ?? ''}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
/>
|
|
);
|
|
}
|