diff --git a/app/components/work_packages/share/bulk_permission_button_component.html.erb b/app/components/work_packages/share/bulk_permission_button_component.html.erb index 8a706b4cb4ca..b1b0a5173f4f 100644 --- a/app/components/work_packages/share/bulk_permission_button_component.html.erb +++ b/app/components/work_packages/share/bulk_permission_button_component.html.erb @@ -4,7 +4,7 @@ anchor_align: :end, color: :subtle, data: { test_selector: 'op-share-wp-bulk-update-role'})) do |menu| - menu.with_show_button(data: { 'work-packages--share--bulk-selection-target': 'bulkUpdateRoleLabel' }) do |button| + menu.with_show_button(scheme: :invisible, color: :subtle, data: { 'work-packages--share--bulk-selection-target': 'bulkUpdateRoleLabel' }) do |button| button.with_trailing_action_icon(icon: "triangle-down") 'Placeholder' end diff --git a/app/components/work_packages/share/modal_body_component.html.erb b/app/components/work_packages/share/modal_body_component.html.erb index d4bb73dc9147..d004ee4c338c 100644 --- a/app/components/work_packages/share/modal_body_component.html.erb +++ b/app/components/work_packages/share/modal_body_component.html.erb @@ -5,7 +5,7 @@ render(WorkPackages::Share::InviteUserFormComponent.new(work_package: @work_package)) end - if shared_principals.none? + if @shares.blank? modal_content.with_row(mt: 3) do render(Primer::Beta::Blankslate.new(border: true)) do |component| component.with_visual_icon(icon: :people, size: :medium) @@ -28,13 +28,59 @@ border_box.with_header(color: :subtle, data: { 'test-selector': 'op-share-wp-header' }) do grid_layout('op-share-wp-modal-body--header', tag: :div, align_items: :center) do |header_grid| header_grid.with_area(:counter, tag: :div) do - render(WorkPackages::Share::CounterComponent.new(work_package: @work_package, count: shared_principals.size)) + render(WorkPackages::Share::CounterComponent.new(work_package: @work_package, count: @shares.size)) + end + + header_grid.with_area(:actions, + tag: :div, + data: { 'work-packages--share--bulk-selection-target': 'defaultActions' }) do + flex_layout do |header_actions| + header_actions.with_column(mr: 2) do + render(Primer::Alpha::ActionMenu.new(anchor_align: :end, + select_variant: :single, + color: :subtle, + data: { 'test-selector': 'op-share-wp-filter-type' })) do |menu| + menu.with_show_button(scheme: :invisible, color: :subtle) do |button| + button.with_trailing_action_icon(icon: "triangle-down") + I18n.t('work_package.sharing.filter.type') + end + type_filter_options.each do |option| + menu.with_item(label: option[:label], + href: filter_url(type_option: option), + method: :get, + tag: :a, + active: type_filter_option_active?(option), + role: "menuitem") + end + end + end + + header_actions.with_column do + render(Primer::Alpha::ActionMenu.new(anchor_align: :end, + select_variant: :single, + color: :subtle, + data: { 'test-selector': 'op-share-wp-filter-role' })) do |menu| + menu.with_show_button(scheme: :invisible, color: :subtle) do |button| + button.with_trailing_action_icon(icon: "triangle-down") + I18n.t('work_package.sharing.filter.role') + end + options.each do |option| + menu.with_item(label: option[:label], + href: filter_url(role_option: option), + method: :get, + tag: :a, + active: role_filter_option_active?(option), + role: "menuitem") + end + end + end + end end header_grid.with_area(:actions, tag: :div, hidden: true, # Prevent flicker on initial render - data: { 'work-packages--share--bulk-selection-target': 'actions' }) do + data: { 'work-packages--share--bulk-selection-target': 'bulkActions' }) do if sharing_manageable? concat( render(WorkPackages::Share::BulkPermissionButtonComponent.new(work_package: @work_package)) @@ -52,12 +98,8 @@ end end - shared_principals.each do |principal| - share = principal.work_package_shares - .where(entity: @work_package) - .first - - render(WorkPackages::Share::ShareRowComponent.new(share:, container: border_box)) + @shares.each do |share| + render(WorkPackages::Share::ShareRowComponent.new(share: share, container: border_box)) end end end diff --git a/app/components/work_packages/share/modal_body_component.rb b/app/components/work_packages/share/modal_body_component.rb index 371d8c627da2..29f237090a1b 100644 --- a/app/components/work_packages/share/modal_body_component.rb +++ b/app/components/work_packages/share/modal_body_component.rb @@ -30,14 +30,17 @@ module WorkPackages module Share class ModalBodyComponent < ApplicationComponent include ApplicationHelper + include MemberHelper include OpTurbo::Streamable include OpPrimer::ComponentHelpers include WorkPackages::Share::Concerns::Authorization + include WorkPackages::Share::Concerns::DisplayableRoles - def initialize(work_package:) + def initialize(work_package:, shares:) super @work_package = work_package + @shares = shares end def self.wrapper_key @@ -54,13 +57,108 @@ def insert_target_modifier_id 'op-share-wp-active-shares' end - def shared_principals - @shared_principals ||= Principal - .having_entity_membership(@work_package) - .includes(work_package_shares: :roles) - .where(work_package_shares: { entity: @work_package }) - .merge(MemberRole.only_non_inherited) - .ordered_by_name + def type_filter_options + [ + { label: I18n.t('work_package.sharing.filter.project_member'), + value: { principal_type: 'User', project_member: true } }, + { label: I18n.t('work_package.sharing.filter.not_project_member'), + value: { principal_type: 'User', project_member: false } }, + { label: I18n.t('work_package.sharing.filter.project_group'), + value: { principal_type: 'Group', project_member: true } }, + { label: I18n.t('work_package.sharing.filter.not_project_group'), + value: { principal_type: 'Group', project_member: false } } + ] + end + + def type_filter_option_active?(_option) + principal_type_filter_value = current_filter_value(params[:filters], 'principal_type') + project_member_filter_value = current_filter_value(params[:filters], 'also_project_member') + + return false if principal_type_filter_value.nil? || project_member_filter_value.nil? + + principal_type_checked = + _option[:value][:principal_type] == principal_type_filter_value + membership_selected = + _option[:value][:project_member] == ActiveRecord::Type::Boolean.new.cast(project_member_filter_value) + + principal_type_checked && membership_selected + end + + def role_filter_option_active?(_option) + role_filter_value = current_filter_value(params[:filters], 'role_id') + + return false if role_filter_value.nil? + + find_role_ids(_option[:value]).first == role_filter_value.to_i + end + + def filter_url(type_option: nil, role_option: nil) + return work_package_shares_path if type_option.nil? && role_option.nil? + + args = {} + filter = [] + + filter += apply_role_filter(role_option) + filter += apply_type_filter(type_option) + + args[:filters] = filter.to_json unless filter.empty? + + work_package_shares_path(args) + end + + def apply_role_filter(_option) + current_role_filter_value = current_filter_value(params[:filters], 'role_id') + filter = [] + + if _option.nil? && current_role_filter_value.present? + # When there is already a role filter set and no new value passed, we want to keep that filter + filter = role_filter_for({ value: current_role_filter_value }, builtin_role: false) + elsif _option.present? && !role_filter_option_active?(_option) + # Only when the passed filter option is not the currently selected one, we apply the filter + filter = role_filter_for(_option) + end + + filter + end + + def role_filter_for(_option, builtin_role: true) + [{ role_id: { operator: "=", values: builtin_role ? find_role_ids(_option[:value]) : [_option[:value]] } }] + end + + def apply_type_filter(_option) + current_type_filter_value = current_filter_value(params[:filters], 'principal_type') + current_member_filter_value = current_filter_value(params[:filters], 'also_project_member') + filter = [] + + if _option.nil? && current_type_filter_value.present? && current_member_filter_value.present? + # When there is already a type filter set and no new value passed, we want to keep that filter + value = { value: { principal_type: current_type_filter_value, project_member: current_member_filter_value } } + filter = type_filter_for(value) + elsif _option.present? && !type_filter_option_active?(_option) + # Only when the passed filter option is not the currently selected one, we apply the filter + filter = type_filter_for(_option) + end + + filter + end + + def type_filter_for(_option) + filter = [] + if ActiveRecord::Type::Boolean.new.cast(_option[:value][:project_member]) + filter.push({ also_project_member: { operator: "=", values: [OpenProject::Database::DB_VALUE_TRUE] } }) + else + filter.push({ also_project_member: { operator: "=", values: [OpenProject::Database::DB_VALUE_FALSE] } }) + end + + filter.push({ principal_type: { operator: "=", values: [_option[:value][:principal_type]] } }) + filter + end + + def current_filter_value(filters, filter_key) + return nil if filters.nil? + + given_filters = JSON.parse(filters).find { |key| key.key?(filter_key) } + given_filters ? given_filters[filter_key]['values'].first : nil end end end diff --git a/app/controllers/concerns/member_helper.rb b/app/controllers/concerns/member_helper.rb index 7948181b4ffb..c9abd1529ce5 100644 --- a/app/controllers/concerns/member_helper.rb +++ b/app/controllers/concerns/member_helper.rb @@ -29,6 +29,12 @@ module MemberHelper module_function + def find_role_ids(builtin_value) + # Role has a left join on permissions included leading to multiple ids being returned which + # is why we unscope. + WorkPackageRole.unscoped.where(builtin: builtin_value).pluck(:id) + end + def find_or_create_users(send_notification: true) @send_notification = send_notification diff --git a/app/controllers/work_packages/shares/bulk_controller.rb b/app/controllers/work_packages/shares/bulk_controller.rb index d56078099812..ea553b7657f9 100644 --- a/app/controllers/work_packages/shares/bulk_controller.rb +++ b/app/controllers/work_packages/shares/bulk_controller.rb @@ -30,15 +30,16 @@ class WorkPackages::Shares::BulkController < ApplicationController include OpTurbo::ComponentStream + include MemberHelper before_action :find_work_package - before_action :find_shares - before_action :find_role_ids, only: :update + before_action :find_selected_shares + before_action :find_role_ids_from_params, only: :update before_action :find_project before_action :authorize def update - @shares.each do |share| + @selected_shares.each do |share| WorkPackageMembers::CreateOrUpdateService .new(user: current_user) .call(entity: @work_package, @@ -50,7 +51,7 @@ def update end def destroy - @shares.each do |share| + @selected_shares.each do |share| WorkPackageMembers::DeleteService .new(user: current_user, model: share) .call @@ -66,7 +67,7 @@ def destroy private def respond_with_update_permission_buttons - @shares.each do |share| + @selected_shares.each do |share| replace_via_turbo_stream( component: WorkPackages::Share::PermissionButtonComponent.new(share:, data: { 'test-selector': 'op-share-wp-update-role' }) @@ -78,14 +79,14 @@ def respond_with_update_permission_buttons def respond_with_replace_modal replace_via_turbo_stream( - component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package) + component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package, shares: find_shares) ) respond_with_turbo_streams end def respond_with_remove_shares - @shares.each do |share| + @selected_shares.each do |share| remove_via_turbo_stream( component: WorkPackages::Share::ShareRowComponent.new(share:) ) @@ -107,15 +108,20 @@ def find_project end def find_shares - @shares = Member.includes(:principal) + @shares = Member.includes(:principal, :member_roles) + .references(:member_roles) .of_work_package(@work_package) - .where(id: params[:share_ids]) + .merge(MemberRole.only_non_inherited) end - def find_role_ids - @role_ids = WorkPackageRole.unscoped - .where(builtin: params[:role_ids]) - .pluck(:id) + def find_selected_shares + @selected_shares = Member.includes(:principal) + .of_work_package(@work_package) + .where(id: params[:share_ids]) + end + + def find_role_ids_from_params + @role_ids = find_role_ids(params[:role_ids]) end def current_visible_member_count diff --git a/app/controllers/work_packages/shares_controller.rb b/app/controllers/work_packages/shares_controller.rb index e7b3770aca06..9b54db9928d9 100644 --- a/app/controllers/work_packages/shares_controller.rb +++ b/app/controllers/work_packages/shares_controller.rb @@ -36,7 +36,15 @@ class WorkPackages::SharesController < ApplicationController before_action :authorize def index - render WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package), layout: nil + query = load_query + + unless query.valid? + flash.now[:error] = query.errors.full_messages + end + + @shares = load_shares query + + render WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package, shares: @shares), layout: nil end def create @@ -49,17 +57,15 @@ def create user_id: member_params[:user_id], role_ids: find_role_ids(params[:member][:role_id])) - @share = service_call.result - overall_result.push(service_call) end - @shares = overall_result.map(&:result).reverse + @new_shares = overall_result.map(&:result).reverse if overall_result.present? # In case the number of newly added shares is equal to the whole number of shares, # we have to render the whole modal again to get rid of the blankslate - if current_visible_member_count > 1 && @shares.size < current_visible_member_count + if current_visible_member_count > 1 && @new_shares.size < current_visible_member_count respond_with_prepend_shares else respond_with_replace_modal @@ -91,7 +97,7 @@ def destroy def respond_with_replace_modal replace_via_turbo_stream( - component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package) + component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package, shares: @new_shares || find_shares) ) respond_with_turbo_streams @@ -106,10 +112,10 @@ def respond_with_prepend_shares component: WorkPackages::Share::CounterComponent.new(work_package: @work_package, count: current_visible_member_count) ) - @shares.each do |share| + @new_shares.each do |share| prepend_via_turbo_stream( component: WorkPackages::Share::ShareRowComponent.new(share:), - target_component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package) + target_component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package, shares: find_shares) ) end @@ -146,14 +152,15 @@ def find_share @work_package = @share.entity end - def find_project - @project = @work_package.project + def find_shares + @shares = Member.includes(:roles) + .references(:member_roles) + .of_work_package(@work_package) + .merge(MemberRole.only_non_inherited) end - def find_role_ids(builtin_value) - # Role has a left join on permissions included leading to multiple ids being returned which - # is why we unscope. - WorkPackageRole.unscoped.where(builtin: builtin_value).pluck(:id) + def find_project + @project = @work_package.project end def current_visible_member_count @@ -163,4 +170,25 @@ def current_visible_member_count .merge(MemberRole.only_non_inherited) .size end + + def load_query + @query = ParamsToQueryService.new(Member, + current_user, + query_class: Queries::Members::WorkPackageMemberQuery) + .call(params) + + # Set default filter on the entity + @query.where('entity_id', '=', @work_package.id) + @query.where('entity_type', '=', WorkPackage.name) + @query.where('project_id', '=', @project.id) + + @query.order(name: :asc) unless params[:sortBy] + + @query + end + + def load_shares(query) + query + .results + end end diff --git a/app/models/queries/members.rb b/app/models/queries/members.rb index 903688871dd0..47ddbce0410f 100644 --- a/app/models/queries/members.rb +++ b/app/models/queries/members.rb @@ -36,8 +36,34 @@ module Queries::Members filter Filters::GroupFilter filter Filters::RoleFilter filter Filters::PrincipalFilter + filter Filters::PrincipalTypeFilter filter Filters::CreatedAtFilter filter Filters::UpdatedAtFilter + filter Filters::EntityIdFilter + filter Filters::EntityTypeFilter + filter Filters::AlsoProjectMemberFilter + + order Orders::DefaultOrder + order Orders::NameOrder + order Orders::EmailOrder + order Orders::StatusOrder + end + + ::Queries::Register.register(WorkPackageMemberQuery) do + filter Filters::NameFilter + filter Filters::AnyNameAttributeFilter + filter Filters::ProjectFilter + filter Filters::StatusFilter + filter Filters::BlockedFilter + filter Filters::GroupFilter + filter Filters::RoleFilter + filter Filters::PrincipalFilter + filter Filters::PrincipalTypeFilter + filter Filters::CreatedAtFilter + filter Filters::UpdatedAtFilter + filter Filters::EntityIdFilter + filter Filters::EntityTypeFilter + filter Filters::AlsoProjectMemberFilter order Orders::DefaultOrder order Orders::NameOrder diff --git a/app/models/queries/members/filters/also_project_member_filter.rb b/app/models/queries/members/filters/also_project_member_filter.rb new file mode 100644 index 000000000000..27a6378dd42a --- /dev/null +++ b/app/models/queries/members/filters/also_project_member_filter.rb @@ -0,0 +1,61 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Queries::Members::Filters::AlsoProjectMemberFilter < Queries::Members::Filters::MemberFilter + include Queries::Filters::Shared::BooleanFilter + + def where + if allowed_values.first.intersect?(values) + "EXISTS (#{project_member_subquery})" + else + "NOT EXISTS (#{project_member_subquery})" + end + end + + def available_operators + [::Queries::Operators::BooleanEquals] + end + + def type_strategy + @type_strategy ||= ::Queries::Filters::Strategies::BooleanListStrict.new self + end + + private + + def project_member_subquery + <<~SQL.squish + SELECT 1 FROM "members" as "project_members" + WHERE + project_members.user_id = members.user_id AND + project_members.project_id = members.project_id AND + project_members.entity_type IS NULL AND + project_members.entity_id IS NULL AND + project_members.id != members.id + SQL + end +end diff --git a/app/models/queries/members/filters/entity_id_filter.rb b/app/models/queries/members/filters/entity_id_filter.rb new file mode 100644 index 000000000000..4f81d1784c1c --- /dev/null +++ b/app/models/queries/members/filters/entity_id_filter.rb @@ -0,0 +1,37 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Queries::Members::Filters::EntityIdFilter < Queries::Members::Filters::MemberFilter + def type + :integer + end + + def self.key + :entity_id + end +end diff --git a/app/models/queries/members/filters/entity_type_filter.rb b/app/models/queries/members/filters/entity_type_filter.rb new file mode 100644 index 000000000000..7430f58b811f --- /dev/null +++ b/app/models/queries/members/filters/entity_type_filter.rb @@ -0,0 +1,41 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Queries::Members::Filters::EntityTypeFilter < Queries::Members::Filters::MemberFilter + def type + :list_optional + end + + def allowed_values + [[WorkPackage.name, WorkPackage.name]] + end + + def self.key + :entity_type + end +end diff --git a/app/models/queries/members/filters/principal_type_filter.rb b/app/models/queries/members/filters/principal_type_filter.rb new file mode 100644 index 000000000000..a7b432a1dacc --- /dev/null +++ b/app/models/queries/members/filters/principal_type_filter.rb @@ -0,0 +1,49 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Queries::Members::Filters::PrincipalTypeFilter < Queries::Members::Filters::MemberFilter + def allowed_values + [[User.name, User.name], [Group.name, Group.name], [PlaceholderUser.name, PlaceholderUser.name]] + end + + def joins + :principal + end + + def type + :list + end + + def self.key + :principal_type + end + + def where + operator_strategy.sql_for_field(values, Principal.table_name, :type) + end +end diff --git a/app/models/queries/members/member_query.rb b/app/models/queries/members/member_query.rb index 63fca028e51a..b7bdf9050ef6 100644 --- a/app/models/queries/members/member_query.rb +++ b/app/models/queries/members/member_query.rb @@ -37,8 +37,6 @@ def results end def default_scope - # TODO: For now we exclude entity specific memberships in the API until we have updated the - # frontend and representers to show those properly - Member.visible(User.current).where(entity: nil) + Member.visible(User.current) end end diff --git a/app/models/queries/members/work_package_member_query.rb b/app/models/queries/members/work_package_member_query.rb new file mode 100644 index 000000000000..36436a38a6f3 --- /dev/null +++ b/app/models/queries/members/work_package_member_query.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2023 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 Queries::Members::WorkPackageMemberQuery < Queries::Members::MemberQuery + def default_scope + Member.joins(:member_roles).merge(MemberRole.only_non_inherited) + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 834ce55ca95a..3177a6c28c03 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3238,7 +3238,13 @@ en: zero: "0 users" one: "1 user" other: "%{count} users" - share: "Share" + filter: + project_member: 'Project member' + not_project_member: 'Not project member' + project_group: 'Project group' + not_project_group: 'Not project group' + role: 'Role' + type: 'Type' label_search: "Search for users to invite" label_search_placeholder: "Search by user or email address" label_toggle_all: "Toggle all shares" @@ -3251,6 +3257,7 @@ en: view: "View" view_description: "Can view this work package." remove: "Remove" + share: "Share" text_empty_state_description: "The work package has not been shared with anyone yet." text_empty_state_header: "No shared users" diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts index dc5ea28be212..3f0199909a68 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts @@ -42,7 +42,8 @@ export default class BulkSelectionController extends Controller { 'shareCheckbox', 'sharedCounter', 'selectedCounter', - 'actions', + 'defaultActions', + 'bulkActions', 'bulkForm', 'hiddenShare', 'userRowRole', @@ -63,7 +64,8 @@ export default class BulkSelectionController extends Controller { // Specific target for bulk update permission forms declare readonly bulkUpdateRoleFormTargets:HTMLFormElement[]; declare readonly hiddenShareTargets:HTMLInputElement[]; - declare readonly actionsTarget:HTMLElement; + declare readonly bulkActionsTarget:HTMLElement; + declare readonly defaultActionsTarget:HTMLElement; // Permission Buttons declare readonly userRowRoleTargets:HTMLButtonElement[]; @@ -126,9 +128,11 @@ export default class BulkSelectionController extends Controller { this.toggleAllTarget.checked = checkedSharesCount === sharesCount; if (this.checked.length === 0) { - this.actionsTarget.setAttribute('hidden', 'true'); + this.bulkActionsTarget.setAttribute('hidden', 'true'); + this.defaultActionsTarget.removeAttribute('hidden'); } else { - this.actionsTarget.removeAttribute('hidden'); + this.bulkActionsTarget.removeAttribute('hidden'); + this.defaultActionsTarget.setAttribute('hidden', 'true'); this.updateBulkUpdateRoleLabelValue(); } diff --git a/spec/features/work_packages/share/filter_spec.rb b/spec/features/work_packages/share/filter_spec.rb new file mode 100644 index 000000000000..253d3f5470eb --- /dev/null +++ b/spec/features/work_packages/share/filter_spec.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 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. +# ++ + +require 'spec_helper' + +RSpec.describe 'Work package sharing', + :js, + :with_cuprite, + with_flag: { work_package_sharing: true } do + shared_let(:view_work_package_role) { create(:view_work_package_role) } + shared_let(:comment_work_package_role) { create(:comment_work_package_role) } + shared_let(:edit_work_package_role) { create(:edit_work_package_role) } + + shared_let(:project_user) { create(:user, firstname: 'Anton') } + shared_let(:project_user2) { create(:user, firstname: 'Bertha') } + shared_let(:inherited_project_user) { create(:user, firstname: 'Caesar') } + shared_let(:non_project_user) { create(:user, firstname: 'Dora') } + + shared_let(:shared_project_group) { create(:group, members: [project_user, inherited_project_user]) } + shared_let(:shared_non_project_group) { create(:group, members: [project_user2, non_project_user]) } + + let(:project) do + create(:project, + members: { current_user => [sharer_role], + project_user => [sharer_role], + project_user2 => [sharer_role], + shared_project_group => [sharer_role] }) + end + + let(:sharer_role) do + create(:project_role, + permissions: %i(view_work_packages + view_shared_work_packages + share_work_packages)) + end + let(:work_package) do + create(:work_package, project:) do |wp| + create(:work_package_member, entity: wp, user: project_user, roles: [view_work_package_role]) + create(:work_package_member, entity: wp, user: project_user2, roles: [comment_work_package_role]) + create(:work_package_member, entity: wp, user: inherited_project_user, roles: [edit_work_package_role]) + create(:work_package_member, entity: wp, user: non_project_user, roles: [edit_work_package_role]) + create(:work_package_member, entity: wp, user: shared_project_group, roles: [edit_work_package_role]) + create(:work_package_member, entity: wp, user: shared_non_project_group, roles: [view_work_package_role]) + end + end + + let(:work_package_page) { Pages::FullWorkPackage.new(work_package) } + let(:share_modal) { Components::WorkPackages::ShareModal.new(work_package) } + + current_user { create(:admin, firstname: 'Signed in', lastname: 'User') } + + context 'when having share permission' do + before do + work_package_page.visit! + click_button 'Share' + end + + it 'allows to filter for the type' do + share_modal.expect_open + share_modal.expect_shared_count_of(6) + + # Filter for: project members (users only) + share_modal.filter('type', I18n.t('work_package.sharing.filter.project_member')) + share_modal.expect_shared_count_of(3) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_shared_with(project_user2, 'Comment') + # The non-project user is listed because it is part of the project group and thus the membership is inherited. + share_modal.expect_shared_with(inherited_project_user, 'Edit') + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + share_modal.expect_not_shared_with(shared_non_project_group) + + # Filter for: non-project members (users only) + share_modal.filter('type', I18n.t('work_package.sharing.filter.not_project_member')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(non_project_user, 'Edit') + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(shared_project_group) + share_modal.expect_not_shared_with(shared_non_project_group) + + # Filter for: project members (groups only) + share_modal.filter('type', I18n.t('work_package.sharing.filter.project_group')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(shared_project_group, 'Edit') + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_non_project_group) + + # Filter for: non-project members (groups only) + share_modal.filter('type', I18n.t('work_package.sharing.filter.not_project_group')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(shared_non_project_group, 'View') + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + + # Clicking again on the filter will reset it + share_modal.filter('type', I18n.t('work_package.sharing.filter.not_project_group')) + share_modal.expect_shared_count_of(6) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_shared_with(project_user2, 'Comment') + share_modal.expect_shared_with(inherited_project_user, 'Edit') + share_modal.expect_shared_with(non_project_user, 'Edit') + share_modal.expect_shared_with(shared_project_group, 'Edit') + share_modal.expect_shared_with(shared_non_project_group, 'View') + end + + it 'allow to filter for the role' do + share_modal.expect_open + share_modal.expect_shared_count_of(6) + + # Filter for: all principals with Edit permission + share_modal.filter('role', I18n.t('work_package.sharing.permissions.edit')) + share_modal.expect_shared_count_of(3) + + share_modal.expect_shared_with(inherited_project_user, 'Edit') + share_modal.expect_shared_with(non_project_user, 'Edit') + share_modal.expect_shared_with(shared_project_group, 'Edit') + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(shared_non_project_group) + + # Filter for: all principals with View permission + share_modal.filter('role', I18n.t('work_package.sharing.permissions.view')) + share_modal.expect_shared_count_of(2) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_shared_with(shared_non_project_group, 'View') + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + + # Filter for: all principals with Comment permission + share_modal.filter('role', I18n.t('work_package.sharing.permissions.comment')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(project_user2, 'Comment') + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + share_modal.expect_not_shared_with(shared_non_project_group) + + # Clicking again on the filter will reset it + share_modal.filter('role', I18n.t('work_package.sharing.permissions.comment')) + share_modal.expect_shared_count_of(6) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_shared_with(project_user2, 'Comment') + share_modal.expect_shared_with(inherited_project_user, 'Edit') + share_modal.expect_shared_with(non_project_user, 'Edit') + share_modal.expect_shared_with(shared_project_group, 'Edit') + share_modal.expect_shared_with(shared_non_project_group, 'View') + end + + it 'allow to filter for role and type at the same time' do + share_modal.expect_open + share_modal.expect_shared_count_of(6) + + # Filter for: all principals with View permission + # role: view + # type: none + share_modal.filter('role', I18n.t('work_package.sharing.permissions.view')) + share_modal.expect_shared_count_of(2) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_shared_with(shared_non_project_group, 'View') + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + + sleep 1 + + # Additional filter for: project members (users only) + # role: view + # type: project members (users only) + share_modal.filter('type', I18n.t('work_package.sharing.filter.project_member')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + share_modal.expect_not_shared_with(shared_non_project_group) + + sleep 1 + + # Change type filter to: project members (groups only) + # role: view + # type: non-project members (groups only) + share_modal.filter('type', I18n.t('work_package.sharing.filter.not_project_group')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(shared_non_project_group, 'View') + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + + sleep 1 + + # Reset role filter + # role: none + # type: non-project members (groups only) + share_modal.filter('role', I18n.t('work_package.sharing.permissions.view')) + share_modal.expect_shared_count_of(1) + + share_modal.expect_shared_with(shared_non_project_group, 'View') + share_modal.expect_not_shared_with(project_user) + share_modal.expect_not_shared_with(project_user2) + share_modal.expect_not_shared_with(inherited_project_user) + share_modal.expect_not_shared_with(non_project_user) + share_modal.expect_not_shared_with(shared_project_group) + + sleep 1 + + # Reset type filter + # role: none + # type: none + share_modal.filter('type', I18n.t('work_package.sharing.filter.not_project_group')) + share_modal.expect_shared_count_of(6) + + share_modal.expect_shared_with(project_user, 'View') + share_modal.expect_shared_with(project_user2, 'Comment') + share_modal.expect_shared_with(inherited_project_user, 'Edit') + share_modal.expect_shared_with(non_project_user, 'Edit') + share_modal.expect_shared_with(shared_project_group, 'Edit') + share_modal.expect_shared_with(shared_non_project_group, 'View') + end + end +end diff --git a/spec/models/queries/members/filters/also_project_member_filter_spec.rb b/spec/models/queries/members/filters/also_project_member_filter_spec.rb new file mode 100644 index 000000000000..47bde3ad27db --- /dev/null +++ b/spec/models/queries/members/filters/also_project_member_filter_spec.rb @@ -0,0 +1,70 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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. +#++ + +require 'spec_helper' + +RSpec.describe Queries::Members::Filters::AlsoProjectMemberFilter do + it_behaves_like 'boolean query filter', scope: false do + let(:model) { Member } + let(:attribute) { nil } + + let(:exists_query) do + <<~SQL.squish + SELECT 1 FROM "members" as "project_members" + WHERE + project_members.user_id = members.user_id AND + project_members.project_id = members.project_id AND + project_members.entity_type IS NULL AND + project_members.entity_id IS NULL AND + project_members.id != members.id + SQL + end + + describe '#where' do + let(:operator) { '=' } + + context 'for true' do + let(:values) { [OpenProject::Database::DB_VALUE_TRUE] } + + it 'is the same as handwriting the query' do + expected = expected_base_scope.where("EXISTS (#{exists_query})") + expect(instance.scope.to_sql).to eql expected.to_sql + end + end + + context 'for false' do + let(:values) { [OpenProject::Database::DB_VALUE_FALSE] } + + it 'is the same as handwriting the query' do + expected = expected_base_scope.where("NOT EXISTS (#{exists_query})") + expect(instance.scope.to_sql).to eql expected.to_sql + end + end + end + end +end diff --git a/spec/models/queries/members/members_query_integration_spec.rb b/spec/models/queries/members/members_query_integration_spec.rb index 429ad97c419b..7efbce0e2add 100644 --- a/spec/models/queries/members/members_query_integration_spec.rb +++ b/spec/models/queries/members/members_query_integration_spec.rb @@ -60,9 +60,14 @@ let!(:project_membership) { create(:member, principal: user, project:, roles: [role]) } let!(:wp_membership) { create(:member, principal: user, project:, entity: work_package, roles: [wp_role]) } - it 'only returns the project membership' do - expect(subject.count).to eq(1) + it 'returns both, the project membership and the workPackage membership' do + expect(subject.count).to eq(2) expect(subject.first).to have_attributes( + project:, + entity: work_package, + user: + ) + expect(subject.second).to have_attributes( project:, entity: nil, user: diff --git a/spec/support/components/work_packages/share_modal.rb b/spec/support/components/work_packages/share_modal.rb index 3b7a086d48ac..d8ef2741e02a 100644 --- a/spec/support/components/work_packages/share_modal.rb +++ b/spec/support/components/work_packages/share_modal.rb @@ -238,6 +238,15 @@ def change_role(user, role_name) end end + def filter(filter_name, value) + within modal_element.find("[data-test-selector='op-share-wp-filter-#{filter_name}']") do + # Open the ActionMenu + click_button filter_name.capitalize + + find('.ActionListContent', text: value).click + end + end + def close within modal_element do click_button 'Close'