From 5885b03b8ae1d270f84bbcfc3216985471672e3c Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 18 Nov 2024 16:46:17 +0200 Subject: [PATCH 01/36] wip --- .../features/overview/overview.component.ts | 55 +++++++++++++-- .../grids/grid/page/grid-page.component.html | 22 +++++- .../sections/show_component.html.erb | 35 ++++++++++ .../sections/show_component.rb | 68 +++++++++++++++++++ .../side_panel_component.html.erb | 20 ++++++ .../side_panel_component.rb | 48 +++++++++++++ .../overviews/overviews_controller.rb | 35 +++++++++- .../project_life_cycles_sidebar.html.erb | 31 +++++++++ .../views/overviews/overviews/show.html.erb | 3 +- modules/overviews/config/routes.rb | 3 + modules/overviews/lib/overviews/engine.rb | 3 +- .../life_cycle/overview_page/sidebar_spec.rb | 58 ++++++++++++++++ 12 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb create mode 100644 modules/overviews/app/components/project_life_cycles/sections/show_component.rb create mode 100644 modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb create mode 100644 modules/overviews/app/components/project_life_cycles/side_panel_component.rb create mode 100644 modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb create mode 100644 spec/features/projects/life_cycle/overview_page/sidebar_spec.rb diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index 742139d19362..86e75ede5e39 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -1,3 +1,31 @@ +//-- 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. +//++ + import { ChangeDetectionStrategy, Component } from '@angular/core'; import { GridPageComponent } from 'core-app/shared/components/grids/grid/page/grid-page.component'; import { GRID_PROVIDERS } from 'core-app/shared/components/grids/grid/grid.component'; @@ -17,15 +45,34 @@ export class OverviewComponent extends GridPageComponent { } protected isTurboFrameSidebarEnabled():boolean { - const sidebarEnabledTag:HTMLMetaElement|null = document.querySelector('meta[name="sidebar_enabled"]'); - return sidebarEnabledTag?.dataset.enabled === 'true'; + return this.isCustomFieldsSidebarEnabled() || this.isLifeCyclesSidebarEnabled(); + } + + protected isCustomFieldsSidebarEnabled():boolean { + const customFieldsSidebarEnabledTag:HTMLMetaElement|null = document.querySelector('meta[name="custom_fields_sidebar_enabled"]'); + + return customFieldsSidebarEnabledTag?.dataset.enabled === 'true'; + } + + protected isLifeCyclesSidebarEnabled():boolean { + const lifeCyclesSidebarEnabledTag:HTMLMetaElement|null = document.querySelector('meta[name="life_cycles_sidebar_enabled"]'); + + return lifeCyclesSidebarEnabledTag?.dataset.enabled === 'true'; + } + + protected lifeCyclesSidebarSrc():string { + return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier ?? ''}/project_life_cycles_sidebar`; + } + + protected lifeCyclesSidebarId():string { + return 'project-life-cycles-sidebar'; } - protected turboFrameSidebarSrc():string { + protected projectCustomFieldsSidebarSrc():string { return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier ?? ''}/project_custom_fields_sidebar`; } - protected turboFrameSidebarId():string { + protected projectCustomFieldsSidebarId():string { return 'project-custom-fields-sidebar'; } diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index 01a5002f85d8..f3c82b9e54c7 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -17,7 +17,27 @@

- + + + + + + + + + + + + + diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb new file mode 100644 index 000000000000..cb1fcd5ac93f --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -0,0 +1,35 @@ +<%= + component_wrapper do + render(Primer::OpenProject::SidePanel::Section.new( + classes: 'op-project-custom-field-section-container', + test_selector: "project-custom-field-section-#{@project_custom_field_section.id}" + )) do |section| + section.with_title { "Life Cycles" } + + if allowed_to_edit? + section.with_action_icon( + icon: :pencil, + tag: :a, + href: project_custom_field_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), + data: { + controller: 'async-dialog' + }, + test_selector: "project-custom-field-section-edit-button", + aria: { label: I18n.t(:label_edit) } + ) + end + + flex_layout do |details_container| + @project_custom_fields.each_with_index do |project_custom_field, i| + margin = i == @project_custom_fields.size - 1 ? 0 : 3 + details_container.with_row(mb: margin) do + render(ProjectCustomFields::Sections::ProjectCustomFields::ShowComponent.new( + project_custom_field:, + project_custom_field_values: get_eager_loaded_project_custom_field_values_for(project_custom_field.id) + )) + end + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb new file mode 100644 index 000000000000..c629c990fa4d --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -0,0 +1,68 @@ +#-- 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. +#++ + +module ProjectLifeCycles + module Sections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:, project_custom_field_section:, project_custom_fields:) + super + + @project = project + @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_fields + + eager_load_project_custom_field_values + end + + private + + def allowed_to_edit? + User.current.allowed_in_project?(:edit_project_attributes, @project) + end + + def eager_load_project_custom_field_values + # TODO: move to service + @eager_loaded_project_custom_field_values = CustomValue + .includes(custom_field: :custom_options) + .where( + custom_field_id: @project_custom_fields.pluck(:id), + customized_id: @project.id + ) + .to_a + end + + def get_eager_loaded_project_custom_field_values_for(custom_field_id) + @eager_loaded_project_custom_field_values.select { |pcfv| pcfv.custom_field_id == custom_field_id } + end + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb b/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb new file mode 100644 index 000000000000..8af7ed711e3b --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb @@ -0,0 +1,20 @@ +<%= + component_wrapper do + if available_project_custom_fields_grouped_by_section.any? + render(Primer::OpenProject::SidePanel.new( + spacious: true, + test_selector: "project-life-cycles-sidebar-async-content" + )) do |panel| + available_project_custom_fields_grouped_by_section.each do |project_custom_field_section, project_custom_fields| + panel.with_section( + ProjectLifeCycles::Sections::ShowComponent.new( + project: @project, + project_custom_field_section:, + project_custom_fields: project_custom_fields + ) + ) + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/side_panel_component.rb b/modules/overviews/app/components/project_life_cycles/side_panel_component.rb new file mode 100644 index 000000000000..c21cba59c501 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/side_panel_component.rb @@ -0,0 +1,48 @@ +#-- 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. +#++ + +module ProjectLifeCycles + class SidePanelComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:) + super + + @project = project + end + + private + + def available_project_custom_fields_grouped_by_section + @available_project_custom_fields_grouped_by_section ||= + @project.available_custom_fields.group_by(&:project_custom_field_section) + end + end +end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 9f1e34476aff..07eba12d7c49 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -1,3 +1,31 @@ +# -- 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. +# ++ + module ::Overviews class OverviewsController < ::Grids::BaseInProjectController include OpTurbo::ComponentStream @@ -16,6 +44,10 @@ def project_custom_fields_sidebar render :project_custom_fields_sidebar, layout: false end + def project_life_cycles_sidebar + render :project_life_cycles_sidebar, layout: false + end + def project_custom_field_section_dialog respond_with_dialog( ProjectCustomFields::Sections::EditDialogComponent.new( @@ -63,9 +95,10 @@ def find_project_custom_field_section end def set_sidebar_enabled - @sidebar_enabled = + @custom_fields_sidebar_enabled = User.current.allowed_in_project?(:view_project_attributes, @project) && @project.project_custom_fields.visible.any? + @life_cycles_sidebar_enabled = OpenProject::FeatureDecisions.stages_and_gates_active? end def handle_errors(project_with_errors, section) diff --git a/modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb b/modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb new file mode 100644 index 000000000000..1b05538bb3c7 --- /dev/null +++ b/modules/overviews/app/views/overviews/overviews/project_life_cycles_sidebar.html.erb @@ -0,0 +1,31 @@ +<%#-- 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. + +++#%> +<%= content_tag("turbo-frame", id: "project-life-cycles-sidebar") do %> + <%= render(ProjectLifeCycles::SidePanelComponent.new(project: @project)) %> +<% end %> diff --git a/modules/overviews/app/views/overviews/overviews/show.html.erb b/modules/overviews/app/views/overviews/overviews/show.html.erb index cce3768ff982..a323d0ca45e8 100644 --- a/modules/overviews/app/views/overviews/overviews/show.html.erb +++ b/modules/overviews/app/views/overviews/overviews/show.html.erb @@ -1,5 +1,6 @@ <% content_for :header_tags do %> - + + <% end -%> <%= diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index 9a007f2a0e7e..8acce99b77c2 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -9,5 +9,8 @@ as: :project_custom_field_section_dialog put "projects/:project_id/update_project_custom_values/:section_id", to: "overviews/overviews#update_project_custom_values", as: :update_project_custom_values + + get "projects/:project_id/project_life_cycles_sidebar", to: "overviews/overviews#project_life_cycles_sidebar", + as: :project_life_cycles_sidebar end end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index eb934b7c033b..df4464731f7f 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -47,7 +47,8 @@ class Engine < ::Rails::Engine OpenProject::AccessControl.permission(:view_project) .controller_actions .push( - "overviews/overviews/show" + "overviews/overviews/show", + "overviews/overviews/project_life_cycles_sidebar" ) OpenProject::AccessControl.permission(:view_project_attributes) diff --git a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb new file mode 100644 index 000000000000..3e1107cdc7b2 --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb @@ -0,0 +1,58 @@ +#-- 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 "spec_helper" + +RSpec.describe "Show project life cycles on project overview page", :js, :with_cuprite, with_flag: { stages_and_gates: true } do + shared_let(:admin) { create(:admin) } + shared_let(:project) { create(:project, name: "Foo project", identifier: "foo-project") } + + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + login_as admin + end + + it "does show the sidebar" do + overview_page.visit_page + + within ".op-grid-page" do + expect(page).to have_css(".op-grid-page--sidebar") + end + end + + context "when stages and gates are disabled", with_flag: { stages_and_gates: false } do + it "does not show the sidebar" do + overview_page.visit_page + + within ".op-grid-page" do + expect(page).to have_no_css(".op-grid-page--sidebar") + end + end + end +end From 96d1f2eac2cded84e72f651a4033310143afbf4b Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 18 Nov 2024 19:05:58 +0200 Subject: [PATCH 02/36] Display life cycles in the sidebar --- app/models/project/gate.rb | 4 ++ app/models/project/life_cycle_step.rb | 2 + app/models/project/stage.rb | 4 ++ .../show_component.html.erb | 23 +++++++ .../project_life_cycles/show_component.rb | 63 +++++++++++++++++++ .../sections/show_component.html.erb | 25 ++------ .../sections/show_component.rb | 22 +------ .../side_panel_component.html.erb | 20 ++---- .../side_panel_component.rb | 7 --- 9 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb create mode 100644 modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb diff --git a/app/models/project/gate.rb b/app/models/project/gate.rb index 46ee6fffdb74..c2342505d2d2 100644 --- a/app/models/project/gate.rb +++ b/app/models/project/gate.rb @@ -39,4 +39,8 @@ def end_date_not_allowed errors.add(:base, :end_date_not_allowed) end end + + def not_set? + date.blank? + end end diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index d4ffcf438427..abccc560a707 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -33,6 +33,8 @@ class Project::LifeCycleStep < ApplicationRecord class_name: "Project::LifeCycleStepDefinition" has_many :work_packages, inverse_of: :project_life_cycle_step, dependent: :nullify + delegate :name, to: :life_cycle + attr_readonly :definition_id, :type validates :type, inclusion: { in: %w[Project::Stage Project::Gate], message: :must_be_a_stage_or_gate } diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb index b49ca7787b71..cf9e456add5f 100644 --- a/app/models/project/stage.rb +++ b/app/models/project/stage.rb @@ -30,4 +30,8 @@ class Project::Stage < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Stage], message: :must_be_a_stage } validates :start_date, :end_date, presence: true + + def not_set? + start_date.blank? || end_date.blank? + end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb new file mode 100644 index 000000000000..ac45e374d7d8 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb @@ -0,0 +1,23 @@ +<%= + flex_layout(align_items: :flex_start, + justify_content: :space_between, + classes: 'op-project-life-cycle-container', + data: { + test_selector: "project-life-cycle-#{@project_life_cycle.id}" + }) do |life_cycle_value_container| + # temporarily using inline styles in order to align the content as desired + life_cycle_container.with_row(mb: 1) do + render(Primer::Beta::Text.new(font_weight: :bold)) do + concat @project_life_cycle.name + end + end + + life_cycle_container.with_row(w: :full) do + if not_set? + render(Primer::Beta::Text.new()) { t('placeholders.default') } + else + render_value + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb new file mode 100644 index 000000000000..9f54f0713855 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb @@ -0,0 +1,63 @@ +#-- 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. +#++ + +module ProjectLifeCycles + module Sections + module ProjectLifeCycles + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + + def initialize(project_life_cycle:) + super + + @project_life_cycle = project_life_cycle + end + + private + + def not_set? + @project_life_cycle.not_set? + end + + def render_value + case @project_life_cycle + when Project::Gate + render(Primer::Beta::Text.new) do + concat @project_life_cycle.date + end + when Project::Stage + render(Primer::Beta::Text.new) do + concat [@project_life_cycle.start_date, " - ", @project_life_cycle.end_date].join + end + end + end + end + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index cb1fcd5ac93f..4a0c5187d9c7 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -1,31 +1,18 @@ <%= component_wrapper do render(Primer::OpenProject::SidePanel::Section.new( - classes: 'op-project-custom-field-section-container', - test_selector: "project-custom-field-section-#{@project_custom_field_section.id}" + classes: 'op-project-life-cyle-section-container', + test_selector: "project-life-cycle-section" )) do |section| section.with_title { "Life Cycles" } - if allowed_to_edit? - section.with_action_icon( - icon: :pencil, - tag: :a, - href: project_custom_field_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), - data: { - controller: 'async-dialog' - }, - test_selector: "project-custom-field-section-edit-button", - aria: { label: I18n.t(:label_edit) } - ) - end flex_layout do |details_container| - @project_custom_fields.each_with_index do |project_custom_field, i| - margin = i == @project_custom_fields.size - 1 ? 0 : 3 + @project_life_cycles.each_with_index do |project_life_cycle, i| + margin = i == @project_life_cycles.size - 1 ? 0 : 3 details_container.with_row(mb: margin) do - render(ProjectCustomFields::Sections::ProjectCustomFields::ShowComponent.new( - project_custom_field:, - project_custom_field_values: get_eager_loaded_project_custom_field_values_for(project_custom_field.id) + render(ProjectLifeCycles::Sections::ProjectLifeCycles::ShowComponent.new( + project_life_cycle: )) end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb index c629c990fa4d..a2788ed9a989 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -33,14 +33,11 @@ class ShowComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable - def initialize(project:, project_custom_field_section:, project_custom_fields:) + def initialize(project:) super @project = project - @project_custom_field_section = project_custom_field_section - @project_custom_fields = project_custom_fields - - eager_load_project_custom_field_values + @project_life_cycles = @project.project_life_cycles.includes(:life_cycle) end private @@ -48,21 +45,6 @@ def initialize(project:, project_custom_field_section:, project_custom_fields:) def allowed_to_edit? User.current.allowed_in_project?(:edit_project_attributes, @project) end - - def eager_load_project_custom_field_values - # TODO: move to service - @eager_loaded_project_custom_field_values = CustomValue - .includes(custom_field: :custom_options) - .where( - custom_field_id: @project_custom_fields.pluck(:id), - customized_id: @project.id - ) - .to_a - end - - def get_eager_loaded_project_custom_field_values_for(custom_field_id) - @eager_loaded_project_custom_field_values.select { |pcfv| pcfv.custom_field_id == custom_field_id } - end end end end diff --git a/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb b/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb index 8af7ed711e3b..763a30ed12c3 100644 --- a/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/side_panel_component.html.erb @@ -1,20 +1,10 @@ <%= component_wrapper do - if available_project_custom_fields_grouped_by_section.any? - render(Primer::OpenProject::SidePanel.new( - spacious: true, - test_selector: "project-life-cycles-sidebar-async-content" - )) do |panel| - available_project_custom_fields_grouped_by_section.each do |project_custom_field_section, project_custom_fields| - panel.with_section( - ProjectLifeCycles::Sections::ShowComponent.new( - project: @project, - project_custom_field_section:, - project_custom_fields: project_custom_fields - ) - ) - end - end + render(Primer::OpenProject::SidePanel.new( + spacious: true, + test_selector: "project-life-cycles-sidebar-async-content" + )) do |panel| + panel.with_section(ProjectLifeCycles::Sections::ShowComponent.new(project: @project)) end end %> diff --git a/modules/overviews/app/components/project_life_cycles/side_panel_component.rb b/modules/overviews/app/components/project_life_cycles/side_panel_component.rb index c21cba59c501..d837969ffa31 100644 --- a/modules/overviews/app/components/project_life_cycles/side_panel_component.rb +++ b/modules/overviews/app/components/project_life_cycles/side_panel_component.rb @@ -37,12 +37,5 @@ def initialize(project:) @project = project end - - private - - def available_project_custom_fields_grouped_by_section - @available_project_custom_fields_grouped_by_section ||= - @project.available_custom_fields.group_by(&:project_custom_field_section) - end end end From 479b9c892845677431b29a644883a48df417b628 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 25 Nov 2024 19:17:04 +0200 Subject: [PATCH 03/36] Update variable names --- app/models/project/life_cycle_step.rb | 4 +++- .../project_life_cycles/show_component.html.erb | 6 +++--- .../sections/project_life_cycles/show_component.rb | 12 ++++++------ .../sections/show_component.html.erb | 7 +++---- .../project_life_cycles/sections/show_component.rb | 2 +- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index abccc560a707..c4e6cc58876e 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -33,12 +33,14 @@ class Project::LifeCycleStep < ApplicationRecord class_name: "Project::LifeCycleStepDefinition" has_many :work_packages, inverse_of: :project_life_cycle_step, dependent: :nullify - delegate :name, to: :life_cycle + delegate :name, to: :definition attr_readonly :definition_id, :type validates :type, inclusion: { in: %w[Project::Stage Project::Gate], message: :must_be_a_stage_or_gate } + scope :active, -> { where(active: true) } + def initialize(*args) if instance_of? Project::LifeCycleStep # Do not allow directly instantiating this class diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb index ac45e374d7d8..00e6527f89f8 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb @@ -3,12 +3,12 @@ justify_content: :space_between, classes: 'op-project-life-cycle-container', data: { - test_selector: "project-life-cycle-#{@project_life_cycle.id}" - }) do |life_cycle_value_container| + test_selector: "project-life-cycle-#{@life_cycle_step.id}" + }) do |life_cycle_container| # temporarily using inline styles in order to align the content as desired life_cycle_container.with_row(mb: 1) do render(Primer::Beta::Text.new(font_weight: :bold)) do - concat @project_life_cycle.name + concat @life_cycle_step.name end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb index 9f54f0713855..3687f4b162fb 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb @@ -33,27 +33,27 @@ class ShowComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - def initialize(project_life_cycle:) + def initialize(life_cycle_step:) super - @project_life_cycle = project_life_cycle + @life_cycle_step = life_cycle_step end private def not_set? - @project_life_cycle.not_set? + @life_cycle_step.not_set? end def render_value - case @project_life_cycle + case @life_cycle_step when Project::Gate render(Primer::Beta::Text.new) do - concat @project_life_cycle.date + concat @life_cycle_step.date end when Project::Stage render(Primer::Beta::Text.new) do - concat [@project_life_cycle.start_date, " - ", @project_life_cycle.end_date].join + concat [@life_cycle_step.start_date, " - ", @life_cycle_step.end_date].join end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index 4a0c5187d9c7..611ddbba64d5 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -6,13 +6,12 @@ )) do |section| section.with_title { "Life Cycles" } - flex_layout do |details_container| - @project_life_cycles.each_with_index do |project_life_cycle, i| - margin = i == @project_life_cycles.size - 1 ? 0 : 3 + @life_cycle_steps.each_with_index do |life_cycle_step, i| + margin = i == @life_cycle_steps.size - 1 ? 0 : 3 details_container.with_row(mb: margin) do render(ProjectLifeCycles::Sections::ProjectLifeCycles::ShowComponent.new( - project_life_cycle: + life_cycle_step: )) end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb index a2788ed9a989..22335c8562c8 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -37,7 +37,7 @@ def initialize(project:) super @project = project - @project_life_cycles = @project.project_life_cycles.includes(:life_cycle) + @life_cycle_steps = @project.life_cycle_steps.active.includes(:definition) end private From f0c3df9bccec0630a37fa5867e80374c86ee7a85 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:54:31 +0200 Subject: [PATCH 04/36] Add Stages and Gates permissions --- config/initializers/permissions.rb | 12 ++ config/locales/en.yml | 2 + ...11225_add_project_life_cycle_step_roles.rb | 39 +++++ .../add_project_life_cycle_step_roles_spec.rb | 135 ++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 db/migrate/20241126111225_add_project_life_cycle_step_roles.rb create mode 100644 spec/migrations/add_project_life_cycle_step_roles_spec.rb diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index e1a2f82b1ebc..8d369cee59bd 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -133,6 +133,18 @@ permissible_on: :project, require: :member + map.permission :view_project_stages_and_gates, + {}, + permissible_on: :project, + dependencies: :view_project + + map.permission :edit_project_stages_and_gates, + {}, + permissible_on: :project, + require: :member, + dependencies: :view_project_stages_and_gates, + contract_actions: { projects: %i[update] } + map.permission :select_project_life_cycle, { "projects/settings/life_cycle_steps": %i[index toggle enable_all disable_all] diff --git a/config/locales/en.yml b/config/locales/en.yml index 711b60a0e861..8a6d759c0e66 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3220,6 +3220,7 @@ en: permission_edit_own_time_entries: "Edit own time logs" permission_edit_project: "Edit project" permission_edit_project_attributes: "Edit project attributes" + permission_edit_project_stages_and_gates: "Edit project stages and gates" permission_edit_reportings: "Edit reportings" permission_edit_time_entries: "Edit time logs for other users" permission_edit_timelines: "Edit timelines" @@ -3275,6 +3276,7 @@ en: permission_work_package_assigned_explanation: "Work packages can be assigned to users and groups in possession of this role in the respective project" permission_view_project_activity: "View project activity" permission_view_project_attributes: "View project attributes" + permission_view_project_stages_and_gates: "View project stages and gates" permission_save_bcf_queries: "Save BCF queries" permission_manage_public_bcf_queries: "Manage public BCF queries" permission_edit_attribute_help_texts: "Edit attribute help texts" diff --git a/db/migrate/20241126111225_add_project_life_cycle_step_roles.rb b/db/migrate/20241126111225_add_project_life_cycle_step_roles.rb new file mode 100644 index 000000000000..e618a7696ae7 --- /dev/null +++ b/db/migrate/20241126111225_add_project_life_cycle_step_roles.rb @@ -0,0 +1,39 @@ +#-- 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 Rails.root.join("db/migrate/migration_utils/permission_adder") + +class AddProjectLifeCycleStepRoles < ActiveRecord::Migration[7.1] + def change + ::Migration::MigrationUtils::PermissionAdder + .add(:view_project, :view_project_stages_and_gates) + + ::Migration::MigrationUtils::PermissionAdder + .add(:edit_project, :edit_project_stages_and_gates) + end +end diff --git a/spec/migrations/add_project_life_cycle_step_roles_spec.rb b/spec/migrations/add_project_life_cycle_step_roles_spec.rb new file mode 100644 index 000000000000..d7317dd8ac1e --- /dev/null +++ b/spec/migrations/add_project_life_cycle_step_roles_spec.rb @@ -0,0 +1,135 @@ +#-- 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 "spec_helper" +require Rails.root.join("db/migrate/20241126111225_add_project_life_cycle_step_roles.rb") + +RSpec.describe AddProjectLifeCycleStepRoles, type: :model do + # Silencing migration logs, since we are not interested in that during testing + subject { ActiveRecord::Migration.suppress_messages { described_class.new.change } } + + shared_examples_for "not changing permissions" do + it "is not changed" do + expect { subject }.not_to change { role.reload.permissions } + end + + it "does not adds any new permissions" do + expect { subject }.not_to change(RolePermission, :count) + end + end + + shared_examples_for "migration is idempotent" do + context "when the migration is ran twice" do + before { subject } + + it_behaves_like "not changing permissions" + end + end + + shared_examples_for "adding permissions" do |new_permissions| + it "adds the #{new_permissions} permissions for the role" do + public_permissions = OpenProject::AccessControl.public_permissions.map(&:name) + expect { subject }.to change { role.reload.permissions } + .from(match_array(permissions + public_permissions)) + .to match_array(permissions + public_permissions + new_permissions) + end + + it "adds #{new_permissions.size} new permissions" do + expect { subject }.to change(RolePermission, :count).by(new_permissions.size) + end + end + + context "for a role not eligible to view_project_stages_and_gates" do + let!(:role) do + create(:project_role, + add_public_permissions: false, + permissions: %i[permission1 permission2]) + end + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end + + context "for a role eligible to view_project_stages_and_gates" do + let(:permissions) { %i[view_project permission1 permission2] } + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "adding permissions", %i[view_project_stages_and_gates] + it_behaves_like "migration is idempotent" + end + + context "for a role with view_project_stages_and_gates" do + let(:permissions) { %i[view_project_stages_and_gates view_project permission1 permission2] } + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end + + context "for a role not eligible to edit_project_stages_and_gates" do + let(:permissions) do + %i[view_project_stages_and_gates permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end + + context "for a role eligible to edit_project_stages_and_gates having view_project_stages_and_gates" do + let(:permissions) do + %i[view_project_stages_and_gates edit_project + view_project permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "adding permissions", %i[edit_project_stages_and_gates] + it_behaves_like "migration is idempotent" + end + + context "for a role eligible to edit_project_stages_and_gates not having view_project_stages_and_gates" do + let(:permissions) do + %i[edit_project view_project permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "adding permissions", %i[edit_project_stages_and_gates view_project_stages_and_gates] + it_behaves_like "migration is idempotent" + end + + context "for a role that already has the edit_project_stages_and_gates and view_project_stages_and_gates permission" do + let(:permissions) do + %i[edit_project_stages_and_gates view_project_stages_and_gates + edit_project view_project permission1 permission2] + end + let!(:role) { create(:project_role, permissions:) } + + it_behaves_like "not changing permissions" + it_behaves_like "migration is idempotent" + end +end From a115cc50fca7b59121b45e379941f617e5bdd210 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 26 Nov 2024 20:34:43 +0200 Subject: [PATCH 05/36] Allow instantiation of Project::LifeCycleStepDefinition in order to let acts_as_list function correctly. --- .../project/life_cycle_step_definition.rb | 23 +++++++++++-------- .../life_cycle_step_definition_spec.rb | 4 ++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index 5f1181f326d0..7386a0ff4a56 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -42,15 +42,20 @@ class Project::LifeCycleStepDefinition < ApplicationRecord acts_as_list - def initialize(*args) - if instance_of? Project::LifeCycleStepDefinition - # Do not allow directly instantiating this class - raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStepDefinition class directly. " \ - "Use Project::StageDefinition or Project::GateDefinition instead." - end - - super - end + # TODO: Enabling this causes an error in the acts_as_customizable gem + # /gems/acts_as_list-1.2.4/lib/acts_as_list/active_record/acts/position_column_method_definer.rb + # define_singleton_method :touch_record_sql do + # new.touch_record_sql + # end + # Calling new will fail with the error below: + # def initialize(*args) + # if instance_of? Project::LifeCycleStepDefinition + # # Do not allow directly instantiating this class + # raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStepDefinition class directly. " \ + # "Use Project::StageDefinition or Project::GateDefinition instead." + # end + # super + # end def step_class raise NotImplementedError diff --git a/spec/models/project/life_cycle_step_definition_spec.rb b/spec/models/project/life_cycle_step_definition_spec.rb index 6251817bb2ef..ff58d203c5c6 100644 --- a/spec/models/project/life_cycle_step_definition_spec.rb +++ b/spec/models/project/life_cycle_step_definition_spec.rb @@ -29,8 +29,8 @@ require "rails_helper" RSpec.describe Project::LifeCycleStepDefinition do - it "cannot be instantiated" do - expect { described_class.new }.to raise_error(NotImplementedError) + it "can be instantiated" do + expect { described_class.new }.not_to raise_error end context "with a Project::StageDefinition" do From 13ce90b1995d9170bd970034f0250d5d48af4164 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:15:21 +0200 Subject: [PATCH 06/36] Rename overview page spec helper methods --- spec/features/projects/copy_spec.rb | 2 +- .../overview_page/dialog/permission_spec.rb | 8 +- .../overview_page/sidebar_spec.rb | 96 +++++++++---------- spec/support/pages/projects/show.rb | 4 +- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 2577e7c5a00c..1c2ae9555aeb 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -391,7 +391,7 @@ overview_page = Pages::Projects::Show.new(copied_project) overview_page.visit! - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do # User has no permission to edit project attributes. expect(page).to have_no_css("[data-test-selector='project-custom-field-section-edit-button']") # The custom fields are still copied from the parent project. diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb index df7bc3a8464c..ba44fca7a034 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb @@ -52,7 +52,7 @@ end it "shows the attributes sidebar" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_text("Input fields") end end @@ -65,7 +65,7 @@ end it "does not show the edit buttons" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_no_css("[data-test-selector='project-custom-field-section-edit-button']") end end @@ -81,7 +81,7 @@ end it "does not show the edit buttons" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_no_css("[data-test-selector='project-custom-field-section-edit-button']") end end @@ -94,7 +94,7 @@ end it "shows the edit buttons" do - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_css("[data-test-selector='project-custom-field-section-edit-button']", count: 3) end end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb index 23bb5d72ff2b..1f96df0b8a06 100644 --- a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -50,7 +50,7 @@ it "shows the project custom field sections in the correct order" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do sections = page.all(".op-project-custom-field-section-container") expect(sections.size).to eq(3) @@ -64,7 +64,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do sections = page.all(".op-project-custom-field-section-container") expect(sections.size).to eq(3) @@ -78,7 +78,7 @@ it "shows the project custom fields in the correct order within the sections" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_section_container(section_for_input_fields) do fields = page.all(".op-project-custom-field-container") @@ -118,7 +118,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_section_container(section_for_input_fields) do fields = page.all(".op-project-custom-field-container") @@ -140,7 +140,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do expect(page).to have_no_text "String field enabled for other project" end end @@ -152,7 +152,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text "Yes" @@ -170,7 +170,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text "No" @@ -187,7 +187,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text I18n.t("placeholders.default") @@ -200,7 +200,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text I18n.t("placeholders.default") @@ -211,7 +211,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(boolean_project_custom_field) do expect(page).to have_text "Boolean field" expect(page).to have_text I18n.t("placeholders.default") @@ -226,7 +226,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text "Foo" @@ -243,7 +243,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text I18n.t("placeholders.default") @@ -260,7 +260,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text I18n.t("placeholders.default") @@ -273,7 +273,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(string_project_custom_field) do expect(page).to have_text "String field" expect(page).to have_text I18n.t("placeholders.default") @@ -288,7 +288,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text "123" @@ -305,7 +305,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text I18n.t("placeholders.default") @@ -322,7 +322,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text I18n.t("placeholders.default") @@ -335,7 +335,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(integer_project_custom_field) do expect(page).to have_text "Integer field" expect(page).to have_text I18n.t("placeholders.default") @@ -350,7 +350,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text "01/01/2024" @@ -367,7 +367,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text I18n.t("placeholders.default") @@ -384,7 +384,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text I18n.t("placeholders.default") @@ -397,7 +397,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(date_project_custom_field) do expect(page).to have_text "Date field" expect(page).to have_text I18n.t("placeholders.default") @@ -412,7 +412,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text "123.456" @@ -429,7 +429,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text I18n.t("placeholders.default") @@ -446,7 +446,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text I18n.t("placeholders.default") @@ -459,7 +459,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(float_project_custom_field) do expect(page).to have_text "Float field" expect(page).to have_text I18n.t("placeholders.default") @@ -479,7 +479,7 @@ it "shows the correct value for the project custom field if given without truncation and dialog button" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text "Lorem ipsum" @@ -500,7 +500,7 @@ it "shows the correct value for the project custom field if given with truncation and dialog button" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text (("lorem " * 5).to_s) @@ -523,7 +523,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text I18n.t("placeholders.default") @@ -542,7 +542,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text I18n.t("placeholders.default") @@ -557,7 +557,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(text_project_custom_field) do expect(page).to have_text "Text field" expect(page).to have_text I18n.t("placeholders.default") @@ -574,7 +574,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text "Option 1" @@ -591,7 +591,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text I18n.t("placeholders.default") @@ -608,7 +608,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text I18n.t("placeholders.default") @@ -621,7 +621,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(list_project_custom_field) do expect(page).to have_text "List field" expect(page).to have_text I18n.t("placeholders.default") @@ -636,7 +636,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do expect(page).to have_text "Version field" expect(page).to have_text "Version 1" @@ -653,7 +653,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do expect(page).to have_text "Version field" expect(page).to have_text I18n.t("placeholders.default") @@ -670,7 +670,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(version_project_custom_field) do expect(page).to have_text "Version field" expect(page).to have_text I18n.t("placeholders.default") @@ -685,7 +685,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text "User field" expect(page).to have_css("opce-principal") @@ -703,7 +703,7 @@ it "shows the correct value for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text "User field" expect(page).to have_text I18n.t("placeholders.default") @@ -720,7 +720,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(user_project_custom_field) do expect(page).to have_text "User field" expect(page).to have_text I18n.t("placeholders.default") @@ -735,7 +735,7 @@ it "shows the correct values for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text "Multi list field" expect(page).to have_text "Option 1, Option 2" @@ -752,7 +752,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text "Multi list field" expect(page).to have_text I18n.t("placeholders.default") @@ -766,7 +766,7 @@ overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_list_project_custom_field) do expect(page).to have_text "Multi list field" expect(page).to have_text I18n.t("placeholders.default") @@ -781,7 +781,7 @@ it "shows the correct values for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_version_project_custom_field) do expect(page).to have_text "Multi version field" expect(page).to have_text "Version 1, Version 2" @@ -798,7 +798,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_version_project_custom_field) do expect(page).to have_text "Multi version field" expect(page).to have_text I18n.t("placeholders.default") @@ -813,7 +813,7 @@ it "shows the correct values for the project custom field if given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do expect(page).to have_text "Multi user field" expect(page).to have_css "opce-principal", count: 2 @@ -832,7 +832,7 @@ it "shows an N/A text for the project custom field if no value given" do overview_page.visit_page - overview_page.within_async_loaded_sidebar do + overview_page.within_project_attributes_sidebar do overview_page.within_custom_field_container(multi_user_project_custom_field) do expect(page).to have_text "Multi user field" expect(page).to have_text I18n.t("placeholders.default") diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 7e1ea638a052..9cf4b2f662e3 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -56,7 +56,7 @@ def expect_no_visible_sidebar expect(page).to have_no_css(".op-grid-page--grid-container") end - def within_async_loaded_sidebar(&) + def within_project_attributes_sidebar(&) within "#project-custom-fields-sidebar" do expect(page).to have_css("[data-test-selector='project-custom-fields-sidebar-async-content']") yield @@ -72,7 +72,7 @@ def within_custom_field_container(custom_field, &) end def open_edit_dialog_for_section(section) - within_async_loaded_sidebar do + within_project_attributes_sidebar do scroll_to_element(page.find("[data-test-selector='project-custom-field-section-#{section.id}']")) within_custom_field_section_container(section) do page.find("[data-test-selector='project-custom-field-section-edit-button']").click From 9b82812d4ec7f0f0adc176fbcc7e3263489ed909 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:23:47 +0200 Subject: [PATCH 07/36] Add sidebar specs for displaying life cycles --- .../project_life_cycles/show_component.rb | 7 +- .../sections/show_component.rb | 3 +- .../overviews/overviews_controller.rb | 5 +- .../overview_page/shared_context.rb | 103 +++++++++++++++++ .../life_cycle/overview_page/sidebar_spec.rb | 109 ++++++++++++++++-- spec/support/pages/projects/show.rb | 16 +++ 6 files changed, 232 insertions(+), 11 deletions(-) create mode 100644 spec/features/projects/life_cycle/overview_page/shared_context.rb diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb index 3687f4b162fb..0cff2b6d13ae 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb @@ -49,11 +49,14 @@ def render_value case @life_cycle_step when Project::Gate render(Primer::Beta::Text.new) do - concat @life_cycle_step.date + concat helpers.format_date(@life_cycle_step.date) end when Project::Stage render(Primer::Beta::Text.new) do - concat [@life_cycle_step.start_date, " - ", @life_cycle_step.end_date].join + concat [ + helpers.format_date(@life_cycle_step.start_date), + helpers.format_date(@life_cycle_step.end_date) + ].join(" - ") end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb index 22335c8562c8..25c27bb29456 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -37,7 +37,8 @@ def initialize(project:) super @project = project - @life_cycle_steps = @project.life_cycle_steps.active.includes(:definition) + @life_cycle_steps = + @project.life_cycle_steps.active.eager_load(:definition).order(position: :asc) end private diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 07eba12d7c49..368a388eb9fd 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -98,7 +98,10 @@ def set_sidebar_enabled @custom_fields_sidebar_enabled = User.current.allowed_in_project?(:view_project_attributes, @project) && @project.project_custom_fields.visible.any? - @life_cycles_sidebar_enabled = OpenProject::FeatureDecisions.stages_and_gates_active? + @life_cycles_sidebar_enabled = + OpenProject::FeatureDecisions.stages_and_gates_active? && + User.current.allowed_in_project?(:view_project_stages_and_gates, @project) && + @project.life_cycle_steps.active.any? end def handle_errors(project_with_errors, section) diff --git a/spec/features/projects/life_cycle/overview_page/shared_context.rb b/spec/features/projects/life_cycle/overview_page/shared_context.rb new file mode 100644 index 000000000000..89aaa454e34e --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/shared_context.rb @@ -0,0 +1,103 @@ +#-- 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. +#++ + +RSpec.shared_context "with seeded projects and stages and gates" do + shared_let(:project) { create(:project, name: "Foo project", identifier: "foo-project") } + shared_let(:standard) { create(:standard_global_role) } + shared_let(:admin) { create(:admin) } + + shared_let(:life_cycle_initiating_definition) do + create :project_stage_definition, name: "Initiating" + end + shared_let(:life_cycle_ready_for_planning_definition) do + create :project_gate_definition, name: "Ready for Planning" + end + shared_let(:life_cycle_planning_definition) do + create :project_stage_definition, name: "Planning" + end + shared_let(:life_cycle_ready_for_executing_definition) do + create :project_gate_definition, name: "Ready for Executing" + end + shared_let(:life_cycle_executing_definition) do + create :project_stage_definition, name: "Executing" + end + shared_let(:life_cycle_ready_for_closing_definition) do + create :project_gate_definition, name: "Ready for Closing" + end + shared_let(:life_cycle_closing_definition) do + create :project_stage_definition, name: "Closing" + end + + let(:life_cycle_initiating) do + create :project_stage, + definition: life_cycle_initiating_definition, + project: + end + let(:life_cycle_ready_for_planning) do + create :project_gate, + definition: life_cycle_ready_for_planning_definition, + project: + end + let(:life_cycle_planning) do + create :project_stage, + definition: life_cycle_planning_definition, + project: + end + let(:life_cycle_ready_for_executing) do + create :project_gate, + definition: life_cycle_ready_for_executing_definition, + project: + end + let(:life_cycle_executing) do + create :project_stage, + definition: life_cycle_executing_definition, + project: + end + let(:life_cycle_ready_for_closing) do + create :project_gate, + definition: life_cycle_ready_for_closing_definition, + project: + end + let(:life_cycle_closing) do + create :project_stage, + definition: life_cycle_closing_definition, + project: + end + + let!(:project_life_cycles) do + [ + life_cycle_initiating, + life_cycle_ready_for_planning, + life_cycle_planning, + life_cycle_ready_for_executing, + life_cycle_executing, + life_cycle_ready_for_closing, + life_cycle_closing + ] + end +end diff --git a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb index 3e1107cdc7b2..0b0b946e5195 100644 --- a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb @@ -27,10 +27,10 @@ #++ require "spec_helper" +require_relative "shared_context" RSpec.describe "Show project life cycles on project overview page", :js, :with_cuprite, with_flag: { stages_and_gates: true } do - shared_let(:admin) { create(:admin) } - shared_let(:project) { create(:project, name: "Foo project", identifier: "foo-project") } + include_context "with seeded projects and stages and gates" let(:overview_page) { Pages::Projects::Show.new(project) } @@ -40,18 +40,113 @@ it "does show the sidebar" do overview_page.visit_page + overview_page.expect_visible_sidebar + end - within ".op-grid-page" do - expect(page).to have_css(".op-grid-page--sidebar") + context "when stages and gates are disabled", with_flag: { stages_and_gates: false } do + it "does not show the sidebar" do + overview_page.visit_page + overview_page.expect_no_visible_sidebar end end - context "when stages and gates are disabled", with_flag: { stages_and_gates: false } do + context "when all stages and gates are disabled for this project" do + before do + project_life_cycles.each { |p| p.toggle!(:active) } + end + it "does not show the sidebar" do overview_page.visit_page + overview_page.expect_no_visible_sidebar + end + end + + describe "with correct order and scoping" do + it "shows the project stages and gates in the correct order" do + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + fields = page.all(".op-project-life-cycle-container") + + expect(fields.size).to eq(7) + + expect(fields[0].text).to include("Initiating") + expect(fields[1].text).to include("Ready for Planning") + expect(fields[2].text).to include("Planning") + expect(fields[3].text).to include("Ready for Executing") + expect(fields[4].text).to include("Executing") + expect(fields[5].text).to include("Ready for Closing") + expect(fields[6].text).to include("Closing") + end + + life_cycle_ready_for_executing_definition.move_to_bottom + + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + fields = page.all(".op-project-life-cycle-container") + + expect(fields.size).to eq(7) + + expect(fields[0].text).to include("Initiating") + expect(fields[1].text).to include("Ready for Planning") + expect(fields[2].text).to include("Planning") + expect(fields[3].text).to include("Executing") + expect(fields[4].text).to include("Ready for Closing") + expect(fields[5].text).to include("Closing") + expect(fields[6].text).to include("Ready for Executing") + end + end + + it "does not show stages and gates not enabled for this project in a sidebar" do + life_cycle_ready_for_executing.toggle!(:active) + + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + expect(page).to have_no_text life_cycle_ready_for_executing.name + end + end + end + + describe "with correct values" do + describe "with values set" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_life_cycles_sidebar do + project_life_cycles.each do |life_cycle| + overview_page.within_life_cycle_container(life_cycle) do + expected_date = if life_cycle.is_a? Project::Stage + [ + life_cycle.start_date.strftime("%m/%d/%Y"), + life_cycle.end_date.strftime("%m/%d/%Y") + ].join(" - ") + else + life_cycle.start_date.strftime("%m/%d/%Y") + end + expect(page).to have_text expected_date + end + end + end + end + end + + describe "with no values" do + before do + Project::LifeCycleStep.update_all(start_date: nil, end_date: nil) + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page - within ".op-grid-page" do - expect(page).to have_no_css(".op-grid-page--sidebar") + overview_page.within_life_cycles_sidebar do + project_life_cycles.each do |life_cycle| + overview_page.within_life_cycle_container(life_cycle) do + expect(page).to have_text I18n.t("placeholders.default") + end + end + end end end end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 9cf4b2f662e3..fb246d90ec57 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -56,6 +56,11 @@ def expect_no_visible_sidebar expect(page).to have_no_css(".op-grid-page--grid-container") end + def expect_visible_sidebar + expect_angular_frontend_initialized + expect(page).to have_css(".op-grid-page--grid-container") + end + def within_project_attributes_sidebar(&) within "#project-custom-fields-sidebar" do expect(page).to have_css("[data-test-selector='project-custom-fields-sidebar-async-content']") @@ -82,6 +87,17 @@ def open_edit_dialog_for_section(section) expect(page).to have_css("[data-test-selector='async-dialog-content']", wait: 5) end + def within_life_cycles_sidebar(&) + within "#project-life-cycles-sidebar" do + expect(page).to have_css("[data-test-selector='project-life-cycles-sidebar-async-content']") + yield + end + end + + def within_life_cycle_container(life_cycle, &) + within("[data-test-selector='project-life-cycle-#{life_cycle.id}']", &) + end + def expand_text(custom_field) within_custom_field_container(custom_field) do page.find('[data-test-selector="expand-button"]').click From b60407ed6e113215b807b8ea7913f8767d7b88d1 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:07:06 +0200 Subject: [PATCH 08/36] Add view permission specs --- .../sections/show_component.html.erb | 2 +- .../overview_page/dialog/permission_spec.rb | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index 611ddbba64d5..f26a88cef748 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -4,7 +4,7 @@ classes: 'op-project-life-cyle-section-container', test_selector: "project-life-cycle-section" )) do |section| - section.with_title { "Life Cycles" } + section.with_title { t('label_life_cycle_plural') } flex_layout do |details_container| @life_cycle_steps.each_with_index do |life_cycle_step, i| diff --git a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb new file mode 100644 index 000000000000..a049bb751ec5 --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb @@ -0,0 +1,64 @@ +#-- 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 "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project stages and gates on project overview page", :js, :with_cuprite, + with_flag: { stages_and_gates: true } do + include_context "with seeded projects and stages and gates" + let(:user) { create(:user) } + let(:overview_page) { Pages::Projects::Show.new(project) } + let(:permissions) { [] } + + before do + allow(User).to receive(:current).and_return user + mock_permissions_for(user) do |mock| + mock.allow_in_project(*permissions, project:) # any project + end + overview_page.visit_page + end + + describe "with insufficient View Stages and Gates permissions" do + let(:permissions) { %i[view_project] } + + it "does not show the attributes sidebar" do + overview_page.expect_no_visible_sidebar + end + end + + describe "with sufficient View Stages and Gates permissions" do + let(:permissions) { %i[view_project view_project_stages_and_gates] } + + it "shows the attributes sidebar" do + overview_page.within_life_cycles_sidebar do + expect(page).to have_text("Project lifecycle") + end + end + end +end From bfe6a7fef734071d07649dd3dbeb1bce55e1f370 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 26 Nov 2024 22:40:45 +0200 Subject: [PATCH 09/36] Add edit permission specs --- .../sections/show_component.html.erb | 13 ++++++ .../sections/show_component.rb | 2 +- .../overviews/overviews_controller.rb | 19 +++++++-- modules/overviews/config/routes.rb | 4 ++ modules/overviews/lib/overviews/engine.rb | 13 ++++++ .../overview_page/dialog/permission_spec.rb | 19 +++++++++ .../edit_project_life_cycles_spec.rb | 41 +++++++++++++++++++ .../view_project_life_cycles_spec.rb | 38 +++++++++++++++++ 8 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 spec/permissions/edit_project_life_cycles_spec.rb create mode 100644 spec/permissions/view_project_life_cycles_spec.rb diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index f26a88cef748..05fe5aa93fc9 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -6,6 +6,19 @@ )) do |section| section.with_title { t('label_life_cycle_plural') } + if allowed_to_edit? + section.with_action_icon( + icon: :pencil, + tag: :a, + href: project_life_cycles_dialog_path(project_id: @project.id), + data: { + controller: 'async-dialog' + }, + test_selector: "project-life-cycles-edit-button", + aria: { label: I18n.t(:label_edit) } + ) + end + flex_layout do |details_container| @life_cycle_steps.each_with_index do |life_cycle_step, i| margin = i == @life_cycle_steps.size - 1 ? 0 : 3 diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb index 25c27bb29456..f7af8e54be14 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -44,7 +44,7 @@ def initialize(project:) private def allowed_to_edit? - User.current.allowed_in_project?(:edit_project_attributes, @project) + User.current.allowed_in_project?(:edit_project_stages_and_gates, @project) end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 368a388eb9fd..62cb78ce1108 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -44,10 +44,6 @@ def project_custom_fields_sidebar render :project_custom_fields_sidebar, layout: false end - def project_life_cycles_sidebar - render :project_life_cycles_sidebar, layout: false - end - def project_custom_field_section_dialog respond_with_dialog( ProjectCustomFields::Sections::EditDialogComponent.new( @@ -81,6 +77,21 @@ def update_project_custom_values respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) end + def project_life_cycles_sidebar + render :project_life_cycles_sidebar, layout: false + end + + def project_life_cycles_dialog + respond_with_dialog( + ProjectCustomFields::Sections::EditDialogComponent.new( + project: @project, + project_custom_field_section: ProjectCustomFieldSection.first + ) + ) + end + + def update_project_life_cycles; end + def jump_to_project_menu_item if params[:jump] # try to redirect to the requested menu item diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index 8acce99b77c2..dcdfc2889792 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -12,5 +12,9 @@ get "projects/:project_id/project_life_cycles_sidebar", to: "overviews/overviews#project_life_cycles_sidebar", as: :project_life_cycles_sidebar + get "projects/:project_id/project_life_cycles_dialog", to: "overviews/overviews#project_life_cycles_dialog", + as: :project_life_cycles_dialog + put "projects/:project_id/update_project_life_cycles", to: "overviews/overviews#update_project_life_cycles", + as: :update_project_life_cycles end end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index df4464731f7f..761a8f015093 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -64,6 +64,19 @@ class Engine < ::Rails::Engine "overviews/overviews/update_project_custom_values" ) + OpenProject::AccessControl.permission(:view_project_stages_and_gates) + .controller_actions + .push( + "overviews/overviews/project_life_cycles_sidebar" + ) + + OpenProject::AccessControl.permission(:edit_project_stages_and_gates) + .controller_actions + .push( + "overviews/overviews/project_life_cycles_dialog", + "overviews/overviews/update_project_life_cycles" + ) + OpenProject::AccessControl.permission(:view_work_packages) .controller_actions .push( diff --git a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb index a049bb751ec5..1623d8ae85d6 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb @@ -60,5 +60,24 @@ expect(page).to have_text("Project lifecycle") end end + + describe "with Edit project permissions" do + let(:permissions) { [:view_project, :view_project_stages_and_gates, :edit_project] } + + it "does not show the edit buttons" do + overview_page.within_life_cycles_sidebar do + expect(page).to have_no_css("[data-test-selector='project-life-cycles-edit-button']") + end + end + end + + describe "with sufficient Edit Stages and Gates permissions" do + let(:permissions) { [:view_project, :view_project_stages_and_gates, :edit_project, :edit_project_stages_and_gates] } + + it "shows the edit buttons" do + overview_page.within_life_cycles_sidebar do + expect(page).to have_css("[data-test-selector='project-life-cycles-edit-button']") + end + end end end diff --git a/spec/permissions/edit_project_life_cycles_spec.rb b/spec/permissions/edit_project_life_cycles_spec.rb new file mode 100644 index 000000000000..50840e76efb3 --- /dev/null +++ b/spec/permissions/edit_project_life_cycles_spec.rb @@ -0,0 +1,41 @@ +#-- 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 "spec_helper" +require File.expand_path("../support/permission_specs", __dir__) + +RSpec.describe Overviews::OverviewsController, "edit_project_life_cycles permission", # rubocop:disable RSpec/EmptyExampleGroup,RSpec/SpecFilePathFormat + type: :controller do + include PermissionSpecs + + # render dialog with inputs for editing project attributes with edit_project permission + check_permission_required_for("overviews/overviews#project_life_cycles_dialog", :edit_project_stages_and_gates) + + # update project attributes with edit_project permission, deeper permission check via contract in place + check_permission_required_for("overviews/overviews#update_project_life_cycles", :edit_project_stages_and_gates) +end diff --git a/spec/permissions/view_project_life_cycles_spec.rb b/spec/permissions/view_project_life_cycles_spec.rb new file mode 100644 index 000000000000..294c55fa8a68 --- /dev/null +++ b/spec/permissions/view_project_life_cycles_spec.rb @@ -0,0 +1,38 @@ +#-- 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 "spec_helper" +require File.expand_path("../support/permission_specs", __dir__) + +RSpec.describe Overviews::OverviewsController, "view_project_life_cycles permission", # rubocop:disable RSpec/EmptyExampleGroup,RSpec/SpecFilePathFormat + type: :controller do + include PermissionSpecs + + # render sidebar on project overview page with view_project permission + check_permission_required_for("overviews/overviews#project_life_cycles_sidebar", :view_project_stages_and_gates) +end From 002fdf35f4126218006f705b3b11e1553666ccea Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 28 Nov 2024 19:04:49 +0200 Subject: [PATCH 10/36] Add Coloured icons, initial rendering of the form fields --- app/forms/application_form.rb | 5 ++ app/forms/projects/life_cycles/form.rb | 81 +++++++++++++++++++ app/models/project.rb | 2 + .../sections/edit_component.html.erb | 18 +++++ .../sections/edit_component.rb | 41 ++++++++++ .../sections/edit_dialog_component.html.erb | 32 ++++++++ .../sections/edit_dialog_component.rb | 37 +++++++++ .../show_component.html.erb | 15 ++-- .../project_life_cycles/show_component.rb | 35 +++++--- .../sections/show_component.html.erb | 4 +- .../overviews/overviews_controller.rb | 5 +- 11 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 app/forms/projects/life_cycles/form.rb create mode 100644 modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb create mode 100644 modules/overviews/app/components/project_life_cycles/sections/edit_component.rb create mode 100644 modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb create mode 100644 modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb diff --git a/app/forms/application_form.rb b/app/forms/application_form.rb index cb52b5b980f0..2fe86546d088 100644 --- a/app/forms/application_form.rb +++ b/app/forms/application_form.rb @@ -40,6 +40,11 @@ def url_helpers Rails.application.routes.url_helpers end + # @return [ActionView::Base] the view helper instance + def helpers + @view_context.helpers + end + # @return [ActiveRecord::Base] the model instance given to the form builder def model @builder.object diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb new file mode 100644 index 000000000000..989e0a2267cc --- /dev/null +++ b/app/forms/projects/life_cycles/form.rb @@ -0,0 +1,81 @@ +#-- 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. +#++ +module Projects::LifeCycles + class Form < ApplicationForm + form do |f| + life_cycle_input(f) + end + + private + + def life_cycle_input(form) + case model + when Project::Stage + multi_value_life_cycle_input(form) + when Project::Gate + single_value_life_cycle_input(form) + else + raise NotImplementedError, "Unknown life cycle definition type #{model.class.name}" + end + end + + def single_value_life_cycle_input(form) + form.text_field name: :date, label: "#{icon} #{text}".html_safe, type: :date # rubocop:disable Rails/OutputSafety + end + + def multi_value_life_cycle_input(form) + helpers.angular_component_tag "opce-range-date-picker", + inputs: { + name: "my-datepicker", + value: model.start_date + } + form.text_field name: :start_date, label: "#{icon} #{text}".html_safe, type: :date # rubocop:disable Rails/OutputSafety + end + + def text + model.name + end + + def icon + icon_name = case model + when Project::Stage + :"git-commit" + when Project::Gate + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleForm with" + end + + render Primer::Beta::Octicon.new(icon: icon_name, classes: icon_color_class) + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model.definition) + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 89191c1eba8b..78ec43158e80 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -88,6 +88,8 @@ class Project < ApplicationRecord has_many :storages, through: :project_storages has_many :life_cycle_steps, class_name: "Project::LifeCycleStep", dependent: :destroy + accepts_nested_attributes_for :life_cycle_steps + store_attribute :settings, :deactivate_work_package_attachments, :boolean acts_as_favorable diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb new file mode 100644 index 000000000000..27eb079bfd74 --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -0,0 +1,18 @@ +<%= helpers.angular_component_tag 'opce-custom-modal-overlay' %> +<%= + component_wrapper do + primer_form_with( + id: "project-life-cycles-edit-form", + model:, + method: :put, + data: { turbo: true, turbo_stream: true, "test-selector": "async-dialog-content" }, + url: update_project_life_cycles_path(project_id: model.id), + ) do |f| + render(Primer::Forms::SpacingWrapper.new) do + f.fields_for(:life_cycle_steps, life_cycle_steps) do |lcf| + render(Projects::LifeCycles::Form.new(lcf)) + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb new file mode 100644 index 000000000000..d0016f8b313d --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb @@ -0,0 +1,41 @@ +#-- 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. +#++ + +module ProjectLifeCycles + module Sections + class EditComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def life_cycle_steps + model.life_cycle_steps.active.eager_load(:definition).order(position: :asc) + end + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb new file mode 100644 index 000000000000..0e51a1686c9e --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb @@ -0,0 +1,32 @@ +<%= + render(Primer::Alpha::Dialog.new(title: t("label_life_cycle_plural"), + size: :medium_portrait, + id: "edit-project-life-cycles-dialog")) do |d| + d.with_header(variant: :large) + d.with_body(classes: "Overlay-body_autocomplete_height") do + render(::ProjectLifeCycles::Sections::EditComponent.new(model)) + end + d.with_footer do + component_collection do |footer_collection| + footer_collection.with_component(Primer::ButtonComponent.new( + data: { + 'close-dialog-id': "edit-project-life-cycles-dialog" + } + )) do + t("button_cancel") + end + footer_collection.with_component(Primer::ButtonComponent.new( + scheme: :primary, + type: :submit, + form: "project-life-cycles-edit-form", + data: { + test_selector: 'save-project-life-cycles-button', + turbo: true + } + )) do + t("button_save") + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb new file mode 100644 index 000000000000..584b01af793b --- /dev/null +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb @@ -0,0 +1,37 @@ +#-- 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. +#++ + +module ProjectLifeCycles + module Sections + class EditDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + end + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb index 00e6527f89f8..ea3f87c3b8ab 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.html.erb @@ -3,12 +3,17 @@ justify_content: :space_between, classes: 'op-project-life-cycle-container', data: { - test_selector: "project-life-cycle-#{@life_cycle_step.id}" + test_selector: "project-life-cycle-#{model.id}" }) do |life_cycle_container| - # temporarily using inline styles in order to align the content as desired - life_cycle_container.with_row(mb: 1) do - render(Primer::Beta::Text.new(font_weight: :bold)) do - concat @life_cycle_step.name + life_cycle_container.with_row(mb: 1, flex_layout: true, ) do |row| + row.with_column(mr: 1, classes: icon_color_class) do + render Primer::Beta::Octicon.new(icon:) + end + + row.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + text + end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb index 0cff2b6d13ae..4c2e552c8d84 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb @@ -33,33 +33,46 @@ class ShowComponent < ApplicationComponent include ApplicationHelper include OpPrimer::ComponentHelpers - def initialize(life_cycle_step:) - super - - @life_cycle_step = life_cycle_step - end - private def not_set? - @life_cycle_step.not_set? + model.not_set? end def render_value - case @life_cycle_step + case model when Project::Gate render(Primer::Beta::Text.new) do - concat helpers.format_date(@life_cycle_step.date) + concat helpers.format_date(model.date) end when Project::Stage render(Primer::Beta::Text.new) do concat [ - helpers.format_date(@life_cycle_step.start_date), - helpers.format_date(@life_cycle_step.end_date) + helpers.format_date(model.start_date), + helpers.format_date(model.end_date) ].join(" - ") end end end + + def icon + case model + when Project::Stage + :"git-commit" + when Project::Gate + :diamond + else + raise NotImplementedError, "Unknown model #{model.class} to render a LifeCycleForm with" + end + end + + def icon_color_class + helpers.hl_inline_class("life_cycle_step_definition", model.definition) + end + + def text + model.name + end end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index 05fe5aa93fc9..2724aaa0aaf2 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -4,7 +4,7 @@ classes: 'op-project-life-cyle-section-container', test_selector: "project-life-cycle-section" )) do |section| - section.with_title { t('label_life_cycle_plural') } + section.with_title { t("label_life_cycle_plural") } if allowed_to_edit? section.with_action_icon( @@ -24,7 +24,7 @@ margin = i == @life_cycle_steps.size - 1 ? 0 : 3 details_container.with_row(mb: margin) do render(ProjectLifeCycles::Sections::ProjectLifeCycles::ShowComponent.new( - life_cycle_step: + life_cycle_step )) end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 62cb78ce1108..c45c13901603 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -83,10 +83,7 @@ def project_life_cycles_sidebar def project_life_cycles_dialog respond_with_dialog( - ProjectCustomFields::Sections::EditDialogComponent.new( - project: @project, - project_custom_field_section: ProjectCustomFieldSection.first - ) + ProjectLifeCycles::Sections::EditDialogComponent.new(@project) ) end From 56445a9770353135ad965adccab3cee07e1941ee Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 2 Dec 2024 18:09:09 +0200 Subject: [PATCH 11/36] Add DatePicker component and dsl for single_date_picker and range_date_picker form fields --- .../basic-range-date-picker.component.html | 1 + .../basic-range-date-picker.component.ts | 3 + .../basic-single-date-picker.component.ts | 4 +- .../datepicker/styles/datepicker.modal.sass | 6 +- .../open_project/forms/date_picker.html.erb | 23 ++++++++ lib/primer/open_project/forms/date_picker.rb | 17 ++++++ .../open_project/forms/dsl/input_methods.rb | 30 ++++++---- .../forms/dsl/range_date_picker_input.rb | 21 +++++++ .../forms/dsl/single_date_picker_input.rb | 33 +++++++++++ .../open_project/common/datepicker_preview.rb | 12 ++-- .../common/datepicker_preview/range.html.erb | 59 ++++++++++++++++++- .../common/datepicker_preview/single.html.erb | 59 ++++++++++++++++++- 12 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 lib/primer/open_project/forms/date_picker.html.erb create mode 100644 lib/primer/open_project/forms/date_picker.rb create mode 100644 lib/primer/open_project/forms/dsl/range_date_picker_input.rb create mode 100644 lib/primer/open_project/forms/dsl/single_date_picker_input.rb diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html index 6c9a4f8699d3..cc5f6d13f9bf 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html @@ -8,6 +8,7 @@ [attr.data-value]="value" [id]="id" [name]="name" + [attr.name]="name" [required]="required" [disabled]="disabled" [ngModel]="stringValue" diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts index 35f9f3494f69..699eef974892 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts @@ -115,6 +115,8 @@ export class OpBasicRangeDatePickerComponent implements OnInit, ControlValueAcce @Input() inputClassNames = ''; + @Input() inDialog = false; + @ViewChild('input') input:ElementRef; stringValue = ''; @@ -199,6 +201,7 @@ export class OpBasicRangeDatePickerComponent implements OnInit, ControlValueAcce !!this.minimalDate && dayElem.dateObj <= this.minimalDate, ); }, + static: this.inDialog, }, this.input.nativeElement as HTMLInputElement, ); diff --git a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts index 130ab9e59b10..6ebc84272fd7 100644 --- a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts @@ -52,7 +52,6 @@ import { DayElement } from 'flatpickr/dist/types/instance'; import { populateInputsFromDataset } from '../../dataset-inputs'; import { DeviceService } from 'core-app/core/browser/device.service'; - @Component({ selector: 'op-basic-single-date-picker', templateUrl: './basic-single-date-picker.component.html', @@ -96,6 +95,8 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O @Input() remoteFieldKey = null; + @Input() inDialog = false; + @ViewChild('input') input:ElementRef; mobile = false; @@ -179,6 +180,7 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O !!this.minimalDate && dayElem.dateObj <= this.minimalDate, ); }, + static: this.inDialog, }, this.input.nativeElement as HTMLInputElement, ); diff --git a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass index 35d68546881f..904eb15044cd 100644 --- a/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass +++ b/frontend/src/app/shared/components/datepicker/styles/datepicker.modal.sass @@ -1,6 +1,5 @@ @import '../../app/spot/styles/sass/variables' @import '../../global_styles/openproject/variables' - .op-datepicker-modal display: flex flex-direction: column @@ -88,3 +87,8 @@ &--date-form &:only-child grid-column: 1 / 3 + +.flatpickr-wrapper + // Make flatpickr behave correctly when it is instantiated + // inside a dialog using the static: true option. + width: 100% diff --git a/lib/primer/open_project/forms/date_picker.html.erb b/lib/primer/open_project/forms/date_picker.html.erb new file mode 100644 index 000000000000..04599700e834 --- /dev/null +++ b/lib/primer/open_project/forms/date_picker.html.erb @@ -0,0 +1,23 @@ +<%= render(FormControl.new(input: @input, tag: :"primer-datepicker-field")) do %> + <%= content_tag(:div, **@field_wrap_arguments) do %> + <%# leading spinner implies a leading visual %> + <% if @input.leading_visual || @input.leading_spinner? %> + + <%= render(Primer::Beta::Octicon.new(**@input.leading_visual, data: { target: "primer-text-field.leadingVisual" })) %> + <% if @input.leading_spinner? %> + <%= render(Primer::Beta::Spinner.new(size: :small, hidden: true, data: { target: "primer-text-field.leadingSpinner" })) %> + <% end %> + + <% end %> + <%= render Primer::ConditionalWrapper.new(condition: @input.auto_check_src, tag: "auto-check", csrf: auto_check_authenticity_token, src: @input.auto_check_src) do %> + <%= angular_component_tag @datepicker_options.fetch(:component), + inputs: @datepicker_options.merge( + id: @datepicker_options.fetch(:id) { builder.field_id(@input.name) }, + name: @datepicker_options.fetch(:name) { builder.field_name(@input.name) }, + value: @datepicker_options.fetch(:value) { @input.input_arguments[:value] }, + inputClassNames: @datepicker_options.fetch(:class) { @input.input_arguments[:class] } + ) + %> + <% end %> + <% end %> +<% end %> diff --git a/lib/primer/open_project/forms/date_picker.rb b/lib/primer/open_project/forms/date_picker.rb new file mode 100644 index 000000000000..f16834892046 --- /dev/null +++ b/lib/primer/open_project/forms/date_picker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + # :nodoc: + class DatePicker < Primer::Forms::TextField + include AngularHelper + + def initialize(input:, datepicker_options:) + super(input:) + @datepicker_options = datepicker_options + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index e2151a300dd5..eca21f89ef75 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -6,31 +6,39 @@ module Forms module Dsl module InputMethods def autocompleter(**, &) - add_input AutocompleterInput.new(builder: @builder, form: @form, **, &) + add_input AutocompleterInput.new(builder:, form:, **, &) end - def work_package_autocompleter(**, &) - add_input WorkPackageAutocompleterInput.new(builder: @builder, form: @form, **, &) + def color_select_list(**, &) + add_input ColorSelectInput.new(builder:, form:, **, &) + end + + def html_content(**, &) + add_input HtmlContentInput.new(builder:, form:, **, &) end def project_autocompleter(**, &) - add_input ProjectAutocompleterInput.new(builder: @builder, form: @form, **, &) + add_input ProjectAutocompleterInput.new(builder:, form:, **, &) + end + + def range_date_picker(**) + add_input RangeDatePickerInput.new(builder:, form:, **) end def rich_text_area(**) - add_input RichTextAreaInput.new(builder: @builder, form: @form, **) + add_input RichTextAreaInput.new(builder:, form:, **) end - def storage_manual_project_folder_selection(**) - add_input StorageManualProjectFolderSelectionInput.new(builder: @builder, form: @form, **) + def single_date_picker(**) + add_input SingleDatePickerInput.new(builder:, form:, **) end - def color_select_list(**, &) - add_input ColorSelectInput.new(builder:, form:, **, &) + def storage_manual_project_folder_selection(**) + add_input StorageManualProjectFolderSelectionInput.new(builder:, form:, **) end - def html_content(**, &) - add_input HtmlContentInput.new(builder: @builder, form: @form, **, &) + def work_package_autocompleter(**, &) + add_input WorkPackageAutocompleterInput.new(builder:, form:, **, &) end end end diff --git a/lib/primer/open_project/forms/dsl/range_date_picker_input.rb b/lib/primer/open_project/forms/dsl/range_date_picker_input.rb new file mode 100644 index 000000000000..e3a5710228de --- /dev/null +++ b/lib/primer/open_project/forms/dsl/range_date_picker_input.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + module Dsl + class RangeDatePickerInput < SingleDatePickerInput + def derive_datepicker_options(options) + options.reverse_merge( + component: "opce-range-date-picker" + ) + end + + def type + :range_date_picker + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb new file mode 100644 index 000000000000..d62eed45b220 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + module Dsl + class SingleDatePickerInput < Primer::Forms::Dsl::TextFieldInput + attr_reader :datepicker_options + + def initialize(name:, label:, datepicker_options:, **system_arguments) + @datepicker_options = derive_datepicker_options(datepicker_options) + + super(name:, label:, **system_arguments) + end + + def derive_datepicker_options(options) + options.reverse_merge( + component: "opce-single-date-picker" + ) + end + + def to_component + DatePicker.new(input: self, datepicker_options:) + end + + def type + :single_date_picker + end + end + end + end + end +end diff --git a/lookbook/previews/open_project/common/datepicker_preview.rb b/lookbook/previews/open_project/common/datepicker_preview.rb index c9bdeaca41ef..4359c5d1dc24 100644 --- a/lookbook/previews/open_project/common/datepicker_preview.rb +++ b/lookbook/previews/open_project/common/datepicker_preview.rb @@ -28,8 +28,10 @@ class DatepickerPreview < Lookbook::Preview # before using or contributing to date pickers. # # @param value date - def single(value: Time.zone.today.iso8601) - render_with_template(locals: { value: }) + # @param in_dialog toggle + # @param icon [Symbol] octicon + def single(value: Time.zone.today, in_dialog: false, icon: :calendar) + render_with_template(locals: { value:, in_dialog:, icon: }) end ## @@ -48,8 +50,10 @@ def single(value: Time.zone.today.iso8601) # before using or contributing to date pickers. # # @param value text - def range(value: "#{Time.zone.today.iso8601} - #{Time.zone.today.iso8601}") - render_with_template(locals: { value: }) + # @param in_dialog toggle + # @param icon [Symbol] octicon + def range(value: "#{Time.zone.today.iso8601} - #{Time.zone.today.iso8601}", in_dialog: false, icon: :calendar) + render_with_template(locals: { value:, in_dialog:, icon: }) end end end diff --git a/lookbook/previews/open_project/common/datepicker_preview/range.html.erb b/lookbook/previews/open_project/common/datepicker_preview/range.html.erb index 559e35bba55a..ca2e4295e87e 100644 --- a/lookbook/previews/open_project/common/datepicker_preview/range.html.erb +++ b/lookbook/previews/open_project/common/datepicker_preview/range.html.erb @@ -1 +1,58 @@ -<%= tag :'opce-range-date-picker', value: %> +<% + the_form = Class.new(ApplicationForm) do + form do |query_form| + query_form.range_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.range_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.range_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value, + datepicker_options: { inDialog: in_dialog } + ) + end + end +%> + +<% if in_dialog %> + <%= render(Primer::Alpha::Dialog.new(title: "Dialog Title", + size: :large, + open: true, + id: "my-dialog")) do |d| %> + <% d.with_show_button { "Show dialog" } %> + <% d.with_header(variant: :medium) %> + + <%= render(Primer::Alpha::Dialog::Body.new) do + primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end + end %> + + <%= d.with_footer do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": "my-dialog" })) { I18n.t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, type: :submit, form: "my-form")) { I18n.t(:button_apply) } %> + <% end %> + <% end %> +<% else %> + <%= primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end %> +<% end %> diff --git a/lookbook/previews/open_project/common/datepicker_preview/single.html.erb b/lookbook/previews/open_project/common/datepicker_preview/single.html.erb index bd6afbdcdc15..8f5f34ef2765 100644 --- a/lookbook/previews/open_project/common/datepicker_preview/single.html.erb +++ b/lookbook/previews/open_project/common/datepicker_preview/single.html.erb @@ -1 +1,58 @@ -<%= tag :'opce-single-date-picker', value: %> +<% + the_form = Class.new(ApplicationForm) do + form do |query_form| + query_form.single_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value.iso8601, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.single_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value.iso8601, + datepicker_options: { inDialog: in_dialog } + ) + + query_form.single_date_picker( + name: :date, + label: 'Date', + leading_visual: { icon: }, + value: value.iso8601, + datepicker_options: { inDialog: in_dialog } + ) + end + end +%> + +<% if in_dialog %> + <%= render(Primer::Alpha::Dialog.new(title: "Dialog Title", + size: :large, + open: true, + id: "my-dialog")) do |d| %> + <% d.with_show_button { "Show dialog" } %> + <% d.with_header(variant: :medium) %> + + <%= render(Primer::Alpha::Dialog::Body.new) do + primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end + end %> + + <%= d.with_footer do %> + <%= render(Primer::Beta::Button.new(data: { "close-dialog-id": "my-dialog" })) { I18n.t(:button_cancel) } %> + <%= render(Primer::Beta::Button.new(scheme: :primary, type: :submit, form: "my-form")) { I18n.t(:button_apply) } %> + <% end %> + <% end %> +<% else %> + <%= primer_form_with( + url: '/abc', + id: "my-form") do |f| + render(the_form.new(f)) + end %> +<% end %> From ddf938c2c7c69c80a4f07529985583c516bc1e6d Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:13:59 +0200 Subject: [PATCH 12/36] Display the datepicker input fields on the life cycle edit form --- app/forms/projects/life_cycles/form.rb | 49 ++++++++++++++++--- .../sections/edit_component.html.erb | 5 +- .../sections/edit_dialog_component.html.erb | 2 +- 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index 989e0a2267cc..2df57d431c4f 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -44,17 +44,52 @@ def life_cycle_input(form) end end + def invalid? + model.errors.any? + end + + def validation_message + model.errors.full_messages.join(", ") if invalid? + end + + def qa_field_name + "life-cycle-step-#{model.id}" + end + + def base_input_attributes + { + label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety + leading_visual: { icon: :calendar }, + required: true, + invalid: invalid?, + validation_message:, + datepicker_options: { inDialog: true }, + wrapper_data_attributes: { + "qa-field-name": qa_field_name + } + } + end + def single_value_life_cycle_input(form) - form.text_field name: :date, label: "#{icon} #{text}".html_safe, type: :date # rubocop:disable Rails/OutputSafety + input_attributes = base_input_attributes.merge( + name: :start_date, + value: model.start_date + ) + + form.single_date_picker **input_attributes end def multi_value_life_cycle_input(form) - helpers.angular_component_tag "opce-range-date-picker", - inputs: { - name: "my-datepicker", - value: model.start_date - } - form.text_field name: :start_date, label: "#{icon} #{text}".html_safe, type: :date # rubocop:disable Rails/OutputSafety + value = [ + helpers.format_date(model.start_date), + helpers.format_date(model.end_date) + ].compact.join(" - ") + + input_attributes = base_input_attributes.merge( + name: :start_date, + value: + ) + form.range_date_picker **input_attributes end def text diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb index 27eb079bfd74..df496c4cb8f4 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -9,8 +9,9 @@ url: update_project_life_cycles_path(project_id: model.id), ) do |f| render(Primer::Forms::SpacingWrapper.new) do - f.fields_for(:life_cycle_steps, life_cycle_steps) do |lcf| - render(Projects::LifeCycles::Form.new(lcf)) + f.fields_for(:life_cycle_steps, life_cycle_steps) do |life_cycle_form| + render(Projects::LifeCycles::Form.new(life_cycle_form) + ) end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb index 0e51a1686c9e..94fa971e49a3 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb @@ -1,6 +1,6 @@ <%= render(Primer::Alpha::Dialog.new(title: t("label_life_cycle_plural"), - size: :medium_portrait, + size: :large, id: "edit-project-life-cycles-dialog")) do |d| d.with_header(variant: :large) d.with_body(classes: "Overlay-body_autocomplete_height") do From 8bb61b7ebbba6f117cb41a6a5bab810dadde80c9 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 3 Dec 2024 19:04:21 +0200 Subject: [PATCH 13/36] Add ProjecLifeCycleSteps::BaseContract and UpdateContract. --- .../project_life_cycle_steps/base_contract.rb | 42 +++++++++++++ .../update_contract.rb | 32 ++++++++++ .../base_contract_spec.rb | 60 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 app/contracts/project_life_cycle_steps/base_contract.rb create mode 100644 app/contracts/project_life_cycle_steps/update_contract.rb create mode 100644 spec/contracts/project_life_cycle_steps/base_contract_spec.rb diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb new file mode 100644 index 000000000000..db94880c1376 --- /dev/null +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -0,0 +1,42 @@ +#-- 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. +#++ + +module ProjectLifeCycleSteps + class BaseContract < ::ModelContract + attribute :project_id + attribute :date + + validate :select_custom_fields_permission + + def select_custom_fields_permission + return if user.allowed_in_project?(:edit_project_stages_and_gates, model.project) + + errors.add :base, :error_unauthorized + end + end +end diff --git a/app/contracts/project_life_cycle_steps/update_contract.rb b/app/contracts/project_life_cycle_steps/update_contract.rb new file mode 100644 index 000000000000..28b22227eaa4 --- /dev/null +++ b/app/contracts/project_life_cycle_steps/update_contract.rb @@ -0,0 +1,32 @@ +#-- 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. +#++ + +module ProjectLifeCycleSteps + class UpdateContract < BaseContract + end +end diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb new file mode 100644 index 000000000000..87ee661c0edc --- /dev/null +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +#-- 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 "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe ProjectLifeCycleSteps::BaseContract do + include_context "ModelContract shared context" + + let(:contract) { described_class.new(project_life_cycle_step, user) } + let(:user) { build_stubbed(:admin) } + let(:project_life_cycle_step) { build_stubbed(:project_gate) } + + context "with authorised user" do + let(:user) { build_stubbed(:user) } + + before do + mock_permissions_for(user) do |mock| + mock.allow_in_project(:edit_project_stages_and_gates, project: project_life_cycle_step.project) + end + end + + it_behaves_like "contract is valid" + end + + context "with unauthorised user" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" +end From bf658585a1e6ec598bbfc70dc38a80064c25a234 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 5 Dec 2024 11:32:22 +0200 Subject: [PATCH 14/36] Make form validations work for nested project life cycle attributes. --- .../project_life_cycle_steps/base_contract.rb | 5 +-- app/forms/projects/life_cycles/form.rb | 5 --- app/models/permitted_params.rb | 6 ++++ app/models/project.rb | 8 +++-- .../set_attributes_service.rb | 32 +++++++++++++++++++ .../update_service.rb | 32 +++++++++++++++++++ .../sections/edit_component.html.erb | 5 ++- .../overviews/overviews_controller.rb | 17 ++++++++-- 8 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 app/services/project_life_cycle_steps/set_attributes_service.rb create mode 100644 app/services/project_life_cycle_steps/update_service.rb diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb index db94880c1376..dfd78adcf195 100644 --- a/app/contracts/project_life_cycle_steps/base_contract.rb +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -28,13 +28,10 @@ module ProjectLifeCycleSteps class BaseContract < ::ModelContract - attribute :project_id - attribute :date - validate :select_custom_fields_permission def select_custom_fields_permission - return if user.allowed_in_project?(:edit_project_stages_and_gates, model.project) + return if user.allowed_in_project?(:edit_project_stages_and_gates, model) errors.add :base, :error_unauthorized end diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index 2df57d431c4f..41dd6331128b 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -48,10 +48,6 @@ def invalid? model.errors.any? end - def validation_message - model.errors.full_messages.join(", ") if invalid? - end - def qa_field_name "life-cycle-step-#{model.id}" end @@ -62,7 +58,6 @@ def base_input_attributes leading_visual: { icon: :calendar }, required: true, invalid: invalid?, - validation_message:, datepicker_options: { inDialog: true }, wrapper_data_attributes: { "qa-field-name": qa_field_name diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index ba22823578ba..7a7b1b1f5f99 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -290,6 +290,12 @@ def project whitelist.merge(custom_field_values(:project)) end + def project_life_cycles + params.require(:project).permit( + available_life_cycle_steps_attributes: %i[id date start_date end_date] + ) + end + def project_custom_field_project_mapping params.require(:project_custom_field_project_mapping) .permit(*self.class.permitted_attributes[:project_custom_field_project_mapping]) diff --git a/app/models/project.rb b/app/models/project.rb index 78ec43158e80..6226b48729c6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -87,8 +87,12 @@ class Project < ApplicationRecord has_many :project_storages, dependent: :destroy, class_name: "Storages::ProjectStorage" has_many :storages, through: :project_storages has_many :life_cycle_steps, class_name: "Project::LifeCycleStep", dependent: :destroy - - accepts_nested_attributes_for :life_cycle_steps + has_many :available_life_cycle_steps, + -> { active.eager_load(:definition).order(position: :asc) }, + class_name: "Project::LifeCycleStep", + inverse_of: :project, + dependent: :destroy + accepts_nested_attributes_for :available_life_cycle_steps store_attribute :settings, :deactivate_work_package_attachments, :boolean diff --git a/app/services/project_life_cycle_steps/set_attributes_service.rb b/app/services/project_life_cycle_steps/set_attributes_service.rb new file mode 100644 index 000000000000..c1603d42603b --- /dev/null +++ b/app/services/project_life_cycle_steps/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- 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. +#++ + +module ProjectLifeCycleSteps + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_life_cycle_steps/update_service.rb b/app/services/project_life_cycle_steps/update_service.rb new file mode 100644 index 000000000000..a8a5a33aa923 --- /dev/null +++ b/app/services/project_life_cycle_steps/update_service.rb @@ -0,0 +1,32 @@ +#-- 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. +#++ + +module ProjectLifeCycleSteps + class UpdateService < ::BaseServices::Update + end +end diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb index df496c4cb8f4..e5025709bae6 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -9,9 +9,8 @@ url: update_project_life_cycles_path(project_id: model.id), ) do |f| render(Primer::Forms::SpacingWrapper.new) do - f.fields_for(:life_cycle_steps, life_cycle_steps) do |life_cycle_form| - render(Projects::LifeCycles::Form.new(life_cycle_form) - ) + f.fields_for(:available_life_cycle_steps) do |life_cycle_form| + render(Projects::LifeCycles::Form.new(life_cycle_form)) end end end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index c45c13901603..946b89fbdf29 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -55,7 +55,6 @@ def project_custom_field_section_dialog def update_project_custom_values section = find_project_custom_field_section - service_call = ::Projects::UpdateService .new( user: current_user, @@ -87,7 +86,21 @@ def project_life_cycles_dialog ) end - def update_project_life_cycles; end + def update_project_life_cycles + service_call = ::ProjectLifeCycleSteps::UpdateService + .new(user: current_user, model: @project) + .call(permitted_params.project_life_cycles) + + if service_call.success? + update_sidebar_component + else + update_via_turbo_stream( + component: ::ProjectLifeCycles::Sections::EditComponent.new(service_call.result) + ) + end + + respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) + end def jump_to_project_menu_item if params[:jump] From 667d1d56f82163baeb620a9a89bfa35e8299a1b0 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:19:58 +0200 Subject: [PATCH 15/36] Add Project::Stage date_range setter and validation. --- app/models/project/stage.rb | 15 ++++++++- config/locales/en.yml | 6 +++- spec/models/project/gate_spec.rb | 2 +- spec/models/project/stage_spec.rb | 56 +++++++++++++++++++++++++++++-- 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb index cf9e456add5f..94e29cf4fd2f 100644 --- a/app/models/project/stage.rb +++ b/app/models/project/stage.rb @@ -29,9 +29,22 @@ class Project::Stage < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Stage], message: :must_be_a_stage } - validates :start_date, :end_date, presence: true + validate :validate_date_range + + def date_range=(param) + self.start_date, self.end_date = param.split(" - ") + self.end_date ||= start_date # Allow single dates as range + end def not_set? start_date.blank? || end_date.blank? end + + def validate_date_range + if not_set? + errors.add(:date_range, :blank) + elsif start_date > end_date + errors.add(:date_range, :start_date_must_be_before_end_date) + end + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8a6d759c0e66..8999c6dbd136 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1218,7 +1218,11 @@ en: project/gate: attributes: base: - end_date_not_allowed: "Cannot assign `end_date` to a Project::Gate" + end_date_not_allowed: "Cannot assign end date to a Project::Gate" + project/stage: + attributes: + date_range: + start_date_must_be_before_end_date: "Start date must be before the end date" query: attributes: project: diff --git a/spec/models/project/gate_spec.rb b/spec/models/project/gate_spec.rb index f634c63c1836..c0e7645e1f95 100644 --- a/spec/models/project/gate_spec.rb +++ b/spec/models/project/gate_spec.rb @@ -41,7 +41,7 @@ expect(subject).not_to be_valid expect(subject.errors[:base]) - .to include("Cannot assign `end_date` to a Project::Gate") + .to include("Cannot assign end date to a Project::Gate") end it "is valid if `end_date` is not present" do diff --git a/spec/models/project/stage_spec.rb b/spec/models/project/stage_spec.rb index 6fd727a34b27..3bd2d19cb190 100644 --- a/spec/models/project/stage_spec.rb +++ b/spec/models/project/stage_spec.rb @@ -33,8 +33,6 @@ it_behaves_like "a Project::LifeCycleStep event" describe "validations" do - it { is_expected.to validate_presence_of(:start_date) } - it { is_expected.to validate_presence_of(:end_date) } it { is_expected.to validate_inclusion_of(:type).in_array(["Project::Stage"]).with_message(:must_be_a_stage) } it "is valid when `start_date` and `end_date` are present" do @@ -42,4 +40,58 @@ expect(valid_stage).to be_valid end end + + describe "#not_set?" do + it "returns true if start_date or end_date is blank" do + expect(subject.not_set?).to be(true) + end + + it "returns false if both start_date and end_date are present" do + subject.start_date = Time.zone.today + subject.end_date = Date.tomorrow + expect(subject.not_set?).to be(false) + end + end + + describe "#date_range=" do + it "splits a valid date range string into start_date and end_date" do + subject.date_range = "2024-11-26 - 2024-11-27" + expect(subject.start_date).to eq(Date.parse("2024-11-26")) + expect(subject.end_date).to eq(Date.parse("2024-11-27")) + end + + it "sets end_date to start_date if a single date is provided" do + subject.date_range = "2024-11-26" + expect(subject.start_date).to eq(Date.parse("2024-11-26")) + expect(subject.end_date).to eq(Date.parse("2024-11-26")) + end + end + + describe "#validate_date_range" do + it "adds error if start_date is blank" do + subject.end_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:date_range)).to include(:blank) + end + + it "adds error if end_date is blank" do + subject.start_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:date_range)).to include(:blank) + end + + it "adds error if start_date is after end_date" do + subject.start_date = Date.tomorrow + subject.end_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:date_range)).to include(:start_date_must_be_before_end_date) + end + + it "does not add errors if start_date is before or equal to end_date" do + subject.start_date = Time.zone.today + subject.end_date = Time.zone.today + expect(subject).not_to be_valid + expect(subject.errors[:date_range]).to be_empty + end + end end From f29a9057131726d6ce35d1c61858b58603fe8ab8 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 6 Dec 2024 23:48:23 +0200 Subject: [PATCH 16/36] Remove condition for invalid LifeCycleStep initialization, add validation instead. --- app/models/project/life_cycle_step.rb | 11 ++++----- .../project/life_cycle_step_definition.rb | 24 +++++++------------ config/locales/en.yml | 14 +++++++++++ spec/models/project/gate_definition_spec.rb | 8 +++++++ spec/models/project/gate_spec.rb | 6 +++++ .../life_cycle_step_definition_spec.rb | 8 +++++++ spec/models/project/life_cycle_step_spec.rb | 12 ++++++++-- spec/models/project/stage_definition_spec.rb | 8 +++++++ spec/models/project/stage_spec.rb | 6 +++++ 9 files changed, 73 insertions(+), 24 deletions(-) diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index c4e6cc58876e..9a6b26a6e351 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -38,17 +38,14 @@ class Project::LifeCycleStep < ApplicationRecord attr_readonly :definition_id, :type validates :type, inclusion: { in: %w[Project::Stage Project::Gate], message: :must_be_a_stage_or_gate } + validate :validate_type_and_class_name_are_identical scope :active, -> { where(active: true) } - def initialize(*args) - if instance_of? Project::LifeCycleStep - # Do not allow directly instantiating this class - raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStep class directly. " \ - "Use Project::Stage or Project::Gate instead." + def validate_type_and_class_name_are_identical + if type != self.class.name + errors.add(:type, :type_and_class_name_mismatch) end - - super end def column_name diff --git a/app/models/project/life_cycle_step_definition.rb b/app/models/project/life_cycle_step_definition.rb index 7386a0ff4a56..0aadf22d0846 100644 --- a/app/models/project/life_cycle_step_definition.rb +++ b/app/models/project/life_cycle_step_definition.rb @@ -37,26 +37,12 @@ class Project::LifeCycleStepDefinition < ApplicationRecord validates :name, presence: true validates :type, inclusion: { in: %w[Project::StageDefinition Project::GateDefinition], message: :must_be_a_stage_or_gate } + validate :validate_type_and_class_name_are_identical attr_readonly :type acts_as_list - # TODO: Enabling this causes an error in the acts_as_customizable gem - # /gems/acts_as_list-1.2.4/lib/acts_as_list/active_record/acts/position_column_method_definer.rb - # define_singleton_method :touch_record_sql do - # new.touch_record_sql - # end - # Calling new will fail with the error below: - # def initialize(*args) - # if instance_of? Project::LifeCycleStepDefinition - # # Do not allow directly instantiating this class - # raise NotImplementedError, "Cannot instantiate the base Project::LifeCycleStepDefinition class directly. " \ - # "Use Project::StageDefinition or Project::GateDefinition instead." - # end - # super - # end - def step_class raise NotImplementedError end @@ -64,4 +50,12 @@ def step_class def column_name "lcsd_#{id}" end + + private + + def validate_type_and_class_name_are_identical + if type != self.class.name + errors.add(:type, :type_and_class_name_mismatch) + end + end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8999c6dbd136..24cabb38a5ba 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1209,20 +1209,34 @@ en: attributes: type: must_be_a_stage_or_gate: "must be either Project::StageDefinition or Project::GateDefinition" + type_and_class_name_mismatch: "must match the instantiated class name" + project/gate_definition: + attributes: + type: + type_and_class_name_mismatch: "must be a Project::GateDefinition" + project/stage_definition: + attributes: + type: + type_and_class_name_mismatch: "must be a Project::StageDefinition" project/life_cycle_step: attributes: type: must_be_a_stage_or_gate: "must be either Project::Stage or Project::Gate" must_be_a_stage: "must be a Project::Stage" must_be_a_gate: "must be a Project::Gate" + type_and_class_name_mismatch: "must match the instantiated class name" project/gate: attributes: base: end_date_not_allowed: "Cannot assign end date to a Project::Gate" + type: + type_and_class_name_mismatch: "must be a Project::Gate" project/stage: attributes: date_range: start_date_must_be_before_end_date: "Start date must be before the end date" + type: + type_and_class_name_mismatch: "must be a Project::Stage" query: attributes: project: diff --git a/spec/models/project/gate_definition_spec.rb b/spec/models/project/gate_definition_spec.rb index f943a416d9b9..2b1d4c2f9eca 100644 --- a/spec/models/project/gate_definition_spec.rb +++ b/spec/models/project/gate_definition_spec.rb @@ -41,6 +41,14 @@ end end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::StageDefinition" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + describe "#step_class" do it "returns Project::Stage" do expect(subject.step_class).to eq(Project::Gate) diff --git a/spec/models/project/gate_spec.rb b/spec/models/project/gate_spec.rb index c0e7645e1f95..dd5b4c84e347 100644 --- a/spec/models/project/gate_spec.rb +++ b/spec/models/project/gate_spec.rb @@ -48,5 +48,11 @@ valid_gate = build(:project_gate, end_date: nil) expect(valid_gate).to be_valid end + + it "is invalid if type and class name do not match" do + subject.type = "Project::Stage" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end end end diff --git a/spec/models/project/life_cycle_step_definition_spec.rb b/spec/models/project/life_cycle_step_definition_spec.rb index ff58d203c5c6..f5c69c5f7f93 100644 --- a/spec/models/project/life_cycle_step_definition_spec.rb +++ b/spec/models/project/life_cycle_step_definition_spec.rb @@ -39,6 +39,14 @@ it { is_expected.to have_readonly_attribute(:type) } end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::GateDefinition" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + # For more specs see: # - spec/support/shared/project_life_cycle_helpers.rb # - spec/models/project/gate_definition_spec.rb diff --git a/spec/models/project/life_cycle_step_spec.rb b/spec/models/project/life_cycle_step_spec.rb index 03722a2cb66a..e1e882d5e1d9 100644 --- a/spec/models/project/life_cycle_step_spec.rb +++ b/spec/models/project/life_cycle_step_spec.rb @@ -29,8 +29,8 @@ require "rails_helper" RSpec.describe Project::LifeCycleStep do - it "cannot be instantiated" do - expect { described_class.new }.to raise_error(NotImplementedError) + it "can be instantiated" do + expect { described_class.new }.not_to raise_error(NotImplementedError) end describe "with an instantiated Gate" do @@ -40,6 +40,14 @@ it { is_expected.to have_readonly_attribute(:type) } end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::Gate" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + # For more specs see: # - spec/support/shared/project_life_cycle_helpers.rb # - spec/models/project/gate_spec.rb diff --git a/spec/models/project/stage_definition_spec.rb b/spec/models/project/stage_definition_spec.rb index 1d94467a546a..0f236e6b46a6 100644 --- a/spec/models/project/stage_definition_spec.rb +++ b/spec/models/project/stage_definition_spec.rb @@ -41,6 +41,14 @@ end end + describe "validations" do + it "is invalid if type and class name do not match" do + subject.type = "Project::GateDefinition" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end + end + describe "#step_class" do it "returns Project::Stage" do expect(subject.step_class).to eq(Project::Stage) diff --git a/spec/models/project/stage_spec.rb b/spec/models/project/stage_spec.rb index 3bd2d19cb190..fb132a03cbb7 100644 --- a/spec/models/project/stage_spec.rb +++ b/spec/models/project/stage_spec.rb @@ -93,5 +93,11 @@ expect(subject).not_to be_valid expect(subject.errors[:date_range]).to be_empty end + + it "is invalid if type and class name do not match" do + subject.type = "Project::Gate" + expect(subject).not_to be_valid + expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) + end end end From 76d8899cec48a4abd78fddf12e2b7c3163b7554c Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 6 Dec 2024 23:55:21 +0200 Subject: [PATCH 17/36] Use date and date_range in the life cycle steps dialog, automatically infer value in the datepicker, complete the dialog workflow. --- app/forms/projects/life_cycles/form.rb | 17 +++++------------ app/models/permitted_params.rb | 2 +- .../open_project/forms/date_picker.html.erb | 2 +- .../overviews/overviews_controller.rb | 6 ++++-- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index 41dd6331128b..e65ce49e9bb8 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -44,10 +44,6 @@ def life_cycle_input(form) end end - def invalid? - model.errors.any? - end - def qa_field_name "life-cycle-step-#{model.id}" end @@ -57,7 +53,6 @@ def base_input_attributes label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety leading_visual: { icon: :calendar }, required: true, - invalid: invalid?, datepicker_options: { inDialog: true }, wrapper_data_attributes: { "qa-field-name": qa_field_name @@ -67,23 +62,21 @@ def base_input_attributes def single_value_life_cycle_input(form) input_attributes = base_input_attributes.merge( - name: :start_date, - value: model.start_date + name: :date, + value: model.date ) form.single_date_picker **input_attributes end def multi_value_life_cycle_input(form) - value = [ - helpers.format_date(model.start_date), - helpers.format_date(model.end_date) - ].compact.join(" - ") + value = [model.start_date, model.end_date].compact.join(" - ") input_attributes = base_input_attributes.merge( - name: :start_date, + name: :date_range, value: ) + form.range_date_picker **input_attributes end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 7a7b1b1f5f99..d145da84d561 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -292,7 +292,7 @@ def project def project_life_cycles params.require(:project).permit( - available_life_cycle_steps_attributes: %i[id date start_date end_date] + available_life_cycle_steps_attributes: %i[id date date_range] ) end diff --git a/lib/primer/open_project/forms/date_picker.html.erb b/lib/primer/open_project/forms/date_picker.html.erb index 04599700e834..8b4eac03d777 100644 --- a/lib/primer/open_project/forms/date_picker.html.erb +++ b/lib/primer/open_project/forms/date_picker.html.erb @@ -14,7 +14,7 @@ inputs: @datepicker_options.merge( id: @datepicker_options.fetch(:id) { builder.field_id(@input.name) }, name: @datepicker_options.fetch(:name) { builder.field_name(@input.name) }, - value: @datepicker_options.fetch(:value) { @input.input_arguments[:value] }, + value: @datepicker_options.fetch(:value) { @input.input_arguments[:value] || builder.object&.send(@input.name) }, inputClassNames: @datepicker_options.fetch(:class) { @input.input_arguments[:class] } ) %> diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 946b89fbdf29..8917113361eb 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -92,10 +92,12 @@ def update_project_life_cycles .call(permitted_params.project_life_cycles) if service_call.success? - update_sidebar_component + update_via_turbo_stream( + component: ProjectLifeCycles::SidePanelComponent.new(project: @project) + ) else update_via_turbo_stream( - component: ::ProjectLifeCycles::Sections::EditComponent.new(service_call.result) + component: ProjectLifeCycles::Sections::EditComponent.new(service_call.result) ) end From 6e2f78066c86cce489f3d1e9a966891acf976587 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 9 Dec 2024 18:44:23 +0200 Subject: [PATCH 18/36] Add life cycle step increasing dates validation to the ProjectLifeCycleStep::BaseContract --- .../project_life_cycle_steps/base_contract.rb | 36 +++++++ config/locales/en.yml | 5 +- .../base_contract_spec.rb | 98 +++++++++++++++++-- 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb index dfd78adcf195..bb0e43a0227f 100644 --- a/app/contracts/project_life_cycle_steps/base_contract.rb +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -29,11 +29,47 @@ module ProjectLifeCycleSteps class BaseContract < ::ModelContract validate :select_custom_fields_permission + validate :consecutive_steps_have_increasing_dates def select_custom_fields_permission return if user.allowed_in_project?(:edit_project_stages_and_gates, model) errors.add :base, :error_unauthorized end + + def consecutive_steps_have_increasing_dates + # Filter out steps with missing dates before proceeding with comparison + filtered_steps = model.available_life_cycle_steps.select(&:start_date) + + # Only proceed with comparisons if there are at least 2 valid steps + return if filtered_steps.size < 2 + + # Compare consecutive steps in pairs + filtered_steps.each_cons(2) do |previous_step, current_step| + if start_date_for(current_step) <= end_date_for(previous_step) + step = previous_step.is_a?(Project::Stage) ? "Stage" : "Gate" + field = current_step.is_a?(Project::Stage) ? :date_range : :date + model.errors.import( + current_step.errors.add(field, :non_continuous_dates, step:), + attribute: :"available_life_cycle_steps.#{field}" + ) + end + end + end + + private + + def start_date_for(step) + step.start_date + end + + def end_date_for(step) + case step + when Project::Gate + step.date + when Project::Stage + step.end_date || step.start_date # Use the start_date as fallback for single date stages + end + end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 24cabb38a5ba..0c833542ee29 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1231,10 +1231,13 @@ en: end_date_not_allowed: "Cannot assign end date to a Project::Gate" type: type_and_class_name_mismatch: "must be a Project::Gate" + date: + non_continuous_dates: "can’t be earlier than the previous %{step}'s end date." project/stage: attributes: date_range: - start_date_must_be_before_end_date: "Start date must be before the end date" + start_date_must_be_before_end_date: "start date must be before the end date." + non_continuous_dates: "can’t be earlier than the previous %{step}'s end date." type: type_and_class_name_mismatch: "must be a Project::Stage" query: diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb index 87ee661c0edc..63b69e43c336 100644 --- a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -34,27 +34,111 @@ RSpec.describe ProjectLifeCycleSteps::BaseContract do include_context "ModelContract shared context" - let(:contract) { described_class.new(project_life_cycle_step, user) } - let(:user) { build_stubbed(:admin) } - let(:project_life_cycle_step) { build_stubbed(:project_gate) } + let(:contract) { described_class.new(project, user) } + let(:project) { build_stubbed(:project) } context "with authorised user" do let(:user) { build_stubbed(:user) } + let(:project) { build_stubbed(:project, available_life_cycle_steps: steps) } + let(:steps) { [] } before do mock_permissions_for(user) do |mock| - mock.allow_in_project(:edit_project_stages_and_gates, project: project_life_cycle_step.project) + mock.allow_in_project(:edit_project_stages_and_gates, project:) end end it_behaves_like "contract is valid" + include_examples "contract reuses the model errors" + + describe "validations" do + describe "#consecutive_steps_have_increasing_dates" do + let(:step1) { build_stubbed(:project_gate, start_date: Date.new(2024, 1, 1)) } + let(:step2) { build_stubbed(:project_stage, start_date: Date.new(2024, 2, 1), end_date: Date.new(2024, 2, 28)) } + let(:step3) { build_stubbed(:project_gate, start_date: Date.new(2024, 3, 1), end_date: Date.new(2024, 3, 15)) } + let(:steps) { [step1, step2, step3] } + + context "when no steps are present" do + let(:steps) { [] } + + it_behaves_like "contract is valid" + end + + context "when only one step is present" do + let(:steps) { [step1] } + + it_behaves_like "contract is valid" + end + + context "when steps have valid and increasing dates" do + let(:steps) { [step1, step2] } + + it_behaves_like "contract is valid" + end + + context "when steps have decreasing dates" do + context "and the erroneous step is a Gate" do + let(:steps) { [step3, step1] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date": :non_continuous_dates + + it "adds an error to the decreasing step" do + contract.validate + expect(step1.errors.symbols_for(:date)).to include(:non_continuous_dates) + end + end + + context "and the erroneous step is a Stage" do + let(:steps) { [step3, step2] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date_range": :non_continuous_dates + + it "adds an error to the decreasing step" do + contract.validate + expect(step2.errors.symbols_for(:date_range)).to include(:non_continuous_dates) + end + end + end + + context "when steps with identical dates" do + let(:step4) { build_stubbed(:project_gate, start_date: Date.new(2024, 1, 1)) } + let(:steps) { [step1, step4] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date": :non_continuous_dates + end + + context "when a step has missing start dates" do + let(:step_missing_dates) { build_stubbed(:project_stage, start_date: nil, end_date: nil) } + + context "and the other steps have increasing dates" do + let(:steps) { [step1, step_missing_dates, step2] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date_range": :blank + end + + context "and the other steps have decreasing dates" do + let(:steps) { [step2, step_missing_dates, step1] } + + it_behaves_like "contract is invalid", + "available_life_cycle_steps.date": :non_continuous_dates + + it "adds an error to the decreasing step" do + contract.validate + expect(step1.errors.symbols_for(:date)).to include(:non_continuous_dates) + end + end + end + end + end end context "with unauthorised user" do let(:user) { build_stubbed(:user) } - it_behaves_like "contract is invalid", base: :error_unauthorized + it_behaves_like "contract user is unauthorized" end - - include_examples "contract reuses the model errors" end From 851f5044474d25ffb057514ef2cd8bf6c0e047c4 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 10 Dec 2024 23:53:45 +0200 Subject: [PATCH 19/36] Activate associated validations for project life cycle steps only when validating via the ProjectLifeCycleSteps contracts. --- app/contracts/model_contract.rb | 5 ++-- .../project_life_cycle_steps/base_contract.rb | 4 +++ app/models/project.rb | 2 ++ app/models/project/life_cycle_step.rb | 2 +- .../base_contract_spec.rb | 7 +++++ .../project_life_cycle_step_factory.rb | 4 +++ spec/lib/api/contracts/model_contract_spec.rb | 10 +++++-- spec/models/project_spec.rb | 26 +++++++++++++++++++ 8 files changed, 55 insertions(+), 5 deletions(-) diff --git a/app/contracts/model_contract.rb b/app/contracts/model_contract.rb index efa06281f331..35cdf5b58c62 100644 --- a/app/contracts/model_contract.rb +++ b/app/contracts/model_contract.rb @@ -48,7 +48,7 @@ class ModelContract < BaseContract # This of course is only true if that contract validates the model and # if the model has an errors object. def valid?(context = nil) - model.valid? if validate_model? + model.valid?(context) if validate_model? contract_valid?(context, clear_errors: !validate_model?) end @@ -61,7 +61,8 @@ def valid?(context = nil) # Clearing would then be done in the #valid? method by calling model.valid? # * Checks for readonly attributes being changed def contract_valid?(context = nil, clear_errors: false) - current_context, self.validation_context = validation_context, context # rubocop:disable Style/ParallelAssignment + current_context = validation_context + self.validation_context = context errors.clear if clear_errors diff --git a/app/contracts/project_life_cycle_steps/base_contract.rb b/app/contracts/project_life_cycle_steps/base_contract.rb index bb0e43a0227f..a8289ceb27a8 100644 --- a/app/contracts/project_life_cycle_steps/base_contract.rb +++ b/app/contracts/project_life_cycle_steps/base_contract.rb @@ -31,6 +31,10 @@ class BaseContract < ::ModelContract validate :select_custom_fields_permission validate :consecutive_steps_have_increasing_dates + def valid?(context = :saving_life_cycle_steps) + super + end + def select_custom_fields_permission return if user.allowed_in_project?(:edit_project_stages_and_gates, model) diff --git a/app/models/project.rb b/app/models/project.rb index 6226b48729c6..5694839fb130 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -92,7 +92,9 @@ class Project < ApplicationRecord class_name: "Project::LifeCycleStep", inverse_of: :project, dependent: :destroy + accepts_nested_attributes_for :available_life_cycle_steps + validates_associated :available_life_cycle_steps, on: :saving_life_cycle_steps store_attribute :settings, :deactivate_work_package_attachments, :boolean diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index 9a6b26a6e351..30fbd04ece84 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -27,7 +27,7 @@ #++ class Project::LifeCycleStep < ApplicationRecord - belongs_to :project, optional: false + belongs_to :project, optional: false, inverse_of: :available_life_cycle_steps belongs_to :definition, optional: false, class_name: "Project::LifeCycleStepDefinition" diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb index 63b69e43c336..2646b6a1df51 100644 --- a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -133,6 +133,13 @@ end end end + + describe "triggering validations on the model" do + it "sets the :saving_life_cycle_steps validation context" do + contract.validate + expect(project).to have_receive(:valid?).with(:saving_life_cycle_steps) + end + end end end diff --git a/spec/factories/project_life_cycle_step_factory.rb b/spec/factories/project_life_cycle_step_factory.rb index cc64e09b02e7..465dd702bfc0 100644 --- a/spec/factories/project_life_cycle_step_factory.rb +++ b/spec/factories/project_life_cycle_step_factory.rb @@ -31,6 +31,10 @@ project active { true } + trait :skip_validate do + to_create { |instance| instance.save(validate: false) } + end + factory :project_stage, class: "Project::Stage" do definition factory: :project_stage_definition start_date { Date.current - 2.days } diff --git a/spec/lib/api/contracts/model_contract_spec.rb b/spec/lib/api/contracts/model_contract_spec.rb index dc47b35544b1..84c8baaf405b 100644 --- a/spec/lib/api/contracts/model_contract_spec.rb +++ b/spec/lib/api/contracts/model_contract_spec.rb @@ -149,8 +149,7 @@ context "when the model extends both modules" do before do - allow(model).to receive(:changed_by_user).and_return([:custom_field1]) - allow(model).to receive(:changed_with_custom_fields).and_return([:no_allowed]) + allow(model).to receive_messages(changed_by_user: [:custom_field1], changed_with_custom_fields: [:no_allowed]) end it "adds an error to the custom field attribute from the OpenProject::ChangedBySystem module" do @@ -159,5 +158,12 @@ .to include(:error_readonly) end end + + context "when a context is provided" do + it "propagates the contex to the model as well" do + model_contract.valid?(:custom_context) + expect(model).to have_receive(:valid?).with(:custom_context) + end + end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2f191a053f34..0a446f85d63d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -383,6 +383,32 @@ describe "life_cycles" do it { is_expected.to have_many(:life_cycle_steps).class_name("Project::LifeCycleStep").dependent(:destroy) } + + it "has many available_life_cycle_steps" do + expect(subject).to have_many(:available_life_cycle_steps) + .class_name("Project::LifeCycleStep") + .inverse_of(:project) + .dependent(:destroy) + .conditions(active: true) + .order(position: :asc) + end + + it "eager loads :definition" do + expect(subject.available_life_cycle_steps.to_sql) + .to include("LEFT OUTER JOIN \"project_life_cycle_step_definitions\" ON") + end + + describe ".validates_associated" do + let!(:project_stage) { create :project_stage, :skip_validate, project:, start_date: nil, end_date: nil } + + it "is valid without a validation context" do + expect(project).to be_valid + end + + it "is invalid with the :saving_life_cycle_steps validation context" do + expect(project).not_to be_valid(:saving_life_cycle_steps) + end + end end describe "#enabled_module_names=", with_settings: { default_projects_modules: %w(work_package_tracking repository) } do From 59bf7cc408c8b6d784300d56ad3af0610b808881 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 10 Dec 2024 23:59:45 +0200 Subject: [PATCH 20/36] Display duration in days caption for Project::Stage entries. --- app/forms/projects/life_cycles/form.rb | 18 ++++++------- app/models/project/stage.rb | 6 +++++ config/locales/en.yml | 5 +++- spec/models/project/stage_spec.rb | 36 ++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index e65ce49e9bb8..de42b5a05ad7 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -61,23 +61,21 @@ def base_input_attributes end def single_value_life_cycle_input(form) - input_attributes = base_input_attributes.merge( - name: :date, - value: model.date - ) + input_attributes = { name: :date, value: model.date } - form.single_date_picker **input_attributes + form.single_date_picker **base_input_attributes, **input_attributes end def multi_value_life_cycle_input(form) value = [model.start_date, model.end_date].compact.join(" - ") - input_attributes = base_input_attributes.merge( - name: :date_range, - value: - ) + input_attributes = { name: :date_range, value: } + if model.working_days_count + input_attributes[:caption] = + I18n.t("project_stage.working_days_count", count: model.working_days_count) + end - form.range_date_picker **input_attributes + form.range_date_picker **base_input_attributes, **input_attributes end def text diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb index 94e29cf4fd2f..bd6a3f0b1c84 100644 --- a/app/models/project/stage.rb +++ b/app/models/project/stage.rb @@ -31,6 +31,12 @@ class Project::Stage < Project::LifeCycleStep validates :type, inclusion: { in: %w[Project::Stage], message: :must_be_a_stage } validate :validate_date_range + def working_days_count + return nil if not_set? + + Day.working.from_range(from: start_date, to: end_date).count + end + def date_range=(param) self.start_date, self.end_date = param.split(" - ") self.end_date ||= start_date # Allow single dates as range diff --git a/config/locales/en.yml b/config/locales/en.yml index 0c833542ee29..0a6aeda61e7d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -425,7 +425,10 @@ en: no_results_content_text: Create a new version storage: no_results_title_text: There is no additional recorded disk space consumed by this project. - + project_stage: + working_days_count: + one: "Duration: %{count} working day" + other: "Duration: %{count} working days" lists: create: success: "The modified list has been saved as a new list" diff --git a/spec/models/project/stage_spec.rb b/spec/models/project/stage_spec.rb index fb132a03cbb7..ebe1b01646bc 100644 --- a/spec/models/project/stage_spec.rb +++ b/spec/models/project/stage_spec.rb @@ -100,4 +100,40 @@ expect(subject.errors.symbols_for(:type)).to include(:type_and_class_name_mismatch) end end + + describe "#working_days_count" do + it "returns nil if not_set? is true" do + allow(Day).to receive(:working) + + subject.start_date = nil + subject.end_date = nil + + expect(subject.working_days_count).to be_nil + expect(Day).not_to have_received(:working) + end + + it "returns the correct number of days if start_date and end_date are the same" do + subject.start_date = Time.zone.today + subject.end_date = Time.zone.today + expect(subject.working_days_count).to eq(1) + end + + it "returns the correct number of days for a valid date range" do + subject.start_date = Date.parse("2024-11-25") + subject.end_date = Date.parse("2024-11-27") + expect(subject.working_days_count).to eq(3) + end + + it "calls the Day.working.from_range method with the right arguments" do + subject.start_date = Date.parse("2024-11-25") + subject.end_date = Date.parse("2024-11-27") + + allow(Day).to receive(:working).and_return(Day) + allow(Day).to receive(:from_range) + .with(from: subject.start_date, to: subject.end_date) + .and_return([]) + + expect(subject.working_days_count).to eq(0) + end + end end From 83064166e771d7925ff2b092aa2460382a48d37f Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 11 Dec 2024 22:24:26 +0200 Subject: [PATCH 21/36] Create form preview to interactively calculate duration of selected dates and display error messages on the ProjectLifeCycle dialog. --- app/forms/projects/life_cycles/form.rb | 5 +- .../preview_attributes_service.rb | 47 +++++++++++++++ .../basic-range-date-picker.component.html | 3 + .../basic-range-date-picker.component.ts | 2 + .../basic-single-date-picker.component.html | 3 + .../basic-single-date-picker.component.ts | 2 + .../project-life-cycles-form.controller.ts | 60 +++++++++++++++++++ .../open_project/forms/date_picker.html.erb | 3 +- .../sections/edit_component.html.erb | 10 +++- .../overviews/overviews_controller.rb | 15 +++++ modules/overviews/config/routes.rb | 2 + modules/overviews/lib/overviews/engine.rb | 1 + .../edit_project_life_cycles_spec.rb | 3 + 13 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 app/services/project_life_cycle_steps/preview_attributes_service.rb create mode 100644 frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index de42b5a05ad7..0e1e2ae37584 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -53,7 +53,10 @@ def base_input_attributes label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety leading_visual: { icon: :calendar }, required: true, - datepicker_options: { inDialog: true }, + datepicker_options: { + inDialog: true, + data: { action: "change->overview--project-life-cycles-form#handleChange" } + }, wrapper_data_attributes: { "qa-field-name": qa_field_name } diff --git a/app/services/project_life_cycle_steps/preview_attributes_service.rb b/app/services/project_life_cycle_steps/preview_attributes_service.rb new file mode 100644 index 000000000000..d70bac50ee04 --- /dev/null +++ b/app/services/project_life_cycle_steps/preview_attributes_service.rb @@ -0,0 +1,47 @@ +#-- 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. +#++ + +module ProjectLifeCycleSteps + class PreviewAttributesService < ::BaseServices::SetAttributes + def perform(*) + super.tap do |service_call| + clear_unchanged_fields(service_call) + end + end + + private + + def clear_unchanged_fields(service_call) + service_call + .result + .available_life_cycle_steps + .reject(&:changed?) + .each { _1.errors.clear } + end + end +end diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html index cc5f6d13f9bf..9fd117204e52 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.html @@ -6,6 +6,7 @@ data-test-selector="op-basic-range-date-picker" [ngClass]="inputClassNames" [attr.data-value]="value" + [attr.data-action]="dataAction" [id]="id" [name]="name" [attr.name]="name" @@ -47,6 +48,8 @@ [attr.data-value]="stringValue" [id]="id" [name]="name" + [attr.name]="name" + [attr.data-action]="dataAction" [ngModel]="stringValue" /> diff --git a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts index 699eef974892..d9c791513467 100644 --- a/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-range-date-picker/basic-range-date-picker.component.ts @@ -117,6 +117,8 @@ export class OpBasicRangeDatePickerComponent implements OnInit, ControlValueAcce @Input() inDialog = false; + @Input() dataAction = ''; + @ViewChild('input') input:ElementRef; stringValue = ''; diff --git a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html index e15dd1bf3ebc..c5381fee5c80 100644 --- a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html +++ b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.html @@ -7,6 +7,7 @@ [ngClass]="inputClassNames" [ngModel]="value" [attr.data-value]="value" + [attr.data-action]="dataAction" [id]="id" [name]="name" [attr.name]="name" @@ -24,8 +25,10 @@ data-filter--filters-form-target="singleDay" [ngClass]="inputClassNames" [attr.data-value]="value" + [attr.data-action]="dataAction" [id]="id" [name]="name" + [attr.name]="name" [required]="required" [disabled]="disabled" [ngModel]="value" diff --git a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts index 6ebc84272fd7..1ecc03b2fe52 100644 --- a/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts +++ b/frontend/src/app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component.ts @@ -97,6 +97,8 @@ export class OpBasicSingleDatePickerComponent implements ControlValueAccessor, O @Input() inDialog = false; + @Input() dataAction = ''; + @ViewChild('input') input:ElementRef; mobile = false; diff --git a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts new file mode 100644 index 000000000000..eee4d16064e0 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts @@ -0,0 +1,60 @@ +/* + * -- 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class ProjectLifeCyclesFormController extends Controller { + static targets = ['form']; + + declare readonly formTarget:HTMLFormElement; + + handleChange(event:Event) { + const target = event.target as HTMLElement; + const previewUrl = this.formTarget.dataset.previewUrl; + + if (!previewUrl || this.datePickerVisible(target)) { + return; // flatpickr is still open, do not submit yet. + } + + // TODO: If morphing is working correctly, we don't need to blur the input field. + target.blur(); + const form = this.formTarget; + form.action = previewUrl; + + form.requestSubmit(); + } + + datePickerVisible(element:HTMLElement) { + const nextElement = element.nextElementSibling; + return nextElement + && nextElement.classList.contains('flatpickr-calendar') + && nextElement.classList.contains('open'); + } +} diff --git a/lib/primer/open_project/forms/date_picker.html.erb b/lib/primer/open_project/forms/date_picker.html.erb index 8b4eac03d777..0483d34c9515 100644 --- a/lib/primer/open_project/forms/date_picker.html.erb +++ b/lib/primer/open_project/forms/date_picker.html.erb @@ -15,7 +15,8 @@ id: @datepicker_options.fetch(:id) { builder.field_id(@input.name) }, name: @datepicker_options.fetch(:name) { builder.field_name(@input.name) }, value: @datepicker_options.fetch(:value) { @input.input_arguments[:value] || builder.object&.send(@input.name) }, - inputClassNames: @datepicker_options.fetch(:class) { @input.input_arguments[:class] } + inputClassNames: @datepicker_options.fetch(:class) { @input.input_arguments[:class] }, + dataAction: @datepicker_options.fetch(:data, {}).fetch(:action, nil) ) %> <% end %> diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb index e5025709bae6..506d398e3a76 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -5,7 +5,15 @@ id: "project-life-cycles-edit-form", model:, method: :put, - data: { turbo: true, turbo_stream: true, "test-selector": "async-dialog-content" }, + data: { + "controller": "overview--project-life-cycles-form", + "overview--project-life-cycles-form-target": "form", + "application-target": "dynamic", + turbo: true, + turbo_stream: true, + preview_url: project_life_cycles_form_path(project_id: model.id), + "test-selector": "async-dialog-content" + }, url: update_project_life_cycles_path(project_id: model.id), ) do |f| render(Primer::Forms::SpacingWrapper.new) do diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 8917113361eb..c56cd76dcacf 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -86,6 +86,21 @@ def project_life_cycles_dialog ) end + def project_life_cycles_form + service_call = ::ProjectLifeCycleSteps::PreviewAttributesService + .new(user: current_user, + model: @project, + contract_class: ProjectLifeCycleSteps::UpdateContract) + .call(permitted_params.project_life_cycles) + + update_via_turbo_stream( + component: ProjectLifeCycles::Sections::EditComponent.new(service_call.result) + ) + # TODO: :unprocessable_entity is not nice, change the dialog logic to accept :ok + # without dismissing the dialog, alternatively use turbo frames instead of streams. + respond_to_with_turbo_streams(status: :unprocessable_entity) + end + def update_project_life_cycles service_call = ::ProjectLifeCycleSteps::UpdateService .new(user: current_user, model: @project) diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index dcdfc2889792..c6a9a0b1b446 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -14,6 +14,8 @@ as: :project_life_cycles_sidebar get "projects/:project_id/project_life_cycles_dialog", to: "overviews/overviews#project_life_cycles_dialog", as: :project_life_cycles_dialog + put "projects/:project_id/project_life_cycles_form", to: "overviews/overviews#project_life_cycles_form", + as: :project_life_cycles_form put "projects/:project_id/update_project_life_cycles", to: "overviews/overviews#update_project_life_cycles", as: :update_project_life_cycles end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index 761a8f015093..e9ba90c450d7 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -74,6 +74,7 @@ class Engine < ::Rails::Engine .controller_actions .push( "overviews/overviews/project_life_cycles_dialog", + "overviews/overviews/project_life_cycles_form", "overviews/overviews/update_project_life_cycles" ) diff --git a/spec/permissions/edit_project_life_cycles_spec.rb b/spec/permissions/edit_project_life_cycles_spec.rb index 50840e76efb3..7e81ea0f34d1 100644 --- a/spec/permissions/edit_project_life_cycles_spec.rb +++ b/spec/permissions/edit_project_life_cycles_spec.rb @@ -36,6 +36,9 @@ # render dialog with inputs for editing project attributes with edit_project permission check_permission_required_for("overviews/overviews#project_life_cycles_dialog", :edit_project_stages_and_gates) + # render form with inputs for editing project attributes with edit_project permission + check_permission_required_for("overviews/overviews#project_life_cycles_form", :edit_project_stages_and_gates) + # update project attributes with edit_project permission, deeper permission check via contract in place check_permission_required_for("overviews/overviews#update_project_life_cycles", :edit_project_stages_and_gates) end From 1eb9eacdd9182b1d7b67c4aa99ed60d679292409 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Thu, 12 Dec 2024 22:57:40 +0200 Subject: [PATCH 22/36] Allow empty date and date range for Project::LifeCycleStep --- app/forms/projects/life_cycles/form.rb | 1 - app/models/project/gate.rb | 1 - app/models/project/stage.rb | 14 +++++++++++--- .../preview_attributes_service.rb | 2 +- config/locales/en.yml | 1 + spec/models/project/gate_spec.rb | 1 - spec/models/project/stage_spec.rb | 9 +++++++-- 7 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index 0e1e2ae37584..1bd83b7b1f61 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -52,7 +52,6 @@ def base_input_attributes { label: "#{icon} #{text}".html_safe, # rubocop:disable Rails/OutputSafety leading_visual: { icon: :calendar }, - required: true, datepicker_options: { inDialog: true, data: { action: "change->overview--project-life-cycles-form#handleChange" } diff --git a/app/models/project/gate.rb b/app/models/project/gate.rb index c2342505d2d2..8c49a5360c5c 100644 --- a/app/models/project/gate.rb +++ b/app/models/project/gate.rb @@ -31,7 +31,6 @@ class Project::Gate < Project::LifeCycleStep # This ensures the type cannot be changed after initialising the class. validates :type, inclusion: { in: %w[Project::Gate], message: :must_be_a_gate } - validates :date, presence: true validate :end_date_not_allowed def end_date_not_allowed diff --git a/app/models/project/stage.rb b/app/models/project/stage.rb index bd6a3f0b1c84..2ee54f26cd3e 100644 --- a/app/models/project/stage.rb +++ b/app/models/project/stage.rb @@ -46,10 +46,18 @@ def not_set? start_date.blank? || end_date.blank? end + def range_set? + !not_set? + end + + def range_incomplete? + start_date.blank? ^ end_date.blank? + end + def validate_date_range - if not_set? - errors.add(:date_range, :blank) - elsif start_date > end_date + if range_incomplete? + errors.add(:date_range, :incomplete) + elsif range_set? && (start_date > end_date) errors.add(:date_range, :start_date_must_be_before_end_date) end end diff --git a/app/services/project_life_cycle_steps/preview_attributes_service.rb b/app/services/project_life_cycle_steps/preview_attributes_service.rb index d70bac50ee04..0070cabfe246 100644 --- a/app/services/project_life_cycle_steps/preview_attributes_service.rb +++ b/app/services/project_life_cycle_steps/preview_attributes_service.rb @@ -40,7 +40,7 @@ def clear_unchanged_fields(service_call) service_call .result .available_life_cycle_steps - .reject(&:changed?) + .select(&:not_set?) .each { _1.errors.clear } end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 0a6aeda61e7d..de8d83c86a23 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1241,6 +1241,7 @@ en: date_range: start_date_must_be_before_end_date: "start date must be before the end date." non_continuous_dates: "can’t be earlier than the previous %{step}'s end date." + incomplete: "is incomplete." type: type_and_class_name_mismatch: "must be a Project::Stage" query: diff --git a/spec/models/project/gate_spec.rb b/spec/models/project/gate_spec.rb index dd5b4c84e347..5fb5bcc7825b 100644 --- a/spec/models/project/gate_spec.rb +++ b/spec/models/project/gate_spec.rb @@ -33,7 +33,6 @@ it_behaves_like "a Project::LifeCycleStep event" describe "validations" do - it { is_expected.to validate_presence_of(:date) } it { is_expected.to validate_inclusion_of(:type).in_array(["Project::Gate"]).with_message(:must_be_a_gate) } it "is invalid if `end_date` is present" do diff --git a/spec/models/project/stage_spec.rb b/spec/models/project/stage_spec.rb index ebe1b01646bc..5fc4234345a2 100644 --- a/spec/models/project/stage_spec.rb +++ b/spec/models/project/stage_spec.rb @@ -68,16 +68,21 @@ end describe "#validate_date_range" do + it "is valid when both dates are blank" do + stage = build(:project_stage, start_date: nil, end_date: nil) + expect(stage).to be_valid + end + it "adds error if start_date is blank" do subject.end_date = Time.zone.today expect(subject).not_to be_valid - expect(subject.errors.symbols_for(:date_range)).to include(:blank) + expect(subject.errors.symbols_for(:date_range)).to include(:incomplete) end it "adds error if end_date is blank" do subject.start_date = Time.zone.today expect(subject).not_to be_valid - expect(subject.errors.symbols_for(:date_range)).to include(:blank) + expect(subject.errors.symbols_for(:date_range)).to include(:incomplete) end it "adds error if start_date is after end_date" do From 4bd144d079481e12d04071f18990477668be9ffb Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 6 Dec 2024 13:46:35 +0100 Subject: [PATCH 23/36] exclude OP custom dom elements from being morphed --- frontend/src/turbo/turbo-global-listeners.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/turbo/turbo-global-listeners.ts b/frontend/src/turbo/turbo-global-listeners.ts index 1b715fe5c1ba..0f379be56818 100644 --- a/frontend/src/turbo/turbo-global-listeners.ts +++ b/frontend/src/turbo/turbo-global-listeners.ts @@ -64,4 +64,13 @@ export function addTurboGlobalListeners() { activateFlashNotice(); activateFlashError(); }); + + document.addEventListener('turbo:before-morph-element', (event) => { + const element = event.target as HTMLElement; + + // In case the element is an OpenProject custom dom element, morphing is prevented. + if (element.tagName.startsWith('OPCE-')) { + event.preventDefault(); + } + }); } From 8040262a8beaab07f8756caf88bb24a04e74bfb7 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Sat, 14 Dec 2024 01:40:40 +0200 Subject: [PATCH 24/36] Enable morphing the Stages and Gates form preview action --- app/components/concerns/op_turbo/streamable.rb | 9 ++++++++- .../concerns/op_turbo/component_stream.rb | 13 +++++++------ .../overview/project-life-cycles-form.controller.ts | 2 -- .../controllers/overviews/overviews_controller.rb | 3 ++- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/components/concerns/op_turbo/streamable.rb b/app/components/concerns/op_turbo/streamable.rb index dfad026a7825..89180a56ddc2 100644 --- a/app/components/concerns/op_turbo/streamable.rb +++ b/app/components/concerns/op_turbo/streamable.rb @@ -33,6 +33,8 @@ class MissingComponentWrapper < StandardError; end # rubocop:enable OpenProject/AddPreviewForViewComponent INLINE_ACTIONS = %i[dialog flash].freeze + # Turbo allows the response method for these actions only: + ACTIONS_WITH_METHOD = %i[update replace].freeze extend ActiveSupport::Concern @@ -43,7 +45,7 @@ def wrapper_key end included do - def render_as_turbo_stream(view_context:, action: :update) + def render_as_turbo_stream(view_context:, action: :update, method: nil) case action when :update, *INLINE_ACTIONS @inner_html_only = true @@ -63,8 +65,13 @@ def render_as_turbo_stream(view_context:, action: :update) "Wrap your component in a `component_wrapper` block in order to use turbo-stream methods" end + if method && !action.in?(ACTIONS_WITH_METHOD) + raise ArgumentError, "The #{action} action does not supports a method" + end + OpTurbo::StreamComponent.new( action:, + method:, target: wrapper_key, template: ).render_in(view_context) diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 6cc60cd36918..f7cd5e1369bc 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -42,23 +42,24 @@ def respond_to_with_turbo_streams(status: turbo_status, &format_block) alias_method :respond_with_turbo_streams, :respond_to_with_turbo_streams - def update_via_turbo_stream(component:, status: :ok) - modify_via_turbo_stream(component:, action: :update, status:) + def update_via_turbo_stream(component:, status: :ok, method: nil) + modify_via_turbo_stream(component:, action: :update, status:, method:) end - def replace_via_turbo_stream(component:, status: :ok) - modify_via_turbo_stream(component:, action: :replace, status:) + def replace_via_turbo_stream(component:, status: :ok, method: nil) + modify_via_turbo_stream(component:, action: :replace, status:, method:) end def remove_via_turbo_stream(component:, status: :ok) modify_via_turbo_stream(component:, action: :remove, status:) end - def modify_via_turbo_stream(component:, action:, status:) + def modify_via_turbo_stream(component:, action:, status:, method: nil) @turbo_status = status turbo_streams << component.render_as_turbo_stream( view_context:, - action: + action:, + method: ) end diff --git a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts index eee4d16064e0..1cc2421b730f 100644 --- a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts @@ -43,8 +43,6 @@ export default class ProjectLifeCyclesFormController extends Controller { return; // flatpickr is still open, do not submit yet. } - // TODO: If morphing is working correctly, we don't need to blur the input field. - target.blur(); const form = this.formTarget; form.action = previewUrl; diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index c56cd76dcacf..679701ae7bca 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -94,7 +94,8 @@ def project_life_cycles_form .call(permitted_params.project_life_cycles) update_via_turbo_stream( - component: ProjectLifeCycles::Sections::EditComponent.new(service_call.result) + component: ProjectLifeCycles::Sections::EditComponent.new(service_call.result), + method: "morph" ) # TODO: :unprocessable_entity is not nice, change the dialog logic to accept :ok # without dismissing the dialog, alternatively use turbo frames instead of streams. From 583a2f3c842cc2cf2aef58645e13d578131bfc88 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Sat, 14 Dec 2024 02:53:03 +0200 Subject: [PATCH 25/36] Add validation error border around the datepicker's input. --- frontend/src/global_styles/primer/_overrides.sass | 6 ++++++ lib/primer/open_project/forms/date_picker.rb | 2 ++ 2 files changed, 8 insertions(+) diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 2ef53fc4dd50..b458b00a2e9d 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -32,6 +32,12 @@ input border-radius: var(--borderRadius-medium) + // Currently we cannot morph the angular datepicker input field, and we have no way to set the + // validation error border on the input. However we can morph the input's wrapper element, thus + // adding this parent wrapper rule we can display the red border on the input. + &-input-wrap[invalid='true'] input:not(:focus) + border-color: var(--control-borderColor-danger) + .UnderlineNav @include no-visible-scroll-bar margin-bottom: 12px diff --git a/lib/primer/open_project/forms/date_picker.rb b/lib/primer/open_project/forms/date_picker.rb index f16834892046..f3089f101f77 100644 --- a/lib/primer/open_project/forms/date_picker.rb +++ b/lib/primer/open_project/forms/date_picker.rb @@ -9,6 +9,8 @@ class DatePicker < Primer::Forms::TextField def initialize(input:, datepicker_options:) super(input:) + + @field_wrap_arguments[:invalid] = true if @input.invalid? @datepicker_options = datepicker_options end end From 5ad73783580759e889ffda66950aa2856272b744 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:37:36 +0200 Subject: [PATCH 26/36] Add specs for stages and gates edit dialog --- app/models/project/life_cycle_step.rb | 2 +- .../overview_page/dialog/update_spec.rb | 73 ++++++++++++ .../project_life_cycles/edit_dialog.rb | 106 ++++++++++++++++++ spec/support/pages/projects/show.rb | 10 +- 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb create mode 100644 spec/support/components/projects/project_life_cycles/edit_dialog.rb diff --git a/app/models/project/life_cycle_step.rb b/app/models/project/life_cycle_step.rb index 30fbd04ece84..28b6a1fa9a8f 100644 --- a/app/models/project/life_cycle_step.rb +++ b/app/models/project/life_cycle_step.rb @@ -33,7 +33,7 @@ class Project::LifeCycleStep < ApplicationRecord class_name: "Project::LifeCycleStepDefinition" has_many :work_packages, inverse_of: :project_life_cycle_step, dependent: :nullify - delegate :name, to: :definition + delegate :name, :position, to: :definition attr_readonly :definition_id, :type diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb new file mode 100644 index 000000000000..8e57f6c3aaf4 --- /dev/null +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -0,0 +1,73 @@ +#-- 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 "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project stages and gates on project overview page", :js, :with_cuprite, + with_flag: { stages_and_gates: true } do + include_context "with seeded projects and stages and gates" + let(:user) { create(:admin) } + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + # TODO: Could this work for all feature specs? + allow(User).to receive(:current).and_return user + overview_page.visit_page + end + + describe "with the dialog open" do + context "when all LifeCycleSteps are blank" do + before do + Project::LifeCycleStep.update_all(start_date: nil, end_date: nil) + end + + it "shows all the Project::LifeCycleSteps without a value" do + dialog = overview_page.open_edit_dialog_for_life_cycles + + dialog.expect_input("Initiating", value: "", type: :stage, position: 1) + dialog.expect_input("Ready for Planning", value: "", type: :gate, position: 2) + dialog.expect_input("Planning", value: "", type: :stage, position: 3) + dialog.expect_input("Ready for Executing", value: "", type: :gate, position: 4) + dialog.expect_input("Executing", value: "", type: :stage, position: 5) + dialog.expect_input("Ready for Closing", value: "", type: :gate, position: 6) + dialog.expect_input("Closing", value: "", type: :stage, position: 7) + end + end + + context "when all LifeCycleSteps have a value" do + it "shows all the Project::LifeCycleSteps including value" do + dialog = overview_page.open_edit_dialog_for_life_cycles + + project.available_life_cycle_steps.each do |step| + dialog.expect_input_for(step) + end + end + end + end +end diff --git a/spec/support/components/projects/project_life_cycles/edit_dialog.rb b/spec/support/components/projects/project_life_cycles/edit_dialog.rb new file mode 100644 index 000000000000..d91435aa8cbb --- /dev/null +++ b/spec/support/components/projects/project_life_cycles/edit_dialog.rb @@ -0,0 +1,106 @@ +# -- 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/components/common/modal" +require "support/components/autocompleter/ng_select_autocomplete_helpers" +module Components + module Projects + module ProjectLifeCycles + class EditDialog < Components::Common::Modal + def dialog_css_selector + "dialog#edit-project-life-cycles-dialog" + end + + def async_content_container_css_selector + "#{dialog_css_selector} [data-test-selector='async-dialog-content']" + end + + def within_dialog(&) + within(dialog_css_selector, &) + end + + def within_async_content(close_after_yield: false, &) + within(async_content_container_css_selector, &) + close if close_after_yield + end + + def close + within_dialog do + page.find(".close-button").click + end + end + alias_method :close_via_icon, :close + + def close_via_button + within(dialog_css_selector) do + click_link_or_button "Cancel" + end + end + + def submit + within(dialog_css_selector) do + page.find("[data-test-selector='save-project-life-cycles-button']").click + end + end + + def expect_open + expect(page).to have_css(dialog_css_selector) + end + + def expect_closed + expect(page).to have_no_css(dialog_css_selector) + end + + def expect_async_content_loaded + expect(page).to have_css(async_content_container_css_selector) + end + + def expect_input(label, value:, type:, position:) + field = type == :stage ? :date_range : :date + expect(page).to have_field( + label, + with: value, + name: "project[available_life_cycle_steps_attributes][#{position - 1}][#{field}]" + ) + end + + def expect_input_for(step) + options = if step.is_a?(Project::Stage) + value = "#{step.start_date.strftime('%Y-%m-%d')} - #{step.end_date.strftime('%Y-%m-%d')}" + { type: :stage, value: } + else + value = step.date.strftime("%Y-%m-%d") + { type: :gate, value: } + end + + expect_input(step.name, position: step.position, **options) + end + end + end + end +end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index fb246d90ec57..bae1d5210bb2 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -33,11 +33,9 @@ module Projects class Show < ::Pages::Page attr_reader :project - # rubocop:disable Lint/MissingSuper def initialize(project) @project = project end - # rubocop:enable Lint/MissingSuper def path project_path(project) @@ -87,6 +85,14 @@ def open_edit_dialog_for_section(section) expect(page).to have_css("[data-test-selector='async-dialog-content']", wait: 5) end + def open_edit_dialog_for_life_cycles + within_life_cycles_sidebar do + page.find("[data-test-selector='project-life-cycles-edit-button']").click + end + + Components::Projects::ProjectLifeCycles::EditDialog.new.tap(&:expect_open) + end + def within_life_cycles_sidebar(&) within "#project-life-cycles-sidebar" do expect(page).to have_css("[data-test-selector='project-life-cycles-sidebar-async-content']") From 6ce663b0acbc422eebeb0d7adeba1e50a5725564 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 16 Dec 2024 23:27:49 +0200 Subject: [PATCH 27/36] Addd more Stage and Gates dialog specs wip. --- .../overview_page/dialog/update_spec.rb | 73 ++++++++++++++++++- .../overview_page/shared_context.rb | 13 ++++ .../components/datepicker/basic_datepicker.rb | 5 ++ .../project_life_cycles/edit_dialog.rb | 62 ++++++++++++++-- 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb index 8e57f6c3aaf4..44caa19131f8 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -57,16 +57,87 @@ dialog.expect_input("Executing", value: "", type: :stage, position: 5) dialog.expect_input("Ready for Closing", value: "", type: :gate, position: 6) dialog.expect_input("Closing", value: "", type: :stage, position: 7) + + # Saving the dialog is successful + dialog.submit + dialog.expect_closed + + # Sidebar displays the same empty values + project_life_cycles.each do |life_cycle| + overview_page.within_life_cycle_container(life_cycle) do + expect(page).to have_text "-" + end + end end end context "when all LifeCycleSteps have a value" do - it "shows all the Project::LifeCycleSteps including value" do + it "shows all the Project::LifeCycleSteps and updates them correctly" do dialog = overview_page.open_edit_dialog_for_life_cycles + expect_angular_frontend_initialized + project.available_life_cycle_steps.each do |step| dialog.expect_input_for(step) end + + initiating_dates = [start_date - 1.week, start_date] + + retry_block do + # Retrying because, the caption update does not always kick in. + original_dates = [life_cycle_initiating.start_date, life_cycle_initiating.end_date] + dialog.set_date_for(life_cycle_initiating, value: original_dates) + dialog.set_date_for(life_cycle_initiating, value: initiating_dates) + + dialog.expect_caption(life_cycle_initiating, text: "Duration: 8 working days") + end + + ready_for_planning_date = start_date + 1.day + dialog.set_date_for(life_cycle_ready_for_planning, value: ready_for_planning_date) + dialog.expect_no_caption(life_cycle_ready_for_planning) + + # Saving the dialog is successful + dialog.submit + dialog.expect_closed + + # Sidebar is refreshed with the updated values + expected_date_range = initiating_dates.map { |date| date.strftime("%m/%d/%Y") }.join(" - ") + overview_page.within_life_cycle_container(life_cycle_initiating) do + expect(page).to have_text expected_date_range + end + + overview_page.within_life_cycle_container(life_cycle_ready_for_planning) do + expect(page).to have_text ready_for_planning_date.strftime("%m/%d/%Y") + end + end + + it "shows the validation errors" do + dialog = overview_page.open_edit_dialog_for_life_cycles + + expected_text = "Date can’t be earlier than the previous Stage's end date." + + # Retrying because, the validation does not always kick in. + retry_block do + dialog.set_date_for(life_cycle_ready_for_planning, value: start_date) + dialog.set_date_for(life_cycle_ready_for_planning, value: start_date + 1.day) + + dialog.expect_validation_message(life_cycle_ready_for_planning, text: expected_text) + end + + # Saving the dialog fails + dialog.submit + dialog.expect_open + + # The validation message is kept after the unsuccessful save attempt + dialog.expect_validation_message(life_cycle_ready_for_planning, text: expected_text) + + # The validation message is cleared when date is changed + dialog.set_date_for(life_cycle_ready_for_planning, value: start_date + 2.days) + dialog.expect_no_validation_message(life_cycle_ready_for_planning) + + # Saving the dialog is successful + dialog.submit + dialog.expect_closed end end end diff --git a/spec/features/projects/life_cycle/overview_page/shared_context.rb b/spec/features/projects/life_cycle/overview_page/shared_context.rb index 89aaa454e34e..062fd05bd05a 100644 --- a/spec/features/projects/life_cycle/overview_page/shared_context.rb +++ b/spec/features/projects/life_cycle/overview_page/shared_context.rb @@ -53,39 +53,52 @@ create :project_stage_definition, name: "Closing" end + let(:start_date) { Time.zone.today.next_week } + let(:life_cycle_initiating) do create :project_stage, definition: life_cycle_initiating_definition, + start_date:, + end_date: start_date + 1.day, project: end let(:life_cycle_ready_for_planning) do create :project_gate, definition: life_cycle_ready_for_planning_definition, + date: start_date + 2.days, project: end let(:life_cycle_planning) do create :project_stage, definition: life_cycle_planning_definition, + start_date: start_date + 3.days, + end_date: start_date + 4.days, project: end let(:life_cycle_ready_for_executing) do create :project_gate, definition: life_cycle_ready_for_executing_definition, + date: start_date + 7.days, project: end let(:life_cycle_executing) do create :project_stage, definition: life_cycle_executing_definition, + start_date: start_date + 8.days, + end_date: start_date + 9.days, project: end let(:life_cycle_ready_for_closing) do create :project_gate, definition: life_cycle_ready_for_closing_definition, + date: start_date + 10.days, project: end let(:life_cycle_closing) do create :project_stage, definition: life_cycle_closing_definition, + start_date: start_date + 11.days, + end_date: start_date + 14.days, project: end diff --git a/spec/support/components/datepicker/basic_datepicker.rb b/spec/support/components/datepicker/basic_datepicker.rb index 08198fccd8b1..ade084d66983 100644 --- a/spec/support/components/datepicker/basic_datepicker.rb +++ b/spec/support/components/datepicker/basic_datepicker.rb @@ -22,5 +22,10 @@ def self.update_field(trigger, date) def flatpickr_container container.find(".flatpickr-calendar") end + + def open(trigger) + input = page.find(trigger) + input.click + end end end diff --git a/spec/support/components/projects/project_life_cycles/edit_dialog.rb b/spec/support/components/projects/project_life_cycles/edit_dialog.rb index d91435aa8cbb..83c1a3fce9f8 100644 --- a/spec/support/components/projects/project_life_cycles/edit_dialog.rb +++ b/spec/support/components/projects/project_life_cycles/edit_dialog.rb @@ -49,6 +49,22 @@ def within_async_content(close_after_yield: false, &) close if close_after_yield end + def set_date_for(step, value:) + datepicker = if value.is_a?(Array) + Components::RangeDatepicker.new + else + Components::BasicDatepicker.new + end + + datepicker.open( + "input[id^='project_available_life_cycle_steps_attributes_#{step.position - 1}']" + ) + + Array(value).each do |date| + datepicker.set_date(date.strftime("%Y-%m-%d")) + end + end + def close within_dialog do page.find(".close-button").click @@ -82,11 +98,13 @@ def expect_async_content_loaded def expect_input(label, value:, type:, position:) field = type == :stage ? :date_range : :date - expect(page).to have_field( - label, - with: value, - name: "project[available_life_cycle_steps_attributes][#{position - 1}][#{field}]" - ) + within_async_content do + expect(page).to have_field( + label, + with: value, + name: "project[available_life_cycle_steps_attributes][#{position - 1}][#{field}]" + ) + end end def expect_input_for(step) @@ -100,6 +118,40 @@ def expect_input_for(step) expect_input(step.name, position: step.position, **options) end + + def expect_caption(step, text: nil, present: true) + selector = 'span[id^="caption"]' + expect_selector_for(step, selector:, text:, present:) + end + + def expect_no_caption(step) + expect_caption(step, present: false) + end + + def expect_validation_message(step, text: nil, present: true) + selector = 'div[id^="validation"]' + expect_selector_for(step, selector:, text:, present:) + end + + def expect_no_validation_message(step) + expect_validation_message(step, present: false) + end + + private + + def expect_selector_for(step, selector:, text: nil, present: true) + within_async_content do + field = step.is_a?(Project::Stage) ? :date_range : :date + input_id = "#project_available_life_cycle_steps_attributes_#{step.position - 1}_#{field}" + parent = find(input_id).ancestor("primer-datepicker-field") + + if present + expect(parent).to have_selector(selector, text:) + else + expect(parent).to have_no_selector(selector) + end + end + end end end end From b8b0bdee50c913999d14ed434c1977b6618a209e Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 12:31:15 +0200 Subject: [PATCH 28/36] Fix unit specs --- spec/contracts/project_life_cycle_steps/base_contract_spec.rb | 4 +++- spec/lib/api/contracts/model_contract_spec.rb | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb index 2646b6a1df51..0b928f849f42 100644 --- a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -136,8 +136,10 @@ describe "triggering validations on the model" do it "sets the :saving_life_cycle_steps validation context" do + allow(project).to receive(:valid?) + contract.validate - expect(project).to have_receive(:valid?).with(:saving_life_cycle_steps) + expect(project).to have_received(:valid?).with(:saving_life_cycle_steps) end end end diff --git a/spec/lib/api/contracts/model_contract_spec.rb b/spec/lib/api/contracts/model_contract_spec.rb index 84c8baaf405b..9854403d193a 100644 --- a/spec/lib/api/contracts/model_contract_spec.rb +++ b/spec/lib/api/contracts/model_contract_spec.rb @@ -161,8 +161,10 @@ context "when a context is provided" do it "propagates the contex to the model as well" do + allow(model).to receive(:valid?) + model_contract.valid?(:custom_context) - expect(model).to have_receive(:valid?).with(:custom_context) + expect(model).to have_received(:valid?).with(:custom_context) end end end From 89de3bbec231791ff84ea225e611f4741be4138d Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:38:31 +0200 Subject: [PATCH 29/36] Attempt to fix flaky specs. --- .../overview_page/dialog/update_spec.rb | 29 +++++++++++++------ .../overview_page/shared_context.rb | 16 +++++----- .../project_life_cycles/edit_dialog.rb | 2 ++ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb index 44caa19131f8..bee2778a93a0 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -32,12 +32,13 @@ RSpec.describe "Edit project stages and gates on project overview page", :js, :with_cuprite, with_flag: { stages_and_gates: true } do include_context "with seeded projects and stages and gates" - let(:user) { create(:admin) } + shared_let(:overview) { create :overview, project: } + let(:overview_page) { Pages::Projects::Show.new(project) } before do # TODO: Could this work for all feature specs? - allow(User).to receive(:current).and_return user + allow(User).to receive(:current).and_return admin overview_page.visit_page end @@ -84,7 +85,7 @@ initiating_dates = [start_date - 1.week, start_date] retry_block do - # Retrying because, the caption update does not always kick in. + # Retrying due to a race condition between filling the input vs submitting the form preview. original_dates = [life_cycle_initiating.start_date, life_cycle_initiating.end_date] dialog.set_date_for(life_cycle_initiating, value: original_dates) dialog.set_date_for(life_cycle_initiating, value: initiating_dates) @@ -112,14 +113,21 @@ end it "shows the validation errors" do + expect_angular_frontend_initialized + wait_for_network_idle + dialog = overview_page.open_edit_dialog_for_life_cycles expected_text = "Date can’t be earlier than the previous Stage's end date." - # Retrying because, the validation does not always kick in. + # Cycling is required so we always select a different date on the datepicker, + # making sure the change event is triggered. + cycled_days = [0, 1].cycle + + # Retrying due to a race condition between filling the input vs submitting the form preview. retry_block do - dialog.set_date_for(life_cycle_ready_for_planning, value: start_date) - dialog.set_date_for(life_cycle_ready_for_planning, value: start_date + 1.day) + value = start_date + cycled_days.next.days + dialog.set_date_for(life_cycle_ready_for_planning, value:) dialog.expect_validation_message(life_cycle_ready_for_planning, text: expected_text) end @@ -131,9 +139,12 @@ # The validation message is kept after the unsuccessful save attempt dialog.expect_validation_message(life_cycle_ready_for_planning, text: expected_text) - # The validation message is cleared when date is changed - dialog.set_date_for(life_cycle_ready_for_planning, value: start_date + 2.days) - dialog.expect_no_validation_message(life_cycle_ready_for_planning) + retry_block do + # The validation message is cleared when date is changed + value = start_date + 2.days + cycled_days.next.days + dialog.set_date_for(life_cycle_ready_for_planning, value:) + dialog.expect_no_validation_message(life_cycle_ready_for_planning) + end # Saving the dialog is successful dialog.submit diff --git a/spec/features/projects/life_cycle/overview_page/shared_context.rb b/spec/features/projects/life_cycle/overview_page/shared_context.rb index 062fd05bd05a..67c480b13f80 100644 --- a/spec/features/projects/life_cycle/overview_page/shared_context.rb +++ b/spec/features/projects/life_cycle/overview_page/shared_context.rb @@ -71,34 +71,34 @@ let(:life_cycle_planning) do create :project_stage, definition: life_cycle_planning_definition, - start_date: start_date + 3.days, - end_date: start_date + 4.days, + start_date: start_date + 4.days, + end_date: start_date + 7.days, project: end let(:life_cycle_ready_for_executing) do create :project_gate, definition: life_cycle_ready_for_executing_definition, - date: start_date + 7.days, + date: start_date + 8.days, project: end let(:life_cycle_executing) do create :project_stage, definition: life_cycle_executing_definition, - start_date: start_date + 8.days, - end_date: start_date + 9.days, + start_date: start_date + 9.days, + end_date: start_date + 10.days, project: end let(:life_cycle_ready_for_closing) do create :project_gate, definition: life_cycle_ready_for_closing_definition, - date: start_date + 10.days, + date: start_date + 11.days, project: end let(:life_cycle_closing) do create :project_stage, definition: life_cycle_closing_definition, - start_date: start_date + 11.days, - end_date: start_date + 14.days, + start_date: start_date + 14.days, + end_date: start_date + 18.days, project: end diff --git a/spec/support/components/projects/project_life_cycles/edit_dialog.rb b/spec/support/components/projects/project_life_cycles/edit_dialog.rb index 83c1a3fce9f8..a0ce072c7f93 100644 --- a/spec/support/components/projects/project_life_cycles/edit_dialog.rb +++ b/spec/support/components/projects/project_life_cycles/edit_dialog.rb @@ -150,6 +150,8 @@ def expect_selector_for(step, selector:, text: nil, present: true) else expect(parent).to have_no_selector(selector) end + rescue StandardError + raise "Expected to#{present ? '' : ' not'} have a visible selector '#{selector}'." end end end From 7aee3c1bf16415a1073180fd4fbb25c4a9b94e1e Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:01:12 +0200 Subject: [PATCH 30/36] Use the correct Project Lifecycle label --- .../project_life_cycles/sections/edit_dialog_component.html.erb | 2 +- .../project_life_cycles/sections/show_component.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb index 94fa971e49a3..7ff822ece821 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb @@ -1,5 +1,5 @@ <%= - render(Primer::Alpha::Dialog.new(title: t("label_life_cycle_plural"), + render(Primer::Alpha::Dialog.new(title: t("label_life_cycle_step_plural"), size: :large, id: "edit-project-life-cycles-dialog")) do |d| d.with_header(variant: :large) diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index 2724aaa0aaf2..0aa8e9678fe6 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -4,7 +4,7 @@ classes: 'op-project-life-cyle-section-container', test_selector: "project-life-cycle-section" )) do |section| - section.with_title { t("label_life_cycle_plural") } + section.with_title { t("label_life_cycle_step_plural") } if allowed_to_edit? section.with_action_icon( From a96fdd1de130f51d520049ea652886db048ae490 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:57:13 +0200 Subject: [PATCH 31/36] Address CR comments --- config/locales/en.yml | 4 +-- frontend/src/turbo/turbo-global-listeners.ts | 2 +- .../open_project/forms/date_picker.html.erb | 29 +++++++++++++++++++ lib/primer/open_project/forms/date_picker.rb | 28 ++++++++++++++++++ .../forms/dsl/range_date_picker_input.rb | 28 ++++++++++++++++++ .../forms/dsl/single_date_picker_input.rb | 28 ++++++++++++++++++ .../sections/edit_component.rb | 4 --- .../sections/edit_dialog_component.html.erb | 4 +-- .../sections/edit_dialog_component.rb | 4 +++ .../project_life_cycles/show_component.rb | 20 +++++-------- .../sections/show_component.html.erb | 2 +- .../sections/show_component.rb | 3 +- modules/overviews/lib/overviews/engine.rb | 3 +- .../base_contract_spec.rb | 4 +-- .../overview_page/dialog/permission_spec.rb | 1 + .../overview_page/dialog/permission_spec.rb | 1 - spec/models/project/stage_spec.rb | 3 ++ spec/support/pages/projects/show.rb | 4 +-- 18 files changed, 141 insertions(+), 31 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index de8d83c86a23..9d8817ed940e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1235,12 +1235,12 @@ en: type: type_and_class_name_mismatch: "must be a Project::Gate" date: - non_continuous_dates: "can’t be earlier than the previous %{step}'s end date." + non_continuous_dates: "can't be earlier than the previous %{step}'s end date." project/stage: attributes: date_range: start_date_must_be_before_end_date: "start date must be before the end date." - non_continuous_dates: "can’t be earlier than the previous %{step}'s end date." + non_continuous_dates: "can't be earlier than the previous %{step}'s end date." incomplete: "is incomplete." type: type_and_class_name_mismatch: "must be a Project::Stage" diff --git a/frontend/src/turbo/turbo-global-listeners.ts b/frontend/src/turbo/turbo-global-listeners.ts index 0f379be56818..8ad1914e8fdc 100644 --- a/frontend/src/turbo/turbo-global-listeners.ts +++ b/frontend/src/turbo/turbo-global-listeners.ts @@ -69,7 +69,7 @@ export function addTurboGlobalListeners() { const element = event.target as HTMLElement; // In case the element is an OpenProject custom dom element, morphing is prevented. - if (element.tagName.startsWith('OPCE-')) { + if (element.tagName.toUpperCase().startsWith('OPCE-')) { event.preventDefault(); } }); diff --git a/lib/primer/open_project/forms/date_picker.html.erb b/lib/primer/open_project/forms/date_picker.html.erb index 0483d34c9515..7b55923f0a48 100644 --- a/lib/primer/open_project/forms/date_picker.html.erb +++ b/lib/primer/open_project/forms/date_picker.html.erb @@ -1,3 +1,32 @@ +<%#-- 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(FormControl.new(input: @input, tag: :"primer-datepicker-field")) do %> <%= content_tag(:div, **@field_wrap_arguments) do %> <%# leading spinner implies a leading visual %> diff --git a/lib/primer/open_project/forms/date_picker.rb b/lib/primer/open_project/forms/date_picker.rb index f3089f101f77..6c7de2fc830c 100644 --- a/lib/primer/open_project/forms/date_picker.rb +++ b/lib/primer/open_project/forms/date_picker.rb @@ -1,5 +1,33 @@ # frozen_string_literal: true +#-- 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. +#++ + module Primer module OpenProject module Forms diff --git a/lib/primer/open_project/forms/dsl/range_date_picker_input.rb b/lib/primer/open_project/forms/dsl/range_date_picker_input.rb index e3a5710228de..0b79a36b068c 100644 --- a/lib/primer/open_project/forms/dsl/range_date_picker_input.rb +++ b/lib/primer/open_project/forms/dsl/range_date_picker_input.rb @@ -1,5 +1,33 @@ # frozen_string_literal: true +#-- 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. +#++ + module Primer module OpenProject module Forms diff --git a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb index d62eed45b220..7a356bdfba4c 100644 --- a/lib/primer/open_project/forms/dsl/single_date_picker_input.rb +++ b/lib/primer/open_project/forms/dsl/single_date_picker_input.rb @@ -1,5 +1,33 @@ # frozen_string_literal: true +#-- 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. +#++ + module Primer module OpenProject module Forms diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb index d0016f8b313d..69e395d772f7 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.rb @@ -32,10 +32,6 @@ class EditComponent < ApplicationComponent include ApplicationHelper include OpTurbo::Streamable include OpPrimer::ComponentHelpers - - def life_cycle_steps - model.life_cycle_steps.active.eager_load(:definition).order(position: :asc) - end end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb index 7ff822ece821..a2eab213ea09 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.html.erb @@ -1,7 +1,7 @@ <%= render(Primer::Alpha::Dialog.new(title: t("label_life_cycle_step_plural"), size: :large, - id: "edit-project-life-cycles-dialog")) do |d| + id: dialog_id)) do |d| d.with_header(variant: :large) d.with_body(classes: "Overlay-body_autocomplete_height") do render(::ProjectLifeCycles::Sections::EditComponent.new(model)) @@ -10,7 +10,7 @@ component_collection do |footer_collection| footer_collection.with_component(Primer::ButtonComponent.new( data: { - 'close-dialog-id': "edit-project-life-cycles-dialog" + "close-dialog-id": dialog_id } )) do t("button_cancel") diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb index 584b01af793b..45f2689421ea 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_dialog_component.rb @@ -32,6 +32,10 @@ class EditDialogComponent < ApplicationComponent include ApplicationHelper include OpTurbo::Streamable include OpPrimer::ComponentHelpers + + def dialog_id + "edit-project-life-cycles-dialog" + end end end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb index 4c2e552c8d84..43e1f19da34f 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/project_life_cycles/show_component.rb @@ -40,18 +40,14 @@ def not_set? end def render_value - case model - when Project::Gate - render(Primer::Beta::Text.new) do - concat helpers.format_date(model.date) - end - when Project::Stage - render(Primer::Beta::Text.new) do - concat [ - helpers.format_date(model.start_date), - helpers.format_date(model.end_date) - ].join(" - ") - end + render(Primer::Beta::Text.new) do + concat [ + model.start_date, + model.end_date + ] + .compact + .map { |d| helpers.format_date(d) } + .join(" - ") end end diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb index 0aa8e9678fe6..c8a801c9f9dd 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.html.erb @@ -21,7 +21,7 @@ flex_layout do |details_container| @life_cycle_steps.each_with_index do |life_cycle_step, i| - margin = i == @life_cycle_steps.size - 1 ? 0 : 3 + margin = i == @life_cycle_steps.size - 1 ? 0 : 3 details_container.with_row(mb: margin) do render(ProjectLifeCycles::Sections::ProjectLifeCycles::ShowComponent.new( life_cycle_step diff --git a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb index f7af8e54be14..f848609e596f 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/show_component.rb +++ b/modules/overviews/app/components/project_life_cycles/sections/show_component.rb @@ -37,8 +37,7 @@ def initialize(project:) super @project = project - @life_cycle_steps = - @project.life_cycle_steps.active.eager_load(:definition).order(position: :asc) + @life_cycle_steps = @project.available_life_cycle_steps end private diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index e9ba90c450d7..f9e5988c90b8 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -47,8 +47,7 @@ class Engine < ::Rails::Engine OpenProject::AccessControl.permission(:view_project) .controller_actions .push( - "overviews/overviews/show", - "overviews/overviews/project_life_cycles_sidebar" + "overviews/overviews/show" ) OpenProject::AccessControl.permission(:view_project_attributes) diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb index 0b928f849f42..8bb93c4a5b65 100644 --- a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -37,7 +37,7 @@ let(:contract) { described_class.new(project, user) } let(:project) { build_stubbed(:project) } - context "with authorised user" do + context "with authorized user" do let(:user) { build_stubbed(:user) } let(:project) { build_stubbed(:project, available_life_cycle_steps: steps) } let(:steps) { [] } @@ -145,7 +145,7 @@ end end - context "with unauthorised user" do + context "with unauthorized user" do let(:user) { build_stubbed(:user) } it_behaves_like "contract user is unauthorized" diff --git a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb index 1623d8ae85d6..85da512cea9a 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb @@ -60,6 +60,7 @@ expect(page).to have_text("Project lifecycle") end end + end describe "with Edit project permissions" do let(:permissions) { [:view_project, :view_project_stages_and_gates, :edit_project] } diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb index ba44fca7a034..579893825534 100644 --- a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb @@ -31,7 +31,6 @@ RSpec.describe "Edit project custom fields on project overview page", :js do include_context "with seeded projects, members and project custom fields" - let(:overview_page) { Pages::Projects::Show.new(project) } describe "with insufficient View attributes permissions" do diff --git a/spec/models/project/stage_spec.rb b/spec/models/project/stage_spec.rb index 5fc4234345a2..99388f780f89 100644 --- a/spec/models/project/stage_spec.rb +++ b/spec/models/project/stage_spec.rb @@ -139,6 +139,9 @@ .and_return([]) expect(subject.working_days_count).to eq(0) + + expect(Day).to have_received(:working).with(no_args) + expect(Day).to have_received(:from_range).with(from: subject.start_date, to: subject.end_date) end end end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index bae1d5210bb2..4877c69ab00e 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -51,12 +51,12 @@ def visit_page def expect_no_visible_sidebar expect_angular_frontend_initialized - expect(page).to have_no_css(".op-grid-page--grid-container") + expect(page).to have_no_css(".op-grid-page--sidebar") end def expect_visible_sidebar expect_angular_frontend_initialized - expect(page).to have_css(".op-grid-page--grid-container") + expect(page).to have_css(".op-grid-page--sidebar") end def within_project_attributes_sidebar(&) From 0b89658cc774b6fb9b55d9201c1d1e12da265072 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:22:39 +0200 Subject: [PATCH 32/36] Fix specs --- spec/contracts/project_life_cycle_steps/base_contract_spec.rb | 3 +-- spec/models/project_spec.rb | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb index 8bb93c4a5b65..e0df64f40504 100644 --- a/spec/contracts/project_life_cycle_steps/base_contract_spec.rb +++ b/spec/contracts/project_life_cycle_steps/base_contract_spec.rb @@ -116,8 +116,7 @@ context "and the other steps have increasing dates" do let(:steps) { [step1, step_missing_dates, step2] } - it_behaves_like "contract is invalid", - "available_life_cycle_steps.date_range": :blank + it_behaves_like "contract is valid" end context "and the other steps have decreasing dates" do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 0a446f85d63d..ab2e73be473a 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -399,7 +399,7 @@ end describe ".validates_associated" do - let!(:project_stage) { create :project_stage, :skip_validate, project:, start_date: nil, end_date: nil } + let!(:project_stage) { create :project_stage, :skip_validate, project:, start_date: nil } it "is valid without a validation context" do expect(project).to be_valid From b488419f1337aa9a4beb012b60e55a5de64455b4 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 18:21:47 +0200 Subject: [PATCH 33/36] Fix single quote character --- .../projects/life_cycle/overview_page/dialog/update_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb index bee2778a93a0..1f3d7dd2ea6e 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -118,7 +118,7 @@ dialog = overview_page.open_edit_dialog_for_life_cycles - expected_text = "Date can’t be earlier than the previous Stage's end date." + expected_text = "Date can't be earlier than the previous Stage's end date." # Cycling is required so we always select a different date on the datepicker, # making sure the change event is triggered. From 3b7b6c3a5576ec5df8cca0fcbfd75873ad95646d Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 18:48:41 +0200 Subject: [PATCH 34/36] Allow retry_block on RSpec::Expectations::ExpectationNotMetError too. This exception is not included by default, because it does not inherit from the StandarError, but from the Exception. --- .../components/projects/project_life_cycles/edit_dialog.rb | 2 -- spec/support/rspec_retry.rb | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/support/components/projects/project_life_cycles/edit_dialog.rb b/spec/support/components/projects/project_life_cycles/edit_dialog.rb index a0ce072c7f93..83c1a3fce9f8 100644 --- a/spec/support/components/projects/project_life_cycles/edit_dialog.rb +++ b/spec/support/components/projects/project_life_cycles/edit_dialog.rb @@ -150,8 +150,6 @@ def expect_selector_for(step, selector:, text: nil, present: true) else expect(parent).to have_no_selector(selector) end - rescue StandardError - raise "Expected to#{present ? '' : ' not'} have a visible selector '#{selector}'." end end end diff --git a/spec/support/rspec_retry.rb b/spec/support/rspec_retry.rb index 281fe9779792..8145d377aac8 100644 --- a/spec/support/rspec_retry.rb +++ b/spec/support/rspec_retry.rb @@ -76,6 +76,11 @@ def retry_block(args: {}, screenshot: false, &) end end + # By default retry_block works with StandardError, but the ExpectationNotMetError is + # not inherited from StandardError. Adding the RSpec::Expectations::ExpectationNotMetError + # will makes sure we retry if an expectation fails inside the retry_block. + args[:on] ||= [StandardError, RSpec::Expectations::ExpectationNotMetError] + Retriable.retriable(on_retry: log_errors, **args, &) end From f00f0513d5d654cfd0aabacffcafe1e4f697aa05 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 17 Dec 2024 19:56:38 +0200 Subject: [PATCH 35/36] Fix rubocop issues --- .../life_cycle/overview_page/dialog/permission_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb index 85da512cea9a..09cdcd192da6 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb @@ -63,7 +63,7 @@ end describe "with Edit project permissions" do - let(:permissions) { [:view_project, :view_project_stages_and_gates, :edit_project] } + let(:permissions) { %i[view_project view_project_stages_and_gates edit_project] } it "does not show the edit buttons" do overview_page.within_life_cycles_sidebar do @@ -73,7 +73,7 @@ end describe "with sufficient Edit Stages and Gates permissions" do - let(:permissions) { [:view_project, :view_project_stages_and_gates, :edit_project, :edit_project_stages_and_gates] } + let(:permissions) { %i[view_project view_project_stages_and_gates edit_project edit_project_stages_and_gates] } it "shows the edit buttons" do overview_page.within_life_cycles_sidebar do From f0fce0e1989ab8e60a45910d42f41728508a424f Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 18 Dec 2024 11:35:51 +0200 Subject: [PATCH 36/36] Address CR comments --- .../overview_page/dialog/permission_spec.rb | 5 +- .../overview_page/dialog/update_spec.rb | 4 +- .../life_cycle/overview_page/sidebar_spec.rb | 48 +++++++++---------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb index 09cdcd192da6..debb041b9fbb 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/permission_spec.rb @@ -32,12 +32,13 @@ RSpec.describe "Edit project stages and gates on project overview page", :js, :with_cuprite, with_flag: { stages_and_gates: true } do include_context "with seeded projects and stages and gates" - let(:user) { create(:user) } + shared_let(:user) { create(:user) } let(:overview_page) { Pages::Projects::Show.new(project) } let(:permissions) { [] } + current_user { user } + before do - allow(User).to receive(:current).and_return user mock_permissions_for(user) do |mock| mock.allow_in_project(*permissions, project:) # any project end diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb index 1f3d7dd2ea6e..59431c3ac7fa 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -36,9 +36,9 @@ let(:overview_page) { Pages::Projects::Show.new(project) } + current_user { admin } + before do - # TODO: Could this work for all feature specs? - allow(User).to receive(:current).and_return admin overview_page.visit_page end diff --git a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb index 0b0b946e5195..66298f4b1c4a 100644 --- a/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/sidebar_spec.rb @@ -34,9 +34,7 @@ let(:overview_page) { Pages::Projects::Show.new(project) } - before do - login_as admin - end + current_user { admin } it "does show the sidebar" do overview_page.visit_page @@ -66,17 +64,17 @@ overview_page.visit_page overview_page.within_life_cycles_sidebar do - fields = page.all(".op-project-life-cycle-container") - - expect(fields.size).to eq(7) - - expect(fields[0].text).to include("Initiating") - expect(fields[1].text).to include("Ready for Planning") - expect(fields[2].text).to include("Planning") - expect(fields[3].text).to include("Ready for Executing") - expect(fields[4].text).to include("Executing") - expect(fields[5].text).to include("Ready for Closing") - expect(fields[6].text).to include("Closing") + expected_stages = [ + "Initiating", + "Ready for Planning", + "Planning", + "Ready for Executing", + "Executing", + "Ready for Closing", + "Closing" + ] + fields = page.all(".op-project-life-cycle-container > div:first-child") + expect(fields.map(&:text)).to eq(expected_stages) end life_cycle_ready_for_executing_definition.move_to_bottom @@ -84,17 +82,17 @@ overview_page.visit_page overview_page.within_life_cycles_sidebar do - fields = page.all(".op-project-life-cycle-container") - - expect(fields.size).to eq(7) - - expect(fields[0].text).to include("Initiating") - expect(fields[1].text).to include("Ready for Planning") - expect(fields[2].text).to include("Planning") - expect(fields[3].text).to include("Executing") - expect(fields[4].text).to include("Ready for Closing") - expect(fields[5].text).to include("Closing") - expect(fields[6].text).to include("Ready for Executing") + expected_stages = [ + "Initiating", + "Ready for Planning", + "Planning", + "Executing", + "Ready for Closing", + "Closing", + "Ready for Executing" + ] + fields = page.all(".op-project-life-cycle-container > div:first-child") + expect(fields.map(&:text)).to eq(expected_stages) end end