diff --git a/app/components/search_component.html.erb b/app/components/search_component.html.erb
new file mode 100644
index 000000000..cf5da1898
--- /dev/null
+++ b/app/components/search_component.html.erb
@@ -0,0 +1,16 @@
+<%= form_with model: @user, url: path(current_user), method: "get", local: true do |f| %>
+
+
+
+
+
+
+
+
+
+
+<% end %>
diff --git a/app/components/search_component.rb b/app/components/search_component.rb
new file mode 100644
index 000000000..dbab17245
--- /dev/null
+++ b/app/components/search_component.rb
@@ -0,0 +1,13 @@
+class SearchComponent < ViewComponent::Base
+ attr_reader :current_user, :label
+
+ def initialize(current_user:, label:)
+ @current_user = current_user
+ @label = label
+ super
+ end
+
+ def path(current_user)
+ current_user.support? ? users_path : users_organisation_path(current_user.organisation)
+ end
+end
diff --git a/app/controllers/modules/users_filter.rb b/app/controllers/modules/users_filter.rb
new file mode 100644
index 000000000..1b6ba4c53
--- /dev/null
+++ b/app/controllers/modules/users_filter.rb
@@ -0,0 +1,10 @@
+module Modules::UsersFilter
+ def filtered_users(base_collection)
+ search_param = params["search-field"]
+ if search_param.present?
+ base_collection.search_by(search_param)
+ else
+ base_collection
+ end.filter_by_active.includes(:organisation)
+ end
+end
diff --git a/app/controllers/organisations_controller.rb b/app/controllers/organisations_controller.rb
index 8468a3f9f..17d3b8a3f 100644
--- a/app/controllers/organisations_controller.rb
+++ b/app/controllers/organisations_controller.rb
@@ -1,6 +1,7 @@
class OrganisationsController < ApplicationController
include Pagy::Backend
include Modules::CaseLogsFilter
+ include Modules::UsersFilter
before_action :authenticate_user!, except: [:index]
before_action :find_resource, except: [:index]
@@ -17,7 +18,8 @@ class OrganisationsController < ApplicationController
end
def users
- @pagy, @users = pagy(@organisation.users.where(active: true))
+ @pagy, @users = pagy(filtered_users(@organisation.users))
+ @searched = params["search-field"].present?
render "users/index"
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 1bd7c4c7e..8e58f7396 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -2,6 +2,7 @@ class UsersController < ApplicationController
include Pagy::Backend
include Devise::Controllers::SignInOut
include Helpers::Email
+ include Modules::UsersFilter
before_action :authenticate_user!
before_action :find_resource, except: %i[new create]
before_action :authenticate_scope!, except: %i[new]
@@ -9,7 +10,8 @@ class UsersController < ApplicationController
def index
redirect_to users_organisation_path(current_user.organisation) unless current_user.support?
- @pagy, @users = pagy(User.all.where(active: true).includes(:organisation))
+ @pagy, @users = pagy(filtered_users(User.all))
+ @searched = params["search-field"].present?
respond_to do |format|
format.html
diff --git a/app/frontend/styles/_search.scss b/app/frontend/styles/_search.scss
new file mode 100644
index 000000000..91399479a
--- /dev/null
+++ b/app/frontend/styles/_search.scss
@@ -0,0 +1,24 @@
+.app-search {
+ @include govuk-responsive-margin(6, "bottom");
+ display: flex;
+ align-items: end;
+}
+
+.app-search__form-group {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.app-search__input {
+ background-image: url("data:image/svg+xml,%3Csvg class='app-search__icon' width='20' height='20' viewBox='0 0 27 27' fill='none' xmlns='http://www.w3.org/2000/svg' aria-hidden='true' focusable='false'%3E%3Ccircle cx='12.0161' cy='11.0161' r='8.51613' stroke='currentColor' stroke-width='3'%3E%3C/circle%3E%3Cline x1='17.8668' y1='17.3587' x2='26.4475' y2='25.9393' stroke='currentColor' stroke-width='3'%3E%3C/line%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-size: 1em 1em;
+ background-position: 7px center;
+ padding-left: govuk-spacing(6);
+}
+
+.app-search__button {
+ margin-left: govuk-spacing(1);
+ margin-bottom: 2px;
+ width: auto;
+}
diff --git a/app/frontend/styles/application.scss b/app/frontend/styles/application.scss
index ebe0af05f..883ea5e63 100644
--- a/app/frontend/styles/application.scss
+++ b/app/frontend/styles/application.scss
@@ -39,6 +39,7 @@ $govuk-breakpoints: (
@import "pagination";
@import "panel";
@import "primary-navigation";
+@import "search";
// App utilities
.app-\!-colour-muted {
diff --git a/app/models/user.rb b/app/models/user.rb
index f32096ad6..97cc43c62 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -32,6 +32,11 @@ class User < ApplicationRecord
enum role: ROLES
+ scope :search_by_name, ->(name) { where("name ILIKE ?", "%#{name}%") }
+ scope :search_by_email, ->(email) { where("email ILIKE ?", "%#{email}%") }
+ scope :filter_by_active, -> { where(active: true) }
+ scope :search_by, ->(param) { search_by_name(param).or(search_by_email(param)) }
+
def case_logs
if support?
CaseLog.all
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
index 87e5b37a0..44f401515 100644
--- a/app/views/users/index.html.erb
+++ b/app/views/users/index.html.erb
@@ -7,10 +7,17 @@
<% if current_user.data_coordinator? || current_user.support? %>
<%= govuk_button_link_to "Invite user", new_user_path, html: { method: :get } %>
<% end %>
+
+<%= render SearchComponent.new(current_user:, label: "Search by name or email address") %>
+
<%= govuk_table do |table| %>
<%= table.caption(size: "s", classes: %w[govuk-!-text-align-left govuk-!-margin-top-4 govuk-!-margin-bottom-4]) do |caption| %>
- <%= @pagy.count %> total users
+ <% if @searched %>
+ Matches <%= @pagy.count %> of <%= User.count %> total users
+ <% else %>
+ <%= @pagy.count %> total users
+ <% end %>
<% if current_user.support? %>
<%= govuk_link_to "Download (CSV)", "/users.csv", type: "text/csv" %>
diff --git a/spec/components/search_component_spec.rb b/spec/components/search_component_spec.rb
new file mode 100644
index 000000000..d79ad53ca
--- /dev/null
+++ b/spec/components/search_component_spec.rb
@@ -0,0 +1,19 @@
+require "rails_helper"
+
+RSpec.describe SearchComponent, type: :component do
+ let(:current_user) { FactoryBot.create(:user, :support) }
+ let(:label) { "Search by name or email address" }
+ let(:page) { Capybara::Node::Simple.new(rendered_component) }
+
+ before do
+ render_inline(described_class.new(current_user:, label:))
+ end
+
+ it "renders a search bar" do
+ expect(page).to have_field("search-field", type: "search")
+ end
+
+ it "renders the given label" do
+ expect(page).to have_content(label)
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 8863f3bc4..df3f82911 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -185,4 +185,33 @@ RSpec.describe User, type: :model do
}.not_to change(user.versions, :count)
end
end
+
+ describe "scopes" do
+ before do
+ FactoryBot.create(:user, name: "Joe Bloggs", email: "joe@example.com")
+ FactoryBot.create(:user, name: "Tom Smith", email: "tom@example.com")
+ FactoryBot.create(:user, name: "Jenny Ford", email: "jenny@smith.com")
+ end
+
+ context "when searching by name" do
+ it "returns case insensitive matching records" do
+ expect(described_class.search_by_name("Joe").count).to eq(1)
+ expect(described_class.search_by_name("joe").count).to eq(1)
+ end
+ end
+
+ context "when searching by email" do
+ it "returns case insensitive matching records" do
+ expect(described_class.search_by_email("Example").count).to eq(2)
+ expect(described_class.search_by_email("example").count).to eq(2)
+ end
+ end
+
+ context "when searching by all searchable field" do
+ it "returns case insensitive matching records" do
+ expect(described_class.search_by("Smith").count).to eq(2)
+ expect(described_class.search_by("smith").count).to eq(2)
+ end
+ end
+ end
end
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index 679cc02c6..beb81a993 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -335,22 +335,92 @@ RSpec.describe UsersController, type: :request do
end
context "when user is signed in as a data coordinator" do
- let(:user) { FactoryBot.create(:user, :data_coordinator) }
- let(:other_user) { FactoryBot.create(:user, organisation: user.organisation) }
+ let(:user) { FactoryBot.create(:user, :data_coordinator, email: "coordinator@example.com") }
+ let!(:other_user) { FactoryBot.create(:user, organisation: user.organisation, name: "filter name", email: "filter@example.com") }
describe "#index" do
before do
sign_in user
- get "/users", headers:, params: {}
end
- it "redirects to the organisation user path" do
- follow_redirect!
- expect(path).to match("/organisations/#{user.organisation.id}/users")
+ context "when there are no url params" do
+ before do
+ get "/users", headers:, params: {}
+ end
+
+ it "redirects to the organisation user path" do
+ follow_redirect!
+ expect(path).to match("/organisations/#{user.organisation.id}/users")
+ end
+
+ it "does not show the download csv link" do
+ expect(page).not_to have_link("Download (CSV)", href: "/users.csv")
+ end
+
+ it "shows a search bar" do
+ follow_redirect!
+ expect(page).to have_field("search-field", type: "search")
+ end
end
- it "does not show the download csv link" do
- expect(page).not_to have_link("Download (CSV)", href: "/users.csv")
+ context "when a search parameter is passed" do
+ let!(:other_user_2) { FactoryBot.create(:user, organisation: user.organisation, name: "joe", email: "other@example.com") }
+ let!(:other_user_3) { FactoryBot.create(:user, name: "User 5", organisation: user.organisation, email: "joe@example.com") }
+ let!(:other_org_user) { FactoryBot.create(:user, name: "User 4", email: "joe@other_example.com") }
+
+ before do
+ get "/organisations/#{user.organisation.id}/users?search-field=#{search_param}"
+ end
+
+ context "when our search string matches case" do
+ let(:search_param) { "filter" }
+
+ it "returns only matching results" do
+ expect(page).not_to have_content(user.name)
+ expect(page).to have_content(other_user.name)
+ end
+
+ it "updates the table caption" do
+ expect(page).to have_content("Matches 1 of 5 total users")
+ end
+ end
+
+ context "when we need case insensitive search" do
+ let(:search_param) { "Filter" }
+
+ it "returns only matching results" do
+ expect(page).not_to have_content(user.name)
+ expect(page).to have_content(other_user.name)
+ end
+ end
+
+ context "when our search term matches an email" do
+ let(:search_param) { "other@example.com" }
+
+ it "returns only matching result within the same organisation" do
+ expect(page).not_to have_content(user.name)
+ expect(page).to have_content(other_user_2.name)
+ expect(page).not_to have_content(other_user.name)
+ expect(page).not_to have_content(other_user_3.name)
+ expect(page).not_to have_content(other_org_user.name)
+ end
+
+ context "when our search term matches an email and a name" do
+ let(:search_param) { "joe" }
+
+ it "returns any results including joe within the same organisation" do
+ expect(page).to have_content(other_user_2.name)
+ expect(page).to have_content(other_user_3.name)
+ expect(page).not_to have_content(other_user.name)
+ expect(page).not_to have_content(other_org_user.name)
+ expect(page).not_to have_content(user.name)
+ end
+
+ it "updates the table caption" do
+ expect(page).to have_content("Matches 2 of 5 total users")
+ end
+ end
+ end
end
end
@@ -613,7 +683,7 @@ RSpec.describe UsersController, type: :request do
it "does update other values" do
expect { patch "/users/#{other_user.id}", headers:, params: }
- .to change { other_user.reload.name }.from("Danny Rojas").to("new name")
+ .to change { other_user.reload.name }.from("filter name").to("new name")
end
end
end
@@ -727,9 +797,9 @@ RSpec.describe UsersController, type: :request do
end
describe "#index" do
- let!(:other_user) { FactoryBot.create(:user, organisation: user.organisation, name: "User 2") }
- let!(:inactive_user) { FactoryBot.create(:user, organisation: user.organisation, active: false, name: "User 3") }
- let!(:other_org_user) { FactoryBot.create(:user, name: "User 4") }
+ let!(:other_user) { FactoryBot.create(:user, organisation: user.organisation, name: "User 2", email: "other@example.com") }
+ let!(:inactive_user) { FactoryBot.create(:user, organisation: user.organisation, active: false, name: "User 3", email: "inactive@example.com") }
+ let!(:other_org_user) { FactoryBot.create(:user, name: "User 4", email: "other_org@other_example.com") }
before do
sign_in user
@@ -750,6 +820,72 @@ RSpec.describe UsersController, type: :request do
it "shows the download csv link" do
expect(page).to have_link("Download (CSV)", href: "/users.csv")
end
+
+ it "shows a search bar" do
+ expect(page).to have_field("search-field", type: "search")
+ end
+
+ context "when a search parameter is passed" do
+ before do
+ get "/users?search-field=#{search_param}"
+ end
+
+ context "when our search term matches a name" do
+ context "when our search string matches case" do
+ let(:search_param) { "Danny" }
+
+ it "returns only matching results" do
+ expect(page).to have_content(user.name)
+ expect(page).not_to have_content(other_user.name)
+ expect(page).not_to have_content(inactive_user.name)
+ expect(page).not_to have_content(other_org_user.name)
+ end
+
+ it "updates the table caption" do
+ expect(page).to have_content("Matches 1 of 4 total users")
+ end
+ end
+
+ context "when we need case insensitive search" do
+ let(:search_param) { "danny" }
+
+ it "returns only matching results" do
+ expect(page).to have_content(user.name)
+ expect(page).not_to have_content(other_user.name)
+ expect(page).not_to have_content(inactive_user.name)
+ expect(page).not_to have_content(other_org_user.name)
+ end
+ end
+
+ context "when our search term matches an email" do
+ let(:search_param) { "other_org@other_example.com" }
+
+ it "returns only matching result" do
+ expect(page).not_to have_content(user.name)
+ expect(page).not_to have_content(other_user.name)
+ expect(page).not_to have_content(inactive_user.name)
+ expect(page).to have_content(other_org_user.name)
+ end
+ end
+
+ context "when our search term matches an email and a name" do
+ let!(:other_user) { FactoryBot.create(:user, organisation: user.organisation, name: "joe", email: "other@example.com") }
+ let!(:other_org_user) { FactoryBot.create(:user, name: "User 4", email: "joe@other_example.com") }
+ let(:search_param) { "joe" }
+
+ it "returns any results including joe" do
+ expect(page).to have_content(other_user.name)
+ expect(page).not_to have_content(inactive_user.name)
+ expect(page).to have_content(other_org_user.name)
+ expect(page).not_to have_content(user.name)
+ end
+
+ it "updates the table caption" do
+ expect(page).to have_content("Matches 2 of 4 total users")
+ end
+ end
+ end
+ end
end
describe "CSV download" do