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? %>
+
+ <%= f.check_box :allow_non_open_versions,
+ data: {
+ 'admin--custom-fields-target': 'allowNonOpenVersions'
+ } %>
+
+ <% 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