diff --git a/modules/backlogs/app/controllers/backlogs/sprints_controller.rb b/modules/backlogs/app/controllers/backlogs/sprints_controller.rb index fd1cc0a08847..86a6bd35998a 100644 --- a/modules/backlogs/app/controllers/backlogs/sprints_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/sprints_controller.rb @@ -50,8 +50,7 @@ class SprintsController < BaseController def index @sprints = Sprint.for_project(@project) - .order_by_date - .order(:name) + .order_by_activity .page(helpers.page_param(params)) .per_page(helpers.per_page_param) diff --git a/modules/backlogs/app/models/sprint.rb b/modules/backlogs/app/models/sprint.rb index c2926f939bac..c0f8f16c508c 100644 --- a/modules/backlogs/app/models/sprint.rb +++ b/modules/backlogs/app/models/sprint.rb @@ -54,6 +54,7 @@ class Sprint < ApplicationRecord scopes :assignable, :for_project, :not_completed, + :order_by_activity, :order_by_date, :receiving_projects, :visible, diff --git a/modules/backlogs/app/models/sprints/scopes/order_by_activity.rb b/modules/backlogs/app/models/sprints/scopes/order_by_activity.rb new file mode 100644 index 000000000000..0cf5f9bcb61b --- /dev/null +++ b/modules/backlogs/app/models/sprints/scopes/order_by_activity.rb @@ -0,0 +1,45 @@ +# 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 Sprints::Scopes::OrderByActivity + extend ActiveSupport::Concern + + class_methods do + def order_by_activity + order( + Arel.sql("CASE status WHEN 'active' THEN 0 WHEN 'in_planning' THEN 1 ELSE 2 END"), + arel_table[:start_date].desc.nulls_last, + arel_table[:finish_date].desc.nulls_last, + arel_table[:name].asc, + arel_table[:id].asc + ) + end + end +end diff --git a/modules/backlogs/spec/features/backlogs/sprints/index_spec.rb b/modules/backlogs/spec/features/backlogs/sprints/index_spec.rb index 05750663b72c..72a74bc0779b 100644 --- a/modules/backlogs/spec/features/backlogs/sprints/index_spec.rb +++ b/modules/backlogs/spec/features/backlogs/sprints/index_spec.rb @@ -78,10 +78,35 @@ end end - it "orders the sprints by date first, and then by name" do - sprints_page.visit! + context "when ordering by activity" do + let(:ordering_project) { create(:project) } + let(:ordering_page) { Pages::Sprints.new(ordering_project) } + let!(:completed_sprint) do + create(:sprint, project: ordering_project, status: :completed, + name: "Completed sprint", + start_date: Date.new(2025, 9, 1), + finish_date: Date.new(2025, 9, 10)) + end + let!(:planning_sprint) do + create(:sprint, project: ordering_project, status: :in_planning, + name: "Planning sprint", + start_date: Date.new(2025, 9, 1), + finish_date: Date.new(2025, 9, 10)) + end + let!(:active_sprint) do + create(:sprint, project: ordering_project, status: :active, + name: "Active sprint", + start_date: Date.new(2025, 9, 1), + finish_date: Date.new(2025, 9, 10)) + end - sprints_page.expect_sprints_in_order(sprints: [past_sprint, past_sprint_with_other_name, sprint, other_sprint]) + current_user { create(:user, member_with_permissions: { ordering_project => permissions }) } + + it "orders active first, then in_planning, then completed" do + ordering_page.visit! + + ordering_page.expect_sprints_in_order(sprints: [active_sprint, planning_sprint, completed_sprint]) + end end it "shows the correct values per column" do @@ -101,18 +126,18 @@ sprints_page.visit! sprints_page.expect_pagination_range(from: 1, to: 2, total: 4) - sprints_page.expect_sprint_present(past_sprint) - sprints_page.expect_sprint_present(past_sprint_with_other_name) - sprints_page.expect_sprint_not_present(sprint) - sprints_page.expect_sprint_not_present(other_sprint) + sprints_page.expect_sprint_present(other_sprint) + sprints_page.expect_sprint_present(sprint) + sprints_page.expect_sprint_not_present(past_sprint) + sprints_page.expect_sprint_not_present(past_sprint_with_other_name) sprints_page.go_to_page!(2) sprints_page.expect_pagination_range(from: 3, to: 4, total: 4) - sprints_page.expect_sprint_present(sprint) - sprints_page.expect_sprint_present(other_sprint) - sprints_page.expect_sprint_not_present(past_sprint) - sprints_page.expect_sprint_not_present(past_sprint_with_other_name) + sprints_page.expect_sprint_present(past_sprint) + sprints_page.expect_sprint_present(past_sprint_with_other_name) + sprints_page.expect_sprint_not_present(other_sprint) + sprints_page.expect_sprint_not_present(sprint) end context "when there are no sprints" do diff --git a/modules/backlogs/spec/models/sprints/scopes/order_by_activity_spec.rb b/modules/backlogs/spec/models/sprints/scopes/order_by_activity_spec.rb new file mode 100644 index 000000000000..4da9d2202128 --- /dev/null +++ b/modules/backlogs/spec/models/sprints/scopes/order_by_activity_spec.rb @@ -0,0 +1,119 @@ +# 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" + +RSpec.describe Sprints::Scopes::OrderByActivity do + shared_let(:project) { create(:project) } + + shared_let(:active_sprint) do + create(:sprint, project:, status: :active, + name: "Active sprint", + start_date: Date.new(2025, 9, 1), + finish_date: Date.new(2025, 9, 10)) + end + shared_let(:in_planning_sprint_later) do + create(:sprint, project:, status: :in_planning, + name: "Later planning sprint", + start_date: Date.new(2025, 9, 11), + finish_date: Date.new(2025, 9, 20)) + end + shared_let(:in_planning_sprint_earlier) do + create(:sprint, project:, status: :in_planning, + name: "Earlier planning sprint", + start_date: Date.new(2025, 9, 1), + finish_date: Date.new(2025, 9, 10)) + end + shared_let(:in_planning_sprint_no_dates) do + create(:sprint, project:, status: :in_planning, + name: "No dates planning sprint", + start_date: nil, + finish_date: nil) + end + shared_let(:completed_sprint_b) do + create(:sprint, project:, status: :completed, + name: "Completed sprint B", + start_date: Date.new(2025, 8, 1), + finish_date: Date.new(2025, 8, 31)) + end + shared_let(:completed_sprint_a) do + create(:sprint, project:, status: :completed, + name: "Completed sprint A", + start_date: Date.new(2025, 8, 1), + finish_date: Date.new(2025, 8, 31)) + end + + it "orders active first, then in_planning, then completed" do + expected_order = [ + active_sprint, + in_planning_sprint_later, + in_planning_sprint_earlier, + in_planning_sprint_no_dates, + completed_sprint_a, + completed_sprint_b + ] + + expect(Sprint.order_by_activity).to eq(expected_order) + end + + it "orders sprints with the same status by start_date descending" do + expected_order = [ + in_planning_sprint_later, + in_planning_sprint_earlier, + in_planning_sprint_no_dates + ] + + expect(Sprint.order_by_activity.in_planning).to eq(expected_order) + end + + it "places sprints without dates last within their status group" do + expect(Sprint.order_by_activity.in_planning.last).to eq(in_planning_sprint_no_dates) + end + + it "breaks ties within the same status and dates by name ascending" do + expect(Sprint.order_by_activity.completed).to eq([completed_sprint_a, completed_sprint_b]) + end + + it "breaks ties within the same status, dates, and name by id ascending" do + duplicate_a = create(:sprint, project:, status: :completed, + name: "Duplicate", + start_date: Date.new(2025, 8, 1), + finish_date: Date.new(2025, 8, 31)) + duplicate_b = create(:sprint, project:, status: :completed, + name: "Duplicate", + start_date: Date.new(2025, 8, 1), + finish_date: Date.new(2025, 8, 31)) + + expect(Sprint.order_by_activity).to include(duplicate_a, duplicate_b) + idx_a = Sprint.order_by_activity.to_a.index(duplicate_a) + idx_b = Sprint.order_by_activity.to_a.index(duplicate_b) + expect(idx_a).to be < idx_b + end +end