diff --git a/app/components/projects/index_page_header_component.html.erb b/app/components/projects/index_page_header_component.html.erb index fd16bd2637d3..e9a98772215c 100644 --- a/app/components/projects/index_page_header_component.html.erb +++ b/app/components/projects/index_page_header_component.html.erb @@ -1,7 +1,7 @@ <% if show_state? %> <%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title(data: { 'test-selector': 'project-query-name'}) { page_title } + header.with_title(data: { 'test-selector': 'project-query-name' }) { page_title } header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: current_breadcrumb_element == page_title ? :bold : :normal) if can_save? @@ -21,6 +21,17 @@ ) end + if can_access_shares? + header.with_action_icon_button( + tag: :a, + href: dialog_project_query_members_path(query), + icon: "share-android", + mobile_icon: "share-android", + label: t(:label_share), + data: { controller: "async-dialog" } + ) + end + if can_toggle_favor? if currently_favored? header.with_action_icon_button( @@ -115,29 +126,6 @@ end if query.persisted? - # TODO: Remove section when the sharing modal is implemented (https://community.openproject.org/projects/openproject/work_packages/55163) - if can_publish? - if query.public? - menu.with_item( - label: t(:button_unpublish), - scheme: :danger, - href: unpublish_project_query_path(query), - content_arguments: { data: { method: :post } } - ) do |item| - item.with_leading_visual_icon(icon: 'eye-closed') - end - else - menu.with_item( - label: t(:button_publish), - scheme: :default, - href: publish_project_query_path(query), - content_arguments: { data: { method: :post } } - ) do |item| - item.with_leading_visual_icon(icon: 'eye') - end - end - end - menu.with_item( label: t(:button_delete), scheme: :danger, @@ -155,7 +143,7 @@ <% else %> <%= render(Primer::OpenProject::PageHeader.new) do |header| - header.with_title(data: { 'test-selector': 'project-query-name'}) do + header.with_title(data: { 'test-selector': 'project-query-name' }) do primer_form_with(model: query, url: @query.new_record? ? project_queries_path(projects_query_params) : project_query_path(@query, projects_query_params), scope: 'query', diff --git a/app/components/projects/index_page_header_component.rb b/app/components/projects/index_page_header_component.rb index fadd93b97d9c..3754115e832d 100644 --- a/app/components/projects/index_page_header_component.rb +++ b/app/components/projects/index_page_header_component.rb @@ -77,11 +77,7 @@ def can_save? return false unless query.persisted? return false unless query.changed? - if query.public? - current_user.allowed_globally?(:manage_public_project_queries) - else - query.user == current_user - end + query.editable? end def can_rename? @@ -89,23 +85,17 @@ def can_rename? return false unless query.persisted? return false if query.changed? - if query.public? - current_user.allowed_globally?(:manage_public_project_queries) - else - query.user == current_user - end - end - - def can_publish? - OpenProject::FeatureDecisions.project_list_sharing_active? && - current_user.allowed_globally?(:manage_public_project_queries) && - query.persisted? + query.editable? end def show_state? state == :show end + def can_access_shares? + query.persisted? && OpenProject::FeatureDecisions.project_list_sharing_active? + end + def can_toggle_favor? = query.persisted? def currently_favored? = query.favored_by?(current_user) diff --git a/app/components/shares/bulk_permission_button_component.rb b/app/components/shares/bulk_permission_button_component.rb index f9a89e186737..b42c5de1dad5 100644 --- a/app/components/shares/bulk_permission_button_component.rb +++ b/app/components/shares/bulk_permission_button_component.rb @@ -30,11 +30,11 @@ module Shares class BulkPermissionButtonComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent - def initialize(entity:, available_roles:) + def initialize(strategy:) super - @entity = entity - @available_roles = available_roles + @entity = strategy.entity + @available_roles = strategy.available_roles end def update_path diff --git a/app/components/shares/counter_component.html.erb b/app/components/shares/counter_component.html.erb index e9281848a582..2e58b8d310de 100644 --- a/app/components/shares/counter_component.html.erb +++ b/app/components/shares/counter_component.html.erb @@ -4,7 +4,7 @@ # 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? + if strategy.manageable? && shared_with_anyone_else_other_than_myself? render(Shares::BulkSelectionCounterComponent.new(count:)) else render(Shares::ShareCounterComponent.new(count:)) diff --git a/app/components/shares/counter_component.rb b/app/components/shares/counter_component.rb index 145ee3b8c530..c2581bfb5dba 100644 --- a/app/components/shares/counter_component.rb +++ b/app/components/shares/counter_component.rb @@ -34,26 +34,22 @@ class CounterComponent < ApplicationComponent # rubocop:disable OpenProject/AddP include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(entity:, - count:, - sharing_manageable:) + def initialize(strategy:, count:) super - @entity = entity + @strategy = strategy + @entity = strategy.entity @count = count - @sharing_manageable = sharing_manageable end private - attr_reader :entity, :count - - def sharing_manageable? = @sharing_manageable + attr_reader :entity, :count, :strategy def shared_with_anyone_else_other_than_myself? Member.of_entity(@entity) .where.not(principal: User.current) - .any? + .exists? end end end diff --git a/app/components/shares/empty_state_component.html.erb b/app/components/shares/empty_state_component.html.erb new file mode 100644 index 000000000000..64e117e461b4 --- /dev/null +++ b/app/components/shares/empty_state_component.html.erb @@ -0,0 +1,15 @@ +<%= render(Primer::Beta::Blankslate.new) do |component| + component.with_visual_icon(icon: blankslate_config[:icon], size: :medium) + component.with_heading(tag: :h2).with_content( + blankslate_config[:heading_text], + ) + component.with_description do + flex_layout do |flex| + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :subtle)) do + blankslate_config[:description_text] + end + end + end + end +end %> diff --git a/app/components/shares/empty_state_component.rb b/app/components/shares/empty_state_component.rb new file mode 100644 index 000000000000..1dda0facba91 --- /dev/null +++ b/app/components/shares/empty_state_component.rb @@ -0,0 +1,68 @@ +#-- 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 EmptyStateComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpPrimer::ComponentHelpers + + def initialize(strategy:) + super + + @strategy = strategy + @entity = strategy.entity + end + + private + + attr_reader :strategy, :entity + + def blankslate_config + @blankslate_config ||= if params[:filters].blank? + unfiltered_blankslate_config + else + filtered_blankslate_config + end + end + + def unfiltered_blankslate_config + { + icon: :people, + heading_text: I18n.t("sharing.text_empty_state_header"), + description_text: I18n.t("sharing.text_empty_state_description", entity: @entity.class.model_name.human) + } + end + + def filtered_blankslate_config + { + icon: :search, + heading_text: I18n.t("sharing.text_empty_search_header"), + description_text: I18n.t("sharing.text_empty_search_description") + } + end + end +end diff --git a/app/components/shares/invite_user_form_component.html.erb b/app/components/shares/invite_user_form_component.html.erb index 5900c1a2ef47..6cfe10cc5061 100644 --- a/app/components/shares/invite_user_form_component.html.erb +++ b/app/components/shares/invite_user_form_component.html.erb @@ -1,17 +1,15 @@ <%= component_wrapper do - if @sharing_manageable + if strategy.manageable? primer_form_with( model: new_share, - url: url_for([@entity, Member]), - data: { controller: 'user-limit ' \ - 'shares--user-selected', + url: url_for([entity, Member]), + data: { controller: 'user-limit shares--user-selected', 'application-target': 'dynamic', 'user-limit-open-seats-value': OpenProject::Enterprise.open_seats_count, action: 'submit->shares--user-selected#ensureUsersSelected' } ) do |form| - grid_layout('invite-user-form', - tag: :div) do |invite_form| + grid_layout('invite-user-form', tag: :div) do |invite_form| invite_form.with_area('invitee') do render(Shares::Invitee.new(form)) end @@ -19,7 +17,7 @@ invite_form.with_area('permission') do render(Shares::PermissionButtonComponent.new( share: new_share, - available_roles: @available_roles, + available_roles: strategy.available_roles, form_arguments: { builder: form, name: "role_id" }, data: { 'test-selector': 'op-share-dialog-invite-role' }) ) @@ -46,7 +44,7 @@ I18n.t( "sharing.warning_user_limit_reached#{'_admin' if User.current.admin?}", upgrade_url: OpenProject::Enterprise.upgrade_url, - entity: @entity.model_name.human + entity: entity.model_name.human ).html_safe end end @@ -65,7 +63,7 @@ no_selected_user_row.with_column do render(Primer::Beta::Text.new(color: :danger)) do - I18n.t("sharing.warning_no_selected_user", entity: @entity.model_name.human) + I18n.t("sharing.warning_no_selected_user", entity: entity.model_name.human) end end end @@ -96,7 +94,7 @@ end end else - render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('sharing.denied', entities: @entity.model_name.human(count: 2)) } + 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 index 48ae8e0715ed..92cd7bf922bc 100644 --- a/app/components/shares/invite_user_form_component.rb +++ b/app/components/shares/invite_user_form_component.rb @@ -32,26 +32,24 @@ class InviteUserFormComponent < ApplicationComponent # rubocop:disable OpenProje include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(entity:, - available_roles:, - sharing_manageable:, - errors: nil) + attr_reader :entity, :strategy, :errors + + def initialize(strategy:, errors: nil) super - @entity = entity - @available_roles = available_roles - @sharing_manageable = sharing_manageable + @strategy = strategy + @entity = strategy.entity @errors = errors end def new_share - @new_share ||= Member.new(entity: @entity, roles: [Role.new(id: default_role[:value])]) + @new_share ||= Member.new(entity:, roles: [Role.new(id: default_role[:value])]) end private def default_role - @available_roles.find { |role_hash| role_hash[:default] } || @available_roles.first + strategy.available_roles.find { |role_hash| role_hash[:default] } || strategy.available_roles.first end end end diff --git a/app/components/shares/modal_body_component.html.erb b/app/components/shares/modal_body_component.html.erb index 97de19aeae13..a4cf6c39cb98 100644 --- a/app/components/shares/modal_body_component.html.erb +++ b/app/components/shares/modal_body_component.html.erb @@ -1,24 +1,25 @@ <%= component_wrapper(tag: 'turbo-frame') do - flex_layout(data: { turbo: true }) do |modal_content| + flex_layout(data: { turbo: true }, + classes: "op-share-dialog-modal-body--row-container") do |modal_content| + if strategy.custom_body_components? + strategy.additional_body_components.each do |component| + render(component.new(strategy: strategy, modal_body_container: modal_content)) + end + end + modal_content.with_row do - render(Shares::InviteUserFormComponent.new(entity: @entity, - available_roles: @available_roles, - sharing_manageable: @sharing_manageable, - errors: @errors)) + render(Shares::InviteUserFormComponent.new(strategy:, errors: errors)) end - modal_content.with_row(mt: 3, - data: { 'test-selector': 'op-share-dialog-active-list', + modal_content.with_row(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-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(Shares::CounterComponent.new(entity: @entity, - count: @shares.size, - sharing_manageable: @sharing_manageable)) + render(Shares::CounterComponent.new(strategy:, count: @shares.size)) end header_grid.with_area(:actions, @@ -28,6 +29,7 @@ header_actions.with_column(mr: 2) do render(Primer::Alpha::ActionMenu.new(anchor_align: :end, select_variant: :single, + size: :small, dynamic_label: true, dynamic_label_prefix: I18n.t('sharing.filter.type'), color: :muted, @@ -50,6 +52,7 @@ header_actions.with_column do render(Primer::Alpha::ActionMenu.new(anchor_align: :end, select_variant: :single, + size: :small, dynamic_label: true, dynamic_label_prefix: I18n.t('sharing.filter.role'), color: :muted, @@ -58,7 +61,7 @@ button.with_trailing_action_icon(icon: "triangle-down") I18n.t('sharing.filter.role') end - @available_roles.each do |role_hash| + strategy.available_roles.each do |role_hash| menu.with_item(label: role_hash[:label], href: filter_url(role_option: role_hash), method: :get, @@ -75,13 +78,11 @@ tag: :div, hidden: true, # Prevent flicker on initial render data: { 'shares--bulk-selection-target': 'bulkActions' }) do - if @sharing_manageable - concat( - render(Shares::BulkPermissionButtonComponent.new(entity: @entity, available_roles: @available_roles)) - ) + if strategy.manageable? + concat(render(Shares::BulkPermissionButtonComponent.new(strategy:))) concat( - form_with(url: url_for([:bulk, @entity, Member]), + form_with(url: url_for([:bulk, entity, Member]), method: :delete, data: { 'shares--bulk-selection-target': 'bulkForm' }) do render(Primer::Beta::IconButton.new(icon: "trash", @@ -96,26 +97,17 @@ end end - if @shares.blank? + if @shares.none? border_box.with_row do - render(Primer::Beta::Blankslate.new) do |component| - component.with_visual_icon(icon: blankslate_config[:icon], size: :medium) - component.with_heading(tag: :h2).with_content(blankslate_config[:heading_text]) - component.with_description do - flex_layout do |flex| - flex.with_row(mb: 2) do - render(Primer::Beta::Text.new(color: :subtle)) { blankslate_config[:description_text] } - end - end - end + if strategy.custom_empty_state_component? + render(strategy.empty_state_component.new(strategy: strategy)) + else + render(Shares::EmptyStateComponent.new(strategy: strategy)) end end else @shares.each do |share| - render(Shares::ShareRowComponent.new(share: share, - available_roles: @available_roles, - sharing_manageable: @sharing_manageable, - container: border_box)) + render(Shares::ShareRowComponent.new(share:, strategy:, container: border_box)) end end end diff --git a/app/components/shares/modal_body_component.rb b/app/components/shares/modal_body_component.rb index 588f60d32016..231cb4bd070a 100644 --- a/app/components/shares/modal_body_component.rb +++ b/app/components/shares/modal_body_component.rb @@ -33,23 +33,17 @@ class ModalBodyComponent < ApplicationComponent # rubocop:disable OpenProject/Ad include OpTurbo::Streamable include OpPrimer::ComponentHelpers - attr_reader :entity, + attr_reader :strategy, + :entity, :shares, - :available_roles, - :sharing_manageable, :errors - def initialize(entity:, - shares:, - available_roles:, - sharing_manageable:, - errors: nil) + def initialize(strategy:, shares:, errors: nil) super - @entity = entity + @strategy = strategy + @entity = strategy.entity @shares = shares - @available_roles = available_roles - @sharing_manageable = sharing_manageable @errors = errors end @@ -71,20 +65,6 @@ 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? [ @@ -106,15 +86,16 @@ def type_filter_options end end - def type_filter_option_active?(option) + def type_filter_option_active?(option) # rubocop:disable Metrics/AbcSize 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? + return false if principal_type_filter_value.nil? + return false if project_scoped_entity? && project_member_filter_value.nil? principal_type_checked = option[:value][:principal_type] == principal_type_filter_value - membership_selected = + membership_selected = !project_scoped_entity? || option[:value][:project_member] == ActiveRecord::Type::Boolean.new.cast(project_member_filter_value) principal_type_checked && membership_selected @@ -125,7 +106,7 @@ def role_filter_option_active?(option) return false if role_filter_value.nil? - selected_role = @available_roles.find { _1[:value] == option[:value] } + selected_role = strategy.available_roles.find { _1[:value] == option[:value] } selected_role[:value] == role_filter_value.to_i end @@ -183,14 +164,18 @@ def apply_type_filter(option) 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] } }) + filter = [ + { principal_type: { operator: "=", values: [option[:value][:principal_type]] } } + ] + + if project_scoped_entity? + 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 end - filter.push({ principal_type: { operator: "=", values: [option[:value][:principal_type]] } }) filter end diff --git a/app/components/shares/modal_body_component.sass b/app/components/shares/modal_body_component.sass index 17f99bc4aab1..44f6e8260e20 100644 --- a/app/components/shares/modal_body_component.sass +++ b/app/components/shares/modal_body_component.sass @@ -1,4 +1,7 @@ .op-share-dialog-modal-body + &--row-container + gap: 16px + &--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/shares/permission_button_component.html.erb b/app/components/shares/permission_button_component.html.erb index 6402891c82ad..24a63a92b6d9 100644 --- a/app/components/shares/permission_button_component.html.erb +++ b/app/components/shares/permission_button_component.html.erb @@ -1,6 +1,7 @@ <%= component_wrapper do render(Primer::Alpha::ActionMenu.new(**{ select_variant: :single, + size: :small, dynamic_label: true, anchor_align: :end, color: :subtle }.deep_merge(@system_arguments))) do |menu| diff --git a/app/components/shares/project_queries/empty_state_component.html.erb b/app/components/shares/project_queries/empty_state_component.html.erb new file mode 100644 index 000000000000..64e117e461b4 --- /dev/null +++ b/app/components/shares/project_queries/empty_state_component.html.erb @@ -0,0 +1,15 @@ +<%= render(Primer::Beta::Blankslate.new) do |component| + component.with_visual_icon(icon: blankslate_config[:icon], size: :medium) + component.with_heading(tag: :h2).with_content( + blankslate_config[:heading_text], + ) + component.with_description do + flex_layout do |flex| + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :subtle)) do + blankslate_config[:description_text] + end + end + end + end +end %> diff --git a/app/components/shares/project_queries/empty_state_component.rb b/app/components/shares/project_queries/empty_state_component.rb new file mode 100644 index 000000000000..b02fcb9d8dd6 --- /dev/null +++ b/app/components/shares/project_queries/empty_state_component.rb @@ -0,0 +1,80 @@ +#-- 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 + module ProjectQueries + class EmptyStateComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpPrimer::ComponentHelpers + + def initialize(strategy:) + super + + @strategy = strategy + @entity = strategy.entity + end + + private + + attr_reader :strategy, :entity + + def blankslate_config + @blankslate_config ||= if entity.public? + public_blankslate_config + elsif params[:filters].blank? + unfiltered_blankslate_config + else + filtered_blankslate_config + end + end + + def public_blankslate_config + { + icon: :people, + heading_text: I18n.t("sharing.project_queries.blank_state.public.header"), + description_text: I18n.t("sharing.project_queries.blank_state.public.description") + } + end + + def unfiltered_blankslate_config + { + icon: :people, + heading_text: I18n.t("sharing.project_queries.blank_state.private.header"), + description_text: I18n.t("sharing.project_queries.blank_state.private.description") + } + end + + def filtered_blankslate_config + { + icon: :search, + heading_text: I18n.t("sharing.text_empty_search_header"), + description_text: I18n.t("sharing.text_empty_search_description") + } + end + end + end +end diff --git a/app/components/shares/project_queries/project_access_warning_component.html.erb b/app/components/shares/project_queries/project_access_warning_component.html.erb new file mode 100644 index 000000000000..629a8b7b9dca --- /dev/null +++ b/app/components/shares/project_queries/project_access_warning_component.html.erb @@ -0,0 +1,3 @@ +<%- if query_is_public? || query_is_shared? %> + <%= container.with_row { render(Primer::Alpha::Banner.new(icon: :info)) { I18n.t('sharing.project_queries.access_warning') } }%> +<%- end %> diff --git a/app/components/shares/project_queries/project_access_warning_component.rb b/app/components/shares/project_queries/project_access_warning_component.rb new file mode 100644 index 000000000000..97b76a75d61f --- /dev/null +++ b/app/components/shares/project_queries/project_access_warning_component.rb @@ -0,0 +1,26 @@ +module Shares + module ProjectQueries + class ProjectAccessWarningComponent < ViewComponent::Base # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpPrimer::ComponentHelpers + + def initialize(strategy:, modal_body_container:) + super + + @strategy = strategy + @container = modal_body_container + end + + private + + attr_reader :strategy, :container + + def query_is_public? + @strategy.entity.public? + end + + def query_is_shared? + @strategy.entity.members.any? + end + end + end +end diff --git a/app/components/shares/project_queries/public_flag_component.html.erb b/app/components/shares/project_queries/public_flag_component.html.erb new file mode 100644 index 000000000000..aec171db24e8 --- /dev/null +++ b/app/components/shares/project_queries/public_flag_component.html.erb @@ -0,0 +1,14 @@ +<%= +container.with_row do + render(Primer::Forms::ToggleSwitchForm.new( + name: "publish_project_query", + label: t('sharing.project_queries.public_flag.label', instance_name: Setting.app_title), + caption: t('sharing.project_queries.public_flag.caption'), + src: toggle_public_flag_link, + csrf_token: form_authenticity_token, + status_label_position: :start, + checked: published?, + enabled: can_publish? + )) +end +%> diff --git a/app/components/shares/project_queries/public_flag_component.rb b/app/components/shares/project_queries/public_flag_component.rb new file mode 100644 index 000000000000..813c5caa2a11 --- /dev/null +++ b/app/components/shares/project_queries/public_flag_component.rb @@ -0,0 +1,61 @@ +# 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 + module ProjectQueries + class PublicFlagComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(strategy:, modal_body_container:) + super + + @strategy = strategy + @container = modal_body_container + end + + private + + attr_reader :strategy, :container + + def toggle_public_flag_link + toggle_public_project_query_path(strategy.entity) + end + + def published? + strategy.entity.public? + end + + def can_publish? + User.current.allowed_globally?(:manage_public_project_queries) + end + end + end +end diff --git a/app/components/shares/share_dialog_component.html.erb b/app/components/shares/share_dialog_component.html.erb new file mode 100644 index 000000000000..df84ba60efd5 --- /dev/null +++ b/app/components/shares/share_dialog_component.html.erb @@ -0,0 +1,8 @@ +<%= + render(Primer::Alpha::Dialog.new(title: t(:label_share_project_list), id: 'sharing-modal', data: { 'keep-open-on-submit': true }, size: :xlarge)) do |d| + d.with_header(variant: :large, mb: 3) + d.with_body do + render(Shares::ModalBodyComponent.new(strategy:, shares:, errors:)) + end + end +%> diff --git a/app/components/shares/share_dialog_component.rb b/app/components/shares/share_dialog_component.rb new file mode 100644 index 000000000000..702f5adc5567 --- /dev/null +++ b/app/components/shares/share_dialog_component.rb @@ -0,0 +1,49 @@ +# 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 ShareDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(shares:, strategy:, errors:) + super + + @shares = shares + @strategy = strategy + @errors = errors + end + + private + + attr_reader :shares, :strategy, :errors + end +end diff --git a/app/components/shares/share_row_component.rb b/app/components/shares/share_row_component.rb index 8f9a1dd6e53f..1a4376af63de 100644 --- a/app/components/shares/share_row_component.rb +++ b/app/components/shares/share_row_component.rb @@ -34,17 +34,14 @@ class ShareRowComponent < ApplicationComponent # rubocop:disable OpenProject/Add include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(share:, - available_roles:, - sharing_manageable:, - container: nil) + def initialize(share:, strategy:, container: nil) super @share = share - @entity = share.entity + @strategy = strategy + @entity = strategy.entity @principal = share.principal - @available_roles = available_roles - @sharing_manageable = sharing_manageable + @available_roles = strategy.available_roles @container = container end @@ -54,13 +51,15 @@ def wrapper_uniq_by private - attr_reader :share, :entity, :principal, :container, :available_roles + attr_reader :share, :entity, :principal, :container, :available_roles, :strategy def share_editable? @share_editable ||= User.current != share.principal && sharing_manageable? end - def sharing_manageable? = @sharing_manageable + def sharing_manageable? + strategy.manageable? + end def grid_css_classes if sharing_manageable? diff --git a/app/controllers/concerns/shares/work_packages/authorization.rb b/app/contracts/shares/project_queries/base_extension.rb similarity index 72% rename from app/controllers/concerns/shares/work_packages/authorization.rb rename to app/contracts/shares/project_queries/base_extension.rb index 32b4bf5f34e0..7392139fe6f3 100644 --- a/app/controllers/concerns/shares/work_packages/authorization.rb +++ b/app/contracts/shares/project_queries/base_extension.rb @@ -29,23 +29,18 @@ # ++ module Shares - module WorkPackages - module Authorization + module ProjectQueries + module BaseExtension 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 + private + + def user_allowed_to_manage? + model.entity.editable? + end + + def assignable_role_class + ProjectQueryRole end end end diff --git a/app/contracts/shares/project_queries/create_contract.rb b/app/contracts/shares/project_queries/create_contract.rb new file mode 100644 index 000000000000..d9ce1351d68d --- /dev/null +++ b/app/contracts/shares/project_queries/create_contract.rb @@ -0,0 +1,35 @@ +# -- 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 ProjectQueries + class CreateContract < Shares::CreateContract + include Shares::ProjectQueries::BaseExtension + end + end +end diff --git a/app/contracts/shares/project_queries/delete_contract.rb b/app/contracts/shares/project_queries/delete_contract.rb new file mode 100644 index 000000000000..925dc2d18e76 --- /dev/null +++ b/app/contracts/shares/project_queries/delete_contract.rb @@ -0,0 +1,37 @@ +# -- 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 ProjectQueries + 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 -> { model.entity.editable? } + end + end +end diff --git a/app/contracts/shares/project_queries/update_contract.rb b/app/contracts/shares/project_queries/update_contract.rb new file mode 100644 index 000000000000..eac81bc4e190 --- /dev/null +++ b/app/contracts/shares/project_queries/update_contract.rb @@ -0,0 +1,35 @@ +# -- 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 ProjectQueries + class UpdateContract < Shares::UpdateContract + include Shares::ProjectQueries::BaseExtension + end + end +end diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb index 0a0a3179c56f..7fbba42d4f5a 100644 --- a/app/controllers/members_controller.rb +++ b/app/controllers/members_controller.rb @@ -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("shares", :update), - authorize_work_package_shares_delete: authorize_for("shares", :bulk_destroy), + authorize_work_package_shares_view: current_user.allowed_in_project?(:view_shared_work_packages, @project), + authorize_work_package_shares_delete: current_user.allowed_in_project?(:share_work_packages, @project), authorize_manage_user: current_user.allowed_globally?(:manage_user), is_filtered: Members::UserFilterComponent.filtered?(params), shared_role_name: diff --git a/app/controllers/projects/queries_controller.rb b/app/controllers/projects/queries_controller.rb index abbc67b2c4fc..e1111712033b 100644 --- a/app/controllers/projects/queries_controller.rb +++ b/app/controllers/projects/queries_controller.rb @@ -30,9 +30,9 @@ class Projects::QueriesController < ApplicationController include Projects::QueryLoading # No need for a more specific authorization check. That is carried out in the contracts. - no_authorization_required! :show, :new, :create, :rename, :update, :publish, :unpublish, :destroy + no_authorization_required! :show, :new, :create, :rename, :update, :toggle_public, :destroy before_action :require_login - before_action :find_query, only: %i[show rename update destroy publish unpublish] + before_action :find_query, only: %i[show rename update destroy toggle_public] before_action :build_query_or_deny_access, only: %i[new create] current_menu_item [:new, :rename, :create, :update] do @@ -71,20 +71,15 @@ def update render_result(call, success_i18n_key: "lists.update.success", error_i18n_key: "lists.update.failure") end - def publish - call = Queries::Projects::ProjectQueries::PublishService - .new(user: current_user, model: @query) - .call(public: true) - - render_result(call, success_i18n_key: "lists.publish.success", error_i18n_key: "lists.publish.failure") - end + def toggle_public + to_be_public = !@query.public? + i18n_key = to_be_public ? "lists.publish" : "lists.unpublish" - def unpublish call = Queries::Projects::ProjectQueries::PublishService .new(user: current_user, model: @query) - .call(public: false) + .call(public: to_be_public) - render_result(call, success_i18n_key: "lists.unpublish.success", error_i18n_key: "lists.unpublish.failure") + render_result(call, success_i18n_key: "#{i18n_key}.success", error_i18n_key: "#{i18n_key}.failure") end def destroy diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index 2de1aef7aeda..5eebc6cef77e 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -28,26 +28,27 @@ class SharesController < ApplicationController include OpTurbo::ComponentStream - include Shares::WorkPackages::Authorization + include OpTurbo::DialogStreamHelper include MemberHelper before_action :load_entity - before_action :load_shares, only: %i[index] + before_action :load_shares, only: %i[index dialog] 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] + before_action :check_if_manageable, except: %i[index dialog] + before_action :check_if_viewable, only: %i[index dialog] + authorization_checked! :dialog, :index, :create, :update, :destroy, :resend_invite, :bulk_update, :bulk_destroy + + def dialog; end + 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 + render Shares::ModalBodyComponent.new(strategy: sharing_strategy, shares: @shares, errors: @errors), layout: nil end def create # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity @@ -129,6 +130,20 @@ def bulk_destroy private + attr_reader :sharing_strategy + + def check_if_viewable + return if sharing_strategy.viewable? || sharing_strategy.manageable? + + render_403 + end + + def check_if_manageable + return if sharing_strategy.manageable? + + render_403 + end + def enterprise_check return if EnterpriseToken.allows_to?(:work_package_sharing) @@ -137,26 +152,23 @@ def enterprise_check def destroy_share(share) Shares::DeleteService - .new(user: current_user, model: share, contract_class: sharing_contract_scope::DeleteContract) + .new(user: current_user, model: share, contract_class: sharing_strategy.delete_contract_class) .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:) + create_contract_class: sharing_strategy.create_contract_class, + update_contract_class: sharing_strategy.update_contract_class + ).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:, + strategy: sharing_strategy, shares: @new_shares || load_shares, - sharing_manageable: sharing_manageable?, errors: @errors ) ) @@ -164,21 +176,18 @@ def respond_with_replace_modal respond_with_turbo_streams end - def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize + def respond_with_prepend_shares replace_via_turbo_stream( component: Shares::InviteUserFormComponent.new( - entity: @entity, - available_roles:, - sharing_manageable: sharing_manageable?, + strategy: sharing_strategy, errors: @errors ) ) update_via_turbo_stream( component: Shares::CounterComponent.new( - entity: @entity, - count: current_visible_member_count, - sharing_manageable: sharing_manageable? + strategy: sharing_strategy, + count: current_visible_member_count ) ) @@ -186,13 +195,10 @@ def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize prepend_via_turbo_stream( component: Shares::ShareRowComponent.new( share:, - available_roles:, - sharing_manageable: sharing_manageable? + strategy: sharing_strategy ), target_component: Shares::ModalBodyComponent.new( - entity: @entity, - available_roles:, - sharing_manageable: sharing_manageable?, + strategy: sharing_strategy, shares: load_shares, errors: @errors ) @@ -205,9 +211,7 @@ def respond_with_prepend_shares # rubocop:disable Metrics/AbcSize def respond_with_new_invite_form replace_via_turbo_stream( component: Shares::InviteUserFormComponent.new( - entity: @entity, - available_roles:, - sharing_manageable: sharing_manageable?, + strategy: sharing_strategy, errors: @errors ) ) @@ -219,7 +223,7 @@ def respond_with_update_permission_button replace_via_turbo_stream( component: Shares::PermissionButtonComponent.new( share: @share, - available_roles:, + available_roles: sharing_strategy.available_roles, data: { "test-selector": "op-share-dialog-update-role" } ) ) @@ -231,15 +235,13 @@ def respond_with_remove_share remove_via_turbo_stream( component: Shares::ShareRowComponent.new( share: @share, - available_roles:, - sharing_manageable: sharing_manageable? + strategy: sharing_strategy ) ) update_via_turbo_stream( component: Shares::CounterComponent.new( - entity: @entity, - count: current_visible_member_count, - sharing_manageable: sharing_manageable? + strategy: sharing_strategy, + count: current_visible_member_count ) ) @@ -250,7 +252,7 @@ def respond_with_update_user_details update_via_turbo_stream( component: Shares::UserDetailsComponent.new( share: @share, - manager_mode: sharing_manageable?, + manager_mode: sharing_strategy.manageable?, invite_resent: true ) ) @@ -263,7 +265,7 @@ def respond_with_bulk_updated_permission_buttons replace_via_turbo_stream( component: Shares::PermissionButtonComponent.new( share:, - available_roles:, + available_roles: sharing_strategy.available_roles, data: { "test-selector": "op-share-dialog-update-role" } ) ) @@ -277,33 +279,38 @@ def respond_with_bulk_removed_shares remove_via_turbo_stream( component: Shares::ShareRowComponent.new( share:, - available_roles:, - sharing_manageable: sharing_manageable? + strategy: sharing_strategy ) ) end update_via_turbo_stream( component: Shares::CounterComponent.new( - entity: @entity, count: current_visible_member_count, - sharing_manageable: sharing_manageable? + strategy: sharing_strategy ) ) 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 + def load_entity # rubocop:disable Metrics/AbcSize + if params["work_package_id"] + @entity = WorkPackage.visible.find(params["work_package_id"]) + @sharing_strategy = SharingStrategies::WorkPackageStrategy.new(@entity, user: current_user) + elsif params["project_query_id"] + @entity = ProjectQuery.visible.find(params["project_query_id"]) + @sharing_strategy = SharingStrategies::ProjectQueryStrategy.new(@entity, user: current_user) + 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 and specify what sharing + strategy should be applied. + + Params: #{params.to_unsafe_h} + Request Path: #{request.path} + ERROR + end if @entity.respond_to?(:project) @project = @entity.project @@ -322,8 +329,8 @@ def load_query return @query if defined?(@query) @query = ParamsToQueryService - .new(Member, current_user, query_class: Queries::Members::EntityMemberQuery) - .call(params) + .new(Member, current_user, query_class: Queries::Members::NonInheritedMemberQuery) + .call(params) # Set default filter on the entity @query.where("entity_id", "=", @entity.id) @@ -346,31 +353,4 @@ def load_selected_shares .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/forms/shares/invitee.rb b/app/forms/shares/invitee.rb index e6857c141cd0..61d5a3d41aba 100644 --- a/app/forms/shares/invitee.rb +++ b/app/forms/shares/invitee.rb @@ -50,7 +50,7 @@ class Invitee < ApplicationForm addTagText: I18n.t("members.send_invite_to"), multiple: true, focusDirectly: true, - appendTo: "body", + appendToComponent: true, disabled: @disabled } ) diff --git a/app/menus/projects/menu.rb b/app/menus/projects/menu.rb index e7cc468af97c..affc4ecbc9f3 100644 --- a/app/menus/projects/menu.rb +++ b/app/menus/projects/menu.rb @@ -108,14 +108,15 @@ def public_filters def my_filters persisted_filters + .reject(&:public?) .select { |query| query.user == current_user } .map { |query| menu_item(query.name, query_id: query.id) } end def shared_filters persisted_filters - .select { |query| !query.public? && query.user != current_user } - # query is not public and not owned by the user, so it must be shared with them + .reject(&:public?) + .reject { |query| query.user == current_user } .map { |query| menu_item(query.name, query_id: query.id) } end diff --git a/app/models/members/scopes/of_entity.rb b/app/models/members/scopes/of_entity.rb index b0082ce97547..4ebf97108f6b 100644 --- a/app/models/members/scopes/of_entity.rb +++ b/app/models/members/scopes/of_entity.rb @@ -33,8 +33,11 @@ module OfEntity class_methods do # Find all members of a specific Work Package def of_entity(entity) - of_any_entity - .where(entity:) + if entity.respond_to?(:project) + where(project: entity.project, entity:) + else + where(project: nil, entity:) + end end end end diff --git a/app/models/queries/members.rb b/app/models/queries/members.rb index 98228d61265e..e1b56bfe2c6e 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(EntityMemberQuery) do + ::Queries::Register.register(NonInheritedMemberQuery) do filter Filters::NameFilter filter Filters::AnyNameAttributeFilter filter Filters::ProjectFilter diff --git a/app/models/queries/members/filters/entity_type_filter.rb b/app/models/queries/members/filters/entity_type_filter.rb index 5ddab94d13fa..60f3be86a865 100644 --- a/app/models/queries/members/filters/entity_type_filter.rb +++ b/app/models/queries/members/filters/entity_type_filter.rb @@ -32,7 +32,10 @@ def type end def allowed_values - [[WorkPackage.name, WorkPackage.name]] + [ + [WorkPackage.name, WorkPackage.name], + [ProjectQuery.name, ProjectQuery.name] + ] end def self.key diff --git a/app/models/queries/members/entity_member_query.rb b/app/models/queries/members/non_inherited_member_query.rb similarity index 94% rename from app/models/queries/members/entity_member_query.rb rename to app/models/queries/members/non_inherited_member_query.rb index 27714076f0e1..87349f0d1f85 100644 --- a/app/models/queries/members/entity_member_query.rb +++ b/app/models/queries/members/non_inherited_member_query.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class Queries::Members::EntityMemberQuery < Queries::Members::MemberQuery +class Queries::Members::NonInheritedMemberQuery < Queries::Members::MemberQuery def default_scope Member.joins(:member_roles).merge(MemberRole.only_non_inherited) end diff --git a/app/models/sharing_strategies/base_strategy.rb b/app/models/sharing_strategies/base_strategy.rb new file mode 100644 index 000000000000..e28dbf2e55a8 --- /dev/null +++ b/app/models/sharing_strategies/base_strategy.rb @@ -0,0 +1,83 @@ +#-- 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 SharingStrategies + class BaseStrategy + attr_reader :entity, :user + + def initialize(entity, user: User.current) + @entity = entity + @user = user + end + + def available_roles + # format: [{ label: "Role name", value: 42, description: "Role description", default: true }] + raise NotImplementedError, "Override in a subclass and return an array of roles that should be displayed" + end + + def viewable? + raise NotImplementedError, + "Override in a subclass and return true if the current user can view who the entity is shared with" + end + + def manageable? + raise NotImplementedError, "Override in a subclass and return true if the current user can manage sharing" + end + + def create_contract_class + raise NotImplementedError, "Override in a subclass and return the contract class for creating a share" + end + + def update_contract_class + raise NotImplementedError, "Override in a subclass and return the contract class for updating a share" + end + + def delete_contract_class + raise NotImplementedError, "Override in a subclass and return the contract class for deleting a share" + end + + def custom_body_components? + !additional_body_components.empty? + end + + # Override by returning a list of component classes that should be rendered in the sharing dialog above the table of shares + def additional_body_components + [] + end + + def custom_empty_state_component? + empty_state_component.present? + end + + # Override by returning a component class that should be rendered in the sharing dialog instead of the table of shares + # when there is no share yet + def empty_state_component + nil + end + end +end diff --git a/app/models/sharing_strategies/project_query_strategy.rb b/app/models/sharing_strategies/project_query_strategy.rb new file mode 100644 index 000000000000..574a41ec3926 --- /dev/null +++ b/app/models/sharing_strategies/project_query_strategy.rb @@ -0,0 +1,80 @@ +#-- 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 SharingStrategies + class ProjectQueryStrategy < BaseStrategy + def available_roles + role_mapping = ProjectQueryRole.pluck(:builtin, :id).to_h + + [ + { + label: I18n.t("sharing.project_queries.permissions.edit"), + value: role_mapping[Role::BUILTIN_PROJECT_QUERY_EDIT], + description: I18n.t("sharing.project_queries.permissions.edit_description") + }, + { + label: I18n.t("sharing.project_queries.permissions.view"), + value: role_mapping[Role::BUILTIN_PROJECT_QUERY_VIEW], + description: I18n.t("sharing.project_queries.permissions.view_description"), + default: true + } + ] + end + + def manageable? + @entity.editable? + end + + def viewable? + @entity.visible? + end + + def create_contract_class + Shares::ProjectQueries::CreateContract + end + + def update_contract_class + Shares::ProjectQueries::UpdateContract + end + + def delete_contract_class + Shares::ProjectQueries::DeleteContract + end + + def additional_body_components + [ + Shares::ProjectQueries::PublicFlagComponent, + Shares::ProjectQueries::ProjectAccessWarningComponent + ] + end + + def empty_state_component + Shares::ProjectQueries::EmptyStateComponent + end + end +end diff --git a/app/models/sharing_strategies/work_package_strategy.rb b/app/models/sharing_strategies/work_package_strategy.rb new file mode 100644 index 000000000000..3d24ef89a816 --- /dev/null +++ b/app/models/sharing_strategies/work_package_strategy.rb @@ -0,0 +1,68 @@ +#-- 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 SharingStrategies + class WorkPackageStrategy < BaseStrategy + def available_roles + 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 } + ] + end + + def manageable? + user.allowed_in_project?(:share_work_packages, @entity.project) + end + + def viewable? + user.allowed_in_project?(:view_shared_work_packages, @entity.project) + end + + def create_contract_class + Shares::WorkPackages::CreateContract + end + + def update_contract_class + Shares::WorkPackages::UpdateContract + end + + def delete_contract_class + Shares::WorkPackages::DeleteContract + end + end +end diff --git a/app/services/authorization/user_permissible_service.rb b/app/services/authorization/user_permissible_service.rb index a3c8e2629b14..ec7a2bc53015 100644 --- a/app/services/authorization/user_permissible_service.rb +++ b/app/services/authorization/user_permissible_service.rb @@ -112,7 +112,9 @@ def allowed_in_single_standalone_entity?(permissions, entity) return false if entity.nil? return true if admin_and_all_granted_to_admin?(permissions) - cached_permissions(entity).intersect?(permissions) + permission_names = permissions.map { |perm| perm.name.to_sym } + + cached_permissions(entity).intersect?(permission_names) end def allowed_in_any_project_scoped_entity?(permissions, entity_class, in_project:) diff --git a/app/views/shares/dialog.turbo_stream.erb b/app/views/shares/dialog.turbo_stream.erb new file mode 100644 index 000000000000..56f9e3b1a3ab --- /dev/null +++ b/app/views/shares/dialog.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.dialog do + render(Shares::ShareDialogComponent.new(strategy: @sharing_strategy, shares: @shares, errors: @errors)) +end %> diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 8b4ab0de9719..896a1c495add 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -180,7 +180,7 @@ map.permission :manage_public_project_queries, { - "projects/queries": %i[publish unpublish] + "projects/queries": %i[toggle_public] }, permissible_on: :global, require: :loggedin, @@ -330,17 +330,14 @@ map.permission :share_work_packages, { - members: %i[destroy_by_principal], - shares: %i[index create destroy update resend_invite bulk_update bulk_destroy] + members: %i[destroy_by_principal] }, permissible_on: :project, dependencies: %i[edit_work_packages view_shared_work_packages], require: :member map.permission :view_shared_work_packages, - { - shares: %i[index] - }, + {}, permissible_on: :project, require: :member, contract_actions: { work_package_shares: %i[index] } diff --git a/config/locales/en.yml b/config/locales/en.yml index 455e04034907..1d4bf25f26ba 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -322,8 +322,9 @@ en: project_custom_fields: header: title: "Project attributes" - description: "These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes. -Project attributes and sections are defined in the administration settings by the administrator of the instance. " + description: + 'These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes. + Project attributes and sections are defined in the administration settings by the administrator of the instance. ' filter: label: "Search project attribute" actions: @@ -2158,6 +2159,7 @@ Project attributes and sections are defined in the Days that are not selected are skipped when scheduling work packages (and not included in the day count). diff --git a/config/routes.rb b/config/routes.rb index 84f2ac8b9bca..8faa0f2241de 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,12 +79,13 @@ # Shared route concerns # TODO: Add description how to configure controller to support shares concern :shareable do - resources :members, path: :shares, controller: "shares", only: %i[index create update destroy] do + resources :members, path: "shares", controller: "shares", only: %i[index create update destroy] do member do post "resend_invite" => "shares#resend_invite" end collection do + get :dialog, to: "shares#dialog" patch :bulk, to: "shares#bulk_update" put :bulk, to: "shares#bulk_update" delete :bulk, to: "shares#bulk_destroy" @@ -196,11 +197,11 @@ end resources :project_queries, only: %i[show new create update destroy], controller: "projects/queries" do + concerns :shareable + member do get :rename - - post :publish - post :unpublish + post :toggle_public end end @@ -537,7 +538,9 @@ get "/bulk" => "bulk#destroy" end - resources :work_packages, only: [:index], concerns: [:shareable] do + resources :work_packages, only: [:index] do + concerns :shareable + # move bulk of wps get "move/new" => "work_packages/moves#new", on: :collection, as: "new_move" post "move" => "work_packages/moves#create", on: :collection, as: "move" diff --git a/frontend/src/turbo/setup.ts b/frontend/src/turbo/setup.ts index 0f10dd45405e..9bd905bbb9a1 100644 --- a/frontend/src/turbo/setup.ts +++ b/frontend/src/turbo/setup.ts @@ -2,7 +2,6 @@ import '../typings/shims.d.ts'; import * as Turbo from '@hotwired/turbo'; import { registerDialogStreamAction } from './dialog-stream-action'; import { addTurboEventListeners } from './turbo-event-listeners'; -import { ModalDialogElement } from '@openproject/primer-view-components/app/components/primer/alpha/modal_dialog'; // Disable default turbo-drive for now as we don't need it for now AND it breaks angular routing Turbo.session.drive = false; @@ -18,15 +17,3 @@ document.addEventListener('turbo:frame-missing', (event:CustomEvent) => { event.preventDefault(); visit(response.url); }); - -// Close the primer dialog when the form inside has been submitted with a success response -// It is necessary to close the primer dialog using the `close()` method, otherwise -// it will leave an overflow:hidden attribute on the body, which prevents scrolling on the page. -document.addEventListener('turbo:submit-end', (event:CustomEvent) => { - const { detail: { success }, target } = event as { detail:{ success:boolean }, target:EventTarget }; - - if (success && target instanceof HTMLFormElement) { - const dialog = target.closest('modal-dialog') as ModalDialogElement; - dialog && dialog.close(true); - } -}); diff --git a/frontend/src/turbo/turbo-event-listeners.ts b/frontend/src/turbo/turbo-event-listeners.ts index 23a76c58a1c1..3e9b24c02341 100644 --- a/frontend/src/turbo/turbo-event-listeners.ts +++ b/frontend/src/turbo/turbo-event-listeners.ts @@ -1,5 +1,9 @@ export function addTurboEventListeners() { - // Close the primer dialog when the form inside has been submitted with a success response + // Close the primer dialog when the form inside has been submitted with a success response. + // + // If you want to keep the dialog open even after a successful form submission, you can add the + // `data-keep-open-on-submit="true"` attribute to the dialog element. + // // It is necessary to close the primer dialog using the `close()` method, otherwise // it will leave an overflow:hidden attribute on the body, which prevents scrolling on the page. document.addEventListener('turbo:submit-end', (event:CustomEvent) => { @@ -7,7 +11,10 @@ export function addTurboEventListeners() { if (success && target instanceof HTMLFormElement) { const dialog = target.closest('dialog') as HTMLDialogElement; - dialog && dialog.close(); + + if (dialog && dialog.dataset.keepOpenOnSubmit !== 'true') { + dialog.close(); + } } }); } diff --git a/lookbook/docs/patterns/05-dialogs.md.erb b/lookbook/docs/patterns/05-dialogs.md.erb index 75046c837aad..e39a97bd4324 100644 --- a/lookbook/docs/patterns/05-dialogs.md.erb +++ b/lookbook/docs/patterns/05-dialogs.md.erb @@ -1,13 +1,9 @@ As of now, this page is still a stub. In particular, the considerations on when and where to apply Dialogs are still missing. - For now, it can be stated that OpenProject employs Dialogs for more purposes than GitHub [Primer specifies](https://primer.style/components/dialog). - ## Async dialogs as turbo streams - Primer dialogs need to be rendered and tied to a button, resulting in their content always being present. If you have any non-trivial content that should be displayed in a dialog, you should use the async dialog pattern. - To render dialogs asynchronously, you use a Button/Link/IconButton component as a trigger and add the following attributes to it: @@ -152,6 +148,23 @@ class TestController < ApplicationControler end ``` +### Dialogs that should stay open on form submission + +Because we normally want to close the dialog when a form is submitted successfully, we have implemented this as the default behaviour. As described in the last section, +when the submission returns a 4xx or 5xx status code, the dialog will stay open and display the validation errors and will be closed when the form is submitted successfully. + +If you want to keep a dialog open even after a successful form submission, you can use the `data-keep-open-on-submit="true"` attribute on the dialog. This will prevent the dialog +from closing even if the form submission is successful. + +```erb +<%%= turbo_stream.dialog do + render(Primer::Alpha::Dialog.new(data: { 'keep-open-on-submit': true }) do |d| + # ... + end +end +%> +``` + ## Special kinds of dialogs We have multiple kind of dialogs that re-appear throughout the whole application. In order to make sure, they all look and feel alike, we further specify them here. @@ -198,7 +211,6 @@ the form via the `form` attribute. In pseudo html, the logical format is then like this: ```html - Dialog title @@ -210,33 +222,25 @@ In pseudo html, the logical format is then like this: Submit - + - ``` <%= embed OpenProject::Common::DialogPreview, :form %> - #### Where it's used - - WorkPackage → Meeting Tab → Add meeting to WorkPackage ### User nudging modal - The idea of nudging modals is to promote users to perform actions that are not 100% mandatory but might be interesting for them. For example, after creating a type, an admin would like to add the new type to a project and configure a workflow for it. Another example is to grant personal access to a recently created storage: - - #### Accessibility considerations - To be written down.. #### Where it's used - - Admin → Storages → OAuth Grant Access diff --git a/lookbook/previews/shares/share_dialog_component_preview.rb b/lookbook/previews/shares/share_dialog_component_preview.rb new file mode 100644 index 000000000000..8818bcb4e508 --- /dev/null +++ b/lookbook/previews/shares/share_dialog_component_preview.rb @@ -0,0 +1,23 @@ +module Shares + class ShareDialogComponentPreview < Lookbook::Preview + def project_query + user = FactoryBot.build_stubbed(:admin) + query = FactoryBot.build_stubbed(:project_query, user:) + strategy = SharingStrategies::ProjectQueryStrategy.new(query, user:) + errors = [] + shares = [] + + render(Shares::ShareDialogComponent.new(strategy:, shares:, errors:)) + end + + def work_package + user = FactoryBot.build_stubbed(:admin) + work_package = FactoryBot.build_stubbed(:work_package) + strategy = SharingStrategies::WorkPackageStrategy.new(work_package, user:) + errors = [] + shares = [] + + render(Shares::ShareDialogComponent.new(strategy:, shares:, errors:)) + end + end +end diff --git a/spec/controllers/projects/queries_controller_spec.rb b/spec/controllers/projects/queries_controller_spec.rb index dc8a82ad9f1b..9793088a9fb9 100644 --- a/spec/controllers/projects/queries_controller_spec.rb +++ b/spec/controllers/projects/queries_controller_spec.rb @@ -263,11 +263,11 @@ end end - describe "#publish" do + describe "#toggle_public" do let(:service_class) { Queries::Projects::ProjectQueries::PublishService } it "requires login" do - put "publish", params: { id: 42 } + post "toggle_public", params: { id: 42 } expect(response).not_to be_successful end @@ -292,7 +292,7 @@ end it "calls publish service on query" do - put "publish", params: { id: 42 } + post "toggle_public", params: { id: 42 } expect(service_instance).to have_received(:call).with(query_params) end @@ -301,7 +301,7 @@ it "redirects to projects" do allow(I18n).to receive(:t).with("lists.publish.success").and_return("foo") - put "publish", params: { id: 42 } + post "toggle_public", params: { id: 42 } expect(flash[:notice]).to eq("foo") expect(response).to redirect_to(projects_path(query_id: query.id)) @@ -319,7 +319,7 @@ it "renders projects/index" do allow(I18n).to receive(:t).with("lists.publish.failure", errors: "something\nwent\nwrong").and_return("bar") - put "publish", params: { id: 42 } + post "toggle_public", params: { id: 42 } expect(flash[:error]).to eq("bar") expect(response).to render_template("projects/index") @@ -328,80 +328,7 @@ it "passes variables to template" do allow(controller).to receive(:render).and_call_original - put "update", params: { id: 42 } - - expect(controller).to have_received(:render).with(include(locals: { query:, state: :edit })) - end - end - end - end - - describe "#unpublish" do - let(:service_class) { Queries::Projects::ProjectQueries::PublishService } - - it "requires login" do - put "unpublish", params: { id: 42 } - - expect(response).not_to be_successful - end - - context "when logged in" do - let(:query) { build_stubbed(:project_query, user:) } - let(:query_id) { "42" } - let(:query_params) { { public: false } } - let(:service_instance) { instance_double(service_class) } - let(:service_result) { instance_double(ServiceResult, success?: success?, result: query) } - let(:success?) { true } - - before do - allow(controller).to receive(:permitted_query_params).and_return(query_params) - scope = instance_double(ActiveRecord::Relation) - allow(ProjectQuery).to receive(:visible).and_return(scope) - allow(scope).to receive(:find).with(query_id).and_return(query) - allow(service_class).to receive(:new).with(model: query, user:).and_return(service_instance) - allow(service_instance).to receive(:call).with(query_params).and_return(service_result) - - login_as user - end - - it "calls publish service on query" do - put "unpublish", params: { id: 42 } - - expect(service_instance).to have_received(:call).with(query_params) - end - - context "when service call succeeds" do - it "redirects to projects" do - allow(I18n).to receive(:t).with("lists.unpublish.success").and_return("foo") - - put "unpublish", params: { id: 42 } - - expect(flash[:notice]).to eq("foo") - expect(response).to redirect_to(projects_path(query_id: query.id)) - end - end - - context "when service call fails" do - let(:success?) { false } - let(:errors) { instance_double(ActiveModel::Errors, full_messages: ["something", "went", "wrong"]) } - - before do - allow(service_result).to receive(:errors).and_return(errors) - end - - it "renders projects/index" do - allow(I18n).to receive(:t).with("lists.unpublish.failure", errors: "something\nwent\nwrong").and_return("bar") - - put "unpublish", params: { id: 42 } - - expect(flash[:error]).to eq("bar") - expect(response).to render_template("projects/index") - end - - it "passes variables to template" do - allow(controller).to receive(:render).and_call_original - - put "unpublish", params: { id: 42 } + post "toggle_public", params: { id: 42 } expect(controller).to have_received(:render).with(include(locals: { query:, state: :edit })) end