diff --git a/app/components/projects/index_page_header_component.html.erb b/app/components/projects/index_page_header_component.html.erb index 88189b02fea8..6f99b4163acd 100644 --- a/app/components/projects/index_page_header_component.html.erb +++ b/app/components/projects/index_page_header_component.html.erb @@ -81,6 +81,29 @@ 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_projects_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_projects_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, @@ -92,7 +115,6 @@ end end %> - <%= render(Projects::ConfigureViewModalComponent.new(query:)) %> <%= render(Projects::DeleteListModalComponent.new(query:)) if query.persisted? %> <%= render(Projects::ExportListModalComponent.new(query:)) %> diff --git a/app/components/projects/index_page_header_component.rb b/app/components/projects/index_page_header_component.rb index fc7f6939bef6..5cf1928edbaa 100644 --- a/app/components/projects/index_page_header_component.rb +++ b/app/components/projects/index_page_header_component.rb @@ -70,9 +70,35 @@ def may_save_as? = current_user.logged? def can_save_as? = may_save_as? && query.changed? - def can_save? = can_save_as? && query.persisted? && query.user == current_user + def can_save? + return false unless current_user.logged? + return false unless query.persisted? + return false unless query.changed? - def can_rename? = may_save_as? && query.persisted? && query.user == current_user && !query.changed? + if query.public? + current_user.allowed_globally?(:manage_public_project_queries) + else + query.user == current_user + end + end + + def can_rename? + return false unless current_user.logged? + 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? + end def show_state? state == :show diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 6dd80c99c2cd..11b55719c5a5 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -46,19 +46,19 @@ def hierarchy def favored render(Primer::Beta::IconButton.new( - icon: currently_favored? ? "star-fill" : "star", - scheme: :invisible, - mobile_icon: currently_favored? ? "star-fill" : "star", - size: :medium, - tag: :a, - tooltip_direction: :e, - href: helpers.build_favorite_path(project, format: :html), - data: { method: currently_favored? ? :delete : :post }, - classes: currently_favored? ? "op-primer--star-icon " : "op-project-row-component--favorite", - label: currently_favored? ? I18n.t(:button_unfavorite) : I18n.t(:button_favorite), - aria: { label: currently_favored? ? I18n.t(:button_unfavorite) : I18n.t(:button_favorite) }, - test_selector: 'project-list-favorite-button' - )) + icon: currently_favored? ? "star-fill" : "star", + scheme: :invisible, + mobile_icon: currently_favored? ? "star-fill" : "star", + size: :medium, + tag: :a, + tooltip_direction: :e, + href: helpers.build_favorite_path(project, format: :html), + data: { method: currently_favored? ? :delete : :post }, + classes: currently_favored? ? "op-primer--star-icon " : "op-project-row-component--favorite", + label: currently_favored? ? I18n.t(:button_unfavorite) : I18n.t(:button_favorite), + aria: { label: currently_favored? ? I18n.t(:button_unfavorite) : I18n.t(:button_favorite) }, + test_selector: "project-list-favorite-button" + )) end def currently_favored? @@ -197,7 +197,7 @@ def column_css_class(column) def additional_css_class(column) if column.attribute == :name "project--hierarchy #{project.archived? ? 'archived' : ''}" - elsif [:status_explanation, :description].include?(column.attribute) + elsif %i[status_explanation description].include?(column.attribute) "project-long-text-container" elsif custom_field_column?(column) cf = column.custom_field @@ -254,7 +254,7 @@ def more_menu_favorite_item href: helpers.build_favorite_path(project, format: :html), data: { method: :post }, label: I18n.t(:button_favorite), - aria: { label: I18n.t(:button_favorite) }, + aria: { label: I18n.t(:button_favorite) } } end @@ -269,7 +269,7 @@ def more_menu_unfavorite_item data: { method: :delete }, classes: "op-primer--star-icon", label: I18n.t(:button_unfavorite), - aria: { label: I18n.t(:button_unfavorite) }, + aria: { label: I18n.t(:button_unfavorite) } } end @@ -302,7 +302,7 @@ def more_menu_activity_item scheme: :default, icon: :check, label: I18n.t(:label_project_activity), - href: project_activity_index_path(project, event_types: ["project_attributes"]), + href: project_activity_index_path(project, event_types: ["project_attributes"]) } end end @@ -317,7 +317,7 @@ def more_menu_archive_item data: { confirm: t("project.archive.are_you_sure", name: project.name), method: :post - }, + } } end end @@ -340,7 +340,7 @@ def more_menu_copy_item scheme: :default, icon: :copy, label: I18n.t(:button_copy), - href: copy_project_path(project), + href: copy_project_path(project) } end end @@ -351,7 +351,7 @@ def more_menu_delete_item scheme: :danger, icon: :trash, label: I18n.t(:button_delete), - href: confirm_destroy_project_path(project), + href: confirm_destroy_project_path(project) } end end @@ -361,7 +361,7 @@ def user_can_view_project? end def custom_field_column?(column) - column.is_a?(Queries::Projects::Selects::CustomField) + column.is_a?(::Queries::Projects::Selects::CustomField) end end end diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index 3c053819422b..8d68f035dbc8 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -64,11 +64,10 @@ def build_sort_header(column, options) # We don't return the project row # but the [project, level] array from the helper def rows - @rows ||= - begin - projects_enumerator = ->(model) { to_enum(:projects_with_levels_order_sensitive, model).to_a } - instance_exec(model, &projects_enumerator) - end + @rows ||= begin + projects_enumerator = ->(model) { to_enum(:projects_with_levels_order_sensitive, model).to_a } + instance_exec(model, &projects_enumerator) + end end def initialize_sorted_model @@ -113,15 +112,14 @@ def sortable_column?(select) end def columns - @columns ||= - begin - columns = query.selects.reject { |select| select.is_a?(Queries::Selects::NotExistingSelect) } + @columns ||= begin + columns = query.selects.reject { |select| select.is_a?(::Queries::Selects::NotExistingSelect) } - index = columns.index { |column| column.attribute == :name } - columns.insert(index, Queries::Projects::Selects::Default.new(:hierarchy)) if index + index = columns.index { |column| column.attribute == :name } + columns.insert(index, ::Queries::Projects::Selects::Default.new(:hierarchy)) if index - columns - end + columns + end end def projects(query) @@ -156,7 +154,7 @@ def projects_with_level(projects, &) end def favored_project_ids - @favored_projects ||= Favorite.where(user: current_user, favored_type: 'Project').pluck(:favored_id) + @favored_project_ids ||= Favorite.where(user: current_user, favored_type: "Project").pluck(:favored_id) end def sorted_by_lft? diff --git a/app/contracts/queries/projects/project_queries/base_contract.rb b/app/contracts/queries/projects/project_queries/base_contract.rb index 4cc8bcd14d29..da00abf3366a 100644 --- a/app/contracts/queries/projects/project_queries/base_contract.rb +++ b/app/contracts/queries/projects/project_queries/base_contract.rb @@ -41,17 +41,36 @@ def self.model presence: true, length: { maximum: 255 } - validate :user_is_current_user_and_logged_in validate :name_select_included validate :existing_selects + validate :user_is_logged_in + validate :allowed_to_modify_private_query + validate :allowed_to_modify_public_query + protected - def user_is_current_user_and_logged_in - unless user.logged? && user == model.user + def user_is_logged_in + unless user.logged? errors.add :base, :error_unauthorized end end + def allowed_to_modify_private_query + return if model.public? + + if model.user != user + errors.add :base, :can_only_be_modified_by_owner + end + end + + def allowed_to_modify_public_query + return unless model.public? + + unless user.allowed_globally?(:manage_public_project_queries) + errors.add :base, :need_permission_to_modify_public_query + end + end + def name_select_included if model.selects.none? { |s| s.attribute == :name } errors.add :selects, :name_not_included diff --git a/app/contracts/queries/projects/project_queries/publish_contract.rb b/app/contracts/queries/projects/project_queries/publish_contract.rb new file mode 100644 index 000000000000..98a9fe2844c9 --- /dev/null +++ b/app/contracts/queries/projects/project_queries/publish_contract.rb @@ -0,0 +1,33 @@ +#-- 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 Queries::Projects::ProjectQueries + class PublishContract < BaseContract + attribute :public + end +end diff --git a/app/controllers/projects/queries_controller.rb b/app/controllers/projects/queries_controller.rb index 273733778fe8..bf9bbc36fd1c 100644 --- a/app/controllers/projects/queries_controller.rb +++ b/app/controllers/projects/queries_controller.rb @@ -31,13 +31,17 @@ class Projects::QueriesController < ApplicationController # No need for a more specific authorization check. That is carried out in the contracts. before_action :require_login - before_action :find_query, only: %i[rename update destroy] + before_action :find_query, only: %i[show rename update destroy publish unpublish] before_action :build_query_or_deny_access, only: %i[new create] current_menu_item [:new, :rename, :create, :update] do :projects end + def show + redirect_to projects_path(query_id: @query.id) + end + def new render template: "/projects/index", layout: "global", @@ -55,17 +59,7 @@ def create .new(from: @query, user: current_user) .call(permitted_query_params) - if call.success? - flash[:notice] = I18n.t("lists.create.success") - - redirect_to projects_path(query_id: call.result.id) - else - flash[:error] = I18n.t("lists.create.failure", errors: call.errors.full_messages.join("\n")) - - render template: "/projects/index", - layout: "global", - locals: { query: call.result, state: :edit } - end + render_result(call, success_i18n_key: "lists.create.success", error_i18n_key: "lists.create.failure") end def update @@ -73,17 +67,23 @@ def update .new(user: current_user, model: @query) .call(permitted_query_params) - if call.success? - flash[:notice] = I18n.t("lists.update.success") + render_result(call, success_i18n_key: "lists.update.success", error_i18n_key: "lists.update.failure") + end - redirect_to projects_path(query_id: call.result.id) - else - flash[:error] = I18n.t("lists.update.failure", errors: call.errors.full_messages.join("\n")) + def publish + call = Queries::Projects::ProjectQueries::PublishService + .new(user: current_user, model: @query) + .call(public: true) - render template: "/projects/index", - layout: "global", - locals: { query: call.result, state: :edit } - end + render_result(call, success_i18n_key: "lists.publish.success", error_i18n_key: "lists.publish.failure") + end + + def unpublish + call = Queries::Projects::ProjectQueries::PublishService + .new(user: current_user, model: @query) + .call(public: false) + + render_result(call, success_i18n_key: "lists.unpublish.success", error_i18n_key: "lists.unpublish.failure") end def destroy @@ -95,7 +95,23 @@ def destroy private + def render_result(service_call, success_i18n_key:, error_i18n_key:) # rubocop:disable Metrics/AbcSize + modified_query = service_call.result + + if service_call.success? + flash[:notice] = I18n.t(success_i18n_key) + + redirect_to modified_query.visible? ? projects_path(query_id: modified_query.id) : projects_path + else + flash[:error] = I18n.t(error_i18n_key, errors: service_call.errors.full_messages.join("\n")) + + render template: "/projects/index", + layout: "global", + locals: { query: modified_query, state: :edit } + end + end + def find_query - @query = Queries::Projects::ProjectQuery.find(params[:id]) + @query = Queries::Projects::ProjectQuery.visible(current_user).find(params[:id]) end end diff --git a/app/controllers/projects/query_loading.rb b/app/controllers/projects/query_loading.rb index 1d0edfa44eac..c7679d9b9b8a 100644 --- a/app/controllers/projects/query_loading.rb +++ b/app/controllers/projects/query_loading.rb @@ -30,10 +30,10 @@ module QueryLoading private def load_query(duplicate:) - Queries::Projects::Factory.find(params[:query_id], - params: permitted_query_params, - user: current_user, - duplicate:) + ::Queries::Projects::Factory.find(params[:query_id], + params: permitted_query_params, + user: current_user, + duplicate:) end def load_query_or_deny_access @@ -55,7 +55,7 @@ def permitted_query_params query_params.merge!(params.require(:query).permit(:name)) end - query_params.merge!(Queries::ParamsParser.parse(params)) + query_params.merge!(::Queries::ParamsParser.parse(params)) query_params.with_indifferent_access end diff --git a/app/helpers/menus/projects.rb b/app/helpers/menus/projects.rb index fae5464c58ea..dc5e5ea63e7a 100644 --- a/app/helpers/menus/projects.rb +++ b/app/helpers/menus/projects.rb @@ -44,6 +44,8 @@ def first_level_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: I18n.t(:"activerecord.attributes.project.status_code"), @@ -76,9 +78,16 @@ def static_filters(ids) end end + def public_filters + ::Queries::Projects::ProjectQuery + .public_lists + .order(:name) + .map { |query| query_menu_item(query) } + end + def my_filters ::Queries::Projects::ProjectQuery - .where(user: current_user) + .private_lists(user: current_user) .order(:name) .map { |query| query_menu_item(query) } end diff --git a/app/models/queries/projects/factory.rb b/app/models/queries/projects/factory.rb index 818c7dd2594e..bbc7dc19acaa 100644 --- a/app/models/queries/projects/factory.rb +++ b/app/models/queries/projects/factory.rb @@ -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.where(user:).find_by(id:) + query = Queries::Projects::ProjectQuery.visible(user).find_by(id:) return unless query diff --git a/app/models/queries/projects/project_query.rb b/app/models/queries/projects/project_query.rb index 6a44a497199a..7bf65abb9ee2 100644 --- a/app/models/queries/projects/project_query.rb +++ b/app/models/queries/projects/project_query.rb @@ -36,6 +36,17 @@ class Queries::Projects::ProjectQuery < ApplicationRecord serialize :orders, coder: Queries::Serialization::Orders.new(self) serialize :selects, coder: Queries::Serialization::Selects.new(self) + scope :public_lists, -> { where(public: true) } + scope :private_lists, ->(user: User.current) { where(public: false, user:) } + + scope :visible, ->(user = User.current) { + public_lists.or(private_lists(user:)) + } + + def visible?(user = User.current) + public? || user == self.user + end + def self.model Project end diff --git a/app/services/queries/projects/project_queries/publish_service.rb b/app/services/queries/projects/project_queries/publish_service.rb new file mode 100644 index 000000000000..e2c26fcda821 --- /dev/null +++ b/app/services/queries/projects/project_queries/publish_service.rb @@ -0,0 +1,49 @@ +#-- 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 Queries::Projects::ProjectQueries + class PublishService < BaseServices::Update + private + + def after_validate(params, service_call) + model.public = params[:public] + + service_call + end + + def persist(service_call) + model.save + + service_call + end + + def default_contract_class + Queries::Projects::ProjectQueries::PublishContract + end + end +end diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index e98381d9be1e..54ed096da78c 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -38,3 +38,5 @@ # initializer 'the_engine.feature_decisions' do # OpenProject::FeatureDecisions.add :some_flag # end + +OpenProject::FeatureDecisions.add :project_list_sharing diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 0f474ddcbb9d..c92ee82c1f11 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -112,7 +112,7 @@ map.permission :select_project_custom_fields, { - 'projects/settings/project_custom_fields': %i[show toggle enable_all_of_section disable_all_of_section] + "projects/settings/project_custom_fields": %i[show toggle enable_all_of_section disable_all_of_section] }, permissible_on: :project, require: :member @@ -178,6 +178,14 @@ permissible_on: :global, require: :loggedin, grant_to_admin: true + + map.permission :manage_public_project_queries, + { + "projects/queries": %i[publish unpublish] + }, + permissible_on: :global, + require: :loggedin, + grant_to_admin: true end map.project_module :work_package_tracking, order: 90 do |wpt| diff --git a/config/locales/en.yml b/config/locales/en.yml index 258ba4841285..8c3d8b8f422b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -298,6 +298,7 @@ en: my: "My projects" favored: "Favorite projects" archived: "Archived projects" + public: "Public project lists" my_private: "My private project lists" new: placeholder: "New project list" @@ -345,6 +346,12 @@ Project attributes and sections are defined in the