Phase 2: Tags UI, Custom Fields UI, Notifications Bell
- 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
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user