Skip to content
Draft
40 changes: 38 additions & 2 deletions modules/reporting/app/models/cost_query/filter/version_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,51 @@

class CostQuery::Filter::VersionId < Report::Filter::Base
use :null_operators
join_table WorkPackage => [Entry, :entity]
db_field "work_package_versions.version_id"
Comment thread
akabiru marked this conversation as resolved.
applies_for :label_work_package_attributes
Comment thread
akabiru marked this conversation as resolved.

# Resolved per report-build rather than via the join_table DSL (which freezes
# the join at class load) so the multiple-versions feature flag can switch
# between all target versions and the primary one.
def self.table_joins
[[CostQuery::WorkPackageTargetVersionJoin.sql]]
end

def self.label
WorkPackage.human_attribute_name(:version)
WorkPackage.human_attribute_name(Setting::WorkPackageMultipleVersions.active? ? :target_versions : :version)
end

def self.available_values(*)
versions = Version.where(project_id: Project.visible.map(&:id))
versions.map { |a| ["#{a.project.name} - #{a.name}", a.id] }.sort_by { |a| a.first.to_s + a.second.to_s }
end

# The default "is not" operator negates row by row. With every target version
# joined that lets a work package slip through on a non-excluded row (targets
# [1, 2] would still match "is not 1" via the 2 row), so exclude on the work
# package as a whole with a NOT EXISTS subquery. The primary-only join is
# already one-to-one, so this only applies while multiple versions is on.
def apply_operator_to(query)
return super unless Setting::WorkPackageMultipleVersions.active? && operator.to_s == "!"

version_ids = sql_query_values(operator.arity).compact
return super if version_ids.empty?

query.where(sql_excluding_target_versions(version_ids))
query
end

private

def sql_excluding_target_versions(version_ids)
<<~SQL.squish
NOT EXISTS (
SELECT 1 FROM work_package_versions excluded
WHERE excluded.work_package_id = entries.entity_id
AND entries.entity_type = 'WorkPackage'
AND excluded.kind = 'target'
AND excluded.version_id IN #{collection(*version_ids)}
)
SQL
end
end
14 changes: 12 additions & 2 deletions modules/reporting/app/models/cost_query/group_by/version_id.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,20 @@
#++

class CostQuery::GroupBy::VersionId < Report::GroupBy::Base
join_table WorkPackage => [Entry, :entity]
# Keep the derived "version_id" field bare so it renders through the existing
# :version_id branch; the table name qualifies it for the SELECT and GROUP BY.
table_name "work_package_versions"
applies_for :label_work_package_attributes

# Resolved per report-build rather than via the join_table DSL (which freezes
# the join at class load) so the multiple-versions feature flag can switch
# between all target versions and the primary one. Must return the same string
# as the filter so the engine collapses them into a single join.
def self.table_joins
[[CostQuery::WorkPackageTargetVersionJoin.sql]]
end

def self.label
WorkPackage.human_attribute_name(:version)
WorkPackage.human_attribute_name(Setting::WorkPackageMultipleVersions.active? ? :target_versions : :version)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 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 CostQuery::WorkPackageTargetVersionJoin
# Reaches a work package's target versions from the reporting entries.
#
# entries.entity_id is polymorphic (a time entry can point at a Meeting whose
# id collides with a work package's), so the entity_type guard lives in the ON
# clause: it keeps LEFT semantics for the group-by (non-work-package entries
# stay, grouped under "no version") instead of dropping them via WHERE.
#
# With multiple target versions on, the join is one-to-many: a work package
# with several target versions is reported under each of them. With the
# feature off a work package is single-version, so the join is narrowed to the
# primary target version (the lowest version id, matching target_versions.first)
# and stays one-to-one.
Comment thread
akabiru marked this conversation as resolved.
JOIN_ALL = <<~SQL.squish
LEFT OUTER JOIN work_package_versions
ON work_package_versions.work_package_id = entries.entity_id
AND entries.entity_type = 'WorkPackage'
AND work_package_versions.kind = 'target'
SQL

JOIN_PRIMARY = <<~SQL.squish
#{JOIN_ALL}
AND work_package_versions.version_id = (
SELECT MIN(primary_target.version_id)
FROM work_package_versions primary_target
WHERE primary_target.work_package_id = entries.entity_id
AND primary_target.kind = 'target'
)
SQL
Comment thread
akabiru marked this conversation as resolved.

# Read at report-build time so a feature-flag change takes effect without a
# restart. The filter and group-by must resolve to the exact same string so
# the engine collapses them into a single join.
def self.sql
Setting::WorkPackageMultipleVersions.active? ? JOIN_ALL : JOIN_PRIMARY
end
end
6 changes: 6 additions & 0 deletions modules/reporting/lib/widget/filters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ def render_filter(f_cls, f_inst)
render_widget User, f, to: html
elsif f_cls == CostQuery::Filter::WorkPackageId
render_widget WorkPackage, f, to: html
elsif f_cls == CostQuery::Filter::VersionId
if Setting::WorkPackageMultipleVersions.active?
render_widget Version, f, to: html
else
render_widget MultiValues, f, to: html, lazy: true, single: true
end
# Handling of generic widgets
elsif f_cls.heavy?
render_widget Heavy, f, to: html
Expand Down
2 changes: 1 addition & 1 deletion modules/reporting/lib/widget/filters/multi_values.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def render # rubocop:disable Metrics/AbcSize
end

content_tag(:span, class: "inline-label") do
label + box + plus
@options[:single] ? label + box : label + box + plus
end
end)
end
Expand Down
78 changes: 78 additions & 0 deletions modules/reporting/lib/widget/filters/version.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# 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.
#++

class Widget::Filters::Version < Widget::Filters::Base
include AngularHelper

def render
write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: "advanced-filters--filter-value") do
label = html_label

box = angular_component_tag "opce-autocompleter",
inputs: {
filters: [],
InputName: "values[#{filter_class.underscore_name}]",
hiddenFieldAction: "change->reporting--page#selectValueChanged",
multiple: true,
items: available_versions,
bindLabel: "name",
bindValue: "id",
model: selected_version_ids
},
id: "#{filter_class.underscore_name}_select_1",
class: "filter-value advanced-filters--ng-select"

content_tag(:span, class: "inline-label") do
label + box
end
end)
end

private

def html_label
label_tag "#{filter_class.underscore_name}_arg_1_val",
"#{h(filter_class.label)} #{I18n.t(:label_filter_value)}",
class: "sr-only"
end

# The whole set of selectable versions, project-qualified so identically named
# versions in different projects stay distinguishable. The list is small, so we
# hand it to the autocompleter up front and let it filter client-side.
def available_versions
filter_class.available_values.map { |label, id| { id:, name: label } }
end
Comment thread
akabiru marked this conversation as resolved.

def selected_version_ids
expand_comma_separated_values!

filter.values.compact_blank.map(&:to_i)
end
end
70 changes: 70 additions & 0 deletions modules/reporting/spec/lib/widget/filters/version_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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_relative "../../../spec_helper"

RSpec.describe Widget::Filters::Version do
let(:admin) { create(:admin) }
let(:project) { create(:project, name: "Demo project") }
let(:version) { create(:version, project:, name: "2.0.0") }

let(:filter) do
CostQuery::Filter::VersionId.new.tap { |f| f.values = [version.id.to_s] }
end
let(:widget) { described_class.new(filter) }

before { login_as(admin) }

describe "#available_versions" do
subject(:items) { widget.send(:available_versions) }

it "offers every version with a project-qualified label the autocompleter can filter" do
expect(items).to include(id: version.id, name: "Demo project - 2.0.0")
end
end

describe "#selected_version_ids" do
subject(:ids) { widget.send(:selected_version_ids) }

it "maps the filter values to integer ids the autocompleter matches against its items" do
expect(ids).to eq([version.id])
end

context "when a blank value slips in" do
let(:filter) do
CostQuery::Filter::VersionId.new.tap { |f| f.values = [version.id.to_s, ""] }
end

it "drops it instead of coercing it to a spurious id 0" do
expect(ids).to eq([version.id])
end
end
end
end
Loading
Loading