Support small spaces for inplaceEditFields by opening inplaceEditFields inside a dialog and truncating the display field

This commit is contained in:
Henriette Darge
2026-02-17 14:51:48 +01:00
parent b1476e32de
commit a3ec1fc15c
15 changed files with 341 additions and 75 deletions
@@ -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 %>
@@ -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)
@@ -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
%>
@@ -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
@@ -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,
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+1
View File
@@ -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?
@@ -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<void> {
// 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;
}
}
@@ -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 %>
<%=
<!--
<%# if show_special_field? %>
<%#=
flex_layout(
align_items: :flex_start,
justify_content: :space_between,
@@ -51,7 +53,10 @@
end
end
end
%>
%>
<%= render_calculated_value_tooltip if calculated_value? %>
<% end %>
<%#= render_calculated_value_tooltip if calculated_value? %>
<%# else %>-->
<%# end %>
@@ -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?