diff --git a/app/components/_index.sass b/app/components/_index.sass index fa7b42a43c7f..a05d3e6b1e6e 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -1,5 +1,5 @@ -@import "work_packages/share/modal_body_component" -@import "work_packages/share/invite_user_form_component" +@import "shares/modal_body_component" +@import "shares/invite_user_form_component" @import "work_packages/progress/modal_body_component" @import "open_project/common/attribute_component" @import "filter/filters_component" diff --git a/app/components/members/user_filter_component.rb b/app/components/members/user_filter_component.rb index 43180102d97a..0ef24a8de7fa 100644 --- a/app/components/members/user_filter_component.rb +++ b/app/components/members/user_filter_component.rb @@ -103,11 +103,11 @@ def builtin_share_roles def mapped_shared_role_name(role) case role.builtin when Role::BUILTIN_WORK_PACKAGE_VIEWER - I18n.t("work_package.sharing.permissions.view") + I18n.t("work_package.permissions.view") when Role::BUILTIN_WORK_PACKAGE_COMMENTER - I18n.t("work_package.sharing.permissions.comment") + I18n.t("work_package.permissions.comment") when Role::BUILTIN_WORK_PACKAGE_EDITOR - I18n.t("work_package.sharing.permissions.edit") + I18n.t("work_package.permissions.edit") else role.name end diff --git a/app/components/work_packages/share/bulk_permission_button_component.html.erb b/app/components/shares/bulk_permission_button_component.html.erb similarity index 55% rename from app/components/work_packages/share/bulk_permission_button_component.html.erb rename to app/components/shares/bulk_permission_button_component.html.erb index b1b0a5173f4f..32acee800a79 100644 --- a/app/components/work_packages/share/bulk_permission_button_component.html.erb +++ b/app/components/shares/bulk_permission_button_component.html.erb @@ -3,26 +3,26 @@ dynamic_label: true, anchor_align: :end, color: :subtle, - data: { test_selector: 'op-share-wp-bulk-update-role'})) do |menu| - menu.with_show_button(scheme: :invisible, color: :subtle, data: { 'work-packages--share--bulk-selection-target': 'bulkUpdateRoleLabel' }) do |button| + data: { test_selector: 'op-share-dialog-bulk-update-role'})) do |menu| + menu.with_show_button(scheme: :invisible, color: :subtle, data: { 'shares--bulk-selection-target': 'bulkUpdateRoleLabel' }) do |button| button.with_trailing_action_icon(icon: "triangle-down") 'Placeholder' end - options.each do |option| - menu.with_item(label: option[:label], + @available_roles.each do |role_hash| + menu.with_item(label: role_hash[:label], href: update_path, method: :patch, active: false, form_arguments: { method: :patch, name: 'role_ids[]', - value: option[:value], - data: { 'work-packages--share--bulk-selection-target': 'bulkForm bulkUpdateRoleForm', - 'role-name': option[:label], - 'test-selector': "op-share-wp-bulk-update-role-permission-#{option[:label]}" } + value: role_hash[:value], + data: { 'shares--bulk-selection-target': 'bulkForm bulkUpdateRoleForm', + 'role-name': role_hash[:label], + 'test-selector': "op-share-dialog-bulk-update-role-permission-#{role_hash[:label]}" } }) do |item| - item.with_description.with_content(option[:description]) + item.with_description.with_content(role_hash[:description]) end end end diff --git a/app/components/work_packages/share/bulk_permission_button_component.rb b/app/components/shares/bulk_permission_button_component.rb similarity index 78% rename from app/components/work_packages/share/bulk_permission_button_component.rb rename to app/components/shares/bulk_permission_button_component.rb index 1f0c2bad284c..f9a89e186737 100644 --- a/app/components/work_packages/share/bulk_permission_button_component.rb +++ b/app/components/shares/bulk_permission_button_component.rb @@ -28,20 +28,17 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackages - module Share - class BulkPermissionButtonComponent < ApplicationComponent - include WorkPackages::Share::Concerns::DisplayableRoles +module Shares + class BulkPermissionButtonComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + def initialize(entity:, available_roles:) + super - def initialize(work_package:) - super - - @work_package = work_package - end + @entity = entity + @available_roles = available_roles + end - def update_path - work_package_shares_bulk_path(@work_package) - end + def update_path + url_for([:bulk, @entity, Member]) end end end diff --git a/app/components/shares/bulk_selection_counter_component.html.erb b/app/components/shares/bulk_selection_counter_component.html.erb new file mode 100644 index 000000000000..9c45cb13e06b --- /dev/null +++ b/app/components/shares/bulk_selection_counter_component.html.erb @@ -0,0 +1,21 @@ +<% + concat( + render(Primer::Alpha::CheckBox.new(name: 'toggle_all', + value: nil, + label: I18n.t('sharing.label_toggle_all'), + visually_hide_label: true, + data: { 'shares--bulk-selection-target': 'toggleAll', + action: 'shares--bulk-selection#toggle' })) + ) + + concat( + render(Primer::Beta::Text.new(ml: 2, data: { 'shares--bulk-selection-target': 'sharedCounter' })) do + I18n.t('sharing.count', count:) + end + ) + + # Text contents managed by Stimulus controller + concat( + render(Primer::Beta::Text.new(ml: 2, data: { 'shares--bulk-selection-target': 'selectedCounter' })) + ) +%> diff --git a/app/components/work_packages/share/bulk_selection_counter_component.rb b/app/components/shares/bulk_selection_counter_component.rb similarity index 84% rename from app/components/work_packages/share/bulk_selection_counter_component.rb rename to app/components/shares/bulk_selection_counter_component.rb index 6dc141ba6e84..fc56f453cdeb 100644 --- a/app/components/work_packages/share/bulk_selection_counter_component.rb +++ b/app/components/shares/bulk_selection_counter_component.rb @@ -28,18 +28,16 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackages - module Share - class BulkSelectionCounterComponent < ApplicationComponent - def initialize(count:) - super +module Shares + class BulkSelectionCounterComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + def initialize(count:) + super - @count = count - end + @count = count + end - private + private - attr_reader :count - end + attr_reader :count end end diff --git a/app/components/work_packages/share/counter_component.html.erb b/app/components/shares/counter_component.html.erb similarity index 65% rename from app/components/work_packages/share/counter_component.html.erb rename to app/components/shares/counter_component.html.erb index 71144ef8f38a..e9281848a582 100644 --- a/app/components/work_packages/share/counter_component.html.erb +++ b/app/components/shares/counter_component.html.erb @@ -1,13 +1,13 @@ <%= - component_wrapper(data: { test_selector: 'op-share-wp-active-count'}) do + component_wrapper(data: { test_selector: 'op-share-dialog-active-count'}) do render(Primer::Box.new(display: :flex, aligns_items: :center)) do # There's no point in rendering the BulkSelectionCounterComponent even if # I'm able to manage shares if the only user that the work package is # currently shared is myself, since I'm not able to manage my own share. if sharing_manageable? && shared_with_anyone_else_other_than_myself? - render(WorkPackages::Share::BulkSelectionCounterComponent.new(count:)) + render(Shares::BulkSelectionCounterComponent.new(count:)) else - render(WorkPackages::Share::ShareCounterComponent.new(count:)) + render(Shares::ShareCounterComponent.new(count:)) end end end diff --git a/app/components/work_packages/share/counter_component.rb b/app/components/shares/counter_component.rb similarity index 65% rename from app/components/work_packages/share/counter_component.rb rename to app/components/shares/counter_component.rb index ee2b0d786da5..145ee3b8c530 100644 --- a/app/components/work_packages/share/counter_component.rb +++ b/app/components/shares/counter_component.rb @@ -28,30 +28,32 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackages - module Share - class CounterComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - include WorkPackages::Share::Concerns::Authorization - - def initialize(work_package:, count:) - super - - @work_package = work_package - @count = count - end - - private - - attr_reader :work_package, :count - - def shared_with_anyone_else_other_than_myself? - Member.of_work_package(@work_package) - .where.not(principal: User.current) - .any? - end +module Shares + class CounterComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(entity:, + count:, + sharing_manageable:) + super + + @entity = entity + @count = count + @sharing_manageable = sharing_manageable + end + + private + + attr_reader :entity, :count + + def sharing_manageable? = @sharing_manageable + + def shared_with_anyone_else_other_than_myself? + Member.of_entity(@entity) + .where.not(principal: User.current) + .any? end end end diff --git a/app/components/work_packages/share/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb similarity index 75% rename from app/components/work_packages/share/invite_user_form_component.html.erb rename to app/components/shares/invite_user_form_component.html.erb index 2cff37583ab6..5900c1a2ef47 100644 --- a/app/components/work_packages/share/invite_user_form_component.html.erb +++ b/app/components/shares/invite_user_form_component.html.erb @@ -1,39 +1,40 @@ <%= component_wrapper do - if sharing_manageable? + if @sharing_manageable primer_form_with( model: new_share, - url: url_for([@work_package, Member]), + url: url_for([@entity, Member]), data: { controller: 'user-limit ' \ - 'work-packages--share--user-selected', + 'shares--user-selected', 'application-target': 'dynamic', 'user-limit-open-seats-value': OpenProject::Enterprise.open_seats_count, - action: 'submit->work-packages--share--user-selected#ensureUsersSelected' } + action: 'submit->shares--user-selected#ensureUsersSelected' } ) do |form| grid_layout('invite-user-form', tag: :div) do |invite_form| invite_form.with_area('invitee') do - render(WorkPackages::Share::Invitee.new(form)) + render(Shares::Invitee.new(form)) end invite_form.with_area('permission') do - render(WorkPackages::Share::PermissionButtonComponent.new( + render(Shares::PermissionButtonComponent.new( share: new_share, + available_roles: @available_roles, form_arguments: { builder: form, name: "role_id" }, - data: { 'test-selector': 'op-share-wp-invite-role' }) + data: { 'test-selector': 'op-share-dialog-invite-role' }) ) end invite_form.with_area('submit') do render(Primer::Beta::Button.new(scheme: :primary, type: :submit)) do - I18n.t('work_package.sharing.share') + I18n.t('sharing.share') end end if OpenProject::Enterprise.user_limit.present? invite_form.with_area('userLimitWarning', data: { 'user-limit-target': 'limitWarning', - 'test-selector': 'op-share-wp-user-limit' }, + 'test-selector': 'op-share-dialog-user-limit' }, display: :none) do flex_layout do |user_limit_row| user_limit_row.with_column(mr: 2) do @@ -43,8 +44,9 @@ user_limit_row.with_column do render(Primer::Beta::Text.new(color: :danger)) do I18n.t( - "work_package.sharing.warning_user_limit_reached#{'_admin' if User.current.admin?}", - upgrade_url: OpenProject::Enterprise.upgrade_url + "sharing.warning_user_limit_reached#{'_admin' if User.current.admin?}", + upgrade_url: OpenProject::Enterprise.upgrade_url, + entity: @entity.model_name.human ).html_safe end end @@ -53,8 +55,8 @@ end invite_form.with_area('userSelectedWarning', - data: { 'work-packages--share--user-selected-target': 'error', - 'test-selector': 'op-share-wp-no-user-selected' }, + data: { 'shares--user-selected-target': 'error', + 'test-selector': 'op-share-dialog-no-user-selected' }, display: :none) do flex_layout do |no_selected_user_row| no_selected_user_row.with_column(mr: 2) do @@ -63,7 +65,7 @@ no_selected_user_row.with_column do render(Primer::Beta::Text.new(color: :danger)) do - I18n.t("work_package.sharing.warning_no_selected_user") + I18n.t("sharing.warning_no_selected_user", entity: @entity.model_name.human) end end end @@ -71,7 +73,7 @@ if @errors.present? invite_form.with_area('errors', - data: { 'test-selector': 'op-share-wp-error-message' }) do + data: { 'test-selector': 'op-share-dialog-error-message' }) do flex_layout do |error_rows| @errors.full_messages.each do |error_message| error_rows.with_row do @@ -94,7 +96,7 @@ end end else - render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('work_package.sharing.permissions.denied') } + render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('sharing.denied', entities: @entity.model_name.human(count: 2)) } end end %> diff --git a/app/components/work_packages/share/invite_user_form_component.rb b/app/components/shares/invite_user_form_component.rb similarity index 61% rename from app/components/work_packages/share/invite_user_form_component.rb rename to app/components/shares/invite_user_form_component.rb index 945256030394..48ae8e0715ed 100644 --- a/app/components/work_packages/share/invite_user_form_component.rb +++ b/app/components/shares/invite_user_form_component.rb @@ -26,24 +26,32 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackages - module Share - class InviteUserFormComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - include WorkPackages::Share::Concerns::Authorization +module Shares + class InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers - def initialize(work_package:, errors: nil) - super + def initialize(entity:, + available_roles:, + sharing_manageable:, + errors: nil) + super - @work_package = work_package - @errors = errors - end + @entity = entity + @available_roles = available_roles + @sharing_manageable = sharing_manageable + @errors = errors + end + + def new_share + @new_share ||= Member.new(entity: @entity, roles: [Role.new(id: default_role[:value])]) + end + + private - def new_share - @new_share ||= Member.new(entity: @work_package, roles: [Role.new(builtin: Role::BUILTIN_WORK_PACKAGE_VIEWER)]) - end + def default_role + @available_roles.find { |role_hash| role_hash[:default] } || @available_roles.first end end end diff --git a/app/components/work_packages/share/invite_user_form_component.sass b/app/components/shares/invite_user_form_component.sass similarity index 100% rename from app/components/work_packages/share/invite_user_form_component.sass rename to app/components/shares/invite_user_form_component.sass diff --git a/app/components/work_packages/share/modal_body_component.html.erb b/app/components/shares/modal_body_component.html.erb similarity index 65% rename from app/components/work_packages/share/modal_body_component.html.erb rename to app/components/shares/modal_body_component.html.erb index fc5bc15cce01..97de19aeae13 100644 --- a/app/components/work_packages/share/modal_body_component.html.erb +++ b/app/components/shares/modal_body_component.html.erb @@ -2,34 +2,39 @@ component_wrapper(tag: 'turbo-frame') do flex_layout(data: { turbo: true }) do |modal_content| modal_content.with_row do - render(WorkPackages::Share::InviteUserFormComponent.new(work_package: @work_package, errors: @errors)) + render(Shares::InviteUserFormComponent.new(entity: @entity, + available_roles: @available_roles, + sharing_manageable: @sharing_manageable, + errors: @errors)) end modal_content.with_row(mt: 3, - data: { 'test-selector': 'op-share-wp-active-list', - controller: 'work-packages--share--bulk-selection', + data: { 'test-selector': 'op-share-dialog-active-list', + controller: 'shares--bulk-selection', application_target: 'dynamic' }) do render(border_box_container(list_id: insert_target_modifier_id)) do |border_box| - border_box.with_header(color: :muted, data: { 'test-selector': 'op-share-wp-header' }) do - grid_layout('op-share-wp-modal-body--header', tag: :div, align_items: :center) do |header_grid| + border_box.with_header(color: :muted, data: { 'test-selector': 'op-share-dialog-header' }) do + grid_layout('op-share-dialog-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: @shares.size)) + render(Shares::CounterComponent.new(entity: @entity, + count: @shares.size, + sharing_manageable: @sharing_manageable)) end header_grid.with_area(:actions, tag: :div, - data: { 'work-packages--share--bulk-selection-target': 'defaultActions' }) do + data: { 'shares--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, dynamic_label: true, - dynamic_label_prefix: I18n.t('work_package.sharing.filter.type'), + dynamic_label_prefix: I18n.t('sharing.filter.type'), color: :muted, - data: { 'test-selector': 'op-share-wp-filter-type' })) do |menu| - menu.with_show_button(scheme: :invisible, color: :muted, data: { 'test-selector': 'op-share-wp-filter-type-button' }) do |button| + data: { 'test-selector': 'op-share-dialog-filter-type' })) do |menu| + menu.with_show_button(scheme: :invisible, color: :muted, data: { 'test-selector': 'op-share-dialog-filter-type-button' }) do |button| button.with_trailing_action_icon(icon: "triangle-down") - I18n.t('work_package.sharing.filter.type') + I18n.t('sharing.filter.type') end type_filter_options.each do |option| menu.with_item(label: option[:label], @@ -46,19 +51,19 @@ render(Primer::Alpha::ActionMenu.new(anchor_align: :end, select_variant: :single, dynamic_label: true, - dynamic_label_prefix: I18n.t('work_package.sharing.filter.role'), + dynamic_label_prefix: I18n.t('sharing.filter.role'), color: :muted, - data: { 'test-selector': 'op-share-wp-filter-role' })) do |menu| - menu.with_show_button(scheme: :invisible, color: :muted, data: { 'test-selector': 'op-share-wp-filter-role-button' }) do |button| + data: { 'test-selector': 'op-share-dialog-filter-role' })) do |menu| + menu.with_show_button(scheme: :invisible, color: :muted, data: { 'test-selector': 'op-share-dialog-filter-role-button' }) do |button| button.with_trailing_action_icon(icon: "triangle-down") - I18n.t('work_package.sharing.filter.role') + I18n.t('sharing.filter.role') end - options.each do |option| - menu.with_item(label: option[:label], - href: filter_url(role_option: option), + @available_roles.each do |role_hash| + menu.with_item(label: role_hash[:label], + href: filter_url(role_option: role_hash), method: :get, tag: :a, - active: role_filter_option_active?(option), + active: role_filter_option_active?(role_hash), role: "menuitem") end end @@ -69,21 +74,21 @@ header_grid.with_area(:actions, tag: :div, hidden: true, # Prevent flicker on initial render - data: { 'work-packages--share--bulk-selection-target': 'bulkActions' }) do - if sharing_manageable? + data: { 'shares--bulk-selection-target': 'bulkActions' }) do + if @sharing_manageable concat( - render(WorkPackages::Share::BulkPermissionButtonComponent.new(work_package: @work_package)) + render(Shares::BulkPermissionButtonComponent.new(entity: @entity, available_roles: @available_roles)) ) concat( - form_with(url: work_package_shares_bulk_path(@work_package), + form_with(url: url_for([:bulk, @entity, Member]), method: :delete, - data: { 'work-packages--share--bulk-selection-target': 'bulkForm' }) do + data: { 'shares--bulk-selection-target': 'bulkForm' }) do render(Primer::Beta::IconButton.new(icon: "trash", type: :submit, scheme: :danger, - "aria-label": I18n.t('work_package.sharing.remove'), - test_selector: 'op-share-wp--bulk-remove')) + "aria-label": I18n.t('sharing.remove'), + test_selector: 'op-share-dialog--bulk-remove')) end ) end @@ -107,7 +112,10 @@ end else @shares.each do |share| - render(WorkPackages::Share::ShareRowComponent.new(share: share, container: border_box)) + render(Shares::ShareRowComponent.new(share: share, + available_roles: @available_roles, + sharing_manageable: @sharing_manageable, + container: border_box)) end end end diff --git a/app/components/shares/modal_body_component.rb b/app/components/shares/modal_body_component.rb new file mode 100644 index 000000000000..588f60d32016 --- /dev/null +++ b/app/components/shares/modal_body_component.rb @@ -0,0 +1,204 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Shares + class ModalBodyComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include MemberHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + attr_reader :entity, + :shares, + :available_roles, + :sharing_manageable, + :errors + + def initialize(entity:, + shares:, + available_roles:, + sharing_manageable:, + errors: nil) + super + + @entity = entity + @shares = shares + @available_roles = available_roles + @sharing_manageable = sharing_manageable + @errors = errors + end + + def self.wrapper_key + "share_list" + end + + private + + def project_scoped_entity? + entity.respond_to?(:project) + end + + def insert_target_modified? + true + end + + def insert_target_modifier_id + "op-share-dialog-active-shares" + end + + def blankslate_config # rubocop:disable Metrics/AbcSize + @blankslate_config ||= {}.tap do |config| + if params[:filters].blank? + config[:icon] = :people + config[:heading_text] = I18n.t("sharing.text_empty_state_header") + config[:description_text] = I18n.t("sharing.text_empty_state_description", entity: @entity.class.model_name.human) + else + config[:icon] = :search + config[:heading_text] = I18n.t("sharing.text_empty_search_header") + config[:description_text] = I18n.t("sharing.text_empty_search_description") + end + end + end + + def type_filter_options + if project_scoped_entity? + [ + { label: I18n.t("sharing.filter.project_member"), + value: { principal_type: "User", project_member: true } }, + { label: I18n.t("sharing.filter.not_project_member"), + value: { principal_type: "User", project_member: false } }, + { label: I18n.t("sharing.filter.project_group"), + value: { principal_type: "Group", project_member: true } }, + { label: I18n.t("sharing.filter.not_project_group"), + value: { principal_type: "Group", project_member: false } } + ] + else + [ + { label: I18n.t("sharing.filter.user"), value: { principal_type: "User" } }, + { label: I18n.t("sharing.filter.group"), value: { principal_type: "Group" } } + ] + + end + 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? + + selected_role = @available_roles.find { _1[:value] == option[:value] } + + selected_role[:value] == role_filter_value.to_i + end + + def filter_url(type_option: nil, role_option: nil) + return url_for([@entity, Member]) 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? + + url_for([@entity, Member, 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 }) + 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) + [ + { role_id: { operator: "=", values: [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/components/work_packages/share/modal_body_component.sass b/app/components/shares/modal_body_component.sass similarity index 95% rename from app/components/work_packages/share/modal_body_component.sass rename to app/components/shares/modal_body_component.sass index 4ba5f5ee53c9..17f99bc4aab1 100644 --- a/app/components/work_packages/share/modal_body_component.sass +++ b/app/components/shares/modal_body_component.sass @@ -1,4 +1,4 @@ -.op-share-wp-modal-body +.op-share-dialog-modal-body &--user-row display: grid grid-template-columns: minmax(31px, auto) 1fr // 31px is the width needed to display a group avatar diff --git a/app/components/work_packages/share/modal_upsale_component.html.erb b/app/components/shares/modal_upsale_component.html.erb similarity index 96% rename from app/components/work_packages/share/modal_upsale_component.html.erb rename to app/components/shares/modal_upsale_component.html.erb index 2d5f3269a0d7..9f6cc4ae3e20 100644 --- a/app/components/work_packages/share/modal_upsale_component.html.erb +++ b/app/components/shares/modal_upsale_component.html.erb @@ -3,6 +3,7 @@ render Primer::Beta::Blankslate.new(border: true) do |component| component.with_visual_icon(icon: :'op-enterprise-addons', classes: 'upsale-colored') component.with_heading(tag: :h2, classes: 'upsale-colored').with_content(I18n.t(:label_enterprise_addon)) + # TODO: Generalize this text component.with_description { I18n.t('mail.sharing.work_packages.enterprise_text') } href = "#{OpenProject::Static::Links.links[:upsale][:href]}/?utm_source=unknown&utm_medium=community-edition&utm_campaign=work-package-sharing-modal" diff --git a/app/components/work_packages/share/modal_upsale_component.rb b/app/components/shares/modal_upsale_component.rb similarity index 82% rename from app/components/work_packages/share/modal_upsale_component.rb rename to app/components/shares/modal_upsale_component.rb index 4a387bec3904..933a8f061612 100644 --- a/app/components/work_packages/share/modal_upsale_component.rb +++ b/app/components/shares/modal_upsale_component.rb @@ -26,16 +26,14 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackages - module Share - class ModalUpsaleComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers +module Shares + class ModalUpsaleComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers - def self.wrapper_key - "work_package_share_list" - end + def self.wrapper_key + "share_list" end end end diff --git a/app/components/work_packages/share/permission_button_component.html.erb b/app/components/shares/permission_button_component.html.erb similarity index 60% rename from app/components/work_packages/share/permission_button_component.html.erb rename to app/components/shares/permission_button_component.html.erb index 076976c73e1c..6402891c82ad 100644 --- a/app/components/work_packages/share/permission_button_component.html.erb +++ b/app/components/shares/permission_button_component.html.erb @@ -4,23 +4,24 @@ dynamic_label: true, anchor_align: :end, color: :subtle }.deep_merge(@system_arguments))) do |menu| - menu.with_show_button(data: { 'work-packages--share--bulk-selection-target': 'userRowRole', + menu.with_show_button(data: { 'shares--bulk-selection-target': 'userRowRole', 'share-id': share.id, - 'active-role-name': permission_name(active_role.builtin)}) do |button| + 'active-role-name': permission_name(active_role.id)}) do |button| button.with_trailing_action_icon(icon: :"triangle-down") - permission_name(active_role.builtin) + permission_name(active_role.id) end - options.each do |option| - menu.with_item(label: option[:label], + + @available_roles.each do |role_hash| + menu.with_item(label: role_hash[:label], href: update_path, method: :patch, - active: option_active?(option), - data: { value: option[:value] }, + active: role_active?(role_hash), + data: { value: role_hash[:value] }, form_arguments: { method: :patch, - inputs: form_inputs(option[:value]) + inputs: form_inputs(role_hash[:value]) }) do |item| - item.with_description.with_content(option[:description]) + item.with_description.with_content(role_hash[:description]) end end end diff --git a/app/components/shares/permission_button_component.rb b/app/components/shares/permission_button_component.rb new file mode 100644 index 000000000000..2746241a1570 --- /dev/null +++ b/app/components/shares/permission_button_component.rb @@ -0,0 +1,84 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 Shares + class PermissionButtonComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(share:, available_roles:, **system_arguments) + super + + @available_roles = available_roles + @share = share + @system_arguments = system_arguments + end + + # Switches the component to either update the share directly (by sending a PATCH to the share path) + # or be passive and work like a select inside a form. + def update_path + if share.persisted? + url_for([share.entity, share]) + end + end + + def role_active?(role_hash) + role_hash[:value] == active_role.id + end + + def wrapper_uniq_by + share.id || @system_arguments.dig(:data, :"test-selector") + end + + private + + attr_reader :share, :available_roles + + def active_role + if share.persisted? + share.roles + .merge(MemberRole.only_non_inherited) + .first + else + share.roles.first + end + end + + def permission_name(value) + available_roles.find { |role_hash| role_hash[:value] == value }[:label] + end + + def form_inputs(role_id) + [].tap do |inputs| + inputs << { name: "role_ids[]", value: role_id } + inputs << { name: "filters", value: params[:filters] } if params[:filters] + end + end + end +end diff --git a/app/components/shares/share_counter_component.html.erb b/app/components/shares/share_counter_component.html.erb new file mode 100644 index 000000000000..04f3ac8250c5 --- /dev/null +++ b/app/components/shares/share_counter_component.html.erb @@ -0,0 +1,3 @@ +<% + concat(render(Primer::Beta::Text.new) { I18n.t('sharing.count', count:) }) +%> diff --git a/app/components/work_packages/share/share_counter_component.rb b/app/components/shares/share_counter_component.rb similarity index 85% rename from app/components/work_packages/share/share_counter_component.rb rename to app/components/shares/share_counter_component.rb index 0b882c56d5dc..094b0c74dc12 100644 --- a/app/components/work_packages/share/share_counter_component.rb +++ b/app/components/shares/share_counter_component.rb @@ -28,18 +28,16 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackages - module Share - class ShareCounterComponent < ApplicationComponent - def initialize(count:) - super +module Shares + class ShareCounterComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + def initialize(count:) + super - @count = count - end + @count = count + end - private + private - attr_reader :count - end + attr_reader :count end end diff --git a/app/components/work_packages/share/share_row_component.html.erb b/app/components/shares/share_row_component.html.erb similarity index 63% rename from app/components/work_packages/share/share_row_component.html.erb rename to app/components/shares/share_row_component.html.erb index 539819842104..f633f52876b4 100644 --- a/app/components/work_packages/share/share_row_component.html.erb +++ b/app/components/shares/share_row_component.html.erb @@ -1,13 +1,13 @@ <%= - component_wrapper(:border_box_row, data: { 'test-selector': "op-share-wp-active-user-#{principal.id}" }) do + component_wrapper(:border_box_row, data: { 'test-selector': "op-share-dialog-active-user-#{principal.id}" }) do grid_layout(grid_css_classes, tag: :div, align_items: :center, classes: 'ellipsis') do |user_row_grid| user_row_grid.with_area(:selection, tag: :div) do if share_editable? - render(Primer::Alpha::CheckBox.new(name: "share_ids", value: share.id, label: "#{principal.name}", + render(Primer::Alpha::CheckBox.new(name: "share_ids", value: share.id, label: principal.name, visually_hide_label: true, scheme: :array, data: { - 'work-packages--share--bulk-selection-target': 'shareCheckbox', - action: 'work-packages--share--bulk-selection#refresh' + 'shares--bulk-selection-target': 'shareCheckbox', + action: 'shares--bulk-selection#refresh' })) end end @@ -17,22 +17,23 @@ end user_row_grid.with_area(:user_details, tag: :div, classes: 'ellipsis') do - render(WorkPackages::Share::UserDetailsComponent.new(share:, manager_mode: share_editable?)) + render(Shares::UserDetailsComponent.new(share:, manager_mode: share_editable?)) end if share_editable? user_row_grid.with_area(:button, tag: :div, color: :subtle) do - render(WorkPackages::Share::PermissionButtonComponent.new(share:, - data: { 'test-selector': 'op-share-wp-update-role' })) + render(Shares::PermissionButtonComponent.new(share:, + available_roles: @available_roles, + data: { 'test-selector': 'op-share-dialog-update-role' })) end user_row_grid.with_area(:remove, tag: :div) do - form_with url: url_for([work_package, share]), method: :delete do + form_with url: url_for([entity, share]), method: :delete do render(Primer::Beta::IconButton.new(icon: "trash", type: :submit, scheme: :danger, - "aria-label": I18n.t('work_package.sharing.remove'), - test_selector: 'op-share-wp--remove')) + "aria-label": I18n.t('sharing.remove'), + test_selector: 'op-share-dialog--remove')) end end end diff --git a/app/components/work_packages/share/share_row_component.rb b/app/components/shares/share_row_component.rb similarity index 50% rename from app/components/work_packages/share/share_row_component.rb rename to app/components/shares/share_row_component.rb index 16d82a331a09..8f9a1dd6e53f 100644 --- a/app/components/work_packages/share/share_row_component.rb +++ b/app/components/shares/share_row_component.rb @@ -28,53 +28,56 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackages - module Share - class ShareRowComponent < ApplicationComponent - include ApplicationHelper - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - include WorkPackages::Share::Concerns::Authorization +module Shares + class ShareRowComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers - def initialize(share:, - container: nil) - super + def initialize(share:, + available_roles:, + sharing_manageable:, + container: nil) + super - @share = share - @work_package = share.entity - @principal = share.principal - @container = container - end + @share = share + @entity = share.entity + @principal = share.principal + @available_roles = available_roles + @sharing_manageable = sharing_manageable + @container = container + end - def wrapper_uniq_by - share.id - end + def wrapper_uniq_by + share.id + end - private + private - attr_reader :share, :work_package, :principal, :container + attr_reader :share, :entity, :principal, :container, :available_roles - def share_editable? - @share_editable ||= User.current != share.principal && sharing_manageable? - end + def share_editable? + @share_editable ||= User.current != share.principal && sharing_manageable? + end - def grid_css_classes - if sharing_manageable? - "op-share-wp-modal-body--user-row_manageable" - else - "op-share-wp-modal-body--user-row" - end - end + def sharing_manageable? = @sharing_manageable - def select_share_checkbox_options - { - name: "share_ids", - value: share.id, - scheme: :array, - label: principal.name, - visually_hide_label: true - } + def grid_css_classes + if sharing_manageable? + "op-share-dialog-modal-body--user-row_manageable" + else + "op-share-dialog-modal-body--user-row" end end + + def select_share_checkbox_options + { + name: "share_ids", + value: share.id, + scheme: :array, + label: principal.name, + visually_hide_label: true + } + end end end diff --git a/app/components/work_packages/share/user_details_component.html.erb b/app/components/shares/user_details_component.html.erb similarity index 67% rename from app/components/work_packages/share/user_details_component.html.erb rename to app/components/shares/user_details_component.html.erb index 6ad99a059151..2af0f5a1929b 100644 --- a/app/components/work_packages/share/user_details_component.html.erb +++ b/app/components/shares/user_details_component.html.erb @@ -9,23 +9,23 @@ if manager_mode? if user_is_a_group? if project_group? - render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.project_group")} + render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.project_group")} else - render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.not_project_group")} + render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.not_project_group")} end else if user_in_non_active_status? if user.locked? concat(render(Primer::Beta::Octicon.new(icon: :lock, color: :muted, mr: 1))) - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.locked") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.locked") }) elsif user.invited? if invite_resent? - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.invite_resent") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.invite_resent") }) else - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t('work_package.sharing.user_details.invited') }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t('sharing.user_details.invited') }) concat( form_with(url: resend_invite_path, method: :post) do - render(Primer::Beta::Button.new(type: :submit, px: 0, scheme: :link)) { I18n.t('work_package.sharing.user_details.resend_invite') } + render(Primer::Beta::Button.new(type: :submit, px: 0, scheme: :link)) { I18n.t('sharing.user_details.resend_invite') } end ) end @@ -34,31 +34,31 @@ if part_of_a_group? if part_of_a_shared_group? if project_member? - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project_or_group") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.additional_privileges_project_or_group") }) else - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_group") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.additional_privileges_group") }) end else if inherited_project_member? - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project_or_group") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.additional_privileges_project_or_group") }) elsif project_member? - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.additional_privileges_project") }) else - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.not_project_member") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.not_project_member") }) end end else if project_member? - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.additional_privileges_project") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.additional_privileges_project") }) else - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.not_project_member") }) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.not_project_member") }) end end end end else if user.invited? - concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("work_package.sharing.user_details.invited")}) + concat(render(Primer::Beta::Text.new(color: :subtle)) { I18n.t("sharing.user_details.invited")}) end end end diff --git a/app/components/shares/user_details_component.rb b/app/components/shares/user_details_component.rb new file mode 100644 index 000000000000..8f189f2b6dbf --- /dev/null +++ b/app/components/shares/user_details_component.rb @@ -0,0 +1,126 @@ +# 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. +# ++ + +module Shares + class UserDetailsComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(share:, + manager_mode:, + invite_resent: false) + super + + @share = share + @user = share.principal + @manager_mode = manager_mode + @invite_resent = invite_resent + end + + private + + attr_reader :user, :share + + def manager_mode? = @manager_mode + + def invite_resent? = @invite_resent + + def wrapper_uniq_by + share.id + end + + def authoritative_work_package_role_name + @authoritative_work_package_role_name = options.find do |option| + option[:value] == share.roles.first.id + end[:label] + end + + def principal_show_path + case user + when User + user_path(user) + when Group + show_group_path(user) + else + placeholder_user_path(user) + end + end + + def resend_invite_path + url_for([:resend_invite, share.entity, share]) + end + + def user_is_a_group? + @user_is_a_group ||= user.is_a?(Group) + end + + def user_in_non_active_status? + user.locked? || user.invited? + end + + # Is a user member of a project no matter whether inherited or directly assigned + def project_member? + Member.exists?(project: share.project, + principal: user, + entity: nil) + end + + # Explicitly check whether the project membership was inherited by a group + def inherited_project_member? + Member.includes(:roles) + .references(:member_roles) + .where(project: share.project, principal: user, entity: nil) # membership in the project + .merge(MemberRole.only_inherited) # that was inherited + .any? + end + + def project_group? + user_is_a_group? && project_member? + end + + def part_of_a_shared_group? + share.member_roles.where.not(inherited_from: nil).any? + end + + def part_of_a_group? + GroupUser.where(user_id: user.id).any? + end + + def project_role_name + Member.where(project: share.project, + principal: user, + entity: nil) + .first + .roles + .first + .name + end + end +end diff --git a/app/components/work_packages/share/bulk_selection_counter_component.html.erb b/app/components/work_packages/share/bulk_selection_counter_component.html.erb deleted file mode 100644 index f3542f12e65c..000000000000 --- a/app/components/work_packages/share/bulk_selection_counter_component.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<% - concat( - render(Primer::Alpha::CheckBox.new(name: 'toggle_all', - value: nil, - label: I18n.t('work_package.sharing.label_toggle_all'), - visually_hide_label: true, - data: { 'work-packages--share--bulk-selection-target': 'toggleAll', - action: 'work-packages--share--bulk-selection#toggle' })) - ) - - concat( - render(Primer::Beta::Text.new(ml: 2, data: { 'work-packages--share--bulk-selection-target': 'sharedCounter' })) do - I18n.t('work_package.sharing.count', count:) - end - ) - - # Text contents managed by Stimulus controller - concat( - render(Primer::Beta::Text.new(ml: 2, data: { 'work-packages--share--bulk-selection-target': 'selectedCounter' })) - ) -%> diff --git a/app/components/work_packages/share/concerns/displayable_roles.rb b/app/components/work_packages/share/concerns/displayable_roles.rb deleted file mode 100644 index ff0faa338347..000000000000 --- a/app/components/work_packages/share/concerns/displayable_roles.rb +++ /dev/null @@ -1,51 +0,0 @@ -# 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. -# ++ - -module WorkPackages - module Share - module Concerns - module DisplayableRoles - def options - [ - { label: I18n.t("work_package.sharing.permissions.edit"), - value: Role::BUILTIN_WORK_PACKAGE_EDITOR, - description: I18n.t("work_package.sharing.permissions.edit_description") }, - { label: I18n.t("work_package.sharing.permissions.comment"), - value: Role::BUILTIN_WORK_PACKAGE_COMMENTER, - description: I18n.t("work_package.sharing.permissions.comment_description") }, - { label: I18n.t("work_package.sharing.permissions.view"), - value: Role::BUILTIN_WORK_PACKAGE_VIEWER, - description: I18n.t("work_package.sharing.permissions.view_description") } - ] - end - end - 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 deleted file mode 100644 index d3e59c2b11c2..000000000000 --- a/app/components/work_packages/share/modal_body_component.rb +++ /dev/null @@ -1,180 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 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:, shares:, errors: nil) - super - - @work_package = work_package - @shares = shares - @errors = errors - end - - def self.wrapper_key - "work_package_share_list" - end - - private - - def insert_target_modified? - true - end - - def insert_target_modifier_id - "op-share-wp-active-shares" - end - - def blankslate_config - @blankslate_config ||= {}.tap do |config| - if params[:filters].blank? - config[:icon] = :people - config[:heading_text] = I18n.t("work_package.sharing.text_empty_state_header") - config[:description_text] = I18n.t("work_package.sharing.text_empty_state_description") - else - config[:icon] = :search - config[:heading_text] = I18n.t("work_package.sharing.text_empty_search_header") - config[:description_text] = I18n.t("work_package.sharing.text_empty_search_description") - end - end - end - - 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 url_for([@work_package, Member]) 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? - - url_for([@work_package, Member, **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 -end diff --git a/app/components/work_packages/share/permission_button_component.rb b/app/components/work_packages/share/permission_button_component.rb deleted file mode 100644 index 07c8f242f8e1..000000000000 --- a/app/components/work_packages/share/permission_button_component.rb +++ /dev/null @@ -1,86 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2012-2024 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 WorkPackages - module Share - class PermissionButtonComponent < ApplicationComponent - include ApplicationHelper - include OpPrimer::ComponentHelpers - include OpTurbo::Streamable - include WorkPackages::Share::Concerns::DisplayableRoles - - def initialize(share:, **system_arguments) - super - - @share = share - @system_arguments = system_arguments - end - - # Switches the component to either update the share directly (by sending a PATCH to the share path) - # or be passive and work like a select inside a form. - def update_path - if share.persisted? - url_for([share.entity, share]) - end - end - - def option_active?(option) - option[:value] == active_role.builtin - end - - def wrapper_uniq_by - share.id || @system_arguments.dig(:data, :"test-selector") - end - - private - - attr_reader :share - - def active_role - if share.persisted? - share.roles - .merge(MemberRole.only_non_inherited) - .first - else - share.roles.first - end - end - - def permission_name(value) - options.find { |option| option[:value] == value }[:label] - end - - def form_inputs(role_id) - [].tap do |inputs| - inputs << { name: "role_ids[]", value: role_id } - inputs << { name: "filters", value: params[:filters] } if params[:filters] - end - end - end - end -end diff --git a/app/components/work_packages/share/share_counter_component.html.erb b/app/components/work_packages/share/share_counter_component.html.erb deleted file mode 100644 index 855f37330382..000000000000 --- a/app/components/work_packages/share/share_counter_component.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<% - concat(render(Primer::Beta::Text.new) { I18n.t('work_package.sharing.count', count:) }) -%> diff --git a/app/components/work_packages/share/user_details_component.rb b/app/components/work_packages/share/user_details_component.rb deleted file mode 100644 index 11c9b3fce510..000000000000 --- a/app/components/work_packages/share/user_details_component.rb +++ /dev/null @@ -1,131 +0,0 @@ -# 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. -# ++ - -module WorkPackages - module Share - # rubocop:disable OpenProject/AddPreviewForViewComponent - class UserDetailsComponent < ApplicationComponent - # rubocop:enable OpenProject/AddPreviewForViewComponent - include OpTurbo::Streamable - include OpPrimer::ComponentHelpers - include WorkPackages::Share::Concerns::DisplayableRoles - - def initialize(share:, - manager_mode: User.current.allowed_in_project?(:share_work_packages, share.project), - invite_resent: false) - super - - @share = share - @user = share.principal - @manager_mode = manager_mode - @invite_resent = invite_resent - end - - private - - attr_reader :user, :share - - def manager_mode? = @manager_mode - - def invite_resent? = @invite_resent - - def wrapper_uniq_by - share.id - end - - def authoritative_work_package_role_name - @authoritative_work_package_role_name = options.find do |option| - option[:value] == share.roles.first.builtin - end[:label] - end - - def principal_show_path - case user - when User - user_path(user) - when Group - show_group_path(user) - else - placeholder_user_path(user) - end - end - - def resend_invite_path - url_for([:resend_invite, share.entity, share]) - end - - def user_is_a_group? - @user_is_a_group ||= user.is_a?(Group) - end - - def user_in_non_active_status? - user.locked? || user.invited? - end - - # Is a user member of a project no matter whether inherited or directly assigned - def project_member? - Member.exists?(project: share.project, - principal: user, - entity: nil) - end - - # Explicitly check whether the project membership was inherited by a group - def inherited_project_member? - Member.includes(:roles) - .references(:member_roles) - .where(project: share.project, principal: user, entity: nil) # membership in the project - .merge(MemberRole.only_inherited) # that was inherited - .any? - end - - def project_group? - user_is_a_group? && project_member? - end - - def part_of_a_shared_group? - share.member_roles.where.not(inherited_from: nil).any? - end - - def part_of_a_group? - GroupUser.where(user_id: user.id).any? - end - - def project_role_name - Member.where(project: share.project, - principal: user, - entity: nil) - .first - .roles - .first - .name - end - end - end -end diff --git a/app/contracts/work_package_members/base_contract.rb b/app/contracts/shares/base_contract.rb similarity index 89% rename from app/contracts/work_package_members/base_contract.rb rename to app/contracts/shares/base_contract.rb index 792865969400..020973c6dd03 100644 --- a/app/contracts/work_package_members/base_contract.rb +++ b/app/contracts/shares/base_contract.rb @@ -26,17 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackageMembers +module Shares class BaseContract < ::ModelContract - delegate :project, - to: :model - attribute :roles validate :user_allowed_to_manage validate :role_grantable validate :single_non_inherited_role - validate :project_set validate :entity_set attribute_alias(:user_id, :principal) @@ -50,7 +46,7 @@ def user_allowed_to_manage end def user_allowed_to_manage? - user.allowed_in_project?(:share_work_packages, model.project) + raise NotImplementedError, "Must be overridden by subclass" end def single_non_inherited_role @@ -58,11 +54,7 @@ def single_non_inherited_role end def role_grantable - errors.add(:roles, :ungrantable) unless active_roles.all? { _1.is_a?(WorkPackageRole) } - end - - def project_set - errors.add(:project, :blank) if project.nil? + errors.add(:roles, :ungrantable) unless active_roles.all? { _1.is_a?(assignable_role_class) } end def active_roles @@ -80,5 +72,9 @@ def active_member_roles def entity_set errors.add(:entity, :blank) if entity_id.nil? end + + def assignable_role_class + raise NotImplementedError, "Must be overridden by subclass" + end end end diff --git a/app/contracts/work_package_members/create_contract.rb b/app/contracts/shares/create_contract.rb similarity index 98% rename from app/contracts/work_package_members/create_contract.rb rename to app/contracts/shares/create_contract.rb index c21ce60ef1a2..190af5d6ea5e 100644 --- a/app/contracts/work_package_members/create_contract.rb +++ b/app/contracts/shares/create_contract.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackageMembers +module Shares class CreateContract < BaseContract attribute :principal attribute :entity_id diff --git a/app/contracts/work_package_members/delete_contract.rb b/app/contracts/shares/delete_contract.rb similarity index 95% rename from app/contracts/work_package_members/delete_contract.rb rename to app/contracts/shares/delete_contract.rb index 41726467fa50..165815bb1fb7 100644 --- a/app/contracts/work_package_members/delete_contract.rb +++ b/app/contracts/shares/delete_contract.rb @@ -26,10 +26,8 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackageMembers +module Shares class DeleteContract < ::DeleteContract - delete_permission :share_work_packages - validate :member_is_deletable private diff --git a/app/contracts/work_package_members/update_contract.rb b/app/contracts/shares/update_contract.rb similarity index 98% rename from app/contracts/work_package_members/update_contract.rb rename to app/contracts/shares/update_contract.rb index 785ec1030ff4..355a739d558c 100644 --- a/app/contracts/work_package_members/update_contract.rb +++ b/app/contracts/shares/update_contract.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackageMembers +module Shares class UpdateContract < BaseContract attribute :principal, writable: false diff --git a/app/contracts/shares/work_packages/base_extension.rb b/app/contracts/shares/work_packages/base_extension.rb new file mode 100644 index 000000000000..6fa99c6a5300 --- /dev/null +++ b/app/contracts/shares/work_packages/base_extension.rb @@ -0,0 +1,54 @@ +# -- 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. +# ++ + +module Shares + module WorkPackages + module BaseExtension + extend ActiveSupport::Concern + + included do + delegate :project, to: :model + validate :project_set + end + + private + + def user_allowed_to_manage? + user.allowed_in_project?(:share_work_packages, project) + end + + def assignable_role_class + WorkPackageRole + end + + def project_set + errors.add(:project, :blank) if project.nil? + end + end + end +end diff --git a/app/contracts/shares/work_packages/create_contract.rb b/app/contracts/shares/work_packages/create_contract.rb new file mode 100644 index 000000000000..eced07e71743 --- /dev/null +++ b/app/contracts/shares/work_packages/create_contract.rb @@ -0,0 +1,35 @@ +# -- 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. +# ++ + +module Shares + module WorkPackages + class CreateContract < Shares::CreateContract + include Shares::WorkPackages::BaseExtension + end + end +end diff --git a/app/contracts/shares/work_packages/delete_contract.rb b/app/contracts/shares/work_packages/delete_contract.rb new file mode 100644 index 000000000000..a2ac75399add --- /dev/null +++ b/app/contracts/shares/work_packages/delete_contract.rb @@ -0,0 +1,37 @@ +# -- 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. +# ++ + +module Shares + module WorkPackages + class DeleteContract < Shares::DeleteContract + # DeleteContract has its own permission check and does not care about the role class, + # so we do not need to include the BaseExtension here. + delete_permission :share_work_packages + end + end +end diff --git a/app/contracts/shares/work_packages/update_contract.rb b/app/contracts/shares/work_packages/update_contract.rb new file mode 100644 index 000000000000..f38d818cda01 --- /dev/null +++ b/app/contracts/shares/work_packages/update_contract.rb @@ -0,0 +1,35 @@ +# -- 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. +# ++ + +module Shares + module WorkPackages + class UpdateContract < Shares::UpdateContract + include Shares::WorkPackages::BaseExtension + end + end +end diff --git a/app/controllers/concerns/member_helper.rb b/app/controllers/concerns/member_helper.rb index 70c7a09a5561..8eb65831fd92 100644 --- a/app/controllers/concerns/member_helper.rb +++ b/app/controllers/concerns/member_helper.rb @@ -29,12 +29,6 @@ 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/components/work_packages/share/concerns/authorization.rb b/app/controllers/concerns/shares/work_packages/authorization.rb similarity index 72% rename from app/components/work_packages/share/concerns/authorization.rb rename to app/controllers/concerns/shares/work_packages/authorization.rb index 4a927c0b5983..32b4bf5f34e0 100644 --- a/app/components/work_packages/share/concerns/authorization.rb +++ b/app/controllers/concerns/shares/work_packages/authorization.rb @@ -2,7 +2,7 @@ # -- copyright # OpenProject is an open source project management software. -# Copyright (C) 2023 the OpenProject GmbH +# Copyright (C) 2010-2024 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. @@ -28,15 +28,22 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackages - module Share - module Concerns - module Authorization - extend ActiveSupport::Concern +module Shares + module WorkPackages + module Authorization + extend ActiveSupport::Concern - included do - def sharing_manageable? - User.current.allowed_in_project?(:share_work_packages, @work_package.project) + included do + def sharing_manageable? + # TODO: Fix this to check based on the entity + case @entity + when WorkPackage + User.current.allowed_in_project?(:share_work_packages, @entity.project) + else + raise ArgumentError, <<~ERROR + Checking sharing capabilities for an unsupported entity: + - #{@entity.class} + ERROR end end end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index 4b64724d3fe5..0a0a3179c56f 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -86,8 +86,8 @@ def destroy_by_principal principal = Principal.find(params[:principal_id]) service_call = Members::DeleteByPrincipalService - .new(user: current_user, project: @project, principal:) - .call(params.permit(:project, :work_package_shares_role_id)) + .new(user: current_user, project: @project, principal:) + .call(params.permit(:project, :work_package_shares_role_id)) if service_call.success? flash[:notice] = I18n.t(:notice_member_removed, user: principal.name) @@ -144,8 +144,8 @@ def members_table_options(roles) available_roles: roles, authorize_update: authorize_for("members", :update), authorize_delete: authorize_for("members", :destroy), - authorize_work_package_shares_view: authorize_for("work_packages/shares", :update), - authorize_work_package_shares_delete: authorize_for("work_packages/shares/bulk", :destroy), + authorize_work_package_shares_view: authorize_for("shares", :update), + authorize_work_package_shares_delete: authorize_for("shares", :bulk_destroy), authorize_manage_user: current_user.allowed_globally?(:manage_user), is_filtered: Members::UserFilterComponent.filtered?(params), shared_role_name: diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb new file mode 100644 index 000000000000..0e8a174e3d31 --- /dev/null +++ b/app/controllers/shares_controller.rb @@ -0,0 +1,376 @@ +# -- 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. +# ++ + +class SharesController < ApplicationController + include OpTurbo::ComponentStream + include Shares::WorkPackages::Authorization + include MemberHelper + + before_action :load_entity + before_action :load_shares, only: %i[index] + before_action :load_selected_shares, only: %i[bulk_update bulk_destroy] + before_action :load_share, only: %i[destroy update resend_invite] + before_action :authorize + before_action :enterprise_check, only: %i[index] + + def index + unless @query.valid? + flash.now[:error] = query.errors.full_messages + end + + render Shares::ModalBodyComponent.new(entity: @entity, + shares: @shares, + errors: @errors, + sharing_manageable: sharing_manageable?, + available_roles:), layout: nil + end + + def create # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity + overall_result = [] + @errors = ActiveModel::Errors.new(self) + + find_or_create_users(send_notification: false) do |member_params| + user = User.find_by(id: member_params[:user_id]) + if user.present? && user.locked? + @errors.add(:base, I18n.t("sharing.warning_locked_user", user: user.name)) + else + service_call = create_or_update_share(member_params[:user_id], [params[:member][:role_id]]) + overall_result.push(service_call) + end + end + + @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 && @new_shares.size < current_visible_member_count + respond_with_prepend_shares + else + respond_with_replace_modal + end + else + respond_with_new_invite_form + end + end + + def update + create_or_update_share(@share.principal.id, params[:role_ids]) + + load_shares + + if @shares.empty? + respond_with_replace_modal + elsif @shares.include?(@share) + respond_with_update_permission_button + else + respond_with_remove_share + end + end + + def destroy + destroy_share(@share) + + if current_visible_member_count.zero? + respond_with_replace_modal + else + respond_with_remove_share + end + end + + def resend_invite + OpenProject::Notifications.send(OpenProject::Events::WORK_PACKAGE_SHARED, + work_package_member: @share, + send_notifications: true) + + respond_with_update_user_details + end + + def bulk_update + @selected_shares.each { |share| create_or_update_share(share.principal.id, params[:role_ids]) } + + respond_with_bulk_updated_permission_buttons + end + + def bulk_destroy + @selected_shares.each { |share| destroy_share(share) } + + if current_visible_member_count.zero? + respond_with_replace_modal + else + respond_with_bulk_removed_shares + end + end + + private + + def enterprise_check + return if EnterpriseToken.allows_to?(:work_package_sharing) + + render Shares::ModalUpsaleComponent.new + end + + def destroy_share(share) + Shares::DeleteService + .new(user: current_user, model: share, contract_class: sharing_contract_scope::DeleteContract) + .call + end + + def create_or_update_share(user_id, role_ids) + Shares::CreateOrUpdateService.new( + user: current_user, + create_contract_class: sharing_contract_scope::CreateContract, + update_contract_class: sharing_contract_scope::UpdateContract + ) + .call(entity: @entity, user_id:, role_ids:) + end + + def respond_with_replace_modal + replace_via_turbo_stream( + component: Shares::ModalBodyComponent.new( + entity: @entity, + available_roles:, + shares: @new_shares || load_shares, + sharing_manageable: sharing_manageable?, + errors: @errors + ) + ) + + respond_with_turbo_streams + end + + def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize + replace_via_turbo_stream( + component: Shares::InviteUserFormComponent.new( + entity: @entity, + available_roles:, + sharing_manageable: sharing_manageable?, + errors: @errors + ) + ) + + update_via_turbo_stream( + component: Shares::CounterComponent.new( + entity: @entity, + count: current_visible_member_count, + sharing_manageable: sharing_manageable? + ) + ) + + @new_shares.each do |share| + prepend_via_turbo_stream( + component: Shares::ShareRowComponent.new( + share:, + available_roles:, + sharing_manageable: sharing_manageable? + ), + target_component: Shares::ModalBodyComponent.new( + entity: @entity, + available_roles:, + sharing_manageable: sharing_manageable?, + shares: load_shares, + errors: @errors + ) + ) + end + + respond_with_turbo_streams + end + + def respond_with_new_invite_form + replace_via_turbo_stream( + component: Shares::InviteUserFormComponent.new( + entity: @entity, + available_roles:, + sharing_manageable: sharing_manageable?, + errors: @errors + ) + ) + + respond_with_turbo_streams + end + + def respond_with_update_permission_button + replace_via_turbo_stream( + component: Shares::PermissionButtonComponent.new( + share: @share, + available_roles:, + data: { "test-selector": "op-share-dialog-update-role" } + ) + ) + + respond_with_turbo_streams + end + + def respond_with_remove_share + remove_via_turbo_stream( + component: Shares::ShareRowComponent.new( + share: @share, + available_roles:, + sharing_manageable: sharing_manageable? + ) + ) + update_via_turbo_stream( + component: Shares::CounterComponent.new( + entity: @entity, + count: current_visible_member_count, + sharing_manageable: sharing_manageable? + ) + ) + + respond_with_turbo_streams + end + + def respond_with_update_user_details + update_via_turbo_stream( + component: Shares::UserDetailsComponent.new( + share: @share, + manager_mode: sharing_manageable?, + invite_resent: true + ) + ) + + respond_with_turbo_streams + end + + def respond_with_bulk_updated_permission_buttons + @selected_shares.each do |share| + replace_via_turbo_stream( + component: Shares::PermissionButtonComponent.new( + share:, + available_roles:, + data: { "test-selector": "op-share-dialog-update-role" } + ) + ) + end + + respond_with_turbo_streams + end + + def respond_with_bulk_removed_shares + @selected_shares.each do |share| + remove_via_turbo_stream( + component: Shares::ShareRowComponent.new( + share:, + available_roles:, + sharing_manageable: sharing_manageable? + ) + ) + end + + update_via_turbo_stream( + component: Shares::CounterComponent.new( + entity: @entity, + count: current_visible_member_count, + sharing_manageable: sharing_manageable? + ) + ) + + respond_with_turbo_streams + end + + def load_entity + @entity = if params["work_package_id"] + WorkPackage.visible.find(params["work_package_id"]) + # TODO: Add support for other entities + else + raise ArgumentError, <<~ERROR + Nested the SharesController under an entity controller that is not yet configured to support sharing. + Edit the SharesController#load_entity method to load the entity from the correct parent. + ERROR + end + + if @entity.respond_to?(:project) + @project = @entity.project + end + end + + def load_share + @share = @entity.members.find(params[:id]) + end + + def current_visible_member_count + @current_visible_member_count ||= load_shares.size + end + + def load_query + return @query if defined?(@query) + + @query = ParamsToQueryService + .new(Member, current_user, query_class: Queries::Members::EntityMemberQuery) + .call(params) + + # Set default filter on the entity + @query.where("entity_id", "=", @entity.id) + @query.where("entity_type", "=", @entity.class.name) + if @project + @query.where("project_id", "=", @project.id) + end + + @query.order(name: :asc) unless params[:sortBy] + + @query + end + + def load_shares + @shares = load_query.results + end + + def load_selected_shares + @selected_shares = Member.includes(:principal) + .of_entity(@entity) + .where(id: params[:share_ids]) + end + + def available_roles + @available_roles ||= if @entity.is_a?(WorkPackage) + role_mapping = WorkPackageRole.unscoped.pluck(:builtin, :id).to_h + + [ + { label: I18n.t("work_package.permissions.edit"), + value: role_mapping[Role::BUILTIN_WORK_PACKAGE_EDITOR], + description: I18n.t("work_package.permissions.edit_description") }, + { label: I18n.t("work_package.permissions.comment"), + value: role_mapping[Role::BUILTIN_WORK_PACKAGE_COMMENTER], + description: I18n.t("work_package.permissions.comment_description") }, + { label: I18n.t("work_package.permissions.view"), + value: role_mapping[Role::BUILTIN_WORK_PACKAGE_VIEWER], + description: I18n.t("work_package.permissions.view_description"), + default: true } + ] + else + [] + end + end + + def sharing_contract_scope + if @entity.is_a?(WorkPackage) + Shares::WorkPackages + end + end +end diff --git a/app/controllers/work_packages/shares/bulk_controller.rb b/app/controllers/work_packages/shares/bulk_controller.rb deleted file mode 100644 index 79db00e72854..000000000000 --- a/app/controllers/work_packages/shares/bulk_controller.rb +++ /dev/null @@ -1,134 +0,0 @@ -# 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. -# ++ - -class WorkPackages::Shares::BulkController < ApplicationController - include OpTurbo::ComponentStream - include MemberHelper - - before_action :find_work_package - before_action :find_selected_shares - before_action :find_role_ids_from_params, only: :update - before_action :find_project - before_action :authorize - - def update - @selected_shares.each do |share| - WorkPackageMembers::CreateOrUpdateService - .new(user: current_user) - .call(entity: @work_package, - user_id: share.principal.id, - role_ids: @role_ids).result - end - - respond_with_update_permission_buttons - end - - def destroy - @selected_shares.each do |share| - WorkPackageMembers::DeleteService - .new(user: current_user, model: share) - .call - end - - if current_visible_member_count.zero? - respond_with_replace_modal - else - respond_with_remove_shares - end - end - - private - - def respond_with_update_permission_buttons - @selected_shares.each do |share| - replace_via_turbo_stream( - component: WorkPackages::Share::PermissionButtonComponent.new(share:, - data: { "test-selector": "op-share-wp-update-role" }) - ) - end - - respond_with_turbo_streams - end - - def respond_with_replace_modal - replace_via_turbo_stream( - component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package, shares: find_shares) - ) - - respond_with_turbo_streams - end - - def respond_with_remove_shares - @selected_shares.each do |share| - remove_via_turbo_stream( - component: WorkPackages::Share::ShareRowComponent.new(share:) - ) - end - - update_via_turbo_stream( - component: WorkPackages::Share::CounterComponent.new(work_package: @work_package, count: current_visible_member_count) - ) - - respond_with_turbo_streams - end - - def find_work_package - @work_package = WorkPackage.find(params[:work_package_id]) - end - - def find_project - @project = @work_package.project - end - - def find_shares - @shares = Member.includes(:principal, :member_roles) - .references(:member_roles) - .of_work_package(@work_package) - .merge(MemberRole.only_non_inherited) - end - - 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 - @current_visible_member_count ||= Member - .joins(:member_roles) - .of_work_package(@work_package) - .merge(MemberRole.only_non_inherited) - .size - end -end diff --git a/app/controllers/work_packages/shares_controller.rb b/app/controllers/work_packages/shares_controller.rb deleted file mode 100644 index 930f2fc8dfc6..000000000000 --- a/app/controllers/work_packages/shares_controller.rb +++ /dev/null @@ -1,238 +0,0 @@ -# -- 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. -# ++ - -class WorkPackages::SharesController < ApplicationController - include OpTurbo::ComponentStream - include MemberHelper - - before_action :find_work_package, only: %i[index create destroy update resend_invite] - before_action :find_share, only: %i[destroy update resend_invite] - before_action :find_project - before_action :authorize - before_action :enterprise_check, only: %i[index] - - def index - 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, errors: @errors), layout: nil - end - - def create - overall_result = [] - @errors = ActiveModel::Errors.new(self) - - find_or_create_users(send_notification: false) do |member_params| - user = User.find_by(id: member_params[:user_id]) - if user.present? && user.locked? - @errors.add(:base, I18n.t("work_package.sharing.warning_locked_user", user: user.name)) - else - service_call = WorkPackageMembers::CreateOrUpdateService - .new(user: current_user) - .call(entity: @work_package, - user_id: member_params[:user_id], - role_ids: find_role_ids(params[:member][:role_id])) - - overall_result.push(service_call) - end - end - - @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 && @new_shares.size < current_visible_member_count - respond_with_prepend_shares - else - respond_with_replace_modal - end - else - respond_with_new_invite_form - end - end - - def update - WorkPackageMembers::UpdateService - .new(user: current_user, model: @share) - .call(role_ids: find_role_ids(params[:role_ids])) - - find_shares - - if @shares.empty? - respond_with_replace_modal - elsif @shares.include?(@share) - respond_with_update_permission_button - else - respond_with_remove_share - end - end - - def destroy - WorkPackageMembers::DeleteService - .new(user: current_user, model: @share) - .call - - if current_visible_member_count.zero? - respond_with_replace_modal - else - respond_with_remove_share - end - end - - def resend_invite - OpenProject::Notifications.send(OpenProject::Events::WORK_PACKAGE_SHARED, - work_package_member: @share, - send_notifications: true) - - respond_with_update_user_details - end - - private - - def enterprise_check - return if EnterpriseToken.allows_to?(:work_package_sharing) - - render WorkPackages::Share::ModalUpsaleComponent.new - end - - def respond_with_replace_modal - replace_via_turbo_stream( - component: WorkPackages::Share::ModalBodyComponent.new(work_package: @work_package, - shares: @new_shares || find_shares, - errors: @errors) - ) - - respond_with_turbo_streams - end - - def respond_with_prepend_shares - replace_via_turbo_stream( - component: WorkPackages::Share::InviteUserFormComponent.new(work_package: @work_package, errors: @errors) - ) - - update_via_turbo_stream( - component: WorkPackages::Share::CounterComponent.new(work_package: @work_package, count: current_visible_member_count) - ) - - @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, - shares: find_shares, - errors: @errors) - ) - end - - respond_with_turbo_streams - end - - def respond_with_new_invite_form - replace_via_turbo_stream( - component: WorkPackages::Share::InviteUserFormComponent.new(work_package: @work_package, errors: @errors) - ) - - respond_with_turbo_streams - end - - def respond_with_update_permission_button - replace_via_turbo_stream( - component: WorkPackages::Share::PermissionButtonComponent.new(share: @share, - data: { "test-selector": "op-share-wp-update-role" }) - ) - - respond_with_turbo_streams - end - - def respond_with_remove_share - remove_via_turbo_stream( - component: WorkPackages::Share::ShareRowComponent.new(share: @share) - ) - - update_via_turbo_stream( - component: WorkPackages::Share::CounterComponent.new(work_package: @work_package, count: current_visible_member_count) - ) - - respond_with_turbo_streams - end - - def respond_with_update_user_details - update_via_turbo_stream( - component: WorkPackages::Share::UserDetailsComponent.new(share: @share, - invite_resent: true) - ) - - respond_with_turbo_streams - end - - def find_work_package - @work_package = WorkPackage.find(params[:work_package_id]) - end - - def find_share - @share = @work_package.members.find(params[:id]) - end - - def find_shares - @shares = load_shares(load_query) - end - - def find_project - @project = @work_package.project - end - - def current_visible_member_count - @current_visible_member_count ||= load_shares(load_query).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/forms/work_packages/share/invitee.rb b/app/forms/shares/invitee.rb similarity index 86% rename from app/forms/work_packages/share/invitee.rb rename to app/forms/shares/invitee.rb index de2d34f73691..e6857c141cd0 100644 --- a/app/forms/work_packages/share/invitee.rb +++ b/app/forms/shares/invitee.rb @@ -25,21 +25,21 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -module WorkPackages::Share +module Shares class Invitee < ApplicationForm form do |user_invite_form| user_invite_form.autocompleter( name: :user_id, - label: I18n.t("work_package.sharing.label_search"), + label: I18n.t("sharing.label_search"), visually_hide_label: true, - data: { "work-packages--share--user-limit-target": "autocompleter" }, + data: { "shares--user-limit-target": "autocompleter" }, autocomplete_options: { component: "opce-user-autocompleter", defaultData: false, - id: "op-share-wp-invite-autocomplete", - placeholder: I18n.t("work_package.sharing.label_search_placeholder"), + id: "op-share-dialog-invite-autocomplete", + placeholder: I18n.t("sharing.label_search_placeholder"), data: { - "test-selector": "op-share-wp-invite-autocomplete" + "test-selector": "op-share-dialog-invite-autocomplete" }, url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, filters: [{ name: "type", operator: "=", values: %w[User Group] }, diff --git a/app/mailers/sharing_mailer.rb b/app/mailers/sharing_mailer.rb index d01efe5a9169..db6e63fa2978 100644 --- a/app/mailers/sharing_mailer.rb +++ b/app/mailers/sharing_mailer.rb @@ -39,11 +39,11 @@ def optionally_activated_url(back_url, invitation_token) def derive_role_rights(role) case role.builtin when Role::BUILTIN_WORK_PACKAGE_EDITOR - I18n.t("work_package.sharing.permissions.edit") + I18n.t("work_package.permissions.edit") when Role::BUILTIN_WORK_PACKAGE_COMMENTER - I18n.t("work_package.sharing.permissions.comment") + I18n.t("work_package.permissions.comment") when Role::BUILTIN_WORK_PACKAGE_VIEWER - I18n.t("work_package.sharing.permissions.view") + I18n.t("work_package.permissions.view") end end @@ -51,14 +51,14 @@ def derive_allowed_work_package_actions(role) allowed_actions = case role.builtin when Role::BUILTIN_WORK_PACKAGE_EDITOR - [I18n.t("work_package.sharing.permissions.view"), - I18n.t("work_package.sharing.permissions.comment"), - I18n.t("work_package.sharing.permissions.edit")] + [I18n.t("work_package.permissions.view"), + I18n.t("work_package.permissions.comment"), + I18n.t("work_package.permissions.edit")] when Role::BUILTIN_WORK_PACKAGE_COMMENTER - [I18n.t("work_package.sharing.permissions.view"), - I18n.t("work_package.sharing.permissions.comment")] + [I18n.t("work_package.permissions.view"), + I18n.t("work_package.permissions.comment")] when Role::BUILTIN_WORK_PACKAGE_VIEWER - [I18n.t("work_package.sharing.permissions.view")] + [I18n.t("work_package.permissions.view")] end allowed_actions.map(&:downcase) diff --git a/app/models/member.rb b/app/models/member.rb index a651c46bc14a..0419e68cb3f4 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -56,6 +56,7 @@ class Member < ApplicationRecord :of_any_project, :of_work_package, :of_any_work_package, + :of_entity, :of_any_entity, :of_anything_in_project, :visible, diff --git a/app/models/members/scopes/of_entity.rb b/app/models/members/scopes/of_entity.rb new file mode 100644 index 000000000000..b9178a060458 --- /dev/null +++ b/app/models/members/scopes/of_entity.rb @@ -0,0 +1,41 @@ +# -- 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. +# ++ + +module Members::Scopes + module OfEntity + extend ActiveSupport::Concern + + class_methods do + # Find all members of a specific Work Package + def of_entity(entity) + of_any_entity + .where(entity:) + end + end + end +end diff --git a/app/models/queries/members.rb b/app/models/queries/members.rb index 8f55951d4dc3..98228d61265e 100644 --- a/app/models/queries/members.rb +++ b/app/models/queries/members.rb @@ -50,7 +50,7 @@ module Queries::Members order Orders::StatusOrder end - ::Queries::Register.register(WorkPackageMemberQuery) do + ::Queries::Register.register(EntityMemberQuery) do filter Filters::NameFilter filter Filters::AnyNameAttributeFilter filter Filters::ProjectFilter diff --git a/app/models/queries/members/work_package_member_query.rb b/app/models/queries/members/entity_member_query.rb similarity index 94% rename from app/models/queries/members/work_package_member_query.rb rename to app/models/queries/members/entity_member_query.rb index 36436a38a6f3..27714076f0e1 100644 --- a/app/models/queries/members/work_package_member_query.rb +++ b/app/models/queries/members/entity_member_query.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class Queries::Members::WorkPackageMemberQuery < Queries::Members::MemberQuery +class Queries::Members::EntityMemberQuery < Queries::Members::MemberQuery def default_scope Member.joins(:member_roles).merge(MemberRole.only_non_inherited) end diff --git a/app/services/members/delete_by_principal_service.rb b/app/services/members/delete_by_principal_service.rb index 620ce72035df..f52fd5c89999 100644 --- a/app/services/members/delete_by_principal_service.rb +++ b/app/services/members/delete_by_principal_service.rb @@ -61,21 +61,15 @@ def call(params) def delete_project_member project_member = Member.of_project(project).find_by!(principal:) - Members::DeleteService - .new(user:, model: project_member) - .call + Members::DeleteService.new(user:, model: project_member).call end def delete_work_package_share(model) - WorkPackageMembers::DeleteService - .new(user:, model:) - .call + Shares::DeleteService.new(user:, model:, contract_class: Shares::WorkPackages::DeleteContract).call end def delete_work_package_share_with_role_id(model, role_id) - WorkPackageMembers::DeleteRoleService - .new(user:, model:) - .call(role_id:) + Shares::DeleteRoleService.new(user:, model:, contract_class: Shares::WorkPackages::DeleteContract).call(role_id:) end def work_package_shares_scope diff --git a/app/services/work_package_members/concerns/role_assignment.rb b/app/services/shares/concerns/role_assignment.rb similarity index 90% rename from app/services/work_package_members/concerns/role_assignment.rb rename to app/services/shares/concerns/role_assignment.rb index 31bdb1194332..0df795fdd0c3 100644 --- a/app/services/work_package_members/concerns/role_assignment.rb +++ b/app/services/shares/concerns/role_assignment.rb @@ -28,11 +28,11 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackageMembers::Concerns::RoleAssignment +module Shares::Concerns::RoleAssignment include Members::Concerns::RoleAssignment - # Work package memberships have a unique distinction from - # project memberships. A User should be able to be granted + # Memberships via shares have a unique distinction from + # regular project memberships. A User should be able to be granted # "Role X" independently and via a group. Meaning that for role assignment # as compared to Project memberships, the existing roles we want to take # into account are those that have not been inherited. diff --git a/app/services/work_package_members/create_or_update_service.rb b/app/services/shares/create_or_update_service.rb similarity index 70% rename from app/services/work_package_members/create_or_update_service.rb rename to app/services/shares/create_or_update_service.rb index 302f55b36a39..becdd8681719 100644 --- a/app/services/work_package_members/create_or_update_service.rb +++ b/app/services/shares/create_or_update_service.rb @@ -26,29 +26,27 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class WorkPackageMembers::CreateOrUpdateService - def initialize(user:, contract_class: nil, contract_options: {}) +class Shares::CreateOrUpdateService + def initialize(user:, create_contract_class:, update_contract_class:, contract_options: {}) self.user = user - self.contract_class = contract_class + self.create_contract_class = create_contract_class + self.update_contract_class = update_contract_class self.contract_options = contract_options end def call(entity:, user_id:, **) - actual_service(entity, user_id) - .call(entity:, user_id:, **) + actual_service(entity, user_id).call(entity:, user_id:, **) end private - attr_accessor :user, :contract_class, :contract_options + attr_accessor :user, :create_contract_class, :update_contract_class, :contract_options def actual_service(entity, user_id) if (member = Member.find_by(entity:, principal: user_id)) - WorkPackageMembers::UpdateService - .new(user:, model: member, contract_class:, contract_options:) + Shares::UpdateService.new(user:, model: member, contract_class: update_contract_class, contract_options:) else - WorkPackageMembers::CreateService - .new(user:, contract_class:, contract_options:) + Shares::CreateService.new(user:, contract_class: create_contract_class, contract_options:) end end end diff --git a/app/services/work_package_members/create_service.rb b/app/services/shares/create_service.rb similarity index 71% rename from app/services/work_package_members/create_service.rb rename to app/services/shares/create_service.rb index ca0706235846..4f6b11f5e889 100644 --- a/app/services/work_package_members/create_service.rb +++ b/app/services/shares/create_service.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class WorkPackageMembers::CreateService < BaseServices::Create +class Shares::CreateService < BaseServices::Create private def instance_class @@ -36,29 +36,28 @@ def instance_class def after_perform(service_call) return service_call unless service_call.success? - work_package_member = service_call.result + share = service_call.result - add_group_memberships(work_package_member) - send_notification(work_package_member) + add_group_memberships(share) + send_notification(share) service_call end - def add_group_memberships(work_package_member) - return unless work_package_member.principal.is_a?(Group) + def add_group_memberships(share) + return unless share.principal.is_a?(Group) Groups::CreateInheritedRolesService - .new(work_package_member.principal, - current_user: user, - contract_class: EmptyContract) - .call(user_ids: work_package_member.principal.user_ids, + .new(share.principal, current_user: user, contract_class: EmptyContract) + .call(user_ids: share.principal.user_ids, send_notifications: false, - project_ids: [work_package_member.project_id]) + project_ids: [share.project_id]) # TODO: Here we should add project_id and the entity id as well end - def send_notification(work_package_member) + def send_notification(share) + # TODO: We should select what sort of notification is sent out based on the shared entity OpenProject::Notifications.send(OpenProject::Events::WORK_PACKAGE_SHARED, - work_package_member:, + work_package_member: share, send_notifications: true) end end diff --git a/app/services/work_package_members/delete_role_service.rb b/app/services/shares/delete_role_service.rb similarity index 94% rename from app/services/work_package_members/delete_role_service.rb rename to app/services/shares/delete_role_service.rb index 54a3710a501d..edde8919863c 100644 --- a/app/services/work_package_members/delete_role_service.rb +++ b/app/services/shares/delete_role_service.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class WorkPackageMembers::DeleteRoleService < WorkPackageMembers::DeleteService +class Shares::DeleteRoleService < Shares::DeleteService def destroy(object) if object.member_roles.where.not("inherited_from IS NULL AND role_id = ?", params[:role_id]).empty? super diff --git a/app/services/work_package_members/delete_service.rb b/app/services/shares/delete_service.rb similarity index 82% rename from app/services/work_package_members/delete_service.rb rename to app/services/shares/delete_service.rb index 1574b02a09a6..b935e618a04d 100644 --- a/app/services/work_package_members/delete_service.rb +++ b/app/services/shares/delete_service.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class WorkPackageMembers::DeleteService < BaseServices::Delete +class Shares::DeleteService < BaseServices::Delete include Members::Concerns::CleanedUp def destroy(object) @@ -41,17 +41,17 @@ def destroy(object) def after_perform(service_call) super.tap do |call| - work_package_member = call.result + share = call.result - cleanup_for_group(work_package_member) + cleanup_for_group(share) end end - def cleanup_for_group(work_package_member) - return unless work_package_member.principal.is_a?(Group) + def cleanup_for_group(share) + return unless share.principal.is_a?(Group) Groups::CleanupInheritedRolesService - .new(work_package_member.principal, current_user: user, contract_class: EmptyContract) + .new(share.principal, current_user: user, contract_class: EmptyContract) .call end end diff --git a/app/services/work_package_members/set_attributes_service.rb b/app/services/shares/set_attributes_service.rb similarity index 89% rename from app/services/work_package_members/set_attributes_service.rb rename to app/services/shares/set_attributes_service.rb index 70bc10ec483d..1ef7f078e45f 100644 --- a/app/services/work_package_members/set_attributes_service.rb +++ b/app/services/shares/set_attributes_service.rb @@ -26,9 +26,9 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -module WorkPackageMembers +module Shares class SetAttributesService < ::BaseServices::SetAttributes - prepend WorkPackageMembers::Concerns::RoleAssignment + prepend Shares::Concerns::RoleAssignment private @@ -36,7 +36,9 @@ def set_attributes(params) super model.change_by_system do - model.project = model.entity&.project + if model.entity.respond_to?(:project) + model.project = model.entity&.project + end end end end diff --git a/app/services/work_package_members/update_service.rb b/app/services/shares/update_service.rb similarity index 75% rename from app/services/work_package_members/update_service.rb rename to app/services/shares/update_service.rb index 42dae489b44d..a065256d24d5 100644 --- a/app/services/work_package_members/update_service.rb +++ b/app/services/shares/update_service.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class WorkPackageMembers::UpdateService < BaseServices::Update +class Shares::UpdateService < BaseServices::Update include Members::Concerns::CleanedUp protected @@ -34,20 +34,16 @@ class WorkPackageMembers::UpdateService < BaseServices::Update def after_perform(service_call) return service_call unless service_call.success? - work_package_member = service_call.result + share = service_call.result - update_group_roles(work_package_member) if work_package_member.principal.is_a?(Group) + update_group_roles(share) if share.principal.is_a?(Group) service_call end - def update_group_roles(work_package_member) + def update_group_roles(share) Groups::UpdateRolesService - .new(work_package_member.principal, - current_user: user, - contract_class: EmptyContract) - .call(member: work_package_member, - send_notifications: false, - message: nil) + .new(share.principal, current_user: user, contract_class: EmptyContract) + .call(member: share, send_notifications: false, message: nil) end end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 4a71cd8380b1..8b4ab0de9719 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -331,8 +331,7 @@ map.permission :share_work_packages, { members: %i[destroy_by_principal], - "work_packages/shares": %i[index create destroy update resend_invite], - "work_packages/shares/bulk": %i[update destroy] + shares: %i[index create destroy update resend_invite bulk_update bulk_destroy] }, permissible_on: :project, dependencies: %i[edit_work_packages view_shared_work_packages], @@ -340,7 +339,7 @@ map.permission :view_shared_work_packages, { - "work_packages/shares": %i[index] + shares: %i[index] }, permissible_on: :project, require: :member, diff --git a/config/locales/en.yml b/config/locales/en.yml index 80e31ee4641c..913f69703039 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3617,54 +3617,56 @@ Project attributes and sections are defined in the upgrade your plan to be able to add more users.' - warning_user_limit_reached: > - Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package. - warning_user_limit_reached_admin: > - Adding additional users will exceed the current limit. Please upgrade your plan to be able to ensure external users are able to access this work package. - warning_no_selected_user: "Please select users to share this work package with" - warning_locked_user: "The user %{user} is locked and cannot be shared with" - user_details: - locked: "Locked user" - invited: "Invite sent. " - resend_invite: "Resend." - invite_resent: "Invite has been resent" - not_project_member: "Not a project member" - project_group: "Group members might have additional privileges (as project members)" - not_project_group: "Group (shared with all members)" - additional_privileges_project: "Might have additional privileges (as project member)" - additional_privileges_group: "Might have additional privileges (as group member)" - additional_privileges_project_or_group: "Might have additional privileges (as project or group member)" + permissions: + comment: "Comment" + comment_description: "Can view and comment this work package." + edit: "Edit" + edit_description: "Can view, comment and edit this work package." + view: "View" + view_description: "Can view this work package." + sharing: + count: + zero: "0 users" + one: "1 user" + other: "%{count} users" + filter: + project_member: "Project member" + not_project_member: "Not project member" + project_group: "Project group" + not_project_group: "Not project group" + user: "User" + group: "Group" + role: "Role" + type: "Type" + denied: "You don't have permissions to share %{entities}." + label_search: "Search for users to invite" + label_search_placeholder: "Search by user or email address" + label_toggle_all: "Toggle all shares" + remove: "Remove" + share: "Share" + text_empty_search_description: "There are no users with the current filter criteria." + text_empty_search_header: "We couldn't find any matching results." + text_empty_state_description: "The %{entity} has not been shared with anyone yet." + text_empty_state_header: "Not shared" + text_user_limit_reached: "Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this %{entity}." + text_user_limit_reached_admins: 'Adding additional users will exceed the current limit. Please upgrade your plan to be able to add more users.' + warning_user_limit_reached: > + Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this %{entity}. + warning_user_limit_reached_admin: > + Adding additional users will exceed the current limit. Please upgrade your plan to be able to ensure external users are able to access this %{entity}. + warning_no_selected_user: "Please select users to share this %{entity} with" + warning_locked_user: "The user %{user} is locked and cannot be shared with" + user_details: + locked: "Locked user" + invited: "Invite sent. " + resend_invite: "Resend." + invite_resent: "Invite has been resent" + not_project_member: "Not a project member" + project_group: "Group members might have additional privileges (as project members)" + not_project_group: "Group (shared with all members)" + additional_privileges_project: "Might have additional privileges (as project member)" + additional_privileges_group: "Might have additional privileges (as group member)" + additional_privileges_project_or_group: "Might have additional privileges (as project or group member)" working_days: info: > diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 76d0b619abd2..3ab5a7eaa6d9 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -990,6 +990,11 @@ en: preformatted_text: "Preformatted Text" wiki_link: "Link to a Wiki page" image: "Image" + sharing: + share: "Share" + selected_count: "%{count} selected" + selection: + mixed: "Mixed" work_packages: bulk_actions: move: "Bulk change of project" @@ -1146,12 +1151,8 @@ en: is_parent: "The dates of this work package are automatically deduced from its children. Activate 'Manual scheduling' to set the dates." is_switched_from_manual_to_automatic: "The dates of this work package may need to be recalculated after switching from manual to automatic scheduling due to relationships with other work packages." sharing: - share: "Share" title: "Share work package" show_all_users: "Show all users with whom the work package has been shared with" - selected_count: "%{count} selected" - selection: - mixed: "Mixed" upsale: description: "Share work packages with users who are not members of the project." table: diff --git a/config/routes.rb b/config/routes.rb index 739003785c74..10d63e1decb6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,6 +76,22 @@ via: :all end + # Shared route concerns + # TODO: Add description how to configure controller to support shares + concern :shareable do + resources :members, path: :shares, controller: "shares", only: %i[index create update destroy] do + member do + post "resend_invite" => "shares#resend_invite" + end + + collection do + patch :bulk, to: "shares#bulk_update" + put :bulk, to: "shares#bulk_update" + delete :bulk, to: "shares#bulk_destroy" + end + end + end + scope controller: "account" do get "/account/force_password_change", action: "force_password_change" post "/account/change_password", action: "change_password" @@ -521,7 +537,7 @@ get "/bulk" => "bulk#destroy" end - resources :work_packages, only: [:index] do + resources :work_packages, only: [:index], concerns: [:shareable] do # move bulk of wps get "move/new" => "work_packages/moves#new", on: :collection, as: "new_move" post "move" => "work_packages/moves#create", on: :collection, as: "move" @@ -531,16 +547,6 @@ # states managed by client-side routing on work_package#index get "details/*state" => "work_packages#index", on: :collection, as: :details - # Rails managed sharing route - resources :members, path: :shares, controller: "work_packages/shares", only: %i[index create update destroy] do - member do - post "resend_invite" => "work_packages/shares#resend_invite" - end - collection do - resource :bulk, controller: "work_packages/shares/bulk", only: %i[update destroy], as: :shares_bulk - end - end - resource :progress, only: %i[new edit update], controller: "work_packages/progress" collection do resource :progress, @@ -554,7 +560,7 @@ get "/create_new" => "work_packages#index", on: :collection, as: "new_split" get "/new" => "work_packages#index", on: :collection, as: "new", state: "new" # We do not want to match the work package export routes - get "(/*state)" => "work_packages#show", on: :member, as: "", constraints: { id: /\d+/ } + get "(/*state)" => "work_packages#show", on: :member, as: "", constraints: { id: /\d+/, state: /(?!shares).+/ } get "/share_upsale" => "work_packages#index", on: :collection, as: "share_upsale" get "/edit" => "work_packages#show", on: :member, as: "edit" end diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts index 417c4dd01742..a6e356bb5b4e 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component.ts @@ -57,7 +57,7 @@ export class WorkPackageShareButtonComponent extends UntilDestroyedMixin impleme shareCount$:Observable; public text = { - share: this.I18n.t('js.work_packages.sharing.share'), + share: this.I18n.t('js.sharing.share'), }; constructor( diff --git a/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html b/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html index 76cdc0ea4a02..657e673b9618 100644 --- a/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html +++ b/frontend/src/app/features/work-packages/components/wp-share-modal/wp-share.modal.html @@ -1,5 +1,5 @@
- + @@ -69,7 +69,7 @@ - + diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts b/frontend/src/stimulus/controllers/dynamic/shares/bulk-selection.controller.ts similarity index 94% rename from frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts rename to frontend/src/stimulus/controllers/dynamic/shares/bulk-selection.controller.ts index f6d119154bc5..a82042673b8a 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/share/bulk-selection.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/shares/bulk-selection.controller.ts @@ -32,7 +32,7 @@ import { Controller } from '@hotwired/stimulus'; export default class BulkSelectionController extends Controller { static values = { - bulkUpdateRoleLabel: { type: String, default: I18n.t('js.work_packages.sharing.selection.mixed') }, + bulkUpdateRoleLabel: { type: String, default: I18n.t('js.sharing.selection.mixed') }, }; declare bulkUpdateRoleLabelValue:string; @@ -152,7 +152,7 @@ export default class BulkSelectionController extends Controller { private updateBulkUpdateRoleLabelValue() { if (new Set(this.selectedPermissions).size > 1) { - this.bulkUpdateRoleLabelValue = I18n.t('js.work_packages.sharing.selection.mixed'); + this.bulkUpdateRoleLabelValue = I18n.t('js.sharing.selection.mixed'); this.bulkPermissionButtons.forEach((button) => button.setAttribute('aria-checked', 'false')); } else { this.bulkUpdateRoleLabelValue = this.selectedPermissions[0]; @@ -180,7 +180,7 @@ export default class BulkSelectionController extends Controller { } private showSelectedCounter() { - this.selectedCounterTarget.textContent = I18n.t('js.work_packages.sharing.selected_count', { count: this.checked.length }); + this.selectedCounterTarget.textContent = I18n.t('js.sharing.selected_count', { count: this.checked.length }); this.sharedCounterTarget.setAttribute('hidden', 'true'); this.selectedCounterTarget.removeAttribute('hidden'); } @@ -201,7 +201,7 @@ export default class BulkSelectionController extends Controller { hiddenInput.type = 'hidden'; hiddenInput.name = 'share_ids[]'; hiddenInput.value = checkbox.value; - hiddenInput.setAttribute('data-work-packages--share--bulk-selection-target', 'hiddenShare'); + hiddenInput.setAttribute('data-shares--bulk-selection-target', 'hiddenShare'); return hiddenInput; }); diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/share/user-selected.controller.ts b/frontend/src/stimulus/controllers/dynamic/shares/user-selected.controller.ts similarity index 100% rename from frontend/src/stimulus/controllers/dynamic/work-packages/share/user-selected.controller.ts rename to frontend/src/stimulus/controllers/dynamic/shares/user-selected.controller.ts diff --git a/lookbook/previews/open_project/progress/modal_preview/status_based.html.erb b/lookbook/previews/open_project/progress/modal_preview/status_based.html.erb index 0964ac897048..1ffc6c8db93f 100644 --- a/lookbook/previews/open_project/progress/modal_preview/status_based.html.erb +++ b/lookbook/previews/open_project/progress/modal_preview/status_based.html.erb @@ -4,7 +4,7 @@