+<%=
+ component_wrapper do
+ primer_form_with(
+ class: "op-new-project-mapping-form",
+ model: @project_mapping,
+ url: link_admin_settings_project_custom_field_path(@project_custom_field),
+ data: { turbo: true },
+ method: :post
+ ) do |form|
+ concat(render(Primer::Alpha::Dialog::Body.new(
+ id: DIALOG_BODY_ID, test_selector: DIALOG_BODY_ID, aria: { label: title },
+ style: "min-height: 300px"
+ )) do
+ render(Projects::CustomFields::CustomFieldMappingForm.new(form, project_mapping: @project_mapping))
+ end)
+ concat(render(Primer::Alpha::Dialog::Footer.new(show_divider: false)) do
+ concat(render(Primer::ButtonComponent.new(data: { 'close-dialog-id': DIALOG_ID })) { cancel_button_text })
+ concat(render(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) { submit_button_text })
+ end)
+ end
+ end
+%>
diff --git a/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_form_component.rb b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_form_component.rb
new file mode 100644
index 000000000000..1693de4871fd
--- /dev/null
+++ b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_form_component.rb
@@ -0,0 +1,60 @@
+#-- 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 Settings
+ module ProjectCustomFields
+ module ProjectCustomFieldMapping
+ class NewProjectMappingFormComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent
+ include OpTurbo::Streamable
+
+ DIALOG_ID = "settings--new-project-custom-field-mapping-component".freeze
+ DIALOG_BODY_ID = "settings--new-project-custom-field-mapping-body-component".freeze
+
+ def initialize(project_mapping:, project_custom_field:)
+ super
+ @project_mapping = project_mapping
+ @project_custom_field = project_custom_field
+ end
+
+ private
+
+ def title
+ I18n.t("projects.settings.project_custom_fields.new_project_mapping_form.add_projects")
+ end
+
+ def cancel_button_text
+ I18n.t("button_cancel")
+ end
+
+ def submit_button_text
+ I18n.t("button_add")
+ end
+ end
+ end
+ end
+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/shares/invite_user_form_component.rb b/app/components/shares/invite_user_form_component.rb
new file mode 100644
index 000000000000..48ae8e0715ed
--- /dev/null
+++ b/app/components/shares/invite_user_form_component.rb
@@ -0,0 +1,57 @@
+#-- 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 InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ def initialize(entity:,
+ available_roles:,
+ sharing_manageable:,
+ errors: nil)
+ super
+
+ @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 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/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/news/base_contract.rb b/app/contracts/news/base_contract.rb
new file mode 100644
index 000000000000..7d6325f0e9eb
--- /dev/null
+++ b/app/contracts/news/base_contract.rb
@@ -0,0 +1,50 @@
+#-- 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.
+#++
+
+class News::BaseContract < ModelContract
+ include Attachments::ValidateReplacements
+
+ validate :allowed_to_manage
+
+ def self.model
+ News
+ end
+
+ attribute :project
+ attribute :title
+ attribute :summary
+ attribute :description
+
+ def allowed_to_manage
+ return if model.project.nil?
+
+ unless user.allowed_in_project?(:manage_news, model.project)
+ errors.add :base, :error_unauthorized
+ end
+ end
+end
diff --git a/app/contracts/news/create_contract.rb b/app/contracts/news/create_contract.rb
new file mode 100644
index 000000000000..9a0274c59548
--- /dev/null
+++ b/app/contracts/news/create_contract.rb
@@ -0,0 +1,30 @@
+#-- 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.
+#++
+
+class News::CreateContract < News::BaseContract
+end
diff --git a/app/contracts/news/delete_contract.rb b/app/contracts/news/delete_contract.rb
new file mode 100644
index 000000000000..42648cce9abc
--- /dev/null
+++ b/app/contracts/news/delete_contract.rb
@@ -0,0 +1,31 @@
+#-- 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.
+#++
+
+class News::DeleteContract < ::DeleteContract
+ delete_permission :manage_news
+end
diff --git a/app/contracts/news/update_contract.rb b/app/contracts/news/update_contract.rb
new file mode 100644
index 000000000000..04bb5fef4bf0
--- /dev/null
+++ b/app/contracts/news/update_contract.rb
@@ -0,0 +1,30 @@
+#-- 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.
+#++
+
+class News::UpdateContract < News::BaseContract
+end
diff --git a/app/contracts/queries/projects/project_queries/base_contract.rb b/app/contracts/queries/projects/project_queries/base_contract.rb
index da00abf3366a..a98d9fa807c3 100644
--- a/app/contracts/queries/projects/project_queries/base_contract.rb
+++ b/app/contracts/queries/projects/project_queries/base_contract.rb
@@ -34,7 +34,7 @@ class BaseContract < ::ModelContract
attribute :orders
def self.model
- Queries::Projects::ProjectQuery
+ ProjectQuery
end
validates :name,
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/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb
index 59d366d3d9a9..8f37bb49c650 100644
--- a/app/controllers/admin/settings/project_custom_fields_controller.rb
+++ b/app/controllers/admin/settings/project_custom_fields_controller.rb
@@ -30,6 +30,7 @@ module Admin::Settings
class ProjectCustomFieldsController < ::Admin::SettingsController
include CustomFields::SharedActions
include OpTurbo::ComponentStream
+ include OpTurbo::DialogStreamHelper
include ApplicationComponentStreams
include FlashMessagesOutputSafetyHelper
include Admin::Settings::ProjectCustomFields::ComponentStreams
@@ -39,7 +40,8 @@ class ProjectCustomFieldsController < ::Admin::SettingsController
# rubocop:disable Rails/LexicallyScopedActionFilter
before_action :set_sections, only: %i[show index edit update move drop]
before_action :find_custom_field,
- only: %i(show edit project_mappings link unlink update destroy delete_option reorder_alphabetical move drop)
+ only: %i(show edit project_mappings new_link link unlink update destroy delete_option reorder_alphabetical
+ move drop)
before_action :prepare_custom_option_position, only: %i(update create)
before_action :find_custom_option, only: :delete_option
before_action :project_custom_field_mappings_query, only: %i[project_mappings unlink]
@@ -69,8 +71,14 @@ def new
def edit; end
- def project_mappings
+ def project_mappings; end
+
+ def new_link
@project_mapping = ProjectCustomFieldProjectMapping.new(project_custom_field: @custom_field)
+ respond_with_dialog Settings::ProjectCustomFields::ProjectCustomFieldMapping::NewProjectMappingComponent.new(
+ project_mapping: @project_mapping,
+ project_custom_field: @custom_field
+ )
end
def link
@@ -148,12 +156,6 @@ def destroy
private
def render_project_list(url_for_action: action_name)
- update_via_turbo_stream(
- component: Settings::ProjectCustomFields::ProjectCustomFieldMapping::NewProjectMappingComponent.new(
- project_mapping: ProjectCustomFieldProjectMapping.new(project_custom_field: @custom_field),
- project_custom_field: @custom_field
- )
- )
update_via_turbo_stream(
component: Settings::ProjectCustomFields::ProjectCustomFieldMapping::TableComponent.new(
query: project_custom_field_mappings_query,
@@ -163,7 +165,7 @@ def render_project_list(url_for_action: action_name)
end
def project_custom_field_mappings_query
- @project_custom_field_mappings_query = Queries::Projects::ProjectQuery.new(
+ @project_custom_field_mappings_query = ProjectQuery.new(
name: "project-custom-field-mappings-#{@custom_field.id}"
) do |query|
query.where(:available_project_attributes, "=", [@custom_field.id])
@@ -196,10 +198,23 @@ def find_unlink_project_custom_field_mapping
end
def find_custom_field_projects_to_link
- @projects = Project.find(params.to_unsafe_h[:project_custom_field_project_mapping][:project_ids])
+ project_ids = params.to_unsafe_h[:project_custom_field_project_mapping][:project_ids]
+ if project_ids.present?
+ @projects = Project.find(project_ids)
+ else
+ project_mapping = ProjectCustomFieldProjectMapping.new(project_custom_field: @custom_field)
+ project_mapping.errors.add(:project_ids, :blank)
+ component = Settings::ProjectCustomFields::ProjectCustomFieldMapping::NewProjectMappingFormComponent.new(
+ project_mapping:,
+ project_custom_field: @custom_field
+ )
+ update_via_turbo_stream(component:, status: :bad_request)
+ respond_with_turbo_streams
+ false
+ end
rescue ActiveRecord::RecordNotFound
update_flash_message_via_turbo_stream(
- message: t(:notice_file_not_found), full: true, dismiss_scheme: :hide, scheme: :danger
+ message: t(:notice_project_not_found), full: true, dismiss_scheme: :hide, scheme: :danger
)
render_project_list
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/controllers/concerns/shares/work_packages/authorization.rb b/app/controllers/concerns/shares/work_packages/authorization.rb
new file mode 100644
index 000000000000..32b4bf5f34e0
--- /dev/null
+++ b/app/controllers/concerns/shares/work_packages/authorization.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# -- copyright
+# OpenProject is an open source project management software.
+# 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.
+#
+# 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 Authorization
+ extend ActiveSupport::Concern
+
+ 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
+ end
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 778ddd6082c6..85384f3b0cab 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -155,7 +155,8 @@ def group_members
end
def visible_group_members?
- current_user.allowed_in_any_project?(:manage_members) ||
+ current_user.admin? ||
+ current_user.allowed_in_any_project?(:manage_members) ||
Group.in_project(Project.allowed_to(current_user, :view_members)).exists?
end
diff --git a/app/controllers/members/menus_controller.rb b/app/controllers/members/menus_controller.rb
index 75be7ba2c8c2..edfaec250776 100644
--- a/app/controllers/members/menus_controller.rb
+++ b/app/controllers/members/menus_controller.rb
@@ -27,13 +27,11 @@
#++
module Members
class MenusController < ApplicationController
- include Menus::MembersHelper
-
before_action :find_project_by_project_id,
:authorize
def show
- @sidebar_menu_items = first_level_menu_items
+ @sidebar_menu_items = Members::Menu.new(project: @project, params:).menu_items
render layout: nil
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/news_controller.rb b/app/controllers/news_controller.rb
index 27e9d57ef12e..31dae3297a4a 100644
--- a/app/controllers/news_controller.rb
+++ b/app/controllers/news_controller.rb
@@ -73,29 +73,44 @@ def new
def edit; end
def create
- @news = News.new(project: @project, author: User.current)
- @news.attributes = permitted_params.news
- if @news.save
+ call = News::CreateService
+ .new(user: current_user)
+ .call(permitted_params.news.merge(project: @project))
+
+ if call.success?
flash[:notice] = I18n.t(:notice_successful_create)
redirect_to controller: "/news", action: "index", project_id: @project
else
+ @news = call.result
render action: "new"
end
end
def update
- @news.attributes = permitted_params.news
- if @news.save
+ call = News::UpdateService
+ .new(model: @news, user: current_user)
+ .call(permitted_params.news.merge(project: @project))
+
+ if call.success?
flash[:notice] = I18n.t(:notice_successful_update)
redirect_to action: "show", id: @news
else
+ @news = call.result
render action: "edit"
end
end
def destroy
- @news.destroy
- flash[:notice] = I18n.t(:notice_successful_delete)
+ call = News::DeleteService
+ .new(model: @news, user: current_user)
+ .call
+
+ if call.success?
+ flash[:notice] = I18n.t(:notice_successful_delete)
+ else
+ call.apply_flash_message!(flash)
+ end
+
redirect_to action: "index", project_id: @project
end
diff --git a/app/controllers/projects/menus_controller.rb b/app/controllers/projects/menus_controller.rb
index cdecdf19bbdf..e339bbc5a180 100644
--- a/app/controllers/projects/menus_controller.rb
+++ b/app/controllers/projects/menus_controller.rb
@@ -32,9 +32,8 @@ class MenusController < ApplicationController
no_authorization_required! :show
def show
- projects_menu = Menus::Projects.new(controller_path: params[:controller_path], params:, current_user:)
-
- @sidebar_menu_items = projects_menu.first_level_menu_items
+ projects_menu = Projects::Menu.new(controller_path: params[:controller_path], params:, current_user:)
+ @sidebar_menu_items = projects_menu.menu_items
render layout: nil
end
diff --git a/app/controllers/projects/queries_controller.rb b/app/controllers/projects/queries_controller.rb
index 04914ac7d6ab..abbc67b2c4fc 100644
--- a/app/controllers/projects/queries_controller.rb
+++ b/app/controllers/projects/queries_controller.rb
@@ -113,6 +113,6 @@ def render_result(service_call, success_i18n_key:, error_i18n_key:) # rubocop:di
end
def find_query
- @query = Queries::Projects::ProjectQuery.visible(current_user).find(params[:id])
+ @query = ProjectQuery.visible(current_user).find(params[:id])
end
end
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/moves_controller.rb b/app/controllers/work_packages/moves_controller.rb
index 42c65161fafa..a9bbe6cd8e96 100644
--- a/app/controllers/work_packages/moves_controller.rb
+++ b/app/controllers/work_packages/moves_controller.rb
@@ -60,7 +60,7 @@ def within_frontend_treshold?
# rubocop:disable Metrics/AbcSize
def perform_in_frontend
call = job_class
- .perform_now(**job_args)
+ .perform_now(**job_args)
if call.success? && @work_packages.any?
flash[:notice] = call.message
@@ -70,6 +70,7 @@ def perform_in_frontend
redirect_back_or_default(project_work_packages_path(@project))
end
end
+
# rubocop:enable Metrics/AbcSize
def perform_in_background
@@ -114,13 +115,31 @@ def prepare_for_work_package_move
@allowed_projects = WorkPackage.allowed_target_projects_on_move(current_user)
@target_project = @allowed_projects.detect { |p| p.id.to_s == params[:new_project_id].to_s } if params[:new_project_id]
@target_project ||= @project
- @types = @target_project.types
+ @types = @target_project.types.order(:position)
@target_type = @types.find { |t| t.id.to_s == params[:new_type_id].to_s }
+ @unavailable_type_in_target_project = set_unavailable_type_in_target_project
@available_versions = @target_project.assignable_versions
@available_statuses = Workflow.available_statuses(@project)
@notes = params[:notes] || ""
end
+ def set_unavailable_type_in_target_project
+ if @target_project == @project
+ false
+ elsif @target_type.nil?
+ hierarchies = WorkPackageHierarchy
+ .includes(:ancestor)
+ .where(ancestor_id: @work_packages.select(:id))
+ Type.where(id: hierarchies.map { _1.ancestor.type_id })
+ .select("distinct id")
+ .pluck(:id)
+ .difference(@types.pluck(:id))
+ .any?
+ else
+ @types.exclude?(@target_type)
+ end
+ end
+
def attributes_for_create
permitted_params
.move_work_package
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/projects/custom_fields/custom_field_mapping_form.rb b/app/forms/projects/custom_fields/custom_field_mapping_form.rb
index f91ef2936a3c..3db99fc5b2d8 100644
--- a/app/forms/projects/custom_fields/custom_field_mapping_form.rb
+++ b/app/forms/projects/custom_fields/custom_field_mapping_form.rb
@@ -28,12 +28,15 @@
module Projects::CustomFields
class CustomFieldMappingForm < ApplicationForm
+ include OpPrimer::ComponentHelpers
+
form do |form|
- form.group(layout: :horizontal) do |group|
+ form.group(layout: :vertical) do |group|
group.project_autocompleter(
name: :id,
label: Project.model_name.human,
visually_hide_label: true,
+ validation_message: project_ids_error_message,
autocomplete_options: {
openDirectly: false,
focusDirectly: false,
@@ -53,16 +56,24 @@ class CustomFieldMappingForm < ApplicationForm
end
end
- def initialize(project_custom_field:)
+ def initialize(project_mapping:)
super()
- @project_custom_field = project_custom_field
+ @project_mapping = project_mapping
end
private
+ def project_ids_error_message
+ @project_mapping
+ .errors
+ .messages_for(:project_ids)
+ .to_sentence
+ .presence
+ end
+
def projects_with_custom_field_mapping
ProjectCustomFieldProjectMapping
- .where(project_custom_field: @project_custom_field)
+ .where(custom_field_id: @project_mapping.custom_field_id)
.pluck(:project_id)
.to_h { |id| [id, id] }
end
diff --git a/app/forms/queries/projects/form.rb b/app/forms/queries/projects/form.rb
index 006e2d96fac9..7e2e2b954faf 100644
--- a/app/forms/queries/projects/form.rb
+++ b/app/forms/queries/projects/form.rb
@@ -36,7 +36,7 @@ class Queries::Projects::Form < ApplicationForm
required: true,
autofocus: true,
name: "name",
- label: Queries::Projects::ProjectQuery.human_attribute_name(:name),
+ label: ProjectQuery.human_attribute_name(:name),
placeholder: I18n.t(:"projects.lists.new.placeholder")
)
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/helpers/attachments_helper.rb b/app/helpers/attachments_helper.rb
index 9f3238dd0124..f94ba25e46f5 100644
--- a/app/helpers/attachments_helper.rb
+++ b/app/helpers/attachments_helper.rb
@@ -45,6 +45,6 @@ def list_attachments(resource, options = {})
options[:inputs] = (options[:inputs] || {})
.reverse_merge(resource:, allowUploading: false, destroyImmediately: true)
- angular_component_tag("op-attachments", **options)
+ angular_component_tag("opce-attachments", **options)
end
end
diff --git a/app/helpers/frontend_asset_helper.rb b/app/helpers/frontend_asset_helper.rb
index 9adfd5dbefda..3d1d30b91765 100644
--- a/app/helpers/frontend_asset_helper.rb
+++ b/app/helpers/frontend_asset_helper.rb
@@ -27,14 +27,14 @@
#++
module FrontendAssetHelper
- CLI_DEFAULT_PROXY = 'http://localhost:4200'.freeze
+ CLI_DEFAULT_PROXY = "http://localhost:4200".freeze
def self.assets_proxied?
- ENV['OPENPROJECT_DISABLE_DEV_ASSET_PROXY'].blank? && !Rails.env.production? && cli_proxy.present?
+ ENV["OPENPROJECT_DISABLE_DEV_ASSET_PROXY"].blank? && !Rails.env.production? && cli_proxy.present?
end
def self.cli_proxy
- ENV.fetch('OPENPROJECT_CLI_PROXY', CLI_DEFAULT_PROXY)
+ ENV.fetch("OPENPROJECT_CLI_PROXY", CLI_DEFAULT_PROXY)
end
##
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 25a702eea32b..862942cf2620 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -53,7 +53,7 @@ def short_project_description(project, length = 255)
end
def projects_columns_options
- @projects_columns_options ||= ::Queries::Projects::ProjectQuery
+ @projects_columns_options ||= ::ProjectQuery
.new
.available_selects
.reject { |c| c.attribute == :hierarchy }
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/helpers/menus/members_helper.rb b/app/menus/members/menu.rb
similarity index 66%
rename from app/helpers/menus/members_helper.rb
rename to app/menus/members/menu.rb
index 06fe2d4931d6..795148e108b8 100644
--- a/app/helpers/menus/members_helper.rb
+++ b/app/menus/members/menu.rb
@@ -1,6 +1,6 @@
#-- copyright
# OpenProject is an open source project management software.
-# Copyright (C) 2012-2024 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.
@@ -25,34 +25,30 @@
#
# See COPYRIGHT and LICENSE files for more details.
#++
+module Members
+ class Menu < Submenu
+ attr_reader :project, :params
-module Menus
- module MembersHelper
- def first_level_menu_items
- [OpenProject::Menu::MenuGroup.new(header: nil, children: user_status_options)] + nested_menu_items
+ def initialize(project: nil, params: nil)
+ super(view_type: nil, project:, params:)
end
- private
-
- def user_status_options
+ def menu_items
[
- OpenProject::Menu::MenuItem.new(title: I18n.t("members.menu.all"),
- href: project_members_path,
- selected: active_filter_count == 0),
- OpenProject::Menu::MenuItem.new(title: I18n.t("members.menu.locked"),
- href: project_members_path(status: :locked),
- selected: selected?(:status, :locked)),
- OpenProject::Menu::MenuItem.new(title: I18n.t("members.menu.invited"),
- href: project_members_path(status: :invited),
- selected: selected?(:status, :invited))
+ OpenProject::Menu::MenuGroup.new(header: nil, children: user_status_options),
+ OpenProject::Menu::MenuGroup.new(header: I18n.t("members.menu.project_roles"), children: project_roles_entries),
+ OpenProject::Menu::MenuGroup.new(header: I18n.t("members.menu.wp_shares"), children: permission_menu_entries),
+ OpenProject::Menu::MenuGroup.new(header: I18n.t("members.menu.groups"), children: project_group_entries)
]
end
- def nested_menu_items
+ def user_status_options
[
- OpenProject::Menu::MenuGroup.new(header: I18n.t("members.menu.project_roles"), children: project_roles_entries),
- OpenProject::Menu::MenuGroup.new(header: I18n.t("members.menu.wp_shares"), children: permission_menu_entries),
- OpenProject::Menu::MenuGroup.new(header: I18n.t("members.menu.groups"), children: project_group_entries)
+ OpenProject::Menu::MenuItem.new(title: I18n.t("members.menu.all"),
+ href: project_members_path(project),
+ selected: active_filter_count == 0),
+ menu_item(I18n.t("members.menu.locked"), status: :locked),
+ menu_item(I18n.t("members.menu.invited"), status: :invited)
]
end
@@ -61,13 +57,13 @@ def project_roles_entries
.where(id: MemberRole.where(member_id: @project.members.select(:id)).select(:role_id))
.distinct
.pluck(:id, :name)
- .map { |id, name| menu_item(:role_id, id, name) }
+ .map { |id, name| menu_item(name, role_id: id) }
end
def permission_menu_entries
Members::UserFilterComponent
.share_options
- .map { |name, id| menu_item(:shared_role_id, id, name) }
+ .map { |name, id| menu_item(name, shared_role_id: id) }
end
def project_group_entries
@@ -76,19 +72,17 @@ def project_group_entries
.order(lastname: :asc)
.distinct
.pluck(:id, :lastname)
- .map { |id, name| menu_item(:group_id, id, name) }
+ .map { |id, name| menu_item(name, group_id: id) }
end
- def menu_item(filter_key, id, name)
- OpenProject::Menu::MenuItem.new(title: name,
- href: project_members_path(filter_key => id),
- selected: selected?(filter_key, id))
- end
-
- def selected?(filter_key, value)
+ def selected?(query_params)
return false if active_filter_count > 1
- params[filter_key] == value.to_s
+ super
+ end
+
+ def query_path(query_params)
+ project_members_path(project, query_params)
end
def active_filter_count
diff --git a/app/helpers/menus/projects.rb b/app/menus/projects/menu.rb
similarity index 69%
rename from app/helpers/menus/projects.rb
rename to app/menus/projects/menu.rb
index 76db83891d8a..bf1f277c5d50 100644
--- a/app/helpers/menus/projects.rb
+++ b/app/menus/projects/menu.rb
@@ -26,33 +26,50 @@
# See COPYRIGHT and LICENSE files for more details.
#++
-module Menus
- class Projects
+module Projects
+ class Menu < Submenu
include Rails.application.routes.url_helpers
attr_reader :controller_path, :params, :current_user
- def initialize(controller_path:, params:, current_user:)
- # rubocop:disable Rails/HelperInstanceVariable
- @controller_path = controller_path
+ def initialize(params:, controller_path:, current_user:)
@params = params
+ @controller_path = controller_path
@current_user = current_user
- # rubocop:enable Rails/HelperInstanceVariable
+
+ super(view_type:, project:, params:)
end
- def first_level_menu_items
+ def menu_items
[
- OpenProject::Menu::MenuGroup.new(header: nil,
- children: main_static_filters),
- OpenProject::Menu::MenuGroup.new(header: I18n.t(:"projects.lists.public"),
- children: public_filters),
- OpenProject::Menu::MenuGroup.new(header: I18n.t(:"projects.lists.my_private"),
- children: my_filters),
+ OpenProject::Menu::MenuGroup.new(header: nil, children: main_static_filters),
+ OpenProject::Menu::MenuGroup.new(header: I18n.t(:"projects.lists.public"), children: public_filters),
+ OpenProject::Menu::MenuGroup.new(header: I18n.t(:"projects.lists.my_private"), children: my_filters),
OpenProject::Menu::MenuGroup.new(header: I18n.t(:"activerecord.attributes.project.status_code"),
children: status_static_filters)
]
end
+ def selected?(query_params)
+ case controller_path
+ when "projects"
+ case params[:query_id]
+ when nil
+ query_params[:query_id].to_s == Queries::Projects::Factory::DEFAULT_STATIC
+ when /\A\d+\z/
+ query_params[:query_id].to_s == params[:query_id]
+ else
+ query_params[:query_id].to_s == params[:query_id] unless modification_params?
+ end
+ when "projects/queries"
+ query_params[:query_id].to_s == params[:id]
+ end
+ end
+
+ def query_path(query_params)
+ projects_path(query_params)
+ end
+
private
def main_static_filters
@@ -74,44 +91,22 @@ def status_static_filters
def static_filters(ids)
ids.map do |id|
- query_menu_item(::Queries::Projects::Factory.static_query(id), id:)
+ menu_item(::Queries::Projects::Factory.static_query(id).name, query_id: id)
end
end
def public_filters
- ::Queries::Projects::ProjectQuery
+ ::ProjectQuery
.public_lists
.order(:name)
- .map { |query| query_menu_item(query) }
+ .map { |query| menu_item(query.name, query_id: query.id) }
end
def my_filters
- ::Queries::Projects::ProjectQuery
+ ::ProjectQuery
.private_lists(user: current_user)
.order(:name)
- .map { |query| query_menu_item(query) }
- end
-
- def query_menu_item(query, id: nil)
- OpenProject::Menu::MenuItem.new(title: query.name,
- href: projects_path(query_id: id || query.id),
- selected: query_item_selected?(id || query.id))
- end
-
- def query_item_selected?(id)
- case controller_path
- when "projects"
- case params[:query_id]
- when nil
- id.to_s == Queries::Projects::Factory::DEFAULT_STATIC
- when /\A\d+\z/
- id.to_s == params[:query_id]
- else
- id.to_s == params[:query_id] unless modification_params?
- end
- when "projects/queries"
- id.to_s == params[:id]
- end
+ .map { |query| menu_item(query.name, query_id: query.id) }
end
def modification_params?
diff --git a/app/menus/submenu.rb b/app/menus/submenu.rb
index e2866c67922f..0e495d4672d1 100644
--- a/app/menus/submenu.rb
+++ b/app/menus/submenu.rb
@@ -48,7 +48,7 @@ def starred_queries
base_query
.where("starred" => "t")
.pluck(:id, :name)
- .map { |id, name| menu_item(query_params(id), name) }
+ .map { |id, name| menu_item(name, query_params(id)) }
end
def default_queries
@@ -60,7 +60,7 @@ def global_queries
.where("starred" => "f")
.where("public" => "t")
.pluck(:id, :name)
- .map { |id, name| menu_item(query_params(id), name) }
+ .map { |id, name| menu_item(name, query_params(id)) }
end
def custom_queries
@@ -68,7 +68,7 @@ def custom_queries
.where("starred" => "f")
.where("public" => "f")
.pluck(:id, :name)
- .map { |id, name| menu_item(query_params(id), name) }
+ .map { |id, name| menu_item(name, query_params(id)) }
end
def base_query
@@ -89,7 +89,7 @@ def query_params(id)
{ query_id: id }
end
- def menu_item(query_params, name)
+ def menu_item(name, query_params)
OpenProject::Menu::MenuItem.new(title: name,
href: query_path(query_params),
selected: selected?(query_params))
@@ -102,6 +102,10 @@ def selected?(query_params)
end
end
+ if query_params.empty? && params[:filters].present?
+ return false
+ end
+
true
end
diff --git a/app/models/member.rb b/app/models/member.rb
index dd918e6d96e5..0419e68cb3f4 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -31,7 +31,7 @@ class Member < ApplicationRecord
ALLOWED_ENTITIES = [
"WorkPackage",
- "Queries::Projects::ProjectQuery"
+ "ProjectQuery"
].freeze
extend DeprecatedAlias
@@ -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/news.rb b/app/models/news.rb
index 202723aee466..eced61e2b18d 100644
--- a/app/models/news.rb
+++ b/app/models/news.rb
@@ -33,6 +33,7 @@ class News < ApplicationRecord
order(:created_at)
}, as: :commented, dependent: :delete_all
+ validates :project, presence: true
validates :title, presence: true
validates :title, length: { maximum: 256 }
validates :summary, length: { maximum: 255 }
@@ -46,6 +47,11 @@ class News < ApplicationRecord
references: :projects,
date_column: "#{table_name}.created_at"
+ acts_as_attachable view_permission: :view_news,
+ add_on_new_permission: :manage_news,
+ add_on_persisted_permission: :manage_news,
+ delete_permission: :manage_news
+
acts_as_watchable
after_create :add_author_as_watcher
diff --git a/app/models/project.rb b/app/models/project.rb
index cd7c1a4cf46a..1bb82198272f 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -40,7 +40,6 @@ class Project < ApplicationRecord
include ::Scopes::Scoped
-
# Maximum length for project identifiers
IDENTIFIER_MAX_LENGTH = 100
@@ -252,6 +251,13 @@ def enabled_module_names
enabled_modules.map(&:name)
end
+ def reload(*)
+ @allowed_permissions = nil
+ @allowed_actions = nil
+
+ super
+ end
+
def allowed_permissions
@allowed_permissions ||=
begin
diff --git a/app/models/queries/projects/project_queries/scopes/allowed_to.rb b/app/models/project_queries/scopes/allowed_to.rb
similarity index 98%
rename from app/models/queries/projects/project_queries/scopes/allowed_to.rb
rename to app/models/project_queries/scopes/allowed_to.rb
index 01b2dea5ca10..d1686cc37008 100644
--- a/app/models/queries/projects/project_queries/scopes/allowed_to.rb
+++ b/app/models/project_queries/scopes/allowed_to.rb
@@ -26,7 +26,7 @@
# See COPYRIGHT and LICENSE files for more details.
# ++
-module Queries::Projects::ProjectQueries::Scopes
+module ProjectQueries::Scopes
module AllowedTo
extend ActiveSupport::Concern
diff --git a/app/models/queries/projects/project_query.rb b/app/models/project_query.rb
similarity index 97%
rename from app/models/queries/projects/project_query.rb
rename to app/models/project_query.rb
index 9a1d7736d320..bf5aaaca35cc 100644
--- a/app/models/queries/projects/project_query.rb
+++ b/app/models/project_query.rb
@@ -26,7 +26,7 @@
# See COPYRIGHT and LICENSE files for more details.
#++
-class Queries::Projects::ProjectQuery < ApplicationRecord
+class ProjectQuery < ApplicationRecord
include Queries::BaseQuery
include Queries::Serialization::Hash
include HasMembers
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/models/queries/projects/factory.rb b/app/models/queries/projects/factory.rb
index bbc7dc19acaa..b4ead080663a 100644
--- a/app/models/queries/projects/factory.rb
+++ b/app/models/queries/projects/factory.rb
@@ -107,7 +107,7 @@ def static_query_status_at_risk
private
def list_with(name)
- Queries::Projects::ProjectQuery.new(name: I18n.t(name)) do |query|
+ ProjectQuery.new(name: I18n.t(name)) do |query|
query.order("lft" => "asc")
query.select(*Setting.enabled_projects_columns, add_not_existing: false)
@@ -133,7 +133,7 @@ def find_static_query_and_set_attributes(id, params, user, duplicate:)
end
def find_persisted_query_and_set_attributes(id, params, user, duplicate:)
- query = Queries::Projects::ProjectQuery.visible(user).find_by(id:)
+ query = ProjectQuery.visible(user).find_by(id:)
return unless query
@@ -150,7 +150,7 @@ def find_persisted_query_and_set_attributes(id, params, user, duplicate:)
end
def duplicate_query(query)
- Queries::Projects::ProjectQuery.new(query.attributes.slice("filters", "orders", "selects"))
+ ProjectQuery.new(query.attributes.slice("filters", "orders", "selects"))
end
def set_query_attributes(query, params, user)
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 749ce4dfbf44..0d885dd16a17 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -133,7 +133,7 @@ def respond_to_missing?(method_name, include_private = false)
private
def accessor_base_name(name)
- name.to_s.sub(/(_writable\?)|(\?)|=\z/, '')
+ name.to_s.sub(/(_writable\?)|(\?)|=\z/, "")
end
end
@@ -337,7 +337,7 @@ def self.deserialize(name, value)
if definition.serialized? && value.is_a?(String)
deserialize_hash(value)
- elsif value != ''.freeze && !value.nil?
+ elsif value != "".freeze && !value.nil?
read_formatted_setting(value, definition.format)
else
definition.format == :string ? value : nil
diff --git a/app/models/user.rb b/app/models/user.rb
index ca6f7e0e1924..dd3c1aaa3c3d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -26,11 +26,11 @@
# See COPYRIGHT and LICENSE files for more details.
#++
-require 'digest/sha1'
+require "digest/sha1"
class User < Principal
VALID_NAME_REGEX = /\A[\d\p{Alpha}\p{Mark}\p{Space}\p{Emoji}'’´\-_.,@()+&*–]+\z/
- CURRENT_USER_LOGIN_ALIAS = 'me'.freeze
+ CURRENT_USER_LOGIN_ALIAS = "me".freeze
USER_FORMATS_STRUCTURE = {
firstname_lastname: %i[firstname lastname],
firstname: [:firstname],
@@ -45,40 +45,40 @@ class User < Principal
include ::Users::PermissionChecks
extend DeprecatedAlias
- has_many :watches, class_name: 'Watcher',
+ has_many :watches, class_name: "Watcher",
dependent: :delete_all
has_many :changesets, dependent: :nullify
has_many :passwords, -> {
- order('id DESC')
- }, class_name: 'UserPassword',
+ order("id DESC")
+ }, class_name: "UserPassword",
dependent: :destroy,
inverse_of: :user
- has_one :rss_token, class_name: '::Token::RSS', dependent: :destroy
- has_one :api_token, class_name: '::Token::API', dependent: :destroy
+ has_one :rss_token, class_name: "::Token::RSS", dependent: :destroy
+ has_one :api_token, class_name: "::Token::API", dependent: :destroy
# The user might have one invitation token
- has_one :invitation_token, class_name: '::Token::Invitation', dependent: :destroy
+ has_one :invitation_token, class_name: "::Token::Invitation", dependent: :destroy
# everytime a user subscribes to a calendar, a new ical_token is generated
# unlike on other token types, all previously generated ical_tokens are kept
# in order to keep all previously generated ical urls valid and usable
- has_many :ical_tokens, class_name: '::Token::ICal', dependent: :destroy
+ has_many :ical_tokens, class_name: "::Token::ICal", dependent: :destroy
belongs_to :ldap_auth_source, optional: true
# Authorized OAuth grants
has_many :oauth_grants,
- class_name: 'Doorkeeper::AccessGrant',
- foreign_key: 'resource_owner_id'
+ class_name: "Doorkeeper::AccessGrant",
+ foreign_key: "resource_owner_id"
# User-defined oauth applications
has_many :oauth_applications,
- class_name: 'Doorkeeper::Application',
+ class_name: "Doorkeeper::Application",
as: :owner
# Meeting memberships
has_many :meeting_participants,
- class_name: 'MeetingParticipant',
+ class_name: "MeetingParticipant",
inverse_of: :user,
dependent: :destroy
@@ -86,7 +86,7 @@ class User < Principal
dependent: :destroy
has_many :project_queries,
- class_name: 'Queries::Projects::ProjectQuery',
+ class_name: "ProjectQuery",
inverse_of: :user,
dependent: :destroy
@@ -109,7 +109,7 @@ def self.create_blocked_scope(scope, blocked)
def self.blocked_condition(blocked)
block_duration = Setting.brute_force_block_minutes.to_i.minutes
blocked_if_login_since = Time.now - block_duration
- negation = blocked ? '' : 'NOT'
+ negation = blocked ? "" : "NOT"
["#{negation} (users.failed_login_count >= ? AND users.last_failed_login_on > ?)",
Setting.brute_force_block_after_failed_logins.to_i,
@@ -141,7 +141,7 @@ def self.blocked_condition(blocked)
validates :password,
confirmation: {
allow_nil: true,
- message: ->(*) { I18n.t('activerecord.errors.models.user.attributes.password_confirmation.confirmation') }
+ message: ->(*) { I18n.t("activerecord.errors.models.user.attributes.password_confirmation.confirmation") }
}
auto_strip_attributes :login, nullify: false
@@ -210,7 +210,7 @@ def self.try_to_login(login, password, session = nil)
# Tries to authenticate a user in the database via external auth source
# or password stored in the database
- def self.try_authentication_for_existing_user(user, password, session = nil)
+ def self.try_authentication_for_existing_user(user, password, session = nil) # rubocop:disable Metrics/PerceivedComplexity
activate_user! user, session if session
return nil if !user.active? || OpenProject::Configuration.disable_password_login?
@@ -255,7 +255,7 @@ def self.try_authentication_and_create_user(login, password)
# Returns the user who matches the given autologin +key+ or nil
def self.try_to_autologin(key)
- token = Token::AutoLogin.find_by_plaintext_value(key)
+ token = Token::AutoLogin.find_by_plaintext_value(key) # rubocop:disable Rails/DynamicFindBy
# Make sure there's only 1 token that matches the key
if token && ((token.created_at > Setting.autologin.to_i.day.ago) && token.user && token.user.active?)
token.user
@@ -295,7 +295,7 @@ def name(formatter = nil)
def authentication_provider
return if identity_url.blank?
- identity_url.split(':', 2).first.titleize
+ identity_url.split(":", 2).first.titleize
end
##
@@ -525,20 +525,20 @@ def missing_authentication_method?
# Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
# one anonymous user per database.
- def self.anonymous
+ def self.anonymous # rubocop:disable Metrics/AbcSize
RequestStore[:anonymous_user] ||= begin
anonymous_user = AnonymousUser.first
if anonymous_user.nil?
(anonymous_user = AnonymousUser.new.tap do |u|
- u.lastname = 'Anonymous'
- u.login = ''
- u.firstname = ''
- u.mail = ''
+ u.lastname = "Anonymous"
+ u.login = ""
+ u.firstname = ""
+ u.mail = ""
u.status = User.statuses[:active]
end).save
- raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
+ raise "Unable to create the anonymous user." if anonymous_user.new_record?
end
anonymous_user
end
@@ -560,7 +560,7 @@ def self.system
system_user.save(validate: false)
- raise 'Unable to create the automatic migration user.' unless system_user.persisted?
+ raise "Unable to create the automatic migration user." unless system_user.persisted?
end
system_user
@@ -586,7 +586,7 @@ def password_meets_requirements
if former_passwords_include?(password)
errors.add(:password,
- I18n.t('activerecord.errors.models.user.attributes.password.reused',
+ I18n.t("activerecord.errors.models.user.attributes.password.reused",
count: Setting[:password_count_former_banned].to_i))
end
end
@@ -596,7 +596,7 @@ def password_meets_requirements
def self.mail_regexp(mail)
separators = Regexp.escape(Setting.mail_suffix_separators)
- recipient, domain = mail.split('@').map { |part| Regexp.escape(part) }
+ recipient, domain = mail.split("@").map { |part| Regexp.escape(part) }
skip_suffix_check = recipient.nil? || Setting.mail_suffix_separators.empty? || recipient.match?(/.+[#{separators}].+/)
regexp = "^#{recipient}([#{separators}][^@]+)*@#{domain}$"
@@ -675,6 +675,6 @@ def log_failed_login_timestamp
end
def self.default_admin_account_changed?
- !User.active.find_by_login('admin').try(:current_password).try(:matches_plaintext?, 'admin')
+ !User.active.find_by_login("admin").try(:current_password).try(:matches_plaintext?, "admin") # rubocop:disable Rails/DynamicFindBy
end
end
diff --git a/app/models/users/permission_checks.rb b/app/models/users/permission_checks.rb
index 9fe321cb7d78..3c60d3377de8 100644
--- a/app/models/users/permission_checks.rb
+++ b/app/models/users/permission_checks.rb
@@ -106,14 +106,24 @@ def member_of?(project)
roles_for_project(project).any?(&:member?)
end
+ # Returns all permissions the user may have for a given context.
+ # "May" because this method does not check e.g. whether the module
+ # the permission belongs to is active.
def all_permissions_for(context)
- Authorization
- .roles(self, context)
- .includes(:role_permissions)
- .pluck(:permission)
- .compact
- .map(&:to_sym)
- .uniq
+ if admin?
+ OpenProject::AccessControl
+ .permissions
+ .select { |p| p.permissible_on?(context) && p.grant_to_admin? }
+ .map(&:name)
+ else
+ Authorization
+ .roles(self, context)
+ .includes(:role_permissions)
+ .pluck(:permission)
+ .compact
+ .map(&:to_sym)
+ .uniq
+ end
end
# Helper method to be used in places where we just throw anything into the permission check and don't know what
diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb
index 8b50adfa503e..d8b30f793406 100644
--- a/app/models/work_package/exports/macros/attributes.rb
+++ b/app/models/work_package/exports/macros/attributes.rb
@@ -77,16 +77,16 @@ def self.resolve_match(type, model_s, id, attribute, work_package, user)
elsif model_s == "project"
resolve_project_match(id || work_package.project.id, type, attribute, user)
else
- msg_macro_error I18n.t('export.macro.model_not_found', model: model_s)
+ msg_macro_error I18n.t("export.macro.model_not_found", model: model_s)
end
end
def self.msg_macro_error(message)
- msg_inline I18n.t('export.macro.error', message:)
+ msg_inline I18n.t("export.macro.error", message:)
end
def self.msg_macro_error_rich_text
- msg_inline I18n.t('export.macro.rich_text_unsupported')
+ msg_inline I18n.t("export.macro.rich_text_unsupported")
end
def self.msg_inline(message)
@@ -109,11 +109,11 @@ def self.resolve_label(model, attribute)
def self.resolve_work_package_match(id, type, attribute, user)
return resolve_label_work_package(attribute) if type == "label"
- return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value"
+ return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value"
work_package = WorkPackage.visible(user).find_by(id:)
if work_package.nil?
- return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}"))
+ return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{WorkPackage.name} #{id}"))
end
resolve_value_work_package(work_package, attribute)
diff --git a/app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb b/app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb
index 9fe3bbf88bca..54ee2e633820 100644
--- a/app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb
+++ b/app/models/work_package/pdf_export/gantt/gantt_builder_weeks.rb
@@ -30,7 +30,7 @@ module WorkPackage::PDFExport::Gantt
class GanttBuilderWeeks < GanttBuilder
def build_column_dates_range(range)
range
- .map { |d| [d.year, d.cweek] }
+ .map { |d| [d.cwyear, d.cweek] }
.uniq
.map { |year, week| Date.commercial(year, week, 1) }
end
diff --git a/app/models/work_package/pdf_export/markdown.rb b/app/models/work_package/pdf_export/markdown.rb
index 84cd5b1c54ad..03bdde5f7461 100644
--- a/app/models/work_package/pdf_export/markdown.rb
+++ b/app/models/work_package/pdf_export/markdown.rb
@@ -33,9 +33,10 @@ class MD2PDF
include MarkdownToPDF::Core
include MarkdownToPDF::Parser
- def initialize(styling_yml)
+ def initialize(styling_yml, pdf)
@styles = MarkdownToPDF::Styles.new(styling_yml)
init_options({ auto_generate_header_ids: false })
+ pdf_init_md2pdf_fonts(pdf)
# @hyphens = Hyphen.new('en', false)
end
@@ -96,7 +97,7 @@ def warn(text, element, node)
end
def write_markdown!(work_package, markdown)
- md2pdf = MD2PDF.new(styles.wp_markdown_styling_yml)
+ md2pdf = MD2PDF.new(styles.wp_markdown_styling_yml, pdf)
md2pdf.draw_markdown(markdown, pdf, ->(src) {
with_images? ? attachment_image_filepath(work_package, src) : nil
})
diff --git a/app/models/work_package/pdf_export/schema.json b/app/models/work_package/pdf_export/schema.json
index 3546fef553d9..50c64700b44a 100644
--- a/app/models/work_package/pdf_export/schema.json
+++ b/app/models/work_package/pdf_export/schema.json
@@ -392,7 +392,7 @@
"group_heading" : {
"type" : "object",
"title" : "Overview group heading",
- "description" : "Styling for the group lavel if grouping is activated",
+ "description" : "Styling for the group label if grouping is activated",
"x-example" : {
"group_heading" : {
"size" : 11,
@@ -981,6 +981,9 @@
"task_list_point" : {
"title" : "Markdown task list point",
"$ref" : "#/$defs/task_list_point"
+ },
+ "alerts": {
+ "$ref": "#/$defs/alerts"
}
},
"patternProperties" : {
@@ -1744,6 +1747,65 @@
"type" : "boolean"
}
}
+ },
+ "alert": {
+ "type": "object",
+ "title": "Alert",
+ "description": "Styling to denote a quote as alert box",
+ "x-example": {
+ "ALERT": {
+ "alert_color": "f4f9ff",
+ "border_color": "f4f9ff",
+ "border_width": 2,
+ "no_border_right": true,
+ "no_border_left": false,
+ "no_border_bottom": true,
+ "no_border_top": true
+ }
+ },
+ "properties": {
+ "background_color": {
+ "$ref": "#/$defs/color"
+ },
+ "alert_color": {
+ "$ref": "#/$defs/color"
+ }
+ },
+ "allOf": [
+ {
+ "$ref": "#/$defs/font"
+ },
+ {
+ "$ref": "#/$defs/border"
+ },
+ {
+ "$ref": "#/$defs/padding"
+ },
+ {
+ "$ref": "#/$defs/margin"
+ }
+ ]
+ },
+ "alerts": {
+ "type": "object",
+ "title": "alert boxes (styled blockquotes)",
+ "properties": {
+ "NOTE": {
+ "$ref": "#/$defs/alert"
+ },
+ "TIP": {
+ "$ref": "#/$defs/alert"
+ },
+ "WARNING": {
+ "$ref": "#/$defs/alert"
+ },
+ "IMPORTANT": {
+ "$ref": "#/$defs/alert"
+ },
+ "CAUTION": {
+ "$ref": "#/$defs/alert"
+ }
+ }
}
}
}
diff --git a/app/models/work_package/pdf_export/standard.yml b/app/models/work_package/pdf_export/standard.yml
index 9a514dbceba4..915d6dd37c2e 100644
--- a/app/models/work_package/pdf_export/standard.yml
+++ b/app/models/work_package/pdf_export/standard.yml
@@ -192,6 +192,62 @@ work_package:
size: 8
border_width: 0.25
padding: 5
+ alerts:
+ NOTE:
+ border_color: '0969da'
+ alert_color: '0969da'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ TIP:
+ border_color: '1a7f37'
+ alert_color: '1a7f37'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ IMPORTANT:
+ border_color: '8250df'
+ alert_color: '8250df'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ WARNING:
+ border_color: 'bf8700'
+ alert_color: 'bf8700'
+ padding: '4mm'
+ size: 10
+ styles: [ ]
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
+ CAUTION:
+ border_color: 'd1242f'
+ alert_color: 'd1242f'
+ size: 10
+ styles: [ ]
+ padding: '4mm'
+ border_width: 2
+ no_border_right: true
+ no_border_left: false
+ no_border_bottom: true
+ no_border_top: true
cover:
header:
diff --git a/app/services/authorization/user_permissible_service.rb b/app/services/authorization/user_permissible_service.rb
index 133a16a26523..a3c8e2629b14 100644
--- a/app/services/authorization/user_permissible_service.rb
+++ b/app/services/authorization/user_permissible_service.rb
@@ -9,7 +9,6 @@ def initialize(user)
def allowed_globally?(permission)
perms = contextual_permissions(permission, :global)
return false unless authorizable_user?
- return true if admin_and_all_granted_to_admin?(perms)
cached_permissions(nil).intersect?(perms.map(&:name))
end
@@ -19,9 +18,7 @@ def allowed_in_project?(permission, projects_to_check)
return false if projects_to_check.blank?
return false unless authorizable_user?
- projects = Array(projects_to_check)
-
- projects.all? do |project|
+ Array(projects_to_check).all? do |project|
allowed_in_single_project?(perms, project)
end
end
@@ -29,7 +26,6 @@ def allowed_in_project?(permission, projects_to_check)
def allowed_in_any_project?(permission)
perms = contextual_permissions(permission, :project)
return false unless authorizable_user?
- return true if admin_and_all_granted_to_admin?(perms)
cached_in_any_project?(perms)
end
@@ -51,7 +47,6 @@ def allowed_in_any_entity?(permission, entity_class, in_project: nil)
perms = contextual_permissions(permission, context_name(entity_class))
return false unless authorizable_user?
return false if in_project && !(in_project.active? || in_project.being_archived?)
- return true if admin_and_all_granted_to_admin?(perms)
if entity_is_project_scoped?(entity_class)
allowed_in_any_project_scoped_entity?(perms, entity_class, in_project:)
@@ -85,7 +80,6 @@ def allowed_in_single_project?(permissions, project)
permissions_filtered_for_project = permissions_by_enabled_project_modules(project, permissions)
return false if permissions_filtered_for_project.empty?
- return true if admin_and_all_granted_to_admin?(permissions)
cached_permissions(project).intersect?(permissions_filtered_for_project)
end
@@ -106,7 +100,6 @@ def allowed_in_single_project_scoped_entity?(permissions, entity)
permissions_filtered_for_project = permissions_by_enabled_project_modules(entity.project, permissions)
return false if permissions_filtered_for_project.empty?
- return true if admin_and_all_granted_to_admin?(permissions)
# The combination of this is better then doing
# EntityClass.allowed_to(user, permission).exists?.
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/news/create_service.rb b/app/services/news/create_service.rb
new file mode 100644
index 000000000000..97af280e9af4
--- /dev/null
+++ b/app/services/news/create_service.rb
@@ -0,0 +1,31 @@
+#-- 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.
+#++
+
+class News::CreateService < BaseServices::Create
+ include Attachments::ReplaceAttachments
+end
diff --git a/app/services/news/delete_service.rb b/app/services/news/delete_service.rb
new file mode 100644
index 000000000000..04d7b5381c83
--- /dev/null
+++ b/app/services/news/delete_service.rb
@@ -0,0 +1,30 @@
+#-- 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.
+#++
+
+class News::DeleteService < BaseServices::Delete
+end
diff --git a/app/services/news/set_attributes_service.rb b/app/services/news/set_attributes_service.rb
new file mode 100644
index 000000000000..b6274c28460c
--- /dev/null
+++ b/app/services/news/set_attributes_service.rb
@@ -0,0 +1,43 @@
+#-- 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.
+#++
+
+class News::SetAttributesService < BaseServices::SetAttributes
+ include Attachments::SetReplacements
+
+ private
+
+ def set_default_attributes(*)
+ set_default_author
+ end
+
+ def set_default_author
+ model.change_by_system do
+ model.author = user
+ end
+ end
+end
diff --git a/app/services/news/update_service.rb b/app/services/news/update_service.rb
new file mode 100644
index 000000000000..d0e1ef90f959
--- /dev/null
+++ b/app/services/news/update_service.rb
@@ -0,0 +1,31 @@
+#-- 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.
+#++
+
+class News::UpdateService < BaseServices::Update
+ include Attachments::ReplaceAttachments
+end
diff --git a/app/services/params_to_query_service.rb b/app/services/params_to_query_service.rb
index 11d93f2e6451..f117459b7a6f 100644
--- a/app/services/params_to_query_service.rb
+++ b/app/services/params_to_query_service.rb
@@ -123,7 +123,9 @@ def set_query_class(query_class, model)
else
model_name = model.name
- "::Queries::#{model_name.pluralize}::#{model_name.demodulize}Query".constantize
+ # Some queries exist as Queries::Models::ModelQuery others as ModelQuery
+ "::Queries::#{model_name.pluralize}::#{model_name.demodulize}Query".safe_constantize ||
+ "::#{model_name.demodulize}Query".constantize
end
end
end
diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb
index 9983adeb566a..72c3990f3a98 100644
--- a/app/services/projects/copy_service.rb
+++ b/app/services/projects/copy_service.rb
@@ -99,8 +99,9 @@ def after_perform(call)
end
def copy_activated_custom_fields(call)
- call.result.project_custom_field_ids = source.project_custom_field_ids
+ call.result.project_custom_field_ids = source.project_custom_field_ids
end
+
def contract_options
{ copy_source: source, validate_model: true }
end
diff --git a/app/services/queries/projects/project_queries/create_service.rb b/app/services/queries/projects/project_queries/create_service.rb
index 0aa6deea366c..7d8482ec9698 100644
--- a/app/services/queries/projects/project_queries/create_service.rb
+++ b/app/services/queries/projects/project_queries/create_service.rb
@@ -35,4 +35,8 @@ def initialize(from: nil, **)
def instance(_params)
@from || super
end
+
+ def instance_class
+ ProjectQuery
+ end
end
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/app/services/users/change_password_service.rb b/app/services/users/change_password_service.rb
index b6ccf84b0a29..b2f5c41f7abc 100644
--- a/app/services/users/change_password_service.rb
+++ b/app/services/users/change_password_service.rb
@@ -65,7 +65,7 @@ def call(params)
def invalidate_tokens
::Users::DropTokensService
- .new(current_user: current_user)
+ .new(current_user:)
.call!
end
diff --git a/app/services/users/drop_tokens_service.rb b/app/services/users/drop_tokens_service.rb
index 098f397e9ad2..56f79cb75c1f 100644
--- a/app/services/users/drop_tokens_service.rb
+++ b/app/services/users/drop_tokens_service.rb
@@ -46,11 +46,11 @@ def call!(clear_invitation_tokens: true)
private
def invalidate_recovery_tokens
- Token::Recovery.where(user: user).delete_all
+ Token::Recovery.where(user:).delete_all
end
def invalidate_invitation_tokens
- Token::Invitation.where(user: user).delete_all
+ Token::Invitation.where(user:).delete_all
end
end
end
diff --git a/app/views/admin/settings/new_project_settings/show.html.erb b/app/views/admin/settings/new_project_settings/show.html.erb
index 00cf388c8ba2..b1f60bb25c60 100644
--- a/app/views/admin/settings/new_project_settings/show.html.erb
+++ b/app/views/admin/settings/new_project_settings/show.html.erb
@@ -26,6 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
+<% html_title t(:label_administration), t(:label_project_new) %>
<%= toolbar title: t(:label_project_new) %>
<%= styled_form_tag(admin_settings_new_project_path, method: :patch) do %>
diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb
index 31d4eadf1ce8..6d078413f512 100644
--- a/app/views/admin/settings/project_custom_fields/edit.html.erb
+++ b/app/views/admin/settings/project_custom_fields/edit.html.erb
@@ -26,6 +26,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
+<% html_title t(:label_administration), t("settings.project_attributes.heading"), @custom_field.name %>
+
<% content_controller 'admin--custom-fields',
dynamic: true,
'admin--custom-fields-format-value': @custom_field.field_format
diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb
index 143135f47ec5..92fd5270f350 100644
--- a/app/views/admin/settings/project_custom_fields/new.html.erb
+++ b/app/views/admin/settings/project_custom_fields/new.html.erb
@@ -31,6 +31,7 @@ See COPYRIGHT and LICENSE files for more details.
'admin--custom-fields-format-value': @custom_field.field_format
%>
+<% html_title t(:label_administration), t("settings.project_attributes.heading"), t('settings.project_attributes.new.heading') %>
<% local_assigns[:additional_breadcrumb] = t('settings.project_attributes.new.heading') %>
<%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new) %>
diff --git a/app/views/admin/settings/project_custom_fields/project_mappings.html.erb b/app/views/admin/settings/project_custom_fields/project_mappings.html.erb
index 7699c86ac2b9..ed01914788ca 100644
--- a/app/views/admin/settings/project_custom_fields/project_mappings.html.erb
+++ b/app/views/admin/settings/project_custom_fields/project_mappings.html.erb
@@ -37,10 +37,15 @@ See COPYRIGHT and LICENSE files for more details.
<%=
render(Primer::OpenProject::SubHeader.new) do |component|
component.with_action_component do
- render(Settings::ProjectCustomFields::ProjectCustomFieldMapping::NewProjectMappingComponent.new(
- project_mapping: @project_mapping,
- project_custom_field: @custom_field
- ))
+ render(Primer::Beta::Button.new(
+ scheme: :primary,
+ tag: :a,
+ href: new_link_admin_settings_project_custom_field_path(@custom_field),
+ data: { controller: "async-dialog" }
+ )) do |button|
+ button.with_leading_visual_icon(icon: 'op-include-projects')
+ I18n.t("projects.settings.project_custom_fields.new_project_mapping_form.add_projects")
+ end
end
end unless @custom_field.required?
%>
diff --git a/app/views/admin/settings/projects_settings/show.html.erb b/app/views/admin/settings/projects_settings/show.html.erb
index f5b2d89f3150..f8ca16c9be30 100644
--- a/app/views/admin/settings/projects_settings/show.html.erb
+++ b/app/views/admin/settings/projects_settings/show.html.erb
@@ -26,6 +26,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See COPYRIGHT and LICENSE files for more details.
++#%>
+<% html_title t(:label_administration), t(:label_project_list_plural) %>
<%= toolbar title: t(:label_project_list_plural) %>
<%= styled_form_tag(admin_settings_projects_path, method: :patch) do %>
diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb
index f1b0039a02bf..bd686068abbb 100644
--- a/app/views/custom_fields/_form.html.erb
+++ b/app/views/custom_fields/_form.html.erb
@@ -224,8 +224,11 @@ See COPYRIGHT and LICENSE files for more details.
diff --git a/app/views/custom_styles/_inline_css_logo.erb b/app/views/custom_styles/_inline_css_logo.erb
index c08ea9449440..904e75831c21 100644
--- a/app/views/custom_styles/_inline_css_logo.erb
+++ b/app/views/custom_styles/_inline_css_logo.erb
@@ -51,6 +51,10 @@ See COPYRIGHT and LICENSE files for more details.
elsif CustomStyle.current.theme_logo.present?
logo_url = asset_path(CustomStyle.current.theme_logo)
end
+
+ if isRu && logo_url == asset_path("logo_openproject.png")
+ logo_url = asset_path("logo-black-bg-ua.png")
+ end
end
%>
diff --git a/app/views/custom_styles/_primer_color_mapping.erb b/app/views/custom_styles/_primer_color_mapping.erb
index e43f54162ab9..11b96023fb16 100644
--- a/app/views/custom_styles/_primer_color_mapping.erb
+++ b/app/views/custom_styles/_primer_color_mapping.erb
@@ -18,6 +18,8 @@
--button-primary-bgColor-hover: var(--button--primary-background-hover-color) !important;
--button-primary-bgColor-disabled: var(--button--primary-background-disabled-color) !important;
--button-primary-borderColor-disabled: var(--button--primary-border-disabled-color) !important;
+ --fc-page-bg-color: var(--body-background) !important;
+ --fc-list-event-hover-bg-color: var(--control-transparent-bgColor-hover) !important;
}
/* Generic color mapping for content variables */
@@ -79,6 +81,7 @@
/* For dark themes we are using a lighter version of the accent and primary color. Otherwise they will not be seen */
[data-dark-theme=dark] {
--accent-color: var(--accent-color--dark-mode);
+ --content-icon-color: var(--accent-color--dark-mode);
--primary-button-color: var(--primary-button-color--dark-mode);
--main-menu-bg-color: var(--overlay-bgColor);
}
diff --git a/app/views/groups/_memberships.html.erb b/app/views/groups/_memberships.html.erb
index 03364ee2adc1..af0297e0ae3f 100644
--- a/app/views/groups/_memberships.html.erb
+++ b/app/views/groups/_memberships.html.erb
@@ -30,7 +30,7 @@ See COPYRIGHT and LICENSE files for more details.
<% projects = Project.active.order(Arel.sql('lft')) %>
<% memberships = @group.memberships %>
-
+
<% if @group.memberships.any? %>
diff --git a/app/views/groups/_users.html.erb b/app/views/groups/_users.html.erb
index cb511e6762d4..4e7f11b2fe4d 100644
--- a/app/views/groups/_users.html.erb
+++ b/app/views/groups/_users.html.erb
@@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
-
+
<% if @group.users.any? %>
@@ -41,7 +41,7 @@ See COPYRIGHT and LICENSE files for more details.
<% end %>
-
+
<% if User.user
.not_in_group(@group)
.where(status: [User.statuses[:active], User.statuses[:invited]])
diff --git a/app/views/individual_principals/_memberships.html.erb b/app/views/individual_principals/_memberships.html.erb
index ae758fff8472..1b4b91cc54c5 100644
--- a/app/views/individual_principals/_memberships.html.erb
+++ b/app/views/individual_principals/_memberships.html.erb
@@ -33,7 +33,7 @@ See COPYRIGHT and LICENSE files for more details.
.order(Arel.sql('lft')) %>
<% memberships = @individual_principal.memberships.where(id: Member.visible(current_user)) %>
-
+
<% if memberships.any? %>
diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb
index de20141c7b9f..a469acb90f2e 100644
--- a/app/views/projects/index.html.erb
+++ b/app/views/projects/index.html.erb
@@ -27,7 +27,6 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<% html_title(t(:label_project_plural)) -%>
-
<%= render(
Projects::IndexPageHeaderComponent.new(
@@ -38,13 +37,10 @@ See COPYRIGHT and LICENSE files for more details.
)
)
%>
-
<%= render(Projects::IndexSubHeaderComponent.new(query:, current_user:, disable_buttons: state === :rename)) %>
-
<%= render Projects::TableComponent.new(
query:,
current_user: current_user,
params:) %>
-
<%= render Projects::DiskUsageInformationComponent.new(current_user: current_user) %>
diff --git a/app/views/work_packages/moves/new.html.erb b/app/views/work_packages/moves/new.html.erb
index f1edbadcd240..f46a4af1b4f5 100644
--- a/app/views/work_packages/moves/new.html.erb
+++ b/app/views/work_packages/moves/new.html.erb
@@ -42,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details.
<% end -%>
-<%= styled_form_tag({action: 'create'},
+<%= styled_form_tag({ action: 'create' },
id: 'move_form',
data: {
'controller': 'refresh-on-form-changes',
@@ -54,134 +54,147 @@ See COPYRIGHT and LICENSE files for more details.
<%= hidden_field_tag 'ids[]', wp.id %>
<% end %>
<%= back_url_hidden_field_tag %>
-
-