Merge pull request #21972 from opf/feature/71380-inplace-edit-for-project-attributes-on-project-overview-page

[71380] Inplace edit for project attributes on project overview page
This commit is contained in:
Henriette Darge
2026-03-23 11:50:31 +01:00
committed by GitHub
84 changed files with 3923 additions and 1253 deletions
@@ -1,20 +1,25 @@
<%= component_wrapper(tag: :div, class: "op-inplace-edit", data: { test_selector: wrapper_test_selector }) do %>
<% if display_field_component.present? && !enforce_edit_mode %>
<%= component_wrapper(
tag: :div,
class: "op-inplace-edit",
uniq_by: wrapper_uniq_by,
data: {
test_selector: wrapper_test_selector,
turbo_stream_target: wrapper_id,
inplace_edit_stable_key: wrapper_uniq_by,
inplace_edit_system_arguments: @system_arguments.to_json
}
) do %>
<% if display_field_component.present? && (!enforce_edit_mode || !writable?) %>
<%= render display_field_component %>
<% else %>
<%= primer_form_with(
model:,
url: inplace_edit_field_update_path(model: model.class.name, id: model.id, attribute:),
method: :patch,
data: { turbo_stream: true }
) do |form|
render_field_component = ->(f) { render edit_field_component(f) } # The render_inline_form method looses context and thus does not know about the `field_component` method
system_arguments = @system_arguments
<%= primer_form_with(**form_options) do |form|
render_field_component = ->(f) { render edit_field_component(f) } # The render_inline_form method looses context and thus does not know about the `field_component` method
system_arguments = @system_arguments
render_inline_form(form) do |f|
f.hidden name: "system_arguments_json", value: system_arguments.to_json
render_field_component.call(f)
end
render_inline_form(form) do |f|
f.hidden name: "system_arguments_json", value: system_arguments.to_json
render_field_component.call(f)
end
end %>
<% end %>
<% end %>
@@ -33,21 +33,38 @@ module OpenProject
class InplaceEditFieldComponent < ViewComponent::Base
include OpTurbo::Streamable
attr_reader :model, :attribute, :enforce_edit_mode
attr_reader :model, :attribute, :enforce_edit_mode, :open_in_dialog, :show_action_buttons, :truncated
def initialize(model:, attribute:, enforce_edit_mode: false,
update_registry: OpenProject::InplaceEdit::UpdateRegistry.default, **system_arguments)
def initialize(model:,
attribute:,
enforce_edit_mode: false,
open_in_dialog: false,
show_action_buttons: true,
truncated: false,
update_registry: OpenProject::InplaceEdit::UpdateRegistry.default,
**system_arguments)
super()
@model = model
@attribute = attribute
@enforce_edit_mode = enforce_edit_mode
@open_in_dialog = open_in_dialog
@show_action_buttons = show_action_buttons
@truncated = truncated
@update_registry = update_registry
@system_arguments = system_arguments
@system_arguments[:id] = system_arguments[:id] || SecureRandom.uuid
@system_arguments[:required] ||= required?
@system_arguments[:label] ||= field_label
@system_arguments[:truncated] = truncated
end
def field_class
OpenProject::InplaceEdit::FieldRegistry.fetch(attribute)
if custom_field?
OpenProject::InplaceEdit::FieldRegistry.fetch_for_custom_field_format(custom_field&.field_format)
else
OpenProject::InplaceEdit::FieldRegistry.fetch(attribute)
end
end
def edit_field_component(form)
@@ -55,6 +72,7 @@ module OpenProject
form:,
attribute:,
model:,
show_action_buttons:,
**@system_arguments
)
end
@@ -70,20 +88,102 @@ module OpenProject
def display_field_component
return nil if display_field_class.nil?
display_field_class.new(model:, attribute:, writable: writable?, **@system_arguments)
@display_field_component ||= build_display_field_component
end
def wrapper_key
model_class = @model.class.name.parameterize(separator: "_")
"op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}--#{@system_arguments[:id]}"
end
def wrapper_test_selector
"op-inplace-edit-field"
"op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}"
end
def wrapper_uniq_by
"#{model_class}_#{@model.id}_#{@attribute}"
end
def form_id
@system_arguments[:form_id]
end
def wrapper_id
@system_arguments[:wrapper_id]
end
def form_options
options = {
model: @model,
url: inplace_edit_field_update_path(
model: @model.class.name,
id: @model.id,
attribute: @attribute
),
method: :patch,
data: { turbo_stream: true,
test_selector: "op-inplace-edit-field--form" }
}
options[:id] = form_id if form_id.present?
options
end
def open_in_dialog?
@open_in_dialog || field_class.open_in_dialog? || (custom_field? && custom_field&.has_comment?)
end
def dialog_edit_url
return unless open_in_dialog?
inplace_edit_field_dialog_path(
model: model.class.name,
id: model.id,
attribute:,
system_arguments_json: @system_arguments
.except(:id)
.merge(page_component_id: @system_arguments[:id], writable: writable?)
.to_json
)
end
def model_class
@model_class ||= @model.class.name.parameterize(separator: "_")
end
private
def build_display_field_component
has_comment = custom_field? && custom_field&.has_comment?
additional_args = open_in_dialog? ? dialog_display_arguments : {}
display_field_class.new(
model:,
attribute:,
writable: writable?,
truncated:,
has_comment:,
# Show comment as read-only text when a non-writable user opens the dialog.
# enforce_edit_mode identifies the dialog context.
show_comment: enforce_edit_mode && !writable? && has_comment,
**@system_arguments.merge(additional_args)
)
end
def dialog_trigger_arguments
{
dialog_controller_name: "inplace-edit",
dialog_url: dialog_edit_url
}
end
# When inside a dialog and the field is not writable, strip dialog trigger args
# to prevent opening a nested dialog from the display component.
def dialog_display_arguments
return {} if enforce_edit_mode && !writable?
dialog_trigger_arguments
end
def writable?
return @writable if defined?(@writable)
@@ -95,6 +195,40 @@ module OpenProject
false
end
end
def field_label
# Check if this is a custom field attribute
if custom_field? && custom_field
return custom_field.name
end
label = model.class.human_attribute_name(attribute)
label = label.titleize if attribute.to_s.include?("_")
label
end
def required?
return @required if instance_variable_defined?(:@required)
@required = if @system_arguments.key?(:required)
@system_arguments[:required]
elsif custom_field?
# For custom fields, check the is_required attribute
custom_field&.is_required || false
else
false
end
end
def custom_field?
attribute.to_s.start_with?("custom_field_")
end
def custom_field
return @custom_field if defined?(@custom_field)
@custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i)
end
end
end
end
@@ -0,0 +1,38 @@
<%=
render(
Primer::Alpha::Dialog.new(
title: dialog_title,
classes: "Overlay--size-large-portrait op-inplace-edit--dialog",
size: :large,
id: dialog_id
)
) do |d|
d.with_header(variant: :large)
d.with_body(classes: "Overlay-body_autocomplete_height",
test_selector: "async-dialog-content") do
render(edit_component)
end
d.with_footer do
component_collection do |footer_collection|
footer_collection.with_component(
Primer::Beta::Button.new(data: { "close-dialog-id": dialog_id })
) do
writable? ? t("button_cancel") : t("button_close")
end
if writable?
footer_collection.with_component(
Primer::Beta::Button.new(scheme: :primary,
type: :submit,
form: form_id,
data: {
test_selector: "save-inplace-edit-field-button",
turbo: true
})
) do
t("button_save")
end
end
end
end
end
%>
@@ -28,43 +28,53 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Overviews
module ProjectCustomFields
class DialogComponent < ApplicationComponent
include ApplicationHelper
module OpenProject
module Common
class InplaceEditFieldDialogComponent < ViewComponent::Base
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def initialize(project:, project_custom_field:)
super
@project = project
@project_custom_field = project_custom_field
def initialize(model:, attribute:, system_arguments: {})
super()
@model = model
@attribute = attribute
@system_arguments = system_arguments
end
private
def writable?
@system_arguments[:writable] == true
end
def dialog_title
@project_custom_field.project_custom_field_section.name
@system_arguments[:label] || @model.class.human_attribute_name(@attribute)
end
def dialog_id
"project-custom-field-dialog-#{@project_custom_field.id}"
model_class = @model.class.name.parameterize(separator: "_")
"inplace-edit-field-dialog--#{model_class}-#{@model.id}--#{@attribute}"
end
def wrapper_id
"##{dialog_id}"
end
def body_component
fail NoMethodError, "Must be overridden in subclass"
def form_id
"inplace-edit-field-form-#{dialog_id}"
end
def close_button_title
fail NoMethodError, "Must be overridden in subclass"
end
def footer_buttons(footer_collection)
# noop
def edit_component
OpenProject::Common::InplaceEditFieldComponent.new(
model: @model,
attribute: @attribute,
enforce_edit_mode: true,
**@system_arguments.merge(
wrapper_id:,
form_id:,
show_action_buttons: false
)
)
end
end
end
@@ -0,0 +1,88 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
module InplaceEditFields
class BaseFieldComponent < ViewComponent::Base
include Primer::AttributesHelper
attr_reader :form, :attribute, :model, :show_action_buttons
def self.display_class
DisplayFields::DisplayFieldComponent
end
def self.open_in_dialog?
false
end
def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments)
super()
@form = form
@attribute = attribute
@model = model
@show_action_buttons = show_action_buttons
@system_arguments = system_arguments
end
def comment_field_if_enabled(form)
return unless show_comment_field?
form.text_area(name: "#{model.class.model_name.param_key}[custom_comments][#{custom_field.id}]",
scope_name_to_model: false,
label: I18n.t("attributes.comment"),
value: model.custom_comment_for(custom_field)&.text,
rows: 5)
end
def show_comment_field?
custom_field? && custom_field&.has_comment?
end
def custom_field?
attribute.to_s.start_with?("custom_field_")
end
def custom_field
return @custom_field if defined?(@custom_field)
@custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i)
end
def qa_field_name
if custom_field?
"custom-field-#{custom_field.id}"
end
end
end
end
end
end
@@ -0,0 +1,73 @@
# 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 OpenProject
module Common
module InplaceEditFields
class BooleanInputComponent < BaseFieldComponent
def call
@system_arguments[:data] = merge_data(
@system_arguments,
**additional_arguments
)
form.check_box name: attribute,
**@system_arguments
comment_field_if_enabled(form)
end
private
def reset_url
inplace_edit_field_reset_path(
model: model.class.name,
id: model.id,
attribute:,
system_arguments_json: @system_arguments.to_json
)
end
def additional_arguments
if show_action_buttons
{
data: { controller: "inplace-edit",
inplace_edit_url_value: reset_url,
action: "click->inplace-edit#submitForm keydown.esc->inplace-edit#request",
qa_field_name: }
}
else
{ data: { qa_field_name: } }
end
end
end
end
end
end
@@ -28,25 +28,20 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Overviews
module ProjectCustomFields
class EditComponent < ApplicationComponent
include ApplicationHelper
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
include CustomFieldHierarchyTreeViewHelper
module OpenProject
module Common
module InplaceEditFields
class CalculatedValueInputComponent < InplaceEditFields::TextInputComponent
def self.display_class
DisplayFields::CalculatedValueInputComponent
end
attr_reader :wrapper_id
def initialize(form:, attribute:, model:, **system_arguments)
system_arguments ||= {}
system_arguments[:readonly] = true
def initialize(project:, project_custom_field:, wrapper_id: nil)
super
@project = project
@project_custom_field = project_custom_field
@wrapper_id = wrapper_id
end
def wrapper_uniq_by
@project_custom_field.id
super(form:, attribute:, model:, show_action_buttons: false, **system_arguments)
end
end
end
end
@@ -0,0 +1,71 @@
# 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 OpenProject
module Common
module InplaceEditFields
class DateInputComponent < InplaceEditFields::TextInputComponent
def initialize(form:, attribute:, model:, **system_arguments)
system_arguments[:type] = :date
super
end
def call
@system_arguments[:data] = merge_data(
@system_arguments,
**additional_arguments
)
form.text_field name: attribute,
**@system_arguments
comment_field_if_enabled(form)
end
private
def additional_arguments
if show_action_buttons
{
data: { controller: "inplace-edit",
inplace_edit_url_value: reset_url,
action: "keydown.esc->inplace-edit#request " \
"keydown.enter->inplace-edit#submitForm " \
"change->inplace-edit#submitForm",
qa_field_name: }
}
else
{ data: { qa_field_name: } }
end
end
end
end
end
end
@@ -0,0 +1,81 @@
# 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 OpenProject
module Common
module InplaceEditFields
module DisplayFields
class CalculatedValueInputComponent < DisplayFieldComponent
include OpPrimer::ComponentHelpers
include CalculatedValues::ErrorsHelper
attr_reader :model, :attribute
# If the writable attribute is not explicitly listed as an argument,
# it will be interpreted as one of the system_arguments and thus overwrite the `writable: false`
# rubocop:disable Lint/UnusedMethodArgument
def initialize(model:, attribute:, writable: nil, truncated: false, has_comment: false, show_comment: false,
**system_arguments)
super(model:, attribute:, writable: false, truncated:, has_comment:, show_comment:, **system_arguments)
end
# rubocop:enable Lint/UnusedMethodArgument
def render_calculation_error
error = custom_field&.first_calculation_error(model)
return unless error
render(Primer::OpenProject::FlexLayout.new(
align_items: :flex_start,
data: { test_selector: "error--custom_field_#{custom_field.id}" }
)) do |container|
container.with_column do
render Primer::Beta::Octicon.new(icon: :"alert-fill", color: :danger)
end
container.with_column(ml: 2) do
render Primer::Beta::Text.new(color: :danger) do
calculated_value_error_msg(error)
end
end
end
end
def render_tooltip
render Primer::Alpha::Tooltip.new(
for_id: @system_arguments[:id],
type: :description,
text: I18n.t("custom_fields.calculated_field_not_editable"),
direction: :s
)
end
end
end
end
end
end
@@ -0,0 +1,52 @@
<%=
flex_layout(
align_items: :flex_start,
justify_content: :space_between,
test_selector: @system_arguments[:test_selector],
classes: @system_arguments[:display_classes]
) do |flex|
flex.with_row(mb: 1) do
render OpenProject::Common::AttributeLabelComponent.new(
attribute:,
model:,
required: @system_arguments[:required],
hidden: @system_arguments[:visually_hide_label]
) do
render(Primer::Beta::Text.new(font_weight: :bold)) { @system_arguments[:label] }
end
end
flex.with_row(w: :full) do
input_specific_call
end
flex.with_row(w: :full) do
render OpenProject::Common::AttributeHelpTextCaptionComponent.new(
help_text: helpers.help_text_for(
model,
attribute,
current_user: helpers.current_user
)
)
end
if show_comment?
flex.with_row(w: :full, mt: 2) do
flex_layout do |inner_flex|
inner_flex.with_row do
render(Primer::Beta::Text.new(tag: :label, font_weight: :bold)) { t("attributes.comment") }
end
inner_flex.with_row(mt: 1) do
render(Primer::Beta::Text.new) { comment_text }
end
end
end
end
if (error_html = render_calculation_error).present?
flex.with_row(w: :full) { error_html }
end
end
%>
<%= render_tooltip %>
@@ -33,47 +33,136 @@ module OpenProject
module InplaceEditFields
module DisplayFields
class DisplayFieldComponent < ViewComponent::Base
include OpenProject::TextFormatting
include OpPrimer::ComponentHelpers
attr_reader :model, :attribute, :writable
attr_reader :model, :attribute, :writable, :truncated
def initialize(model:, attribute:, writable:, **system_arguments)
def initialize(model:, attribute:, writable:, truncated:, has_comment: false, show_comment: false, **system_arguments)
super()
@model = model
@attribute = attribute
@writable = writable
@truncated = truncated
@has_comment = has_comment
@show_comment = show_comment
@system_arguments = system_arguments
end
def render_display_value
value = model.public_send(attribute)
if value.present?
format_text(value)
if value.is_a?(TrueClass) || value.is_a?(FalseClass)
boolean_display_value(value)
elsif value.is_a?(Date) || value.is_a?(Time)
helpers.format_date(value)
elsif value.present? && value != [nil]
format_present_value(value)
else
""
t("placeholders.default")
end
end
def display_field_arguments
@display_field_arguments ||= {
classes: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable}",
@display_field_arguments ||= if open_in_dialog?
base_arguments.merge(dialog_field_arguments)
else
base_arguments.merge(inline_edit_field_arguments)
end
end
def open_in_dialog?
@system_arguments[:dialog_controller_name].present?
end
def base_arguments
{
classes: display_field_classes,
id: @system_arguments[:id],
role: "button",
tabindex: 0
}
end
def dialog_field_arguments
return {} unless writable? || @has_comment
{
data: {
controller: "inplace-edit",
inplace_edit_url_value: edit_url,
action: writable ? "click->inplace-edit#request" : ""
controller: "inplace-edit async-dialog",
inplace_edit_dialog_url_value: @system_arguments[:dialog_url],
action: dialog_controller_actions,
test_selector: "inplace-edit-dialog-button-#{model.id}"
},
aria: {
label: [
I18n.t(:label_edit_x, x: @system_arguments[:label]),
I18n.t(:label_value_x, x: render_display_value)
].join(", ")
}
}
end
def call
def inline_edit_field_arguments
return {} unless writable?
{
data: {
controller: "inplace-edit",
inplace_edit_url_value: edit_url,
action: inline_controller_actions,
test_selector: "inplace-edit-field-button-#{model.id}"
}
}
end
def render_calculation_error
# no-op — subclasses may override to render a calculation error row
end
def show_comment?
@show_comment
end
def input_specific_call
render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do
render_display_value
end
end
def render_tooltip
nil
end
def custom_field?
attribute.to_s.start_with?("custom_field_")
end
def custom_field
return @custom_field if defined?(@custom_field)
@custom_field = CustomField.find_by(id: attribute.to_s.sub("custom_field_", "").to_i)
end
private
def display_field_classes
# The later check catches non-editable users which should still see the comment in a dialog
clickable = writable? || open_in_dialog?
"op-inplace-edit--display-field#{' op-inplace-edit--display-field_clickable' if clickable}"
end
def format_present_value(value)
if custom_field?
helpers.format_value(value, custom_field)
else
value.to_s
end
end
def comment_text
model.custom_comment_for(custom_field)&.text.presence || t("placeholders.default")
end
def edit_url
inplace_edit_field_edit_path(
model: model.class.name,
@@ -82,6 +171,45 @@ module OpenProject
system_arguments_json: @system_arguments.to_json
)
end
def boolean_display_value(value)
I18n.t("general_text_#{value ? 'Yes' : 'No'}")
end
def writable?
writable && (@system_arguments[:readonly].nil? || @system_arguments[:readonly] == false)
end
def custom_field_values
CustomValue
.includes(custom_field: :custom_options)
.where(
custom_field_id: custom_field&.id,
customized_id: model.id
)
.to_a
end
def dialog_controller_actions
return "" unless writable? || @has_comment
[
"click->inplace-edit#openDialog",
"keydown.enter->inplace-edit#openDialog",
"keydown.space->inplace-edit#openDialog",
"inplace-edit:open-dialog->async-dialog#handleOpenDialog"
].join(" ")
end
def inline_controller_actions
return "" unless writable?
[
"click->inplace-edit#request",
"keydown.enter->inplace-edit#request",
"keydown.space->inplace-edit#request"
].join(" ")
end
end
end
end
@@ -1,16 +1,22 @@
.op-inplace-edit
&--display-field
&_editable
margin-left: -9px !important // cancel out 8px padding + 1px border
margin-right: -9px !important // cancel out 8px padding + 1px border
padding: var(--base-size-8)
width: calc(100% + 18px) !important
border: 1px solid transparent
border-radius: var(--borderRadius-medium)
padding: var(--base-size-4) var(--base-size-8)
border: 1px solid transparent
border-radius: var(--borderRadius-medium)
@include text-shortener(false)
&:hover, &:focus
border-color: var(--borderColor-default)
box-shadow: var(--shadow-inset)
&:hover, &:focus
border-color: var(--borderColor-default)
box-shadow: var(--shadow-inset)
&:not(&_editable)
&:not(&_clickable)
cursor: not-allowed
&:hover, &:focus
background-color: var(--bgColor-muted)
&--dialog
.op-inplace-edit--display-field
&:not(&_clickable)
&:hover, &:focus
border-color: transparent
box-shadow: none
@@ -28,36 +28,30 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Overviews
module ProjectCustomFields
class EditDialogComponent < DialogComponent
private
module OpenProject
module Common
module InplaceEditFields
module DisplayFields
class HierarchyListComponent < DisplayFieldComponent
def render_display_value
items = hierarchy_items
def body_component
Overviews::ProjectCustomFields::EditComponent.new(
project_custom_field: @project_custom_field,
project: @project,
wrapper_id:
)
end
if items.empty?
t("placeholders.default")
elsif custom_field.multi_value?
items.join(", ")
else
items.first.to_s
end
end
def close_button_title
t("button_cancel")
end
private
def footer_buttons(footer_collection)
footer_collection.with_component(
Primer::Beta::Button.new(
scheme: :primary,
type: :submit,
form: "project-custom-field-edit-form",
data: {
test_selector: "save-project-attributes-button",
turbo: true
}
)
) do
t("button_save")
def hierarchy_items
custom_field_values.filter_map do |cv|
CustomField::Hierarchy::Item.find_by(id: cv.value&.to_i)
end
end
end
end
end
@@ -28,19 +28,35 @@
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
require "support/permission_specs"
module OpenProject
module Common
module InplaceEditFields
module DisplayFields
class LinkInputComponent < DisplayFieldComponent
include OpenProject::TextFormatting
RSpec.describe Overviews::ProjectCustomFieldsController, "edit_project_attributes permission", # rubocop:disable RSpec/EmptyExampleGroup,RSpec/SpecFilePathFormat
type: :controller do
include PermissionSpecs
attr_reader :model, :attribute, :writable
# render dialog displaying project attributes
check_permission_required_for("overviews/project_custom_fields#show", :view_project_attributes)
def render_display_value
value = model.public_send(attribute)
# render dialog with inputs for editing project attributes with edit_project permission
check_permission_required_for("overviews/project_custom_fields#edit", :edit_project_attributes)
if value.present?
render_link(value)
else
t("placeholders.default")
end
end
# update project attributes with edit_project permission, deeper permission check via contract in place
check_permission_required_for("overviews/project_custom_fields#update", :edit_project_attributes)
def render_link(href)
link = Addressable::URI.parse(href)
return href unless link
render(Primer::Beta::Link.new(href:, rel: "noopener noreferrer")) do
href
end
end
end
end
end
end
end
@@ -33,16 +33,35 @@ module OpenProject
module InplaceEditFields
module DisplayFields
class RichTextAreaComponent < DisplayFieldComponent
include OpenProject::TextFormatting
attr_reader :model, :attribute, :writable
def call
def input_specific_call
render(Primer::BaseComponent.new(tag: :div, **display_field_arguments)) do
render(Primer::BaseComponent.new(tag: :div,
classes: "op-uc-container op-uc-container_reduced-headings -multiline")) do
render_display_value
if field_value.present?
if truncated
render OpenProject::Common::AttributeComponent.new("#{attribute}-truncated-display-field",
attribute,
field_value,
lines: 3)
else
format_text(field_value)
end
else
t("placeholders.default")
end
end
end
end
private
def field_value
model.public_send(attribute)
end
end
end
end
@@ -0,0 +1,71 @@
# 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 OpenProject
module Common
module InplaceEditFields
module DisplayFields
class SelectListComponent < DisplayFieldComponent
include CustomFieldsHelper
attr_reader :model, :attribute, :writable
def render_display_value
value = model.public_send(attribute)
if value.present? && value != [nil]
render_value(value)
else
t("placeholders.default")
end
end
private
def render_value(value)
if custom_field?
formatted_custom_field_values.presence || t("placeholders.default")
else
value.is_a?(Array) ? value.join(", ") : value.to_s
end
end
def formatted_custom_field_values
return @formatted_custom_field_values if defined?(@formatted_custom_field_values)
values = custom_field_values.map { |v| format_value(v.value, custom_field) }
@formatted_custom_field_values = custom_field&.multi_value? ? values.join(", ") : values.first
end
end
end
end
end
end
@@ -0,0 +1,71 @@
# 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 OpenProject
module Common
module InplaceEditFields
module DisplayFields
class UserSelectListComponent < SelectListComponent
include CustomFieldsHelper
attr_reader :model, :attribute, :writable
def formatted_custom_field_values
return @formatted_custom_field_values if defined?(@formatted_custom_field_values)
cf_values = custom_field_values
users = cf_values.filter_map(&:typed_value)
@formatted_custom_field_values = if custom_field.multi_value?
flex_layout do |avatar_container|
users.each do |user|
avatar_container.with_row do
render_avatar(user)
end
end
end
else
render_avatar(users.first)
end
end
private
def render_avatar(user)
return unless user
render(::Users::AvatarComponent.new(user:, size: :mini))
end
end
end
end
end
end
@@ -0,0 +1,43 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module OpenProject
module Common
module InplaceEditFields
class FloatInputComponent < InplaceEditFields::TextInputComponent
def initialize(form:, attribute:, model:, **system_arguments)
system_arguments[:type] = :number
system_arguments[:step] = "any"
super
end
end
end
end
end
@@ -0,0 +1,97 @@
# 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 OpenProject
module Common
module InplaceEditFields
class HierarchyListComponent < BaseFieldComponent
include CustomFieldHierarchyTreeViewHelper
def self.display_class
DisplayFields::HierarchyListComponent
end
def self.open_in_dialog?
true
end
def initialize(form:, attribute:, model:, show_action_buttons: false, **system_arguments)
super
end
def call
form_field_name = "project[custom_field_values][]"
form.hidden(name: form_field_name, value: "", scope_name_to_model: false)
filterable_tree_view(form)
comment_field_if_enabled(form)
end
private
def filterable_tree_view(form)
form.html_content do
render(Primer::OpenProject::FilterableTreeView.new(
form_arguments: { builder: rails_builder, name: "custom_field_values" },
include_sub_items_check_box_arguments: { hidden: true },
filter_mode_control_arguments: { hidden: true }
)) do |tree_view|
item_options = {
expanded_fn: ->(*) { true },
label_fn:,
checked_fn:,
select_variant: custom_field.multi_value? ? :multiple : :single
}
populate_tree_view(tree_view, custom_field, item_options:)
end
end
end
# Primer's FormObject stores the underlying ActionView/Primer form builder
# as @builder. FilterableTreeView requires an ActionView::FormBuilder to
# generate its hidden form inputs via hidden_field.
def rails_builder
form.instance_variable_get(:@builder)
end
def checked_fn
current_values = Array(model.custom_value_for(custom_field)).map(&:value)
lambda { |item| current_values.include?(item.id.to_s) }
end
def label_fn
item_formatter = standard_tree_view_item_formatter
lambda { |item| item_formatter.format(item:) }
end
end
end
end
end
@@ -0,0 +1,42 @@
# 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 OpenProject
module Common
module InplaceEditFields
class IntegerInputComponent < InplaceEditFields::TextInputComponent
def initialize(form:, attribute:, model:, **system_arguments)
system_arguments[:type] = :number
super
end
end
end
end
end
@@ -28,21 +28,18 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Overviews
module ProjectCustomFields
class ShowDialogComponent < DialogComponent
private
module OpenProject
module Common
module InplaceEditFields
class LinkInputComponent < TextInputComponent
def self.display_class
DisplayFields::LinkInputComponent
end
def body_component
Overviews::ProjectCustomFields::ShowComponent.new(
project_custom_field: @project_custom_field,
project_custom_field_values: @project.custom_values_for_custom_field(@project_custom_field),
project: @project
)
end
def close_button_title
t("button_close")
def initialize(form:, attribute:, model:, **system_arguments)
system_arguments[:type] = :url
super
end
end
end
end
@@ -31,38 +31,47 @@
module OpenProject
module Common
module InplaceEditFields
class RichTextAreaComponent < ViewComponent::Base
attr_reader :form, :attribute, :model
class RichTextAreaComponent < BaseFieldComponent
def self.display_class
DisplayFields::RichTextAreaComponent
end
def initialize(form:, attribute:, model:, **system_arguments)
super()
@form = form
@attribute = attribute
@model = model
@system_arguments = system_arguments
def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments)
super
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"op-inplace-edit-field--text-area"
)
@system_arguments[:label] ||= model.class.human_attribute_name(attribute)
@system_arguments[:rich_text_options] ||= {}
@system_arguments[:rich_text_options][:primerized] = true
@system_arguments[:data] = merge_data(
@system_arguments,
data: { qa_field_name: }
)
end
def call
form.rich_text_area(name: attribute,
wrapper_data_attributes: {
controller: "ckeditor-focus",
ckeditor_focus_target: "editor",
ckeditor_focus_autofocus_value: true
},
wrapper_data_attributes: ckeditor_wrapper_data,
**@system_arguments)
comment_field_if_enabled(form)
render_action_buttons if show_action_buttons
end
private
def ckeditor_wrapper_data
{
controller: "ckeditor-focus",
ckeditor_focus_target: "editor",
ckeditor_focus_autofocus_value: true
}
end
def render_action_buttons
form.group(layout: :horizontal, justify_content: :flex_end) do |button_group|
button_group.submit(name: :reset,
type: :submit,
@@ -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.
#++
module OpenProject
module Common
module InplaceEditFields
class SelectListComponent < BaseFieldComponent
def self.display_class
DisplayFields::SelectListComponent
end
def initialize(form:, attribute:, model:, show_action_buttons: true, **system_arguments)
super
@system_arguments[:autocomplete_options] ||= {}
set_autocomplete_defaults(model, attribute)
end
def call
if custom_field?
render_custom_field_input
else
render_autocompleter
end
comment_field_if_enabled(form)
render_action_buttons if show_action_buttons
end
private
def set_autocomplete_defaults(model, attribute)
opts = @system_arguments[:autocomplete_options]
opts[:model] ||= { id: model.id, name: model.name }
opts[:inputName] ||= attribute
opts[:wrapper_id] ||= @system_arguments[:wrapper_id]
opts[:focusDirectly] = true if opts[:focusDirectly].nil?
opts[:closeOnSelect] = false if opts[:closeOnSelect].nil?
end
def render_action_buttons
form.group(layout: :horizontal, justify_content: :flex_end) do |button_group|
button_group.submit(name: :reset,
type: :submit,
label: I18n.t(:button_cancel),
scheme: :default,
formaction: inplace_edit_field_reset_path(model: model.class.name, id: model.id, attribute:),
formmethod: :get)
button_group.submit(name: :submit,
label: I18n.t(:button_save),
scheme: :primary)
end
end
def render_custom_field_input
input_class = if custom_field.multi_value?
CustomFields::Inputs::MultiSelectList
else
CustomFields::Inputs::SingleSelectList
end
# Use fields_for to create the proper context for custom field inputs
form.fields_for(:custom_field_values) do |builder|
input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options])
end
end
def render_autocompleter
form.autocompleter(name: attribute, **@system_arguments)
end
end
end
end
end
@@ -31,29 +31,18 @@
module OpenProject
module Common
module InplaceEditFields
class TextInputComponent < ViewComponent::Base
attr_reader :form, :attribute, :model
def self.display_class
DisplayFields::DisplayFieldComponent
end
def initialize(form:, attribute:, model:, **system_arguments)
super()
@form = form
@attribute = attribute
@model = model
@system_arguments = system_arguments
@system_arguments[:label] ||= model.class.human_attribute_name(attribute)
end
class TextInputComponent < BaseFieldComponent
def call
@system_arguments[:data] = merge_data(
@system_arguments,
**additional_arguments
)
form.text_field name: attribute,
autofocus: true,
data: { controller: "inplace-edit",
inplace_edit_url_value: reset_url,
action: "keydown.esc->inplace-edit#request" },
**@system_arguments
comment_field_if_enabled(form)
end
private
@@ -66,6 +55,19 @@ module OpenProject
system_arguments_json: @system_arguments.to_json
)
end
def additional_arguments
if show_action_buttons
{
data: { controller: "inplace-edit",
inplace_edit_url_value: reset_url,
action: "keydown.esc->inplace-edit#request",
qa_field_name: }
}
else
{ data: { qa_field_name: } }
end
end
end
end
end
@@ -0,0 +1,56 @@
# 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 OpenProject
module Common
module InplaceEditFields
class UserSelectListComponent < SelectListComponent
def self.display_class
DisplayFields::UserSelectListComponent
end
private
def render_custom_field_input
input_class = if custom_field.multi_value?
CustomFields::Inputs::MultiUserSelectList
else
CustomFields::Inputs::SingleUserSelectList
end
# Use fields_for to create the proper context for custom field inputs
form.fields_for(:custom_field_values) do |builder|
input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options])
end
end
end
end
end
end
@@ -0,0 +1,90 @@
# 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 OpenProject
module Common
module InplaceEditFields
class VersionSelectListComponent < SelectListComponent
def initialize(form:, attribute:, model:, **system_arguments)
super
unless custom_field?
assign_defaults!
end
end
private
def render_custom_field_input
input_class = if custom_field.multi_value?
CustomFields::Inputs::MultiVersionSelectList
else
CustomFields::Inputs::SingleVersionSelectList
end
# Use fields_for to create the proper context for custom field inputs
form.fields_for(:custom_field_values) do |builder|
input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options])
end
end
def render_autocompleter
form.autocompleter(name: attribute, **@system_arguments) do |list|
model.assignable_versions.each do |version|
list.option(
label: version.name,
value: version.id,
selected: version.id == model.version&.id
)
end
end
end
def assign_defaults!
version = model.version
@system_arguments[:autocomplete_options][:inputValue] = version&.id
@system_arguments[:autocomplete_options][:model] = version_model
@system_arguments[:autocomplete_options][:decorated] = true
@system_arguments[:autocomplete_options][:closeOnSelect] = true
# Override inputName to use Rails form builder naming convention
@system_arguments[:autocomplete_options][:inputName] = input_name
end
def version_model
version ? { id: version.id, name: version.name } : nil
end
def input_name
"#{model.class.model_name.param_key}[#{attribute}]"
end
end
end
end
end
+190 -27
View File
@@ -33,7 +33,7 @@ class InplaceEditFieldsController < ApplicationController
before_action :find_model
before_action :set_attribute
no_authorization_required! :edit, :update, :reset
no_authorization_required! :edit, :update, :reset, :dialog
def edit
replace_via_turbo_stream(
@@ -45,29 +45,9 @@ class InplaceEditFieldsController < ApplicationController
end
def update
handler = update_registry.fetch_handler(@model)
if handler.present?
success = handler.call(
model: @model,
params: permitted_params,
user: current_user
)
else
raise ArgumentError, "Missing update handler for #{@model}"
end
if success
render_success_flash_message_via_turbo_stream(
message: I18n.t(:notice_successful_update)
)
end
replace_via_turbo_stream(
component: component(enforce_edit_mode: !success),
status: success ? :ok : :unprocessable_entity
)
success = invoke_update_handler
handle_update_success if success
replace_field_component(success)
respond_with_turbo_streams
rescue ArgumentError
head :not_found
@@ -78,8 +58,47 @@ class InplaceEditFieldsController < ApplicationController
respond_with_turbo_streams
end
def dialog
respond_with_dialog(
OpenProject::Common::InplaceEditFieldDialogComponent.new(
model: @model,
attribute: @attribute,
system_arguments: system_arguments.to_h.symbolize_keys
)
)
end
private
def invoke_update_handler
handler = update_registry.fetch_handler(@model)
raise ArgumentError, "Missing update handler for #{@model}" if handler.blank?
handler.call(model: @model, params: permitted_params, user: current_user)
end
def handle_update_success
render_success_flash_message_via_turbo_stream(
message: I18n.t(:notice_successful_update)
)
close_dialog_via_turbo_stream(dialog_id) if dialog_id
refresh_calculated_dependents
end
def replace_field_component(success)
if !success && dialog_id
replace_via_turbo_stream(
component: dialog_field_component,
status: :unprocessable_entity
)
else
replace_via_turbo_stream(
component: component(enforce_edit_mode: !success),
status: success ? :ok : :unprocessable_entity
)
end
end
def find_model
model_class = resolve_model_class(params[:model])
@model = model_class.visible.find(params[:id])
@@ -107,20 +126,164 @@ class InplaceEditFieldsController < ApplicationController
end
def permitted_params
params
.expect(@model.model_name.param_key => [@attribute])
if custom_field_via_fields_for?
transform_custom_field_values_params.merge(custom_comments_params)
else
params.expect(@model.model_name.param_key => [@attribute]).merge(custom_comments_params)
end
end
def custom_field_attribute?
@attribute.to_s.start_with?("custom_field_")
end
def custom_field_via_fields_for?
custom_field_attribute? &&
params[@model.model_name.param_key]&.key?(:custom_field_values)
end
def custom_comments_params
return {} unless custom_field_attribute?
custom_field_id = @attribute.to_s.delete_prefix("custom_field_")
raw_comment = params.dig(@model.model_name.param_key, :custom_comments, custom_field_id)
return {} if raw_comment.nil?
{ custom_comments: { custom_field_id => raw_comment } }
end
def transform_custom_field_values_params
model_key = @model.model_name.param_key
custom_field_id = @attribute.to_s.delete_prefix("custom_field_")
# Strong Parameters doesn't support dynamic keys in nested hashes
# So we extract the value directly from the raw params.
# Two formats are supported:
# - Array format: project[custom_field_values][] (used by FilterableTreeView / hierarchy fields)
# - Hash format: project[custom_field_values][{id}] (used by SelectList / legacy fields_for)
cf_values = params.dig(model_key, :custom_field_values)
raw_value = cf_values.is_a?(Array) ? cf_values : cf_values&.dig(custom_field_id)
{ @attribute => process_cf_raw_value(raw_value, custom_field_id) }
end
def process_cf_raw_value(raw_value, custom_field_id)
return raw_value unless raw_value.is_a?(Array)
cleaned_values = raw_value.compact_blank
# FilterableTreeView encodes each selected item as a JSON payload
# {"path":[...],"value":"<id>"} — extract only the "value" field.
# Only hierarchy-format fields use this encoding, so we check the field format first.
values = if hierarchy_format_custom_field?(custom_field_id)
cleaned_values.map { |v| JSON.parse(v)["value"] }
else
cleaned_values
end
# For single-select, unwrap the array to get the single value
values.size <= 1 ? values.first : values
end
def hierarchy_format_custom_field?(custom_field_id)
custom_field = @model.available_custom_fields.find { |cf| cf.id.to_s == custom_field_id }
custom_field&.field_format.in?(%w[hierarchy weighted_item_list])
end
def component(enforce_edit_mode: false)
args = system_arguments.to_h.symbolize_keys
# When saving from a dialog, restore the page component's id so the Turbo
# Stream replacement targets the correct wrapper on the page. Also strip
# dialog-specific arguments that must not bleed into the display component.
args[:id] = args.delete(:page_component_id) if args[:page_component_id]
args = args.except(:wrapper_id, :form_id)
OpenProject::Common::InplaceEditFieldComponent.new(
model: @model,
attribute: @attribute,
enforce_edit_mode:,
update_registry:,
**system_arguments.to_h.symbolize_keys
**args
)
end
# Builds the edit-mode component targeting the field *inside* the dialog.
# Used when an update fails while submitting from a dialog: the error state
# should be shown within the dialog, not at the page trigger location.
# Keeps the dialog field's own :id (not page_component_id) so the Turbo
# Stream targets the correct wrapper inside the dialog, and preserves
# :wrapper_id / :form_id so the re-rendered form still submits via the dialog.
def dialog_field_component
args = system_arguments.to_h.symbolize_keys
OpenProject::Common::InplaceEditFieldComponent.new(
model: @model,
attribute: @attribute,
enforce_edit_mode: true,
show_action_buttons: false,
update_registry:,
**args
)
end
def dialog_id
wrapper_id = system_arguments.to_h["wrapper_id"]
wrapper_id&.delete_prefix("#")
end
def refresh_calculated_dependents
return unless custom_field_attribute?
return unless @model.respond_to?(:available_custom_fields)
affected = affected_calculated_fields
return if affected.empty?
affected.each { |custom_field| turbo_streams << calculated_field_turbo_stream(custom_field) }
end
def affected_calculated_fields
cf_id = @attribute.to_s.delete_prefix("custom_field_").to_i
@model.available_custom_fields.affected_calculated_fields([cf_id])
end
def calculated_field_turbo_stream(custom_field)
attribute = custom_field.attribute_name.to_sym
stable_key = "#{@model.class.name.parameterize(separator: '_')}_#{@model.id}_#{attribute}"
# Use the field's own system_arguments sent by the client from the DOM data attribute.
# Fall back to an empty hash if not present (e.g. in tests or non-JS contexts).
field_args = stable_key_system_arguments
.fetch(stable_key, {})
.symbolize_keys
.except(:id) # exclude UUID so the component generates a fresh one
comp = OpenProject::Common::InplaceEditFieldComponent.new(
model: @model,
attribute:,
update_registry:,
**field_args
)
comp.render_as_turbo_stream(
view_context:,
action: :replace,
target: nil,
targets: "[data-inplace-edit-stable-key='#{stable_key}']"
)
end
def stable_key_system_arguments
@stable_key_system_arguments ||= parse_stable_key_system_arguments
end
def parse_stable_key_system_arguments
raw = params[:stable_key_system_arguments]
return {} if raw.blank?
JSON.parse(raw)
rescue JSON::ParserError
{}
end
def update_registry
@update_registry ||= OpenProject::InplaceEdit::UpdateRegistry.default
end
+1
View File
@@ -88,6 +88,7 @@ class CustomField < ApplicationRecord
validates :has_comment, absence: true, unless: :can_have_comment?
before_validation :check_searchability
after_destroy :destroy_help_text
# make sure int, float, date, and bool are not searchable
@@ -33,6 +33,26 @@ Rails.application.config.to_prepare do
OpenProject::InplaceEdit::FieldRegistry.register(:description, OpenProject::Common::InplaceEditFields::RichTextAreaComponent)
OpenProject::InplaceEdit::FieldRegistry.register(:status_explanation, OpenProject::Common::InplaceEditFields::RichTextAreaComponent)
# Register custom field edit components based on field format
# This mirrors the pattern used in CustomFields::CustomFieldRendering
custom_field_format_mappings = {
"string" => OpenProject::Common::InplaceEditFields::TextInputComponent,
"text" => OpenProject::Common::InplaceEditFields::RichTextAreaComponent,
"int" => OpenProject::Common::InplaceEditFields::IntegerInputComponent,
"float" => OpenProject::Common::InplaceEditFields::FloatInputComponent,
"date" => OpenProject::Common::InplaceEditFields::DateInputComponent,
"bool" => OpenProject::Common::InplaceEditFields::BooleanInputComponent,
"link" => OpenProject::Common::InplaceEditFields::LinkInputComponent,
"hierarchy" => OpenProject::Common::InplaceEditFields::HierarchyListComponent,
"weighted_item_list" => OpenProject::Common::InplaceEditFields::HierarchyListComponent,
"list" => OpenProject::Common::InplaceEditFields::SelectListComponent,
"user" => OpenProject::Common::InplaceEditFields::UserSelectListComponent,
"version" => OpenProject::Common::InplaceEditFields::VersionSelectListComponent,
"calculated_value" => OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent
}
OpenProject::InplaceEdit::FieldRegistry.register_custom_field_format_mappings(custom_field_format_mappings)
# Register the update handler per model
OpenProject::InplaceEdit::UpdateRegistry.register(Project,
handler: OpenProject::InplaceEdit::Handlers::ProjectUpdate,
+1
View File
@@ -1117,6 +1117,7 @@ Rails.application.routes.draw do
patch :update, controller: "inplace_edit_fields", action: :update
get :reset, controller: "inplace_edit_fields", action: :reset
get :edit, controller: "inplace_edit_fields", action: :edit
get :dialog, controller: "inplace_edit_fields", action: :dialog
end
if OpenProject::Configuration.lookbook_enabled?
@@ -35,11 +35,43 @@ import { renderStreamMessage } from '@hotwired/turbo';
export default class extends Controller {
static values = {
url: String,
dialogUrl: String,
};
declare urlValue:string;
declare dialogUrlValue:string;
declare hasDialogUrlValue:boolean;
private boundFormDataHandler:((e:FormDataEvent) => void) | null = null;
connect() {
const form = this.element.closest('form');
if (form) {
this.boundFormDataHandler = (e:FormDataEvent) => this.appendStableKeySystemArguments(e);
form.addEventListener('formdata', this.boundFormDataHandler);
}
}
disconnect() {
const form = this.element.closest('form');
if (form && this.boundFormDataHandler) {
form.removeEventListener('formdata', this.boundFormDataHandler);
this.boundFormDataHandler = null;
}
}
async request(e:Event):Promise<void> {
// Don't trigger edit mode if the user is selecting text or just finished a selection
if (window.getSelection()?.toString()) {
return;
}
// Don't trigger edit mode if clicking on a link
const target = e.target as HTMLElement;
if (target.tagName === 'a' || target.closest('a')) {
return;
}
async request() {
const response = await fetch(this.urlValue, {
method: 'GET',
headers: { Accept: 'text/vnd.turbo-stream.html' },
@@ -52,4 +84,58 @@ export default class extends Controller {
throw new Error(response.statusText);
}
}
openDialog(event:Event) {
// Don't trigger edit mode if the user is selecting text or just finished a selection
if (window.getSelection()?.toString()) {
return;
}
const target = event.target as HTMLElement;
// Check if the event is on an interactive element that should be ignored
if (this.isInteractiveElement(target)) {
// Don't handle this event, let the child element handle it
return;
}
// Prevent default and dispatch custom event for async-dialog to handle
event.preventDefault();
this.dispatch('open-dialog', { detail: { url: this.dialogUrlValue } });
}
submitForm() {
const form = this.element.closest('form');
if (form) {
form.requestSubmit();
}
}
private appendStableKeySystemArguments(e:FormDataEvent):void {
const result:Record<string, unknown> = {};
document.querySelectorAll<HTMLElement>('[data-inplace-edit-stable-key][data-inplace-edit-system-arguments]').forEach((el) => {
const key = el.dataset.inplaceEditStableKey;
const raw = el.dataset.inplaceEditSystemArguments;
if (key && raw) {
try {
result[key] = JSON.parse(raw);
} catch {
// ignore malformed JSON
}
}
});
e.formData.set('stable_key_system_arguments', JSON.stringify(result));
}
private isInteractiveElement(element:HTMLElement):boolean {
// Check if the element is or is inside an interactive element.
let current = element;
while (current && current !== this.element) {
if (current.matches('button, a, dialog')) {
return true;
}
current = current.parentElement!;
}
return false;
}
}
@@ -33,22 +33,33 @@ module OpenProject
class FieldRegistry
def initialize
@registry = {}
@custom_field_format_mappings = {}
end
def register(attribute_name, field_component)
@registry[attribute_name.to_s] = field_component
end
def register_custom_field_format_mappings(mappings)
@custom_field_format_mappings = mappings
end
def fetch(attribute_name)
@registry.fetch(attribute_name.to_s) { Common::InplaceEditFields::TextInputComponent }
end
def fetch_for_custom_field_format(field_format)
return Common::InplaceEditFields::TextInputComponent if field_format.nil?
@custom_field_format_mappings.fetch(field_format.to_s) { Common::InplaceEditFields::TextInputComponent }
end
@default = new
class << self
attr_reader :default
delegate :register, :fetch, to: :@default
delegate :register, :fetch, :fetch_for_custom_field_format, :register_custom_field_format_mappings, to: :@default
end
end
end
@@ -9,9 +9,13 @@ The InplaceEdit system consists of:
- **A generic wrapper component**
(`InplaceEditFieldComponent`)
- **A base class for edit field components**
(`BaseFieldComponent`)
- **Edit field components**
(e.g. `TextInputComponent`, `RichTextAreaComponent`)
(e.g. `TextInputComponent`, `RichTextAreaComponent`, `HierarchyListComponent`)
- **Optional display field components**
- **A dialog component**
(`InplaceEditFieldDialogComponent`)
- **A central registry**
- **A generic controller**
- **TurboStreams + Stimulus** for lazy loading
@@ -31,14 +35,34 @@ OpenProject::Common::InplaceEditFieldComponent.new(
The `InplaceEditFieldComponent` is the **single entry point used in views**.
It is initialized with a model and an attribute and decides which concrete field component to render. It also decides whether the component is currently in display mode or edit mode.
Only model and attribute are required. All additional keyword arguments are treated as system arguments and forwarded unchanged through Turbo roundtrips. Editability is determined via a contract and exposed through the `writable?` check.
Only `model` and `attribute` are required. All additional keyword arguments are treated as system arguments and forwarded unchanged through Turbo roundtrips. Editability is determined via a contract and exposed through the `writable?` check.
The component resolves the edit field via the `FieldRegistry` and optionally a display field via the edit fields `display_class`.
The component resolves the edit field via the `FieldRegistry` and optionally a display field via the edit field's `display_class`.
**Parameters:**
| Parameter | Default | Description |
|---|---|---|
| `model` | — | The ActiveRecord model instance |
| `attribute` | — | The attribute name as a symbol |
| `enforce_edit_mode` | `false` | Render in edit mode when writable. Non-writable users always see the display component, even with `enforce_edit_mode: true`. Used by the dialog to skip the display→edit click. |
| `open_in_dialog` | `false` | Force edit form to open in a dialog |
| `show_action_buttons` | `true` | Show save/cancel buttons inside the field component |
| `truncated` | `false` | Pass truncation hint to the display component |
**Automatic dialog mode:**
The component switches to dialog mode automatically (regardless of the `open_in_dialog` parameter) in any of these cases:
- The registered edit field component returns `true` from `self.open_in_dialog?`
- The attribute is a custom field that has comments enabled (`custom_field.has_comment?`)
This allows individual field components to declare that they always require a dialog (e.g. because they embed a complex tree picker), without the caller needing to know about it.
**Simplified HTML of the `InplaceEditFieldComponent`:**
```html
&lt;%= component_wrapper(tag: :div, class: "op-inplace-edit") do
if display_field_component.present? && !enforce_edit_mode
if display_field_component.present? && (!enforce_edit_mode || !writable?)
render display_field_component
else
primer_form_with(
@@ -59,6 +83,7 @@ end %>
- selecting the correct edit field
- if needed: rendering the appropriate display field
- checking whether the attribute is writable
- deciding between inline and dialog edit mode
#### FieldRegistry
@@ -74,6 +99,52 @@ OpenProject::InplaceEdit::FieldRegistry.register(
)
```
**Custom field registration:**
Custom fields are registered automatically. The format-to-component mappings (e.g. `"text"` → `RichTextAreaComponent`) are configured once in `config/initializers/inplace_edit_fields.rb` via `register_custom_field_format_mappings`. At startup, all existing custom fields are pre-registered. Whenever a new custom field is created, an `after_create` callback on `CustomField` calls `FieldRegistry.register_custom_field(id, field_format)` automatically — no manual registration is needed.
#### BaseFieldComponent
`BaseFieldComponent` is the **base class for all edit field components**. It provides shared functionality that concrete field components can use, and should be inherited from when building new field components.
It handles the optional rendering of a comment field for custom fields with comments enabled.
**Class-level API:**
| Method | Default | Description |
|---|---|---|
| `self.display_class` | `DisplayFields::DisplayFieldComponent` | The display component class to use for the read-only view |
| `self.open_in_dialog?` | `false` | Return `true` to force the field to always open in a dialog regardless of the caller's settings |
```ruby
module OpenProject
module Common
module InplaceEditFields
class MyCustomComponent < BaseFieldComponent
def self.display_class
DisplayFields::DisplayFieldComponent
end
def self.open_in_dialog?
false # override to true for dialog-only fields
end
def call
form.text_field(name: attribute, **@system_arguments)
comment_field_if_enabled(form)
end
end
end
end
end
```
**`comment_field_if_enabled(f)`**
Call this helper from any field component's `call` method to conditionally render a comment textarea. It renders only when the attribute is a custom field with comments enabled (`custom_field.has_comment?`).
The generated form field name follows the pattern `model[custom_comments][custom_field_id]` (e.g. `project[custom_comments][18]`), so that multiple custom field comments can coexist in the same form submission.
#### EditFieldComponents
`EditFieldComponents` are responsible for rendering the actual form field. They receive a form builder, the model, the attribute, and the forwarded system arguments.
@@ -82,26 +153,21 @@ They may render only the field itself or also include submit and reset buttons.
Edit field components define a `display_class`. This class is used to render the read-only display state.
All field components should inherit from `BaseFieldComponent` to get access to the comment field helper and other shared behaviour.
**Simplified example of an `EditFieldComponent`:**
```ruby
module OpenProject
module Common
module InplaceEditFields
class RichTextAreaComponent < ViewComponent::Base
class RichTextAreaComponent < BaseFieldComponent
def self.display_class
DisplayFields::RichTextAreaComponent
end
def initialize(form:, attribute:, model:, **system_arguments)
super()
@form = form
@attribute = attribute
@model = model
@system_arguments = system_arguments
end
def call
form.rich_text_area(name: attribute, **@system_arguments)
comment_field_if_enabled(form)
form.group(layout: :horizontal) do |button_group|
button_group.submit(name: :reset,
@@ -120,6 +186,94 @@ module OpenProject
end
```
#### HierarchyListComponent (example of a dialog-only field)
`HierarchyListComponent` is the edit field for hierarchy-type custom fields. It uses `Primer::OpenProject::FilterableTreeView` to render a filterable, hierarchical tree picker. Because the tree picker is a complex, non-inline widget, the component declares `self.open_in_dialog? = true` so it always opens in a dialog.
**Key patterns used:**
- **`self.open_in_dialog?` → `true`**: Tells `InplaceEditFieldComponent` to always use dialog mode for this field.
- **`form.html_content`**: Embeds arbitrary rendered HTML (the `FilterableTreeView`) within the Primer form DSL `call` method.
- **`rails_builder`**: `FilterableTreeView` requires an ActionView `FormBuilder` (not a Primer `FormObject`). The underlying builder is accessed via `form.instance_variable_get(:@builder)`.
- **Array form encoding**: The hidden sentinel field and tree view items both use `project[custom_field_values][]` (array format). The controller handles this with `extract_tree_view_value` (see below).
```ruby
class HierarchyListComponent < BaseFieldComponent
include CustomFieldHierarchyTreeViewHelper
def self.display_class
DisplayFields::HierarchyListComponent
end
def self.open_in_dialog?
true
end
def call
# Hidden sentinel so the param key is always present even with nothing selected
form.hidden(name: "project[custom_field_values][]", value: "", scope_name_to_model: false)
filterable_tree_view(form)
comment_field_if_enabled(form)
end
private
def filterable_tree_view(form)
form.html_content do
render(Primer::OpenProject::FilterableTreeView.new(
form_arguments: { builder: rails_builder, name: "custom_field_values" },
include_sub_items_check_box_arguments: { hidden: true },
filter_mode_control_arguments: { hidden: true }
)) do |tree_view|
item_options = {
expanded_fn: ->(*) { true },
label_fn:, checked_fn:,
select_variant: custom_field.multi_value? ? :multiple : :single
}
populate_tree_view(tree_view, custom_field, item_options:)
end
end
end
def rails_builder
# Primer's FormObject stores the underlying ActionView/Primer form builder
# as @builder. FilterableTreeView requires an ActionView::FormBuilder to
# generate its hidden form inputs via hidden_field.
form.instance_variable_get(:@builder)
end
end
```
#### CalculatedValueInputComponent
`CalculatedValueInputComponent` is a **display-only** field component used for calculated custom fields. Calculated fields derive their value from other fields via a formula — they cannot be edited directly.
The component always passes `writable: false` to its parent `DisplayFieldComponent`, which prevents the Stimulus inplace-edit controller from being attached to the DOM element. It also renders a non-editable tooltip.
When a calculation fails, the component renders an inline error beneath the value via the `render_calculation_error` hook.
**Automatic refresh of calculated fields:**
Calculated field values depend on other (non-calculated) fields. When a non-calculated field is saved, the controller automatically refreshes all calculated fields that depend on it:
1. After a successful save, `InplaceEditFieldsController#refresh_calculated_dependents` calls `available_custom_fields.affected_calculated_fields([cf_id])` to find all direct and transitive dependents.
2. For each dependent, a new `InplaceEditFieldComponent` is rendered with the updated value and any calculation errors.
3. The rendered component is pushed as a Turbo Stream `replace` using a CSS selector (`targets:`), targeting every DOM element with a matching `data-inplace-edit-stable-key` attribute.
The `stable_key` is a deterministic string derived from the model class, model ID, and attribute name (e.g. `project_42_custom_field_18`). Unlike the UUID-based `wrapper_key`, it is consistent across page loads and independent of dialog state, which makes CSS-selector targeting reliable even when the same field appears multiple times on the page.
```
Non-calculated field saved (POST /inplace_edit_fields/update)
└─ refresh_calculated_dependents
└─ affected_calculated_fields([cf_id]) → [cf_18, cf_22]
└─ turbo-stream replace targets="[data-inplace-edit-stable-key='project_42_custom_field_18']"
└─ turbo-stream replace targets="[data-inplace-edit-stable-key='project_42_custom_field_22']"
```
**Presentation context preservation:**
Calculated fields have no edit form, so their display settings (`truncated`, `open_in_dialog`) are not available in the DOM when a refresh is triggered. The controller inherits these settings from the `system_arguments_json` of the field being saved — valid because fields in the same container share the same presentation context.
#### DisplayFieldComponents
`DisplayFieldComponents` render the attribute value in read-only mode. They handle formatting and attach the Stimulus controller that triggers the switch to edit mode.
@@ -150,18 +304,59 @@ module OpenProject
end
```
#### InplaceEditFieldDialogComponent
When a field is configured to open in dialog mode, the display component renders a trigger button instead of a direct click target. Clicking the button fetches and opens the `InplaceEditFieldDialogComponent` via a lazy-loaded Turbo request.
The dialog wraps an `InplaceEditFieldComponent` in `enforce_edit_mode: true`. The body and footer buttons differ based on writability:
- **Writable user**: the edit form is shown with Save and Cancel buttons.
- **Non-writable user**: the display component is shown (read-only value, and comment as plain text if `has_comment?`) with only a Close button.
The `writable` flag is computed once when building the dialog URL and embedded in `system_arguments_json`, so the dialog component does not need to re-query the registry.
```
Display component (with dialog trigger)
└─ click → GET /inplace_edit_fields/dialog?...
└─ InplaceEditFieldDialogComponent
├─ Primer::Alpha::Dialog
│ ├─ body: InplaceEditFieldComponent (enforce_edit_mode: true)
│ │ ├─ [writable] EditFieldComponent
│ │ │ ├─ value field
│ │ │ └─ comment textarea (if has_comment?)
│ │ └─ [non-writable] DisplayFieldComponent
│ │ └─ comment as text (if has_comment?)
│ └─ footer: [writable] Save / Cancel — [non-writable] Close
```
**`page_component_id` convention:**
Each `InplaceEditFieldComponent` generates a UUID and stores it in `@system_arguments[:id]`. This UUID is part of the `wrapper_key` that Turbo Stream uses to target the correct DOM element when replacing the component after an update.
When building the dialog URL, the component passes this UUID as `page_component_id` inside `system_arguments_json` (while excluding its own `:id` key to avoid a clash with the dialog's UUID). On a successful `update`, the controller restores `page_component_id` as `:id` before constructing the response component, so the `wrapper_key` matches the original element on the page and the Turbo Stream replacement lands in the right place. The controller also closes the dialog via `close_dialog_via_turbo_stream` on success, and strips dialog-specific arguments (`wrapper_id`, `form_id`) so they do not bleed into the display component.
### Update behaviour
#### InplaceEditFieldsController
The `InplaceEditFieldsController` is a generic controller shared by all `InplaceEditComponent`s. It dynamically resolves the model but only allows models that are registered in the `UpdateRegistry`.
* The edit action replaces the display component with the edit component via Turbo Stream.
* The update action delegates persistence to a registered handler and then replaces the component.
* The reset action switches back to display mode without saving.
* The `edit` action replaces the display component with the edit component via Turbo Stream.
* The `update` action delegates persistence to a registered handler and then replaces the component.
* The `reset` action switches back to display mode without saving.
* The `dialog` action renders the `InplaceEditFieldDialogComponent` for lazy dialog loading.
The controller itself contains no model-specific logic.
**Strong Parameters and `custom_comments`:**
The controller's `permitted_params` method handles two cases:
1. **Custom fields via `custom_field_values`** (legacy `fields_for` approach): the value is extracted manually from the raw params because Strong Parameters does not support dynamic hash keys.
2. **Regular attributes**: permitted via `params.expect(model_key => [attribute])`.
In both cases, `custom_comments` is extracted alongside the field value. Because the comment field name uses a dynamic key (`model[custom_comments][id]`), it is also extracted manually and merged into the permitted params as `{ custom_comments: { "18" => "..." } }`.
#### UpdateRegistry
The `UpdateRegistry` maps models to update handlers and contracts. The handler performs the update, while the contract is responsible for authorization and validation.
@@ -187,7 +382,11 @@ end
## Adding new fields
To add a new editable attribute, create an `EditFieldComponent` and register it in the `FieldRegistry`. Optionally provide a display component.
To add a new editable attribute:
1. Create an `EditFieldComponent` that inherits from `BaseFieldComponent`.
2. Call `comment_field_if_enabled(form)` in `call` if the field should support custom field comments.
3. Register it in the `FieldRegistry`. Optionally provide a display component.
No changes to the core component or controller should be required.
@@ -1,3 +1,2 @@
@import "./overviews/project_custom_fields/show_component.sass"
@import "./overviews/project_phases/item_component.sass"
@import "./overviews/overview_grid_component.sass"
@@ -1,30 +0,0 @@
<%=
render(
Primer::Alpha::Dialog.new(
title: dialog_title,
classes: "Overlay--size-large-portrait",
size: :large,
id: dialog_id
)
) do |d|
d.with_header(variant: :large)
d.with_body(classes: "Overlay-body_autocomplete_height") do
render(body_component)
end
d.with_footer do
component_collection do |footer_collection|
footer_collection.with_component(
Primer::Beta::Button.new(
data: {
"close-dialog-id": dialog_id
}
)
) do
close_button_title
end
footer_buttons(footer_collection)
end
end
end
%>
@@ -1,68 +0,0 @@
<%= helpers.angular_component_tag "opce-custom-modal-overlay" %>
<%=
component_wrapper do
primer_form_with(
id: "project-custom-field-edit-form",
model: @project,
method: :put,
data: { turbo: true, turbo_stream: true, "test-selector": "async-dialog-content" },
url: project_custom_field_path(project_id: @project.id, id: @project_custom_field.id)
) do |f|
if @project_custom_field.hierarchical_list?
form_field_name = "project[custom_field_values][]"
concat(
render_inline_form(f) do |hidden_form|
hidden_form.hidden(name: form_field_name, value: "", scope_name_to_model: false)
end
)
concat(
render(
Primer::OpenProject::FilterableTreeView.new(
form_arguments: { builder: f, name: "custom_field_values" },
include_sub_items_check_box_arguments: { hidden: true },
filter_mode_control_arguments: { hidden: true }
)
) do |tree_view|
current_values = Array(@project.custom_value_for(@project_custom_field)).map(&:value)
checked_fn = lambda { |item| current_values.include?(item.id.to_s) }
item_formatter = standard_tree_view_item_formatter
label_fn = lambda { |item| item_formatter.format(item:) }
item_options = {
expanded_fn: ->(*) { true },
label_fn:,
checked_fn:,
select_variant: @project_custom_field.multi_value? ? :multiple : :single
}
populate_tree_view(tree_view, @project_custom_field, item_options:)
end
)
if @project_custom_field.has_comment?
concat(
f.fields_for(:custom_comments) do |builder|
render(
CustomFields::CommentField.new(
builder,
custom_field: @project_custom_field,
object: @project
)
)
end
)
end
else
render(
Projects::CustomFields::Form.new(
f,
project: @project,
custom_field: @project_custom_field,
wrapper_id:
)
)
end
end
end
%>
@@ -33,72 +33,9 @@ module Overviews
class ItemComponent < ShowComponent
private
def show_comment? = false
def value_wrapper_attributes
if allowed_to_edit?
if calculated_value? && !has_comment?
non_editable_wrapper(id: calculated_value_tooltip_id)
else
modal_wrapper
end
elsif has_comment?
modal_wrapper
else
non_editable_wrapper
end
end
def allowed_to_edit?
User.current.allowed_in_project?(:edit_project_attributes, @project)
end
def modal_wrapper
action_label_key = allowed_to_edit? ? :label_edit_x : :label_view_x
url = if allowed_to_edit?
edit_project_custom_field_path(project_id: @project, id: @project_custom_field)
else
project_custom_field_path(project_id: @project, id: @project_custom_field)
end
{
tag: :div,
classes: "project-custom-field-clickable",
data: {
controller: "project-custom-field-modal async-dialog",
"project-custom-field-modal-url-value": url,
action: "click->project-custom-field-modal#open " \
"keydown.enter->project-custom-field-modal#open " \
"keydown.space->project-custom-field-modal#open " \
"project-custom-field-modal:open-dialog->async-dialog#handleOpenDialog"
},
aria: {
label: [
I18n.t(action_label_key, x: @project_custom_field.name),
I18n.t(:label_value_x, x: accessible_value_text)
].join(", ")
},
role: "button",
tabindex: 0,
test_selector: "project-custom-field-modal-button-#{@project_custom_field.id}"
}
end
def non_editable_wrapper(**)
{
tag: :div,
classes: "project-custom-field-non-editable",
aria: {
disabled: true,
label: [
@project_custom_field.name,
I18n.t(:label_value_x, x: accessible_value_text)
].join(", ")
},
tabindex: 0,
**
}
def limited_space?
@project_custom_field.field_format == "text" &&
@project_custom_field.project_custom_field_section&.shown_in_overview_sidebar?
end
end
end
@@ -5,7 +5,6 @@
render(
Overviews::ProjectCustomFields::ItemComponent.new(
project_custom_field:,
project_custom_field_values: attribute_load_service.get_eager_loaded_project_custom_field_values_for(project_custom_field.id),
project: @project
)
)
@@ -40,11 +40,6 @@ module Overviews
@project_custom_fields = project_custom_fields
@project = project
end
def attribute_load_service
@attribute_load_service ||= ::ProjectCustomFields::LoadService.new(project: @project,
project_custom_fields: @project_custom_fields)
end
end
end
end
@@ -1,61 +1,8 @@
<%=
flex_layout(
align_items: :flex_start,
justify_content: :space_between,
classes: "op-project-custom-field-container",
data: {
test_selector: "project-custom-field-#{@project_custom_field.id}"
}
) do |custom_field_value_container|
# temporarily using inline styles in order to align the content as desired
custom_field_value_container.with_row(mb: 1) do
render OpenProject::Common::AttributeLabelComponent.new(
attribute: @project_custom_field.attribute_name,
model: @project,
required: required?
) do
render(Primer::Beta::Text.new(font_weight: :bold)) { @project_custom_field.name }
end
end
custom_field_value_container.with_row(w: :full) do
render(value_wrapper) do
if not_set?
render(Primer::Beta::Text.new) { t("placeholders.default") }
else
render_value
end
end
end
custom_field_value_container.with_row(w: :full) do
render OpenProject::Common::AttributeHelpTextCaptionComponent.new(
help_text: helpers.help_text_for(
@project,
@project_custom_field.attribute_name,
current_user: helpers.current_user
)
)
end
if calculation_error?
custom_field_value_container.with_row(w: :full) do
render_calculation_error
end
end
if show_comment?
custom_field_value_container.with_row(w: :full, mt: 1) do
render Primer::Alpha::TextArea.new(
name: :custom_comment,
label: I18n.t("attributes.comment"),
value: @project_custom_field.comment_for(@project)&.text,
rows: 5,
readonly: true
)
end
end
end
%>
<%= render_calculated_value_tooltip if calculated_value? %>
<%= render OpenProject::Common::InplaceEditFieldComponent.new(
model: @project,
attribute: @project_custom_field.attribute_name.to_sym,
open_in_dialog: limited_space?,
truncated: limited_space?,
test_selector: "project-custom-field-#{@project_custom_field.id}",
display_classes: "op-project-custom-field-container"
) %>
@@ -31,139 +31,17 @@
module Overviews
module ProjectCustomFields
class ShowComponent < ApplicationComponent
include ApplicationHelper
include CalculatedValues::ErrorsHelper
include CustomFieldsHelper
include OpPrimer::ComponentHelpers
delegate :has_comment?, :calculated_value?, to: :@project_custom_field
def initialize(project_custom_field:, project_custom_field_values:, project:)
def initialize(project_custom_field:, project:)
super
@project_custom_field = project_custom_field
@project_custom_field_values = Array(project_custom_field_values)
@project = project
end
private
def show_comment? = @project_custom_field.has_comment?
def value_wrapper_attributes = {}
def value_wrapper
Primer::Beta::Text.new(**value_wrapper_attributes)
end
def required?
@project_custom_field.required? && !@project_custom_field.calculated_value?
end
def not_set?
@project_custom_field_values.none?(&:value?)
end
def calculation_error?
@project_custom_field.first_calculation_error(@project).present?
end
def render_calculation_error
error = @project_custom_field.first_calculation_error(@project)
render(Primer::OpenProject::FlexLayout.new(align_items: :flex_start,
data: {
test_selector: "error-cf-#{@project_custom_field.id}"
})) do |container|
container.with_column do
render Primer::Beta::Octicon.new(icon: :"alert-fill", color: :danger)
end
container.with_column(ml: 2) do
render Primer::Beta::Text.new(color: :danger) do
calculated_value_error_msg(error)
end
end
end
end
def render_calculated_value_tooltip
render Primer::Alpha::Tooltip.new(
for_id: calculated_value_tooltip_id,
type: :description,
text: I18n.t("custom_fields.calculated_field_not_editable"),
direction: :s
)
end
def calculated_value_tooltip_id
calculated_value? ? "calculated-field-tooltip-#{@project_custom_field.id}" : nil
end
def render_value
case @project_custom_field.field_format
when "link"
render_link
when "text"
render_long_text
when "user"
render_user
else
render_custom_field_values
end
end
def render_long_text
render OpenProject::Common::AttributeComponent.new("dialog-cf-#{@project_custom_field.id}",
@project_custom_field.name,
@project_custom_field_values.first&.value,
lines: 3)
end
def render_user
if @project_custom_field.multi_value?
flex_layout do |avatar_container|
@project_custom_field_values.each do |cf_value|
avatar_container.with_row do
render_avatar(cf_value.typed_value)
end
end
end
else
render_avatar(@project_custom_field_values.first&.typed_value)
end
end
def render_avatar(user)
render(Users::AvatarComponent.new(user:, size: :mini))
end
def render_link
href = @project_custom_field_values.first&.value
link = Addressable::URI.parse(href)
return href unless link
target = link.host == Setting.host_without_protocol ? "_top" : "_blank"
render(Primer::Beta::Link.new(href:, rel: "noopener noreferrer", target:)) do
href
end
end
def render_custom_field_values
render(Primer::Beta::Text.new) { custom_field_values }
end
def accessible_value_text
return I18n.t("placeholders.default") if not_set?
custom_field_values
end
def custom_field_values
return @custom_field_values if defined?(@custom_field_values)
values = @project_custom_field_values.map { |v| format_value(v.value, @project_custom_field) }
@custom_field_values = @project_custom_field.multi_value? ? values.join(", ") : values.first
def limited_space?
false
end
end
end
@@ -1,22 +0,0 @@
.project-custom-fields-rich-text-preview
:last-child
display: inline
// Style non-editable fields to match hover-input dimensions
.project-custom-field-non-editable
@include hover-input-base
min-height: 1.2em // Ensure consistent height even when empty
@media (hover: none) and (pointer: coarse)
background-color: var(--bgColor-disabled)
color: var(--fgColor-disabled)
@media (hover: hover)
&:hover, &:focus-visible
background-color: var(--bgColor-disabled)
color: var(--fgColor-disabled)
// Style clickable fields
.project-custom-field-clickable
@include hover-input-base
cursor: pointer
min-height: 1.2em
@@ -1,124 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
class Overviews::ProjectCustomFieldsController < ApplicationController
include OpTurbo::ComponentStream
before_action :find_project_by_project_id
before_action :find_project_custom_field
before_action :authorize
def show
respond_with_dialog(
Overviews::ProjectCustomFields::ShowDialogComponent.new(
project: @project,
project_custom_field: @custom_field
)
)
end
def edit
respond_with_dialog(
Overviews::ProjectCustomFields::EditDialogComponent.new(
project: @project,
project_custom_field: @custom_field
)
)
end
def update
# FIXME: submitted format of form parameters are not configurable for the tree view component. Hence, we
# need to process it before giving them in standard format to the update service.
if @custom_field.hierarchical_list?
process_hierarchy_params
end
service_call = ::Projects::UpdateService
.new(
user: current_user,
model: @project,
contract_options: { project_attributes_only: true }
)
.call(permitted_params.project)
if service_call.success?
if field_shown_in_sidebar?(@custom_field)
update_sidebar_component
else
update_widgets_component
end
else
handle_errors(service_call.result, @custom_field)
end
respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity)
end
private
def process_hierarchy_params
values = params.dig(:project, :custom_field_values)
ids = Array(values).reject(&:empty?).map do |value|
MultiJson.load(value, symbolize_keys: true)[:value]
end
params[:project][:custom_field_values] = { @custom_field.id.to_s => ids.one? ? ids.first : ids }
end
def find_project_custom_field
@custom_field = @project.available_custom_fields.find(params[:id])
end
def handle_errors(project_with_errors, custom_field)
update_via_turbo_stream(
component: Overviews::ProjectCustomFields::EditComponent.new(
project: project_with_errors,
project_custom_field: custom_field
)
)
end
def update_sidebar_component
update_via_turbo_stream(
component: Overviews::ProjectCustomFields::SidePanelComponent.new(project: @project)
)
end
def update_widgets_component
update_via_turbo_stream(
component: Grids::ProjectAttributeWidgets.new(@project)
)
end
def field_shown_in_sidebar?(custom_field)
CustomFieldSection.find(custom_field.custom_field_section_id).shown_in_overview_sidebar?
end
end
-2
View File
@@ -12,8 +12,6 @@ Rails.application.routes.draw do
get "project_custom_fields_sidebar" => :project_custom_fields_sidebar, as: :custom_fields_sidebar
get "project_life_cycle_sidebar" => :project_life_cycle_sidebar, as: :life_cycle_sidebar
end
resources :project_custom_fields, only: %i[show edit update], as: :custom_fields
end
end
end
@@ -75,18 +75,16 @@ RSpec.describe "Project description widget", :js do
# Edit the project description
# Find the editable description field
description_field = Turbo::TextEditorField.new(page,
"description",
selector:)
description_field = Components::Common::InplaceEditField.new(portfolio, :description)
# Activate the field for editing
description_field.activate!
description_field.open_field
wait_for_network_idle
# Set a new description
new_description = "This is a **test** project description with markdown formatting."
description_field.set_value(new_description)
# Save the changes
description_field.save!
description_field.fill_and_submit_value(name: "project[description]", val: new_description, ckeditor: true)
tested_page.expect_and_dismiss_flash message: I18n.t("js.notice_successful_update")
@@ -1,117 +0,0 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe Overviews::ProjectCustomFieldsController do
describe "routing" do
describe "#show" do
it do
expect(get("/projects/my-project/project_custom_fields/33"))
.to route_to(
controller: "overviews/project_custom_fields",
action: "show",
project_id: "my-project",
id: "33"
)
end
end
describe "#edit" do
it do
expect(get("/projects/my-project/project_custom_fields/33/edit"))
.to route_to(
controller: "overviews/project_custom_fields",
action: "edit",
project_id: "my-project",
id: "33"
)
end
end
describe "PUT/PATCH #update" do
it do
expect(put("/projects/my-project/project_custom_fields/44"))
.to route_to(
controller: "overviews/project_custom_fields",
action: "update",
project_id: "my-project",
id: "44"
)
end
it do
expect(patch("/projects/my-project/project_custom_fields/44"))
.to route_to(
controller: "overviews/project_custom_fields",
action: "update",
project_id: "my-project",
id: "44"
)
end
end
end
describe "named routing" do
describe "GET #edit" do
it do
expect(get(edit_project_custom_field_path("my-project", 33)))
.to route_to(
controller: "overviews/project_custom_fields",
action: "edit",
project_id: "my-project",
id: "33"
)
end
end
describe "PUT/PATCH #update" do
it do
expect(put(project_custom_field_path("my-project", 44)))
.to route_to(
controller: "overviews/project_custom_fields",
action: "update",
project_id: "my-project",
id: "44"
)
end
it do
expect(patch("/projects/my-project/project_custom_fields/44"))
.to route_to(
controller: "overviews/project_custom_fields",
action: "update",
project_id: "my-project",
id: "44"
)
end
end
end
end
@@ -73,7 +73,7 @@ RSpec.describe OpenProject::Common::InplaceEditFieldComponent, type: :component
render_inline(described_class.new(model: project, attribute: :description, update_registry:))
expect(rendered_content)
.to have_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_editable")
.to have_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_clickable")
end
it "renders edit field when enforce_edit_mode is true" do
@@ -98,9 +98,38 @@ RSpec.describe OpenProject::Common::InplaceEditFieldComponent, type: :component
render_inline(described_class.new(model: project, attribute: :description, update_registry:))
expect(rendered_content)
.not_to include("click->inplace-edit#request")
.not_to include("click-&gt;inplace-edit#request")
expect(rendered_content)
.to have_no_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_editable")
.to have_no_css(".op-inplace-edit--display-field.op-inplace-edit--display-field_clickable")
end
end
describe "wrapper" do
let(:allowed_attributes) { %w(description) }
it "renders a stable key on the wrapper for calculated field refresh" do
render_inline(described_class.new(model: project, attribute: :description, update_registry:))
expected_key = "project_#{project.id}_description"
expect(rendered_content)
.to have_css("[data-inplace-edit-stable-key='#{expected_key}']")
end
end
describe "open_in_dialog" do
let(:allowed_attributes) { %w(description) }
it "uses the dialog controller on the display field when open_in_dialog is true" do
render_inline(
described_class.new(
model: project,
attribute: :description,
open_in_dialog: true,
update_registry:
)
)
expect(rendered_content).to include("click-&gt;inplace-edit#openDialog")
end
end
end
@@ -0,0 +1,115 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFieldDialogComponent, type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
let(:allowed_attributes) { %w[description] }
let(:contract) do
contract = instance_double(BaseContract)
allow(contract).to receive(:writable?) { |attr| allowed_attributes.include?(attr.to_s) }
allow(contract).to receive(:model).and_return(instance_double(Project))
contract
end
let(:contract_class) do
instance_double(Class).tap do |klass|
allow(klass).to receive(:new).with(project, User.current).and_return(contract)
end
end
let(:update_registry) do
registry = OpenProject::InplaceEdit::UpdateRegistry.new
registry.register(Project, handler: double, contract: contract_class)
registry
end
before { allow(User).to receive(:current).and_return(build_stubbed(:user)) }
it "renders a dialog with the expected ID and label" do
render_inline(described_class.new(model: project, attribute: :description,
system_arguments: { update_registry:, writable: true }))
expect(rendered_content).to have_css("#inplace-edit-field-dialog--project-#{project.id}--description")
expect(rendered_content).to have_text(Project.human_attribute_name(:description))
end
it "uses system_arguments[:label] as dialog title when provided" do
render_inline(
described_class.new(
model: project,
attribute: :description,
system_arguments: { update_registry:, writable: true, label: "My Label" }
)
)
expect(rendered_content).to have_text("My Label")
end
context "when the user has write access" do
let(:allowed_attributes) { %w[description] }
it "renders the edit form in the dialog body" do
render_inline(described_class.new(model: project, attribute: :description,
system_arguments: { update_registry:, writable: true }))
expect(rendered_content).to have_test_selector("op-inplace-edit-field--form")
end
it "renders Cancel and Save buttons in the footer" do
render_inline(described_class.new(model: project, attribute: :description,
system_arguments: { update_registry:, writable: true }))
expect(rendered_content).to have_button(I18n.t(:button_cancel))
expect(rendered_content).to have_button(I18n.t(:button_save))
end
end
context "when the user does not have write access" do
let(:allowed_attributes) { [] }
it "renders the display component in the dialog body instead of the edit form" do
render_inline(described_class.new(model: project, attribute: :description,
system_arguments: { update_registry:, writable: false }))
expect(rendered_content).not_to have_test_selector("op-inplace-edit-field--form")
expect(rendered_content).to have_css(".op-inplace-edit--display-field")
end
it "renders only a Close button in the footer" do
render_inline(described_class.new(model: project, attribute: :description,
system_arguments: { update_registry:, writable: false }))
expect(rendered_content).to have_button(I18n.t(:button_close))
expect(rendered_content).to have_no_button(I18n.t(:button_cancel))
expect(rendered_content).to have_no_button(I18n.t(:button_save))
end
end
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::BooleanInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a checkbox for the attribute with stimulus controller attached" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_field("project[name]", type: "checkbox")
expect(rendered_content).to include("click-&gt;inplace-edit#submitForm")
end
it "does not add a submit-on-click Stimulus action whe show_action_buttons is false" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false)
end
end
end
expect(rendered_content).not_to include("click-&gt;inplace-edit#submitForm")
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.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::CalculatedValueInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a readonly text input without buttons" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_field("project[name]", type: "text", readonly: true)
expect(rendered_content).to have_no_button(I18n.t(:button_save))
expect(rendered_content).to have_no_button(I18n.t(:button_cancel))
end
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DateInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a date input for the attribute with Stimulus controller attached" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_field("project[name]", type: "date")
expect(rendered_content).to include("change-&gt;inplace-edit#submitForm")
end
it "does not add a submit-on-change Stimulus action whe show_action_buttons is false" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false)
end
end
end
expect(rendered_content).not_to include("change-&gt;inplace-edit#submitForm")
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::CalculatedValueInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project, name: "42") }
it "never attaches the inplace-edit Stimulus controller, even when writable is passed" do
render_inline(
described_class.new(model: project, attribute: :name, writable: true, truncated: false, id: "cf-42")
)
expect(rendered_content).not_to include("click->inplace-edit#request")
expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_clickable")
end
it "renders the not-editable tooltip" do
render_inline(
described_class.new(model: project, attribute: :name, writable: false, truncated: false, id: "cf-42")
)
expect(rendered_content).to have_text(I18n.t("custom_fields.calculated_field_not_editable"))
end
end
@@ -0,0 +1,82 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::DisplayFieldComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project, name: "My project") }
describe "value rendering" do
it "renders the attribute value" do
render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false))
expect(rendered_content).to have_text("My project")
end
it "renders a placeholder when the value is blank" do
project = build_stubbed(:project, name: nil)
render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("placeholders.default"))
end
it "renders 'Yes' for a true boolean value" do
project = build_stubbed(:project, public: true)
render_inline(described_class.new(model: project, attribute: :public, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("general_text_Yes"))
end
it "renders 'No' for a false boolean value" do
project = build_stubbed(:project, public: false)
render_inline(described_class.new(model: project, attribute: :public, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("general_text_No"))
end
end
describe "editability" do
it "marks the display field as editable when writable" do
render_inline(described_class.new(model: project, attribute: :name, writable: true, truncated: false))
expect(rendered_content).to have_css(".op-inplace-edit--display-field_clickable")
expect(rendered_content).to include("click-&gt;inplace-edit#request")
end
it "does not mark the display field as editable when not writable" do
render_inline(described_class.new(model: project, attribute: :name, writable: false, truncated: false))
expect(rendered_content).to have_no_css(".op-inplace-edit--display-field_clickable")
expect(rendered_content).not_to include("click-&gt;inplace-edit#request")
end
end
end
@@ -0,0 +1,69 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::HierarchyListComponent,
type: :component, with_ee: [:custom_field_hierarchies] do
include ViewComponent::TestHelpers
let(:project) { create(:project) }
let(:custom_field) { create(:project_custom_field, :hierarchy) }
let(:attribute) { custom_field.attribute_name.to_sym }
it "renders a placeholder when no value is set" do
render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("placeholders.default"))
end
it "renders the item label for a single hierarchy value" do
item = create(:hierarchy_item, label: "Alpha")
create(:custom_value, :skip_validations, customized: project, custom_field:, value: item.id.to_s)
render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false))
expect(rendered_content).to have_text("Alpha")
end
context "with a multi-value hierarchy field" do
let(:custom_field) { create(:project_custom_field, :multi_hierarchy) }
it "renders multiple item labels joined by comma" do
item1 = create(:hierarchy_item, label: "Alpha")
item2 = create(:hierarchy_item, label: "Beta")
create(:custom_value, :skip_validations, customized: project, custom_field:, value: item1.id.to_s)
create(:custom_value, :skip_validations, customized: project, custom_field:, value: item2.id.to_s)
render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false))
expect(rendered_content).to have_text("Alpha, Beta")
end
end
end
@@ -0,0 +1,55 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::LinkInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a link for a URL value" do
without_partial_double_verification do
allow(project).to receive(:homepage).and_return("https://example.com")
render_inline(described_class.new(model: project, attribute: :homepage, writable: false, truncated: false))
expect(rendered_content).to have_link("https://example.com", href: "https://example.com")
end
end
it "renders a placeholder when the value is blank" do
without_partial_double_verification do
allow(project).to receive(:homepage).and_return(nil)
render_inline(described_class.new(model: project, attribute: :homepage, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("placeholders.default"))
end
end
end
@@ -40,24 +40,27 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::RichTextAr
described_class.new(
model: project,
attribute: :description,
writable: true
writable: true,
truncated: false
)
)
expect(rendered_content).to have_css("h2", text: "Hello")
expect(rendered_content).to include("click-&gt;inplace-edit#request")
end
it "adds inplace-edit stimulus data when writable" do
it "renders a truncated attribute component when truncated is true" do
render_inline(
described_class.new(
model: project,
attribute: :description,
writable: true
writable: false,
truncated: true
)
)
expect(rendered_content)
.to include("data-action=\"click-&gt;inplace-edit#request\"")
expect(rendered_content).to have_css("[data-controller='attribute']", text: "Hello")
expect(rendered_content).to have_css(".ellipsis-expander")
end
it "adds no inplace-edit stimulus data when not writable" do
@@ -65,11 +68,11 @@ RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::RichTextAr
described_class.new(
model: project,
attribute: :description,
writable: false
writable: false,
truncated: false
)
)
expect(rendered_content)
.not_to include("data-action=\"click-&gt;inplace-edit#request\"")
expect(rendered_content).not_to include("click-&gt;inplace-edit#request")
end
end
@@ -0,0 +1,64 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::SelectListComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a single string value" do
without_partial_double_verification do
allow(project).to receive(:status_label).and_return("Active")
render_inline(described_class.new(model: project, attribute: :status_label, writable: false, truncated: false))
expect(rendered_content).to have_text("Active")
end
end
it "renders multiple values joined by comma" do
without_partial_double_verification do
allow(project).to receive(:tag_list).and_return(%w[Alpha Beta])
render_inline(described_class.new(model: project, attribute: :tag_list, writable: false, truncated: false))
expect(rendered_content).to have_text("Alpha, Beta")
end
end
it "renders a placeholder when the value is blank" do
without_partial_double_verification do
allow(project).to receive(:status_label).and_return(nil)
render_inline(described_class.new(model: project, attribute: :status_label, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("placeholders.default"))
end
end
end
@@ -0,0 +1,57 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::DisplayFields::UserSelectListComponent,
type: :component do
include ViewComponent::TestHelpers
let(:user_admin) { create(:admin) }
let(:project) { create(:project) }
let(:custom_field) { create(:project_custom_field, :user, projects: [project]) }
let(:attribute) { custom_field.attribute_name.to_sym }
let(:selected_user) { create(:user) }
before { allow(User).to receive(:current).and_return(user_admin) }
it "renders the user avatar for a single-value user custom field" do
create(:custom_value, :skip_validations, customized: project, custom_field:, value: selected_user.id.to_s)
render_inline(described_class.new(model: Project.find(project.id), attribute:, writable: false, truncated: false))
expect(rendered_content).to have_css "opce-principal"
expect(rendered_content).to have_no_text(I18n.t("placeholders.default"))
end
it "renders the placeholder when no user is selected" do
render_inline(described_class.new(model: project, attribute:, writable: false, truncated: false))
expect(rendered_content).to have_text(I18n.t("placeholders.default"))
end
end
@@ -0,0 +1,51 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::FloatInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a number input for the attribute" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_field("project[name]", type: "number")
expect(rendered_content).to include('step="any"')
end
end
@@ -0,0 +1,135 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::HierarchyListComponent,
type: :component, with_ee: [:custom_field_hierarchies] do
include ViewComponent::TestHelpers
describe ".open_in_dialog?" do
it "returns true so that the field always opens in a dialog" do
expect(described_class.open_in_dialog?).to be(true)
end
end
context "with a single-value hierarchy custom field" do
let(:project) { create(:project) }
let(:custom_field) { create(:project_custom_field, :hierarchy) }
let(:attribute) { custom_field.attribute_name.to_sym }
let!(:item) { create(:hierarchy_item, label: "Alpha", parent: custom_field.hierarchy_root) }
def render_component
component_class = described_class
cf_attribute = attribute
cf_label = custom_field.name
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label)
end
end
end
end
it "renders a filterable-tree-view element" do
render_component
expect(rendered_content).to have_css("filterable-tree-view")
end
it "renders a hidden sentinel field to allow clearing the selection" do
render_component
expect(rendered_content).to have_field("project[custom_field_values][]", type: :hidden, with: "")
end
it "renders item labels inside the tree" do
render_component
expect(rendered_content).to have_text("Alpha")
end
it "renders items with single-select checkmarks" do
render_component
expect(rendered_content).to have_css(".TreeViewItem-singleSelectCheckmark")
end
end
context "with a multi-value hierarchy custom field" do
let(:project) { create(:project) }
let(:custom_field) { create(:project_custom_field, :multi_hierarchy) }
let(:attribute) { custom_field.attribute_name.to_sym }
let!(:item) { create(:hierarchy_item, label: "Beta", parent: custom_field.hierarchy_root) }
it "renders items with multi-select checkboxes" do
component_class = described_class
cf_attribute = attribute
cf_label = custom_field.name
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label)
end
end
end
expect(rendered_content).to have_css(".FormControl-checkbox")
end
end
context "with a pre-selected item" do
let(:user) { create(:admin) }
let(:project) { create(:project) }
let(:custom_field) { create(:project_custom_field, :hierarchy, projects: [project]) }
let(:attribute) { custom_field.attribute_name.to_sym }
let!(:item) { create(:hierarchy_item, label: "Gamma", parent: custom_field.hierarchy_root) }
before do
allow(User).to receive(:current).and_return(user)
create(:custom_value, :skip_validations, customized: project, custom_field:, value: item.id.to_s)
end
it "marks the currently selected item as checked" do
component_class = described_class
cf_attribute = attribute
cf_label = custom_field.name
render_in_view_context(Project.find(project.id)) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label)
end
end
end
expect(rendered_content).to have_css("[aria-checked='true']", text: "Gamma")
end
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::IntegerInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a number input for the attribute" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_field("project[name]", type: "number")
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::LinkInputComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders a URL input for the attribute" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_field("project[name]", type: "url")
end
end
@@ -43,7 +43,8 @@ RSpec.describe OpenProject::Common::InplaceEditFields::RichTextAreaComponent,
render component_class.new(
form:,
model:,
attribute: :name
attribute: :name,
label: "Name"
)
end
end
@@ -54,4 +55,18 @@ RSpec.describe OpenProject::Common::InplaceEditFields::RichTextAreaComponent,
expect(rendered_content).to have_button(I18n.t(:button_save))
expect(rendered_content).to have_button(I18n.t(:button_cancel))
end
it "omits action buttons when show_action_buttons is false" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false)
end
end
end
expect(rendered_content).to have_no_button(I18n.t(:button_save))
expect(rendered_content).to have_no_button(I18n.t(:button_cancel))
end
end
@@ -0,0 +1,67 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::SelectListComponent,
type: :component do
include ViewComponent::TestHelpers
let(:project) { build_stubbed(:project) }
it "renders save and cancel buttons" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name")
end
end
end
expect(rendered_content).to have_css("opce-autocompleter")
expect(rendered_content).to have_button(I18n.t(:button_save))
expect(rendered_content).to have_button(I18n.t(:button_cancel))
end
it "omits action buttons when show_action_buttons is false" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false)
end
end
end
expect(rendered_content).to have_css("opce-autocompleter")
expect(rendered_content).to have_no_button(I18n.t(:button_save))
expect(rendered_content).to have_no_button(I18n.t(:button_cancel))
end
end
@@ -43,12 +43,27 @@ RSpec.describe OpenProject::Common::InplaceEditFields::TextInputComponent,
render component_class.new(
form:,
model:,
attribute: :name
attribute: :name,
label: "Name"
)
end
end
end
expect(rendered_content).to have_field("project[name]", type: "text")
expect(rendered_content).to include("keydown.esc-&gt;inplace-edit#request")
end
it "does not add a submit-on-change Stimulus action whe show_action_buttons is false" do
component_class = described_class
render_in_view_context(project) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: :name, label: "Name", show_action_buttons: false)
end
end
end
expect(rendered_content).not_to include("keydown.esc-&gt;inplace-edit#request")
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "rails_helper"
RSpec.describe OpenProject::Common::InplaceEditFields::VersionSelectListComponent,
type: :component do
include ViewComponent::TestHelpers
let(:user_admin) { create(:admin) }
let(:project) { create(:project) }
let(:custom_field) { create(:project_custom_field, :version, projects: [project]) }
let(:attribute) { custom_field.attribute_name.to_sym }
let(:version1) { create(:version, name: "v1.0", project:) }
let(:version2) { create(:version, name: "v2.0", project:) }
before { allow(User).to receive(:current).and_return(user_admin) }
def render_component(project_model)
component_class = described_class
cf_attribute = attribute
cf_label = custom_field.name
render_in_view_context(project_model) do |model|
primer_form_with(url: "/foo", model:) do |f|
render_inline_form(f) do |form|
render component_class.new(form:, model:, attribute: cf_attribute, label: cf_label)
end
end
end
end
it "renders an autocompleter for a version custom field" do
version1
version2
render_component(project)
expect(rendered_content).to have_css("opce-autocompleter")
# Options are serialised as JSON in the opce-autocompleter's items attribute
expect(rendered_content).to include("v1.0")
expect(rendered_content).to include("v2.0")
end
it "marks the currently selected version via the model attribute" do
version1
create(:custom_value, :skip_validations, customized: project, custom_field:, value: version1.id.to_s)
render_component(Project.find(project.id))
# The decorated autocompleter serialises the selected item as a data-model attribute
expect(page).to have_element "opce-autocompleter" do |autocompleter|
expect(autocompleter["data-model"]).to be_json_eql(
%{{"disabled": false, "group_by": "#{project.name}", "name": "#{version1.name}", "selected": true}}
)
end
end
end
@@ -67,6 +67,21 @@ RSpec.describe InplaceEditFieldsController do
end
end
describe "GET #dialog" do
let(:handler) { double }
it "returns a turbo stream response with the dialog component" do
get :dialog, params: {
model: model_param,
id: model.id,
attribute:
}, format: :turbo_stream
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
end
end
describe "PATCH #update" do
let(:handler) { double(call: success) }
@@ -106,6 +121,58 @@ RSpec.describe InplaceEditFieldsController do
end
end
context "when successful and system_arguments contain a wrapper_id (dialog context)" do
let(:handler) { double(call: true) }
let(:wrapper_id) { "#my-inplace-dialog" }
it "includes a turbo stream to close the dialog" do
patch :update, params: {
model: model_param,
id: model.id,
attribute:,
project: { name: "New project" },
system_arguments_json: { wrapper_id: }.to_json
}, format: :turbo_stream
expect(response).to have_http_status(:ok)
expect(response.body).to include("my-inplace-dialog")
end
end
context "when attribute is a custom field (hash params via fields_for)" do
let(:handler) { double(call: true) }
let(:custom_field) { create(:project_custom_field) }
let(:attribute) { custom_field.attribute_name.to_sym }
it "accepts custom_field_values hash params and returns ok" do
patch :update, params: {
model: model_param,
id: model.id,
attribute:,
project: { custom_field_values: { custom_field.id.to_s => "Option A" } }
}, format: :turbo_stream
expect(response).to have_http_status(:ok)
end
end
context "when attribute is a custom field (array params from FilterableTreeView)" do
let(:handler) { double(call: true) }
let(:custom_field) { create(:project_custom_field) }
let(:attribute) { custom_field.attribute_name.to_sym }
it "accepts custom_field_values array params and returns ok" do
patch :update, params: {
model: model_param,
id: model.id,
attribute:,
project: { custom_field_values: ["{\"value\":\"42\"}", ""] }
}, format: :turbo_stream
expect(response).to have_http_status(:ok)
end
end
context "when no update handler is registered" do
let(:handler) { nil }
+1 -1
View File
@@ -470,7 +470,7 @@ RSpec.describe "Projects copy", :js,
overview_page.within_project_attributes_sidebar do
# User has no permission to edit project attributes.
expect(page).to have_no_css("[data-test-selector*='project-custom-field-modal-button-']")
expect(page).to have_no_css("[data-test-selector*='inplace-edit-dialog-button-']")
# The custom fields are still copied from the parent project.
expect(page).to have_content(project_custom_field.name)
expect(page).to have_content("some text cf")
@@ -29,7 +29,7 @@
#++
require "spec_helper"
require_relative "../shared_context"
require_relative "shared_context"
RSpec.describe "Edit project custom fields on project overview page", "attribute help texts", :js do
include_context "with seeded projects, members and project custom fields"
@@ -46,10 +46,13 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
context "without attribute help texts defined" do
it "shows field labels without help text link" do
input_fields.each do |custom_field|
edit_dialog = overview_page.open_modal_for_custom_field(custom_field)
edit_dialog.expect_title "Input fields"
edit_dialog.expect_field_label_without_help_text custom_field.name
edit_dialog.close
field = if custom_field == text_project_custom_field
overview_page.open_modal_for_custom_field(custom_field)
else
overview_page.open_inplace_edit_field_for_custom_field(custom_field)
end
field.expect_field_label_without_help_text custom_field.name
field.close
end
end
end
@@ -65,19 +68,21 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
it "shows field labels with help text link" do
input_fields.each do |custom_field|
edit_dialog = overview_page.open_modal_for_custom_field(custom_field)
edit_dialog.expect_title "Input fields"
edit_dialog.expect_field_label_with_help_text custom_field.name
edit_dialog.close
field = if custom_field == text_project_custom_field
overview_page.open_modal_for_custom_field(custom_field)
else
overview_page.open_inplace_edit_field_for_custom_field(custom_field)
end
field.expect_field_label_with_help_text custom_field.name
field.close
end
end
context "without attachments" do
it "shows help text modal on clicking help text link" do
edit_dialog = overview_page.open_modal_for_custom_field(date_project_custom_field)
edit_dialog.expect_title "Input fields"
field = overview_page.open_inplace_edit_field_for_custom_field(date_project_custom_field)
edit_dialog.click_help_text_link_for_label "Date field"
field.click_help_text_link_for_label "Date field"
expect(page).to have_modal "Date field"
within_modal "Date field" do
@@ -94,10 +99,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
let!(:attachments) { create_list(:attachment, 2, container: integer_help_text) }
it "shows help text modal, including attachments, on clicking help text link" do
edit_dialog = overview_page.open_modal_for_custom_field(integer_project_custom_field)
edit_dialog.expect_title "Input fields"
field = overview_page.open_inplace_edit_field_for_custom_field(integer_project_custom_field)
edit_dialog.click_help_text_link_for_label "Integer field"
field.click_help_text_link_for_label "Integer field"
expect(page).to have_modal "Integer field"
within_modal "Integer field" do
expect(page).to have_text "Attribute help text"
@@ -128,10 +132,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
context "without attribute help texts defined" do
it "shows field labels without help text link" do
select_fields.each do |custom_field|
edit_dialog = overview_page.open_modal_for_custom_field(custom_field)
edit_dialog.expect_title "Select fields"
edit_dialog.expect_field_label_without_help_text custom_field.name
edit_dialog.close
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
field.expect_field_label_without_help_text custom_field.name
field.close
end
end
end
@@ -143,18 +146,16 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
it "shows field labels with help text link" do
select_fields.each do |custom_field|
edit_dialog = overview_page.open_modal_for_custom_field(custom_field)
edit_dialog.expect_title "Select fields"
edit_dialog.expect_field_label_with_help_text custom_field.name
edit_dialog.close
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
field.expect_field_label_with_help_text custom_field.name
field.close
end
end
it "shows help text modal on clicking help text link" do
edit_dialog = overview_page.open_modal_for_custom_field(user_project_custom_field)
edit_dialog.expect_title "Select fields"
field = overview_page.open_inplace_edit_field_for_custom_field(user_project_custom_field)
edit_dialog.click_help_text_link_for_label "User field"
field.click_help_text_link_for_label "User field"
expect(page).to have_modal "User field"
within_modal "User field" do
@@ -171,10 +172,9 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
context "without attribute help texts defined" do
it "shows field labels without help text link" do
multi_select_fields.each do |custom_field|
edit_dialog = overview_page.open_modal_for_custom_field(custom_field)
edit_dialog.expect_title "Multi select fields"
edit_dialog.expect_field_label_without_help_text custom_field.name
edit_dialog.close
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
field.expect_field_label_without_help_text custom_field.name
field.close
end
end
end
@@ -192,18 +192,16 @@ RSpec.describe "Edit project custom fields on project overview page", "attribute
it "shows field labels with help text link" do
multi_select_fields.each do |custom_field|
edit_dialog = overview_page.open_modal_for_custom_field(custom_field)
edit_dialog.expect_title "Multi select fields"
edit_dialog.expect_field_label_with_help_text custom_field.name
edit_dialog.close
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
field.expect_field_label_with_help_text custom_field.name
field.close
end
end
it "shows help text modal on clicking help text link" do
edit_dialog = overview_page.open_modal_for_custom_field(multi_list_project_custom_field)
edit_dialog.expect_title "Multi select fields"
field = overview_page.open_inplace_edit_field_for_custom_field(multi_list_project_custom_field)
edit_dialog.click_help_text_link_for_label "Multi list field"
field.click_help_text_link_for_label "Multi list field"
expect(page).to have_modal "Multi list field"
within_modal "Multi list field" do
@@ -29,46 +29,57 @@
#++
require "spec_helper"
require_relative "../shared_context"
RSpec.describe "Edit project custom fields on project overview page", :js do
include_context "with seeded projects, members and project custom fields"
let(:project) { create(:project) }
let(:admin) { create(:admin) }
let(:project_custom_field_section) { create(:project_custom_field_section, name: "Section A") }
let(:text_project_custom_field) do
create(:text_project_custom_field,
name: "Required Foo",
project_custom_field_section:)
end
let(:overview_page) { Pages::Projects::Show.new(project) }
before do
login_as member_with_project_attributes_edit_permissions
create(:project_custom_field_project_mapping, project:, project_custom_field: text_project_custom_field)
login_as admin
overview_page.visit_page
end
it "opens a dialog showing the input for project custom field" do
dialog = overview_page.open_modal_for_custom_field(boolean_project_custom_field)
field = overview_page.open_modal_for_custom_field(text_project_custom_field)
dialog = field.dialog
dialog.expect_open
dialog.within_async_content(close_after_yield: true) do
expect(page).to have_content(boolean_project_custom_field.name)
expect(page).to have_content(text_project_custom_field.name)
end
end
it "renders the dialog body asynchronically" do
dialog = Components::Projects::ProjectCustomFields::Dialog.new(project, boolean_project_custom_field)
dialog = Components::Common::InplaceEditFields::Dialog.new(project, text_project_custom_field.attribute_name.to_sym)
expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all)
overview_page.open_modal_for_custom_field(boolean_project_custom_field)
field = overview_page.open_modal_for_custom_field(text_project_custom_field)
dialog = field.dialog
expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible)
end
it "can be closed via close icon or cancel button" do
dialog = overview_page.open_modal_for_custom_field(boolean_project_custom_field)
field = overview_page.open_modal_for_custom_field(text_project_custom_field)
dialog = field.dialog
dialog.close_via_icon
dialog.expect_closed
dialog = overview_page.open_modal_for_custom_field(string_project_custom_field)
field = overview_page.open_modal_for_custom_field(text_project_custom_field)
dialog = field.dialog
dialog.close_via_button
@@ -29,7 +29,7 @@
#++
require "spec_helper"
require_relative "../shared_context"
require_relative "shared_context"
RSpec.describe "Edit project custom fields on project overview page", :js do
include_context "with seeded projects, members and project custom fields"
@@ -44,15 +44,13 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with correct initialization and input behaviour" do
# not using let as dialog is closed every time, so new should be opened
def dialog = overview_page.open_modal_for_custom_field(custom_field)
def field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
def dialog = overview_page.open_modal_for_custom_field(custom_field).dialog
shared_examples "shows comment input only when comments are allowed by custom field" do
it "shows comment input only when comments are allowed by custom field" do
dialog.within_async_content(close_after_yield: true) do
expect(page).to have_no_field("Comment")
end
custom_field.update!(has_comment: true)
refresh
dialog.within_async_content(close_after_yield: true) do
expect(page).to have_field("Comment", with: "")
@@ -82,7 +80,8 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to value_expectation
end
expect(page).to have_field("Comment", with: "baz", readonly: true)
expect(page).to have_text("Comment")
expect(page).to have_text("baz")
end
end
end
@@ -90,7 +89,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with input fields" do
shared_examples "a custom field checkbox" do
it "shows the correct value if given" do
dialog.within_async_content(close_after_yield: true) do
field.within_field do
if expected_initial_value
expect(page).to have_checked_field(custom_field.name)
else
@@ -102,23 +101,27 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "is unchecked if no value and no default value is given" do
custom_field.custom_values.destroy_all
dialog.within_async_content(close_after_yield: true) do
field.within_field do
expect(page).to have_no_checked_field(custom_field.name)
end
end
it "shows default value if no value is given" do
it "shows default true value if no value is given" do
custom_field.custom_values.destroy_all
custom_field.update!(default_value: true)
dialog.within_async_content(close_after_yield: true) do
field.within_field do
expect(page).to have_checked_field(custom_field.name)
end
end
it "shows default false value if no value is given" do
custom_field.custom_values.destroy_all
custom_field.update!(default_value: false)
dialog.within_async_content(close_after_yield: true) do
field.within_field do
expect(page).to have_no_checked_field(custom_field.name)
end
end
@@ -128,7 +131,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
shared_examples "a custom field input" do
it "shows the correct value if given" do
dialog.within_async_content(close_after_yield: true) do
field.within_field do
expect(page).to have_field(custom_field.name, with: expected_initial_value)
end
end
@@ -136,7 +139,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "shows a blank input if no value or default value is given" do
custom_field.custom_values.destroy_all
dialog.within_async_content(close_after_yield: true) do
field.within_field do
expect(page).to have_field(custom_field.name, with: expected_blank_value)
end
end
@@ -145,7 +148,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.custom_values.destroy_all
custom_field.update!(default_value:)
dialog.within_async_content(close_after_yield: true) do
field.within_field do
expect(page).to have_field(custom_field.name, with: default_value)
end
end
@@ -156,7 +159,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
shared_examples "a rich text custom field input" do
it "shows the correct value if given" do
dialog.within_async_content(close_after_yield: true) do
field.expect_value(expected_initial_value)
form_field.expect_value(expected_initial_value)
end
end
@@ -164,7 +167,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.custom_values.destroy_all
dialog.within_async_content(close_after_yield: true) do
field.expect_value(expected_blank_value)
form_field.expect_value(expected_blank_value)
end
end
@@ -173,7 +176,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.update!(default_value:)
dialog.within_async_content(close_after_yield: true) do
field.expect_value(default_value)
form_field.expect_value(default_value)
end
end
@@ -242,7 +245,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
let(:default_value) { "https://openproject.org" }
let(:expected_blank_value) { "" }
let(:expected_initial_value) { "https://www.openproject.org" }
let(:field) { FormFields::Primerized::InputField.new(custom_field) }
let(:form_field) { FormFields::Primerized::InputField.new(custom_field) }
it_behaves_like "a custom field input"
@@ -259,7 +262,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with text CF" do
let(:custom_field) { text_project_custom_field }
let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) }
let(:form_field) { FormFields::Primerized::EditorFormField.new(custom_field) }
let(:default_value) { "Default value" }
let(:expected_blank_value) { "" }
let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing?
@@ -318,50 +321,50 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with single select fields" do
shared_examples "an autocomplete single select field" do
it "shows the correct value if given" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.expect_selected(expected_initial_value)
end
field.expect_selected(expected_initial_value)
end
it "shows a blank input if no value or default value is given" do
custom_field.custom_values.destroy_all
overview_page.open_modal_for_custom_field(custom_field)
field.expect_blank
field.within_field do
form_field.expect_blank
end
end
it "filters the list based on the input" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search(second_option)
field.search(second_option)
field.expect_option(second_option)
field.expect_no_option(first_option)
field.expect_no_option(third_option)
form_field.expect_option(second_option)
form_field.expect_no_option(first_option)
form_field.expect_no_option(third_option)
end
end
it "enables the user to select a single value from a list" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search(second_option)
form_field.select_option(second_option)
field.search(second_option)
field.select_option(second_option)
form_field.expect_selected(second_option)
field.expect_selected(second_option)
form_field.search(third_option)
form_field.select_option(third_option)
field.search(third_option)
field.select_option(third_option)
field.expect_selected(third_option)
field.expect_not_selected(second_option)
form_field.expect_selected(third_option)
form_field.expect_not_selected(second_option)
end
end
it "clears the input if clicked on the clear button" do
overview_page.open_modal_for_custom_field(custom_field)
field.clear
field.expect_blank
field.within_field do
form_field.clear
form_field.expect_blank
end
end
include_examples "shows comment input only when comments are allowed by custom field"
@@ -369,7 +372,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with single select list CF" do
let(:custom_field) { list_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:expected_initial_value) { custom_field.custom_options.first.value }
@@ -384,9 +387,9 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.custom_options.first.update!(default_value: true)
overview_page.open_modal_for_custom_field(custom_field)
field.expect_selected(custom_field.custom_options.first.value)
field.within_field do
form_field.expect_selected(custom_field.custom_options.first.value)
end
end
it_behaves_like "displays readonly modal for user without edit permission"
@@ -394,7 +397,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with single version select list CF" do
let(:custom_field) { version_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:expected_initial_value) { first_version.name }
@@ -411,11 +414,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only versions that are associated with this project" do
overview_page.open_modal_for_custom_field(custom_field)
field.search("Version 1")
field.expect_option(first_version.name, grouping: project.name)
field.expect_no_option(version_in_other_project.name)
field.within_field do
form_field.search("Version 1")
form_field.expect_option(first_version.name, grouping: project.name)
form_field.expect_no_option(version_in_other_project.name)
end
end
end
@@ -430,11 +433,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
let(:allow_non_open_versions) { false }
it "does not shows closed version option" do
overview_page.open_modal_for_custom_field(custom_field)
field.open_options
field.within_field do
form_field.open_options
field.expect_option(first_version.name)
field.expect_no_option(closed_version.name)
form_field.expect_option(first_version.name)
form_field.expect_no_option(closed_version.name)
end
end
end
@@ -442,11 +446,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
let(:allow_non_open_versions) { true }
it "shows closed version option" do
overview_page.open_modal_for_custom_field(custom_field)
field.open_options
field.within_field do
form_field.open_options
field.expect_option(first_version.name)
field.expect_option(closed_version.name)
form_field.expect_option(first_version.name)
form_field.expect_option(closed_version.name)
end
end
end
end
@@ -457,7 +462,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with single user select list CF" do
let(:custom_field) { user_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:expected_initial_value) { member_in_project.name }
@@ -476,12 +481,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only users that are members of the project" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search("Member 1")
field.search("Member 1")
field.expect_option(member_in_project.name)
field.expect_no_option(member_in_other_project.name)
form_field.expect_option(member_in_project.name)
form_field.expect_no_option(member_in_other_project.name)
end
end
end
@@ -502,12 +507,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only groups that are associated with this project" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search("Group 1")
field.search("Group 1")
field.expect_option(group.name)
field.expect_no_option(group_in_other_project.name)
form_field.expect_option(group.name)
form_field.expect_no_option(group_in_other_project.name)
end
end
end
@@ -518,11 +523,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows the placeholder user" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search("Placeholder User")
field.search("Placeholder User")
field.expect_option(placeholder_user.name)
form_field.expect_option(placeholder_user.name)
end
end
end
@@ -533,67 +538,67 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with multi select fields" do
shared_examples "an autocomplete multi select field" do
it "shows the correct value if given" do
overview_page.open_modal_for_custom_field(custom_field)
field.expect_selected(*expected_initial_value)
field.within_field do
form_field.expect_selected(*expected_initial_value)
end
end
it "shows a blank input if no value or default value is given" do
custom_field.custom_values.destroy_all
overview_page.open_modal_for_custom_field(custom_field)
field.expect_blank
field.within_field do
form_field.expect_blank
end
end
it "filters the list based on the input" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search(second_option)
field.search(second_option)
field.expect_option(second_option)
field.expect_no_option(first_option)
field.expect_no_option(third_option)
form_field.expect_option(second_option)
form_field.expect_no_option(first_option)
form_field.expect_no_option(third_option)
end
end
it "allows to select multiple values" do
custom_field.custom_values.destroy_all
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.select_option(second_option)
form_field.select_option(third_option)
field.select_option(second_option)
field.select_option(third_option)
field.expect_selected(second_option)
field.expect_selected(third_option)
form_field.expect_selected(second_option)
form_field.expect_selected(third_option)
end
end
it "allows to remove selected values" do
custom_field.custom_values.destroy_all
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.select_option(second_option)
form_field.select_option(third_option)
field.select_option(second_option)
field.select_option(third_option)
form_field.deselect_option(third_option)
field.deselect_option(third_option)
field.expect_selected(second_option)
field.expect_not_selected(third_option)
form_field.expect_selected(second_option)
form_field.expect_not_selected(third_option)
end
end
it "allows to remove all selected values at once" do
custom_field.custom_values.destroy_all
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.select_option(second_option)
form_field.select_option(third_option)
field.select_option(second_option)
field.select_option(third_option)
form_field.clear
field.clear
field.expect_not_selected(second_option)
field.expect_not_selected(third_option)
form_field.expect_not_selected(second_option)
form_field.expect_not_selected(third_option)
end
end
include_examples "shows comment input only when comments are allowed by custom field"
@@ -601,7 +606,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with multi select list CF" do
let(:custom_field) { multi_list_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] }
@@ -617,10 +622,10 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
multi_list_project_custom_field.custom_options.first.update!(default_value: true)
multi_list_project_custom_field.custom_options.second.update!(default_value: true)
overview_page.open_modal_for_custom_field(custom_field)
field.expect_selected(multi_list_project_custom_field.custom_options.first.value)
field.expect_selected(multi_list_project_custom_field.custom_options.second.value)
field.within_field do
form_field.expect_selected(multi_list_project_custom_field.custom_options.first.value)
form_field.expect_selected(multi_list_project_custom_field.custom_options.second.value)
end
end
it_behaves_like "displays readonly modal for user without edit permission" do
@@ -630,7 +635,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with multi version select list CF" do
let(:custom_field) { multi_version_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:expected_initial_value) { [first_version.name, second_version.name] }
@@ -647,12 +652,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only versions that are associated with this project" do
overview_page.open_modal_for_custom_field(custom_field)
field.search("Version 1")
field.expect_option(first_version.name, grouping: project.name)
field.expect_no_option(version_in_other_project.name)
field.within_field do
form_field.search("Version 1")
form_field.expect_option(first_version.name, grouping: project.name)
form_field.expect_no_option(version_in_other_project.name)
end
end
end
@@ -667,11 +671,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
let(:allow_non_open_versions) { false }
it "does not shows closed version option" do
overview_page.open_modal_for_custom_field(custom_field)
field.open_options
field.within_field do
form_field.open_options
field.expect_option(first_version.name)
field.expect_no_option(closed_version.name)
form_field.expect_option(first_version.name)
form_field.expect_no_option(closed_version.name)
end
end
end
@@ -679,11 +684,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
let(:allow_non_open_versions) { true }
it "shows closed version option" do
overview_page.open_modal_for_custom_field(custom_field)
field.open_options
field.within_field do
form_field.open_options
field.expect_option(first_version.name)
field.expect_option(closed_version.name)
form_field.expect_option(first_version.name)
form_field.expect_option(closed_version.name)
end
end
end
end
@@ -696,7 +702,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with multi user select list CF" do
let(:custom_field) { multi_user_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] }
@@ -715,12 +721,12 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only users that are members of the project" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search("Member 1")
field.search("Member 1")
field.expect_option(member_in_project.name)
field.expect_no_option(member_in_other_project.name)
form_field.expect_option(member_in_project.name)
form_field.expect_no_option(member_in_other_project.name)
end
end
end
@@ -745,21 +751,21 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only groups that are associated with this project" do
overview_page.open_modal_for_custom_field(custom_field)
field.search("Group 1")
field.expect_option(group.name)
field.expect_no_option(group_in_other_project.name)
field.within_field do
form_field.search("Group 1")
form_field.expect_option(group.name)
form_field.expect_no_option(group_in_other_project.name)
end
end
it "enables to select multiple user groups" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.select_option("Group 1 in project")
form_field.select_option("Group 2 in project")
field.select_option("Group 1 in project")
field.select_option("Group 2 in project")
field.expect_selected("Group 1 in project")
field.expect_selected("Group 2 in project")
form_field.expect_selected("Group 1 in project")
form_field.expect_selected("Group 2 in project")
end
end
end
@@ -778,23 +784,23 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "shows only placeholder users from this project" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.search("Placeholder User")
field.search("Placeholder User")
field.expect_option(placeholder_user.name)
field.expect_option(another_placeholder_user.name)
field.expect_no_option(placeholder_user_in_other_project.name)
form_field.expect_option(placeholder_user.name)
form_field.expect_option(another_placeholder_user.name)
form_field.expect_no_option(placeholder_user_in_other_project.name)
end
end
it "enables to select multiple placeholder users" do
overview_page.open_modal_for_custom_field(custom_field)
field.within_field do
form_field.select_option(placeholder_user.name)
form_field.select_option(another_placeholder_user.name)
field.select_option(placeholder_user.name)
field.select_option(another_placeholder_user.name)
field.expect_selected(placeholder_user.name)
field.expect_selected(another_placeholder_user.name)
form_field.expect_selected(placeholder_user.name)
form_field.expect_selected(another_placeholder_user.name)
end
end
end
@@ -29,7 +29,7 @@
#++
require "spec_helper"
require_relative "../shared_context"
require_relative "shared_context"
RSpec.describe "Edit project custom fields on project overview page", :js do
include_context "with seeded projects, members and project custom fields"
@@ -77,7 +77,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "does not show the modal buttons" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']")
expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']")
end
end
end
@@ -91,7 +91,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "does not show the modal buttons" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']")
expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']")
end
end
@@ -100,7 +100,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "does not show the modal buttons" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']")
expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']")
end
end
end
@@ -117,7 +117,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "does not show the modal buttons" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_no_css("[data-test-selector*='project-custom-field-modal-button-']")
expect(page).to have_no_css("[data-test-selector*='inplace-edit-dialog-button-']")
end
end
@@ -126,7 +126,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "shows the modal buttons on all enabled custom fields" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_css("[data-test-selector*='project-custom-field-modal-button-']", count: 15)
expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 15)
end
end
end
@@ -140,7 +140,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "shows the modal buttons" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_css("[data-test-selector*='project-custom-field-modal-button-']", count: 13)
expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 1)
end
end
@@ -149,30 +149,30 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "shows the modal buttons on all enabled custom fields" do
overview_page.within_project_attributes_sidebar do
expect(page).to have_css("[data-test-selector*='project-custom-field-modal-button-']", count: 15)
expect(page).to have_css("[data-test-selector*='inplace-edit-dialog-button-']", count: 15)
end
end
end
end
describe "with insufficient Edit attribute permission on the update dialog" do
let(:member) { member_with_project_attributes_edit_permissions }
let(:member) { member_in_project }
let(:custom_field) { boolean_project_custom_field }
let(:dialog) { Components::Projects::ProjectCustomFields::Dialog.new(project, custom_field) }
let(:enable_comments) { true }
before do
login_as member
overview_page.visit_page
end
it "responds with a permission denied message" do
overview_page.open_modal_for_custom_field(custom_field)
# Change role to project edit, so the user won't have the project attributes edit role
member_with_project_attributes_edit_permissions.memberships.first.update(roles: [edit_project_role])
member_with_project_attributes_edit_permissions.reload
dialog.submit
it "opens the dialog in readonly mode" do
dialog = overview_page.open_modal_for_custom_field(custom_field)
expect_flash(type: :error, message: I18n.t(:notice_not_authorized))
dialog.expect_open
dialog.within_dialog do
expect(page).not_to have_test_selector("op-inplace-edit-field--form")
end
end
end
end
@@ -49,8 +49,9 @@ RSpec.shared_context "with seeded projects, members and project custom fields" d
create(:project_role, permissions: %i[view_work_packages view_project_attributes edit_project])
end
# TODO: Remove the edit_projects permission as soon as #73225 is fixed
shared_let(:edit_attributes_role) do
create(:project_role, permissions: %i[view_work_packages view_project_attributes edit_project_attributes])
create(:project_role, permissions: %i[view_work_packages view_project_attributes edit_project_attributes edit_project])
end
let!(:admin) do
@@ -199,7 +199,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value for the project custom field if no value given" do
it "does show the default value for the project custom field if no value given" do
boolean_project_custom_field.update!(default_value: true)
overview_page.visit_page
@@ -207,7 +207,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(boolean_project_custom_field) do
expect(page).to have_text "Boolean field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text "Yes"
end
end
@@ -218,7 +218,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(boolean_project_custom_field) do
expect(page).to have_text "Boolean field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text "No"
end
end
end
@@ -272,7 +272,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value for the project custom field if no value given" do
it "does show the default value for the project custom field if no value given" do
string_project_custom_field.update!(default_value: "Bar")
overview_page.visit_page
@@ -280,7 +280,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(string_project_custom_field) do
expect(page).to have_text "String field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text "Bar"
end
end
end
@@ -334,7 +334,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value for the project custom field if no value given" do
it "does show the default value for the project custom field if no value given" do
integer_project_custom_field.update!(default_value: 456)
overview_page.visit_page
@@ -342,7 +342,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(integer_project_custom_field) do
expect(page).to have_text "Integer field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text 456
end
end
end
@@ -396,7 +396,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value for the project custom field if no value given" do
it "does show the default value for the project custom field if no value given" do
date_project_custom_field.update!(default_value: Date.new(2024, 2, 2))
overview_page.visit_page
@@ -404,7 +404,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(date_project_custom_field) do
expect(page).to have_text "Date field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text "02/02/2024"
end
end
end
@@ -458,7 +458,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "dies not show the default value for the project custom field if no value given" do
it "does show the default value for the project custom field if no value given" do
float_project_custom_field.update!(default_value: 456.789)
overview_page.visit_page
@@ -466,7 +466,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(float_project_custom_field) do
expect(page).to have_text "Float field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text 456.789
end
end
end
@@ -556,7 +556,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value for the project custom field if no value given" do
it "does show the default value for the project custom field if no value given" do
text_project_custom_field.update!(default_value: "Dolor sit amet")
overview_page.visit_page
@@ -564,7 +564,7 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(text_project_custom_field) do
expect(page).to have_text "Text field"
expect(page).to have_text I18n.t("placeholders.default")
expect(page).to have_text "Dolor sit amet"
end
overview_page.expect_text_not_truncated(text_project_custom_field)
@@ -662,9 +662,8 @@ RSpec.describe "Show project custom fields on project overview page", :js do
overview_page.visit_page
# Remove value that is used in a formula:
overview_page.open_modal_for_custom_field(float_project_custom_field)
page.fill_in(float_project_custom_field.name, with: "")
page.click_on "Save"
field = overview_page.open_inplace_edit_field_for_custom_field(float_project_custom_field)
field.fill_and_submit_value name: float_project_custom_field.name, val: ""
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(calculated_from_int_project_custom_field) do
@@ -680,9 +679,8 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
# Change the value so that the calculation succeeds.
overview_page.open_modal_for_custom_field(float_project_custom_field)
page.fill_in(float_project_custom_field.name, with: "0.2")
page.click_on "Save"
field.open_field
field.fill_and_submit_value name: float_project_custom_field.name, val: "0.2"
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(calculated_from_int_project_custom_field) do
@@ -747,15 +745,27 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value for the project custom field if no value given" do
list_project_custom_field.custom_options.first.update!(default_value: true)
context "with a new field" do
# We need to create a completely new field, just deleting the options is not enough...
let!(:new_list_project_custom_field) do
create(:list_project_custom_field,
projects: [project],
name: "New list field",
project_custom_field_section: section_for_select_fields,
possible_values: ["Option 1", "Option 2", "Option 3"]) do |field|
create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first)
end
end
overview_page.visit_page
it "does show the default value for the project custom field if no value given" do
new_list_project_custom_field.custom_options.first.update!(default_value: true)
overview_page.visit_page
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(list_project_custom_field) do
expect(page).to have_text "List field"
expect(page).to have_text I18n.t("placeholders.default")
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(new_list_project_custom_field) do
expect(page).to have_text "New list field"
expect(page).to have_text "Option 1"
end
end
end
end
@@ -916,16 +926,31 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
end
it "does not show the default value(s) for the project custom field if no value given" do
multi_list_project_custom_field.custom_options.first.update!(default_value: true)
multi_list_project_custom_field.custom_options.second.update!(default_value: true)
context "with a new field" do
# We need to create a completely new field, just deleting the options is not enough...
let!(:new_multi_list_project_custom_field) do
create(:list_project_custom_field,
projects: [project],
name: "New multi list field",
project_custom_field_section: section_for_multi_select_fields,
possible_values: ["Option 1", "Option 2", "Option 3"],
multi_value: true) do |field|
create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first.id)
create(:custom_value, customized: project, custom_field: field, value: field.custom_options.second.id)
end
end
overview_page.visit_page
it "does not show the default value(s) for the project custom field if no value given" do
new_multi_list_project_custom_field.custom_options.first.update!(default_value: true)
new_multi_list_project_custom_field.custom_options.second.update!(default_value: true)
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(multi_list_project_custom_field) do
expect(page).to have_text "Multi list field"
expect(page).to have_text I18n.t("placeholders.default")
overview_page.visit_page
overview_page.within_project_attributes_sidebar do
overview_page.within_custom_field_container(new_multi_list_project_custom_field) do
expect(page).to have_text "New multi list field"
expect(page).to have_text "Option 1, Option 2"
end
end
end
end
@@ -29,7 +29,7 @@
#++
require "spec_helper"
require_relative "../shared_context"
require_relative "shared_context"
RSpec.describe "Edit project custom fields on project overview page", :js do
include_context "with seeded projects, members and project custom fields"
@@ -42,13 +42,21 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with correct updating behaviour" do
def open_dialog
dialog = overview_page.open_modal_for_custom_field(custom_field)
dialog = overview_page.open_modal_for_custom_field(custom_field).dialog
yield dialog
dialog.submit
dialog.expect_closed
end
def open_field
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
yield field
field.submit
field.expect_close
end
shared_examples "saves custom comment" do
it "saves custom comment" do
custom_field.update!(has_comment: true)
@@ -74,7 +82,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content I18n.t("placeholders.default")
end
open_dialog do
open_field do
field.check
end
@@ -90,7 +98,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content "Yes"
end
open_dialog do
open_field do
field.uncheck
end
@@ -100,6 +108,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "does not change the value if untouched" do
custom_field.update!(has_comment: true)
overview_page.visit_page
overview_page.within_custom_field_container(custom_field) do
@@ -128,7 +137,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content I18n.t("placeholders.default")
end
open_dialog do
open_field do
field.fill_in(with: update_value)
end
@@ -138,6 +147,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
it "does not change the value if untouched" do
custom_field.update!(has_comment: true)
overview_page.visit_page
overview_page.within_custom_field_container(custom_field) do
@@ -160,7 +170,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content expected_initial_value
end
open_dialog do
open_field do
field.fill_in(with: "")
end
@@ -180,7 +190,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content expected_initial_calculated_value
end
open_dialog do
open_field do
field.fill_in(with: update_value)
end
@@ -196,7 +206,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content expected_initial_calculated_value
end
open_dialog do |dialog|
open_field do |dialog|
# don't touch the input
end
@@ -212,7 +222,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_content expected_initial_calculated_value
end
open_dialog do
open_field do
field.fill_in(with: "")
end
@@ -375,7 +385,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_no_text first_option
end
open_dialog do
open_field do
field.select_option(first_option)
end
@@ -391,7 +401,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_text first_option
end
open_dialog do
open_field do
field.expect_selected(first_option) # wait for proper initialization
# don't touch the input
end
@@ -408,7 +418,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_text first_option
end
open_dialog do
open_field do
field.clear
end
@@ -430,7 +440,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_no_text unused_option
end
open_dialog do
open_field do
# Choose the unused option as the new selection
field.select_option(unused_option)
end
@@ -488,7 +498,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
overview_page.visit_page
open_dialog do
open_field do
field.select_option(group.name)
end
@@ -509,7 +519,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
overview_page.visit_page
open_dialog do
open_field do
field.select_option(placeholder_user.name)
end
@@ -532,7 +542,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_no_text first_option
end
open_dialog do
open_field do
field.select_option(first_option)
end
@@ -551,7 +561,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_no_text second_option
end
open_dialog do
open_field do
field.select_option(first_option)
field.select_option(second_option)
end
@@ -570,7 +580,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_text second_option
end
open_dialog do
open_field do
field.deselect_option(first_option)
end
@@ -588,7 +598,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_text second_option
end
open_dialog do
open_field do
field.expect_selected(first_option, second_option) # wait for proper initialization
# don't touch the values
end
@@ -607,7 +617,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_text second_option
end
open_dialog do
open_field do
field.clear
end
@@ -627,7 +637,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_no_text second_option
end
open_dialog do
open_field do
field.select_option(first_option)
end
@@ -636,7 +646,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
expect(page).to have_no_text second_option
end
open_dialog do
open_field do
field.select_option(second_option)
end
@@ -693,7 +703,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
overview_page.visit_page
open_dialog do
open_field do
field.select_option(group.name)
field.select_option(another_group.name)
end
@@ -720,7 +730,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
overview_page.visit_page
open_dialog do
open_field do
field.select_option(placeholder_user.name)
field.select_option(another_placeholder_user.name)
end
@@ -769,7 +779,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
overview_page.visit_page
open_dialog do
open_field do
field.fill_in(with: "new value")
end
@@ -801,7 +811,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
overview_page.visit_page
open_dialog do
open_field do
field.fill_in(with: 567)
end
@@ -29,7 +29,7 @@
#++
require "spec_helper"
require_relative "../shared_context"
require_relative "shared_context"
RSpec.describe "Edit project custom fields on project overview page", :js do
include_context "with seeded projects, members and project custom fields"
@@ -46,22 +46,22 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "keeps showing only activated custom fields (tricky regression)" do
custom_field = string_project_custom_field
custom_field.update!(is_required: true)
field = FormFields::Primerized::InputField.new(custom_field)
form_field = FormFields::Primerized::InputField.new(custom_field)
dialog = overview_page.open_modal_for_custom_field(custom_field)
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
dialog.within_async_content do
field.within_field do
expect(page).to have_text("String field")
expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name)
end
field.fill_in(with: "") # this will trigger the validation
form_field.fill_in(with: "") # this will trigger the validation
dialog.submit
field.submit
field.expect_error(I18n.t("activerecord.errors.messages.blank"))
form_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
dialog.within_async_content do
field.within_field do
expect(page).to have_text("String field")
expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name)
end
@@ -71,9 +71,10 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
shared_examples "keeps the unpersisted values" do
it "keeps the value" do
invalid_custom_field.update!(is_required: true)
dialog = overview_page.open_modal_for_custom_field(invalid_custom_field)
refresh
field = overview_page.open_inplace_edit_field_for_custom_field(invalid_custom_field)
invalid_field.clear
dialog.submit
field.submit
invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
invalid_field.expect_blank
@@ -81,6 +82,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "keeps the custom comment value" do
invalid_custom_field.update!(is_required: true, has_comment: true)
refresh
dialog = overview_page.open_modal_for_custom_field(invalid_custom_field)
invalid_field.clear
fill_in "Comment", with: "A helpful comment"
@@ -147,25 +149,19 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
end
describe "editing multiple fields" do
let(:input_fields_dialog) do
Components::Projects::ProjectCustomFields::Dialog.new(project, section_for_input_fields)
end
let(:select_fields_dialog) do
Components::Projects::ProjectCustomFields::Dialog.new(project, section_for_select_fields)
end
let(:field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) }
it "displays validation errors, when the previous modal was canceled (Regression)" do
list_project_custom_field.update!(is_required: true)
list_project_custom_field.custom_values.destroy_all
dialog = overview_page.open_modal_for_custom_field(string_project_custom_field)
dialog.close
field = overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field)
field.close
dialog = overview_page.open_modal_for_custom_field(list_project_custom_field)
dialog.submit
field = overview_page.open_inplace_edit_field_for_custom_field(list_project_custom_field)
field.submit
field.expect_error(I18n.t("activerecord.errors.messages.blank"))
form_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
end
context "with required custom fields in different sections" do
@@ -185,44 +181,44 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "validates required fields only within their respective sections" do
# Test 1: Multi-select field can be saved even when other required fields are invalid
multi_list_field_dialog =
overview_page.open_modal_for_custom_field(multi_list_project_custom_field)
multi_list_inplace_field =
overview_page.open_inplace_edit_field_for_custom_field(multi_list_project_custom_field)
multi_list_field_dialog.submit
multi_list_field_dialog.expect_closed
multi_list_inplace_field.submit
multi_list_inplace_field.expect_close
# Test 2: Edit the required string field
string_field_dialog =
overview_page.open_modal_for_custom_field(string_project_custom_field)
string_field_inplace_field =
overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field)
# Submit without filling - should show error
string_field_dialog.submit
string_field_inplace_field.submit
string_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
string_field_dialog.close
string_field_inplace_field.close
# Test 3: Edit the required list field
list_field_dialog =
overview_page.open_modal_for_custom_field(list_project_custom_field)
list_field_inplace_field =
overview_page.open_inplace_edit_field_for_custom_field(list_project_custom_field)
# Submit without filling - should show error
list_field_dialog.submit
list_field_inplace_field.submit
list_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
# Test 4: Fill required field and submit successfully
list_field.select_option("Option 1")
list_field_dialog.submit
list_field_dialog.expect_closed
list_field_inplace_field.submit
list_field_inplace_field.expect_close
# Test 5: The required string field dialog still fails validation when empty
string_field_dialog =
overview_page.open_modal_for_custom_field(string_project_custom_field)
string_field_dialog.submit
string_field_inplace_field =
overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field)
string_field_inplace_field.submit
string_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
# Test 6: Complete the required string field and expect to pass validation
string_field.fill_in(with: "Test value")
string_field_dialog.submit
string_field_dialog.expect_closed
string_field_inplace_field.submit
string_field_inplace_field.expect_close
end
end
end
@@ -233,11 +229,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.update!(is_required: true)
custom_field.custom_values.destroy_all
dialog = overview_page.open_modal_for_custom_field(custom_field)
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
dialog.submit
field.submit
field.expect_error(I18n.t("activerecord.errors.messages.blank"))
form_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
end
end
@@ -245,37 +241,51 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
describe "with string CF" do
let(:custom_field) { string_project_custom_field }
let(:field) { FormFields::Primerized::InputField.new(custom_field) }
let(:form_field) { FormFields::Primerized::InputField.new(custom_field) }
it_behaves_like "a custom field input"
end
describe "with integer CF" do
let(:custom_field) { integer_project_custom_field }
let(:field) { FormFields::Primerized::InputField.new(custom_field) }
let(:form_field) { FormFields::Primerized::InputField.new(custom_field) }
it_behaves_like "a custom field input"
end
describe "with float CF" do
let(:custom_field) { float_project_custom_field }
let(:field) { FormFields::Primerized::InputField.new(custom_field) }
let(:form_field) { FormFields::Primerized::InputField.new(custom_field) }
it_behaves_like "a custom field input"
end
describe "with date CF" do
let(:custom_field) { date_project_custom_field }
let(:field) { FormFields::Primerized::InputField.new(custom_field) }
let(:form_field) { FormFields::Primerized::InputField.new(custom_field) }
it_behaves_like "a custom field input"
end
describe "with text CF" do
let(:custom_field) { text_project_custom_field }
let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) }
let(:form_field) do
FormFields::Primerized::EditorFormField.new(
custom_field,
selector: "[data-test-selector='augmented-text-area-custom_field_#{custom_field.id}']"
)
end
it_behaves_like "a custom field input"
it "shows an error if the value is invalid" do
custom_field.update!(is_required: true)
custom_field.custom_values.destroy_all
dialog = overview_page.open_modal_for_custom_field(custom_field).dialog
dialog.submit
form_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
end
end
describe "with calculated value CFs" do
@@ -283,17 +293,11 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
it "allows saving the dialog even if the calculated custom field is invalid" do
custom_field.custom_values.destroy_all
dialog = overview_page.open_modal_for_custom_field(custom_field)
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
dialog.submit
field.submit
dialog.expect_closed
end
it "displays the custom field label without the required asterisk" do
expect(page).to have_css("span", text: calculated_field.name) do |label|
expect(label).to have_no_css("span", text: "*")
end
field.expect_close
end
end
@@ -314,7 +318,7 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
before do
# prevent calculation from happening
calculated_from_int_and_float_project_custom_field.custom_values.delete_all
calculated_from_int_and_float_project_custom_field.update(is_required: true)
calculated_from_int_and_float_project_custom_field.update!(is_required: true)
end
let(:custom_field) { integer_project_custom_field }
@@ -331,31 +335,31 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.update!(is_required: true)
custom_field.custom_values.destroy_all
dialog = overview_page.open_modal_for_custom_field(custom_field)
dialog = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
dialog.submit
field.expect_error(I18n.t("activerecord.errors.messages.blank"))
form_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
end
end
describe "with list CF" do
let(:custom_field) { list_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
it_behaves_like "a custom field select"
end
describe "with version CF" do
let(:custom_field) { version_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
it_behaves_like "a custom field select"
end
describe "with user CF" do
let(:custom_field) { user_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
it_behaves_like "a custom field select"
end
@@ -367,31 +371,31 @@ RSpec.describe "Edit project custom fields on project overview page", :js do
custom_field.update!(is_required: true)
custom_field.custom_values.destroy_all
dialog = overview_page.open_modal_for_custom_field(custom_field)
field = overview_page.open_inplace_edit_field_for_custom_field(custom_field)
dialog.submit
field.submit
field.expect_error(I18n.t("activerecord.errors.messages.blank"))
form_field.expect_error(I18n.t("activerecord.errors.messages.blank"))
end
end
describe "with multi list CF" do
let(:custom_field) { multi_list_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
it_behaves_like "a custom field multi select"
end
describe "with multi version CF" do
let(:custom_field) { multi_version_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
it_behaves_like "a custom field multi select"
end
describe "with multi user CF" do
let(:custom_field) { multi_user_project_custom_field }
let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
let(:form_field) { FormFields::Primerized::AutocompleteField.new(custom_field) }
it_behaves_like "a custom field multi select"
end
@@ -156,9 +156,9 @@ RSpec.describe "Show project custom fields on project overview page", :js do
end
it "can edit a project custom field from within the widget" do
overview_page.open_modal_for_custom_field(string_project_custom_field)
page.fill_in(string_project_custom_field.name, with: "My super awesome new value")
page.click_on "Save"
field = overview_page.open_inplace_edit_field_for_custom_field(string_project_custom_field)
field.fill_and_submit_value name: string_project_custom_field.name, val: "My super awesome new value"
# The new value is shown in the widget
overview_page.within_main_area do
@@ -61,4 +61,35 @@ RSpec.describe OpenProject::InplaceEdit::FieldRegistry do
expect(registry.fetch(:description)).to eq(rich_text_component)
end
end
describe "#register_custom_field_format_mappings" do
it "stores format-to-component mappings used by fetch_for_custom_field_format" do
text_component = Class.new
registry.register_custom_field_format_mappings("text" => text_component)
expect(registry.fetch_for_custom_field_format("text")).to eq(text_component)
end
end
describe "#fetch_for_custom_field_format" do
let(:text_component) { Class.new }
before do
registry.register_custom_field_format_mappings("text" => text_component)
end
it "returns the correct component for the given field format" do
expect(registry.fetch_for_custom_field_format("text")).to eq(text_component)
end
it "falls back to TextInputComponent when the format has no mapping" do
expect(registry.fetch_for_custom_field_format("unknown_format"))
.to eq(OpenProject::Common::InplaceEditFields::TextInputComponent)
end
it "falls back to TextInputComponent when field_format is nil" do
expect(registry.fetch_for_custom_field_format(nil))
.to eq(OpenProject::Common::InplaceEditFields::TextInputComponent)
end
end
end
@@ -55,4 +55,16 @@ RSpec.describe OpenProject::InplaceEdit::UpdateRegistry do
expect(registry.registered?(Project)).to be(false)
end
end
describe "#resolve_model_class" do
it "returns the model class for a registered param string" do
registry.register(Project, handler:, contract:)
expect(registry.resolve_model_class("project")).to eq(Project)
end
it "returns nil for an unregistered param string" do
expect(registry.resolve_model_class("unknown")).to be_nil
end
end
end
@@ -0,0 +1,175 @@
# 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_relative "../../flash/expectations"
module Components
module Common
class InplaceEditField
include Capybara::DSL
include Capybara::RSpecMatchers
include RSpec::Matchers
attr_reader :model, :attribute, :show_in_dialog, :model_class
def initialize(model, attribute, show_in_dialog: false)
@model = model
@attribute = attribute
@show_in_dialog = show_in_dialog
if show_in_dialog
@dialog = InplaceEditFields::Dialog.new(model, attribute)
end
@model_class = @model.class.name.parameterize(separator: "_")
end
def open_field
within_field do
# Link and user type custom fields might contain a clickable link inside the edit container.
# Use JavaScript to directly trigger the click event on the container to avoid nested links.
selector = "op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}"
page.execute_script(
"document.querySelector('[data-test-selector=\"#{selector}\"] .op-inplace-edit--display-field').click()"
)
end
end
def expect_open
if show_in_dialog
dialog.expect_open
else
within_field do
expect(page).to have_test_selector("op-inplace-edit-field--form")
end
end
end
def expect_close
if show_in_dialog
dialog.expect_close
else
within_field do
expect(page).not_to have_test_selector("op-inplace-edit-field--form")
end
end
end
def expect_field_label_with_help_text(label_text)
expect_field_label(label_text)
expect(find_field_label(label_text)).to have_link accessible_name: "Show help text"
end
def expect_field_label_without_help_text(label_text)
expect_field_label(label_text)
expect(find_field_label(label_text)).to have_no_link accessible_name: "Show help text"
end
def click_help_text_link_for_label(label_text)
link = find_field_label(label_text).find(:link, accessible_name: "Show help text")
link.click
end
def expect_error(string)
within_field do
expect(page).to have_css(".FormControl-inlineValidation", text: string)
end
end
def expect_calculation_error(string)
within_field do
expect(page).to have_test_selector("error--#{attribute.name}")
expect(page).to have_content(string)
end
end
def fill_and_submit_value(name:, val:, ckeditor: false)
if ckeditor
find(".ck-content").base.send_keys val
else
fill_in(name, with: val)
end
submit
end
def submit
if show_in_dialog
dialog.submit
elsif save_button_present?
within_field { click_on "Save" }
else
# Fields that auto-submit (e.g. boolean checkboxes) may have already closed the form.
# Use `first` with minimum: 0 to return nil instead of raising when no input is present.
within_field { page.first("input, textarea", minimum: 0)&.send_keys(:return) }
end
wait_for_network_idle
end
def close
if show_in_dialog
dialog.close
elsif cancel_button_present?
within_field { click_on "Cancel" }
else
within_field { find("input, textarea").send_keys(:escape) }
end
wait_for_network_idle
end
def dialog
@dialog
end
def within_field(&)
page.within_test_selector("op-inplace-edit-field--#{model_class}-#{model.id}--#{attribute.name}", &)
end
private
def save_button_present?
within_field { page.has_button?("Save") }
end
def cancel_button_present?
within_field { page.has_button?("Cancel") }
end
def expect_field_label(label_text)
expect(page).to have_element :label, text: label_text
end
def find_field_label(label_text)
page.find(:element, :label, text: label_text)
end
end
end
end
@@ -32,23 +32,23 @@ require "support/components/common/modal"
require "support/components/autocompleter/ng_select_autocomplete_helpers"
module Components
module Projects
module ProjectCustomFields
module Common
class InplaceEditFields
class Dialog < Components::Common::Modal
include Components::Autocompleter::NgSelectAutocompleteHelpers
attr_reader :project, :project_custom_field, :title
attr_reader :model, :attribute
def initialize(project, project_custom_field)
def initialize(model, attribute)
super()
@project = project
@project_custom_field = project_custom_field
@title = @project_custom_field.name
@model = model
@attribute = attribute
@model_class = @model.class.name.parameterize(separator: "_")
end
def dialog_css_selector
"dialog#project-custom-field-dialog-#{@project_custom_field.id}"
"dialog#inplace-edit-field-dialog--#{@model_class}-#{model.id}--#{attribute.name}"
end
def async_content_container_css_selector
@@ -82,7 +82,7 @@ module Components
def submit
within(dialog_css_selector) do
page.find("[data-test-selector='save-project-attributes-button']").click
click_link_or_button "Save"
end
end
@@ -124,21 +124,6 @@ module Components
page.find(:element, :label, text: label_text)
end
end
###
def input_containers
within "#project-custom-field-edit-form > .FormControl-spacingWrapper" do
page.all(".FormControl-spacingWrapper")
end
end
def within_custom_field_input_container(custom_field, &)
# wrapping in `within_async_content` to make sure the container is properly loaded
within_async_content do
within("[data-test-selector='project-custom-field-input-container-#{custom_field.id}']", &)
end
end
end
end
end
@@ -16,7 +16,7 @@ module FormFields
end
def field_container
augmented_textarea = page.find("[data-text-area-id='\"project_custom_field_values_#{property.id}\"']")
augmented_textarea = page.find(selector || "[data-text-area-id='\"project_custom_field_values_#{property.id}\"']")
augmented_textarea.first(:xpath, ".//..")
end
@@ -5,7 +5,18 @@ require_relative "form_field"
module FormFields
module Primerized
class InputField < FormField
delegate :fill_in, :check, :uncheck, to: :input_element
delegate :fill_in, to: :input_element
# Capybara's native .click on a checkbox can update the DOM property directly
# without dispatching a browser click event, so Stimulus event handlers won't fire.
# Using execute_script with element.click() fires a real browser event.
def check
page.execute_script("document.querySelector(\"#{selector}\").click()")
end
def uncheck
page.execute_script("document.querySelector(\"#{selector}\").click()")
end
def field_container
page.find(selector).first(:xpath, ".//..").first(:xpath, ".//..")
+14 -13
View File
@@ -87,27 +87,28 @@ module Pages
def expect_custom_field_without_modal_button(custom_field)
within_custom_field_container(custom_field) do
expect(page).to have_no_test_selector("[data-test-selector*='project-custom-field-modal-button-']")
expect(page).to have_no_test_selector("[data-test-selector*='inplace-edit-dialog-button-']")
end
end
def open_modal_for_custom_field(custom_field)
scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']"))
within_custom_field_container(custom_field) do
# Link and user type custom fields might contain a clickable link inside the edit container.
# Use JavaScript to directly trigger the click event on the container to avoid nested links.
# Once we create the project custom field inline editing, this can be reverted to a normal
# capybara click method call.
page.execute_script(
"document.querySelector('[data-test-selector=\"project-custom-field-modal-button-#{custom_field.id}\"]').click()"
)
end
field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym, show_in_dialog: true)
field.open_field
dialog = Components::Projects::ProjectCustomFields::Dialog.new(project, custom_field)
wait_for_size_animation_completion(field.dialog.dialog_css_selector)
wait_for_size_animation_completion(dialog.dialog_css_selector)
field
end
dialog
def open_inplace_edit_field_for_custom_field(custom_field)
scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']"))
field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym)
field.open_field
wait_for_network_idle
field
end
def open_edit_dialog_for_life_cycle(life_cycle, wait_angular: false)