diff --git a/app/components/admin/custom_fields/calculated_values/details_component.html.erb b/app/components/admin/custom_fields/calculated_values/details_component.html.erb deleted file mode 100644 index f2c7bc22106..00000000000 --- a/app/components/admin/custom_fields/calculated_values/details_component.html.erb +++ /dev/null @@ -1,42 +0,0 @@ -<%#-- 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. - -++#%> - -<%= - with_enterprise_banner_guard(:calculated_values, variant: :large, image: "enterprise/calculated-values.png") do - component_wrapper do - settings_primer_form_with( - model:, - scope: :custom_field, - id: "custom_field_form", - url: form_url, - method: form_method - ) { |form| render CustomFields::CalculatedValues::DetailsForm.new(form) } - end - end -%> diff --git a/app/components/custom_fields/details_component.html.erb b/app/components/custom_fields/details_component.html.erb index 88ba8c9aaeb..4c6560c5171 100644 --- a/app/components/custom_fields/details_component.html.erb +++ b/app/components/custom_fields/details_component.html.erb @@ -1,16 +1,16 @@ <%= - with_enterprise_banner_guard(enterprise_addon[:key], variant: :large, image: enterprise_addon[:image]) do + with_enterprise_banner_guard(enterprise_addon_key, image: enterprise_addon_image, inactive_guard: no_enterprise_feature?, variant: :large) do component_wrapper do flex_layout do |content| - if persisted_cf_has_no_items_or_projects? + if show_top_banner? content.with_row(mb: 3) do render Primer::Alpha::Banner.new( scheme: :default, icon: :info, dismiss_scheme: :hide, - test_selector: "op-custom-fields--new-hierarchy-banner" + test_selector: "op-custom-fields--top-banner" ) do - I18n.t("custom_fields.admin.notice.remember_items_and_projects") + top_banner_text end end end diff --git a/app/components/custom_fields/details_component.rb b/app/components/custom_fields/details_component.rb index 17229d0fa32..a81ad052f37 100644 --- a/app/components/custom_fields/details_component.rb +++ b/app/components/custom_fields/details_component.rb @@ -35,15 +35,20 @@ module CustomFields include OpPrimer::ComponentHelpers include OpTurbo::Streamable - alias_method :custom_field, :model + ENTERPRISE_GUARDED = { + "calculated_value" => { key: :calculated_values, image: "enterprise/calculated-values.png" }, + "hierarchy" => { key: :custom_field_hierarchies, image: "enterprise/hierarchies.png" }, + "weighted_item_list" => { key: :weighted_item_lists, image: "enterprise/weighted_item_lists.png" } + }.freeze - def persisted_cf_has_no_items_or_projects? - custom_field.persisted? && - custom_field.hierarchical_list? && - custom_field.hierarchy_root.children.empty? && - custom_field.projects.empty? + class << self + def supported?(custom_field) + custom_field.field_format.in?(%w[bool calculated_value hierarchy weighted_item_list]) + end end + alias_method :custom_field, :model + def form_url model.new_record? ? custom_fields_path : custom_field_path(model) end @@ -52,15 +57,39 @@ module CustomFields model.new_record? ? :post : :put end - def enterprise_addon - @enterprise_addon ||= case custom_field.field_format - when "hierarchy" - { key: :custom_field_hierarchies, image: "enterprise/hierarchies.png" } - when "weighted_item_list" - { key: :weighted_item_lists, image: "enterprise/weighted_item_lists.png" } - else - raise "Custom fields of format #{custom_field.field_format} are not supported by #{self.class.name}" - end + def enterprise_addon_key + ENTERPRISE_GUARDED.dig(custom_field.field_format, :key) + end + + def enterprise_addon_image + ENTERPRISE_GUARDED.dig(custom_field.field_format, :image) + end + + def no_enterprise_feature? + ENTERPRISE_GUARDED[custom_field.field_format].nil? + end + + def show_top_banner? + case custom_field.field_format + when "hierarchy", "weighted_item_list" + persisted_cf_has_no_items_or_projects? + else + false + end + end + + def top_banner_text + case custom_field.field_format + when "hierarchy", "weighted_item_list" + I18n.t("custom_fields.admin.notice.remember_items_and_projects") + end + end + + def persisted_cf_has_no_items_or_projects? + custom_field.persisted? && + custom_field.hierarchical_list? && + custom_field.hierarchy_root.children.empty? && + custom_field.projects.empty? end end end diff --git a/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.html.erb index be471cc6591..3c010df85ae 100644 --- a/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.html.erb @@ -16,6 +16,13 @@ helpers.label_for_custom_field_format(@project_custom_field.field_format) end end + if @project_custom_field.is_for_all? + title_container.with_column(pt: 1, mr: 2) do + render(Primer::Beta::Text.new(font_size: :small)) do + t("settings.project_attributes.label_for_all_projects") + end + end + end if @project_custom_field.required? title_container.with_column(pt: 1) do render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do @@ -41,4 +48,3 @@ end end %> - diff --git a/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.rb index e0f7e494b37..80aeb6c5276 100644 --- a/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/creation_wizard/project_custom_field_sections/custom_field_row_component.rb @@ -57,6 +57,10 @@ module Projects end end + def toggle_enabled? + !@project_custom_field.required? + end + def toggle_data_attributes { "turbo-method": :post, diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb index f56ec9422b9..3e04f6d3cf1 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -16,6 +16,13 @@ helpers.label_for_custom_field_format(@project_custom_field.field_format) end end + if @project_custom_field.is_for_all? + title_container.with_column(pt: 1, mr: 2) do + render(Primer::Beta::Text.new(font_size: :small)) do + t("settings.project_attributes.label_for_all_projects") + end + end + end if @project_custom_field.required? title_container.with_column(pt: 1) do render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb index 1fbb2c874aa..1f5891239c7 100644 --- a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -70,7 +70,7 @@ module Projects end def toggle_enabled? - !@project_custom_field.required? + !@project_custom_field.is_for_all? end def toggle_data_attributes diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb index 6a9fb982a3b..d50e07d8703 100644 --- a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -24,10 +24,11 @@ end content_container.with_column(mr: 2) do render(Primer::Beta::Text.new(font_size: :small)) do - t( - "project.count", - count: @project_custom_field.project_custom_field_project_mappings.size - ) + if @project_custom_field.is_for_all? + t("settings.project_attributes.label_for_all_projects") + else + t("project.count", count: @project_custom_field.project_custom_field_project_mappings.size) + end end end if @project_custom_field.required? diff --git a/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb index a474ef4c119..6a48812643f 100644 --- a/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb +++ b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb @@ -32,10 +32,6 @@ module Settings module ProjectCustomFields module ProjectCustomFieldMapping class NewProjectMappingComponent < Admin::CustomFields::CustomFieldProjects::NewCustomFieldProjectsModalComponent - def render? - !custom_field.required? - end - private def form_modal_component diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index e3b027ef34f..d959804701b 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -37,7 +37,9 @@ module CustomFields attribute :field_format attribute :is_filter attribute :is_for_all - attribute :is_required + attribute :is_required do + validate_non_true_for_some_formats + end attribute :max_length attribute :min_length attribute :name @@ -47,10 +49,15 @@ module CustomFields attribute :searchable attribute :admin_only attribute :default_value - attribute :possible_values attribute :multi_value attribute :content_right_to_left attribute :custom_field_section_id attribute :allow_non_open_versions + + def validate_non_true_for_some_formats + return unless %w[bool calculated_value].include?(field_format) + + errors.add(:is_required, :cannot_be_true) if is_required == true + end end end diff --git a/app/contracts/project_custom_field_project_mappings/base_contract.rb b/app/contracts/project_custom_field_project_mappings/base_contract.rb index 0d506e33dc3..580a3855532 100644 --- a/app/contracts/project_custom_field_project_mappings/base_contract.rb +++ b/app/contracts/project_custom_field_project_mappings/base_contract.rb @@ -34,8 +34,8 @@ module ProjectCustomFieldProjectMappings attribute :custom_field_id validate :select_project_custom_fields_permission - validate :not_required - validate :visbile_to_user + validate :not_for_all_projects + validate :visible_to_user def select_project_custom_fields_permission return if user.allowed_in_project?(:select_project_custom_fields, model.project) @@ -43,15 +43,15 @@ module ProjectCustomFieldProjectMappings errors.add :base, :error_unauthorized end - def not_required - # only mappings of custom fields which are not required can be manipulated by the user - # enabling a custom field which is required happens in an after_save hook within the custom field model itself - return if model.project_custom_field.nil? || !model.project_custom_field.required? + def not_for_all_projects + # only mappings of custom fields which are not activated for all projects can be manipulated by the user + # enabling a custom field which is force-active happens in an after_save hook within the custom field model itself + return if model.project_custom_field.nil? || !model.project_custom_field.is_for_all? errors.add :custom_field_id, :cannot_delete_mapping end - def visbile_to_user + def visible_to_user # "invisible" custom fields can only be seen and edited by admins # using visible scope to check if the custom field is actually visible to the user return if model.project_custom_field.nil? || diff --git a/app/contracts/work_package_types/create_contract.rb b/app/contracts/work_package_types/create_contract.rb index 288e2a9660e..1662b6810fb 100644 --- a/app/contracts/work_package_types/create_contract.rb +++ b/app/contracts/work_package_types/create_contract.rb @@ -39,7 +39,6 @@ module WorkPackageTypes attribute :project_ids attribute :attribute_groups - validates :name, presence: true, length: { maximum: 255 } validates :is_default, :is_milestone, :is_in_roadmap, inclusion: { in: [true, false] } end end diff --git a/app/forms/custom_fields/calculated_values/details_form.rb b/app/forms/custom_fields/calculated_values/details_form.rb deleted file mode 100644 index 7d172c8df02..00000000000 --- a/app/forms/custom_fields/calculated_values/details_form.rb +++ /dev/null @@ -1,103 +0,0 @@ -# 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 CustomFields - module CalculatedValues - class DetailsForm < ApplicationForm - form do |details_form| - if model.new_record? - details_form.hidden(name: :field_format) - details_form.hidden(name: :type, scope_name_to_model: false) - end - - details_form.text_field( - name: :name, - label: I18n.t(:label_name), - required: true - ) - - details_form.select_list( - name: :custom_field_section_id, - label: I18n.t("activerecord.attributes.project_custom_field.custom_field_section"), - required: true - ) do |li| - ProjectCustomFieldSection.find_each do |cs| - li.option(value: cs.id, label: cs.name) - end - end - - details_form.pattern_input( - name: :formula, - value: model.formula_string, - suggestions: formula_suggestions, - label: I18n.t(:label_formula), - required: true, - caption: I18n.t("custom_fields.instructions.formula") - ) - - details_form.check_box( - name: :is_required, - label: I18n.t("activerecord.attributes.project_custom_field.is_required"), - caption: I18n.t("custom_fields.instructions.is_required_for_project") - ) - - details_form.check_box( - name: :admin_only, - label: I18n.t("activerecord.attributes.custom_field.admin_only"), - caption: I18n.t("custom_fields.instructions.admin_only") - ) - - details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :primary) - end - - private - - def formula_suggestions - operators = CustomField::CalculatedValue::MATH_OPERATORS_FOR_FORMULA - # Hide % from the suggestions as it can be used as either modulo or percentage. - .reject { it == "%" } - .map do |op| - # Insert operators as plain text nodes instead of tokens, since displaying them as tokens would result - # in too much visual clutter. We still want to offer autocompletion for them. - { key: op, label: op, insert_as_text: true, enabled: true } - end - - custom_fields = model.usable_custom_field_references_for_formula.map do |cf| - { key: "cf_#{cf.id}", label: cf.name, enabled: true } - end - - { - custom_fields: { title: I18n.t("label_custom_field_plural"), tokens: custom_fields }, - operators: { title: I18n.t("label_mathematical_operators"), tokens: operators } - } - end - end - end -end diff --git a/app/forms/custom_fields/details_form.rb b/app/forms/custom_fields/details_form.rb index 30fe8c7e9f0..44d17c05556 100644 --- a/app/forms/custom_fields/details_form.rb +++ b/app/forms/custom_fields/details_form.rb @@ -48,7 +48,7 @@ module CustomFields required: true ) - if model.is_a?(ProjectCustomField) + if show_section_field? details_form.select_list( name: :custom_field_section_id, label: I18n.t("activerecord.attributes.project_custom_field.custom_field_section"), @@ -60,33 +60,143 @@ module CustomFields end end - if model.multi_value_possible? + if show_multi_value_field? details_form.check_box( name: :multi_value, - label: I18n.t("activerecord.attributes.custom_field.multi_value"), - caption: I18n.t("custom_fields.instructions.multi_select") + label: label(:multi_value), + caption: instructions(:multi_select) ) end - details_form.check_box( - name: :is_required, - label: I18n.t("activerecord.attributes.custom_field.is_required"), - caption: I18n.t("custom_fields.instructions.is_required") - ) + if show_formula_field? + details_form.pattern_input( + name: :formula, + value: model.formula_string, + suggestions: formula_suggestions, + label: I18n.t(:label_formula), + required: true, + caption: instructions(:formula) + ) + end - details_form.check_box( - name: :is_for_all, - label: I18n.t("activerecord.attributes.custom_field.is_for_all"), - caption: I18n.t("custom_fields.instructions.is_for_all") - ) + if show_default_bool_field? + details_form.check_box( + name: :default_value, + label: label(:default_value) + ) + end - details_form.check_box( - name: :is_filter, - label: I18n.t("activerecord.attributes.custom_field.is_filter"), - caption: I18n.t("custom_fields.instructions.is_filter") - ) + if show_is_required_field? + details_form.check_box( + name: :is_required, + label: label(:is_required), + caption: instructions(:is_required) + ) + end + + if show_is_for_all_field? + details_form.check_box( + name: :is_for_all, + label: label(:is_for_all), + caption: instructions(:is_for_all) + ) + end + + if show_is_filter_field? + details_form.check_box( + name: :is_filter, + label: label(:is_filter), + caption: instructions(:is_filter) + ) + end + + if show_admin_only_field? + details_form.check_box( + name: :admin_only, + label: label(:admin_only), + caption: instructions(:admin_only) + ) + end + + if show_editable_field? + details_form.check_box( + name: :editable, + label: label(:editable), + caption: instructions(:editable) + ) + end details_form.submit(name: :submit, label: I18n.t(:button_save), scheme: :default) end + + def label(field) + I18n.t("activerecord.attributes.custom_field.#{field}") + end + + def instructions(field) + key = if model.is_a?(ProjectCustomField) + "custom_fields.instructions.#{field}.project" + else + "custom_fields.instructions.#{field}.all" + end + + I18n.t(key) + end + + def show_section_field? + model.is_a?(ProjectCustomField) + end + + def show_default_bool_field? + %w[bool].include?(model.field_format) + end + + def show_is_required_field? + %w[calculated_value bool].exclude?(model.field_format) + end + + def show_multi_value_field? + model.multi_value_possible? + end + + def show_formula_field? + %w[calculated_value].include?(model.field_format) + end + + def show_is_for_all_field? + model.is_a?(WorkPackageCustomField) || model.is_a?(ProjectCustomField) + end + + def show_is_filter_field? + model.is_a?(WorkPackageCustomField) + end + + def show_admin_only_field? + model.is_a?(ProjectCustomField) || model.is_a?(UserCustomField) + end + + def show_editable_field? + model.is_a?(UserCustomField) + end + + def formula_suggestions + operators = CustomField::CalculatedValue::MATH_OPERATORS_FOR_FORMULA + # Hide % from the suggestions as it can be used as either modulo or percentage. + .reject { it == "%" } + .map do |op| + # Insert operators as plain text nodes instead of tokens, since displaying them as tokens would result + # in too much visual clutter. We still want to offer autocompletion for them. + { key: op, label: op, insert_as_text: true, enabled: true } + end + + custom_fields = model.usable_custom_field_references_for_formula.map do |cf| + { key: "cf_#{cf.id}", label: cf.name, enabled: true } + end + + { + custom_fields: { title: I18n.t("label_custom_field_plural"), tokens: custom_fields }, + operators: { title: I18n.t("label_mathematical_operators"), tokens: operators } + } + end end end diff --git a/app/forms/projects/settings/custom_fields_form.rb b/app/forms/projects/settings/custom_fields_form.rb index 2fffeb5e3e5..0bacd9b81dc 100644 --- a/app/forms/projects/settings/custom_fields_form.rb +++ b/app/forms/projects/settings/custom_fields_form.rb @@ -43,9 +43,14 @@ module Projects private def custom_fields - @custom_fields ||= model - .available_custom_fields - .required + @custom_fields ||= begin + enabled_custom_fields = model.enabled_custom_field_ids.presence || ProjectCustomField.for_all.select(:id) + + model + .available_custom_fields + .where(id: enabled_custom_fields) + .required + end end end end diff --git a/app/helpers/enterprise_helper.rb b/app/helpers/enterprise_helper.rb index 0b2d64b2f51..24a7b0a8522 100644 --- a/app/helpers/enterprise_helper.rb +++ b/app/helpers/enterprise_helper.rb @@ -32,9 +32,23 @@ module EnterpriseHelper ## # Renders the enterprise banner component with a guard for the given feature key. # If the feature is not enabled, it will not render the given block. - def with_enterprise_banner_guard(feature_key, **args) - concat(render(EnterpriseEdition::BannerComponent.new(feature_key, **args))) - yield if EnterpriseToken.allows_to?(feature_key) + # + # Parameters: + # - feature_key: The key that identifies the specific enterprise feature. + # - inactive_guard: A boolean flag determining whether the guard should be active + # or bypassed. If set to `true`, the guard is bypassed and only the block is executed. + # Defaults to `false`. + # - **args: Additional keyword arguments to be passed to the banner component. + # + # Yields: + # - Executes the provided block within the guard's context. + def with_enterprise_banner_guard(feature_key, inactive_guard: false, **args) + if inactive_guard + yield + else + concat(render(EnterpriseEdition::BannerComponent.new(feature_key, **args))) + yield if EnterpriseToken.allows_to?(feature_key) + end end def enterprise_angular_trial_inputs diff --git a/app/models/custom_value.rb b/app/models/custom_value.rb index b9e1e4294d4..cb88fbede6a 100644 --- a/app/models/custom_value.rb +++ b/app/models/custom_value.rb @@ -46,6 +46,7 @@ class CustomValue < ApplicationRecord delegate :editable?, :admin_only?, :required?, + :is_for_all?, :max_length, :min_length, :calculated_value?, diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index 1d24ffb10a8..18aa643262f 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -37,7 +37,7 @@ class ProjectCustomField < CustomField acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] - after_save :activate_required_field_in_all_projects, if: :required? + after_save :activate_required_field_in_all_projects, if: :is_for_all? validates :custom_field_section_id, presence: true diff --git a/app/models/role.rb b/app/models/role.rb index a6abb6286e6..6e2eaadf0b3 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -80,7 +80,7 @@ class Role < ApplicationRecord validates :name, presence: true, length: { maximum: 256 }, - uniqueness: { case_sensitive: true } + uniqueness: { case_sensitive: false } # Turn this class into an abstract one by validating the STI column. validates :type, diff --git a/app/models/type.rb b/app/models/type.rb index a3b26c86b54..d7d5e097974 100644 --- a/app/models/type.rb +++ b/app/models/type.rb @@ -61,7 +61,10 @@ class Type < ApplicationRecord acts_as_list - validates :name, uniqueness: { case_sensitive: false } + validates :name, + presence: true, + uniqueness: { case_sensitive: false }, + length: { maximum: 255 } scopes :milestone diff --git a/app/services/project_custom_field_project_mappings/bulk_update_service.rb b/app/services/project_custom_field_project_mappings/bulk_update_service.rb index 009d9a401f1..ef5b866cba4 100644 --- a/app/services/project_custom_field_project_mappings/bulk_update_service.rb +++ b/app/services/project_custom_field_project_mappings/bulk_update_service.rb @@ -84,11 +84,11 @@ module ProjectCustomFieldProjectMappings end def fetch_custom_field_ids - # only custom fields which are not set to required can be disabled + # only custom fields which are not set "for all projects" can be disabled ProjectCustomField .visible(@user) .where(custom_field_section_id: @project_custom_field_section.id) - .where(is_required: false) + .where(is_for_all: false) .pluck(:id) end diff --git a/app/services/projects/concerns/new_project_service.rb b/app/services/projects/concerns/new_project_service.rb index 077f59abe12..bad9e78e9b6 100644 --- a/app/services/projects/concerns/new_project_service.rb +++ b/app/services/projects/concerns/new_project_service.rb @@ -93,7 +93,7 @@ module Projects::Concerns # although the user explicitly provided a blank value. In order to not patch `acts_as_customizable` # further, we simply identify these custom values and deactivate the custom field. - custom_field_ids = new_project.custom_values.select { |cv| cv.value.blank? && !cv.required? }.pluck(:custom_field_id) + custom_field_ids = new_project.custom_values.select { |cv| cv.value.blank? && !cv.is_for_all? }.pluck(:custom_field_id) custom_field_project_mappings = new_project.project_custom_field_project_mappings custom_field_project_mappings @@ -104,11 +104,11 @@ module Projects::Concerns end def build_missing_project_custom_field_project_mappings(project) - # Activate all custom fields (via mapping table) that are required or - # have a value provided by the user, but no mapping exists. + # Activate all custom fields (via mapping table) that have no mapping, but are either + # intended for all projects, or have a value provided by the user. custom_field_ids = project.custom_values - .select { |cv| cv.value? || cv.required? } + .select { |cv| cv.value? || cv.is_for_all? } .pluck(:custom_field_id).uniq activated_custom_field_ids = project.project_custom_field_project_mappings.pluck(:custom_field_id).uniq diff --git a/app/views/admin/settings/project_custom_fields/edit.html.erb b/app/views/admin/settings/project_custom_fields/edit.html.erb index 9c5092917a8..644f966d3a0 100644 --- a/app/views/admin/settings/project_custom_fields/edit.html.erb +++ b/app/views/admin/settings/project_custom_fields/edit.html.erb @@ -38,13 +38,11 @@ See COPYRIGHT and LICENSE files for more details. ) %> -<%= error_messages_for "custom_field" %> - -<% if @custom_field.field_format_calculated_value? %> - <%= render Admin::CustomFields::CalculatedValues::DetailsComponent.new(@custom_field) %> -<% elsif @custom_field.hierarchical_list? %> +<% if CustomFields::DetailsComponent.supported?(@custom_field) %> <%= render CustomFields::DetailsComponent.new(@custom_field) %> <% else %> + <%= error_messages_for "custom_field" %> + <% content_controller "admin--custom-fields", "admin--custom-fields-format-value": @custom_field.field_format, "admin--custom-fields-format-config-value": OpenProject::CustomFieldFormatDependent.stimulus_config %> diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb index 15b15441c4b..d36d93794f2 100644 --- a/app/views/admin/settings/project_custom_fields/new.html.erb +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -31,13 +31,11 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new(@custom_field)) %> -<%= error_messages_for "custom_field" %> - -<% if @custom_field.field_format_calculated_value? %> - <%= render Admin::CustomFields::CalculatedValues::DetailsComponent.new(@custom_field) %> -<% elsif @custom_field.hierarchical_list? %> +<% if CustomFields::DetailsComponent.supported?(@custom_field) %> <%= render CustomFields::DetailsComponent.new(@custom_field) %> <% else %> + <%= error_messages_for "custom_field" %> + <% content_controller "admin--custom-fields", "admin--custom-fields-format-value": @custom_field.field_format, "admin--custom-fields-format-config-value": OpenProject::CustomFieldFormatDependent.stimulus_config %> diff --git a/app/views/admin/settings/project_custom_fields/project_mappings.html.erb b/app/views/admin/settings/project_custom_fields/project_mappings.html.erb index 2c0091f80f6..ec678d8b692 100644 --- a/app/views/admin/settings/project_custom_fields/project_mappings.html.erb +++ b/app/views/admin/settings/project_custom_fields/project_mappings.html.erb @@ -37,14 +37,16 @@ See COPYRIGHT and LICENSE files for more details. %> <%= - unless @custom_field.required? + unless @custom_field.is_for_all? render(Primer::OpenProject::SubHeader.new(test_selector: "add-projects-sub-header")) do |component| - component.with_action_button(scheme: :primary, - leading_icon: :"op-include-projects", - label: I18n.t(:label_add_projects), - tag: :a, - href: new_link_admin_settings_project_custom_field_path(@custom_field), - data: { controller: "async-dialog" }) do + component.with_action_button( + scheme: :primary, + leading_icon: :"op-include-projects", + label: I18n.t(:label_add_projects), + tag: :a, + href: new_link_admin_settings_project_custom_field_path(@custom_field), + data: { controller: "async-dialog" } + ) do I18n.t(:label_add_projects) end end @@ -52,10 +54,10 @@ See COPYRIGHT and LICENSE files for more details. %> <%= - if @custom_field.required? + if @custom_field.is_for_all? render Primer::OpenProject::FeedbackMessage.new(icon_arguments: { icon: :checklist }, border: true) do |component| - component.with_heading(tag: :h2).with_content(I18n.t("projects.settings.project_custom_fields.is_required_blank_slate.heading")) - component.with_description { I18n.t("projects.settings.project_custom_fields.is_required_blank_slate.description") } + component.with_heading(tag: :h2).with_content(I18n.t("projects.settings.project_custom_fields.is_for_all_blank_slate.heading")) + component.with_description { I18n.t("projects.settings.project_custom_fields.is_for_all_blank_slate.description") } end else render( diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index e25119494da..cddcda38427 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -81,7 +81,13 @@ See COPYRIGHT and LICENSE files for more details. <%= f.check_box :multi_value, data: { action: "admin--custom-fields#checkOnlyOne" } %>
-

<%= t("custom_fields.instructions.multi_select") %>

+

+ <% if @custom_field.is_a?(ProjectCustomField) %> + <%= t("custom_fields.instructions.multi_select.project") %> + <% else %> + <%= t("custom_fields.instructions.multi_select.all") %> + <% end %> +

@@ -158,25 +164,25 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.check_box :is_required %>
-

<%= t("custom_fields.instructions.is_required") %>

+

<%= t("custom_fields.instructions.is_required.all") %>

<%= f.check_box :is_for_all %>
-

<%= t("custom_fields.instructions.is_for_all") %>

+

<%= t("custom_fields.instructions.is_for_all.all") %>

<%= f.check_box :is_filter %>
-

<%= t("custom_fields.instructions.is_filter") %>

+

<%= t("custom_fields.instructions.is_filter.all") %>

> <%= f.check_box :searchable %>
-

<%= t("custom_fields.instructions.searchable") %>

+

<%= t("custom_fields.instructions.searchable.all") %>

> @@ -186,52 +192,58 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.check_box :is_required %>
-

<%= t("custom_fields.instructions.is_required") %>

+

<%= t("custom_fields.instructions.is_required.all") %>

<%= f.check_box :admin_only %>
-

<%= t("custom_fields.instructions.admin_only") %>

+

<%= t("custom_fields.instructions.admin_only.all") %>

<%= f.check_box :editable %>
-

<%= t("custom_fields.instructions.editable") %>

+

<%= t("custom_fields.instructions.editable.all") %>

<% when "ProjectCustomField" %>
<%= f.check_box :is_required %>
-

<%= t("custom_fields.instructions.is_required_for_project") %>

+

<%= t("custom_fields.instructions.is_required.project") %>

+
+
+
+ <%= f.check_box :is_for_all %> +
+

<%= t("custom_fields.instructions.is_for_all.project") %>

<%= f.check_box :admin_only %>
-

<%= t("custom_fields.instructions.admin_only") %>

+

<%= t("custom_fields.instructions.admin_only.project") %>

> <%= f.check_box :searchable %>
-

<%= t("custom_fields.instructions.searchable_for_project") %>

+

<%= t("custom_fields.instructions.searchable.project") %>

<% when "TimeEntryCustomField" %>
<%= f.check_box :is_required %>
-

<%= t("custom_fields.instructions.is_required") %>

+

<%= t("custom_fields.instructions.is_required.all") %>

<% else %>
<%= f.check_box :is_required %>
-

<%= t("custom_fields.instructions.is_required") %>

+

<%= t("custom_fields.instructions.is_required.all") %>

<% end %> diff --git a/app/views/custom_fields/edit.html.erb b/app/views/custom_fields/edit.html.erb index 0814385d202..937e22cd26d 100644 --- a/app/views/custom_fields/edit.html.erb +++ b/app/views/custom_fields/edit.html.erb @@ -31,7 +31,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Admin::CustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :edit)) %> -<% if @custom_field.hierarchical_list? %> +<% if CustomFields::DetailsComponent.supported?(@custom_field) %> <%= render CustomFields::DetailsComponent.new(@custom_field) %> <% else %> <%= error_messages_for "custom_field" %> diff --git a/app/views/custom_fields/new.html.erb b/app/views/custom_fields/new.html.erb index 3769b68cc7b..feb38394b5c 100644 --- a/app/views/custom_fields/new.html.erb +++ b/app/views/custom_fields/new.html.erb @@ -42,7 +42,7 @@ See COPYRIGHT and LICENSE files for more details. end %> -<% if @custom_field.hierarchical_list? %> +<% if CustomFields::DetailsComponent.supported?(@custom_field) %> <%= render CustomFields::DetailsComponent.new(@custom_field) %> <% else %> <%= error_messages_for "custom_field" %> diff --git a/config/initializers/log_slow_sql_queries.rb b/config/initializers/log_slow_sql_queries.rb index 6bd3f31882e..324c9cd9a2f 100644 --- a/config/initializers/log_slow_sql_queries.rb +++ b/config/initializers/log_slow_sql_queries.rb @@ -42,8 +42,8 @@ Rails.application.configure do # Skip transaction that may be blocked next if data[:sql].match?(/BEGIN|COMMIT/) - # Skip tenant creation (load dump) - next if data[:sql][..120].include?("Dumped by pg_dump") + # Skip tenant creation (load dump sql which is around 300kB) + next if data[:sql][..120].include?("Dumped by pg_dump") || data[:sql].size > 200_000 # Skip smaller durations duration = ((finish - start) * 1000).round(4) diff --git a/config/locales/en.yml b/config/locales/en.yml index 11bd317a4f7..4473d695f57 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -377,18 +377,29 @@ en: calculated_field_not_editable: "Non-editable attribute. This value is calculated automatically." no_role_assigment: "No role assignment" instructions: - is_required: "Mark the custom field as required. This will make it mandatory to fill in the field when creating new resources. Existing resources will not require a value when being updated." - is_required_for_project: "Check to enable this attribute and make it required in all projects. It cannot be deactived for individual projects. Existing projects will not require a value when being updated." - is_for_all: "Mark the custom field as available in all existing and new projects." - multi_select: "Allows the user to assign multiple values to this custom field." - searchable: "Include the field values when using the global search functionality." - searchable_for_project: "Check to make this attribute available as a filter in project lists." - editable: "Allow the field to be editable by users themselves." - admin_only: "Check to make this attribute only visible to administrators. Users without admin rights will not be able to view or edit it." - is_filter: > - Allow the custom field to be used in a filter in work package views. - Note that only with 'For all projects' selected, the custom field will show up in global views. - formula: "Add numeric values or type / to search for an attribute or a mathematical operator." + is_required: + all: "Mark the custom field as required. This will make it mandatory to fill in the field when creating new resources. Existing resources will not require a value when being updated." + project: "Check to enable this attribute and make it required in all projects. It cannot be deactivated for individual projects. Existing projects will not require a value when being updated." + is_for_all: + all: "Mark the custom field as available in all existing and new projects." + project: "Mark the attribute as available in all existing and new projects." + multi_select: + all: "Allows the user to assign multiple values to this custom field." + project: "Allows the user to assign multiple values to this attribute." + searchable: + all: "Include the field values when using the global search functionality." + project: "Check to make this attribute available as a filter in project lists." + editable: + all: "Allow the field to be editable by users themselves." + admin_only: + all: "Check to make this custom field only visible to administrators. Users without admin rights will not be able to view or edit it." + project: "Check to make this attribute only visible to administrators. Users without admin rights will not be able to view or edit it." + is_filter: + all: > + Allow the custom field to be used in a filter in work package views. + Note that only with 'For all projects' selected, the custom field will show up in global views. + formula: + project: "Add numeric values or type / to search for an attribute or a mathematical operator." tab: no_results_title_text: There are currently no custom fields. @@ -625,9 +636,9 @@ en: label_enable_single: "Active in this project, click to disable" label_disable_single: "Inactive in this project, click to enable" remove_from_project: "Remove from project" - is_required_blank_slate: - heading: Required in all projects - description: This project attribute is activated in all projects since the "Required in all projects" option is checked. It cannot be deactivated for individual projects. + is_for_all_blank_slate: + heading: For all projects + description: This project attribute is enabled in all projects since the "For all projects" option is checked. It cannot be deactivated for individual projects. types: no_results_title_text: There are currently no types available. form: @@ -1363,7 +1374,7 @@ en: work_packages: "Work Packages" workspace_type: "Workspace type" project_custom_field: - is_required: "Required for all projects" + is_required: "Required" custom_field_section: Section subproject_template_assignment: workspace_type: "Workspace type" @@ -1652,6 +1663,8 @@ en: invalid_characters: "Only numeric values, mathematical operators and project attributes of type integer, float, calculated value and weighted list are allowed." not_allowed_custom_fields_referenced: "The attribute %{custom_fields} cannot be used because it leads to a circular reference; one attribute depends on the other." format: "%{message}" + required: + cannot_be_true: "cannot be set to true." custom_fields_project: attributes: project_ids: @@ -4737,6 +4750,7 @@ en: passwords: "Passwords" project_attributes: heading: "Project attributes" + label_for_all_projects: "All projects" label_new_attribute: "Project attribute" label_new_section: "Section" label_edit_section: "Edit title" diff --git a/db/migrate/20251211160744_set_is_for_all_and_unset_required.rb b/db/migrate/20251211160744_set_is_for_all_and_unset_required.rb new file mode 100644 index 00000000000..0d991a3f270 --- /dev/null +++ b/db/migrate/20251211160744_set_is_for_all_and_unset_required.rb @@ -0,0 +1,57 @@ +# 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 Rails.root.join("db/migrate/migration_utils/utils") + +class SetIsForAllAndUnsetRequired < ActiveRecord::Migration[8.0] + include Migration::Utils + + def up + # With WP-69399, project custom fields support both required and is_for_all as separate flags. + # Before, there was only is_required, which implied is_for_all. + # + # Take all project custom fields that are required and set is_for_all to true: + ProjectCustomField + .where(is_required: true) + .update_all(is_for_all: true) + + # Additionally, bool and calculated value can no longer be required. + + CustomField + .where(field_format: %w(bool calculated_value)) + .update_all(is_required: false) + end + + def down + # Down migration can only partly reconstruct the data + ProjectCustomField + .update_all(is_for_all: false) + end +end diff --git a/db/migrate/20251218100700_add_uniqueness_for_status_names.rb b/db/migrate/20251218100700_add_uniqueness_for_status_names.rb new file mode 100644 index 00000000000..3e3b72d4a31 --- /dev/null +++ b/db/migrate/20251218100700_add_uniqueness_for_status_names.rb @@ -0,0 +1,47 @@ +# 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 AddUniquenessForStatusNames < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + execute <<~SQL.squish + UPDATE statuses SET name = statuses.name || ' ' || counter.rn + FROM (SELECT id, row_number() OVER (PARTITION BY LOWER(name) ORDER BY id) AS rn FROM statuses) AS counter + WHERE statuses.id = counter.id AND counter.rn > 1; + SQL + + add_index :statuses, "LOWER(name)", unique: true, algorithm: :concurrently + end + + def down + remove_index :statuses, column: "LOWER(name)", algorithm: :concurrently + end +end diff --git a/app/components/admin/custom_fields/calculated_values/details_component.rb b/db/migrate/20251218100721_add_uniqueness_for_role_names.rb similarity index 69% rename from app/components/admin/custom_fields/calculated_values/details_component.rb rename to db/migrate/20251218100721_add_uniqueness_for_role_names.rb index f2c70c9bba5..044022404db 100644 --- a/app/components/admin/custom_fields/calculated_values/details_component.rb +++ b/db/migrate/20251218100721_add_uniqueness_for_role_names.rb @@ -27,28 +27,21 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -# -module Admin::CustomFields::CalculatedValues - class DetailsComponent < ApplicationComponent - include ApplicationHelper - include EnterpriseHelper - include OpPrimer::ComponentHelpers - include OpTurbo::Streamable - alias_method :custom_field, :model +class AddUniquenessForRoleNames < ActiveRecord::Migration[8.0] + disable_ddl_transaction! - private + def up + execute <<~SQL.squish + UPDATE roles SET name = roles.name || ' ' || counter.rn + FROM (SELECT id, row_number() OVER (PARTITION BY LOWER(name) ORDER BY id) AS rn FROM roles) AS counter + WHERE roles.id = counter.id AND counter.rn > 1; + SQL - def form_url - if custom_field.new_record? - admin_settings_project_custom_fields_path - else - admin_settings_project_custom_field_path(custom_field) - end - end + add_index :roles, "LOWER(name)", unique: true, algorithm: :concurrently + end - def form_method - custom_field.new_record? ? :post : :put - end + def down + remove_index :roles, column: "LOWER(name)", algorithm: :concurrently end end diff --git a/db/migrate/20251218100741_add_uniqueness_for_type_names.rb b/db/migrate/20251218100741_add_uniqueness_for_type_names.rb new file mode 100644 index 00000000000..445df2aca36 --- /dev/null +++ b/db/migrate/20251218100741_add_uniqueness_for_type_names.rb @@ -0,0 +1,47 @@ +# 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 AddUniquenessForTypeNames < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + execute <<~SQL.squish + UPDATE types SET name = types.name || ' ' || counter.rn + FROM (SELECT id, row_number() OVER (PARTITION BY LOWER(name) ORDER BY id) AS rn FROM types) AS counter + WHERE types.id = counter.id AND counter.rn > 1; + SQL + + add_index :types, "LOWER(name)", unique: true, algorithm: :concurrently + end + + def down + remove_index :types, column: "LOWER(name)", algorithm: :concurrently + end +end diff --git a/db/migrate/migration_utils/utils.rb b/db/migrate/migration_utils/utils.rb index 62c3a4337f3..9fe0d69be2c 100644 --- a/db/migrate/migration_utils/utils.rb +++ b/db/migrate/migration_utils/utils.rb @@ -41,7 +41,7 @@ module Migration def in_configurable_batches(klass, default_batch_size: 1000) batches = ENV["OPENPROJECT_MIGRATION_BATCH_SIZE"]&.to_i || default_batch_size - klass.in_batches(of: batches) + yield klass.in_batches(of: batches) end def remove_index_if_exists(table_name, index_name) diff --git a/lib/open_project/custom_field_format_dependent.rb b/lib/open_project/custom_field_format_dependent.rb index a07c6d0828d..9a39e7de624 100644 --- a/lib/open_project/custom_field_format_dependent.rb +++ b/lib/open_project/custom_field_format_dependent.rb @@ -44,7 +44,8 @@ module OpenProject }.freeze def self.stimulus_config - CONFIG.map { |target_name, (operator, formats)| [target_name, operator, formats] }.to_json + CONFIG + .map { |target_name, (operator, formats)| [target_name, operator, formats] }.to_json end attr_reader :format diff --git a/lib/open_project/logging/log_delegator.rb b/lib/open_project/logging/log_delegator.rb index c1aac281b32..364bfd539a8 100644 --- a/lib/open_project/logging/log_delegator.rb +++ b/lib/open_project/logging/log_delegator.rb @@ -5,7 +5,7 @@ module OpenProject ## # Consume a message and let it be handled # by all handlers - def log(exception, context = {}) + def log(exception, context = {}) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity # in case we're getting ActionController::Parameters context = if context.respond_to?(:to_unsafe_h) context.to_unsafe_h @@ -28,9 +28,11 @@ module OpenProject # Set current contexts context[:level] ||= context[:exception] ? :error : :info - context[:current_user] ||= User.current + if the_current_user = current_user + context[:current_user] ||= the_current_user + end - registered_handlers.values.each do |handler| + registered_handlers.each_value do |handler| handler.call message, context rescue StandardError => e Rails.logger.error "Failed to delegate log to #{handler.inspect}: #{e.inspect}\nMessage: #{message.inspect}" @@ -103,6 +105,14 @@ module OpenProject extended = OpenProject::Logging.extend_payload!(payload, context) OpenProject::Logging.formatter.call extended end + + def current_user + User.current + # Probably more performant to rescue "ActiveRecord::StatementInvalid: PG::UndefinedTable" + # than to check for table existence with `User.connection.data_source_exists?(User.table_name)` + rescue StandardError + nil + end end end end diff --git a/spec/components/custom_fields/details_component_spec.rb b/spec/components/custom_fields/details_component_spec.rb new file mode 100644 index 00000000000..021cc262dfe --- /dev/null +++ b/spec/components/custom_fields/details_component_spec.rb @@ -0,0 +1,138 @@ +# 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 CustomFields::DetailsComponent, type: :component do + describe ".supported?" do + context "with a bool cf" do + let(:custom_field) { build_stubbed(:boolean_wp_custom_field) } + + it "is supported" do + expect(described_class).to be_supported(custom_field) + end + end + + context "with a string cf" do + let(:custom_field) { build_stubbed(:string_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a text cf" do + let(:custom_field) { build_stubbed(:text_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a link cf" do + let(:custom_field) { build_stubbed(:link_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with an int cf" do + let(:custom_field) { build_stubbed(:integer_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a version cf" do + let(:custom_field) { build_stubbed(:version_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a user cf" do + let(:custom_field) { build_stubbed(:user_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a date cf" do + let(:custom_field) { build_stubbed(:date_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a list cf" do + let(:custom_field) { build_stubbed(:list_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a float cf" do + let(:custom_field) { build_stubbed(:float_wp_custom_field) } + + it "is not supported" do + expect(described_class).not_to be_supported(custom_field) + end + end + + context "with a calculated_value cf" do + let(:custom_field) { build_stubbed(:calculated_value_project_custom_field) } + + it "is supported" do + expect(described_class).to be_supported(custom_field) + end + end + + context "with a hierarchy cf" do + let(:custom_field) { build_stubbed(:hierarchy_wp_custom_field) } + + it "is supported" do + expect(described_class).to be_supported(custom_field) + end + end + + context "with a weighted_item_list cf" do + let(:custom_field) { build_stubbed(:weighted_item_list_wp_custom_field) } + + it "is supported" do + expect(described_class).to be_supported(custom_field) + end + end + end +end diff --git a/spec/contracts/custom_fields/create_contract_spec.rb b/spec/contracts/custom_fields/create_contract_spec.rb index a412d7ab23b..dde1c8147dd 100644 --- a/spec/contracts/custom_fields/create_contract_spec.rb +++ b/spec/contracts/custom_fields/create_contract_spec.rb @@ -29,28 +29,32 @@ #++ require "spec_helper" -require "contracts/shared/model_contract_shared_context" +require_relative "shared_contract_examples" RSpec.describe CustomFields::CreateContract do - include_context "ModelContract shared context" - - let(:cf) { build_stubbed(:project_custom_field) } - let(:contract) do - described_class.new(cf, current_user, options: {}) - end - - it_behaves_like "contract is valid for active admins and invalid for regular users" - - context "for calculated value custom field" do - let(:cf) { build_stubbed(:calculated_value_project_custom_field) } - let(:current_user) { build_stubbed(:admin) } - - context "without calculated_values enterprise feature" do - it_behaves_like "contract is invalid", base: :error_enterprise_only + it_behaves_like "custom_field contract" do + let(:custom_field) do + CustomField.new(name: custom_field_name, + type: custom_field_type, + field_format: custom_field_field_format, + editable: custom_field_editable, + is_filter: custom_field_is_filter, + is_for_all: custom_field_is_for_all, + is_required: custom_field_is_required, + max_length: custom_field_max_length, + min_length: custom_field_min_length, + possible_values: custom_field_possible_values, + regexp: custom_field_regexp, + formula: custom_field_formula, + searchable: custom_field_searchable, + admin_only: custom_field_admin_only, + default_value: custom_field_default_value, + multi_value: custom_field_multi_value, + content_right_to_left: custom_field_right_to_left, + custom_field_section_id: custom_field_custom_field_section_id, + allow_non_open_versions: custom_field_allow_non_open_versions) end - context "with calculated_values enterprise feature", with_ee: %i[calculated_values] do - it_behaves_like "contract is valid" - end + subject(:contract) { described_class.new(custom_field, current_user) } end end diff --git a/spec/contracts/custom_fields/shared_contract_examples.rb b/spec/contracts/custom_fields/shared_contract_examples.rb new file mode 100644 index 00000000000..8be282bf0ed --- /dev/null +++ b/spec/contracts/custom_fields/shared_contract_examples.rb @@ -0,0 +1,100 @@ +# 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.shared_examples_for "custom_field contract" do + include_context "ModelContract shared context" + + current_user { build_stubbed(:admin) } + + let(:custom_field_name) { "Project name" } + let(:custom_field_type) { "ProjectCustomField" } + let(:custom_field_editable) { true } + let(:custom_field_field_format) { "int" } + let(:custom_field_is_filter) { true } + let(:custom_field_is_for_all) { true } + let(:custom_field_is_required) { true } + let(:custom_field_max_length) { 0 } + let(:custom_field_min_length) { 0 } + let(:custom_field_possible_values) { [] } + let(:custom_field_regexp) { nil } + let(:custom_field_formula) { nil } + let(:custom_field_searchable) { true } + let(:custom_field_admin_only) { true } + let(:custom_field_default_value) { nil } + let(:custom_field_multi_value) { false } + let(:custom_field_right_to_left) { false } + let(:custom_field_custom_field_section_id) { custom_field_section.id } + let(:custom_field_allow_non_open_versions) { nil } + + let(:custom_field_section) { build_stubbed(:project_custom_field_section) } + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + context "if the name is nil" do + let(:custom_field_name) { nil } + + it_behaves_like "contract is invalid", name: %i(blank) + end + + context "for a boolean field" do + let(:custom_field_field_format) { "bool" } + let(:custom_field_is_required) { false } + + context "if required is true" do + let(:custom_field_is_required) { true } + + it_behaves_like "contract is invalid", is_required: :cannot_be_true + end + end + + context "for a calculated field", with_ee: %i[calculated_values], + with_flag: { calculated_value_project_attribute: true } do + let(:custom_field_field_format) { "calculated_value" } + let(:custom_field_is_required) { false } + let(:custom_field_formula) { "1 + 1" } + + context "without calculated_values enterprise feature", with_ee: %i[] do + it_behaves_like "contract is invalid", base: :error_enterprise_only + end + + context "with calculated_values enterprise feature" do + it_behaves_like "contract is valid" + end + + context "if required is true" do + let(:custom_field_is_required) { true } + + it_behaves_like "contract is invalid", is_required: :cannot_be_true + end + end +end diff --git a/spec/contracts/custom_fields/update_contract_spec.rb b/spec/contracts/custom_fields/update_contract_spec.rb index 0acb09b3e79..f0640fc3b34 100644 --- a/spec/contracts/custom_fields/update_contract_spec.rb +++ b/spec/contracts/custom_fields/update_contract_spec.rb @@ -29,35 +29,47 @@ #++ require "spec_helper" -require "contracts/shared/model_contract_shared_context" +require_relative "shared_contract_examples" RSpec.describe CustomFields::UpdateContract do - include_context "ModelContract shared context" - - let(:cf) { build_stubbed(:project_custom_field) } - let(:contract) do - described_class.new(cf, current_user) - end - - it_behaves_like "contract is valid for active admins and invalid for regular users" - - context "for calculated value custom field" do - let(:cf) { build_stubbed(:calculated_value_project_custom_field) } - let(:current_user) { build_stubbed(:admin) } - - context "without calculated_values enterprise feature" do - it_behaves_like "contract is invalid", base: :error_enterprise_only + it_behaves_like "custom_field contract" do + let(:custom_field) do + build_stubbed(:custom_field, + name: custom_field_name, + type: custom_field_type, + field_format: custom_field_field_format, + editable: custom_field_editable, + is_filter: custom_field_is_filter, + is_for_all: custom_field_is_for_all, + is_required: custom_field_is_required, + max_length: custom_field_max_length, + min_length: custom_field_min_length, + possible_values: custom_field_possible_values, + regexp: custom_field_regexp, + formula: custom_field_formula, + searchable: custom_field_searchable, + admin_only: custom_field_admin_only, + default_value: custom_field_default_value, + multi_value: custom_field_multi_value, + content_right_to_left: custom_field_right_to_left, + custom_field_section_id: custom_field_custom_field_section_id, + allow_non_open_versions: custom_field_allow_non_open_versions) end - context "with calculated_values enterprise feature", with_ee: %i[calculated_values] do - it_behaves_like "contract is valid" + subject(:contract) { described_class.new(custom_field, current_user) } + + context "for a calculated field", with_ee: %i[calculated_values], + with_flag: { calculated_value_project_attribute: true } do + let(:custom_field_field_format) { "calculated_value" } + + let(:custom_field_formula) { "1 + 1" } context "with a CustomFields::RecalculateValuesJob already existing", with_good_job: CustomFields::RecalculateValuesJob do before do CustomFields::RecalculateValuesJob .set(wait: 10.minutes) # GoodJob executes inline job without wait immediately - .perform_later(user: current_user, custom_field_id: cf.id) + .perform_later(user: current_user, custom_field_id: custom_field.id) end it_behaves_like "contract is invalid", base: :previous_custom_field_recalculation_unprocessed diff --git a/spec/features/admin/custom_fields/projects/boolean_spec.rb b/spec/features/admin/custom_fields/projects/boolean_spec.rb new file mode 100644 index 00000000000..971c8ef482b --- /dev/null +++ b/spec/features/admin/custom_fields/projects/boolean_spec.rb @@ -0,0 +1,47 @@ +# 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" +require_relative "shared_context" + +RSpec.describe "Edit project custom fields", :js do + include_context "with seeded project custom fields" + + let(:custom_field) { boolean_project_custom_field } + + it_behaves_like "prevents access on insufficient permissions" + it_behaves_like "has breadcrumb and tabs" + it_behaves_like "shows checkboxes for configuration" do + let(:required_supported) { false } + end + it_behaves_like "editing the field" do + let(:using_primer) { true } + end +end diff --git a/spec/features/admin/custom_fields/projects/calculated_value_spec.rb b/spec/features/admin/custom_fields/projects/calculated_value_spec.rb index a89c6a0ee83..61cc532108c 100644 --- a/spec/features/admin/custom_fields/projects/calculated_value_spec.rb +++ b/spec/features/admin/custom_fields/projects/calculated_value_spec.rb @@ -42,173 +42,146 @@ RSpec.describe "Edit project custom field calculated value", project_custom_field_section: section_for_select_fields) end - let(:calculated_value) { calculated_from_int_project_custom_field } + let(:custom_field) { calculated_from_int_project_custom_field } - context "with insufficient permissions" do - it "is not accessible" do - login_as(non_admin) - visit edit_admin_settings_project_custom_field_path(calculated_value) - - expect(page).to have_text("You are not authorized to access this page.") - end + it_behaves_like "prevents access on insufficient permissions" + it_behaves_like "has breadcrumb and tabs" + it_behaves_like "shows checkboxes for configuration" do + let(:required_supported) { false } end - context "with sufficient permissions" do + context "with calculated_values enterprise feature" do before do login_as(admin) - visit edit_admin_settings_project_custom_field_path(calculated_value) + visit edit_admin_settings_project_custom_field_path(custom_field) end - it "shows a correct breadcrumb menu" do + it "allows to change basic attributes and the section of the calculated value" do + expect(page).to have_css(".PageHeader-title", text: custom_field.name) + + fill_in("custom_field_name", with: "Updated name", fill_options: { clear: :backspace }) + + # Calculated values cannot be required since the user cannot fill them out themselves. + expect(page).to have_no_field("Required") + + select(section_for_select_fields.name, from: "Section") + find_field(id: "custom_field_formula", type: :hidden).set("1 + 1") + + click_on "Save" + + expect(page).to have_text("Successful update") + + expect(page).to have_css(".PageHeader-title", text: "Updated name") + + expect(custom_field.reload.name).to eq("Updated name") + expect(custom_field.reload.project_custom_field_section).to eq(section_for_select_fields) + expect(custom_field.reload.formula_string).to eq("1 + 1") + within ".PageHeader-breadcrumbs" do expect(page).to have_link("Administration") expect(page).to have_link("Projects") expect(page).to have_link("Project attributes") - expect(page).to have_text(calculated_value.name) + expect(page).to have_text("Updated name") end end - it "shows tab navigation" do - within_test_selector("project_attribute_detail_header") do - expect(page).to have_link("Details") - expect(page).to have_link("Projects") - end + it "prevents saving a calculated value with an empty name" do + original_name = custom_field.name + + fill_in("custom_field_name", with: "") + click_on "Save" + + expect(page).to have_text("Name can't be blank") + + expect(page).to have_no_text("Successful update") + + expect(page).to have_css(".PageHeader-title", text: original_name) + expect(custom_field.reload.name).to eq(original_name) end - context "without calculated_values enterprise feature", with_ee: [] do - it do - expect(page) - .to have_enterprise_banner(:premium) - .and have_no_field("custom_field_name") - .and have_no_button("Save") - end + it "prevents saving a calculated value with an empty formula" do + original_formula = custom_field.formula_string + + find_field(id: "custom_field_formula", type: :hidden).set("") + click_on "Save" + + expect(page).to have_text("Formula can't be blank") + expect(page).to have_no_text("Successful update") + + expect(custom_field.reload.formula_string).to eq(original_formula) end - context "with calculated_values enterprise feature" do - it "allows to change basic attributes and the section of the calculated value" do - expect(page).to have_css(".PageHeader-title", text: calculated_value.name) + it "allows submitting formula by pressing Enter/Return" do + # ensure multiple spaces are handled without problems + formula = "2 + (1 +1)" - fill_in("custom_field_name", with: "Updated name", fill_options: { clear: :backspace }) - select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") - find_field(id: "custom_field_formula", type: :hidden).set("1 + 1") + pattern_input = find(:xpath, "//input[@id='custom_field_formula']/parent::div//div[@contenteditable='true']") - click_on "Save" + pattern_input.set("#{formula}\n") - expect(page).to have_text("Successful update") + expect(page).to have_text("Successful update") - expect(page).to have_css(".PageHeader-title", text: "Updated name") + expect(custom_field.reload.formula_string).to eq(formula) + end - expect(calculated_value.reload.name).to eq("Updated name") - expect(calculated_value.reload.project_custom_field_section).to eq(section_for_select_fields) - expect(calculated_value.reload.formula_string).to eq("1 + 1") + context "when editing the formula" do + using CustomFieldFormulaReferencing - within ".PageHeader-breadcrumbs" do - expect(page).to have_link("Administration") - expect(page).to have_link("Projects") - expect(page).to have_link("Project attributes") - expect(page).to have_text("Updated name") + it "allows using the pattern input component" do + expect(page).to have_css(".PageHeader-title", text: custom_field.name) + + expect(page).to have_css("input#custom_field_formula[value='#{integer_project_custom_field} * 2']", + visible: :hidden) + + # Suggestions drop down is hidden + expect(page).to have_no_css(".op-pattern-input--suggestions-dropdown .ActionListItem") + + pattern_input = page.find(".op-pattern-input--text-field") + pattern_input.click + pattern_input.send_keys(" + ") + expect(page).to have_no_css(".op-pattern-input--suggestions-dropdown .ActionListItem") + + # Open suggestion list + pattern_input.send_keys("/") + within ".op-pattern-input--suggestions-dropdown" do + expect(page).to have_css(".ActionListItem", text: float_project_custom_field.name) + click_on(float_project_custom_field.name) end - end - it "prevents saving a calculated value with an empty name" do - original_name = calculated_value.name + # Input divide operator + pattern_input.send_keys(" / ") + expect(page).to have_no_css(".op-pattern-input--suggestions-dropdown .ActionListItem") - fill_in("custom_field_name", with: "") - click_on "Save" - - expect(page).to have_text("Name can't be blank") - - expect(page).to have_no_text("Successful update") - - expect(page).to have_css(".PageHeader-title", text: original_name) - expect(calculated_value.reload.name).to eq(original_name) - end - - it "prevents saving a calculated value with an empty formula" do - original_formula = calculated_value.formula_string - - find_field(id: "custom_field_formula", type: :hidden).set("") - click_on "Save" - - expect(page).to have_text("Formula can't be blank") - expect(page).to have_no_text("Successful update") - - expect(calculated_value.reload.formula_string).to eq(original_formula) - end - - it "allows submitting formula by pressing Enter/Return" do - # ensure multiple spaces are handled without problems - formula = "2 + (1 +1)" - - pattern_input = find(:xpath, "//input[@id='custom_field_formula']/parent::div//div[@contenteditable='true']") - - pattern_input.set("#{formula}\n") - - expect(page).to have_text("Successful update") - - expect(calculated_value.reload.formula_string).to eq(formula) - end - - context "when editing the formula" do - using CustomFieldFormulaReferencing - - it "allows using the pattern input component" do - expect(page).to have_css(".PageHeader-title", text: calculated_value.name) - - expect(page).to have_css("input#custom_field_formula[value='#{integer_project_custom_field} * 2']", - visible: :hidden) - - # Suggestions drop down is hidden - expect(page).to have_no_css(".op-pattern-input--suggestions-dropdown .ActionListItem") - - pattern_input = page.find(".op-pattern-input--text-field") - pattern_input.click - pattern_input.send_keys(" + ") - expect(page).to have_no_css(".op-pattern-input--suggestions-dropdown .ActionListItem") - - # Open suggestion list - pattern_input.send_keys("/") - within ".op-pattern-input--suggestions-dropdown" do - expect(page).to have_css(".ActionListItem", text: float_project_custom_field.name) - click_on(float_project_custom_field.name) - end - - # Input divide operator - pattern_input.send_keys(" / ") - expect(page).to have_no_css(".op-pattern-input--suggestions-dropdown .ActionListItem") - - # Open suggestion list again - pattern_input.send_keys("/") - within ".op-pattern-input--suggestions-dropdown" do - expect(page).to have_css(".ActionListItem", text: weighted_item_list_project_custom_field.name) - click_on(weighted_item_list_project_custom_field.name) - end - - click_on("Save") - wait_for_network_idle - - new_formula = calculated_value.reload.formula_string - expect(new_formula) - .to eq( - "#{integer_project_custom_field} * 2 + #{float_project_custom_field} / #{weighted_item_list_project_custom_field}" - ) + # Open the suggestion list again + pattern_input.send_keys("/") + within ".op-pattern-input--suggestions-dropdown" do + expect(page).to have_css(".ActionListItem", text: weighted_item_list_project_custom_field.name) + click_on(weighted_item_list_project_custom_field.name) end + + click_on("Save") + wait_for_network_idle + + new_formula = custom_field.reload.formula_string + expect(new_formula) + .to eq( + "#{integer_project_custom_field} * 2 + #{float_project_custom_field} / #{weighted_item_list_project_custom_field}" + ) end end end - context "without the feature flag", with_flag: { calculated_value_project_attribute: false } do - it "prevents saving a calculated value" do - expect do - login_as(admin) - visit new_admin_settings_project_custom_field_path(field_format: "calculated_value", - custom_field_section_id: section_for_input_fields.id) - fill_in("custom_field_name", with: "New calculated value") - find_field(id: "custom_field_formula", type: :hidden).set("1 + 1") - click_on "Save" + context "without calculated_value enterprise feature", with_ee: [] do + before do + login_as(admin) + visit edit_admin_settings_project_custom_field_path(custom_field) + end - expect(page).to have_text("Format is not set to one of the allowed values.") - end.not_to change(CustomField, :count) + it do + expect(page) + .to have_enterprise_banner(:premium) + .and have_no_field("custom_field_name") + .and have_no_button("Save") end end end diff --git a/spec/features/admin/custom_fields/projects/edit_spec.rb b/spec/features/admin/custom_fields/projects/edit_spec.rb deleted file mode 100644 index f1fee9d9bc2..00000000000 --- a/spec/features/admin/custom_fields/projects/edit_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -# 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" -require_relative "shared_context" - -RSpec.describe "Edit project custom fields", :js do - include_context "with seeded project custom fields" - - context "with insufficient permissions" do - it "is not accessible" do - login_as(non_admin) - visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) - - expect(page).to have_text("You are not authorized to access this page.") - end - end - - context "with sufficient permissions" do - before do - login_as(admin) - visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) - end - - it "shows a correct breadcrumb menu" do - within ".PageHeader-breadcrumbs" do - expect(page).to have_link("Administration") - expect(page).to have_link("Projects") - expect(page).to have_link("Project attributes") - expect(page).to have_text(boolean_project_custom_field.name) - end - end - - it "shows tab navigation" do - within_test_selector("project_attribute_detail_header") do - expect(page).to have_link("Details") - expect(page).to have_link("Projects") - end - end - - it "allows to change basic attributes and the section of the project custom field" do - # TODO: reuse specs for classic custom field form in order to test for other attribute manipulations - expect(page).to have_css(".PageHeader-title", text: boolean_project_custom_field.name) - - fill_in("custom_field_name", with: "Updated name", fill_options: { clear: :backspace }) - select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") - - click_on("Save") - - expect(page).to have_text("Successful update") - - expect(page).to have_css(".PageHeader-title", text: "Updated name") - - expect(boolean_project_custom_field.reload.name).to eq("Updated name") - expect(boolean_project_custom_field.reload.project_custom_field_section).to eq(section_for_select_fields) - - within ".PageHeader-breadcrumbs" do - expect(page).to have_link("Administration") - expect(page).to have_link("Projects") - expect(page).to have_link("Project attributes") - expect(page).to have_text("Updated name") - end - end - - it "prevents saving a project custom field with an empty name" do - original_name = boolean_project_custom_field.name - - fill_in("custom_field_name", with: "") - click_on("Save") - - expect(page).to have_field "custom_field_name", validation_message: /Please fill (in|out) this field./ - - expect(page).to have_no_text("Successful update") - - expect(page).to have_css(".PageHeader-title", text: original_name) - expect(boolean_project_custom_field.reload.name).to eq(original_name) - end - end -end diff --git a/spec/features/admin/custom_fields/projects/index_spec.rb b/spec/features/admin/custom_fields/projects/index_spec.rb index 12e5ee1e3f8..da661d28c8a 100644 --- a/spec/features/admin/custom_fields/projects/index_spec.rb +++ b/spec/features/admin/custom_fields/projects/index_spec.rb @@ -269,6 +269,12 @@ RSpec.describe "List project custom fields", :js do within_project_custom_field_container(boolean_project_custom_field) do expect(page).to have_text("1 project") end + + for_all_cf = create(:project_custom_field, :integer, is_for_all: true) + cf_index_page.visit! + within_project_custom_field_container(for_all_cf) do + expect(page).to have_text("All projects") + end end describe "deleting custom fields" do diff --git a/spec/features/admin/custom_fields/projects/integer_spec.rb b/spec/features/admin/custom_fields/projects/integer_spec.rb new file mode 100644 index 00000000000..5f737795c75 --- /dev/null +++ b/spec/features/admin/custom_fields/projects/integer_spec.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. +#++ + +require "spec_helper" +require_relative "shared_context" + +RSpec.describe "Edit project custom fields", :js do + include_context "with seeded project custom fields" + + let(:custom_field) { integer_project_custom_field } + + it_behaves_like "prevents access on insufficient permissions" + it_behaves_like "has breadcrumb and tabs" + it_behaves_like "shows checkboxes for configuration" + it_behaves_like "editing the field" +end diff --git a/spec/features/admin/custom_fields/projects/project_mappings_spec.rb b/spec/features/admin/custom_fields/projects/project_mappings_spec.rb index 988e72a462f..cc0756d377c 100644 --- a/spec/features/admin/custom_fields/projects/project_mappings_spec.rb +++ b/spec/features/admin/custom_fields/projects/project_mappings_spec.rb @@ -143,11 +143,11 @@ RSpec.describe "Project Custom Field Mappings", :js do end end - context "and the project custom field is required" do - shared_let(:project_custom_field) { create(:project_custom_field, is_required: true) } + context "and the project custom field is for_all projects" do + shared_let(:project_custom_field) { create(:project_custom_field, is_for_all: true) } it "renders a blank slate" do - expect(page).to have_text("Required in all projects") + expect(page).to have_text("For all projects") expect(page).not_to have_test_selector("add-projects-sub-header") end end diff --git a/spec/features/admin/custom_fields/projects/shared_context.rb b/spec/features/admin/custom_fields/projects/shared_context.rb index 88dc429e621..e4033565791 100644 --- a/spec/features/admin/custom_fields/projects/shared_context.rb +++ b/spec/features/admin/custom_fields/projects/shared_context.rb @@ -153,3 +153,134 @@ RSpec.shared_context "with seeded project custom fields" do ] end end + +RSpec.shared_examples "prevents access on insufficient permissions" do + current_user { non_admin } + + before do + visit edit_admin_settings_project_custom_field_path(custom_field) + end + + it "is not accessible" do + expect(page).to have_text("You are not authorized to access this page.") + end +end + +RSpec.shared_examples "has breadcrumb and tabs" do + current_user { admin } + + before do + visit edit_admin_settings_project_custom_field_path(custom_field) + end + + it "shows a correct breadcrumb menu" do + within ".PageHeader-breadcrumbs" do + expect(page).to have_link("Administration") + expect(page).to have_link("Projects") + expect(page).to have_link("Project attributes") + expect(page).to have_text(custom_field.name) + end + end + + it "shows tab navigation" do + within_test_selector("project_attribute_detail_header") do + expect(page).to have_link("Details") + expect(page).to have_link("Projects") + end + end +end + +RSpec.shared_examples "shows checkboxes for configuration" do + current_user { admin } + + let(:required_supported) do + super() + rescue NoMethodError + true + end + + before do + visit edit_admin_settings_project_custom_field_path(custom_field) + end + + it "shows checkboxes for 'Required', 'Admin-only' and 'For all projects' attributes" do + if required_supported + expect(page).to have_unchecked_field("Required") + + check("Required") + else + expect(page).to have_no_field("Required") + end + + expect(page).to have_unchecked_field("Admin-only") + check("Admin-only") + + expect(page).to have_unchecked_field("For all projects") + check("For all projects") + + click_on("Save") + + expect(page).to have_text("Successful update") + + custom_field.reload + expect(custom_field.is_required).to eq required_supported + expect(custom_field.admin_only).to be_truthy + expect(custom_field.is_for_all).to be_truthy + end +end + +RSpec.shared_examples "editing the field" do + current_user { admin } + + let(:using_primer) do + super() + rescue NoMethodError + false + end + + before do + visit edit_admin_settings_project_custom_field_path(custom_field) + end + + it "allows to change name and the section of the project custom field" do + # TODO: reuse specs for classic custom field form in order to test for other attribute manipulations + expect(page).to have_css(".PageHeader-title", text: custom_field.name) + + fill_in("Name", with: "Updated name", fill_options: { clear: :backspace }) + select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") + + click_on("Save") + + expect(page).to have_text("Successful update") + + expect(page).to have_css(".PageHeader-title", text: "Updated name") + + expect(custom_field.reload.name).to eq("Updated name") + expect(custom_field.reload.project_custom_field_section).to eq(section_for_select_fields) + + within ".PageHeader-breadcrumbs" do + expect(page).to have_link("Administration") + expect(page).to have_link("Projects") + expect(page).to have_link("Project attributes") + expect(page).to have_text("Updated name") + end + end + + it "prevents saving a project custom field with an empty name" do + original_name = custom_field.name + + fill_in("Name", with: "") + click_on("Save") + + if using_primer + expect(page).to have_css(".FormControl-inlineValidation", text: "Name can't be blank") + else + expect(page).to have_field "Name", validation_message: /Please fill (in|out) this field./ + end + + expect(page).to have_no_text("Successful update") + + expect(page).to have_css(".PageHeader-title", text: original_name) + expect(custom_field.reload.name).to eq(original_name) + end +end diff --git a/spec/features/admin/custom_fields/shared_custom_field_expectations.rb b/spec/features/admin/custom_fields/shared_custom_field_expectations.rb index ef6caf19975..8628576adad 100644 --- a/spec/features/admin/custom_fields/shared_custom_field_expectations.rb +++ b/spec/features/admin/custom_fields/shared_custom_field_expectations.rb @@ -256,10 +256,10 @@ RSpec.shared_examples_for "expected fields for the custom field's format" do |ty end expect_page_to_have_texts( - label_default_value, label_is_required + label_default_value ) expect_page_not_to_have_texts( - label_min_length, label_max_length, label_regexp, label_multi_value, + label_min_length, label_max_length, label_regexp, label_multi_value, label_is_required, label_allow_non_open_versions, label_possible_values, label_ee_banner_hierarchy ) end diff --git a/spec/features/admin/custom_fields/work_packages/boolean_spec.rb b/spec/features/admin/custom_fields/work_packages/boolean_spec.rb index 6fd514ab840..b0aab4f1a33 100644 --- a/spec/features/admin/custom_fields/work_packages/boolean_spec.rb +++ b/spec/features/admin/custom_fields/work_packages/boolean_spec.rb @@ -44,11 +44,10 @@ RSpec.describe "custom fields", :js do describe "available fields" do it "shows all form elements" do - expect(cf_page).to have_form_element("Name") - expect(cf_page).to have_form_element("Default value") - expect(cf_page).to have_form_element("Required") - expect(cf_page).to have_form_element("For all projects") - expect(cf_page).to have_form_element("Used as a filter") + expect(cf_page).to have_field("Name") + expect(cf_page).to have_field("Default value") + expect(cf_page).to have_field("For all projects") + expect(cf_page).to have_field("Used as a filter") end end diff --git a/spec/features/admin/custom_fields/work_packages/hierarchy_spec.rb b/spec/features/admin/custom_fields/work_packages/hierarchy_spec.rb index 69f85dbe380..24904b71eeb 100644 --- a/spec/features/admin/custom_fields/work_packages/hierarchy_spec.rb +++ b/spec/features/admin/custom_fields/work_packages/hierarchy_spec.rb @@ -60,7 +60,7 @@ RSpec.describe "work package custom fields of type hierarchy", :js do # region Edit the details of the custom field - expect(page).to have_test_selector("op-custom-fields--new-hierarchy-banner") + expect(page).to have_test_selector("op-custom-fields--top-banner") expect(page).to have_css(".PageHeader-title", text: hierarchy_name) # Now, that was the wrong name, so I can change it to the correct one @@ -140,7 +140,7 @@ RSpec.describe "work package custom fields of type hierarchy", :js do # And is the blue banner gone, now that I have added some items? hierarchy_page.switch_tab "Details" - expect(page).not_to have_test_selector("op-custom-fields--new-hierarchy-banner") + expect(page).not_to have_test_selector("op-custom-fields--top-banner") # Finally, we delete the custom field ... I'm done with this ... custom_field_index_page.visit! diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 2e0e5c46127..21f7d067321 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -67,13 +67,25 @@ RSpec.describe "Projects copy", :js, end let!(:project_custom_field_section) { create(:project_custom_field_section, name: "Section A") } let!(:project_custom_field) do - create(:text_project_custom_field, name: "Required Foo", is_required: true, project_custom_field_section:) + create(:text_project_custom_field, + name: "Required Foo", + is_for_all: true, + is_required: true, + project_custom_field_section:) end let!(:optional_project_custom_field) do - create(:text_project_custom_field, name: "Optional Foo", is_required: false, project_custom_field_section:) + create(:text_project_custom_field, + name: "Optional Foo", + is_for_all: true, + is_required: false, + project_custom_field_section:) end let!(:optional_project_custom_field_with_default) do - create(:text_project_custom_field, is_required: false, default_value: "foo", project_custom_field_section:) + create(:text_project_custom_field, + is_for_all: true, + is_required: false, + default_value: "foo", + project_custom_field_section:) end let!(:wp_custom_field) do create(:text_wp_custom_field) @@ -168,6 +180,7 @@ RSpec.describe "Projects copy", :js, let!(:required_user_custom_field) do create(:user_project_custom_field, name: "Required User", is_required: true, + is_for_all: true, project_custom_field_section:) end diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index 9a4d1431648..1ef2eacd56d 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -116,6 +116,7 @@ RSpec.describe "Projects", "creation", create(:list_project_custom_field, name: "List CF", is_required: true, + is_for_all: true, multi_value: true, project_custom_field_section:) end @@ -177,6 +178,7 @@ RSpec.describe "Projects", "creation", create(:version_project_custom_field, name: "Version CF", is_required: true, + is_for_all: true, multi_value: true, project_custom_field_section:) end @@ -258,7 +260,14 @@ RSpec.describe "Projects", "creation", project_custom_field_section:) end - it "renders required custom fields for new" do + shared_let(:required_but_inactive_custom_field) do + create(:text_project_custom_field, + name: "Required inactive", + is_required: true, + project_custom_field_section:) + end + + it "renders activated required custom fields for new" do visit new_project_path expect(page).to have_heading "New project" @@ -275,7 +284,12 @@ RSpec.describe "Projects", "creation", expect(page).to have_text("3 of 3") expect(page).to have_field "Required Foo *" expect(page).to have_field "Required User *" + + # Optional fields should not be shown expect(page).to have_no_field "Optional Foo" + + # Inactive fields, even if required, should not be shown + expect(page).to have_no_field "Required Inactive *" end end @@ -329,7 +343,7 @@ RSpec.describe "Projects", "creation", fill_in "Required Foo", with: "Required value" end - it "enables custom fields with provided values for this project" do + it "enables custom fields with provided values and for_all fields for this project" do click_on "Complete" expect_and_dismiss_flash type: :success, message: "Successful creation." @@ -340,7 +354,7 @@ RSpec.describe "Projects", "creation", # unused custom field should not be activated expect(project.project_custom_field_ids).to contain_exactly( - required_custom_field.id + required_custom_field.id, optional_custom_field.id ) end @@ -349,6 +363,7 @@ RSpec.describe "Projects", "creation", create(:project_custom_field, name: "Foo with default value", field_format: "string", is_required: true, + is_for_all: true, default_value: "Default value", project_custom_field_section:) end @@ -365,7 +380,7 @@ RSpec.describe "Projects", "creation", # custom_field_with_default_value should be activated and contain the default value expect(project.project_custom_field_ids).to contain_exactly( - custom_field_with_default_value.id, required_custom_field.id + custom_field_with_default_value.id, required_custom_field.id, optional_custom_field.id ) expect(project.custom_value_for(custom_field_with_default_value).value).to eq("Default value") @@ -384,7 +399,7 @@ RSpec.describe "Projects", "creation", # custom_field_with_default_value should be activated and contain the overwritten value expect(project.project_custom_field_ids).to contain_exactly( - custom_field_with_default_value.id, required_custom_field.id + custom_field_with_default_value.id, required_custom_field.id, optional_custom_field.id ) expect(project.custom_value_for(custom_field_with_default_value).value).to eq("foo") @@ -395,6 +410,7 @@ RSpec.describe "Projects", "creation", shared_let(:invisible_field) do create(:string_project_custom_field, name: "Text for Admins only", is_required: true, + is_for_all: true, admin_only: true, project_custom_field_section:) end @@ -414,7 +430,7 @@ RSpec.describe "Projects", "creation", project = Project.last expect(project.project_custom_field_ids).to contain_exactly( - required_custom_field.id, invisible_field.id + required_custom_field.id, optional_custom_field.id, invisible_field.id ) expect(project.custom_value_for(invisible_field).typed_value).to eq("foo") @@ -438,7 +454,7 @@ RSpec.describe "Projects", "creation", project = Project.last expect(project.project_custom_field_ids).to contain_exactly( - required_custom_field.id + required_custom_field.id, optional_custom_field.id ) end end diff --git a/spec/forms/projects/settings/custom_fields_form_spec.rb b/spec/forms/projects/settings/custom_fields_form_spec.rb index 16489afaffa..dae7f3bf554 100644 --- a/spec/forms/projects/settings/custom_fields_form_spec.rb +++ b/spec/forms/projects/settings/custom_fields_form_spec.rb @@ -34,25 +34,48 @@ RSpec.describe Projects::Settings::CustomFieldsForm, type: :forms, with_ee: %i[calculated_values], with_flag: { calculated_value_project_attribute: true } do - let(:string_project_custom_field) { create(:string_project_custom_field, name: "String field", is_required: true) } - let(:boolean_project_custom_field) { create(:boolean_project_custom_field, name: "Boolean field", is_required: true) } - let(:text_project_custom_field) { create(:text_project_custom_field, name: "Text field", is_required: true) } - let(:integer_project_custom_field) { create(:integer_project_custom_field, name: "Integer field", is_required: true) } - let(:float_project_custom_field) { create(:float_project_custom_field, name: "Float field", is_required: true) } - let(:date_project_custom_field) { create(:date_project_custom_field, name: "Date field", is_required: true) } + let(:string_project_custom_field) do + create(:string_project_custom_field, name: "String field", is_required: true, is_for_all: true) + end + let(:boolean_project_custom_field) do + create(:boolean_project_custom_field, name: "Boolean field", is_required: true, is_for_all: true) + end + let(:text_project_custom_field) do + create(:text_project_custom_field, name: "Text field", is_required: true, is_for_all: true) + end + let(:integer_project_custom_field) do + create(:integer_project_custom_field, name: "Integer field", is_required: true, is_for_all: true) + end + let(:float_project_custom_field) do + create(:float_project_custom_field, name: "Float field", is_required: true, is_for_all: true) + end + let(:date_project_custom_field) do + create(:date_project_custom_field, name: "Date field", is_required: true, is_for_all: true) + end let(:list_project_custom_field) do - create(:list_project_custom_field, name: "List field", is_required: true, possible_values: ["eins", "zwei", "drei"]) + create(:list_project_custom_field, + name: "List field", + is_required: true, + is_for_all: true, + possible_values: %w[eins zwei drei]) end let(:multi_list_project_custom_field) do create(:list_project_custom_field, name: "Multi-list field", is_required: true, + is_for_all: true, multi_value: true, - possible_values: ["uno", "due", "tre", "quattro"]) + possible_values: %w[uno due tre quattro]) + end + let(:version_project_custom_field) do + create(:version_project_custom_field, name: "Version field", is_required: true, is_for_all: true) + end + let(:user_project_custom_field) do + create(:user_project_custom_field, name: "User field", is_required: true, is_for_all: true) + end + let(:link_project_custom_field) do + create(:link_project_custom_field, name: "Link field", is_required: true, is_for_all: true) end - let(:version_project_custom_field) { create(:version_project_custom_field, name: "Version field", is_required: true) } - let(:user_project_custom_field) { create(:user_project_custom_field, name: "User field", is_required: true) } - let(:link_project_custom_field) { create(:link_project_custom_field, name: "Link field", is_required: true) } let!(:calculated_project_custom_field) do create(:calculated_value_project_custom_field, name: "Calculated field", is_required: true, is_for_all: true) end @@ -135,8 +158,35 @@ RSpec.describe Projects::Settings::CustomFieldsForm, end end - it "does not render the required calculated field" do + it "does not render the activated and required calculated field" do expect(page).to have_no_text("Calculated field") expect(page).to have_no_field("Calculated field", disabled: true) end + + context "for a model without custom values set" do + let(:model) { build(:project) } + + let!(:required_inactive_field) do + create(:string_project_custom_field, name: "Required inactive field", is_required: true, is_for_all: false) + end + let!(:optional_active_field) do + create(:string_project_custom_field, name: "Optional active field", is_required: false, is_for_all: true) + end + let!(:required_active_field) do + create(:string_project_custom_field, name: "Required active field", is_required: true, is_for_all: true) + end + + include_context "with rendered form" + + it "only renders required, activated fields" do + expect(page).to have_no_text("Required inactive field") + expect(page).to have_no_field("Required inactive field", disabled: true) + + expect(page).to have_no_text("Optional active field") + expect(page).to have_no_field("Optional active field", disabled: true) + + expect(page).to have_text("Required active field") + expect(page).to have_field("Required active field", required: true) + end + end end diff --git a/spec/migrations/set_is_for_all_and_unset_required_spec.rb b/spec/migrations/set_is_for_all_and_unset_required_spec.rb new file mode 100644 index 00000000000..61b874fe19d --- /dev/null +++ b/spec/migrations/set_is_for_all_and_unset_required_spec.rb @@ -0,0 +1,88 @@ +# 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" +require Rails.root.join("db/migrate/20251211160744_set_is_for_all_and_unset_required") + +RSpec.describe SetIsForAllAndUnsetRequired, type: :model, + with_ee: %i[calculated_values], + with_flag: { calculated_value_project_attribute: true } do + # Project custom fields to be migrated + shared_let(:required_project_cf) { create(:project_custom_field, :integer, is_required: true, is_for_all: false) } + shared_let(:optional_project_cf) { create(:project_custom_field, :integer, is_required: false, is_for_all: false) } + shared_let(:required_boolean_project_cf) { create(:project_custom_field, :boolean, is_required: true, is_for_all: false) } + + # Regular custom fields, to be ignored by the migration + shared_let(:required_cf) { create(:custom_field, :integer, is_required: true, is_for_all: false) } + shared_let(:optional_cf) { create(:custom_field, :integer, is_required: false, is_for_all: false) } + + shared_let(:required_boolean_wp_cf) { create(:wp_custom_field, :boolean, is_required: true, is_for_all: false) } + shared_let(:required_int_wp_cf) { create(:wp_custom_field, :integer, is_required: true, is_for_all: false) } + shared_let(:required_boolean_te_cf) { create(:time_entry_custom_field, :boolean, is_required: true, is_for_all: false) } + shared_let(:required_int_te_cf) { create(:time_entry_custom_field, :integer, is_required: true, is_for_all: false) } + shared_let(:required_boolean_user_cf) { create(:user_custom_field, :boolean, is_required: true, is_for_all: false) } + shared_let(:required_int_user_cf) { create(:user_custom_field, :integer, is_required: true, is_for_all: false) } + shared_let(:required_boolean_group_cf) { create(:group_custom_field, :boolean, is_required: true, is_for_all: false) } + shared_let(:required_int_group_cf) { create(:group_custom_field, :integer, is_required: true, is_for_all: false) } + + # Cannot be done as a shared_let as with_ee and with_flag haven't taken hold when shared_let is run + let!(:required_calculated_project_cf) { create(:project_custom_field, :calculated_value, is_required: true) } + + it "updates required project custom fields as well as boolean and calculated values" do # rubocop:disable RSpec/MultipleExpectations + ActiveRecord::Migration.suppress_messages { described_class.migrate(:up) } + + expect(required_project_cf.reload.is_for_all).to be_truthy + expect(optional_project_cf.reload.is_for_all).to be_falsey + expect(required_boolean_project_cf.reload.is_for_all).to be_truthy + expect(required_calculated_project_cf.reload.is_for_all).to be_truthy + + # ignores non project required custom fields + + expect(required_cf.reload.is_for_all).to be_falsey + expect(optional_cf.reload.is_for_all).to be_falsey + + # Keeps the existing required values except for boolean and calculated values + # which are set to false + expect(required_project_cf.is_required).to be_truthy + expect(optional_project_cf.is_required).to be_falsey + expect(required_boolean_project_cf.is_required).to be_falsey + expect(required_calculated_project_cf.is_required).to be_falsey + + # This happens for all boolean fields + expect(required_boolean_wp_cf.reload.is_required).to be_falsey + expect(required_int_wp_cf.reload.is_required).to be_truthy + expect(required_boolean_te_cf.reload.is_required).to be_falsey + expect(required_int_te_cf.reload.is_required).to be_truthy + expect(required_boolean_user_cf.reload.is_required).to be_falsey + expect(required_int_user_cf.reload.is_required).to be_truthy + expect(required_boolean_group_cf.reload.is_required).to be_falsey + expect(required_int_group_cf.reload.is_required).to be_truthy + end +end diff --git a/spec/models/custom_field/calculated_value_spec.rb b/spec/models/custom_field/calculated_value_spec.rb index 0644a7b4a5e..d8b8ff133c4 100644 --- a/spec/models/custom_field/calculated_value_spec.rb +++ b/spec/models/custom_field/calculated_value_spec.rb @@ -249,6 +249,9 @@ RSpec.describe CustomField::CalculatedValue, let!(:other_calculated_value) do create(:calculated_value_project_custom_field, formula: "2 + 2", projects: [project_without_permission]) end + let!(:weighted_item_list) do + create(:project_custom_field, :weighted_item_list, projects: [project_without_permission]) + end current_user { user } diff --git a/spec/models/global_role_spec.rb b/spec/models/global_role_spec.rb index 06337879ff9..ba745764cd9 100644 --- a/spec/models/global_role_spec.rb +++ b/spec/models/global_role_spec.rb @@ -33,10 +33,6 @@ require "spec_helper" RSpec.describe GlobalRole do let!(:global_role) { create(:global_role, name: "globalrole", permissions: ["permissions"]) } - it { is_expected.to validate_presence_of :name } - it { is_expected.to validate_uniqueness_of :name } - it { is_expected.to validate_length_of(:name).is_at_most(256) } - describe "attributes" do subject(:role) { described_class.new } diff --git a/spec/models/project_custom_field_spec.rb b/spec/models/project_custom_field_spec.rb index 268356f1925..314d25e6add 100644 --- a/spec/models/project_custom_field_spec.rb +++ b/spec/models/project_custom_field_spec.rb @@ -32,12 +32,12 @@ require "spec_helper" RSpec.describe ProjectCustomField do describe "activation in projects" do - context "when creating a new required project custom field" do + context "when creating a new 'is_for_all' project custom field" do let!(:project) { create(:project) } let!(:another_project) { create(:project) } - it "activates the required project custom fields in all projects" do - project_custom_field = create(:project_custom_field, is_required: true) + it "activates the project custom fields in all projects" do + project_custom_field = create(:project_custom_field, is_for_all: true) expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, project_id: project.id) @@ -46,20 +46,38 @@ RSpec.describe ProjectCustomField do end end - context "when setting an existing project custom field to required" do + context "when creating a new project custom field" do + let!(:project) { create(:project) } + let!(:another_project) { create(:project) } + + it "activates the custom field in projects it is assigned to" do + # Same activation rules apply for optional and required custom fields: + optional_cf = create(:project_custom_field, projects: [project]) + required_cf = create(:project_custom_field, is_required: true, projects: [project]) + + [optional_cf, required_cf].each do |cf| + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: cf.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).not_to exist(custom_field_id: cf.id, + project_id: another_project.id) + end + end + end + + context "when setting an existing project custom field to is_for_all" do let!(:project_custom_field) { create(:string_project_custom_field) } # optional now let!(:project) do create(:project, custom_field_values: { "#{project_custom_field.id}": "foo" }) end let!(:another_project) { create(:project) } # not using the custom field - it "activates the required project custom fields in all projects where it is not already activated" do + it "activates for_all project custom fields in all projects where it is not already activated" do expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, project_id: project.id) expect(ProjectCustomFieldProjectMapping).not_to exist(custom_field_id: project_custom_field.id, project_id: another_project.id) - project_custom_field.update!(is_required: true) # required now + project_custom_field.update!(is_for_all: true) # forced active now expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, project_id: project.id) @@ -67,9 +85,9 @@ RSpec.describe ProjectCustomField do project_id: another_project.id) end - it "does not disabled project custom fields when set to optional" do - project_custom_field.update!(is_required: true) # required now - project_custom_field.update!(is_required: false) # optional again + it "does not disable project custom fields when set to optional" do + project_custom_field.update!(is_for_all: true) # forced active now + project_custom_field.update!(is_for_all: false) # optional again expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, project_id: project.id) @@ -78,7 +96,7 @@ RSpec.describe ProjectCustomField do end it "does not create duplicate mappings" do - project_custom_field.update!(is_required: true) # required now + project_custom_field.update!(is_for_all: true) # activated now # mapping existed before, should not be duplicated expect(ProjectCustomFieldProjectMapping.where(project_id: project.id, diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index dbb6ce11934..a83a690f2ce 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -84,7 +84,7 @@ RSpec.describe Project, "customizable" do context "with a custom field activated in different projects " \ "and the user has view_project_attributes permission in one of the project " \ - "and with a required custom field" do + "and with a custom field assigned to all projects" do let(:other_project) { create(:project) } let!(:project_cf) do # This custom field is enabled in both project and other_project to test that there is no @@ -96,8 +96,8 @@ RSpec.describe Project, "customizable" do end end - let!(:required_cf) do - create(:string_project_custom_field, is_required: true) + let!(:for_all_cf) do + create(:string_project_custom_field, is_for_all: true) end let(:user) do @@ -112,7 +112,7 @@ RSpec.describe Project, "customizable" do .to be_empty expect(other_project.available_custom_fields) - .to contain_exactly(project_cf, required_cf) + .to contain_exactly(project_cf, for_all_cf) end end end @@ -391,12 +391,14 @@ RSpec.describe Project, "customizable" do let!(:required_text_custom_field) do create(:text_project_custom_field, + is_for_all: true, is_required: true, project_custom_field_section: another_section) end let!(:required_calculated_custom_field) do create(:calculated_value_project_custom_field, + is_for_all: true, is_required: true, project_custom_field_section: another_section) end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb index 1412c5eb5fd..be0dc6d23b0 100644 --- a/spec/models/role_spec.rb +++ b/spec/models/role_spec.rb @@ -35,6 +35,10 @@ RSpec.describe Role do let(:build_role) { build(:project_role, permissions:) } let(:created_role) { create(:project_role, permissions:) } + it { is_expected.to validate_presence_of :name } + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + it { is_expected.to validate_length_of(:name).is_at_most(256) } + describe ".create" do it "is prevented for type Role" do build_role.type = described_class.name diff --git a/spec/models/work_package_role_spec.rb b/spec/models/work_package_role_spec.rb index f6451bde9d9..d8daaae1a2f 100644 --- a/spec/models/work_package_role_spec.rb +++ b/spec/models/work_package_role_spec.rb @@ -10,12 +10,6 @@ RSpec.describe WorkPackageRole do permissions: %w[permissions]) end - describe "validations" do - it { is_expected.to validate_presence_of :name } - it { is_expected.to validate_uniqueness_of :name } - it { is_expected.to validate_length_of(:name).is_at_most(256) } - end - describe "#member?" do it "is one (even though it is builtin)" do expect(work_package_role).to be_member diff --git a/spec/requests/api/v3/projects/create_resource_spec.rb b/spec/requests/api/v3/projects/create_resource_spec.rb index c24bb2f2fe9..e588cec7dba 100644 --- a/spec/requests/api/v3/projects/create_resource_spec.rb +++ b/spec/requests/api/v3/projects/create_resource_spec.rb @@ -119,11 +119,109 @@ RSpec.describe "API v3 Project resource create", content_type: :json do end describe "custom fields" do - context "with a required custom field" do + context "with an optional custom field" do + shared_let(:optional_custom_field) do + create(:text_project_custom_field, + name: "Department", + is_for_all: true) + end + + shared_examples "creates a project with an empty custom value" do + it "responds with 201" do + expect(last_response).to have_http_status(:created) + end + + it "returns the newly created project" do + expect(last_response.body) + .to be_json_eql("Project".to_json) + .at_path("_type") + + expect(last_response.body) + .to be_json_eql("Project name".to_json) + .at_path("name") + end + + it "creates a project with an empty custom field value" do + project = Project.last + expect(project.typed_custom_value_for(optional_custom_field)) + .to eq("") + end + + it "automatically activates the cf for project" do + expect(Project.last.project_custom_fields) + .to contain_exactly(optional_custom_field) + end + end + + context "when no custom field value is provided" do + let(:body) do + { + identifier: "new_project_identifier", + name: "Project name" + }.to_json + end + + it_behaves_like "creates a project with an empty custom value" + end + + context "when the custom field is provided but empty" do + let(:body) do + { + identifier: "new_project_identifier", + name: "Project name", + optional_custom_field.attribute_name(:camel_case) => { + raw: "" + } + }.to_json + end + + it_behaves_like "creates a project with an empty custom value" + end + + context "when the custom field value is provided and valid" do + let(:body) do + { + identifier: "new_project_identifier", + name: "Project name", + optional_custom_field.attribute_name(:camel_case) => { + raw: "Engineering" + } + }.to_json + end + + it "responds with 201" do + expect(last_response).to have_http_status(:created) + end + + it "returns the newly created project" do + expect(last_response.body) + .to be_json_eql("Project".to_json) + .at_path("_type") + + expect(last_response.body) + .to be_json_eql("Project name".to_json) + .at_path("name") + end + + it "creates a project with the custom field value" do + project = Project.last + expect(project.typed_custom_value_for(optional_custom_field)) + .to eq("Engineering") + end + + it "automatically activates the cf for project if the value was provided" do + expect(Project.last.project_custom_fields) + .to contain_exactly(optional_custom_field) + end + end + end + + context "with a required for_all custom field" do shared_let(:required_custom_field) do create(:text_project_custom_field, name: "Department", - is_required: true) + is_required: true, + is_for_all: true) end context "when no custom field value is provided" do diff --git a/spec/requests/api/v3/projects/index_resource_spec.rb b/spec/requests/api/v3/projects/index_resource_spec.rb index fdb65d53d95..7cc03f90f67 100644 --- a/spec/requests/api/v3/projects/index_resource_spec.rb +++ b/spec/requests/api/v3/projects/index_resource_spec.rb @@ -437,8 +437,8 @@ RSpec.describe "API v3 Project resource index", content_type: :json do create(:project_custom_field_project_mapping, project: public_wp_share_project) .project_custom_field end - shared_let(:required_cf) do - create(:string_project_custom_field, is_required: true) + shared_let(:for_all_cf) do + create(:string_project_custom_field, is_for_all: true) end shared_let(:current_user) do @@ -508,22 +508,22 @@ RSpec.describe "API v3 Project resource index", content_type: :json do ) end - it "returns the required_cf only for the other_project as a member " \ + it "returns the for_all_cf only for the other_project as a member " \ "with view_project_attributes" do expect(subject).not_to have_json_path( - "_embedded/elements/0/#{required_cf.attribute_name(:camel_case)}" + "_embedded/elements/0/#{for_all_cf.attribute_name(:camel_case)}" ) expect(subject).not_to have_json_path( - "_embedded/elements/1/#{required_cf.attribute_name(:camel_case)}" + "_embedded/elements/1/#{for_all_cf.attribute_name(:camel_case)}" ) expect(subject).not_to have_json_path( - "_embedded/elements/2/#{required_cf.attribute_name(:camel_case)}" + "_embedded/elements/2/#{for_all_cf.attribute_name(:camel_case)}" ) expect(subject).not_to have_json_path( - "_embedded/elements/3/#{required_cf.attribute_name(:camel_case)}" + "_embedded/elements/3/#{for_all_cf.attribute_name(:camel_case)}" ) expect(subject).to have_json_path( - "_embedded/elements/4/#{required_cf.attribute_name(:camel_case)}" + "_embedded/elements/4/#{for_all_cf.attribute_name(:camel_case)}" ) end end diff --git a/spec/requests/api/v3/workspaces/update_form_resource_examples.rb b/spec/requests/api/v3/workspaces/update_form_resource_examples.rb index 03dc1099a40..47eef2dbc76 100644 --- a/spec/requests/api/v3/workspaces/update_form_resource_examples.rb +++ b/spec/requests/api/v3/workspaces/update_form_resource_examples.rb @@ -379,6 +379,7 @@ RSpec.shared_examples_for "APIv3 workspace update form" do let!(:required_custom_field) do create(:text_project_custom_field, name: "Department", + is_for_all: true, is_required: true) end diff --git a/spec/requests/api/v3/workspaces/update_resource_examples.rb b/spec/requests/api/v3/workspaces/update_resource_examples.rb index 435a220808f..f2d9a99c9f4 100644 --- a/spec/requests/api/v3/workspaces/update_resource_examples.rb +++ b/spec/requests/api/v3/workspaces/update_resource_examples.rb @@ -90,10 +90,11 @@ RSpec.shared_examples_for "APIv3 workspace update" do end describe "custom fields" do - context "with a required custom field" do + context "with a required for_all custom field" do let!(:required_custom_field) do create(:text_project_custom_field, name: "Department", + is_for_all: true, is_required: true) end @@ -195,6 +196,19 @@ RSpec.shared_examples_for "APIv3 workspace update" do expect(workspace.project_custom_fields) .to contain_exactly(custom_field) end + + context "when the field is for_all, but not required" do + let!(:for_all_custom_field) do + create(:text_project_custom_field, + name: "Department", + is_for_all: true) + end + + it "automatically activates the cf for workspace even if no value was provided" do + expect(workspace.project_custom_fields) + .to contain_exactly(for_all_custom_field, custom_field) + end + end end context "with an admin only custom field" do @@ -277,6 +291,31 @@ RSpec.shared_examples_for "APIv3 workspace update" do .to be_empty end end + + context "and when the custom field is forced active (is_for_all)" do + let(:admin_only_custom_field) do + create(:text_project_custom_field, admin_only: true, is_for_all: true) + end + let(:body) do + { + name: "Updated workspace name" + } + end + + it "responds with 200 OK" do + expect(last_response).to have_http_status(:ok) + end + + it "does not set the cf value" do + expect(workspace.reload.typed_custom_value_for(admin_only_custom_field)) + .to be_nil + end + + it "does activate the cf for workspace" do + expect(workspace.reload.project_custom_fields) + .to contain_exactly(admin_only_custom_field) + end + end end end end diff --git a/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb b/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb index e905976c158..a4b3774ec11 100644 --- a/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb +++ b/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb @@ -52,6 +52,14 @@ RSpec.describe ProjectCustomFieldProjectMappings::BulkUpdateService do project_custom_field_section:) end + shared_let(:visible_activated_project_custom_field) do + create(:project_custom_field, + name: "Visible activated field", + admin_only: false, + is_for_all: true, + project_custom_field_section:) + end + shared_let(:invisible_project_custom_field) do create(:project_custom_field, name: "Admin only field", @@ -62,23 +70,23 @@ RSpec.describe ProjectCustomFieldProjectMappings::BulkUpdateService do context "with admin permissions" do let(:user) { create(:admin) } - it "bulk enables/disables all (non-required) fields of the section, including invisible ones" do - expect(project.project_custom_fields).to contain_exactly( - visible_required_project_custom_field - ) + it "bulk enables/disables all (non-for_all) fields of the section, including invisible ones" do + expect(project.project_custom_fields).to contain_exactly(visible_activated_project_custom_field) expect(instance.call(action: :enable)).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - visible_required_project_custom_field, visible_project_custom_field, invisible_project_custom_field - ) + expected = [ + visible_activated_project_custom_field, + visible_required_project_custom_field, + visible_project_custom_field, + invisible_project_custom_field + ] + expect(project.reload.project_custom_fields).to match_array(expected) expect(instance.call(action: :disable)).to be_success - # required fields cannot be disabled, even not by admins - expect(project.reload.project_custom_fields).to contain_exactly( - visible_required_project_custom_field - ) + # for_all fields cannot be disabled, even not by admins + expect(project.reload.project_custom_fields).to contain_exactly(visible_activated_project_custom_field) end end @@ -97,24 +105,27 @@ RSpec.describe ProjectCustomFieldProjectMappings::BulkUpdateService do end it "bulk enables/disables all fields of the section, excluding invisible ones" do - expect(project.project_custom_fields).to contain_exactly( - visible_required_project_custom_field - ) + expect(project.project_custom_fields).to contain_exactly(visible_activated_project_custom_field) expect(instance.call(action: :enable)).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - visible_required_project_custom_field, visible_project_custom_field - ) + expected = [ + visible_activated_project_custom_field, + visible_required_project_custom_field, + visible_project_custom_field + ] + expect(project.reload.project_custom_fields).to match_array(expected) project.project_custom_fields << invisible_project_custom_field expect(instance.call(action: :disable)).to be_success - # required fields cannot be disabled, invisible fields are not affected by non-admins - expect(project.reload.project_custom_fields).to contain_exactly( - visible_required_project_custom_field, invisible_project_custom_field - ) + # force-activated fields cannot be disabled, invisible fields are not affected by non-admins + expected = [ + visible_activated_project_custom_field, + invisible_project_custom_field + ] + expect(project.reload.project_custom_fields).to match_array(expected) end end @@ -132,15 +143,11 @@ RSpec.describe ProjectCustomFieldProjectMappings::BulkUpdateService do end it "cannot bulk enable/disable project custom fields" do - expect(project.project_custom_fields).to contain_exactly( - visible_required_project_custom_field - ) + expect(project.project_custom_fields).to contain_exactly(visible_activated_project_custom_field) expect(instance.call(action: :enable)).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - visible_required_project_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(visible_activated_project_custom_field) expect(instance.call(action: :disable)).to be_failure end diff --git a/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb index 79bed3f0c7a..cfe3745fda5 100644 --- a/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb +++ b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb @@ -46,12 +46,20 @@ RSpec.describe ProjectCustomFieldProjectMappings::ToggleService do shared_let(:required_custom_field) do create(:project_custom_field, - name: "Visible required field", + name: "Required field", admin_only: false, is_required: true, project_custom_field_section:) end + shared_let(:forced_active_custom_field) do + create(:project_custom_field, + name: "Visible forced-active field", + admin_only: false, + is_for_all: true, + project_custom_field_section:) + end + shared_let(:invisible_custom_field) do create(:project_custom_field, name: "Admin only field", @@ -61,71 +69,73 @@ RSpec.describe ProjectCustomFieldProjectMappings::ToggleService do let(:visible_custom_field_params) { { project_id: project.id, custom_field_id: visible_custom_field.id } } let(:required_custom_field_params) { { project_id: project.id, custom_field_id: required_custom_field.id } } + let(:forced_active_custom_field_params) { { project_id: project.id, custom_field_id: forced_active_custom_field.id } } let(:invisible_custom_field_params) { { project_id: project.id, custom_field_id: invisible_custom_field.id } } context "with admin permissions" do shared_let(:user) { create(:admin) } - it "toggles visible, non-required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "toggles visible, non-is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) 2.times do expect(instance.call(**visible_custom_field_params, value: "1")).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field, visible_custom_field - ) + expected = [forced_active_custom_field, visible_custom_field] + expect(project.reload.project_custom_fields).to match_array(expected) end 2.times do expect(instance.call(**visible_custom_field_params, value: "0")).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end end - it "toggles invisible, non-required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "toggles invisible, non-is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) 2.times do expect(instance.call(**invisible_custom_field_params, value: "1")).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field, invisible_custom_field - ) + expected = [forced_active_custom_field, invisible_custom_field] + expect(project.reload.project_custom_fields).to match_array(expected) end 2.times do expect(instance.call(**invisible_custom_field_params, value: "0")).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end end - it "does not toggle required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "does not toggle is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) - expect(instance.call(**required_custom_field_params, value: "1")).to be_failure + expect(instance.call(**forced_active_custom_field_params, value: "1")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) - expect(instance.call(**required_custom_field_params, value: "0")).to be_failure + expect(instance.call(**forced_active_custom_field_params, value: "0")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) + end + + it "does toggle required fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) + + 2.times do + expect(instance.call(**required_custom_field_params, value: "1")).to be_success + + expected = [forced_active_custom_field, required_custom_field] + expect(project.reload.project_custom_fields).to match_array(expected) + end + + 2.times do + expect(instance.call(**required_custom_field_params, value: "0")).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) + end end end @@ -143,62 +153,45 @@ RSpec.describe ProjectCustomFieldProjectMappings::ToggleService do }) end - it "toggles visible, non-required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "toggles visible, non-is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) 2.times do expect(instance.call(**visible_custom_field_params, value: "1")).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field, visible_custom_field - ) + expected = [forced_active_custom_field, visible_custom_field] + expect(project.reload.project_custom_fields).to match_array(expected) end 2.times do expect(instance.call(**visible_custom_field_params, value: "0")).to be_success - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end end it "does not toggle invisible, non-required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) expect(instance.call(**invisible_custom_field_params, value: "1")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) expect(instance.call(**invisible_custom_field_params, value: "0")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end - it "does not toggle required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "does not toggle is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) - expect(instance.call(**required_custom_field_params, value: "1")).to be_failure + expect(instance.call(**forced_active_custom_field_params, value: "1")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) - expect(instance.call(**required_custom_field_params, value: "0")).to be_failure + expect(instance.call(**forced_active_custom_field_params, value: "0")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end end @@ -215,58 +208,40 @@ RSpec.describe ProjectCustomFieldProjectMappings::ToggleService do }) end - it "does not toggle visible, non-required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "does not toggle visible, non-is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) expect(instance.call(**visible_custom_field_params, value: "1")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) expect(instance.call(**visible_custom_field_params, value: "0")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end - it "does not toggle invisible, non-required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "does not toggle invisible, non-is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) expect(instance.call(**invisible_custom_field_params, value: "1")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) expect(instance.call(**invisible_custom_field_params, value: "0")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end - it "does not toggle required fields" do - expect(project.project_custom_fields).to contain_exactly( - required_custom_field - ) + it "does not toggle is_for_all fields" do + expect(project.project_custom_fields).to contain_exactly(forced_active_custom_field) - expect(instance.call(**required_custom_field_params, value: "1")).to be_failure + expect(instance.call(**forced_active_custom_field_params, value: "1")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) - expect(instance.call(**required_custom_field_params, value: "0")).to be_failure + expect(instance.call(**forced_active_custom_field_params, value: "0")).to be_failure - expect(project.reload.project_custom_fields).to contain_exactly( - required_custom_field - ) + expect(project.reload.project_custom_fields).to contain_exactly(forced_active_custom_field) end end end diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index ea3bcc570d5..f4527f71015 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -166,16 +166,16 @@ RSpec.describe Projects::CreateService, type: :model do end end - context "with required custom fields", + context "with for_all custom fields", with_ee: %i[calculated_values], with_flag: { calculated_value_project_attribute: true } do let!(:calculated_custom_field) do create(:calculated_value_project_custom_field, project_custom_field_section: section) end - let!(:required_calculated_custom_field) do + let!(:for_all_calculated_custom_field) do create(:calculated_value_project_custom_field, - is_required: true, + is_for_all: true, project_custom_field_section: section) end @@ -183,10 +183,10 @@ RSpec.describe Projects::CreateService, type: :model do { custom_field_values: { text_custom_field.id => "foo" } } end - it "activates required calculated custom fields even if no value is provided" do + it "activates calculated custom fields even if no value is provided" do subject expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) - .to contain_exactly(required_calculated_custom_field.id, text_custom_field.id) + .to contain_exactly(for_all_calculated_custom_field.id, text_custom_field.id) end end @@ -230,7 +230,7 @@ RSpec.describe Projects::CreateService, type: :model do describe "calculated custom fields", with_ee: %i[calculated_values], with_flag: { calculated_value_project_attribute: true } do - shared_let(:cf_static) { create(:integer_project_custom_field, is_required: true) } + shared_let(:cf_static) { create(:integer_project_custom_field, is_for_all: true) } let(:project) { create(:project) } let!(:model_instance) { project } let(:stub_model_instance) { false } @@ -248,7 +248,7 @@ RSpec.describe Projects::CreateService, type: :model do context "when trying to explicitly set values of calculated custom fields" do let!(:cf_calculated) do create(:calculated_value_project_custom_field, - is_required: true, formula: "1 + 1") + is_for_all: true, formula: "1 + 1") end let(:project_attributes) do @@ -269,11 +269,11 @@ RSpec.describe Projects::CreateService, type: :model do context "when setting value of field referenced in calculated values" do let!(:cf_calculated1) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_static} * 7") + is_for_all: true, formula: "#{cf_static} * 7") end let!(:cf_calculated2) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_calculated1} * 11") + is_for_all: true, formula: "#{cf_calculated1} * 11") end let(:project_attributes) do @@ -296,11 +296,11 @@ RSpec.describe Projects::CreateService, type: :model do context "when not setting value of field referenced in calculated values" do let!(:cf_calculated1) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_static} * 7") + is_for_all: true, formula: "#{cf_static} * 7") end let!(:cf_calculated2) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_calculated1} * 11") + is_for_all: true, formula: "#{cf_calculated1} * 11") end let(:project_attributes) do @@ -326,15 +326,15 @@ RSpec.describe Projects::CreateService, type: :model do let!(:cf_c) { create(:integer_project_custom_field) } let!(:cf_calculated1) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_a} * 7") + is_for_all: true, formula: "#{cf_a} * 7") end let!(:cf_calculated2) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_b} * 11") + is_for_all: true, formula: "#{cf_b} * 11") end let!(:cf_calculated3) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_c} * 13") + is_for_all: true, formula: "#{cf_c} * 13") end let(:project_attributes) do @@ -362,7 +362,7 @@ RSpec.describe Projects::CreateService, type: :model do context "when intermediate calculated value field is not enabled" do let!(:cf_calculated1) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_static} * 7") + is_for_all: true, formula: "#{cf_static} * 7") end let!(:cf_calculated2) do create(:calculated_value_project_custom_field, :skip_validations, @@ -370,7 +370,7 @@ RSpec.describe Projects::CreateService, type: :model do end let!(:cf_calculated3) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_calculated2} * 13") + is_for_all: true, formula: "#{cf_calculated2} * 13") end let(:project_attributes) do @@ -400,15 +400,15 @@ RSpec.describe Projects::CreateService, type: :model do context "when intermediate calculated value field is for admin only" do let!(:cf_calculated1) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_static} * 7") + is_for_all: true, formula: "#{cf_static} * 7") end let!(:cf_calculated2) do create(:calculated_value_project_custom_field, :skip_validations, :admin_only, - is_required: true, formula: "#{cf_calculated1} * 11") + is_for_all: true, formula: "#{cf_calculated1} * 11") end let!(:cf_calculated3) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_calculated2} * 13") + is_for_all: true, formula: "#{cf_calculated2} * 13") end let(:project_attributes) do @@ -432,11 +432,11 @@ RSpec.describe Projects::CreateService, type: :model do context "when referenced value field is for admin only" do let!(:cf_calculated) do create(:calculated_value_project_custom_field, :skip_validations, - is_required: true, formula: "#{cf_static} * #{cf_referenced}") + is_for_all: true, formula: "#{cf_static} * #{cf_referenced}") end context "when referenced value is static" do - let!(:cf_referenced) { create(:integer_project_custom_field, :admin_only, is_required: true) } + let!(:cf_referenced) { create(:integer_project_custom_field, :admin_only, is_for_all: true) } let(:project_attributes) do { @@ -466,7 +466,7 @@ RSpec.describe Projects::CreateService, type: :model do context "when referenced value is calculated value without references" do let!(:cf_referenced) do create(:calculated_value_project_custom_field, :skip_validations, :admin_only, - is_required: true, formula: "2") + is_for_all: true, formula: "2") end let(:project_attributes) do @@ -494,10 +494,10 @@ RSpec.describe Projects::CreateService, type: :model do end context "when referenced value is calculated value with another reference" do - let!(:cf_referenced1) { create(:integer_project_custom_field, :admin_only, is_required: true) } + let!(:cf_referenced1) { create(:integer_project_custom_field, :admin_only, is_for_all: true) } let!(:cf_referenced) do create(:calculated_value_project_custom_field, :skip_validations, :admin_only, - is_required: true, formula: "-1 * #{cf_referenced1}") + is_for_all: true, formula: "-1 * #{cf_referenced1}") end let(:project_attributes) do diff --git a/spec/workers/custom_fields/recalculate_values_job_spec.rb b/spec/workers/custom_fields/recalculate_values_job_spec.rb index d477448380b..d9672d6baa6 100644 --- a/spec/workers/custom_fields/recalculate_values_job_spec.rb +++ b/spec/workers/custom_fields/recalculate_values_job_spec.rb @@ -66,7 +66,7 @@ RSpec.describe CustomFields::RecalculateValuesJob, type: :model do end context "when custom field is calculated value" do - context "and is not required" do + context "and is not activated for all projects" do context "with formula referencing other fields" do let!(:static) { create(:integer_project_custom_field, projects:) } let!(:custom_field) do @@ -115,9 +115,9 @@ RSpec.describe CustomFields::RecalculateValuesJob, type: :model do end end - context "and is required" do + context "and is activated for all projects" do context "with a static formula" do - let!(:custom_field) { create(:calculated_value_project_custom_field, formula: "1 + 1", is_required: true) } + let!(:custom_field) { create(:calculated_value_project_custom_field, formula: "1 + 1", is_for_all: true) } let(:custom_field_id) { custom_field.id } it "updates calculated values on all objects" do @@ -142,7 +142,7 @@ RSpec.describe CustomFields::RecalculateValuesJob, type: :model do context "with formula referencing other fields" do let!(:static) { create(:integer_project_custom_field, projects: [project1, project2, project3]) } let!(:custom_field) do - create(:calculated_value_project_custom_field, formula: "#{static} * 3", is_required: true) + create(:calculated_value_project_custom_field, formula: "#{static} * 3", is_for_all: true) end let(:custom_field_id) { custom_field.id }