mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-13 00:03:42 +00:00
Merge pull request #416 from warmbly/fix/issue-413-contact-full-name-search
Fix: contact search now matches a full name
This commit is contained in:
@@ -73,6 +73,8 @@ See [Personalization & expressions](/guides/expressions/) for the full templatin
|
||||
|
||||
A filter bar sits above the contact list. **Category**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search and sort stay in the toolbar.
|
||||
|
||||
Free-text search matches first name, last name, email, company and phone. Every word you type has to match one of those, so `Test Demo` finds the contact whose first name is Test and last name is Demo, and `Demo Acme` finds everyone named Demo at Acme. Words can be in any order, and only the first six count.
|
||||
|
||||
## Selecting rows
|
||||
|
||||
Tick a row's checkbox to select it, or the one in the table header to select every row loaded so far. Contact lists load in pages as you scroll, so the header checkbox on its own only ever covers what is on screen.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Contact search matches every word of the query against some field, so a
|
||||
// full name finds the person whose first and last name are separate columns.
|
||||
// Before issue #413 the query went at each column whole and "Test Demo"
|
||||
// matched nothing while "Test" matched.
|
||||
//
|
||||
// Run against the dev stack:
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveContactSearchTerms -v
|
||||
|
||||
type searchTermsFixture struct {
|
||||
org uuid.UUID
|
||||
owner uuid.UUID
|
||||
}
|
||||
|
||||
func newSearchTermsFixture(t *testing.T, pool *pgxpool.Pool) *searchTermsFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
f := &searchTermsFixture{org: uuid.New(), owner: uuid.New()}
|
||||
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("fixture %q: %v", sql[:min(60, len(sql))], err)
|
||||
}
|
||||
}
|
||||
tag := f.org.String()[:8]
|
||||
|
||||
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash)
|
||||
VALUES ($1, 'Terms', 'Live', $2, 'x')`, f.owner, "owner-"+tag+"@fixture.invalid")
|
||||
exec(`INSERT INTO organizations (id, name, slug, owner_user_id)
|
||||
VALUES ($1, 'Terms', $2, $3)`, f.org, "terms-"+tag, f.owner)
|
||||
exec(`INSERT INTO organization_members (organization_id, user_id, role, accepted_at)
|
||||
VALUES ($1, $2, 'owner', NOW())`, f.org, f.owner)
|
||||
|
||||
people := []struct{ first, last, company string }{
|
||||
{"Test", "Demo", "Acme Freight"},
|
||||
{"Test", "Other", "Globex"},
|
||||
{"Demo", "Person", "Acme Freight"},
|
||||
}
|
||||
for i, p := range people {
|
||||
exec(`INSERT INTO contacts (id, user_id, organization_id, email, first_name, last_name, company, phone, custom_fields, subscribed, updated_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{}'::jsonb, true, NOW(), NOW())`,
|
||||
uuid.New(), f.owner, f.org,
|
||||
fmt.Sprintf("person-%s-%02d@fixture.invalid", tag, i), p.first, p.last, p.company)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
c := context.Background()
|
||||
for _, step := range []struct {
|
||||
sql string
|
||||
arg any
|
||||
}{
|
||||
{`DELETE FROM contacts WHERE organization_id = $1`, f.org},
|
||||
{`DELETE FROM organization_members WHERE organization_id = $1`, f.org},
|
||||
{`DELETE FROM organizations WHERE id = $1`, f.org},
|
||||
{`DELETE FROM users WHERE id = $1`, f.owner},
|
||||
} {
|
||||
if _, err := pool.Exec(c, step.sql, step.arg); err != nil {
|
||||
t.Errorf("cleanup %q: %v", step.sql, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestLiveContactSearchTerms(t *testing.T) {
|
||||
handle, pool := liveContactDB(t)
|
||||
f := newSearchTermsFixture(t, pool)
|
||||
repo := NewContactRepostory(handle)
|
||||
ctx := context.Background()
|
||||
org := f.org.String()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{"first name alone", "Test", 2},
|
||||
{"last name alone", "Demo", 2},
|
||||
{"full name", "Test Demo", 1},
|
||||
{"full name, reversed", "Demo Test", 1},
|
||||
{"full name, odd spacing", " Test Demo ", 1},
|
||||
{"name and company", "Demo Acme", 2},
|
||||
{"a word that matches nobody", "Test Nobody", 0},
|
||||
{"empty query lists everyone", "", 3},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
page, xerr := repo.Search(ctx, org, nil, nil, models.SearchContacts{Query: tc.query}, 50)
|
||||
if xerr != nil {
|
||||
t.Fatalf("search: %v", xerr)
|
||||
}
|
||||
if len(page.Data) != tc.want {
|
||||
t.Fatalf("search %q returned %d contacts, want %d", tc.query, len(page.Data), tc.want)
|
||||
}
|
||||
// The list and "select all matching" share a WHERE builder, so
|
||||
// the bulk selection has to resolve the same rows.
|
||||
ids, xerr := repo.SearchIDs(ctx, org, models.SearchContacts{Query: tc.query}, models.MaxContactBulkSelection)
|
||||
if xerr != nil {
|
||||
t.Fatalf("search ids: %v", xerr)
|
||||
}
|
||||
if len(ids) != tc.want {
|
||||
t.Fatalf("SearchIDs %q returned %d contacts, want %d", tc.query, len(ids), tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactSearchTermsSplit(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{" ", nil},
|
||||
{"Test", []string{"Test"}},
|
||||
{" Test Demo ", []string{"Test", "Demo"}},
|
||||
{"a b c d e f g h", []string{"a", "b", "c", "d", "e", "f"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := contactSearchTerms(tc.query)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("contactSearchTerms(%q) = %v, want %v", tc.query, got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("contactSearchTerms(%q) = %v, want %v", tc.query, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1038,6 +1038,21 @@ type contactFilter struct {
|
||||
singleCampaign string
|
||||
}
|
||||
|
||||
// contactSearchMaxTerms bounds how many words one search box turns into ILIKE
|
||||
// terms; past a handful the extra scans cost more than they narrow.
|
||||
const contactSearchMaxTerms = 6
|
||||
|
||||
// contactSearchTerms splits a contact search into the words that must each
|
||||
// match some field. An empty or whitespace-only query yields no terms, which
|
||||
// leaves the search unfiltered exactly as before.
|
||||
func contactSearchTerms(query string) []string {
|
||||
terms := strings.Fields(query)
|
||||
if len(terms) > contactSearchMaxTerms {
|
||||
terms = terms[:contactSearchMaxTerms]
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
// buildContactFilter compiles a search request into WHERE terms. Search and
|
||||
// SearchIDs share it so a "select all" bulk action resolves exactly the rows
|
||||
// the list was showing, filter for filter.
|
||||
@@ -1056,17 +1071,20 @@ func (r *contactRepository) buildContactFilter(ctx context.Context, orgID string
|
||||
// -----------------------------
|
||||
// Text search across core fields
|
||||
// -----------------------------
|
||||
if filters.Query != "" {
|
||||
q := "%" + filters.Query + "%"
|
||||
// Every word of the query has to match one of the fields, rather than the
|
||||
// query as a whole matching one of them: no single column holds both
|
||||
// halves of a person's name, so "Test Demo" found nothing while "Test"
|
||||
// found the contact (issue #413).
|
||||
for _, term := range contactSearchTerms(filters.Query) {
|
||||
whereClauses = append(whereClauses, fmt.Sprintf(`
|
||||
(c.first_name ILIKE $%d OR
|
||||
c.last_name ILIKE $%d OR
|
||||
c.email ILIKE $%d OR
|
||||
c.company ILIKE $%d OR
|
||||
c.phone ILIKE $%d)
|
||||
`, argIndex, argIndex+1, argIndex+2, argIndex+3, argIndex+4))
|
||||
args = append(args, q, q, q, q, q)
|
||||
argIndex += 5
|
||||
`, argIndex, argIndex, argIndex, argIndex, argIndex))
|
||||
args = append(args, "%"+term+"%")
|
||||
argIndex++
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
|
||||
Reference in New Issue
Block a user