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:
Agent Zero
2026-07-23 23:35:35 +02:00
parent 63b99ba489
commit bdad91a649
15 changed files with 762 additions and 4 deletions
@@ -0,0 +1,147 @@
/**
* CustomFieldRenderer tests — renders custom fields based on field_type.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import type { CustomFieldDefinition } from '@/api/customFields';
// Mock i18n
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Mock UI components
vi.mock('@/components/ui/Input', () => ({
Input: ({ value, onChange, type, id, ...props }: any) => (
<input
data-testid={props['data-testid'] || `input-${id}`}
type={type || 'text'}
value={value || ''}
onChange={onChange}
/>
),
}));
vi.mock('@/components/ui/Select', () => ({
Select: ({ value, onChange, id, options, children, ...props }: any) => (
<select
data-testid={props['data-testid'] || `select-${id}`}
value={value || ''}
onChange={onChange}
>
{options ? options.map((o: any) => <option key={o.value} value={o.value}>{o.label}</option>) : children}
</select>
),
}));
const mockFields: CustomFieldDefinition[] = [
{
name: 'lead_source',
label: 'Lead Source',
label_key: 'custom.leadSource',
field_type: 'select',
options: ['website', 'referral', 'cold_call'],
default_value: null,
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: 'website',
},
{
name: 'score',
label: 'Score',
label_key: 'custom.score',
field_type: 'number',
options: [],
default_value: 0,
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: 42,
},
{
name: 'active',
label: 'Active',
label_key: 'custom.active',
field_type: 'boolean',
options: [],
default_value: false,
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: true,
},
];
describe('CustomFieldRenderer', () => {
it('renders read mode with field values', () => {
render(<CustomFieldRenderer fields={mockFields} mode="read" />);
expect(screen.getByTestId('custom-fields-read')).toBeInTheDocument();
expect(screen.getByText('website')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
expect(screen.getByText('✓')).toBeInTheDocument();
});
it('renders edit mode with form controls', () => {
render(
<CustomFieldRenderer
fields={mockFields}
mode="edit"
values={{ lead_source: 'website', score: 42, active: true }}
onChange={vi.fn()}
/>
);
expect(screen.getByTestId('custom-fields-edit')).toBeInTheDocument();
expect(screen.getByTestId('select-cf-lead_source')).toBeInTheDocument();
expect(screen.getByTestId('input-cf-score')).toBeInTheDocument();
expect(document.getElementById('cf-active')).toBeInTheDocument();
});
it('calls onChange when select value changes', () => {
const onChange = vi.fn();
render(
<CustomFieldRenderer
fields={[mockFields[0]]}
mode="edit"
values={{ lead_source: 'website' }}
onChange={onChange}
/>
);
const select = screen.getByTestId('select-cf-lead_source');
fireEvent.change(select, { target: { value: 'referral' } });
expect(onChange).toHaveBeenCalledWith('lead_source', 'referral');
});
it('renders nothing when fields array is empty', () => {
const { container } = render(<CustomFieldRenderer fields={[]} mode="read" />);
expect(container.firstChild).toBeNull();
});
it('renders multiselect field with checkboxes', () => {
const multiField: CustomFieldDefinition = {
name: 'tags',
label: 'Tags',
label_key: 'custom.tags',
field_type: 'multiselect',
options: ['vip', 'customer', 'lead'],
default_value: [],
required: false,
entity: 'contact',
plugin: 'test_plugin',
value: ['vip'],
};
render(
<CustomFieldRenderer
fields={[multiField]}
mode="edit"
values={{ tags: ['vip'] }}
onChange={vi.fn()}
/>
);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(3);
expect(checkboxes[0]).toBeChecked();
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Custom fields API hooks — merge plugin definitions with stored values.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPatch } from './client';
export interface CustomFieldDefinition {
name: string;
label: string;
label_key: string;
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
options: string[];
default_value: any;
required: boolean;
entity: string;
plugin: string;
value: any;
}
export interface CustomFieldsResponse {
fields: CustomFieldDefinition[];
}
export function useCustomFields(contactId?: string) {
return useQuery({
queryKey: ['custom-fields', contactId],
queryFn: () => apiGet<CustomFieldsResponse>(`/contacts/${contactId}/custom-fields`),
enabled: !!contactId,
});
}
export function useUpdateCustomFields() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contactId, values }: { contactId: string; values: Record<string, any> }) =>
apiPatch<CustomFieldsResponse>(`/contacts/${contactId}/custom-fields`, { values }),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['custom-fields', variables.contactId] });
},
});
}
@@ -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: [],
},
];
@@ -25,6 +25,7 @@ const mockManifests: PluginUiManifest[] = [
dashboard_widgets: [
{ id: 'widget_a', label_key: 'widgets.a', label: 'Widget A', component: '@/components/WidgetA', icon: 'A', order: 200, col_span: 2, row_span: 1, permission: '' },
],
custom_fields: [],
},
{
name: 'plugin_b',
@@ -46,6 +47,7 @@ const mockManifests: PluginUiManifest[] = [
dashboard_widgets: [
{ id: 'widget_b', label_key: 'widgets.b', label: 'Widget B', component: '@/components/WidgetB', icon: 'B', order: 100, col_span: 1, row_span: 1, permission: '' },
],
custom_fields: [],
},
];
+20
View File
@@ -50,6 +50,17 @@ export interface PluginDashboardWidget {
permission: string;
}
export interface PluginCustomFieldDefinition {
name: string;
label: string;
label_key: string;
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'boolean';
options: string[];
default_value: any;
required: boolean;
entity: string;
}
export interface PluginUiManifest {
name: string;
display_name: string;
@@ -60,6 +71,7 @@ export interface PluginUiManifest {
detail_tabs: PluginDetailTab[];
settings_pages: PluginSettingsPage[];
dashboard_widgets: PluginDashboardWidget[];
custom_fields: PluginCustomFieldDefinition[];
}
interface PluginState {
@@ -77,6 +89,7 @@ interface PluginState {
getDetailTabsForEntity: (entityType: string) => PluginDetailTab[];
getAllSettingsPages: () => PluginSettingsPage[];
getAllDashboardWidgets: () => PluginDashboardWidget[];
getCustomFieldsForEntity: (entityType: string) => PluginCustomFieldDefinition[];
}
export const usePluginStore = create<PluginState>((set, get) => ({
@@ -125,4 +138,11 @@ export const usePluginStore = create<PluginState>((set, get) => ({
.flatMap((m) => m.dashboard_widgets)
.sort((a, b) => a.order - b.order);
},
getCustomFieldsForEntity: (entityType: string) => {
const { manifests } = get();
return manifests
.flatMap((m) => m.custom_fields || [])
.filter((cf) => cf.entity === entityType);
},
}));