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:
@@ -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