diff --git a/app/components/projects/life_cycle_type_component.html.erb b/app/components/projects/life_cycle_type_component.html.erb new file mode 100644 index 000000000000..921c1cf191a2 --- /dev/null +++ b/app/components/projects/life_cycle_type_component.html.erb @@ -0,0 +1,8 @@ +<%= flex_layout(align_items: :center) do |type_container| + type_container.with_column(mr: 1, classes: icon_color_class) do + render Primer::Beta::Octicon.new(icon: icon) + end + type_container.with_column do + render(Primer::Beta::Text.new(**text_options)) { text } + end +end %> diff --git a/app/components/projects/life_cycle_type_component.rb b/app/components/projects/life_cycle_type_component.rb new file mode 100644 index 000000000000..453f5d90c5d6 --- /dev/null +++ b/app/components/projects/life_cycle_type_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ +module Projects + class LifeCycleTypeComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + def text + model.model_name.human + end + + def icon + case model + when Project::StageDefinition + :"git-commit" + when Project::GateDefinition + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleTypeComponent with" + end + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model) + end + + def text_options + # The tag: :div is is a hack to fix the line height difference + # caused by font_size: :small. That line height difference + # would otherwise lead to the text being not on the same height as the icon + { color: :muted, font_size: :small, tag: :div }.merge(options) + end + end +end diff --git a/app/components/projects/settings/life_cycle_steps/index_component.html.erb b/app/components/projects/settings/life_cycle_steps/index_component.html.erb new file mode 100644 index 000000000000..8ab58ef17910 --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_component.html.erb @@ -0,0 +1,88 @@ +<%= + flex_layout(data: wrapper_data_attributes) do |flex| + flex.with_row do + render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_filter_input(name: "border-box-filter", + label: t('projects.settings.life_cycle.filter.label'), + visually_hide_label: true, + placeholder: t('projects.settings.life_cycle.filter.label'), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: clear_button_id, + data: { + action: "input->projects--settings--border-box-filter#filterLists", + "projects--settings--border-box-filter-target": "filter" + }) + end + end + + flex.with_row do + render(border_box_container(mb: 3, data: { test_selector: "project-life-cycle-administration" })) do |component| + component.with_header(font_weight: :bold, py: 2) do + flex_layout(justify_content: :space_between, align_items: :center) do |header_container| + header_container.with_column(py: 2) do + # adding py: 2 here to match the padding of the actions_container + # otherwise the header height changes when the actions gets hidden when filtering + render(Primer::Beta::Text.new(font_weight: :bold)) do + I18n.t('projects.settings.life_cycle.section_header') + end + end + header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: enable_all_project_settings_life_cycle_steps_path(project_id: project), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.actions.label_enable_all'), + data: { 'turbo-method': :post, test_selector: "enable-all-life-cycle-steps" } + )) do |button| + button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) + t('projects.settings.actions.label_enable_all') + end + end + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: disable_all_project_settings_life_cycle_steps_path(project_id: project), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.actions.label_disable_all'), + data: { 'turbo-method': :post, test_selector: "disable-all-life-cycle-steps" } + )) do |button| + button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) + t('projects.settings.actions.label_disable_all') + end + end + end + end + end + if life_cycle_definitions.empty? + component.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t("projects.settings.life_cycle.non_defined") } + end + else + life_cycle_definitions_and_step_active.each do |definition, active| + component.with_row(data: { 'projects--settings--border-box-filter-target': 'searchItem' }, + test_selector: "project-life-cycle-step-#{definition.id}") do + render(Projects::Settings::LifeCycleSteps::StepComponent.new(definition:, active?: active)) + end + end + end + end + end + flex.with_row do + render Primer::Beta::Text.new(display: :none, + data: { + "projects--settings--border-box-filter-target": "noResultsText", + }) do + I18n.t("js.autocompleter.notFoundText") + end + end + end +%> diff --git a/app/components/projects/settings/life_cycle_steps/index_component.rb b/app/components/projects/settings/life_cycle_steps/index_component.rb new file mode 100644 index 000000000000..bd1c174f1ccc --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_component.rb @@ -0,0 +1,64 @@ +# -- 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 Projects + module Settings + module LifeCycleSteps + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + options :project, + :life_cycle_definitions + + private + + def life_cycle_definitions_and_step_active + active_ids = project.life_cycle_steps.where(active: true).pluck(:definition_id).to_set + + life_cycle_definitions.all.map do |definition| + [definition, definition.id.in?(active_ids)] + end + end + + def wrapper_data_attributes + { + controller: "projects--settings--border-box-filter", + "application-target": "dynamic", + "projects--settings--border-box-filter-clear-button-id-value": clear_button_id + } + end + + def clear_button_id + "border-box-filter-clear-button" + end + end + end + end +end diff --git a/app/components/projects/settings/life_cycle_steps/index_page_header_component.html.erb b/app/components/projects/settings/life_cycle_steps/index_page_header_component.html.erb new file mode 100644 index 000000000000..8c7eb8f55bcc --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_page_header_component.html.erb @@ -0,0 +1,11 @@ +<%= + render Primer::OpenProject::PageHeader.new do |header| + header.with_title { t('projects.settings.life_cycle.header.title') } + header.with_description do + t('projects.settings.life_cycle.header.description_html', + overview_url: project_path(project), + admin_settings_url: admin_settings_project_life_cycle_step_definitions_path) + end + header.with_breadcrumbs(breadcrumb_items) + end +%> diff --git a/app/components/projects/settings/life_cycle_steps/index_page_header_component.rb b/app/components/projects/settings/life_cycle_steps/index_page_header_component.rb new file mode 100644 index 000000000000..8be423936dbe --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/index_page_header_component.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module Projects::Settings::LifeCycleSteps + class IndexPageHeaderComponent < ApplicationComponent + include ApplicationHelper + + options :project + + def breadcrumb_items + [{ href: project_overview_path(project), text: project.name }, + { href: project_settings_general_path(project), text: I18n.t("label_project_settings") }, + t("projects.settings.life_cycle.header.title")] + end + end +end diff --git a/app/components/projects/settings/life_cycle_steps/step_component.html.erb b/app/components/projects/settings/life_cycle_steps/step_component.html.erb new file mode 100644 index 000000000000..9eda07459e3c --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/step_component.html.erb @@ -0,0 +1,30 @@ +<%= + flex_layout(align_items: :center, + justify_content: :space_between) do |step_container| + step_container.with_column(flex_layout: true) do |title_container| + title_container.with_column(pt: 1, mr: 3) do + render(Primer::Beta::Text.new(classes: 'filter-target-visible-text')) { definition.name } + end + title_container.with_column(pt: 1) do + render(Projects::LifeCycleTypeComponent.new(definition)) + end + end + # py: 1 quick fix: prevents the row from bouncing as the toggle switch currently changes height while toggling + step_container.with_column(py: 1, mr: 2) do + # buggy currently: + # small variant + status_label_position: :start leads to a small bounce while toggling + # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small + # quick fix: don't display loading indicator which is causing the bounce + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_life_cycle_step_path(id: definition.id), + csrf_token: form_authenticity_token, + data: { test_selector: "toggle-project-life-cycle-#{definition.id}" }, + aria: { label: toggle_aria_label }, + checked: active?, + size: :small, + status_label_position: :start, + classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator", + )) + end + end +%> diff --git a/app/components/projects/settings/life_cycle_steps/step_component.rb b/app/components/projects/settings/life_cycle_steps/step_component.rb new file mode 100644 index 000000000000..2dd0f59b6479 --- /dev/null +++ b/app/components/projects/settings/life_cycle_steps/step_component.rb @@ -0,0 +1,46 @@ +# -- 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 Projects + module Settings + module LifeCycleSteps + class StepComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + options :definition, + :active? + + def toggle_aria_label + I18n.t("projects.settings.life_cycle.step.use_in_project", step: definition.name) + end + end + end + end +end diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index 374bb4ed0642..fae8cdf15c38 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -5,7 +5,7 @@ }) do |custom_field_container| custom_field_container.with_column(flex_layout: true) do |title_container| title_container.with_column(pt: 1, mr: 2) do - render(Primer::Beta::Text.new) do + render(Primer::Beta::Text.new(classes: 'filter-target-visible-text')) do @project_custom_field.name end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb index b4044ee99669..f54e78c9f20b 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -3,7 +3,7 @@ flex_layout do |flex| flex.with_row do render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_filter_input(name: "project-custom-fields-mapping-filter", + subheader.with_filter_input(name: "border-box-filter", label: t('projects.settings.project_custom_fields.filter.label'), visually_hide_label: true, placeholder: t('projects.settings.project_custom_fields.filter.label'), @@ -14,8 +14,8 @@ show_clear_button: true, clear_button_id: clear_button_id, data: { - action: "input->projects--settings--project-custom-fields-mapping-filter#filterLists", - "projects--settings--project-custom-fields-mapping-filter-target": "filter" + action: "input->projects--settings--border-box-filter#filterLists", + "projects--settings--border-box-filter-target": "filter" }) end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb index 25b8a86e8b71..b405aa381d38 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -45,14 +45,14 @@ def initialize(project:, project_custom_field_sections:) def wrapper_data_attributes { - controller: "projects--settings--project-custom-fields-mapping-filter", + controller: "projects--settings--border-box-filter", "application-target": "dynamic", - "projects--settings--project-custom-fields-mapping-filter-clear-button-id-value": clear_button_id + "projects--settings--border-box-filter-clear-button-id-value": clear_button_id } end def clear_button_id - "project-custom-fields-mapping-filter-clear-button" + "border-box-filter-clear-button" end end end diff --git a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb index 7cbbd5903e1d..b56371d9d989 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.html.erb @@ -1,8 +1,7 @@ <%= render Primer::OpenProject::PageHeader.new do |header| %> - <%= header.with_title(variant: :default) { t('projects.settings.project_custom_fields.header.title') } %> - <%= header.with_description { t('projects.settings.project_custom_fields.header.description', - overview_url: project_path(@project), - admin_settings_url: admin_settings_project_custom_fields_path - ).html_safe } %> + <%= header.with_title { t('projects.settings.project_custom_fields.header.title') } %> + <%= header.with_description { t('projects.settings.project_custom_fields.header.description_html', + overview_url: project_path(project), + admin_settings_url: admin_settings_project_custom_fields_path) } %> <%= header.with_breadcrumbs(breadcrumb_items) %> <% end %> diff --git a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb index 13014aae6aab..fe1b93434a1f 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/index_page_header_component.rb @@ -33,14 +33,11 @@ module Projects::Settings::ProjectCustomFieldSections class IndexPageHeaderComponent < ApplicationComponent include ApplicationHelper - def initialize(project: nil) - super - @project = project - end + options :project def breadcrumb_items - [{ href: project_overview_path(@project.id), text: @project.name }, - { href: project_settings_general_path(@project.id), text: I18n.t("label_project_settings") }, + [{ href: project_overview_path(project), text: project.name }, + { href: project_settings_general_path(project), text: I18n.t("label_project_settings") }, t("settings.project_attributes.heading")] end end diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 727d8901228b..f2c5ee4e3a7f 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -13,7 +13,7 @@ end end section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| - actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do render(Primer::Beta::Button.new( tag: :a, href: enable_all_of_section_project_settings_project_custom_fields_path( @@ -25,14 +25,14 @@ scheme: :invisible, font_weight: :bold, color: :subtle, - 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_all'), + 'aria-label': t('projects.settings.actions.label_enable_all'), data: { 'turbo-method': :put, 'turbo-stream': true, test_selector: "enable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } )) do |button| button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) - t('projects.settings.project_custom_fields.actions.label_enable_all') + t('projects.settings.actions.label_enable_all') end end - actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + actions_container.with_column(data: { 'projects--settings--border-box-filter-target': 'bulkActionContainer' }) do render(Primer::Beta::Button.new( tag: :a, href: disable_all_of_section_project_settings_project_custom_fields_path( @@ -44,11 +44,11 @@ scheme: :invisible, font_weight: :bold, color: :subtle, - 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_all'), + 'aria-label': t('projects.settings.actions.label_disable_all'), data: { 'turbo-method': :put, 'turbo-stream': true, test_selector: "disable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } )) do |button| button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) - t('projects.settings.project_custom_fields.actions.label_disable_all') + t('projects.settings.actions.label_disable_all') end end end @@ -60,7 +60,7 @@ end else @project_custom_fields.each do |project_custom_field| - component.with_row(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'searchItem' }) do + component.with_row(data: { 'projects--settings--border-box-filter-target': 'searchItem' }) do render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( project: @project, project_custom_field:, diff --git a/app/controllers/projects/settings/life_cycle_steps_controller.rb b/app/controllers/projects/settings/life_cycle_steps_controller.rb new file mode 100644 index 000000000000..c28a3b12ab93 --- /dev/null +++ b/app/controllers/projects/settings/life_cycle_steps_controller.rb @@ -0,0 +1,81 @@ +# -- 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. +# ++ + +class Projects::Settings::LifeCycleStepsController < Projects::SettingsController + include OpTurbo::ComponentStream + + before_action :deny_access_on_feature_flag + + before_action :load_life_cycle_definitions, only: %i[index enable_all disable_all] + + menu_item :settings_life_cycle_steps + + def index; end + + def toggle + definition = Project::LifeCycleStepDefinition.where(id: params[:id]) + + upsert_steps(definition, active: params["value"]) + end + + def disable_all + upsert_steps(@life_cycle_definitions, active: false) + + redirect_to action: :index + end + + def enable_all + upsert_steps(@life_cycle_definitions, active: true) + + redirect_to action: :index + end + + private + + def load_life_cycle_definitions + @life_cycle_definitions = Project::LifeCycleStepDefinition.order(position: :asc) + end + + def deny_access_on_feature_flag + deny_access(not_found: true) unless OpenProject::FeatureDecisions.stages_and_gates_active? + end + + def upsert_steps(definitions, active:) + Project::LifeCycleStep.upsert_all( + definitions.map do |definition| + { + project_id: @project.id, + definition_id: definition.id, + active:, + type: definition.step_class + } + end, + unique_by: %i[project_id definition_id] + ) + end +end diff --git a/app/models/project/gate_definition.rb b/app/models/project/gate_definition.rb index a51561075728..a0cfef69352e 100644 --- a/app/models/project/gate_definition.rb +++ b/app/models/project/gate_definition.rb @@ -32,4 +32,8 @@ class Project::GateDefinition < Project::LifeCycleStepDefinition foreign_key: :definition_id, inverse_of: :definition, dependent: :destroy + + def step_class + Project::Gate + end end diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index d511cc284cc4..48e8e00d94bf 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -51,4 +51,8 @@ def initialize(*args) super end + + def step_class + raise NotImplementedError + end end diff --git a/app/models/project/stage_definition.rb b/app/models/project/stage_definition.rb index 91ae98584def..f373d13cd646 100644 --- a/app/models/project/stage_definition.rb +++ b/app/models/project/stage_definition.rb @@ -32,4 +32,8 @@ class Project::StageDefinition < Project::LifeCycleStepDefinition foreign_key: :definition_id, inverse_of: :definition, dependent: :destroy + + def step_class + Project::Stage + end end diff --git a/app/views/projects/settings/life_cycle_steps/index.html.erb b/app/views/projects/settings/life_cycle_steps/index.html.erb new file mode 100644 index 000000000000..05e2233b8a80 --- /dev/null +++ b/app/views/projects/settings/life_cycle_steps/index.html.erb @@ -0,0 +1,33 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 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. + +++#%> + +<%= render(Projects::Settings::LifeCycleSteps::IndexPageHeaderComponent.new(project: @project)) %> + +<%= render(Projects::Settings::LifeCycleSteps::IndexComponent.new(project: @project, + life_cycle_definitions: @life_cycle_definitions)) %> diff --git a/app/views/projects/settings/project_custom_fields/show.html.erb b/app/views/projects/settings/project_custom_fields/show.html.erb index 34103faae4c9..be4ac50fa5f2 100644 --- a/app/views/projects/settings/project_custom_fields/show.html.erb +++ b/app/views/projects/settings/project_custom_fields/show.html.erb @@ -26,11 +26,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -
- <%= render Projects::Settings::ProjectCustomFieldSections::IndexPageHeaderComponent.new(project: @project) %> - - <%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( - project: @project, - project_custom_field_sections: @project_custom_field_sections, - )) %> -
+<%= render(Projects::Settings::ProjectCustomFieldSections::IndexPageHeaderComponent.new(project: @project)) %> + +<%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( + project: @project, + project_custom_field_sections: @project_custom_field_sections, +)) %> diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 33bdb9cd0d25..da3a44c89786 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -624,23 +624,28 @@ allow_deeplink: true project_menu_items = { - general: :label_information_plural, - project_custom_fields: :label_project_attributes_plural, - modules: :label_module_plural, - types: :label_work_package_types, - custom_fields: :label_custom_field_plural, - versions: :label_version_plural, - categories: :label_work_package_category_plural, - repository: :label_repository, - time_entry_activities: :enumeration_activities, - storage: :label_required_disk_storage + general: { caption: :label_information_plural }, + life_cycle_steps: { + caption: :label_life_cycle_step_plural, + action: :index, + if: ->(*) { OpenProject::FeatureDecisions.stages_and_gates_active? } + }, + project_custom_fields: { caption: :label_project_attributes_plural }, + modules: { caption: :label_module_plural }, + types: { caption: :label_work_package_types }, + custom_fields: { caption: :label_custom_field_plural }, + versions: { caption: :label_version_plural }, + categories: { caption: :label_work_package_category_plural }, + repository: { caption: :label_repository }, + time_entry_activities: { caption: :enumeration_activities }, + storage: { caption: :label_required_disk_storage } } - project_menu_items.each do |key, caption| + project_menu_items.each do |key, options| menu.push :"settings_#{key}", - { controller: "/projects/settings/#{key}", action: "show" }, - caption:, - parent: :settings + { controller: "/projects/settings/#{key}", action: "show" }.merge(options.slice(:action)), + parent: :settings, + **options.except(:action) end end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 87f571f46a9d..d0ed1a2f597e 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -133,6 +133,14 @@ permissible_on: :project, require: :member + map.permission :select_project_life_cycle, + { + "projects/settings/life_cycle_steps": %i[index toggle enable_all disable_all] + }, + permissible_on: :project, + require: :member, + visible: -> { OpenProject::FeatureDecisions.stages_and_gates_active? } + map.permission :manage_members, { members: %i[index new create update destroy destroy_by_principal autocomplete_for_member menu], diff --git a/config/locales/en.yml b/config/locales/en.yml index 9657cfe546b0..e1be26ca815a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -378,6 +378,9 @@ en: text: "This action will not delete any project the list contains. Are you sure you want to delete this project list?" settings: change_identifier: Change identifier + actions: + label_enable_all: "Enable all" + label_disable_all: "Disable all" activities: no_results_title_text: There are currently no activities available. forums: @@ -388,10 +391,20 @@ en: no_results_content_text: Create a new work package category custom_fields: no_results_title_text: There are currently no custom fields available. + life_cycle: + header: + title: "Project lifecycle" + description_html: 'These project stages and gates in the project lifecycle will be displayed in your project overview page and are defined in the administration settings by the administrator of the instance. You can enable or disable individual attributes.' + non_defined: "Neither stages nor gates are currently defined." + section_header: "Stages and gates" + step: + use_in_project: "Use %{step} in this project" + filter: + label: "Search project stage or gate" project_custom_fields: header: title: "Project attributes" - description: + description_html: '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: @@ -400,8 +413,6 @@ en: label_enable_single: "Active in this project, click to disable" label_disable_single: "Inactive in this project, click to enable" remove_from_project: "Remove from project" - label_enable_all: "Enable all" - label_disable_all: "Disable all" is_required_blank_slate: heading: Required in all projects description: This project attribute is activated in all projects since the "Required in all projects" option is checked. It cannot be deactivated for individual projects. @@ -1403,6 +1414,8 @@ en: other: "Notifications" placeholder_user: "Placeholder user" project: "Project" + project/gate_definition: "Gate" + project/stage_definition: "Stage" project_query: one: "Project list" other: "Project lists" @@ -2579,6 +2592,7 @@ en: label_none_parentheses: "(none)" label_not_contains: "doesn't contain" label_not_equals: "is not" + label_life_cycle_step_plural: "Project lifecycle" label_on: "on" label_operator_all: "is not empty" label_operator_none: "is empty" @@ -3203,6 +3217,8 @@ en: permission_search_project: "Search project" permission_select_custom_fields: "Select custom fields" permission_select_project_custom_fields: "Select project attributes" + permission_select_project_life_cycle: "Select project stages and gates" + permission_select_project_life_cycle_explanation: "Activate/Deactivate the stages and gates in a project. Enables the user to select the lifecycle appropriate for the project as inactive stages and gates will not be visible in the project overview page nor the project list." permission_select_project_modules: "Select project modules" permission_share_work_packages: "Share work packages" permission_manage_types: "Select types" diff --git a/config/routes.rb b/config/routes.rb index 158ed8a6a293..5dd529cc78d9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -244,6 +244,15 @@ put :disable_all_of_section end end + resources :life_cycle_steps, only: %i[index], path: "life_cycle" do + member do + post :toggle + end + collection do + post :enable_all + post :disable_all + end + end resource :custom_fields, only: %i[show update] resource :repository, only: %i[show], controller: "repository" resource :versions, only: %i[show] @@ -518,6 +527,9 @@ delete :unlink end end + # TODO: This is for now only added to be able to create a link + # There is no controller behind this. + resources :project_life_cycle_step_definitions, path: "project_life_cycles", only: %i[index] resources :project_custom_field_sections, controller: "/admin/settings/project_custom_field_sections", only: %i[create update destroy] do member do diff --git a/db/migrate/20241125161226_unique_index_on_project_life_cycle_steps.rb b/db/migrate/20241125161226_unique_index_on_project_life_cycle_steps.rb new file mode 100644 index 000000000000..e2358eb64f92 --- /dev/null +++ b/db/migrate/20241125161226_unique_index_on_project_life_cycle_steps.rb @@ -0,0 +1,7 @@ +class UniqueIndexOnProjectLifeCycleSteps < ActiveRecord::Migration[7.1] + def change + add_index :project_life_cycle_steps, + %i[project_id definition_id], + unique: true + end +end diff --git a/db/migrate/20241127161228_grant_select_project_life_cycle_permission.rb b/db/migrate/20241127161228_grant_select_project_life_cycle_permission.rb new file mode 100644 index 000000000000..8309166220a7 --- /dev/null +++ b/db/migrate/20241127161228_grant_select_project_life_cycle_permission.rb @@ -0,0 +1,7 @@ +require_relative "migration_utils/permission_adder" + +class GrantSelectProjectLifeCyclePermission < ActiveRecord::Migration[7.1] + def up + ::Migration::MigrationUtils::PermissionAdder.add(:edit_project, :select_project_life_cycle) + end +end diff --git a/frontend/src/stimulus/controllers/dynamic/filter/filter-list.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/filter-list.controller.ts index 420be0098525..4272a74adf8d 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filter-list.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filter-list.controller.ts @@ -59,12 +59,23 @@ export default class FilterListController extends Controller { }); } + /** + * Filters the list of items based on the input value in the filter target. + * + * This method converts the input value to lowercase and compares it with the text content + * of each search item. To avoid unwanted text from being part of the search string. E.g. the + * labels of buttons, the method first tries to find an element with the class `filter-target-visible-text`. + * If such an element exists, only the content of that element is used for the search. + * If the text content includes the input value, the item is shown; + * otherwise, it is hidden. Additionally, it controls the visibility of the no results text + * based on whether any items match the input value. + */ filterLists() { const query = this.filterTarget.value.toLowerCase(); let showNoResultsText = true; this.searchItemTargets.forEach((item) => { - const text = item.textContent?.toLowerCase(); + const text = item.querySelector('.filter-target-visible-text')?.textContent?.toLowerCase() || item.textContent?.toLowerCase(); if (text?.includes(query)) { this.setVisibility(item, true); diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/border-box-filter.controller.ts similarity index 100% rename from frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts rename to frontend/src/stimulus/controllers/dynamic/projects/settings/border-box-filter.controller.ts diff --git a/lookbook/previews/open_project/projects/life_cycle_type_component_preview.rb b/lookbook/previews/open_project/projects/life_cycle_type_component_preview.rb new file mode 100644 index 000000000000..319c4b19c779 --- /dev/null +++ b/lookbook/previews/open_project/projects/life_cycle_type_component_preview.rb @@ -0,0 +1,14 @@ +module OpenProject + module Projects + # @logical_path OpenProject/Projects + class LifeCycleTypeComponentPreview < Lookbook::Preview + def gate + render_with_template(locals: { model: Project::GateDefinition.new(id: 1, name: "The first gate") }) + end + + def stage + render_with_template(locals: { model: Project::StageDefinition.new(id: 1, name: "The first stage") }) + end + end + end +end diff --git a/lookbook/previews/open_project/projects/life_cycle_type_component_preview/gate.html.erb b/lookbook/previews/open_project/projects/life_cycle_type_component_preview/gate.html.erb new file mode 100644 index 000000000000..442cbfcb2e3e --- /dev/null +++ b/lookbook/previews/open_project/projects/life_cycle_type_component_preview/gate.html.erb @@ -0,0 +1,6 @@ + + + +<%= render(::Projects::LifeCycleTypeComponent.new(model)) %> diff --git a/lookbook/previews/open_project/projects/life_cycle_type_component_preview/stage.html.erb b/lookbook/previews/open_project/projects/life_cycle_type_component_preview/stage.html.erb new file mode 100644 index 000000000000..442cbfcb2e3e --- /dev/null +++ b/lookbook/previews/open_project/projects/life_cycle_type_component_preview/stage.html.erb @@ -0,0 +1,6 @@ + + + +<%= render(::Projects::LifeCycleTypeComponent.new(model)) %> diff --git a/modules/backlogs/spec/features/resolved_status_spec.rb b/modules/backlogs/spec/features/resolved_status_spec.rb index ce72102dc5ed..3e094570a960 100644 --- a/modules/backlogs/spec/features/resolved_status_spec.rb +++ b/modules/backlogs/spec/features/resolved_status_spec.rb @@ -27,6 +27,7 @@ #++ require "spec_helper" +require_relative "../support/pages/projects/settings/backlogs" RSpec.describe "Resolved status" do let!(:project) do @@ -42,14 +43,14 @@ create(:user, member_with_roles: { project => role }) end - let(:settings_page) { Pages::Projects::Settings.new(project) } + let(:settings_page) { Pages::Projects::Settings::Backlogs.new(project) } before do login_as current_user end it "allows setting a status as done although it is not closed" do - settings_page.visit_tab! "backlogs" + settings_page.visit! check status.name click_button "Save" diff --git a/modules/backlogs/spec/support/pages/projects/settings/backlogs.rb b/modules/backlogs/spec/support/pages/projects/settings/backlogs.rb new file mode 100644 index 000000000000..8c68637908ef --- /dev/null +++ b/modules/backlogs/spec/support/pages/projects/settings/backlogs.rb @@ -0,0 +1,49 @@ +# -- 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. +# ++ + +require "support/pages/page" + +module Pages + module Projects + module Settings + class Backlogs < Pages::Page + attr_reader :project + + def initialize(project) + super() + + @project = project + end + + def path + "/projects/#{project.identifier}/settings/backlogs" + end + end + end + end +end diff --git a/spec/features/custom_fields/activate_in_project_spec.rb b/spec/features/custom_fields/activate_in_project_spec.rb index 796b0eef3b81..ea6fc23de27a 100644 --- a/spec/features/custom_fields/activate_in_project_spec.rb +++ b/spec/features/custom_fields/activate_in_project_spec.rb @@ -35,13 +35,13 @@ let(:for_all_cf) { create(:list_wp_custom_field, is_for_all: true) } let(:project_specific_cf) { create(:integer_wp_custom_field) } let(:work_package) do - wp = build(:work_package).tap do |wp| + build(:work_package) do |wp| wp.type.custom_fields = [for_all_cf, project_specific_cf] wp.save! end end let(:wp_page) { Pages::FullWorkPackage.new(work_package) } - let(:project_settings_page) { Pages::Projects::Settings.new(work_package.project) } + let(:project_settings_page) { Pages::Projects::Settings::WorkPackageCustomFields.new(work_package.project) } before do login_as user @@ -53,9 +53,9 @@ wp_page.expect_attributes "customField#{for_all_cf.id}": "-" wp_page.expect_no_attribute "customField#{project_specific_cf.id}" - project_settings_page.visit_tab!("custom_fields") + project_settings_page.visit! - project_settings_page.activate_wp_custom_field(project_specific_cf) + project_settings_page.activate(project_specific_cf) project_settings_page.save! diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 18253e9452a8..03be48add18b 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -164,8 +164,7 @@ context "with correct project custom field activations" do before do - original_settings_page = Pages::Projects::Settings.new(project) - original_settings_page.visit! + Pages::Projects::Settings::General.new(project).visit! find(".toolbar a", text: "Copy").click @@ -229,8 +228,7 @@ optional_project_custom_field_with_default.id ) - original_settings_page = Pages::Projects::Settings.new(project) - original_settings_page.visit! + Pages::Projects::Settings::General.new(project).visit! find(".toolbar a", text: "Copy").click @@ -302,8 +300,7 @@ end before do - original_settings_page = Pages::Projects::Settings.new(project) - original_settings_page.visit! + Pages::Projects::Settings::General.new(project).visit! find(".toolbar a", text: "Copy").click @@ -373,8 +370,7 @@ end it "copies the project attributes" do - original_settings_page = Pages::Projects::Settings.new(project) - original_settings_page.visit! + Pages::Projects::Settings::General.new(project).visit! find(".toolbar a", text: "Copy").click @@ -406,8 +402,7 @@ end it "copies projects and the associated objects" do - original_settings_page = Pages::Projects::Settings.new(project) - original_settings_page.visit! + Pages::Projects::Settings::General.new(project).visit! find(".toolbar a", text: "Copy").click @@ -433,8 +428,7 @@ # Will redirect to the new project automatically once the copy process is done expect(page).to have_current_path(Regexp.new("#{project_path(copied_project)}/?")) - copied_settings_page = Pages::Projects::Settings.new(copied_project) - copied_settings_page.visit! + Pages::Projects::Settings::General.new(copied_project).visit! # has the parent of the original project parent_field.expect_selected parent_project.name @@ -445,19 +439,21 @@ editor.expect_value "some text cf" # has wp custom fields of original project active - copied_settings_page.visit_tab!("custom_fields") + copied_settings_wp_cf_page = Pages::Projects::Settings::WorkPackageCustomFields.new(copied_project) + copied_settings_wp_cf_page.visit! - copied_settings_page.expect_wp_custom_field_active(wp_custom_field) - copied_settings_page.expect_wp_custom_field_inactive(inactive_wp_custom_field) + copied_settings_wp_cf_page.expect_active(wp_custom_field) + copied_settings_wp_cf_page.expect_inactive(inactive_wp_custom_field) # has types of original project active - copied_settings_page.visit_tab!("types") + copied_settings_type_page = Pages::Projects::Settings::Type.new(copied_project) + copied_settings_type_page.visit! active_types.each do |type| - copied_settings_page.expect_type_active(type) + copied_settings_type_page.expect_type_active(type) end - copied_settings_page.expect_type_inactive(inactive_type) + copied_settings_type_page.expect_type_inactive(inactive_type) # Expect wiki was copied expect(copied_project.wiki.pages.count).to eq(project.wiki.pages.count) @@ -540,8 +536,7 @@ wp_table.expect_work_package_listed *order wp_table.expect_work_package_order *order - original_settings_page = Pages::Projects::Settings.new(project) - original_settings_page.visit! + Pages::Projects::Settings::General.new(project).visit! find(".toolbar a", text: "Copy").click diff --git a/spec/features/projects/life_cycle/active_in_project_spec.rb b/spec/features/projects/life_cycle/active_in_project_spec.rb new file mode 100644 index 000000000000..c545b59004a4 --- /dev/null +++ b/spec/features/projects/life_cycle/active_in_project_spec.rb @@ -0,0 +1,148 @@ +# -- 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. +# ++ + +require "spec_helper" + +RSpec.describe "Projects life cycle settings", :js, :with_cuprite, with_flag: { stages_and_gates: true } do + shared_let(:project) { create(:project) } + + shared_let(:user_with_permission) do + create(:user, + member_with_permissions: { + project => %w[ + select_project_life_cycle + ] + }) + end + shared_let(:user_without_permission) do + create(:user, + member_with_permissions: { + project => %w[ + edit_project + ] + }) + end + + shared_let(:initiating_stage) { create(:project_stage_definition, name: "Initiating") } + shared_let(:ready_to_execute_gate) { create(:project_gate_definition, name: "Ready to Execute") } + shared_let(:executing_stage) { create(:project_stage_definition, name: "Executing") } + shared_let(:ready_to_close_gate) { create(:project_gate_definition, name: "Ready to Close") } + shared_let(:closing_stage) { create(:project_stage_definition, name: "Closing") } + + let(:project_life_cycle_page) { Pages::Projects::Settings::LifeCycle.new(project) } + + context "with sufficient permissions" do + current_user { user_with_permission } + + it "allows toggling the active/inactive state of lifecycle steps and filtering them" do + project_life_cycle_page.visit! + + project_life_cycle_page.expect_listed(initiating_stage => false, + ready_to_execute_gate => false, + executing_stage => false, + ready_to_close_gate => false, + closing_stage => false) + + # Activate the stages to be found within the project + project_life_cycle_page.toggle(initiating_stage) + project_life_cycle_page.toggle(ready_to_close_gate) + project_life_cycle_page.toggle(closing_stage) + + project_life_cycle_page.expect_listed(initiating_stage => true, + ready_to_execute_gate => false, + executing_stage => false, + ready_to_close_gate => true, + closing_stage => true) + + # Expect the activation state to be kept after a reload + project_life_cycle_page.reload_with_home_page_detour + + project_life_cycle_page.expect_listed(initiating_stage => true, + ready_to_execute_gate => false, + executing_stage => false, + ready_to_close_gate => true, + closing_stage => true) + + # Disable all stages at once + project_life_cycle_page.disable_all + + project_life_cycle_page.expect_listed(initiating_stage => false, + ready_to_execute_gate => false, + executing_stage => false, + ready_to_close_gate => false, + closing_stage => false) + + # Expect the activation state to be kept after a reload + project_life_cycle_page.reload_with_home_page_detour + + project_life_cycle_page.expect_listed(initiating_stage => false, + ready_to_execute_gate => false, + executing_stage => false, + ready_to_close_gate => false, + closing_stage => false) + + # Enable all stages at once + project_life_cycle_page.enable_all + + project_life_cycle_page.expect_listed(initiating_stage => true, + ready_to_execute_gate => true, + executing_stage => true, + ready_to_close_gate => true, + closing_stage => true) + + # Expect the activation state to be kept after a reload + project_life_cycle_page.reload_with_home_page_detour + + project_life_cycle_page.expect_listed(initiating_stage => true, + ready_to_execute_gate => true, + executing_stage => true, + ready_to_close_gate => true, + closing_stage => true) + + # The user can filter the life cycle steps + project_life_cycle_page.filter_by("ing") + + project_life_cycle_page.expect_listed(initiating_stage => true, + executing_stage => true, + closing_stage => true) + + project_life_cycle_page.expect_not_listed(ready_to_execute_gate, + ready_to_close_gate) + end + end + + context "without sufficient permissions" do + current_user { user_without_permission } + + it "does not allow the user to access the page" do + project_life_cycle_page.visit! + + project_life_cycle_page.expect_flash(message: "You are not authorized to access this page", type: :error) + end + end +end diff --git a/spec/features/projects/modules_spec.rb b/spec/features/projects/modules_spec.rb index 045ab880ed6a..69ea63ff35b8 100644 --- a/spec/features/projects/modules_spec.rb +++ b/spec/features/projects/modules_spec.rb @@ -34,7 +34,7 @@ end let(:permissions) { %i(edit_project select_project_modules view_work_packages) } - let(:settings_page) { Pages::Projects::Settings.new(project) } + let(:modules_settings_page) { Pages::Projects::Settings::Modules.new(project) } current_user do create(:user, member_with_permissions: { project => permissions }) @@ -43,7 +43,7 @@ it "allows adding and removing modules" do project_work_packages_menu_link_selector = '//ul[contains(@class, "menu_root")]//span[text()="Work packages"]' - settings_page.visit_tab!("modules") + modules_settings_page.visit! expect(page).to have_unchecked_field "Activity" expect(page).to have_unchecked_field "Calendar" @@ -95,10 +95,11 @@ create(:user, member_with_permissions: { project => %i(edit_project) }) end + let(:general_settings_page) { Pages::Projects::Settings::General.new(project) } before do login_as user_without_permission - settings_page.visit_tab!("general") + general_settings_page.visit! end it "I can't see the modules menu item" do diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb index f94c13871eee..3cc245f6fb6d 100644 --- a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -260,7 +260,7 @@ it "filters the project custom fields by name with given user input" do visit project_settings_project_custom_fields_path(project) - fill_in "project-custom-fields-mapping-filter", with: "Boolean" + fill_in "border-box-filter", with: "Boolean" within_custom_field_section_container(section_for_input_fields) do expect(page).to have_content("Boolean field") diff --git a/spec/features/types/activate_in_project_spec.rb b/spec/features/types/activate_in_project_spec.rb index 4f0f1e67042b..68a484b591ce 100644 --- a/spec/features/types/activate_in_project_spec.rb +++ b/spec/features/types/activate_in_project_spec.rb @@ -37,7 +37,7 @@ let!(:active_type) { create(:type) } let!(:type) { create(:type) } let!(:project) { create(:project, types: [active_type]) } - let(:project_settings_page) { Pages::Projects::Settings.new(project) } + let(:project_type_settings_page) { Pages::Projects::Settings::Type.new(project) } let(:work_packages_page) { Pages::WorkPackagesTable.new(project) } before do @@ -51,7 +51,7 @@ work_packages_page.expect_type_available_for_create(active_type) work_packages_page.expect_type_not_available_for_create(type) - project_settings_page.visit_tab!("types") + project_type_settings_page.visit! expect(page) .to have_unchecked_field(type.name) @@ -62,9 +62,9 @@ check(type.name) uncheck(active_type.name) - project_settings_page.save! + project_type_settings_page.save! - project_settings_page.expect_and_dismiss_flash(message: "Successful update.") + project_type_settings_page.expect_and_dismiss_flash(message: "Successful update.") expect(page) .to have_checked_field(type.name) diff --git a/spec/features/types/form_configuration_spec.rb b/spec/features/types/form_configuration_spec.rb index 0457df023ee0..0376c310c078 100644 --- a/spec/features/types/form_configuration_spec.rb +++ b/spec/features/types/form_configuration_spec.rb @@ -274,7 +274,7 @@ end describe "custom fields" do - let(:project_settings_page) { Pages::Projects::Settings.new(project) } + let(:project_cf_settings_page) { Pages::Projects::Settings::WorkPackageCustomFields.new(project) } let(:custom_fields) { [custom_field] } let(:custom_field) { create(:issue_custom_field, :integer, name: "MyNumber") } @@ -315,7 +315,7 @@ def add_cf_to_group wp_page.expect_attribute_hidden(cf_identifier_api) # Enable in project, should then be visible - project_settings_page.visit_tab!("custom_fields") + project_cf_settings_page.visit! expect(page).to have_css(".custom-field-#{custom_field.id} td", text: "MyNumber") expect(page).to have_css(".custom-field-#{custom_field.id} td", text: type.name) @@ -356,7 +356,7 @@ def add_cf_to_group end # Ensure CF is checked - project_settings_page.visit_tab!("custom_fields") + project_cf_settings_page.visit! expect(page).to have_css(".custom-field-#{custom_field.id} td", text: "MyNumber") expect(page).to have_css(".custom-field-#{custom_field.id} td", text: type.name) expect(page).to have_css("#project_work_package_custom_field_ids_#{custom_field.id}[checked]") diff --git a/spec/models/project/gate_definition_spec.rb b/spec/models/project/gate_definition_spec.rb index 7d69be5a91ef..f943a416d9b9 100644 --- a/spec/models/project/gate_definition_spec.rb +++ b/spec/models/project/gate_definition_spec.rb @@ -40,4 +40,10 @@ .dependent(:destroy) end end + + describe "#step_class" do + it "returns Project::Stage" do + expect(subject.step_class).to eq(Project::Gate) + end + end end diff --git a/spec/models/project/stage_definition_spec.rb b/spec/models/project/stage_definition_spec.rb index f642056d4a34..1d94467a546a 100644 --- a/spec/models/project/stage_definition_spec.rb +++ b/spec/models/project/stage_definition_spec.rb @@ -40,4 +40,10 @@ .dependent(:destroy) end end + + describe "#step_class" do + it "returns Project::Stage" do + expect(subject.step_class).to eq(Project::Stage) + end + end end diff --git a/spec/support/pages/projects/settings.rb b/spec/support/pages/projects/settings.rb deleted file mode 100644 index 7b698f23e05e..000000000000 --- a/spec/support/pages/projects/settings.rb +++ /dev/null @@ -1,91 +0,0 @@ -#-- copyright -# OpenProject is an open source project management software. -# Copyright (C) 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. -#++ - -require "support/pages/page" - -module Pages - module Projects - class Settings < Pages::Page - attr_accessor :project - - def initialize(project) - super() - - self.project = project - end - - def visit_tab!(name) - visit "/projects/#{project.identifier}/settings/#{name}" - end - - def expect_type_active(type) - expect_type(type, true) - end - - def expect_type_inactive(type) - expect_type(type, false) - end - - def expect_type(type, active = true) - expect(page) - .to have_field("project_planning_element_type_ids_#{type.id}", checked: active) - end - - def expect_wp_custom_field_active(custom_field) - expect_wp_custom_field(custom_field, true) - end - - def expect_wp_custom_field_inactive(custom_field) - expect_wp_custom_field(custom_field, false) - end - - def activate_wp_custom_field(custom_field) - check custom_field.name - end - - def save! - click_button "Save" - end - - def expect_wp_custom_field(custom_field, active = true) - expect(page) - .to have_field(custom_field.name, checked: active) - end - - def fieldset_label - find "fieldset#project_issue_custom_fields label" - end - - private - - def path - project_settings_general_path(project) - end - end - end -end diff --git a/spec/support/pages/projects/settings/general.rb b/spec/support/pages/projects/settings/general.rb new file mode 100644 index 000000000000..64f34ee9818e --- /dev/null +++ b/spec/support/pages/projects/settings/general.rb @@ -0,0 +1,49 @@ +# -- 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. +# ++ + +require "support/pages/page" + +module Pages + module Projects + module Settings + class General < Pages::Page + attr_reader :project + + def initialize(project) + super() + + @project = project + end + + def path + "/projects/#{project.identifier}/settings/general" + end + end + end + end +end diff --git a/spec/support/pages/projects/settings/life_cycle.rb b/spec/support/pages/projects/settings/life_cycle.rb new file mode 100644 index 000000000000..ab8ea8f0bcdd --- /dev/null +++ b/spec/support/pages/projects/settings/life_cycle.rb @@ -0,0 +1,115 @@ +# -- 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. +# ++ + +require "support/pages/page" + +module Pages + module Projects + module Settings + class LifeCycle < Pages::Page + attr_reader :project + + def initialize(project) + super() + + @project = project + end + + def path + "/projects/#{project.identifier}/settings/life_cycle" + end + + # Checks if the life cycle steps are listed in the order given and with the correct toggle state. + # @param life_cycle_definitions [Hash{LifeCycleElement => Boolean}] + def expect_listed(**life_cycle_steps) + life_cycle_steps.each_cons(2) do |(predecessor, _), (successor, _)| + expect(page).to have_css("#{life_cycle_test_selector(predecessor)} ~ #{life_cycle_test_selector(successor)}") + end + + life_cycle_steps.each do |step, active| + expect_toggle_state(step, active) + end + end + + def expect_not_listed(*life_cycle_steps) + life_cycle_steps.each do |step| + expect(page).to have_no_css(life_cycle_test_selector(step)) + end + end + + def expect_toggle_state(definition, active) + within toggle_step(definition) do + expect(page) + .to have_css(".ToggleSwitch-status#{expected_toggle_status(active)}"), + "Expected toggle for '#{definition.name}' to be #{expected_toggle_status(active)} " \ + "but was #{expected_toggle_status(!active)}" + end + end + + def toggle(definition) + toggle_step(definition).click + end + + def disable_all + find_test_selector("disable-all-life-cycle-steps").click + end + + def enable_all + find_test_selector("enable-all-life-cycle-steps").click + end + + def life_cycle_test_selector(definition) + test_selector("project-life-cycle-step-#{definition.id}") + end + + def toggle_step(definition) + find_test_selector("toggle-project-life-cycle-#{definition.id}") + end + + def filter_by(filter) + fill_in I18n.t("projects.settings.life_cycle.filter.label"), with: filter + end + + def expected_toggle_status(active) + active ? "On" : "Off" + end + + # Reloads the page and via the check carried out on the Home page + # a proper reload is ensured. + def reload_with_home_page_detour + visit home_path + + expect(page) + .to have_css(".PageHeader-title", text: "Home") + + visit! + end + end + end + end +end diff --git a/spec/support/pages/projects/settings/modules.rb b/spec/support/pages/projects/settings/modules.rb new file mode 100644 index 000000000000..f97681f7901f --- /dev/null +++ b/spec/support/pages/projects/settings/modules.rb @@ -0,0 +1,49 @@ +# -- 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. +# ++ + +require "support/pages/page" + +module Pages + module Projects + module Settings + class Modules < Pages::Page + attr_accessor :project + + def initialize(project) + super() + + self.project = project + end + + def path + "/projects/#{project.identifier}/settings/modules" + end + end + end + end +end diff --git a/spec/support/pages/projects/settings/type.rb b/spec/support/pages/projects/settings/type.rb new file mode 100644 index 000000000000..de34fe8566ac --- /dev/null +++ b/spec/support/pages/projects/settings/type.rb @@ -0,0 +1,66 @@ +# -- 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. +# ++ + +require "support/pages/page" + +module Pages + module Projects + module Settings + class Type < Pages::Page + attr_accessor :project + + def initialize(project) + super() + + self.project = project + end + + def path + "/projects/#{project.identifier}/settings/types" + end + + def expect_type_active(type) + expect_type(type, active: true) + end + + def expect_type_inactive(type) + expect_type(type, active: false) + end + + def expect_type(type, active: true) + expect(page) + .to have_field("project_planning_element_type_ids_#{type.id}", checked: active) + end + + def save! + click_link_or_button "Save" + end + end + end + end +end diff --git a/spec/support/pages/projects/settings/work_package_custom_fields.rb b/spec/support/pages/projects/settings/work_package_custom_fields.rb new file mode 100644 index 000000000000..4b80d0cfe0bf --- /dev/null +++ b/spec/support/pages/projects/settings/work_package_custom_fields.rb @@ -0,0 +1,70 @@ +# -- 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. +# ++ + +require "support/pages/page" + +module Pages + module Projects + module Settings + class WorkPackageCustomFields < Pages::Page + attr_accessor :project + + def initialize(project) + super() + + self.project = project + end + + def path + "/projects/#{project.identifier}/settings/custom_fields" + end + + def expect_active(custom_field) + expect_field(custom_field, active: true) + end + + def expect_inactive(custom_field) + expect_field(custom_field, active: false) + end + + def activate(custom_field) + check custom_field.name + end + + def expect_field(custom_field, active: true) + expect(page) + .to have_field(custom_field.name, checked: active) + end + + def save! + click_link_or_button "Save" + end + end + end + end +end