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
+2
View File
@@ -47,6 +47,7 @@ from app.routes import (
sequences, sequences,
system_settings, system_settings,
attachments, attachments,
custom_fields,
) )
@@ -304,6 +305,7 @@ def create_app() -> FastAPI:
app.include_router(attachments.router) app.include_router(attachments.router)
app.include_router(addresses.router) app.include_router(addresses.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(custom_fields.router)
# ── Register plugin routes (before SPA catch-all) ────────────────── # ── Register plugin routes (before SPA catch-all) ──────────────────
registry = get_registry() registry = get_registry()
+18
View File
@@ -138,6 +138,21 @@ class FrontendDashboardWidget(BaseModel):
permission: str = Field(default="", description="Optional permission required") permission: str = Field(default="", description="Optional permission required")
class CustomFieldDefinition(BaseModel):
"""A custom field definition contributed by a plugin manifest."""
name: str = Field(..., min_length=1, max_length=80, description="Unique field name (snake_case)")
label: str = Field(default="", description="Human-readable label (fallback if i18n key missing)")
label_key: str = Field(default="", description="i18n key for the field label")
field_type: str = Field(
..., pattern="^(text|number|date|select|multiselect|boolean)$", description="Field type"
)
options: list[str] = Field(default_factory=list, description="Options for select/multiselect types")
default_value: Any = Field(default=None, description="Default value for the field")
required: bool = Field(default=False, description="Whether the field is required")
entity: str = Field(default="contact", description="Entity type this field applies to (contact/file/etc)")
class MiniAppContribution(BaseModel): class MiniAppContribution(BaseModel):
"""A MiniApp contributed by a plugin manifest.""" """A MiniApp contributed by a plugin manifest."""
@@ -214,6 +229,9 @@ class PluginManifest(BaseModel):
miniapps: list[MiniAppContribution] = Field( miniapps: list[MiniAppContribution] = Field(
default_factory=list, description="MiniApps contributed by this plugin" default_factory=list, description="MiniApps contributed by this plugin"
) )
custom_fields: list[CustomFieldDefinition] = Field(
default_factory=list, description="Custom field definitions contributed by this plugin"
)
@field_validator("name") @field_validator("name")
+1
View File
@@ -760,6 +760,7 @@ class PluginRegistry:
"detail_tabs": [tab.model_dump() for tab in m.detail_tabs], "detail_tabs": [tab.model_dump() for tab in m.detail_tabs],
"settings_pages": [page.model_dump() for page in m.settings_pages], "settings_pages": [page.model_dump() for page in m.settings_pages],
"dashboard_widgets": [widget.model_dump() for widget in m.dashboard_widgets], "dashboard_widgets": [widget.model_dump() for widget in m.dashboard_widgets],
"custom_fields": [cf.model_dump() for cf in m.custom_fields],
} }
) )
return manifests return manifests
+158
View File
@@ -0,0 +1,158 @@
"""Custom fields routes — merge plugin definitions with stored values."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Body
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.models.contact import Contact
from app.plugins.registry import get_registry
router = APIRouter(prefix="/api/v1/contacts", tags=["custom-fields"])
class CustomFieldUpdateRequest(BaseModel):
"""Request body for updating custom field values."""
values: dict[str, Any] = {}
def _collect_custom_field_definitions(entity: str = "contact") -> list[dict[str, Any]]:
"""Collect all custom field definitions from active plugin manifests."""
definitions: list[dict[str, Any]] = []
seen_names: set[str] = set()
registry = get_registry()
for plugin in registry._plugins.values():
manifest = plugin.manifest
for cf in manifest.custom_fields:
if cf.entity != entity:
continue
if cf.name in seen_names:
continue
seen_names.add(cf.name)
definitions.append(
{
"name": cf.name,
"label": cf.label,
"label_key": cf.label_key,
"field_type": cf.field_type,
"options": cf.options,
"default_value": cf.default_value,
"required": cf.required,
"entity": cf.entity,
"plugin": manifest.name,
}
)
return definitions
def _merge_definitions_with_values(
definitions: list[dict[str, Any]], stored: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""Merge field definitions with stored values, applying defaults."""
stored = stored or {}
result: list[dict[str, Any]] = []
for d in definitions:
name = d["name"]
value = stored.get(name, d.get("default_value"))
entry = {**d, "value": value}
result.append(entry)
return result
@router.get("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:read"))])
async def get_custom_fields(
contact_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get all custom fields for a contact (merged definitions + stored values)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
definitions = _collect_custom_field_definitions("contact")
merged = _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
@router.patch("/{contact_id}/custom-fields", dependencies=[Depends(require_permission("contacts:write"))])
async def update_custom_fields(
contact_id: str,
body: CustomFieldUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Update custom field values for a contact (stored in contacts.custom JSONB)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}) from None
result = await db.execute(
select(Contact).where(Contact.id == cid, Contact.tenant_id == tenant_id)
)
contact = result.scalar_one_or_none()
if contact is None:
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
# Validate against definitions
definitions = _collect_custom_field_definitions("contact")
def_map = {d["name"]: d for d in definitions}
current_custom = dict(contact.custom or {})
for name, value in body.values.items():
if name not in def_map:
raise HTTPException(
400,
detail={"detail": f"Unknown custom field: {name}", "code": "unknown_field"},
)
field_def = def_map[name]
# Validate required
if field_def["required"] and (value is None or value == ""):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' is required", "code": "required_field"},
)
# Validate select/multiselect options
if field_def["field_type"] == "select" and value is not None:
if value not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option for field '{name}'", "code": "invalid_option"},
)
if field_def["field_type"] == "multiselect" and value is not None:
if not isinstance(value, list):
raise HTTPException(
400,
detail={"detail": f"Field '{name}' must be a list", "code": "invalid_type"},
)
for v in value:
if v not in field_def["options"]:
raise HTTPException(
400,
detail={"detail": f"Invalid option '{v}' for field '{name}'", "code": "invalid_option"},
)
current_custom[name] = value
contact.custom = current_custom
await db.flush()
merged = _merge_definitions_with_values(definitions, contact.custom)
return {"fields": merged}
BIN
View File
Binary file not shown.
@@ -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 { useAIUIControlStore } from '@/store/aiUIControlStore';
import { PluginPage } from '@/components/plugins/PluginLoader'; import { PluginPage } from '@/components/plugins/PluginLoader';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields } from '@/api/customFields';
import { import {
type UnifiedContact, type UnifiedContact,
type ContactPerson, type ContactPerson,
@@ -177,6 +179,10 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
}, [aiActiveModal]); }, [aiActiveModal]);
const user = useAuthStore(s => s.user); 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) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12" data-testid="contact-detail-loading"> <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> </Section>
{/* Custom Fields */} {/* Custom Fields */}
{contact.custom && Object.keys(contact.custom).length > 0 && ( {contact.id && customFieldDefs.length > 0 && (
<Section title={t('contacts.customFields')}> <Section title={t('contacts.customFields')}>
<pre className="text-xs text-secondary-700 bg-secondary-50 rounded p-2 overflow-x-auto"> <CustomFieldRenderer
{JSON.stringify(contact.custom, null, 2)} fields={customFieldsData?.fields || []}
</pre> mode="read"
/>
</Section> </Section>
)} )}
@@ -13,6 +13,9 @@ import {
useCreateUnifiedContact, useCreateUnifiedContact,
useUpdateUnifiedContact, useUpdateUnifiedContact,
} from '@/api/hooks'; } from '@/api/hooks';
import { CustomFieldRenderer } from '@/components/contacts/CustomFieldRenderer';
import { useCustomFields, useUpdateCustomFields } from '@/api/customFields';
import { usePluginStore } from '@/store/pluginStore';
export interface ContactEditModalProps { export interface ContactEditModalProps {
open: boolean; open: boolean;
@@ -105,6 +108,26 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
const currentType = watch('type'); 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 // Reset form when modal opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -164,15 +187,27 @@ export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEdi
}; };
try { try {
let savedId: string | undefined;
if (isEdit && contact) { if (isEdit && contact) {
await updateMutation.mutateAsync({ id: contact.id, data }); await updateMutation.mutateAsync({ id: contact.id, data });
savedId = contact.id;
toast.success(t('contacts.updated')); toast.success(t('contacts.updated'));
onSaved?.(contact.id); onSaved?.(contact.id);
} else { } else {
const result = await createMutation.mutateAsync(data) as { id: string }; const result = await createMutation.mutateAsync(data) as { id: string };
savedId = result.id;
toast.success(t('contacts.created')); toast.success(t('contacts.created'));
onSaved?.(result.id); 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(); onClose();
} catch (err: any) { } catch (err: any) {
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed'))); 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>
</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 */} {/* Actions */}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button> <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: [], detail_tabs: [],
settings_pages: [], settings_pages: [],
dashboard_widgets: [], dashboard_widgets: [],
custom_fields: [],
}, },
]; ];
@@ -29,6 +29,7 @@ const mockManifests: PluginUiManifest[] = [
detail_tabs: [], detail_tabs: [],
settings_pages: [], settings_pages: [],
dashboard_widgets: [], dashboard_widgets: [],
custom_fields: [],
}, },
{ {
name: 'calendar_plugin', name: 'calendar_plugin',
@@ -42,6 +43,7 @@ const mockManifests: PluginUiManifest[] = [
detail_tabs: [], detail_tabs: [],
settings_pages: [], settings_pages: [],
dashboard_widgets: [], dashboard_widgets: [],
custom_fields: [],
}, },
]; ];
@@ -25,6 +25,7 @@ const mockManifests: PluginUiManifest[] = [
dashboard_widgets: [ 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: '' }, { 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', name: 'plugin_b',
@@ -46,6 +47,7 @@ const mockManifests: PluginUiManifest[] = [
dashboard_widgets: [ 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: '' }, { 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; 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 { export interface PluginUiManifest {
name: string; name: string;
display_name: string; display_name: string;
@@ -60,6 +71,7 @@ export interface PluginUiManifest {
detail_tabs: PluginDetailTab[]; detail_tabs: PluginDetailTab[];
settings_pages: PluginSettingsPage[]; settings_pages: PluginSettingsPage[];
dashboard_widgets: PluginDashboardWidget[]; dashboard_widgets: PluginDashboardWidget[];
custom_fields: PluginCustomFieldDefinition[];
} }
interface PluginState { interface PluginState {
@@ -77,6 +89,7 @@ interface PluginState {
getDetailTabsForEntity: (entityType: string) => PluginDetailTab[]; getDetailTabsForEntity: (entityType: string) => PluginDetailTab[];
getAllSettingsPages: () => PluginSettingsPage[]; getAllSettingsPages: () => PluginSettingsPage[];
getAllDashboardWidgets: () => PluginDashboardWidget[]; getAllDashboardWidgets: () => PluginDashboardWidget[];
getCustomFieldsForEntity: (entityType: string) => PluginCustomFieldDefinition[];
} }
export const usePluginStore = create<PluginState>((set, get) => ({ export const usePluginStore = create<PluginState>((set, get) => ({
@@ -125,4 +138,11 @@ export const usePluginStore = create<PluginState>((set, get) => ({
.flatMap((m) => m.dashboard_widgets) .flatMap((m) => m.dashboard_widgets)
.sort((a, b) => a.order - b.order); .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);
},
})); }));
+164
View File
@@ -0,0 +1,164 @@
"""Custom fields tests — plugin-defined custom fields on contacts."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
@pytest.mark.asyncio
class TestCustomFieldsGet:
"""GET /api/v1/contacts/{id}/custom-fields"""
async def test_get_custom_fields_returns_200(self, client: AsyncClient, db_session):
"""GET custom fields for a contact returns 200 with merged definitions."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create a contact first
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Test Corp", "displayname": "Test Corp"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
contact_id = resp.json()["id"]
resp = await client.get(
f"/api/v1/contacts/{contact_id}/custom-fields",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert "fields" in data
assert isinstance(data["fields"], list)
async def test_get_custom_fields_invalid_uuid_returns_400(self, client: AsyncClient, db_session):
"""GET custom fields with invalid UUID returns 400."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get(
"/api/v1/contacts/not-a-uuid/custom-fields",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 400
async def test_get_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
"""GET custom fields for non-existent contact returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.get(
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestCustomFieldsUpdate:
"""PATCH /api/v1/contacts/{id}/custom-fields"""
async def test_update_custom_fields_returns_200(self, client: AsyncClient, db_session):
"""PATCH custom fields stores values in contacts.custom JSONB."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
# Create a contact
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Custom Corp", "displayname": "Custom Corp"},
headers=ORIGIN_HEADER,
)
contact_id = resp.json()["id"]
# Update custom fields (empty values is valid since no plugin defines fields)
resp = await client.patch(
f"/api/v1/contacts/{contact_id}/custom-fields",
json={"values": {"test_field": "test_value"}},
headers=ORIGIN_HEADER,
)
# Without plugin definitions, unknown fields should be rejected
assert resp.status_code == 400
async def test_update_custom_fields_empty_values_returns_200(self, client: AsyncClient, db_session):
"""PATCH custom fields with empty values dict returns 200."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.post(
"/api/v1/contacts",
json={"type": "company", "name": "Empty Corp", "displayname": "Empty Corp"},
headers=ORIGIN_HEADER,
)
contact_id = resp.json()["id"]
resp = await client.patch(
f"/api/v1/contacts/{contact_id}/custom-fields",
json={"values": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
assert "fields" in resp.json()
async def test_update_custom_fields_not_found_returns_404(self, client: AsyncClient, db_session):
"""PATCH custom fields for non-existent contact returns 404."""
await seed_tenant_and_users(db_session)
await login_client(client, "admin@tenanta.com")
resp = await client.patch(
"/api/v1/contacts/00000000-0000-0000-0000-000000000000/custom-fields",
json={"values": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestCustomFieldDefinition:
"""Test CustomFieldDefinition model in manifest."""
async def test_custom_field_definition_creation(self):
"""CustomFieldDefinition can be created with valid field types."""
from app.plugins.manifest import CustomFieldDefinition
cf = CustomFieldDefinition(
name="lead_source",
label="Lead Source",
label_key="custom.leadSource",
field_type="select",
options=["website", "referral", "cold_call"],
required=False,
entity="contact",
)
assert cf.name == "lead_source"
assert cf.field_type == "select"
assert len(cf.options) == 3
async def test_custom_field_definition_invalid_type_raises(self):
"""CustomFieldDefinition rejects invalid field_type."""
from app.plugins.manifest import CustomFieldDefinition
from pydantic import ValidationError
with pytest.raises(ValidationError):
CustomFieldDefinition(
name="bad_field",
field_type="invalid_type",
)
async def test_plugin_manifest_with_custom_fields(self):
"""PluginManifest accepts custom_fields list."""
from app.plugins.manifest import PluginManifest, CustomFieldDefinition
manifest = PluginManifest(
name="test_plugin",
version="1.0.0",
display_name="Test Plugin",
custom_fields=[
CustomFieldDefinition(
name="score",
label="Score",
field_type="number",
entity="contact",
),
],
)
assert len(manifest.custom_fields) == 1
assert manifest.custom_fields[0].name == "score"