From a3ec1fc15c4e6dfdcfc3b83df9d6fe877645c342 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Tue, 17 Feb 2026 14:51:48 +0100 Subject: [PATCH] Support small spaces for inplaceEditFields by opening inplaceEditFields inside a dialog and truncating the display field --- .../inplace_edit_field_component.html.erb | 29 +++---- .../common/inplace_edit_field_component.rb | 63 ++++++++++++++- ...place_edit_field_dialog_component.html.erb | 35 +++++++++ .../inplace_edit_field_dialog_component.rb | 77 +++++++++++++++++++ .../display_fields/display_field_component.rb | 48 +++++++++++- .../rich_text_area_component.rb | 23 ++++-- .../rich_text_area_component.rb | 31 ++++---- .../select_list_component.rb | 28 ++++--- .../user_select_list_component.rb | 6 +- .../version_select_list_component.rb | 2 +- .../inplace_edit_fields_controller.rb | 12 ++- config/routes.rb | 1 + .../dynamic/inplace-edit.controller.ts | 29 +++++++ .../item_component.html.erb | 25 +++--- .../project_custom_fields/item_component.rb | 7 +- 15 files changed, 341 insertions(+), 75 deletions(-) create mode 100644 app/components/open_project/common/inplace_edit_field_dialog_component.html.erb create mode 100644 app/components/open_project/common/inplace_edit_field_dialog_component.rb diff --git a/app/components/open_project/common/inplace_edit_field_component.html.erb b/app/components/open_project/common/inplace_edit_field_component.html.erb index 01251f341c5..a59106cd554 100644 --- a/app/components/open_project/common/inplace_edit_field_component.html.erb +++ b/app/components/open_project/common/inplace_edit_field_component.html.erb @@ -1,20 +1,23 @@ -<%= component_wrapper(tag: :div, class: "op-inplace-edit", data: { test_selector: wrapper_test_selector }) do %> +<%= component_wrapper( + tag: :div, + class: "op-inplace-edit", + uniq_by: wrapper_uniq_by, + data: { + test_selector: wrapper_test_selector, + turbo_stream_target: wrapper_id + } + ) do %> <% if display_field_component.present? && !enforce_edit_mode %> <%= 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 %> diff --git a/app/components/open_project/common/inplace_edit_field_component.rb b/app/components/open_project/common/inplace_edit_field_component.rb index dec9b12a8f4..a02f856f661 100644 --- a/app/components/open_project/common/inplace_edit_field_component.rb +++ b/app/components/open_project/common/inplace_edit_field_component.rb @@ -33,13 +33,17 @@ 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, **system_arguments) + def initialize(model:, attribute:, enforce_edit_mode: false, open_in_dialog: false, show_action_buttons: true, + truncated: false, **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 @system_arguments = system_arguments @system_arguments[:id] = system_arguments[:id] || SecureRandom.uuid @system_arguments[:required] ||= required? @@ -55,6 +59,7 @@ module OpenProject form:, attribute:, model:, + show_action_buttons:, **@system_arguments ) end @@ -70,7 +75,8 @@ module OpenProject def display_field_component return nil if display_field_class.nil? - display_field_class.new(model:, attribute:, writable: writable?, **@system_arguments) + additional_args = open_in_dialog? ? dialog_display_arguments : {} + display_field_class.new(model:, attribute:, writable: writable?, truncated:, **@system_arguments.merge(additional_args)) end def wrapper_key @@ -82,8 +88,59 @@ module OpenProject "op-inplace-edit-field" end + def wrapper_uniq_by + "#{@model.class.name.parameterize(separator: '_')}_#{@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 } + } + + options[:id] = form_id if form_id.present? + options + end + + def open_in_dialog? + @open_in_dialog + 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).to_json + ) + end + private + def dialog_display_arguments + { + dialog_controller_name: "inplace-edit", + dialog_url: dialog_edit_url, + dialog_test_selector: "inplace-edit-dialog-button-#{wrapper_key}" + } + end + def writable? return @writable if defined?(@writable) diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb new file mode 100644 index 00000000000..ae8522e0943 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.html.erb @@ -0,0 +1,35 @@ +<%= + 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(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 + t("button_cancel") + end + 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 +%> diff --git a/app/components/open_project/common/inplace_edit_field_dialog_component.rb b/app/components/open_project/common/inplace_edit_field_dialog_component.rb new file mode 100644 index 00000000000..7c99b04cc03 --- /dev/null +++ b/app/components/open_project/common/inplace_edit_field_dialog_component.rb @@ -0,0 +1,77 @@ +# 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 + class InplaceEditFieldDialogComponent < ViewComponent::Base + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(model:, attribute:, system_arguments: {}) + super() + @model = model + @attribute = attribute + @system_arguments = system_arguments + end + + private + + def dialog_title + @system_arguments[:label] || @model.class.human_attribute_name(@attribute) + end + + def dialog_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 form_id + "inplace-edit-field-form-#{dialog_id}" + end + + 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 +end diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb index 5dc821258cb..d49262d10d0 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/display_field_component.rb @@ -35,13 +35,14 @@ module OpenProject class DisplayFieldComponent < ViewComponent::Base 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:, **system_arguments) super() @model = model @attribute = attribute @writable = writable + @truncated = truncated @system_arguments = system_arguments end @@ -58,9 +59,48 @@ module OpenProject end def display_field_arguments - @display_field_arguments ||= { + @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: "op-inplace-edit--display-field #{'op-inplace-edit--display-field_editable' if writable?}", - id: @system_arguments[:id], + id: @system_arguments[:id] + } + end + + def dialog_field_arguments + { + data: { + controller: "inplace-edit async-dialog", + inplace_edit_dialog_url_value: @system_arguments[:dialog_url], + action: "click->inplace-edit#open " \ + "keydown.enter->inplace-edit#open " \ + "keydown.space->inplace-edit#open " \ + "inplace-edit:open-dialog->async-dialog#handleOpenDialog", + test_selector: @system_arguments[:dialog_test_selector] + }, + aria: { + label: [ + I18n.t(:label_edit_x, x: @system_arguments[:label]), + I18n.t(:label_value_x, x: render_display_value) + ].join(", ") + }, + role: "button", + tabindex: 0 + } + end + + def inline_edit_field_arguments + { data: { controller: "inplace-edit", inplace_edit_url_value: edit_url, diff --git a/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb index 59f7d213df6..676c46fb971 100644 --- a/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/display_fields/rich_text_area_component.rb @@ -41,19 +41,26 @@ module OpenProject 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 - def render_display_value - value = model.public_send(attribute) + private - if value.present? - format_text(value) - else - t("placeholders.default") - end + def field_value + model.public_send(attribute) end end end diff --git a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb index 0c75b97bc29..721dbb87455 100644 --- a/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/rich_text_area_component.rb @@ -32,17 +32,18 @@ module OpenProject module Common module InplaceEditFields class RichTextAreaComponent < ViewComponent::Base - attr_reader :form, :attribute, :model + attr_reader :form, :attribute, :model, :show_action_buttons def self.display_class DisplayFields::RichTextAreaComponent end - def initialize(form:, attribute:, model:, **system_arguments) + 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 @system_arguments[:classes] = class_names( @system_arguments[:classes], @@ -56,18 +57,20 @@ module OpenProject def call form.rich_text_area(name: attribute, **@system_arguments) - 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, - test_selector: "op-inplace-edit-field--textarea-cancel") - button_group.submit(name: :submit, - label: I18n.t(:button_save), - scheme: :primary, - test_selector: "op-inplace-edit-field--textarea-save") + if show_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, + test_selector: "op-inplace-edit-field--textarea-cancel") + button_group.submit(name: :submit, + label: I18n.t(:button_save), + scheme: :primary, + test_selector: "op-inplace-edit-field--textarea-save") + end end end end diff --git a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb index 38ab8b276f1..a6058b5e0d9 100644 --- a/app/components/open_project/common/inplace_edit_fields/select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/select_list_component.rb @@ -38,11 +38,12 @@ module OpenProject DisplayFields::SelectListComponent end - def initialize(form:, attribute:, model:, **system_arguments) + 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 @system_arguments[:classes] = class_names( @system_arguments[:classes], @@ -53,6 +54,7 @@ module OpenProject @system_arguments[:autocomplete_options] ||= {} @system_arguments[:autocomplete_options][:model] ||= { id: model.id, name: model.name } @system_arguments[:autocomplete_options][:inputName] ||= attribute + @system_arguments[:autocomplete_options][:wrapper_id] ||= system_arguments[:wrapper_id] if @system_arguments[:autocomplete_options][:focusDirectly].nil? @system_arguments[:autocomplete_options][:focusDirectly] = true @@ -70,16 +72,18 @@ module OpenProject render_autocompleter end - 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) + if @show_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 end @@ -94,7 +98,7 @@ module OpenProject # 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) + input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options]) end end diff --git a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb index 7f82b639442..f5bb5f79e51 100644 --- a/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/user_select_list_component.rb @@ -40,10 +40,6 @@ module OpenProject def initialize(form:, attribute:, model:, **system_arguments) super - - unless custom_field? - assign_defaults! - end end private @@ -57,7 +53,7 @@ module OpenProject # 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) + input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options]) end end end diff --git a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb index c9ff1c199cf..167330e2877 100644 --- a/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb +++ b/app/components/open_project/common/inplace_edit_fields/version_select_list_component.rb @@ -53,7 +53,7 @@ module OpenProject # 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) + input_class.new(builder, custom_field:, object: model, **@system_arguments[:autocomplete_options]) end end diff --git a/app/controllers/inplace_edit_fields_controller.rb b/app/controllers/inplace_edit_fields_controller.rb index b9df80a865e..b09aba8e8e8 100644 --- a/app/controllers/inplace_edit_fields_controller.rb +++ b/app/controllers/inplace_edit_fields_controller.rb @@ -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( @@ -78,6 +78,16 @@ 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 find_model diff --git a/config/routes.rb b/config/routes.rb index 5373a93f1f3..dc9c5787126 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1063,6 +1063,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? diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index cba2bf0915f..bb667e03ae9 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -35,9 +35,12 @@ 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; async request(e:Event):Promise { // Don't trigger edit mode if clicking on a link @@ -59,10 +62,36 @@ export default class extends Controller { } } + open(event:Event) { + 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 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; + } } diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb index 7a8f467f53b..952ce45eeac 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.html.erb @@ -1,11 +1,13 @@ +<%= render OpenProject::Common::InplaceEditFieldComponent.new( + model: @project, + attribute: @project_custom_field.attribute_name.to_sym, + open_in_dialog: limited_space?, + truncated: limited_space? +) %> -<% if show_inplace_edit_field? %> - <%= render OpenProject::Common::InplaceEditFieldComponent.new( - model: @project, - attribute: @project_custom_field.attribute_name.to_sym - ) %> -<% else %> - <%= + + + +<%# end %> diff --git a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb index 7e93f316a10..38596fa411a 100644 --- a/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb +++ b/modules/overviews/app/components/overviews/project_custom_fields/item_component.rb @@ -46,10 +46,9 @@ module Overviews private - def show_inplace_edit_field? - # TODO: Move outside of this component and pass in instead - @project_custom_field.field_format != "text" || - @project_custom_field.project_custom_field_section&.shown_in_overview_main_area? + def limited_space? + @project_custom_field.field_format == "text" && + @project_custom_field.project_custom_field_section&.shown_in_overview_sidebar? end def allowed_to_edit?