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 @@