Skip to content

Unified Contact Filters

The ContactFilter type provides a single filter model used by both the contacts list page and the messaging sidebar. Search, sort, tags, status, custom fields, and last-contacted range all flow through one type, one service, and one URL-serialization layer.

src/mods/contact/types/contact_filter_type.rs
pub struct ContactFilter {
pub search: String,
pub sort: ContactListSort,
pub tag_ids: Vec<Uuid>,
pub status_filter: Option<ContactStatus>,
pub member_filter: Option<AssignmentMemberFilter>,
pub custom_field_filters: Vec<CustomFieldFilter>,
pub custom_field_logic: CustomFieldLogic,
pub last_contacted_range: Option<TimeRange>,
pub page: u64,
pub page_size: u64,
}

ContactFilter replaces the old ContactListQuery. It defaults to page_size: 25, sort: LastContactedAt, and custom_field_logic: And.

VariantBehavior
LastContactedAtMost recently contacted first (default)
RecentlyAddedNewest contacts first
NameAscAlphabetical A–Z
pub struct CustomFieldFilter {
pub field_def_id: Uuid,
pub field_type: ContactFieldType,
pub values: Vec<String>,
}

Each filter targets one custom field definition. How values is interpreted depends on field_type:

Field TypeValues
SelectMultiple option strings (OR within the field)
BooleanSingle "true" or "false"
Text, Textarea, Email, UrlSingle substring for case-insensitive match
Number[min, max] — either can be empty for unbounded
DateSingle JSON-serialized TimeRange

CustomFieldLogic controls how multiple custom field filters combine: And (contact matches all) or Or (contact matches any).

Call filter.active_filter_count() to get the number of active filter categories. The UI uses this to display a badge count on the filter icon button.

src/mods/contact/services/apply_contact_filters_service.rs
pub fn apply_contact_filters(
condition: sea_orm::Condition,
filter: &ContactFilter,
org_id: Uuid,
) -> sea_orm::Condition

Pass in an existing SeaORM Condition and the function appends clauses for search, tags, status, custom fields, and last-contacted range. It does not handle permission scoping or member_filter — those stay in the API handler.

Key implementation details:

  • Search matches against first_name, last_name, phone numbers, and emails via subqueries
  • Tags use an OR subquery — the contact must have at least one of the selected tags
  • Custom fields use per-field subqueries with defense-in-depth org scoping on field_def_id
  • Number casts the stored string to DOUBLE PRECISION via Expr::cust_with_values for min/max range comparison
  • Date deserializes values[0] as a TimeRange, converts to a (start_date, end_date) pair via range.to_date_range(), and compares the stored YYYY-MM-DD string with gte/lte
  • Email/Url share the same case-insensitive ILIKE substring logic as Text and Textarea
  • No catch-all arms — the compiler enforces exhaustiveness across all 8 ContactFieldType variants

ContactFilterQuery serializes filters into URL query parameters so they survive page reloads.

src/mods/contact/types/contact_filter_query_type.rs
pub struct ContactFilterQuery {
pub search: String,
pub tab: String, // messaging tab (empty = "all")
pub sort: String, // "recently_added" | "name_asc" | "" (default)
pub tags: String, // comma-separated UUIDs
pub status: String,
pub cf: String, // JSON-encoded Vec<CustomFieldFilter>
pub logic: String, // "or" or "" (default = and)
pub member: String, // "" | "unassigned" | member UUID
pub lcr: String, // JSON-encoded TimeRange
}

The Display implementation replaces & with ! and = with ~ to prevent the Dioxus router from splitting query values during URL decoding. FromStr reverses this.

Convert between the two types:

// ContactFilter → URL query
let query = ContactFilterQuery::from_filter(&filter);
let url_string = query.to_string();
// URL query → ContactFilter
let query: ContactFilterQuery = url_string.parse().unwrap();
let filter = query.to_filter(25); // page_size

The filter bar lives in src/mods/contact/components/filter_bar/ as a set of focused sub-components:

ComponentFileResponsibility
ContactFilterBarcontact_filter_bar_component.rsOrchestrator (~180 lines). Wires search, sort, popover, and member filter.
ContactFilterPopovercontact_filter_popover_component.rsPopover panel with collapsible sections for tags, status, date range, custom fields, and AND/OR logic.
ContactSortSelectcontact_sort_select_component.rsSort dropdown. Rendered inline in full mode, inside the popover in compact mode.
CustomFieldFilterInputcustom_field_filter_component.rsType-aware filter control per custom field (see below).

ContactFilterBar operates in two modes controlled by the compact prop:

ModeWhereLayout
Full (compact: false)Contacts pageInline search + sort dropdown + “Filters” button
Compact (compact: true)Messaging sidebarSearch always visible, filter icon button opens a popover

The popover anchors to the full filter-bar width to prevent clipping in narrow sidebars.

use_contact_filter_draft (src/mods/contact/hooks/use_contact_filter_draft.rs) bundles the popover’s draft signals into a ContactFilterDraftState struct:

pub struct ContactFilterDraftState {
pub local_tag_ids: Signal<Vec<String>>,
pub local_status: Signal<Option<ContactStatus>>,
pub local_custom_filters: Signal<Vec<CustomFieldFilter>>,
pub local_logic: Signal<CustomFieldLogic>,
pub local_sort: Signal<ContactListSort>,
pub local_last_contacted_range: Signal<Option<TimeRange>>,
pub st_section_open: Signal<bool>,
pub dr_section_open: Signal<bool>,
pub cf_section_open: Signal<bool>,
}

Key methods:

  • sync_from_committed(&mut self, filter) — copy committed filter into draft signals when the popover opens
  • apply(&self, filter, compact, show_sort) — build a new ContactFilter from draft values and reset pagination
  • clear_all() / clear_status_and_tags() / clear_custom_fields() / clear_date_range() — section-level resets
  • status_and_tags_count() / custom_fields_count() / date_range_count() — badge counts per section

CustomFieldFilterInput renders the correct filter control based on the field’s ContactFieldType:

Field TypeControlBehavior
SelectMultiSelect dropdownOR within selected options. Shows “No options defined” if the field has no options.
BooleanThree toggle buttons (Any / Yes / No)Exact match on "true" or "false"
Text, Textarea, Email, UrlSingle text InputCase-insensitive substring match
NumberTwo side-by-side number inputs (Min – Max)Either bound can be empty for unbounded range
DateTimeRangeSelector (presets + calendar)JSON-serialized TimeRange value

The Number range filter uses a shared render_range_filter helper with .peek() on the signal to read the “other” slot without triggering re-renders — this prevents signal borrow panics in Dioxus.

The contacts list API (get_contacts_list_api) accepts ContactFilter directly and delegates shared filtering to apply_contact_filters:

GET /api/contacts?search=...&sort=...&tags=...&status=...

The messaging contact list also uses ContactFilterBar on the “All” tab. Unanswered tabs use a separate API and hide the filter bar.