Custom Contact Fields
Custom fields let each organization define its own data points on contact profiles. Admins create field definitions (name, type, description), and users fill in values per contact. All values are stored as strings and validated both client-side and server-side.
Field Types
Section titled “Field Types”ContactFieldType controls input rendering, validation, and normalization:
| Type | Input | Validation | Normalization |
|---|---|---|---|
Text | Single-line text | Always valid | Trim whitespace |
Number | Numeric input | Must parse as f64 | — |
Date | Date picker | Must match YYYY-MM-DD | — |
Url | URL input | Must contain a dot (.) | Prepends https:// if no protocol |
Email | Email input | Must contain @ and domain dot | Lowercase |
Boolean | Checkbox | Must be "true" or "false" | Lowercase |
Textarea | Multi-line text | Always valid | Trim whitespace |
Select | Dropdown | Must match an option label exactly | — |
pub enum ContactFieldType { Text, Number, Date, Url, Email, Boolean, Textarea, Select,}Data Model
Section titled “Data Model”Field Definition
Section titled “Field Definition”pub struct ContactFieldDef { pub id: Uuid, pub name: String, pub field_type: ContactFieldType, pub description: Option<String>, pub options: Option<Vec<FieldOption>>, // Select fields only pub sort_order: i32,}The options field is Some(...) only for Select types. It stores the allowed dropdown choices with optional colors.
FieldOption
Section titled “FieldOption”pub struct FieldOption { pub label: String, // Display text, e.g. "Hot Lead" pub color: Option<String>, // Hex color, e.g. "#ef4444"}Options are stored as JSONB in the contact_field_def.options column. The 8-color palette matches contact tags:
| Color | Hex |
|---|---|
| Blue | #3b82f6 |
| Green | #22c55e |
| Red | #ef4444 |
| Yellow | #eab308 |
| Purple | #a855f7 |
| Pink | #ec4899 |
| Orange | #f97316 |
| Teal | #14b8a6 |
Field Value
Section titled “Field Value”Per-contact values live in contact_field_value with a unique constraint on (contact_id, field_def_id):
pub struct ContactFieldValueWithDef { pub def_id: Uuid, pub name: String, pub field_type: ContactFieldType, pub description: Option<String>, pub options: Option<Vec<FieldOption>>, pub sort_order: i32, pub value_id: Option<Uuid>, pub value: Option<String>,}When you fetch a contact, the API joins definitions with values so you get the full schema — including Select options — even for empty fields.
Validation
Section titled “Validation”The validate_field_value() function runs on both client and server. It returns Result<String, String> — either the normalized value or an error message.
// Server-side usage in upsert_contact_field_value_api.rslet validated_value = match validate_field_value( &field_type, &data.value, options.as_deref(),) { Ok(v) => v, Err(msg) => return HttpError::bad_request(msg),};Empty values are always valid — the system handles them by deleting the value record upstream.
Error examples:
| Input | Type | Error |
|---|---|---|
abc | Number | ”Must be a valid number” |
2024-1-5 | Date | ”Must be a valid date (YYYY-MM-DD)“ |
localhost | Url | ”Must be a valid URL” |
yes | Boolean | ”Must be true or false” |
Other | Select | ”Must be one of the allowed options” |
API Endpoints
Section titled “API Endpoints”Field Definitions (Admin)
Section titled “Field Definitions (Admin)”| Method | Route | Body | Returns |
|---|---|---|---|
GET | /api/contact-field-defs | — | Vec<ContactFieldDef> |
POST | /api/contact-field-defs | ContactFieldDefData | Field def UUID |
PUT | /api/contact-field-defs/{id} | ContactFieldDefData | () |
DELETE | /api/contact-field-defs/{id} | — | () |
ContactFieldDefData (create/update payload):
pub struct ContactFieldDefData { pub name: String, pub field_type: ContactFieldType, pub description: Option<String>, pub options: Option<Vec<FieldOption>>,}Select field validation on create/update:
- At least one option required
- No empty labels
- All labels must be unique
sort_order is auto-assigned on creation. Deleting a definition cascades to all its values.
Reorder Definitions
Section titled “Reorder Definitions”| Method | Route | Body | Returns |
|---|---|---|---|
PUT | /api/contact-field-defs/reorder | ReorderContactFieldDefsData | () |
pub struct ReorderContactFieldDefsData { pub field_def_ids: Vec<Uuid>,}Send the full list of field definition IDs in the desired display order. The array index becomes the new sort_order value. The endpoint validates that:
- The list is not empty
- No duplicate IDs
- All IDs belong to the caller’s organization
All updates run in a single database transaction — either every field’s sort_order updates or none do.
Authorization: Requires ContactCollectionPermission::Create (the same permission gate used for managing field definitions and tags).
Field Values (Per-Contact)
Section titled “Field Values (Per-Contact)”| Method | Route | Body | Returns |
|---|---|---|---|
PUT | /api/contacts/{contact_id}/fields | UpsertContactFieldValueData | Value UUID |
DELETE | /api/contacts/{contact_id}/fields/{def_id} | — | () |
pub struct UpsertContactFieldValueData { pub field_def_id: Uuid, pub value: String,}The upsert endpoint runs validate_field_value() server-side before storing. Normalized values (lowercased emails, protocol-prefixed URLs) are what get persisted.
UI Components
Section titled “UI Components”Admin: Field Definitions View
Section titled “Admin: Field Definitions View”Route: /contacts/fields — Key file: contact_field_defs_view.rs
Admins create, edit, and delete field definitions. When the type is set to Select, an inline options editor appears with label inputs, color pickers (8-color circular buttons), and add/remove controls.
Drag-and-Drop Reordering
Section titled “Drag-and-Drop Reordering”Field definitions support drag-and-drop reordering via the use_field_def_dnd hook. Each row shows a grip handle (GripVertical icon) that you drag to reposition fields.
Component hierarchy:
ContactFieldDefsView└── ContactFieldDefsList ├── ContactFieldDefForm (create new) └── ContactFieldDefItem (per field) ├── ContactFieldDefForm (edit mode) └── ContactFieldDefRow (display mode + drag handle)How it works:
- Grab the grip handle on any field row — the row fades to 50% opacity.
- Drag over another row — a blue drop indicator line appears at the target position.
- Drop — the list reorders instantly (optimistic update) and calls
PUT /api/contact-field-defs/reorderin the background. - If the API call fails, the list reverts to the server order and a toast shows the error.
The use_field_def_dnd hook manages all drag state (dragging ID, drop target ID, local order) and exposes four callbacks: on_drag_start, on_drag_end, on_drag_over, and on_drop. On drop, it reorders the local signal, extracts the ordered IDs, and fires the on_reorder event handler back to the view.
// View-level handler (contact_field_defs_view.rs)let handle_reorder = move |ordered_ids: Vec<uuid::Uuid>| async move { match reorder_contact_field_defs_api(ReorderContactFieldDefsData { field_def_ids: ordered_ids, }).await { Ok(()) => {} Err(e) => { defs.restart(); // refetch from server toaster.mutation_error(&e.to_string()); } }};The new order persists across page reloads and applies everywhere custom fields are displayed.
Contact Sidebar: Custom Fields Section
Section titled “Contact Sidebar: Custom Fields Section”Key file: contact_custom_fields_section_component.rs
Renders all org-defined fields with type-aware inputs:
- Boolean — Checkbox. Never deletes; always upserts
"true"or"false". - Textarea — Multi-line input with 3 rows.
- Select — Dropdown with color dots next to each option label.
- Others — Standard
<input>with the matching HTML type.
Edits are batched — change multiple fields, then click Save. Client-side validation runs on save, displaying inline errors below each invalid field. Only changed values trigger API calls.
Database
Section titled “Database”contact_field_def ( id UUID PRIMARY KEY, organization_id UUID REFERENCES organization(id) ON DELETE CASCADE, name VARCHAR NOT NULL, field_type VARCHAR NOT NULL, description VARCHAR, options JSONB, -- Select field options array sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now())
contact_field_value ( id UUID PRIMARY KEY, contact_id UUID REFERENCES contact(id) ON DELETE CASCADE, field_def_id UUID REFERENCES contact_field_def(id) ON DELETE CASCADE, value VARCHAR NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() -- UNIQUE(contact_id, field_def_id))The options column was added in migration m20260328_130000_contact_field_def_add_options. It stores a JSON array of {"label": "...", "color": "..."} objects, and is NULL for non-Select types.