diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 2cb0ebb6bbdd..917cbece33cf 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -54,6 +54,9 @@ class BaseContract < ::ModelContract permission: :assign_versions do validate_target_versions_are_assignable end + # Observed versions have no deprecated single-value counterpart to coexist + # with, so unlike target versions they are always offered and always + # multi-valued. attribute :observed_in_versions, permission: :assign_versions do validate_observed_in_versions_are_assignable diff --git a/app/models/activities/fetcher.rb b/app/models/activities/fetcher.rb index f31e07c4c6d4..a50dfde3dc5f 100644 --- a/app/models/activities/fetcher.rb +++ b/app/models/activities/fetcher.rb @@ -124,7 +124,8 @@ def journals_of_event_set(events) journal_ids = events.map(&:event_id) Journal - .includes(:data, :customizable_journals, :attachable_journals, :target_version_journals, :bcf_comment) + .includes(:data, :customizable_journals, :attachable_journals, :work_package_version_journals, + :bcf_comment) .find(journal_ids) .then { |journals| ::API::V3::Activities::ActivityEagerLoadingWrapper.wrap(journals) } .index_by(&:id) diff --git a/app/models/journal.rb b/app/models/journal.rb index ff0b3475e5bb..84b9bae66e67 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -60,6 +60,7 @@ class Journal < ApplicationRecord register_journal_formatter OpenProject::JournalFormatter::MeetingStartTime register_journal_formatter OpenProject::JournalFormatter::MeetingState register_journal_formatter OpenProject::JournalFormatter::MeetingWorkPackageId + register_journal_formatter OpenProject::JournalFormatter::ObservedInVersions register_journal_formatter OpenProject::JournalFormatter::ParticipantChange register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseActive register_journal_formatter OpenProject::JournalFormatter::ProjectPhaseDates @@ -122,13 +123,6 @@ class Journal < ApplicationRecord has_many :project_phase_journals, class_name: "Journal::ProjectPhaseJournal", dependent: :delete_all has_many :storable_journals, class_name: "Journal::StorableJournal", dependent: :delete_all has_many :work_package_version_journals, class_name: "Journal::WorkPackageVersionJournal", dependent: :delete_all - # Row lifecycle is owned by work_package_version_journals above. - # rubocop:disable Rails/HasManyOrHasOneDependent - has_many :target_version_journals, - -> { where(kind: "target") }, - class_name: "Journal::WorkPackageVersionJournal", - inverse_of: :journal - # rubocop:enable Rails/HasManyOrHasOneDependent has_many :notifications, dependent: :destroy @@ -146,6 +140,20 @@ class Journal < ApplicationRecord alias_attribute :internal, :restricted + # The snapshotted versions of a work package, split by the kind that + # references them. + # + # Deliberately derived in memory instead of being declared as kind-scoped + # associations: as associations, each kind would issue its own query, and + # every caller eager loads all of them together anyway. + def target_version_journals + work_package_version_journals.select { |journal| journal.kind == "target" } + end + + def observed_in_version_journals + work_package_version_journals.select { |journal| journal.kind == "observed_in" } + end + # In conjunction with the included Comparable module, allows comparison of journal records # based on their corresponding version numbers, creation timestamps and IDs. def <=>(other) diff --git a/app/models/queries/work_packages.rb b/app/models/queries/work_packages.rb index 26b3589eb3c8..1f648f49d273 100644 --- a/app/models/queries/work_packages.rb +++ b/app/models/queries/work_packages.rb @@ -59,6 +59,7 @@ module Queries::WorkPackages filter Filter::UpdatedAtFilter filter Filter::VersionFilter filter Filter::TargetVersionsFilter + filter Filter::ObservedInVersionsFilter filter Filter::WatcherFilter filter Filter::DatesIntervalFilter filter Filter::ParentFilter diff --git a/app/models/queries/work_packages/filter/filter_on_target_versions_mixin.rb b/app/models/queries/work_packages/filter/filter_on_work_package_versions_mixin.rb similarity index 78% rename from app/models/queries/work_packages/filter/filter_on_target_versions_mixin.rb rename to app/models/queries/work_packages/filter/filter_on_work_package_versions_mixin.rb index 8a16f5e388c1..c9da6ec352bc 100644 --- a/app/models/queries/work_packages/filter/filter_on_target_versions_mixin.rb +++ b/app/models/queries/work_packages/filter/filter_on_work_package_versions_mixin.rb @@ -28,7 +28,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Queries::WorkPackages::Filter::FilterOnTargetVersionsMixin +# Filters work packages on the versions referenced through +# work_package_versions. +# +# Including filters must define #version_kind, returning the +# work_package_versions kind they match on. +module Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin STATUS_BY_OPERATOR = { "o" => "open", "c" => "closed", "l" => "locked" }.freeze def allowed_values @@ -57,7 +62,7 @@ def ar_object_filter? end def where - target_versions_where + versions_where end def value_objects @@ -90,40 +95,40 @@ def versions end end - def target_versions_where + def versions_where case operator when "!" # is not - "NOT (#{target_version_matching_values})" + "NOT (#{version_matching_values})" when "!*" # empty - "NOT (#{any_target_version_associated})" + "NOT (#{any_version_associated})" when "*" # not empty - any_target_version_associated + any_version_associated when "o", "c", "l" # version status - target_version_with_status(STATUS_BY_OPERATOR[operator]) + version_with_status(STATUS_BY_OPERATOR[operator]) else # "=" is (or) - target_version_matching_values + version_matching_values end end - def any_target_version_associated - "EXISTS (#{target_associations.select(1).to_sql})" + def any_version_associated + "EXISTS (#{kind_associations.select(1).to_sql})" end - def target_version_matching_values - "EXISTS (#{target_associations.where(version_id: values).select(1).to_sql})" + def version_matching_values + "EXISTS (#{kind_associations.where(version_id: values).select(1).to_sql})" end - def target_version_with_status(status) - sub = target_associations + def version_with_status(status) + sub = kind_associations .joins(:version) .where(Version.table_name => { status: }) .select(1) "EXISTS (#{sub.to_sql})" end - def target_associations + def kind_associations WorkPackageVersion - .where(kind: "target") + .where(kind: version_kind) .where("#{WorkPackageVersion.table_name}.work_package_id = #{WorkPackage.table_name}.id") end end diff --git a/app/models/queries/work_packages/filter/observed_in_versions_filter.rb b/app/models/queries/work_packages/filter/observed_in_versions_filter.rb new file mode 100644 index 000000000000..6200a3db3127 --- /dev/null +++ b/app/models/queries/work_packages/filter/observed_in_versions_filter.rb @@ -0,0 +1,39 @@ +# 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 Queries::WorkPackages::Filter::ObservedInVersionsFilter < + Queries::WorkPackages::Filter::WorkPackageFilter + include ::Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin + + def version_kind = "observed_in" + + def self.key = :observed_in_version_id + def human_name = WorkPackage.human_attribute_name("observed_in_versions") +end diff --git a/app/models/queries/work_packages/filter/target_versions_filter.rb b/app/models/queries/work_packages/filter/target_versions_filter.rb index 00ae4f35c814..806496dcd85f 100644 --- a/app/models/queries/work_packages/filter/target_versions_filter.rb +++ b/app/models/queries/work_packages/filter/target_versions_filter.rb @@ -30,7 +30,9 @@ class Queries::WorkPackages::Filter::TargetVersionsFilter < Queries::WorkPackages::Filter::WorkPackageFilter - include ::Queries::WorkPackages::Filter::FilterOnTargetVersionsMixin + include ::Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin + + def version_kind = "target" def self.key = :target_version_id def human_name = WorkPackage.human_attribute_name("target_versions") diff --git a/app/models/queries/work_packages/filter/version_filter.rb b/app/models/queries/work_packages/filter/version_filter.rb index 3593510bdc5e..f7d49d912202 100644 --- a/app/models/queries/work_packages/filter/version_filter.rb +++ b/app/models/queries/work_packages/filter/version_filter.rb @@ -32,7 +32,9 @@ class Queries::WorkPackages::Filter::VersionFilter < Queries::WorkPackages::Filter::WorkPackageFilter # Filters on `target_versions` as it is replacing # the legacy `work_packages.version_id` column. - include ::Queries::WorkPackages::Filter::FilterOnTargetVersionsMixin + include ::Queries::WorkPackages::Filter::FilterOnWorkPackageVersionsMixin + + def version_kind = "target" def human_name WorkPackage.human_attribute_name("version") diff --git a/app/models/queries/work_packages/selects/property_select.rb b/app/models/queries/work_packages/selects/property_select.rb index 57c16655c41e..bd7f3ab1e625 100644 --- a/app/models/queries/work_packages/selects/property_select.rb +++ b/app/models/queries/work_packages/selects/property_select.rb @@ -144,6 +144,29 @@ def caption WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'target') SQL }, + observed_in_versions: { + sortable: [ + <<~SQL.squish, + (SELECT STRING_AGG(LOWER(v.name), ' ' ORDER BY LOWER(v.name), wpv.version_id) + FROM work_package_versions wpv + INNER JOIN versions v ON v.id = wpv.version_id + WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'observed_in') + SQL + <<~SQL.squish + (SELECT STRING_AGG(wpv.version_id::text, '.' ORDER BY LOWER(v.name), wpv.version_id) + FROM work_package_versions wpv + INNER JOIN versions v ON v.id = wpv.version_id + WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'observed_in') + SQL + ], + groupable: + <<~SQL.squish + (SELECT STRING_AGG(wpv.version_id::text, '.' ORDER BY LOWER(v.name), wpv.version_id) + FROM work_package_versions wpv + INNER JOIN versions v ON v.id = wpv.version_id + WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'observed_in') + SQL + }, start_date: { sortable: "#{WorkPackage.table_name}.start_date" }, diff --git a/app/models/work_package/exports/formatters/observed_in_versions.rb b/app/models/work_package/exports/formatters/observed_in_versions.rb new file mode 100644 index 000000000000..aa0879aa4ce0 --- /dev/null +++ b/app/models/work_package/exports/formatters/observed_in_versions.rb @@ -0,0 +1,43 @@ +# 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 WorkPackage::Exports + module Formatters + class ObservedInVersions < ::Exports::Formatters::Default + def self.apply?(attribute, _export_format) + attribute.to_sym == :observed_in_versions + end + + def retrieve_value(object) + object.observed_in_versions.map(&:name) + end + end + end +end diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb index 9261067e9d25..d50d9d1f17fa 100644 --- a/app/models/work_package/journalized.rb +++ b/app/models/work_package/journalized.rb @@ -102,6 +102,7 @@ def self.event_url register_journal_formatted_fields /\Afile_links_?\d+\z/, formatter_key: :file_link register_journal_formatted_fields "project_phase_definition_id", formatter_key: :project_phase_definition register_journal_formatted_fields "target_versions", formatter_key: :target_versions + register_journal_formatted_fields "observed_in_versions", formatter_key: :observed_in_versions # Joined register_journal_formatted_fields :parent_id, :project_id, diff --git a/app/models/work_package/versions.rb b/app/models/work_package/versions.rb index 6c5df559c231..33980513ecfc 100644 --- a/app/models/work_package/versions.rb +++ b/app/models/work_package/versions.rb @@ -187,6 +187,18 @@ def effective_target_versions target_version_ids_replacements.filter_map { |id| versions_by_id[id] } end + # List of observed in versions, but takes into account pending overrides that + # were not written yet. + # + # There is no deprecated single-value column mirroring this kind, so unlike + # #effective_target_versions only the override has to be considered. + def effective_observed_in_versions + return observed_in_versions if observed_in_version_ids_replacements.nil? + + versions_by_id = Version.where(id: observed_in_version_ids_replacements).index_by(&:id) + observed_in_version_ids_replacements.filter_map { |id| versions_by_id[id] } + end + # An override can also originate from the system, e.g. when versions that are # not shared with the (new) project are cleared on a project change. Such # overrides are marked here so that contracts don't attribute them to the diff --git a/app/services/journals/create_service/work_package_version.rb b/app/services/journals/create_service/work_package_version.rb index d2474ba34ac7..db3eb0a55d06 100644 --- a/app/services/journals/create_service/work_package_version.rb +++ b/app/services/journals/create_service/work_package_version.rb @@ -30,11 +30,9 @@ class Journals::CreateService # Journals the version associations of a work package. Only the kinds listed - # in JOURNALED_KINDS are snapshotted: observed_in versions stay unjournaled - # until the legacy version_id column stops being journaled, as they would - # otherwise render alongside it. + # in JOURNALED_KINDS are snapshotted. class WorkPackageVersion < Association - JOURNALED_KINDS = %w[target].freeze + JOURNALED_KINDS = %w[target observed_in].freeze def associated? journable.respond_to?(:target_versions) diff --git a/app/services/work_packages/activities_tab/paginator.rb b/app/services/work_packages/activities_tab/paginator.rb index f1176b8b78a8..f003ad2d3bee 100644 --- a/app/services/work_packages/activities_tab/paginator.rb +++ b/app/services/work_packages/activities_tab/paginator.rb @@ -178,8 +178,8 @@ def with_changesets(scope) def page_journals(page_relation) page_relation - .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, :target_version_journals, - :notifications, :attachments) + .includes(:user, :customizable_journals, :attachable_journals, :storable_journals, + :work_package_version_journals, :notifications, :attachments) .to_a end diff --git a/config/initializers/export_formats.rb b/config/initializers/export_formats.rb index 793e3563e99d..e1015bce9c4e 100644 --- a/config/initializers/export_formats.rb +++ b/config/initializers/export_formats.rb @@ -52,6 +52,7 @@ formatter WorkPackage, WorkPackage::Exports::Formatters::ProjectPhase formatter WorkPackage, WorkPackage::Exports::Formatters::SpentUnits formatter WorkPackage, WorkPackage::Exports::Formatters::TargetVersions + formatter WorkPackage, WorkPackage::Exports::Formatters::ObservedInVersions list Project, Projects::Exports::CSV list Project, Projects::Exports::PDF diff --git a/config/locales/en.yml b/config/locales/en.yml index 84e0b983f81f..da1d422a670e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -462,6 +462,7 @@ en: true: "include non-working days" journal_internal: Internal Journal notify: "Notify" # used in custom actions + observed_in_versions: "Observed versions" parent: "Parent" parent_issue: "Parent" parent_work_package: "Parent" diff --git a/docs/api/apiv3/components/schemas/work_package_model.yml b/docs/api/apiv3/components/schemas/work_package_model.yml index 648709496d3e..49d9e9f692cc 100644 --- a/docs/api/apiv3/components/schemas/work_package_model.yml +++ b/docs/api/apiv3/components/schemas/work_package_model.yml @@ -590,6 +590,19 @@ allOf: - Transitioning from single to multiple version support - (Temporary) Only allows a single value for compatibility with the version field - (Temporary) Must not be written together with `version` in the same request + observedInVersions: + type: array + items: + $ref: "./link.yml" + description: |- + List of versions the work package has been observed in + + **Resource**: Collection of Version + + # Conditions + + - Unlike `targetVersions`, closed versions may be assigned + - Always multi-valued, independently of multiple version support watchers: allOf: - $ref: "./link.yml" @@ -671,6 +684,9 @@ example: targetVersions: - href: "/api/v3/versions/1" title: Version 1 + observedInVersions: + - href: "/api/v3/versions/2" + title: Version 2 availableWatchers: href: "/api/v3/work_packages/1528/available_watchers" watch: diff --git a/docs/api/apiv3/components/schemas/work_package_schema_model.yml b/docs/api/apiv3/components/schemas/work_package_schema_model.yml index 97ae25c0f939..828110344355 100644 --- a/docs/api/apiv3/components/schemas/work_package_schema_model.yml +++ b/docs/api/apiv3/components/schemas/work_package_schema_model.yml @@ -90,6 +90,8 @@ properties: $ref: './schema_property_model.yml' targetVersions: $ref: './schema_property_model.yml' + observedInVersions: + $ref: './schema_property_model.yml' priority: $ref: './schema_property_model.yml' _links: diff --git a/docs/api/apiv3/components/schemas/work_package_write_model.yml b/docs/api/apiv3/components/schemas/work_package_write_model.yml index 483596c9689f..2efc036fb0a8 100644 --- a/docs/api/apiv3/components/schemas/work_package_write_model.yml +++ b/docs/api/apiv3/components/schemas/work_package_write_model.yml @@ -126,6 +126,19 @@ properties: - Transitioning from single to multiple version support - (Temporary) Only allows a single value for compatibility with the version field + observedInVersions: + type: array + items: + $ref: './link.yml' + description: |- + List of versions the work package has been observed in + + **Resource**: Collection of Version + + # Conditions + + - Unlike `targetVersions`, closed versions may be assigned + - Always multi-valued, independently of multiple version support parent: allOf: - $ref: './link.yml' diff --git a/docs/api/apiv3/tags/work_packages.yml b/docs/api/apiv3/tags/work_packages.yml index bf37263b099b..3e29ebf95623 100644 --- a/docs/api/apiv3/tags/work_packages.yml +++ b/docs/api/apiv3/tags/work_packages.yml @@ -50,6 +50,7 @@ description: |- | type | The type of the work package | Type | not null | READ / WRITE | | | version | The version associated to the work package | Version | | READ / WRITE | | | targetVersions | List of versions associated to the work package | []Version | | READ / WRITE | | + | observedInVersions | List of versions the work package has been observed in | []Version | | READ / WRITE | | | watchers | All users that are currently watching this work package | Collection | | READ | **Permission** view work package watchers | ## Local Properties diff --git a/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts b/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts index dd948d7ad67f..18a893cbab52 100644 --- a/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts +++ b/frontend/src/app/features/work-packages/components/wp-edit/work-package-changeset.ts @@ -62,13 +62,18 @@ export class WorkPackageChangeset extends ResourceChangeset delete (payload as { subject?:string }).subject; } - // Explicitly exclude the targetVersions if it's empty + // Explicitly exclude the version collections if they are empty if (isNewResource(this.pristineResource)) { - const links = (payload as { _links?:{ targetVersions?:unknown[] } })._links; - const targetVersions = links?.targetVersions; + const links = (payload as { _links?:Record })._links; - if (links && (!Array.isArray(targetVersions) || targetVersions.length === 0)) { - delete links.targetVersions; + if (links) { + ['targetVersions', 'observedInVersions'].forEach((attribute) => { + const value = links[attribute]; + + if (!Array.isArray(value) || value.length === 0) { + delete links[attribute]; + } + }); } } diff --git a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts index 01967f873396..8318d2b66339 100644 --- a/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts +++ b/frontend/src/app/shared/components/fields/edit/edit-field.initializer.ts @@ -143,7 +143,7 @@ export function initializeCoreEditFields(editFieldService:EditFieldService, sele 'WorkPackage', VersionsEditFieldComponent, 'versions', - ['targetVersions'], + ['targetVersions', 'observedInVersions'], ) .addSpecificFieldType('Project', ProjectStatusEditFieldComponent, 'status', ['status']) .addSpecificFieldType('Portfolio', ProjectStatusEditFieldComponent, 'status', ['status']) diff --git a/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts index 22ca2a1ae82f..b1d6569c614b 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/versions-edit-field.component.ts @@ -39,13 +39,14 @@ import { CurrentProjectService } from 'core-app/core/current-project/current-pro import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; /** - * Edit field for the targetVersions attribute of work packages. + * Edit field for the version collection attributes of work packages + * (targetVersions, observedInVersions). * - * The attribute always reads and writes a collection, but as long as the - * multiple versions setting is inactive, the schema restricts it to a single - * value (options.multiple). In that mode the field mimics the single select - * fields it stands in for: an explicit "-" option, no save/cancel controls, - * saving right on selection. + * The attributes always read and write a collection, but the schema may + * restrict one to a single value (options.multiple) — targetVersions does so as + * long as the multiple versions setting is inactive. In that mode the field + * mimics the single select fields it stands in for: an explicit "-" option, no + * save/cancel controls, saving right on selection. * * Versions can be created from within the field when the user is allowed to * (mirroring VersionAutocompleterComponent). diff --git a/lib/api/v3/activities/activity_eager_loading_wrapper.rb b/lib/api/v3/activities/activity_eager_loading_wrapper.rb index ad191acdbc3d..f3f96777d312 100644 --- a/lib/api/v3/activities/activity_eager_loading_wrapper.rb +++ b/lib/api/v3/activities/activity_eager_loading_wrapper.rb @@ -150,7 +150,8 @@ def predecessor_journals(journals) ) AS journals SQL ) - .includes(:attachable_journals, :customizable_journals, :storable_journals, :target_version_journals) + .includes(:attachable_journals, :customizable_journals, :storable_journals, + :work_package_version_journals) end end end diff --git a/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb new file mode 100644 index 000000000000..9935428d2c31 --- /dev/null +++ b/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer.rb @@ -0,0 +1,46 @@ +# 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 API + module V3 + module Queries + module Schemas + # The allowed values of the observed versions filter are the same + # collection of versions the single version filter offers, so the + # dependency representation is identical. A dedicated class is still + # needed, as FilterDependencyRepresenterFactory resolves the representer + # from the filter's class name. + class ObservedInVersionsFilterDependencyRepresenter < + VersionFilterDependencyRepresenter + end + end + end + end +end diff --git a/lib/api/v3/work_packages/eager_loading/checksum.rb b/lib/api/v3/work_packages/eager_loading/checksum.rb index c7bb0c0e707e..f94bed8601a2 100644 --- a/lib/api/v3/work_packages/eager_loading/checksum.rb +++ b/lib/api/v3/work_packages/eager_loading/checksum.rb @@ -54,19 +54,20 @@ def fetch_checksums_for(work_packages) protected - # The target versions associated via work_package_versions are a - # has_many, which the left_joins/pluck design above cannot express - # (it would multiply rows), so they enter the checksum as an - # aggregated correlated subquery. This also covers versions beyond - # the first, which the deprecated version association could not see. - # Only "target" rows are folded in: they are what the representer - # renders (as `version` and `targetVersions`); observed_in versions - # do not appear in the representation and must not bust its cache. + # The versions associated via work_package_versions are a has_many, + # which the left_joins/pluck design above cannot express (it would + # multiply rows), so they enter the checksum as an aggregated + # correlated subquery. This also covers versions beyond the first, + # which the deprecated version association could not see. + # Every kind is folded in, since the representer renders both the + # target (as `version` and `targetVersions`) and the observed_in + # rows (as `observedInVersions`). The kind is part of the aggregated + # value so that moving a version between kinds busts the cache too. VERSIONS_CHECKSUM_SQL = <<~SQL.squish - (SELECT COALESCE(STRING_AGG(CONCAT(v.id, v.updated_at), ',' ORDER BY v.id), '') + (SELECT COALESCE(STRING_AGG(CONCAT(wpv.kind, v.id, v.updated_at), ',' ORDER BY wpv.kind, v.id), '') FROM work_package_versions wpv INNER JOIN versions v ON v.id = wpv.version_id - WHERE wpv.work_package_id = work_packages.id AND wpv.kind = 'target') + WHERE wpv.work_package_id = work_packages.id) SQL def md5_concat diff --git a/lib/api/v3/work_packages/schema/specific_work_package_schema.rb b/lib/api/v3/work_packages/schema/specific_work_package_schema.rb index a62908b970a3..ad86dc0b534d 100644 --- a/lib/api/v3/work_packages/schema/specific_work_package_schema.rb +++ b/lib/api/v3/work_packages/schema/specific_work_package_schema.rb @@ -56,6 +56,7 @@ def initialize(work_package:) :assignable_priorities, :assignable_versions, :assignable_target_versions, + :assignable_observed_in_versions, :assignable_budgets, :assignable_project_phases, to: :contract diff --git a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb index 79c9d7427051..504dddec0cf9 100644 --- a/lib/api/v3/work_packages/schema/work_package_schema_representer.rb +++ b/lib/api/v3/work_packages/schema/work_package_schema_representer.rb @@ -334,6 +334,22 @@ def initialize(schema, self_link:, **context) required: false, options: -> { { multiple: Setting::WorkPackageMultipleVersions.active? } } + # Unlike target versions, observed versions have no single-valued + # predecessor to stand in for, so the attribute is always offered and + # always a collection. + schema_with_allowed_collection :observed_in_versions, + type: "[]Version", + value_representer: Versions::VersionRepresenter, + link_factory: ->(version) { + { + href: api_v3_paths.version(version.id), + title: version.name + } + }, + writable: ->(*) { represented.writable?(:observed_in_versions) }, + required: false, + options: -> { { multiple: true } } + schema_with_allowed_collection :priority, value_representer: Priorities::PriorityRepresenter, link_factory: ->(priority) { diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index d7dac7f07d58..fcd237dfe1a0 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -606,6 +606,31 @@ def self_v3_path(*) represented.target_version_ids = parse_link_ids_from_fragment(fragment, :version).compact end + associated_resources :observed_in_versions, + v3_path: :version, + representer: ::API::V3::Versions::VersionRepresenter, + getter: ->(*) { + next unless embed_link?(:observed_in_versions) + + represented.effective_observed_in_versions.map do |version| + ::API::V3::Versions::VersionRepresenter.create(version, current_user:) + end + }, + link: ->(*) { + represented.effective_observed_in_versions.map do |version| + ::API::Decorators::LinkObject + .new(version, + property_name: :itself, + path: :version, + getter: :id, + title_attribute: :name) + .to_hash + end + }, + setter: ->(fragment:, **) do + represented.observed_in_version_ids = parse_link_ids_from_fragment(fragment, :version).compact + end + associated_resource :parent, v3_path: :work_package, representer: ::API::V3::WorkPackages::WorkPackageRepresenter, @@ -830,7 +855,8 @@ def ordered_custom_actions watchers attachments budget - target_versions] + target_versions + observed_in_versions] # The dynamic class generation introduced because of the custom fields interferes with # the class naming as well as prevents calls to super diff --git a/lib/open_project/journal_formatter/joined_versions.rb b/lib/open_project/journal_formatter/joined_versions.rb new file mode 100644 index 000000000000..e9a8e681019f --- /dev/null +++ b/lib/open_project/journal_formatter/joined_versions.rb @@ -0,0 +1,50 @@ +# 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. +#++ + +# Renders the change to a set of versions referenced by a work package. Each +# value is the sorted, comma-joined version ids (see JournalChanges); every id +# is resolved to the version's name, dropping versions that have been deleted in +# the meantime. +class OpenProject::JournalFormatter::JoinedVersions < JournalFormatter::NamedAssociation + private + + def format_values(values, key, cache:) + klass = class_from_field(key) + + values.map do |value| + next if value.blank? || klass.nil? + + value.to_s.split(",") + .filter_map { |id| associated_object(klass, id.to_i, cache:)&.name } + .join(", ") + .presence + end + end +end diff --git a/lib/open_project/journal_formatter/observed_in_versions.rb b/lib/open_project/journal_formatter/observed_in_versions.rb new file mode 100644 index 000000000000..f99881cf4ae8 --- /dev/null +++ b/lib/open_project/journal_formatter/observed_in_versions.rb @@ -0,0 +1,34 @@ +# 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. +#++ + +# Renders the change to the set of versions a work package was observed in +# (see JournalChanges#get_observed_in_versions_changes). +class OpenProject::JournalFormatter::ObservedInVersions < OpenProject::JournalFormatter::JoinedVersions +end diff --git a/lib/open_project/journal_formatter/target_versions.rb b/lib/open_project/journal_formatter/target_versions.rb index 81cb1676f91b..38a552689e6a 100644 --- a/lib/open_project/journal_formatter/target_versions.rb +++ b/lib/open_project/journal_formatter/target_versions.rb @@ -28,11 +28,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Renders the change to the set of target versions. Each value is the sorted, -# comma-joined version ids (see JournalChanges#get_target_versions_changes); -# every id is resolved to the version's name, dropping versions that have -# been deleted in the meantime. -class OpenProject::JournalFormatter::TargetVersions < JournalFormatter::NamedAssociation +# Renders the change to the set of target versions +# (see JournalChanges#get_target_versions_changes). +class OpenProject::JournalFormatter::TargetVersions < OpenProject::JournalFormatter::JoinedVersions private # While the multiple versions feature is inactive, the rest of the UI still @@ -44,17 +42,4 @@ def label(key) super("version") end end - - def format_values(values, key, cache:) - klass = class_from_field(key) - - values.map do |value| - next if value.blank? || klass.nil? - - value.to_s.split(",") - .filter_map { |id| associated_object(klass, id.to_i, cache:)&.name } - .join(", ") - .presence - end - end end diff --git a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb index b933ca03c73e..4e4bf75925f2 100644 --- a/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb +++ b/lib_static/plugins/acts_as_journalized/lib/journal_changes.rb @@ -33,20 +33,7 @@ def get_changes return @changes if @changes return {} if data.nil? - changes = [ - get_cause_changes, - get_data_changes, - get_attachments_changes, - get_custom_comments_changes, - get_custom_fields_changes, - get_project_phases_changes, - get_target_versions_changes, - get_file_links_changes, - get_participants_changes, - get_agenda_items_changes - ].compact - - merged = changes.reduce({}.with_indifferent_access, :merge!) + merged = all_changes.reduce({}.with_indifferent_access, :merge!) @changes = suppress_mirrored_version_change(merged) end @@ -143,6 +130,18 @@ def get_target_versions_changes { target_versions: [old_value, new_value] } end + # Diffed as a single value just like the target versions above: one + # "Observed versions" line with the old and the new list. + def get_observed_in_versions_changes + return unless journable.respond_to?(:observed_in_versions) + + old_value = predecessor && joined_observed_in_version_ids(predecessor) + new_value = joined_observed_in_version_ids(self) + return if old_value == new_value + + { observed_in_versions: [old_value, new_value] } + end + def get_file_links_changes return unless has_file_links? @@ -187,6 +186,22 @@ def get_participants_changes private + def all_changes + [ + get_cause_changes, + get_data_changes, + get_attachments_changes, + get_custom_comments_changes, + get_custom_fields_changes, + get_project_phases_changes, + get_target_versions_changes, + get_observed_in_versions_changes, + get_file_links_changes, + get_participants_changes, + get_agenda_items_changes + ].compact + end + # While the deprecated version_id column mirrors the target versions, a # version change diffs under both keys; only the target_versions # representation is rendered. Historical journals render the same way, @@ -201,6 +216,10 @@ def joined_target_version_ids(journal) journal.target_version_journals.map(&:version_id).sort.join(",").presence end + def joined_observed_in_version_ids(journal) + journal.observed_in_version_journals.map(&:version_id).sort.join(",").presence + end + def participant_baseline_journal journals = journable.journals.to_a current_index = journals.index { |entry| entry.id == id } diff --git a/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb b/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb index 5d40e69bf447..0a996853a0cb 100644 --- a/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb +++ b/modules/xls_export/app/models/xls_export/work_package/exporter/xls.rb @@ -35,7 +35,7 @@ class XLS < WorkPackage::Exports::QueryExporter def records work_packages - .includes(:assigned_to, :type, :priority, :category, :version, :target_versions) + .includes(:assigned_to, :type, :priority, :category, :version, :target_versions, :observed_in_versions) end def spreadsheet_title diff --git a/spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb b/spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb new file mode 100644 index 000000000000..ada73f80c039 --- /dev/null +++ b/spec/lib/api/v3/queries/schemas/observed_in_versions_filter_dependency_representer_spec.rb @@ -0,0 +1,165 @@ +# 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 API::V3::Queries::Schemas::ObservedInVersionsFilterDependencyRepresenter do + include API::V3::Utilities::PathHelper + + let(:project) { build_stubbed(:project) } + let(:query) { build_stubbed(:query, project:) } + let(:filter) { Queries::WorkPackages::Filter::ObservedInVersionsFilter.create!(context: query) } + let(:form_embedded) { false } + + let(:instance) do + described_class.new(filter, + operator, + form_embedded:) + end + + subject(:generated) { instance.to_json } + + describe "generation" do + describe "properties" do + describe "values" do + let(:path) { "values" } + let(:type) { "[]Version" } + let(:order) { "sortBy=#{CGI.escape(JSON.dump([%i(name asc)]))}&pageSize=-1" } + + context "for operator 'Queries::Operators::All'" do + let(:operator) { Queries::Operators::All } + + it_behaves_like "filter dependency empty" + end + + context "for operator 'Queries::Operators::None'" do + let(:operator) { Queries::Operators::None } + + it_behaves_like "filter dependency empty" + end + + context "within project" do + let(:href) do + "#{api_v3_paths.versions_by_workspace(project.id)}?#{order}" + end + + context "for operator 'Queries::Operators::Equals'" do + let(:operator) { Queries::Operators::Equals } + + it_behaves_like "filter dependency with allowed link" + end + + context "for operator 'Queries::Operators::NotEquals'" do + let(:operator) { Queries::Operators::NotEquals } + + it_behaves_like "filter dependency with allowed link" + end + end + + context "without a project" do + let(:project) { nil } + let(:href) do + "#{api_v3_paths.versions}?#{order}" + end + + context "for operator 'Queries::Operators::Equals'" do + let(:operator) { Queries::Operators::Equals } + + it_behaves_like "filter dependency with allowed link" + end + + context "for operator 'Queries::Operators::NotEquals'" do + let(:operator) { Queries::Operators::NotEquals } + + it_behaves_like "filter dependency with allowed link" + end + end + end + end + + describe "caching" do + let(:operator) { Queries::Operators::Equals } + let(:other_project) { build_stubbed(:project) } + + before do + allow(instance).to receive(:to_hash).and_call_original + + # fill the cache + instance.to_json + end + + it "is cached" do + instance.to_json + + expect(instance) + .to have_received(:to_hash).once + end + + it "busts the cache on a different operator" do + instance.send(:operator=, Queries::Operators::NotEquals) + + instance.to_json + + expect(instance) + .to have_received(:to_hash).twice + end + + it "busts the cache on a different project" do + query.project = other_project + + instance.to_json + + expect(instance) + .to have_received(:to_hash).twice + end + + it "busts the cache on changes to the locale" do + I18n.with_locale(:de) do + instance.to_json + end + + expect(instance) + .to have_received(:to_hash).twice + end + + it "busts the cache on different form_embedded" do + embedded_instance = described_class.new(filter, + operator, + form_embedded: !form_embedded) + allow(embedded_instance).to receive(:to_hash).and_call_original + + embedded_instance.to_json + + expect(embedded_instance) + .to have_received(:to_hash).once + end + end + end +end diff --git a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb index cab3e50536db..43ac9482935d 100644 --- a/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb +++ b/spec/lib/api/v3/work_packages/eager_loading/cache_checksum_integration_spec.rb @@ -145,12 +145,12 @@ .not_to eql orig_checksum end - it "produces the same checksum on changes to an observed_in version" do + it "produces a different checksum on changes to an observed_in version" do other_version = create(:version, project:) work_package.work_package_versions.create!(version: other_version, kind: "observed_in") expect(new_checksum) - .to eql orig_checksum + .not_to eql orig_checksum end it "produces a different checksum on changes to the type id" do diff --git a/spec/lib/open_project/journal_formatter/observed_in_versions_spec.rb b/spec/lib/open_project/journal_formatter/observed_in_versions_spec.rb new file mode 100644 index 000000000000..52f0dcb4d06c --- /dev/null +++ b/spec/lib/open_project/journal_formatter/observed_in_versions_spec.rb @@ -0,0 +1,108 @@ +# 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 OpenProject::JournalFormatter::ObservedInVersions do + describe "#render" do + let(:version) { build_stubbed(:version, name: "Alpha") } + let(:other_version) { build_stubbed(:version, name: "Beta") } + let(:work_package) { build_stubbed(:work_package) } + let(:journal) { build_stubbed(:work_package_journal, journable: work_package) } + let(:instance) { described_class.new(journal) } + # Unlike the target versions formatter, the label never falls back to the + # single-version wording, the attribute having no such counterpart. + let(:label) { "Observed versions" } + + before do + allow(Version).to receive(:find_by).and_return(nil) + + [version, other_version].each do |v| + allow(Version).to receive(:find_by).with(id: v.id).and_return(v) + end + end + + context "when setting observed versions" do + it "renders the version names as the new value" do + expect(instance.render(:observed_in_versions, [nil, "#{version.id},#{other_version.id}"])) + .to eq(I18n.t(:text_journal_set_to, label:, value: "Alpha, Beta")) + end + end + + context "when changing observed versions" do + it "renders the old and new version names" do + expect(instance.render(:observed_in_versions, [version.id.to_s, other_version.id.to_s])) + .to eq(I18n.t(:text_journal_changed_plain, + label:, + linebreak: nil, + old: "Alpha", + new: "Beta")) + end + end + + context "when removing all observed versions" do + it "renders the old version names as deleted" do + expect(instance.render(:observed_in_versions, ["#{version.id},#{other_version.id}", nil])) + .to eq(I18n.t(:text_journal_deleted, label:, old: "Alpha, Beta")) + end + end + + context "with a version that no longer exists" do + it "renders only the existing version names" do + expect(instance.render(:observed_in_versions, [nil, "#{version.id},99999"])) + .to eq(I18n.t(:text_journal_set_to, label:, value: "Alpha")) + end + end + + context "when the multiple versions feature is active", + with_flag: { work_package_multiple_versions: true }, + with_settings: { work_package_multiple_versions: true } do + it "keeps the same label" do + expect(instance.render(:observed_in_versions, [version.id.to_s, other_version.id.to_s])) + .to eq(I18n.t(:text_journal_changed_plain, + label:, + linebreak: nil, + old: "Alpha", + new: "Beta")) + end + end + + context "with html: false" do + it "renders plain text" do + expect(instance.render(:observed_in_versions, [version.id.to_s, other_version.id.to_s], html: false)) + .to eq(I18n.t(:text_journal_changed_plain, + label: "Observed versions", + linebreak: nil, + old: "Alpha", + new: "Beta")) + end + end + end +end diff --git a/spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb b/spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb new file mode 100644 index 000000000000..c1193fe5c377 --- /dev/null +++ b/spec/models/queries/work_packages/filter/observed_in_versions_filter_spec.rb @@ -0,0 +1,281 @@ +# 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 Queries::WorkPackages::Filter::ObservedInVersionsFilter do + let(:actual_project) { create(:project) } + let(:version) { create(:version, project: actual_project) } + let(:other_project_version) { create(:version, project: create(:project)) } + + let(:role) { create(:project_role, permissions: %i[view_work_packages]) } + let(:user) { create(:user, member_with_roles: { actual_project => role }) } + + before { login_as(user) } + + it_behaves_like "basic query filter" do + let(:project) { actual_project } + let(:type) { :list_optional } + let(:class_key) { :observed_in_version_id } + let(:values) { [version.id.to_s] } + let(:name) { WorkPackage.human_attribute_name("observed_in_versions") } + + # Unlike the target versions filter, this one has no deprecated + # single-valued counterpart to alternate with, so it is offered regardless + # of the multiple versions feature. + describe "#available?" do + context "with the feature flag and the setting enabled", + with_flag: { work_package_multiple_versions: true }, + with_settings: { work_package_multiple_versions: true } do + it "is available" do + expect(instance).to be_available + end + end + + context "with the feature flag and the setting disabled", + with_flag: { work_package_multiple_versions: false }, + with_settings: { work_package_multiple_versions: false } do + it "is still available" do + expect(instance).to be_available + end + end + end + + describe "#valid?" do + context "within a project" do + context "and the version belongs to the project" do + it "is valid" do + expect(instance).to be_valid + end + end + + context "and the version is from another project" do + let(:values) { [other_project_version.id.to_s] } + + it "is not valid" do + expect(instance).not_to be_valid + end + end + end + + context "without a project" do + let(:project) { nil } + + context "and the version is visible to the user" do + it "is valid" do + expect(instance).to be_valid + end + end + + context "and the version does not exist" do + let(:values) { ["12345"] } + + it "is not valid" do + expect(instance).not_to be_valid + end + end + end + + context "with a version status operator and no values" do + let(:operator) { "o" } + let(:values) { [] } + + it "is valid" do + expect(instance).to be_valid + end + end + end + + describe "#allowed_values" do + context "within a project" do + it "returns the project's shared versions" do + expect(instance.allowed_values) + .to contain_exactly([version.id.to_s, version.id.to_s]) + end + end + + context "without a project" do + let(:project) { nil } + + it "returns only versions visible to the current user" do + other_project_version + + expect(instance.allowed_values) + .to contain_exactly([version.id.to_s, version.id.to_s]) + end + end + end + + describe "#value_objects" do + let!(:other_version) { create(:version, project: actual_project) } + + it "returns the Version records matching the filter values" do + expect(instance.value_objects).to contain_exactly(version) + end + end + + describe "#available_operators" do + it "includes the version status operators" do + expect(instance.available_operators).to include( + Queries::Operators::Versions::OpenStatus, + Queries::Operators::Versions::ClosedStatus, + Queries::Operators::Versions::LockedStatus + ) + end + end + + describe "#operator_strategy" do + context 'for "o"' do + let(:operator) { "o" } + + it "is the open status operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::Versions::OpenStatus) + end + end + + context 'for "c"' do + let(:operator) { "c" } + + it "is the closed status operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::Versions::ClosedStatus) + end + end + + context 'for "l"' do + let(:operator) { "l" } + + it "is the locked status operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::Versions::LockedStatus) + end + end + + context 'for "="' do + it "is the equals operator" do + expect(instance.operator_strategy).to eq(Queries::Operators::EqualsOr) + end + end + end + + describe "#where" do + let(:open_version) { version } + let(:closed_version) { create(:version, project: actual_project, status: "closed") } + let(:locked_version) { create(:version, project: actual_project, status: "locked") } + + let!(:wp_observing_open) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: open_version, kind: :observed_in) + end + end + let!(:wp_observing_closed) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: closed_version, kind: :observed_in) + end + end + let!(:wp_observing_locked) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: locked_version, kind: :observed_in) + end + end + # The mirror of the target filter's guard: a target row on the same + # version must never be mistaken for an observed one. + let!(:wp_targeting_only) do + create(:work_package, project: actual_project).tap do |wp| + create(:work_package_version, work_package: wp, version: open_version, kind: :target) + end + end + let!(:wp_without_versions) { create(:work_package, project: actual_project) } + + subject(:result) { WorkPackage.where(instance.where) } + + context 'for "=" with a version' do + let(:values) { [open_version.id.to_s] } + + it "returns work packages observed in that version" do + expect(result).to contain_exactly(wp_observing_open) + end + end + + context 'for "!" with a version' do + let(:operator) { "!" } + let(:values) { [open_version.id.to_s] } + + it "returns work packages not observed in that version, including ones without observed versions" do + expect(result).to contain_exactly(wp_observing_closed, wp_observing_locked, wp_targeting_only, + wp_without_versions) + end + end + + context 'for "*" (any observed version)' do + let(:operator) { "*" } + let(:values) { [] } + + it "returns work packages with at least one observed version" do + expect(result).to contain_exactly(wp_observing_open, wp_observing_closed, wp_observing_locked) + end + end + + context 'for "!*" (no observed version)' do + let(:operator) { "!*" } + let(:values) { [] } + + it "returns work packages without any observed version" do + expect(result).to contain_exactly(wp_targeting_only, wp_without_versions) + end + end + + context 'for "o" (open version)' do + let(:operator) { "o" } + let(:values) { [] } + + it "returns work packages observed in an open version" do + expect(result).to contain_exactly(wp_observing_open) + end + end + + context 'for "c" (closed version)' do + let(:operator) { "c" } + let(:values) { [] } + + it "returns work packages observed in a closed version" do + expect(result).to contain_exactly(wp_observing_closed) + end + end + + context 'for "l" (locked version)' do + let(:operator) { "l" } + let(:values) { [] } + + it "returns work packages observed in a locked version" do + expect(result).to contain_exactly(wp_observing_locked) + end + end + end + end +end diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index 2b3bb51b9854..7af9388313dd 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -250,6 +250,7 @@ "Priority", "Normal", *(work_package.sprint.present? ? ["Sprint", work_package.sprint] : ["Sprint"]), *(work_package.backlog_bucket.present? ? ["Backlog bucket", work_package.backlog_bucket] : ["Backlog bucket"]), + "Observed versions", "Version", work_package.version, "Category", work_package.category, "Project phase",