mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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?
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user