Merge pull request #19226 from opf/implementation/64347-new-project-attribute-calculated-value-add-attribute

Implementation/64347 new project attribute calculated value
This commit is contained in:
Jens Ulferts
2025-07-01 13:07:56 +02:00
committed by GitHub
33 changed files with 833 additions and 64 deletions
+3
View File
@@ -390,6 +390,9 @@ gem "googleauth", require: false
# Required for contracts
gem "disposable", "~> 0.6.2"
# Used for formula evaluation of calculated values
gem "dentaku", "~> 3.5"
platforms :mri, :mingw, :x64_mingw do
group :postgres do
gem "pg", "~> 1.5.0"
+5
View File
@@ -464,6 +464,9 @@ GEM
deckar01-task_list (2.3.4)
html-pipeline (~> 2.0)
declarative (0.0.20)
dentaku (3.5.4)
bigdecimal
concurrent-ruby
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
diff-lcs (1.6.2)
@@ -1371,6 +1374,7 @@ DEPENDENCIES
dashboards!
date_validator (~> 0.12.0)
deckar01-task_list (~> 2.3.1)
dentaku (~> 3.5)
disposable (~> 0.6.2)
doorkeeper (~> 5.8.0)
dotenv-rails
@@ -1639,6 +1643,7 @@ CHECKSUMS
date_validator (0.12.0) sha256=68c9834da240347b9c17441c553a183572508617ebfbe8c020020f3192ce3058
deckar01-task_list (2.3.4) sha256=66abdc7e009ea759732bb53867e1ea42de550e2aa03ac30a015cbf42a04c1667
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
dentaku (3.5.4) sha256=0f897acf360776c43c3b3629134224abbf6a90a59084707af6e194c5d69ad9c7
descendants_tracker (0.0.4) sha256=e9c41dd4cfbb85829a9301ea7e7c48c2a03b26f09319db230e6479ccdc780897
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
disposable (0.6.3) sha256=7f2a3fb251bff6cd83f25b164043d4ec3531209b51b066ed476a9df9c2d384cc
@@ -0,0 +1,48 @@
<%#-- 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.
++#%>
<%=
component_wrapper do
flex_layout do |content|
content.with_row do
render BaseErrorsComponent.new(model, keys: %w[field_format], mb: 3)
end
content.with_row 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
end
%>
@@ -0,0 +1,53 @@
# 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 Admin::CustomFields::CalculatedValues
class DetailsComponent < ApplicationComponent
include ApplicationHelper
include OpPrimer::ComponentHelpers
include OpTurbo::Streamable
alias_method :custom_field, :model
private
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
def form_method
custom_field.new_record? ? :post : :put
end
end
end
@@ -29,7 +29,7 @@ See COPYRIGHT and LICENSE files for more details.
<%=
render(Primer::OpenProject::PageHeader.new(test_selector: "custom-fields--page-header")) do |header|
header.with_title { @custom_field.name }
header.with_title { @custom_field.attribute_in_database("name") }
header.with_breadcrumbs(breadcrumbs_items)
@@ -71,7 +71,7 @@ module Admin
[{ href: admin_index_path, text: t(:label_administration) },
{ href: custom_fields_path, text: t(:label_custom_field_plural) },
{ href: custom_fields_path(tab: @custom_field.type), text: I18n.t(@custom_field.type_name) },
@custom_field.name]
@custom_field.attribute_in_database("name")]
end
end
end
@@ -13,7 +13,7 @@
end
title_container.with_column(pt: 1, mr: 2, data: { test_selector: "custom-field-type" }) do
render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do
@project_custom_field.field_format.capitalize
helpers.label_for_custom_field_format(@project_custom_field.field_format)
end
end
if @project_custom_field.required?
@@ -19,7 +19,7 @@
end
content_container.with_column(mr: 2) do
render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do
@project_custom_field.field_format.capitalize
helpers.label_for_custom_field_format(@project_custom_field.field_format)
end
end
content_container.with_column(mr: 2) do
@@ -38,6 +38,7 @@ module Settings
@project_custom_field_section = project_custom_field_section
@project_custom_fields = project_custom_field_section.custom_fields
@first_and_last = first_and_last
end
@@ -28,7 +28,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<%=
render(Primer::OpenProject::PageHeader.new) do |header|
header.with_title { @custom_field.name }
header.with_title { @custom_field.attribute_in_database("name") }
header.with_description { t("settings.project_attributes.edit.description") }
header.with_breadcrumbs(breadcrumbs_items)
@@ -53,7 +53,7 @@ module Settings
[{ href: admin_index_path, text: t("label_administration") },
{ href: admin_settings_project_custom_fields_path, text: t("label_project_plural") },
{ href: admin_settings_project_custom_fields_path, text: t("settings.project_attributes.heading") },
@custom_field.name]
@custom_field.attribute_in_database("name")]
end
end
end
@@ -41,6 +41,7 @@ module CustomFields
attribute :name
attribute :possible_values
attribute :regexp
attribute :formula
attribute :searchable
attribute :admin_only
attribute :default_value
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -55,7 +57,7 @@ module Admin::Settings
def show
# quick fixing redirect issue from perform_update
# perform_update is always redirecting to the show action altough configured otherwise
# perform_update is always redirecting to the show action although configured otherwise
render :edit
end
@@ -0,0 +1,80 @@
# 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.text_field(
name: :formula,
value: model.formula_string,
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
end
end
end
+19 -3
View File
@@ -30,7 +30,8 @@
class CustomField < ApplicationRecord
include CustomField::OrderStatements
scope :required, -> { where(is_required: true) }
include CustomField::CalculatedValue
has_many :custom_values, dependent: :delete_all
# WARNING: the inverse_of option is also required in order
# for the 'touch: true' option on the custom_field association in CustomOption
@@ -49,6 +50,7 @@ class CustomField < ApplicationRecord
inverse_of: "custom_field"
scope :hierarchy_root_and_children, -> { includes(hierarchy_root: { children: :children }) }
scope :required, -> { where(is_required: true) }
acts_as_list scope: [:type]
@@ -68,8 +70,7 @@ class CustomField < ApplicationRecord
errors.add(:name, :taken) if name.in?(taken_names)
end
validates :field_format, inclusion: { in: -> { OpenProject::CustomFieldFormat.available_formats } }
validate :validate_field_format_inclusion
validate :validate_default_value
validate :validate_regex
@@ -105,6 +106,17 @@ class CustomField < ApplicationRecord
end
end
def validate_field_format_inclusion
available = OpenProject::CustomFieldFormat.available_formats
# When creating a new custom field, only the available formats are allowed.
# But you can edit and update existing custom fields, even if they have a field format that is disabled.
allowed = new_record? ? available : (available + OpenProject::CustomFieldFormat.disabled_formats).uniq
unless allowed.include?(field_format)
errors.add(:field_format, :inclusion)
end
end
def validate_default_value
# It is not possible to determine the validity of a value, when there is no valid format.
# another validation will take care of adding an error, but here we need to abort.
@@ -299,6 +311,10 @@ class CustomField < ApplicationRecord
field_format == "hierarchy"
end
def field_format_calculated_value?
field_format == "calculated_value"
end
def multi_value_possible?
OpenProject::CustomFieldFormat.find_by(name: field_format)&.multi_value_possible?
end
+101
View File
@@ -0,0 +1,101 @@
# 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.
#++
# Methods for custom fields with a format of "calculated value".
# Should be included in the CustomField model.
module CustomField::CalculatedValue
extend ActiveSupport::Concern
included do
validate :validate_formula
def validate_formula
return unless field_format_calculated_value?
if formula_string.blank?
errors.add(:formula, :blank)
return
end
unless formula_contains_only_allowed_characters?
errors.add(:formula, :invalid_characters)
return
end
# WP-64348: check for valid (i.e., visible & enabled) custom field references (see #cf_ids_used_in_formula)
# Dentaku will return nil if the formula is invalid.
# TODO WP-64348: add support for referenced custom fields by injecting them as variables,
# e.g. calculator.evaluate(formula_string, cf_123: CustomField.find(123).value)
errors.add(:formula, :invalid) unless Dentaku::Calculator.new.evaluate(formula_string)
# TODO: consider differentiating between a formula that contains missing variables, invalid
# syntax, or mathematical errors.
end
def formula=(value)
if value.is_a?(String)
super({ formula: value, referenced_custom_fields: cf_ids_used_in_formula(value) })
else
super
end
end
# Returns the formula as a string. Will return an empty string if the formula is not set.
def formula_string
formula ? formula.fetch("formula", "") : ""
end
private
def formula_contains_only_allowed_characters?
# List of allowed characters in a formula. This only performs a very basic validation.
# Allowed characters are:
# + - / * ( ) whitespace digits and decimal points
# Additionally, the formula may contain references to custom fields in the form of `cf_123` where 123 is the ID of
# the custom field.
# Once this basic validation passes, the formula will be parsed and validated by Dentaku, which builds an AST
# and ensures that the formula is really valid. A welcome side effect of the basic validation done here is that
# it prevents built-in functions from being used in the formula, which we do not want to allow.
allowed_chars = %w[+ - / * ( )] + [" "]
allowed_tokens = /\A(cf_\d+|\d+\.?\d*|\.\d+)\z/
formula_string.split(Regexp.union(allowed_chars)).reject(&:empty?).all? do |token|
token.match?(allowed_tokens)
end
end
# Returns a list of custom field IDs used in the formula.
# For a formula like `2 + cf_12 + cf_4` it returns `[12, 4]`.
def cf_ids_used_in_formula(formula_str)
formula_str.scan(/\bcf_(\d+)\b/).flatten.map(&:to_i)
end
end
end
+1
View File
@@ -481,6 +481,7 @@ class PermittedParams
custom_field: [
:editable,
:field_format,
:formula,
:is_filter,
:is_for_all,
:is_required,
@@ -38,18 +38,22 @@ 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) %>
<% 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 %>
<% content_controller "admin--custom-fields",
"admin--custom-fields-format-value": @custom_field.field_format,
"admin--custom-fields-format-config-value": OpenProject::CustomFieldFormatDependent.stimulus_config %>
<%= labelled_tabular_form_for @custom_field, as: :custom_field,
url: admin_settings_project_custom_field_path(@custom_field),
html: { method: :put, id: "custom_field_form" } do |f| %>
<%= render partial: "custom_fields/form", locals: { f: f, custom_field: @custom_field } %>
<% if @custom_field.new_record? %>
<%= hidden_field_tag "type", @custom_field.type %>
<%= labelled_tabular_form_for @custom_field, as: :custom_field,
url: admin_settings_project_custom_field_path(@custom_field),
html: { method: :put, id: "custom_field_form" } do |f| %>
<%= render partial: "custom_fields/form", locals: { f: f, custom_field: @custom_field } %>
<% if @custom_field.new_record? %>
<%= hidden_field_tag "type", @custom_field.type %>
<% end %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
@@ -31,18 +31,22 @@ See COPYRIGHT and LICENSE files for more details.
<%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new) %>
<%= error_messages_for "custom_field" %>
<% if @custom_field.field_format_calculated_value? %>
<%= render Admin::CustomFields::CalculatedValues::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 %>
<% content_controller "admin--custom-fields",
"admin--custom-fields-format-value": @custom_field.field_format,
"admin--custom-fields-format-config-value": OpenProject::CustomFieldFormatDependent.stimulus_config %>
<%= labelled_tabular_form_for @custom_field, as: :custom_field,
url: admin_settings_project_custom_fields_path,
html: { id: "custom_field_form" } do |f| %>
<%= render partial: "custom_fields/form", locals: { f: f, custom_field: @custom_field } %>
<% if @custom_field.new_record? %>
<%= hidden_field_tag "type", @custom_field.type %>
<%= labelled_tabular_form_for @custom_field, as: :custom_field,
url: admin_settings_project_custom_fields_path,
html: { id: "custom_field_form" } do |f| %>
<%= render partial: "custom_fields/form", locals: { f: f, custom_field: @custom_field } %>
<% if @custom_field.new_record? %>
<%= hidden_field_tag "type", @custom_field.type %>
<% end %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
<%= styled_button_tag t(:button_save), class: "-highlight -with-icon icon-checkmark" %>
<% end %>
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -88,4 +90,12 @@ OpenProject::CustomFieldFormat.map do |fields|
multi_value_possible: true,
enterprise_feature: :custom_field_hierarchies,
formatter: "CustomValue::HierarchyStrategy")
fields.register OpenProject::CustomFieldFormat.new("calculated_value",
label: :label_calculated_value,
only: %w(Project),
order: 13,
enabled: lambda do
OpenProject::FeatureDecisions.calculated_value_project_attribute_active?
end)
end
+5
View File
@@ -302,6 +302,7 @@ en:
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."
tab:
no_results_title_text: There are currently no custom fields.
@@ -980,6 +981,7 @@ en:
default_value: "Default value"
editable: "Editable"
field_format: "Format"
formula: "Formula"
is_filter: "Used as a filter"
is_for_all: "For all projects"
is_required: "Required"
@@ -1274,6 +1276,7 @@ en:
inclusion: "is not set to one of the allowed values."
inclusion_nested: "is not set to one of the allowed values at path '%{path}'."
invalid: "is invalid."
invalid_characters: "contains invalid characters."
invalid_url: "is not a valid URL."
invalid_url_scheme: "is not a supported protocol (allowed: %{allowed_schemes})."
less_than_or_equal_to: "must be less than or equal to %{count}."
@@ -2727,6 +2730,7 @@ en:
label_select_main_menu_item: Select new main menu item
label_required_disk_storage: "Required disk storage"
label_send_invitation: Send invitation
label_calculated_value: "Calculated value"
label_change_plural: "Changes"
label_change_properties: "Change properties"
label_change_status: "Change status"
@@ -2862,6 +2866,7 @@ en:
label_follows: "follows"
label_force_user_language_to_default: "Set language of users having a non allowed language to default"
label_form_configuration: "Form configuration"
label_formula: "Formula"
label_gantt_chart: "Gantt chart"
label_gantt_chart_plural: "Gantt charts"
label_general: "General"
@@ -0,0 +1,37 @@
# 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 AddFormulaToCustomFields < ActiveRecord::Migration[8.0]
def change
add_column :custom_fields, :formula, :jsonb, null: true
add_index :custom_fields, :formula, using: :gin
end
end
+19 -2
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -41,6 +43,7 @@ module OpenProject
only: nil,
multi_value_possible: false,
enterprise_feature: nil,
enabled: lambda { true },
formatter: "CustomValue::StringStrategy")
@name = name
@label = label
@@ -49,6 +52,7 @@ module OpenProject
@class_names = only
@multi_value_possible = multi_value_possible
@enterprise_feature = enterprise_feature
@enabled = enabled
@formatter = formatter
end
@@ -61,6 +65,14 @@ module OpenProject
Kernel.const_get(@formatter)
end
def enabled?
@enabled.call
end
def disabled?
!enabled?
end
class << self
def map(&)
yield self
@@ -72,8 +84,9 @@ module OpenProject
end
def available
registered
.select { |_, format| !format.enterprise_feature || EnterpriseToken.allows_to?(format.enterprise_feature) }
registered.select do |_, format|
format.enabled? && (!format.enterprise_feature || EnterpriseToken.allows_to?(format.enterprise_feature))
end
end
def available_formats
@@ -91,6 +104,10 @@ module OpenProject
.sort_by(&:order)
.reject { |format| format.label.nil? }
end
def disabled_formats
registered.select { |_, format| format.disabled? }.keys
end
end
end
end
@@ -32,12 +32,13 @@ module OpenProject
allowNonOpenVersions: [:only, %w[version]],
defaultBool: [:only, %w[bool]],
defaultLongText: [:only, %w[text]],
defaultText: [:except, %w[list bool date text user version hierarchy]],
length: [:except, %w[list bool date user version link hierarchy]],
defaultText: [:except, %w[list bool date text user version hierarchy calculated_value]],
length: [:except, %w[list bool date user version link hierarchy calculated_value]],
multiSelect: [:only, %w[list user version hierarchy]],
possibleValues: [:only, %w[list]],
regexp: [:except, %w[list bool date user version hierarchy]],
searchable: [:except, %w[bool date float int user version hierarchy]],
regexp: [:except, %w[list bool date user version hierarchy calculated_value]],
formula: [:only, %w[calculated_value]],
searchable: [:except, %w[bool date float int user version hierarchy calculated_value]],
textOrientation: [:only, %w[text]],
enterpriseBanner: [:only, %w[hierarchy]]
}.freeze
@@ -49,11 +49,7 @@ RSpec.describe ProjectCustomFieldProjectMappings::BaseContract do
end
context "with non-visible custom field and admin user" do
let(:project_custom_field) { build_stubbed(:project_custom_field, admin_only: true) }
before do
allow(ProjectCustomField).to receive(:all).and_return([project_custom_field])
end
let(:project_custom_field) { create(:project_custom_field, admin_only: true) }
it_behaves_like "contract is valid"
end
+6 -1
View File
@@ -81,6 +81,10 @@ FactoryBot.define do
field_format { "int" }
end
trait :calculated_value do
field_format { "calculated_value" }
end
trait :float do
field_format { "float" }
end
@@ -96,7 +100,7 @@ FactoryBot.define do
end
field_format { "list" }
multi_value { false }
possible_values { ["A", "B", "C", "D", "E", "F", "G"] }
possible_values { %w[A B C D E F G] }
# update custom options default value from the default_option transient
# field for non-multiselect field
@@ -203,6 +207,7 @@ FactoryBot.define do
factory :string_project_custom_field, traits: [:string]
factory :text_project_custom_field, traits: [:text]
factory :integer_project_custom_field, traits: [:integer]
factory :calculated_value_project_custom_field, traits: [:calculated_value]
factory :float_project_custom_field, traits: [:float]
factory :date_project_custom_field, traits: [:date]
factory :list_project_custom_field, traits: [:list]
@@ -0,0 +1,143 @@
# 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 field calculated value", :js, with_flag: { calculated_value_project_attribute: true } do
include_context "with seeded project custom fields"
let!(:calculated_value) do
create(:calculated_value_project_custom_field,
name: "Calculated value field",
formula: "42 + 1",
project_custom_field_section: section_for_input_fields)
end
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
end
context "with sufficient permissions" do
before do
login_as(admin)
visit edit_admin_settings_project_custom_field_path(calculated_value)
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(calculated_value.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 calculated value" do
expect(page).to have_css(".PageHeader-title", text: calculated_value.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")
fill_in("custom_field_formula", with: "1 + 1", fill_options: { clear: :backspace })
click_on "Save"
expect(page).to have_text("Successful update")
expect(page).to have_css(".PageHeader-title", text: "Updated name")
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")
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 calculated value with an empty name" do
original_name = calculated_value.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(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
fill_in("custom_field_formula", with: "")
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
end
context "without the feature flag", with_flag: { calculated_value_project_attribute: false } do
let!(:calculated_value) { nil }
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")
fill_in("custom_field_formula", with: "1 + 1")
click_on "Save"
expect(page).to have_text("Format is not set to one of the allowed values.")
end.not_to change(CustomField, :count)
end
end
end
@@ -157,6 +157,56 @@ RSpec.describe "List project custom fields", :js do
end
describe "managing project custom fields" do
context "with calculated value feature flag active", with_flag: { calculated_value_project_attribute: true } do
it "offers the type for creation" do
cf_index_page.expect_having_create_item("Calculated value")
end
context "with fields of type calculated value" do
let!(:calculated_value_project_custom_field) do
create(:calculated_value_project_custom_field,
name: "Calculated value field",
formula: "42 + 1",
project_custom_field_section: section_for_input_fields)
end
before do
login_as(admin)
cf_index_page.visit!
end
it "lists the calculated value custom field" do
within_project_custom_field_section_container(section_for_input_fields) do
containers = page.all(".op-project-custom-field-container")
expect(containers.last.text).to include(calculated_value_project_custom_field.name)
end
end
it "lists calculated values even if the feature flag is deactivated later" do
# This spec tests that calculated values are still shown after the feature flag is deactivated.
# First, a custom field of type calculated value is created. This must be done while the feature flag is active,
# or else the model validation will fail.
# Next, we simulate that the feature flag is off:
allow(OpenProject::FeatureDecisions).to receive(:calculated_value_project_attribute_active?).and_return(false)
# Revisit the page and check that the field is still listed:
cf_index_page.visit!
within_project_custom_field_section_container(section_for_input_fields) do
containers = page.all(".op-project-custom-field-container")
expect(containers.last.text).to include(calculated_value_project_custom_field.name)
end
end
end
end
context "without calculated value feature flag active" do
it "does not offer the type for creation" do
cf_index_page.expect_not_having_create_item("Calculated value")
end
end
it "shows all custom fields in the correct order within their section and allows reordering via menu or drag and drop" do
within_project_custom_field_section_container(section_for_input_fields) do
containers = page.all(".op-project-custom-field-container")
@@ -142,7 +142,7 @@ RSpec.describe "Projects custom fields mapping via project settings", :js do
end
within_custom_field_container(string_project_custom_field) do
expect(page).to have_content("String field")
expect_type("String")
expect_type("Text")
expect_unchecked_state
end
end
@@ -41,66 +41,95 @@ RSpec.describe OpenProject::CustomFieldFormat do
end
context "for a 'Project' class" do
it_behaves_like "custom field formats",
"Project",
["bool", "date", "float", "int", "link", "list", "string", "text", "user", "version"]
context "with calculated values feature flag enabled", with_flag: { calculated_value_project_attribute: true } do
it_behaves_like "custom field formats",
"Project",
%w[bool calculated_value date float int link list string text user version]
end
context "with calculated values feature flag disabled" do
it_behaves_like "custom field formats",
"Project",
%w[bool date float int link list string text user version]
end
end
context "for a 'WorkPackage' class" do
context "with a custom_field_hierarchies ee", with_ee: [:custom_field_hierarchies] do
it_behaves_like "custom field formats",
"WorkPackage",
["bool", "date", "float", "int", "link", "list", "string", "text", "user", "version", "hierarchy"]
%w[bool date float int link list string text user version hierarchy]
end
context "without a custom_field_hierarchies ee" do
it_behaves_like "custom field formats",
"WorkPackage",
["bool", "date", "float", "int", "link", "list", "string", "text", "user", "version"]
%w[bool date float int link list string text user version]
end
end
context "for a 'Version' class" do
it_behaves_like "custom field formats",
"Version",
["bool", "date", "float", "int", "list", "string", "text", "user", "version"]
%w[bool date float int list string text user version]
end
context "for a 'TimeEntry' class" do
it_behaves_like "custom field formats",
"TimeEntry",
["bool", "date", "float", "int", "list", "string", "text", "user", "version"]
%w[bool date float int list string text user version]
end
context "for a 'User' class" do
it_behaves_like "custom field formats",
"User",
["bool", "date", "float", "int", "list", "string", "text"]
%w[bool date float int list string text]
end
context "for a 'Group' class" do
it_behaves_like "custom field formats",
"Group",
["bool", "date", "float", "int", "list", "string", "text"]
%w[bool date float int list string text]
end
end
describe ".available_formats" do
context "with a custom_field_hierarchies ee", with_ee: [:custom_field_hierarchies] do
it "returns all custom field formats including hierarchy" do
shared_examples_for "available custom field formats" do |suffix, expected_formats|
it "returns all custom field formats #{suffix}", :aggregate_failures do
formats = described_class.available_formats
expect(formats)
.to contain_exactly("bool", "date", "float", "int", "link", "list", "string", "text", "user",
"version", "hierarchy", "empty")
expect(formats).to match_array(expected_formats)
end
end
context "with a custom_field_hierarchies ee", with_ee: [:custom_field_hierarchies] do
it_behaves_like "available custom field formats",
"including hierarchy",
%w[bool date float int link list string text user version hierarchy empty]
end
context "without a custom_field_hierarchies ee" do
it "returns all custom field formats excluding hierarchy" do
formats = described_class.available_formats
expect(formats)
.to contain_exactly("bool", "date", "float", "int", "link", "list", "string", "text", "user",
"version", "empty")
it_behaves_like "available custom field formats",
"excluding hierarchy",
%w[bool date float int link list string text user version empty]
context "with calculated values feature flag enabled", with_flag: { calculated_value_project_attribute: true } do
it_behaves_like "available custom field formats",
"including calculated values",
%w[bool calculated_value date float int link list string text user version empty]
end
end
end
describe ".disabled_formats" do
it "returns disabled formats" do
formats = described_class.disabled_formats
expect(formats).to match_array(%w[calculated_value])
end
context "with calculated values feature flag enabled", with_flag: { calculated_value_project_attribute: true } do
it "returns no disabled formats" do
formats = described_class.disabled_formats
expect(formats).to be_empty
end
end
end
@@ -0,0 +1,128 @@
# 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 CustomField::CalculatedValue, with_flag: { calculated_value_project_attribute: true } do
subject(:custom_field) { create(:calculated_value_project_custom_field, formula: "1 + 1") }
describe "#formula=" do
it "splits formula and referenced custom fields on persist if given a string" do
formula = "1 * cf_7 + cf_42"
subject.formula = formula
expect(subject.formula).to eq({ "formula" => formula, "referenced_custom_fields" => [7, 42] })
end
it "omits referenced custom fields if none are given" do
formula = "2 + 3 * (8 / 7)"
subject.formula = formula
expect(subject.formula).to eq({ "formula" => formula, "referenced_custom_fields" => [] })
end
end
describe "#formula_string" do
it "returns an empty string if no formula is set" do
subject.formula = nil
expect(subject.formula_string).to eq("")
end
it "returns the formula as a string" do
formula = "1 * cf_7 + cf_42"
subject.formula = formula
expect(subject.formula_string).to eq(formula)
end
end
describe "#validate_formula" do
shared_examples_for "valid formula" do
it "is valid", :aggregate_failures do
subject.formula = formula
subject.validate_formula
expect(subject).to be_valid
end
end
shared_examples_for "invalid formula" do |error_message|
it "is invalid", :aggregate_failures do
subject.formula = formula
subject.validate_formula
expect(subject).not_to be_valid
expect(subject.errors[:formula]).to include(error_message)
end
end
let(:formula) { "" }
context "with an empty formula" do
it_behaves_like "invalid formula", "can't be blank."
end
context "with a formula containing only allowed characters" do
let(:formula) { "1 / 2 + (3 * 4.5) - 0.0" }
it_behaves_like "valid formula"
end
context "when omitting leading decimals before a decimal point" do
let(:formula) { "1.5 + .0 - 3.25" }
it_behaves_like "valid formula"
end
context "when omitting trailing decimals after a decimal point" do
let(:formula) { "1.5 + 1. - 3.25" }
it_behaves_like "invalid formula", "is invalid."
end
context "with a formula containing forbidden characters" do
let(:formula) { "abc + 2" }
it_behaves_like "invalid formula", "contains invalid characters."
end
context "with a formula that is not a valid equation" do
let(:formula) { "1 / + - 3" }
it_behaves_like "invalid formula", "is invalid."
end
context "with a formula that causes a division by zero error" do
let(:formula) { "1 / 0" }
it_behaves_like "invalid formula", "is invalid."
end
end
end
+9
View File
@@ -485,6 +485,15 @@ RSpec.describe CustomField do
end
end
context "with a project calculated value cf" do
let(:field) { build_stubbed(:calculated_value_project_custom_field) }
it "is false" do
expect(field)
.not_to be_multi_value_possible
end
end
context "with a time_entry user cf" do
let(:field) { build_stubbed(:time_entry_custom_field, :user) }
@@ -74,6 +74,26 @@ module Pages
click_on type
end
def expect_having_create_item(type)
wait_for_network_idle
click_button "Add"
click_button "Project attribute"
expect(page).to have_link(type)
end
def expect_not_having_create_item(type)
wait_for_network_idle
click_button "Add"
click_button "Project attribute"
expect(page).to have_no_link(type)
end
end
end
end