mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
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:
@@ -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
-18
@@ -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
|
||||
+12
-17
@@ -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
|
||||
+81
@@ -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
|
||||
+52
@@ -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 %>
|
||||
+140
-12
@@ -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
|
||||
|
||||
+17
-11
@@ -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
|
||||
|
||||
+21
-27
@@ -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
|
||||
+27
-11
@@ -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
|
||||
+21
-2
@@ -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
|
||||
|
||||
+71
@@ -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
|
||||
+71
@@ -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
|
||||
+11
-14
@@ -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
|
||||
+24
-15
@@ -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
|
||||
+90
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 field’s `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
|
||||
<%= 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"
|
||||
|
||||
-30
@@ -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
|
||||
%>
|
||||
-68
@@ -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
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+8
-61
@@ -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"
|
||||
) %>
|
||||
|
||||
+3
-125
@@ -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
|
||||
@@ -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->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->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
|
||||
+64
@@ -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->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->inplace-edit#submitForm")
|
||||
end
|
||||
end
|
||||
+53
@@ -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->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->inplace-edit#submitForm")
|
||||
end
|
||||
end
|
||||
+54
@@ -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
|
||||
+82
@@ -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->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->inplace-edit#request")
|
||||
end
|
||||
end
|
||||
end
|
||||
+69
@@ -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
|
||||
+55
@@ -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
|
||||
+11
-8
@@ -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->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->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->inplace-edit#request\"")
|
||||
expect(rendered_content).not_to include("click->inplace-edit#request")
|
||||
end
|
||||
end
|
||||
|
||||
+64
@@ -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
|
||||
+57
@@ -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
|
||||
+135
@@ -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
|
||||
+50
@@ -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
|
||||
+16
-1
@@ -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
|
||||
+16
-1
@@ -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->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->inplace-edit#request")
|
||||
end
|
||||
end
|
||||
|
||||
+81
@@ -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 }
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+35
-37
@@ -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
|
||||
|
||||
|
||||
+165
-159
@@ -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
|
||||
|
||||
+17
-17
@@ -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
|
||||
|
||||
+36
-26
@@ -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
|
||||
|
||||
+74
-70
@@ -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
|
||||
+9
-24
@@ -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, ".//..")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user