Skip to content

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.

ContactFieldType controls input rendering, validation, and normalization:

TypeInputValidationNormalization
TextSingle-line textAlways validTrim whitespace
NumberNumeric inputMust parse as f64
DateDate pickerMust match YYYY-MM-DD
UrlURL inputMust contain a dot (.)Prepends https:// if no protocol
EmailEmail inputMust contain @ and domain dotLowercase
BooleanCheckboxMust be "true" or "false"Lowercase
TextareaMulti-line textAlways validTrim whitespace
SelectDropdownMust match an option label exactly
pub enum ContactFieldType {
Text,
Number,
Date,
Url,
Email,
Boolean,
Textarea,
Select,
}
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.

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:

ColorHex
Blue#3b82f6
Green#22c55e
Red#ef4444
Yellow#eab308
Purple#a855f7
Pink#ec4899
Orange#f97316
Teal#14b8a6

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.

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.rs
let 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:

InputTypeError
abcNumber”Must be a valid number”
2024-1-5Date”Must be a valid date (YYYY-MM-DD)“
localhostUrl”Must be a valid URL”
yesBoolean”Must be true or false”
OtherSelect”Must be one of the allowed options”
MethodRouteBodyReturns
GET/api/contact-field-defsVec<ContactFieldDef>
POST/api/contact-field-defsContactFieldDefDataField 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.

MethodRouteBodyReturns
PUT/api/contact-field-defs/reorderReorderContactFieldDefsData()
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).

MethodRouteBodyReturns
PUT/api/contacts/{contact_id}/fieldsUpsertContactFieldValueDataValue 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.

Route: /contacts/fieldsKey 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.

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:

  1. Grab the grip handle on any field row — the row fades to 50% opacity.
  2. Drag over another row — a blue drop indicator line appears at the target position.
  3. Drop — the list reorders instantly (optimistic update) and calls PUT /api/contact-field-defs/reorder in the background.
  4. 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.

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.

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.