Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def index
records = records.where.not(id: exclude_ids)
end

records = records.limit(25)
records = records.order(Arel.sql(model_class.remote_search_columns.first)).limit(25)

render json: records.map(&:remote_search_label)
end
Expand Down
61 changes: 58 additions & 3 deletions app/frontend/javascript/controllers/remote_select_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,48 @@ import TomSelect from "tom-select";
export default class extends Controller {
static values = { model: String, exclude: String };

get personModel() {
return this.modelValue === "person" || this.modelValue === "user";
}

connect() {
this.select = new TomSelect(this.element, {
const options = {
valueField: "id",
labelField: "label",
searchField: "label",
score: () => () => 1,
create: false,
load: (query, callback) => {
if (!query.length) return callback();

this.select.clearOptions();

let url = `/search/${this.modelValue}?q=${encodeURIComponent(query)}`
if (this.hasExcludeValue && this.excludeValue) {
url += `&exclude=${encodeURIComponent(this.excludeValue)}`
}
fetch(url)
.then((r) => r.json())
.then((json) => callback(json))
.then((json) => {
callback(json);
this.updateScrollHint();
})
.catch(() => callback());
},
});
};

if (this.personModel) {
const renderFn = (data, escape) => {
const match = data.label.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
if (match) {
return `<div><span style="font-weight:600;color:#111827">${escape(match[1].trim())}</span> <span style="color:#9ca3af">(${escape(match[2])})</span></div>`;
}
return `<div><span style="font-weight:600;color:#111827">${escape(data.label)}</span></div>`;
};
options.render = { option: renderFn, item: renderFn };
}

this.select = new TomSelect(this.element, options);
// Inject CSS to remove some default tom-select styles -might be a better way to do this.
const style = document.createElement("style");
style.textContent = `
Expand All @@ -46,10 +69,42 @@ export default class extends Controller {
margin: 0 !important; /* Remove padding/margin from selected items */
padding: 0 !important;
}
.ts-dropdown-content {
max-height: 400px !important;
overflow-y: auto !important;
}
.ts-dropdown .scroll-hint {
text-align: center;
padding: 4px 0;
color: #9ca3af;
font-size: 0.75rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
`;
document.head.appendChild(style);
}

updateScrollHint() {
requestAnimationFrame(() => {
const dropdown = this.select.dropdown;
if (!dropdown) return;

const content = dropdown.querySelector(".ts-dropdown-content");
if (!content) return;

const existing = dropdown.querySelector(".scroll-hint");
if (existing) existing.remove();

if (content.scrollHeight > content.clientHeight) {
const hint = document.createElement("div");
hint.className = "scroll-hint";
hint.textContent = `Scroll for more results`;
dropdown.appendChild(hint);
}
});
}

disconnect() {
if (this.select) this.select.destroy();
}
Expand Down
20 changes: 10 additions & 10 deletions app/models/concerns/remote_searchable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ def remote_search(query)
return none if query.blank?
raise "remote_searchable_by not defined for #{name}" if remote_search_columns.empty?

words = query.split.flat_map { |w| w.split(/[\s\-]+/) }.reject(&:blank?)
return none if words.blank?
terms = query.split
scope = all

conditions = words.each_with_index.map do |word, i|
bind_var = "pattern_#{i}".to_sym
column_conditions = remote_search_columns.map { |column| "#{table_name}.#{column} LIKE :#{bind_var}" }
"(#{column_conditions.join(' OR ')})"
terms.each_with_index do |term, i|
pattern_key = :"pattern_#{i}"
conditions = remote_search_columns
.map { |column| "#{table_name}.#{column} LIKE :#{pattern_key}" }
.join(" OR ")
scope = scope.where(conditions, pattern_key => "%#{term}%")
end
bindings = words.each_with_index.each_with_object({}) do |(word, i), hash|
hash["pattern_#{i}".to_sym] = "%#{word}%"
end
where(conditions.join(" AND "), bindings)

scope
end
end

Expand Down
18 changes: 18 additions & 0 deletions app/models/person.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ def preferred_email

remote_searchable_by :first_name, :last_name, :email, :email_2

def self.remote_search(query)
return none if query.blank?

terms = query.split
scope = left_joins(:user)

terms.each_with_index do |term, i|
pattern_key = :"pattern_#{i}"
conditions = remote_search_columns
.map { |col| "#{table_name}.#{col} LIKE :#{pattern_key}" }
.push("users.email LIKE :#{pattern_key}")
.join(" OR ")
scope = scope.where(conditions, pattern_key => "%#{term}%")
end

scope.distinct
end

def remote_search_label
{
id: id,
Expand Down
91 changes: 91 additions & 0 deletions spec/models/concerns/remote_searchable_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
require "rails_helper"

RSpec.describe RemoteSearchable, type: :model do
describe ".remote_search" do
let(:admin) { create(:user, :admin) }

let!(:alice) { create(:person, first_name: "Alice", last_name: "Smith", email: "alice@example.com", created_by: admin, updated_by: admin) }
let!(:bob) { create(:person, first_name: "Bob", last_name: "Smith", email: "bob@example.com", created_by: admin, updated_by: admin) }
let!(:carol) { create(:person, first_name: "Carol", last_name: "Jones", email: "carol@test.org", created_by: admin, updated_by: admin) }

context "with a single term" do
it "matches first name" do
expect(Person.remote_search("Alice")).to include(alice)
expect(Person.remote_search("Alice")).not_to include(bob, carol)
end

it "matches last name" do
results = Person.remote_search("Smith")
expect(results).to include(alice, bob)
expect(results).not_to include(carol)
end

it "matches email" do
results = Person.remote_search("carol@test")
expect(results).to include(carol)
expect(results).not_to include(alice, bob)
end

it "matches email_2" do
person = create(:person, first_name: "Dana", last_name: "White", email: nil, email_2: "dana@secondary.org", created_by: admin, updated_by: admin)
results = Person.remote_search("dana@secondary")
expect(results).to include(person)
end

it "matches user email" do
user = create(:user, email: "unique-login@corp.com")
person = create(:person, first_name: "Zara", last_name: "Test", user: user, created_by: admin, updated_by: admin)
results = Person.remote_search("unique-login@corp")
expect(results).to include(person)
end

it "matches partial strings" do
expect(Person.remote_search("ali")).to include(alice)
end

it "is case insensitive" do
expect(Person.remote_search("ALICE")).to include(alice)
end
end

context "with multiple terms" do
it "matches across different columns" do
results = Person.remote_search("Alice Smith")
expect(results).to include(alice)
expect(results).not_to include(bob, carol)
end

it "matches regardless of term order" do
results = Person.remote_search("Smith Alice")
expect(results).to include(alice)
expect(results).not_to include(bob)
end

it "matches partial terms across columns" do
results = Person.remote_search("ali smi")
expect(results).to include(alice)
expect(results).not_to include(bob, carol)
end

it "matches names containing spaces" do
mary_ann = create(:person, first_name: "Mary Ann", last_name: "De La Cruz", email: "ma@example.com", created_by: admin, updated_by: admin)

expect(Person.remote_search("Mary Ann")).to include(mary_ann)
expect(Person.remote_search("Ann Cruz")).to include(mary_ann)
expect(Person.remote_search("De La")).to include(mary_ann)
end

it "requires all terms to match" do
results = Person.remote_search("Alice Jones")
expect(results).not_to include(alice, carol)
end
end

context "with a blank query" do
it "returns no results" do
expect(Person.remote_search("")).to be_empty
expect(Person.remote_search(" ")).to be_empty
end
end
end
end
Loading