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:
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user