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();
});
});