feat(5.20): Custom Fields — plugin-defined fields in Contact UI
- Add CustomFieldDefinition to PluginManifest (text/number/date/select/multiselect/boolean)
- Add GET/PATCH /api/v1/contacts/{id}/custom-fields routes
- Merge plugin definitions with stored values in contacts.custom JSONB
- Add CustomFieldRenderer component (read + edit modes)
- Integrate into ContactDetail (read-only) and ContactEditModal (editable)
- Add custom_fields to pluginStore and active manifests API
- Add useCustomFields/useUpdateCustomFields React Query hooks
- Tests: test_custom_fields.py (9 tests) + CustomFieldRenderer.test.tsx (5 tests)
- i18n keys already present (contacts.customFields)
This commit is contained in:
@@ -14,6 +14,8 @@ import { usePluginStore } from '@/store/pluginStore';
|
||||
import { useAIUIControlStore } from '@/store/aiUIControlStore';
|
||||
import { PluginPage } from '@/components/plugins/PluginLoader';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
|
||||
import { useCustomFields } from '@/api/customFields';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
type ContactPerson,
|
||||
@@ -177,6 +179,10 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
}, [aiActiveModal]);
|
||||
const user = useAuthStore(s => s.user);
|
||||
|
||||
// Custom fields from plugin definitions
|
||||
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
|
||||
const { data: customFieldsData } = useCustomFields(contact?.id);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="contact-detail-loading">
|
||||
@@ -420,11 +426,12 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
|
||||
</Section>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{contact.custom && Object.keys(contact.custom).length > 0 && (
|
||||
{contact.id && customFieldDefs.length > 0 && (
|
||||
<Section title={t('contacts.customFields')}>
|
||||
<pre className="text-xs text-secondary-700 bg-secondary-50 rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(contact.custom, null, 2)}
|
||||
</pre>
|
||||
<CustomFieldRenderer
|
||||
fields={customFieldsData?.fields || []}
|
||||
mode="read"
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
useCreateUnifiedContact,
|
||||
useUpdateUnifiedContact,
|
||||
} from '@/api/hooks';
|
||||
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
|
||||
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
|
||||
import { usePluginStore } from '@/store/pluginStore';
|
||||
|
||||
export interface ContactEditModalProps {
|
||||
open: boolean;
|
||||
@@ -105,6 +108,26 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
|
||||
const currentType = watch('type');
|
||||
|
||||
// Custom fields
|
||||
const customFieldDefs = usePluginStore(s => s.getCustomFieldsForEntity('contact'));
|
||||
const { data: customFieldsData } = useCustomFields(contact?.id);
|
||||
const updateCustomFields = useUpdateCustomFields();
|
||||
const [customValues, setCustomValues] = React.useState<Record<string, any>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && customFieldsData?.fields) {
|
||||
const vals: Record<string, any> = {};
|
||||
for (const f of customFieldsData.fields) {
|
||||
vals[f.name] = f.value ?? f.default_value ?? null;
|
||||
}
|
||||
setCustomValues(vals);
|
||||
}
|
||||
}, [open, customFieldsData]);
|
||||
|
||||
const handleCustomFieldChange = (name: string, value: any) => {
|
||||
setCustomValues(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -164,15 +187,27 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
};
|
||||
|
||||
try {
|
||||
let savedId: string | undefined;
|
||||
if (isEdit && contact) {
|
||||
await updateMutation.mutateAsync({ id: contact.id, data });
|
||||
savedId = contact.id;
|
||||
toast.success(t('contacts.updated'));
|
||||
onSaved?.(contact.id);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(data) as { id: string };
|
||||
savedId = result.id;
|
||||
toast.success(t('contacts.created'));
|
||||
onSaved?.(result.id);
|
||||
}
|
||||
// Save custom fields if any definitions exist and we have a contact ID
|
||||
if (savedId && customFieldDefs.length > 0 && Object.keys(customValues).length > 0) {
|
||||
try {
|
||||
await updateCustomFields.mutateAsync({ contactId: savedId, values: customValues });
|
||||
} catch (cfErr: any) {
|
||||
// Don't fail the whole save if custom fields fail
|
||||
console.error('Custom fields save failed:', cfErr);
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
|
||||
@@ -281,6 +316,19 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{customFieldDefs.length > 0 && (
|
||||
<div className="border border-secondary-200 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.customFields')}</h3>
|
||||
<CustomFieldRenderer
|
||||
fields={customFieldsData?.fields || customFieldDefs.map(d => ({ ...d, value: d.default_value, plugin: '' }))}
|
||||
mode="edit"
|
||||
values={customValues}
|
||||
onChange={handleCustomFieldChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* CustomFieldRenderer — renders custom fields based on field_type.
|
||||
* Supports read-only and editable modes.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import type { CustomFieldDefinition } from '@/api/customFields';
|
||||
|
||||
export interface CustomFieldRendererProps {
|
||||
fields: CustomFieldDefinition[];
|
||||
mode: 'read' | 'edit';
|
||||
values?: Record<string, any>;
|
||||
onChange?: (name: string, value: any) => void;
|
||||
}
|
||||
|
||||
function formatValue(value: any, fieldType: string): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (fieldType === 'boolean') return value ? '✓' : '✗';
|
||||
if (fieldType === 'multiselect' && Array.isArray(value)) return value.join(', ');
|
||||
if (fieldType === 'date' && value) {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function CustomFieldRenderer({ fields, mode, values = {}, onChange }: CustomFieldRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!fields || fields.length === 0) return null;
|
||||
|
||||
if (mode === 'read') {
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-3" data-testid="custom-fields-read">
|
||||
{fields.map((field) => {
|
||||
const label = field.label_key ? t(field.label_key) : field.label || field.name;
|
||||
const value = values[field.name] ?? field.value;
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<dt className="text-xs font-medium text-secondary-500">{label}</dt>
|
||||
<dd className="text-sm text-secondary-900">{formatValue(value, field.field_type)}</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3" data-testid="custom-fields-edit">
|
||||
{fields.map((field) => {
|
||||
const label = field.label_key ? t(field.label_key) : field.label || field.name;
|
||||
const value = values[field.name] ?? field.value ?? field.default_value ?? '';
|
||||
|
||||
if (field.field_type === 'boolean') {
|
||||
return (
|
||||
<div key={field.name} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`cf-${field.name}`}
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange?.(field.name, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-secondary-300"
|
||||
/>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-sm text-secondary-700">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === 'select') {
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
<Select
|
||||
id={`cf-${field.name}`}
|
||||
value={value || ''}
|
||||
options={field.options.map((opt) => ({ value: opt, label: opt }))}
|
||||
onChange={(e) => onChange?.(field.name, e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === 'multiselect') {
|
||||
const selected: string[] = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{field.options.map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onChange?.(field.name, [...selected, opt]);
|
||||
} else {
|
||||
onChange?.(field.name, selected.filter((v) => v !== opt));
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-secondary-300"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text, number, date
|
||||
return (
|
||||
<div key={field.name}>
|
||||
<label htmlFor={`cf-${field.name}`} className="text-xs font-medium text-secondary-500">
|
||||
{label}{field.required && ' *'}
|
||||
</label>
|
||||
<Input
|
||||
id={`cf-${field.name}`}
|
||||
type={field.field_type === 'number' ? 'number' : field.field_type === 'date' ? 'date' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
let val: any = e.target.value;
|
||||
if (field.field_type === 'number' && val !== '') val = Number(val);
|
||||
if (val === '') val = null;
|
||||
onChange?.(field.name, val);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
detail_tabs: [],
|
||||
settings_pages: [],
|
||||
dashboard_widgets: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
detail_tabs: [],
|
||||
settings_pages: [],
|
||||
dashboard_widgets: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
{
|
||||
name: 'calendar_plugin',
|
||||
@@ -42,6 +43,7 @@ const mockManifests: PluginUiManifest[] = [
|
||||
detail_tabs: [],
|
||||
settings_pages: [],
|
||||
dashboard_widgets: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user