Convert custom filters on user administration to standard query

This commit is contained in:
Oliver Günther
2026-05-08 11:30:37 +02:00
parent 2c1c7afe39
commit c950be910e
16 changed files with 344 additions and 133 deletions
+50 -49
View File
@@ -26,55 +26,56 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
<div class="generic-table--container <%= container_class %>" data-test-selector="<%= test_selector %>" id="<%= container_id %>">
<div class="generic-table--results-container">
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
<colgroup>
<% headers.each do |_name, _options| %>
<col>
<% end %>
<col data-highlight="false">
</colgroup>
<thead>
<tr>
<% headers.each do |name, options| %>
<% if sortable_column?(name) %>
<%= helpers.sort_header_tag(name, **options) %>
<% else %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= options[:caption] %>
</span>
</div>
</div>
</th>
<% end %>
<%= component_wrapper do %>
<div class="generic-table--container <%= container_class %>" data-test-selector="<%= test_selector %>" id="<%= container_id %>">
<div class="generic-table--results-container">
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
<colgroup>
<% headers.each do |_name, _options| %>
<col>
<% end %>
<th>
<%# last column for buttons %>
<div class="generic-table--empty-header"></div>
</th>
</tr>
</thead>
<tbody>
<% if rows.empty? %>
<tr class="generic-table--empty-row">
<td colspan="<%= headers.length + 1 %>"><%= empty_row_message %></td>
</tr>
<% end %>
<%= render_collection rows %>
</tbody>
<% end %>
<% if inline_create_link %>
<div class="wp-inline-create-button">
<%= inline_create_link %>
</div>
<% end %>
<col data-highlight="false">
</colgroup>
<thead>
<tr>
<% headers.each do |name, options| %>
<% if sortable_column?(name) %>
<%= helpers.sort_header_tag(name, **options) %>
<% else %>
<th>
<div class="generic-table--sort-header-outer">
<div class="generic-table--sort-header">
<span>
<%= options[:caption] %>
</span>
</div>
</div>
</th>
<% end %>
<% end %>
<th>
<%# last column for buttons %>
<div class="generic-table--empty-header"></div>
</th>
</tr>
</thead>
<tbody>
<% if rows.empty? %>
<tr class="generic-table--empty-row">
<td colspan="<%= headers.length + 1 %>"><%= empty_row_message %></td>
</tr>
<% end %>
<%= render_collection rows %>
</tbody>
<% end %>
<% if inline_create_link %>
<div class="wp-inline-create-button">
<%= inline_create_link %>
</div>
<% end %>
</div>
</div>
</div>
<% if paginated? %>
<%= helpers.pagination_links_full rows %>
<% if paginated? %>
<%= helpers.pagination_links_full rows %>
<% end %>
<% end %>
+1
View File
@@ -32,6 +32,7 @@
# Abstract view component. Subclass this for a concrete table.
class TableComponent < ApplicationComponent
include Primer::AttributesHelper
include OpTurbo::Streamable
def initialize(rows: [], table_arguments: {}, **)
super(rows, **)
@@ -1,5 +1,5 @@
<%=
render(Primer::OpenProject::SubHeader.new) do |subheader|
render(Primer::OpenProject::SubHeader.new(data: sub_header_data_attributes)) do |subheader|
subheader.with_action_button(
scheme: :primary,
leading_icon: :plus,
@@ -10,8 +10,21 @@
t("activerecord.models.user")
end
subheader.with_filter_input(
name: "any_name_attribute",
label: t(:label_search),
value: filter_input_value,
placeholder: t(:label_search),
clear_button_id: clear_button_id,
data: filter_input_data_attributes
)
subheader.with_filter_component do
render Users::UserFilterButtonComponent.new(query: @query)
end
subheader.with_bottom_pane_component do
render Users::UserFilterComponent.new(@params, groups: @groups, status: @status)
render Users::UserFiltersComponent.new(query: @query, initially_expanded: filters_expanded?)
end
end
%>
@@ -32,11 +32,39 @@ module Users
class IndexSubHeaderComponent < ApplicationComponent
include ApplicationHelper
def initialize(groups:, status:, params:)
def initialize(query:)
super
@groups = groups
@status = status
@params = params
@query = query
end
def filter_input_value
@query.find_active_filter(:any_name_attribute)&.values&.first
end
def sub_header_data_attributes
{
controller: "filter--filters-form",
"filter--filters-form-perform-turbo-requests-value": true,
"filter--filters-form-clear-button-id-value": clear_button_id,
"filter--filters-form-display-filters-value": filters_expanded?
}
end
def filter_input_data_attributes
{
"filter-name": "any_name_attribute",
"filter-type": "string",
"filter-operator": "~",
"filter--filters-form-target": "simpleFilter filterValueContainer simpleValue"
}
end
def clear_button_id
"user-filters-form-clear-button"
end
def filters_expanded?
params[:filters].present?
end
end
end
@@ -1,6 +1,6 @@
# frozen_string_literal: true
#-- copyright
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
@@ -26,16 +26,19 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
# ++
module Users
class UserFilterComponent < ::UserFilterComponent
def filter_role(query, role_id)
super.uniq
end
class UserFilterButtonComponent < Filter::FilterButtonComponent
HIDDEN_FILTERS = [
Queries::Users::Filters::AnyNameAttributeFilter,
Queries::Users::Filters::BlockedFilter
].freeze
def clear_url
users_path
private
def filters_count
query.filters.count { |f| HIDDEN_FILTERS.none? { |klass| f.is_a?(klass) } }
end
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++
module Users
class UserFiltersComponent < Filter::FilterComponent
def turbo_requests? = true
def allowed_filters
super
.grep_v(Queries::Users::Filters::AnyNameAttributeFilter)
.grep_v(Queries::Users::Filters::BlockedFilter)
.sort_by(&:human_name)
end
protected
def additional_filter_attributes(filter)
case filter
when Queries::Users::Filters::GroupFilter
{
autocomplete_options: {
component: "opce-group-autocompleter",
resource: "groups"
}
}
else
super
end
end
end
end
+14 -3
View File
@@ -79,11 +79,15 @@ class UsersController < ApplicationController
include SortHelper
include CustomFieldsHelper
include PaginationHelper
include Queries::Loading
before_action :load_query_or_deny_access, only: :index
def index
@groups = Group.visible.sort
@status = Users::UserFilterComponent.status_param params
@users = Users::UserFilterComponent.filter params
respond_to do |format|
format.html
format.turbo_stream { render_index_turbo_stream }
end
end
def show
@@ -423,6 +427,13 @@ class UsersController < ApplicationController
status: User.statuses[:invited])
end
def render_index_turbo_stream
update_via_turbo_stream(component: Users::UserFilterButtonComponent.new(query: @query))
replace_via_turbo_stream(component: Users::TableComponent.new(rows: @query, current_user:))
turbo_streams << turbo_stream.push_state(url_for(params.permit(:filters, :sortBy, :sort, :page, :per_page)))
render turbo_stream: turbo_streams
end
def prepare_views_for_tab # rubocop:disable Metrics/AbcSize
if params[:tab] == "non_working_times"
authorize_manage_working_times
@@ -32,6 +32,6 @@ class Queries::Users::Orders::DefaultOrder < Queries::Orders::Base
self.model = User
def self.key
/\A(id|lastname|firstname|mail|login)\z/
/\A(id|lastname|firstname|mail|login|admin|created_at|last_login_on)\z/
end
end
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++
class UserQueries::Static
DEFAULT = "active"
class << self
def query(id)
case id
when DEFAULT, nil
static_query_active
end
end
private
def static_query_active
UserQuery.new(name: I18n.t(:status_active)) do |query|
query.where("status", "=", "active")
query.clear_changes_information
end
end
end
end
+2
View File
@@ -29,6 +29,8 @@
#++
class UserQuery < PersistedQuery
scope :visible, ->(user = User.current) { where(principal: user) }
def self.model
User
end
@@ -0,0 +1,60 @@
# frozen_string_literal: true
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++
class UserQueries::SetAttributesService < BaseServices::SetAttributes
private
def set_attributes(params)
set_filters(params.delete(:filters))
set_order(params.delete(:orders))
super
end
def set_default_attributes(_params)
# No user or project association needed for admin-scoped user queries
end
def set_filters(filters)
return unless filters
model.filters.clear
filters.each do |filter|
model.where(filter[:attribute], filter[:operator], filter[:values])
end
end
def set_order(orders)
return unless orders
model.orders.clear
model.order(orders.to_h { |o| [o[:attribute], o[:direction]] })
end
end
+2 -2
View File
@@ -30,6 +30,6 @@ See COPYRIGHT and LICENSE files for more details.
<%= render Users::IndexPageHeaderComponent.new %>
<%= render Users::IndexSubHeaderComponent.new(groups: @groups, status: @status, params: params) %>
<%= render Users::IndexSubHeaderComponent.new(query: @query) %>
<%= render Users::TableComponent.new(rows: @users, current_user:) %>
<%= render Users::TableComponent.new(rows: @query, current_user:) %>
+15 -16
View File
@@ -736,15 +736,15 @@ RSpec.describe UsersController do
expect(response).to have_rendered("index")
end
it "assigns users" do
expect(assigns(:users)).to contain_exactly(user, admin, user_manager)
it "assigns a query" do
expect(assigns(:query).results).to contain_exactly(user, admin, user_manager)
end
context "with a name filter" do
let(:params) { { name: user.firstname } }
let(:params) { { filters: %(any_name_attribute ~ "#{user.firstname}") } }
it "assigns users" do
expect(assigns(:users)).to contain_exactly(user)
it "assigns a query filtered by name" do
expect(assigns(:query).results).to contain_exactly(user)
end
end
@@ -752,11 +752,11 @@ RSpec.describe UsersController do
let(:group) { create(:group, members: [user]) }
let(:params) do
{ group_id: group.id }
{ filters: %(group = "#{group.id}") }
end
it "assigns users" do
expect(assigns(:users)).to contain_exactly(user)
it "assigns a query filtered by group" do
expect(assigns(:query).results).to contain_exactly(user)
end
end
@@ -764,7 +764,7 @@ RSpec.describe UsersController do
let!(:deleted_user) { create(:user_marked_for_deletion) }
it "does not include this user to the users list" do
expect(assigns(:users)).to contain_exactly(user, admin, user_manager)
expect(assigns(:query).results).to contain_exactly(user, admin, user_manager)
end
end
end
@@ -806,14 +806,13 @@ RSpec.describe UsersController do
context "enabled" do
before do
allow(Setting).to receive(:session_ttl_enabled?).and_return(true)
allow(Setting).to receive(:session_ttl).and_return("120")
allow(Setting).to receive_messages(session_ttl_enabled?: true, session_ttl: "120")
@controller.send(:logged_user=, admin)
end
context "before 120 min of inactivity" do
before do
session[:updated_at] = Time.now - 1.hour
session[:updated_at] = 1.hour.ago
get :index
end
@@ -822,7 +821,7 @@ RSpec.describe UsersController do
context "after 120 min of inactivity" do
before do
session[:updated_at] = Time.now - 3.hours
session[:updated_at] = 3.hours.ago
get :index
end
@@ -842,7 +841,7 @@ RSpec.describe UsersController do
context "with ttl = 0" do
before do
allow(Setting).to receive(:session_ttl).and_return("0")
session[:updated_at] = Time.now - 1.hour
session[:updated_at] = 1.hour.ago
get :index
end
@@ -852,7 +851,7 @@ RSpec.describe UsersController do
context "with ttl < 0" do
before do
allow(Setting).to receive(:session_ttl).and_return("-60")
session[:updated_at] = Time.now - 1.hour
session[:updated_at] = 1.hour.ago
get :index
end
@@ -862,7 +861,7 @@ RSpec.describe UsersController do
context "with ttl < 5 > 0" do
before do
allow(Setting).to receive(:session_ttl).and_return("4")
session[:updated_at] = Time.now - 1.hour
session[:updated_at] = 1.hour.ago
get :index
end
+12 -33
View File
@@ -68,27 +68,18 @@ RSpec.describe "index users", :js do
shared_let(:registered_user) { create(:user, status: User.statuses[:registered]) }
shared_let(:invited_user) { create(:user, status: User.statuses[:invited]) }
it "shows the users by status and allows status manipulations",
it "shows active users by default and allows status filtering and manipulations",
with_settings: { brute_force_block_after_failed_logins: 5,
brute_force_block_minutes: 10 } do
index_page.visit!
# Order is by id, asc
# so first ones created are on top.
index_page.expect_listed(current_user, active_user, registered_user, invited_user)
index_page.order_by("Created on")
index_page.expect_order(invited_user, registered_user, active_user, current_user)
index_page.order_by("Created on")
index_page.expect_order(current_user, active_user, registered_user, invited_user)
# Default filter: active users only
index_page.expect_listed(current_user, active_user)
index_page.lock_user(active_user)
index_page.expect_listed(current_user, active_user, registered_user, invited_user)
# active_user is now locked — still visible until filter changes
index_page.expect_user_locked(active_user)
expect(active_user.reload)
.to be_locked
expect(active_user.reload).to be_locked
index_page.filter_by_status("locked permanently")
index_page.expect_listed(active_user)
@@ -106,30 +97,19 @@ RSpec.describe "index users", :js do
index_page.filter_by_name(active_user.lastname[0..-3])
index_page.expect_listed(active_user)
# temporarily block user
# temporarily block user — reset via action, no filter needed
active_user.update(failed_login_count: 6,
last_failed_login_on: 9.minutes.ago)
index_page.clear_filters
index_page.expect_listed(current_user, active_user, registered_user, invited_user)
index_page.filter_by_status("locked temporarily")
index_page.expect_listed(active_user)
# after clear, default active filter is restored
index_page.expect_listed(current_user, active_user)
index_page.reset_failed_logins(active_user)
index_page.expect_non_listed
# temporarily block user and lock permanently
active_user.reload
active_user.update(failed_login_count: 6,
last_failed_login_on: 9.minutes.ago)
index_page.clear_filters
index_page.filter_by_status("locked temporarily")
index_page.expect_listed(active_user)
# still listed — reset doesn't change status
index_page.expect_listed(current_user, active_user)
# lock permanently and unlock
index_page.lock_user(active_user)
index_page.expect_listed(active_user)
index_page.filter_by_status("locked permanently")
index_page.expect_listed(active_user)
@@ -145,7 +125,6 @@ RSpec.describe "index users", :js do
index_page.activate_user(registered_user)
index_page.filter_by_status("active")
index_page.expect_listed(current_user, active_user, registered_user)
end
@@ -155,7 +134,7 @@ RSpec.describe "index users", :js do
it "can too visit the page" do
index_page.visit!
index_page.expect_listed(admin, current_user, active_user, registered_user, invited_user)
index_page.expect_listed(admin, current_user, active_user)
end
end
end
+19 -5
View File
@@ -62,25 +62,39 @@ module Pages
end
def filter_by_status(value)
select value, from: "Status:"
click_button "Apply"
open_filter_panel
select "Status", from: "Add filter"
within_filter("status") do
find("[data-filter-name='status'] select").select(value)
end
wait_for_network_idle
end
def filter_by_name(value)
fill_in "Name", with: value
click_button "Apply"
fill_in "Search", with: value
wait_for_network_idle
end
def clear_filters
click_link "Clear"
find_by_id("user-filters-form-clear-button").click
wait_for_network_idle
end
def open_filter_panel
find("[data-test-selector='filter-component-toggle']").click unless filter_panel_open?
end
def filter_panel_open?
page.has_css?(".advanced-filters--container.-expanded", wait: 0)
end
def within_filter(name, &)
within("[data-filter-name='#{name}']", &)
end
def order_by(key)
within "thead" do
click_link key
+1 -10
View File
@@ -39,9 +39,7 @@ RSpec.describe "users/index" do
before do
User.system # create system user which is active but should not count towards limit
assign(:users, User.where(id: [admin.id, user.id]))
assign(:status, "all")
assign(:groups, Group.visible)
assign(:query, UserQueries::Static.query(nil))
without_partial_double_verification do
allow(view).to receive_messages(current_user: admin, controller_name: "users", action_name: "index")
@@ -50,13 +48,6 @@ RSpec.describe "users/index" do
subject { rendered.squish }
it "renders the user table" do
render
expect(subject).to have_text("#{admin.firstname} #{admin.lastname}")
expect(subject).to have_text("Scarlet Scallywag")
end
context "with an Enterprise token" do
before do
create_enterprise_token("token_5_users", restrictions: { active_user_count: 5 })