diff --git a/app/contracts/concerns/assignable_custom_field_values.rb b/app/contracts/concerns/assignable_custom_field_values.rb index a479b7ae4c6..14740f85ecc 100644 --- a/app/contracts/concerns/assignable_custom_field_values.rb +++ b/app/contracts/concerns/assignable_custom_field_values.rb @@ -35,7 +35,11 @@ module AssignableCustomFieldValues when 'list' custom_field.possible_values when 'version' - assignable_versions + if custom_field.allow_non_open_versions? + all_versions + else + assignable_versions + end end end end diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index 5721e5c400c..381b140ab5c 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -47,5 +47,6 @@ module CustomFields attribute :possible_values attribute :multi_value attribute :content_right_to_left + attribute :allow_non_open_versions end end diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb index 32bee318904..2fa2f773360 100644 --- a/app/contracts/projects/base_contract.rb +++ b/app/contracts/projects/base_contract.rb @@ -67,6 +67,7 @@ module Projects end delegate :assignable_versions, to: :model + delegate :all_versions, to: :model def assignable_status_codes Project.status_codes.keys diff --git a/app/contracts/work_packages/base_contract.rb b/app/contracts/work_packages/base_contract.rb index 364e246a22e..c1ece1ba29f 100644 --- a/app/contracts/work_packages/base_contract.rb +++ b/app/contracts/work_packages/base_contract.rb @@ -189,6 +189,10 @@ module WorkPackages model.try(:assignable_versions) if model.project end + def all_versions + model.try(:all_versions) if model.project + end + def assignable_budgets model.project&.budgets end diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 57554dfa4fb..b0b92cbd790 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -264,6 +264,10 @@ class CustomField < ApplicationRecord field_format == "list" end + def version? + field_format == "version" + end + def formattable? field_format == "text" end @@ -281,6 +285,14 @@ class CustomField < ApplicationRecord [ProjectCustomField, WorkPackageCustomField].include?(self.class) end + def allow_non_open_versions? + allow_non_open_versions + end + + def allow_non_open_versions_possible? + version? && [ProjectCustomField, WorkPackageCustomField].include?(self.class) + end + ## # Overrides cache key so that a custom field's representation # is updated correctly when it's mutli_value attribute changes. diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 00109e0d1ed..28b6d63a5b2 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -466,6 +466,7 @@ class PermittedParams :possible_values, :multi_value, :content_right_to_left, + :allow_non_open_versions, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } ], diff --git a/app/models/project.rb b/app/models/project.rb index 38c30f42c6f..9d7b38796af 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -274,6 +274,12 @@ class Project < ApplicationRecord @assignable_versions ||= shared_versions.references(:project).with_status_open.order_by_semver_name.to_a end + # Returns all versions, including closed and locked, as an array. + # This is useful for custom fields allowing non-open versions as input values. + def all_versions + @all_versions ||= shared_versions.references(:project).order_by_semver_name.to_a + end + # Returns an AR scope of all custom fields enabled for project's work packages # (explicitly associated custom fields and custom fields enabled for all projects) def all_work_package_custom_fields diff --git a/app/models/work_package.rb b/app/models/work_package.rb index b418e8854c2..e43b4815b0c 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -262,6 +262,10 @@ class WorkPackage < ApplicationRecord end end + def all_versions + @all_versions ||= project&.all_versions + end + def to_s "#{type.is_standard ? '' : type.name} ##{id}: #{subject}" end diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index 53644c6fd6e..6b0c988e39e 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -105,6 +105,16 @@ See COPYRIGHT and LICENSE files for more details. <% end %> + + <% if @custom_field.new_record? || @custom_field.version? || @custom_field.allow_non_open_versions_possible? %> + + <% end %> +
<% if @custom_field.new_record? || !%w[text bool].include?(@custom_field.field_format) %> diff --git a/config/locales/en.yml b/config/locales/en.yml index e345a483c60..d7cafef2523 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -538,6 +538,7 @@ en: custom_action: actions: "Actions" custom_field: + allow_non_open_versions: "Allow non-open versions" default_value: "Default value" editable: "Editable" field_format: "Format" diff --git a/db/migrate/20231017093339_add_allow_non_open_versions_to_custom_fields.rb b/db/migrate/20231017093339_add_allow_non_open_versions_to_custom_fields.rb new file mode 100644 index 00000000000..91e6abb15d2 --- /dev/null +++ b/db/migrate/20231017093339_add_allow_non_open_versions_to_custom_fields.rb @@ -0,0 +1,5 @@ +class AddAllowNonOpenVersionsToCustomFields < ActiveRecord::Migration[7.0] + def change + add_column :custom_fields, :allow_non_open_versions, :boolean, default: false + end +end diff --git a/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts index 22c0e1208f7..9a72661058a 100644 --- a/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/admin/custom-fields.controller.ts @@ -37,6 +37,7 @@ export default class CustomFieldsController extends Controller { 'length', 'regexp', 'multiSelect', + 'allowNonOpenVersions', 'possibleValues', 'defaultValue', 'defaultText', @@ -61,6 +62,7 @@ export default class CustomFieldsController extends Controller { declare readonly lengthTargets:HTMLElement[]; declare readonly regexpTargets:HTMLElement[]; declare readonly multiSelectTargets:HTMLElement[]; + declare readonly allowNonOpenVersionsTargets:HTMLElement[]; declare readonly possibleValuesTargets:HTMLElement[]; declare readonly defaultValueTargets:HTMLElement[]; declare readonly defaultTextTargets:HTMLElement[]; @@ -272,6 +274,7 @@ export default class CustomFieldsController extends Controller { this.activate(this.defaultBoolTargets, false); this.activate(this.defaultLongTextTargets, false); this.activate(this.multiSelectTargets, false); + this.activate(this.allowNonOpenVersionsTargets, false); this.activate(this.textOrientationTargets, false); this.activate(this.defaultValueTargets); this.activate(this.defaultTextTargets); @@ -311,7 +314,7 @@ export default class CustomFieldsController extends Controller { this.unsearchable(); break; case 'version': - this.show(...this.multiSelectTargets); + this.show(...this.multiSelectTargets, ...this.allowNonOpenVersionsTargets); this.activate(this.defaultValueTargets, false); this.activate(this.possibleValuesTargets, false); this.hide(...this.lengthTargets, ...this.regexpTargets, ...this.defaultValueTargets); 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 dda95eb95b9..d6fd81c2cbe 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 @@ -53,6 +53,7 @@ module API :assignable_categories, :assignable_priorities, :assignable_versions, + :all_versions, :assignable_budgets, to: :contract diff --git a/modules/costs/app/contracts/time_entries/base_contract.rb b/modules/costs/app/contracts/time_entries/base_contract.rb index 220e32f62cd..983eea4d058 100644 --- a/modules/costs/app/contracts/time_entries/base_contract.rb +++ b/modules/costs/app/contracts/time_entries/base_contract.rb @@ -83,6 +83,11 @@ module TimeEntries work_package.try(:assignable_versions) || project.try(:assignable_versions) || [] end + # Necessary for custom fields of type version with allow non-open enabled. + def all_versions + work_package.try(:all_versions) || project.try(:all_versions) || [] + end + private def validate_work_package diff --git a/spec/features/custom_fields/custom_fields_spec.rb b/spec/features/custom_fields/custom_fields_spec.rb index afe976efc70..880f28db0ad 100644 --- a/spec/features/custom_fields/custom_fields_spec.rb +++ b/spec/features/custom_fields/custom_fields_spec.rb @@ -9,8 +9,8 @@ RSpec.describe 'custom fields', js: true, with_cuprite: true do login_as user end - shared_examples "creating a new list custom field" do |type| - it "creates a new list custom field with its options in the right order" do + shared_examples "creating a new custom field" do |type| + it "has the options in the right order for a list custom field" do cf_page.visit_tab type click_on "Create a new custom field" @@ -61,14 +61,105 @@ RSpec.describe 'custom fields', js: true, with_cuprite: true do expect(page).to have_field("custom_field_custom_options_attributes_1_default_value", checked: true) expect(page).to have_field("custom_field_custom_options_attributes_2_default_value", checked: false) end + + it "shows the right options for each custom field type" do + cf_page.visit_tab type + + click_on "Create a new custom field" + wait_for_reload + cf_page.set_name "Ignored" + + # Form element labels, default English translation in the trailing comment: + label_min_length = I18n.t('activerecord.attributes.custom_field.min_length') # Minimum length + label_max_length = I18n.t('activerecord.attributes.custom_field.max_length') # Maximum length + label_regexp = I18n.t('activerecord.attributes.custom_field.regexp') # Regular expression + label_multi_value = I18n.t('activerecord.attributes.custom_field.multi_value') # Allow multi-select + label_allow_non_open_versions = I18n.t('activerecord.attributes.custom_field.allow_non_open_versions') # Allow non-open versions + label_possible_values = I18n.t('activerecord.attributes.custom_field.possible_values').upcase # Possible values, capitalized on UI + label_default_value = I18n.t('activerecord.attributes.custom_field.default_value') # Default value + label_is_required = I18n.t('activerecord.attributes.custom_field.is_required') # Required + label_searchable = I18n.t('activerecord.attributes.custom_field.searchable') # Searchable + # Project CFs don't show "For all projects" and "Used as a filter". Not tested here. + # Content right to left is not shown for Project CFs Long text. Strange. Not tested. + + def expect_page_to_have_texts(*text) + text.each do |t| + expect(page).to have_text(t) + end + end + + def expect_page_not_to_have_texts(*text) + text.each do |t| + expect(page).not_to have_text(t) + end + end + + select "Text", from: "custom_field_field_format" + expect_page_to_have_texts( + label_min_length, label_max_length, label_regexp, label_default_value, label_is_required, label_searchable) + expect_page_not_to_have_texts( + label_multi_value, label_allow_non_open_versions, label_possible_values) + + select "Long text", from: "custom_field_field_format" + expect_page_to_have_texts( + label_min_length, label_max_length, label_regexp, label_default_value, label_is_required, label_searchable) + expect_page_not_to_have_texts( + label_multi_value, label_allow_non_open_versions, label_possible_values) + + # Both Integer and Float have min/max_len and regex as well which seems strange. + select "Integer", from: "custom_field_field_format" + expect_page_to_have_texts( + label_min_length, label_max_length, label_regexp, label_default_value, label_is_required) + expect_page_not_to_have_texts( + label_multi_value, label_allow_non_open_versions, label_possible_values, label_searchable) + + select "Float", from: "custom_field_field_format" + expect_page_to_have_texts( + label_min_length, label_max_length, label_regexp, label_default_value, label_is_required) + expect_page_not_to_have_texts( + label_multi_value, label_allow_non_open_versions, label_possible_values, label_searchable) + + select "List", from: "custom_field_field_format" + expect_page_to_have_texts( + label_multi_value, label_possible_values, label_is_required, label_searchable) + expect_page_not_to_have_texts( + label_min_length, label_max_length, label_regexp, label_allow_non_open_versions, label_default_value) + + select "Date", from: "custom_field_field_format" + expect_page_to_have_texts(label_is_required) + expect_page_not_to_have_texts( + label_min_length, label_max_length, label_regexp, label_multi_value, + label_allow_non_open_versions, label_possible_values, label_default_value, label_searchable) + + select "Boolean", from: "custom_field_field_format" + expect_page_to_have_texts( + label_default_value, label_is_required) + expect_page_not_to_have_texts( + label_min_length, label_max_length, label_regexp, label_multi_value, + label_allow_non_open_versions, label_possible_values, label_searchable) + + select "User", from: "custom_field_field_format" + expect_page_to_have_texts( + label_multi_value, label_is_required) + expect_page_not_to_have_texts( + label_min_length, label_max_length, label_regexp, label_allow_non_open_versions, + label_possible_values, label_default_value, label_searchable) + + select "Version", from: "custom_field_field_format" + expect_page_to_have_texts( + label_multi_value, label_allow_non_open_versions, label_is_required) + expect_page_not_to_have_texts( + label_min_length, label_max_length, label_regexp, + label_possible_values, label_default_value, label_searchable) + end end describe 'projects' do - it_behaves_like "creating a new list custom field", 'Projects' + it_behaves_like "creating a new custom field", 'Projects' end describe 'work packages' do - it_behaves_like "creating a new list custom field", 'Work packages' + it_behaves_like "creating a new custom field", 'Work packages' end context "with an existing list custom field" do diff --git a/spec/features/custom_fields/non_open_version_custom_field_spec.rb b/spec/features/custom_fields/non_open_version_custom_field_spec.rb new file mode 100644 index 00000000000..bd67c470417 --- /dev/null +++ b/spec/features/custom_fields/non_open_version_custom_field_spec.rb @@ -0,0 +1,165 @@ +require "spec_helper" +require "support/pages/work_packages/abstract_work_package" + +RSpec.describe "support for non-open version values in version custom field", :js, :with_cuprite do + shared_let(:admin) { create(:admin) } + let(:current_user) { admin } + let(:wp_page) { Pages::FullWorkPackage.new work_package } + let(:cf_edit_field) do + field = wp_page.edit_field custom_field.attribute_name(:camel_case) + field.field_type = 'create-autocompleter' + field + end + let(:work_package) { create(:work_package, project:, type:) } + let!(:version_closed) { create(:version, project:, name: 'Version Closed', status: 'closed') } + let!(:version_locked) { create(:version, project:, name: 'Version Locked', status: 'locked') } + let!(:version_open) { create(:version, project:, name: 'Version Open', status: 'open') } + + shared_let(:type) { create(:type) } + shared_let(:project) { create(:project, types: [type]) } + shared_let(:role) { create(:project_role) } + + shared_let(:custom_field) do + create( + :version_wp_custom_field, + name: "Affected version", + multi_value: false, + allow_non_open_versions: true, + types: [type], + projects: [project] + ) + end + + before do + login_as current_user + wp_page.visit! + wp_page.ensure_page_loaded + end + + it "is shown and allowed to be updated with open or non-open version" do + expect(page).to have_text custom_field.name + + cf_edit_field.activate! + expect(page).to have_text "Version Open" + expect(page).to have_text "Version Locked" + expect(page).to have_text "Version Closed" + + cf_edit_field.set_value "Version Locked" + wp_page.expect_and_dismiss_toaster(message: "Successful update.") + + expect(page).to have_text custom_field.name + expect(page).not_to have_text "Version Open" + expect(page).to have_text "Version Locked" + expect(page).not_to have_text "Version Closed" + + work_package.reload + + # only one value, so no array + cvs = work_package + .custom_value_for(custom_field) + .typed_value + expect(cvs).to eq version_locked + + # Let's check edit and both closed and open versions as well: + cf_edit_field.activate! + cf_edit_field.set_value "Version Closed" + wp_page.expect_and_dismiss_toaster(message: "Successful update.") + expect(page).to have_text "Version Closed" + + cf_edit_field.activate! + cf_edit_field.set_value "Version Open" + wp_page.expect_and_dismiss_toaster(message: "Successful update.") + expect(page).to have_text "Version Open" + + work_package.reload + + cvs = work_package + .custom_value_for(custom_field) + .typed_value + expect(cvs).to eq version_open + end + + context "with multi-value version field" do + shared_let(:custom_field) do + create( + :version_wp_custom_field, + name: "Affected versions", + multi_value: true, + allow_non_open_versions: true, + types: [type], + projects: [project] + ) + end + + it "is shown and allowed to be updated with open or non-open version" do + expect(page).to have_text custom_field.name + + # First we set mix of open and non-open values + cf_edit_field.activate! + expect(page).to have_text "Version Open" + expect(page).to have_text "Version Locked" + expect(page).to have_text "Version Closed" + + cf_edit_field.set_value "Version Locked" + cf_edit_field.set_value "Version Open" + + cf_edit_field.submit_by_dashboard + wp_page.expect_and_dismiss_toaster(message: "Successful update.") + + expect(page).to have_text custom_field.name + expect(page).to have_text "Version Open" + expect(page).to have_text "Version Locked" + expect(page).not_to have_text "Version Closed" + + work_package.reload + + cvs = work_package + .custom_value_for(custom_field) + .map(&:typed_value) + expect(cvs).to contain_exactly(version_open, version_locked) + + # Update with a single non-open value + cf_edit_field.activate! + cf_edit_field.unset_value "Version Open", multi: true + cf_edit_field.unset_value "Version Locked", multi: true + cf_edit_field.set_value "Version Closed" + + cf_edit_field.submit_by_dashboard + wp_page.expect_and_dismiss_toaster(message: "Successful update.") + + expect(page).to have_text "Version Closed" + + work_package.reload + + # only one value, so no array + cvs = work_package + .custom_value_for(custom_field) + .typed_value + + expect(cvs).to eq version_closed + end + end + + context "with non-open values disabled" do + shared_let(:custom_field) do + create( + :version_wp_custom_field, + name: "Affected versions", + multi_value: false, # this doesn't matter that much, it's the same for single and multi-values + allow_non_open_versions: false, + types: [type], + projects: [project] + ) + end + + it "is shown but non-open version are not shown as options" do + expect(page).to have_text custom_field.name + + # We'll just check the options and nothing more, the rest is checked elsewhere + cf_edit_field.activate! + expect(page).to have_text "Version Open" + expect(page).not_to have_text "Version Locked" + expect(page).not_to have_text "Version Closed" + end + end +end