Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
75d9162
Register custom fields for inplace editing and add more field types
HDinger Feb 12, 2026
fe2cadf
Add support for tooltips to show tooltip for caclulated values
HDinger Feb 13, 2026
33c5fcd
Add special logic for date fields to submit on change
HDinger Feb 13, 2026
f7be040
Support link custom fields for inplace editing
HDinger Feb 16, 2026
dbe4e5e
Basic select list for inplace edit fields
HDinger Feb 16, 2026
42ba39b
VersionListComponent for inplaceEditComponent
HDinger Feb 16, 2026
b1476e3
Basic userSelect field for inplaceEditComponent
HDinger Feb 16, 2026
a3ec1fc
Support small spaces for inplaceEditFields by opening inplaceEditFiel…
HDinger Feb 17, 2026
2558874
Add BaseClass for input edit fields to avoid duplication
HDinger Feb 17, 2026
3541a96
Merge remote-tracking branch 'origin/dev' into feature/71380-inplace-…
HDinger Mar 10, 2026
45d9c5f
Support custom comments for custom fields
HDinger Mar 11, 2026
c934aa7
Avoid click handling when marking text && improve keyboard navigation
HDinger Mar 11, 2026
eb60f20
Add HierarchyFields for inplaceEditComponents
HDinger Mar 12, 2026
2e2ed4e
Avoid doubled initialisation of displayFields
HDinger Mar 12, 2026
420b85c
Avoid that calculated fields are editable at all && update docs
HDinger Mar 12, 2026
1f8853a
Support calculatedFields error messages and update them once a depend…
HDinger Mar 12, 2026
ec2fea0
remove old implementation of editing project attributes in the sidebar
HDinger Mar 12, 2026
4630e05
Guard the table check
HDinger Mar 13, 2026
66dc683
Add unit tests for new inplace edit fields
HDinger Mar 13, 2026
9866ece
Merge remote-tracking branch 'origin/dev' into feature/71380-inplace-…
HDinger Mar 13, 2026
0f4f0d1
Adapt tests to new inplaceEditFields
HDinger Mar 13, 2026
0d211cc
Merge remote-tracking branch 'origin/dev' into feature/71380-inplace-…
HDinger Mar 16, 2026
a6f54aa
Start adapting features tests to new inplace edit functionality
HDinger Mar 16, 2026
a445cc1
* Take care that newly created CF are also correctly registered
HDinger Mar 16, 2026
f2410d0
Add comment fields to the displayFields in case a user w/o permission…
HDinger Mar 18, 2026
ee83264
Continue the endless journey of adapting the tests to the new inplace…
HDinger Mar 18, 2026
b943d56
Merge branch 'dev' into feature/71380-inplace-edit-for-project-attrib…
HDinger Mar 18, 2026
adb88c9
Some more test fixing.. There is light and the end of the tunnel :fin…
HDinger Mar 18, 2026
c7afb49
Get inplaceEditField for customField dynamically by the format instea…
HDinger Mar 19, 2026
2d92b5d
Use CustomValue.formatted_value instead of formatting the values manu…
HDinger Mar 20, 2026
d933974
Revert "Use CustomValue.formatted_value instead of formatting the val…
HDinger Mar 20, 2026
4f9a486
Refactor extract_tree_view_value to avoid exception-based control flow
HDinger Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 %>
148 changes: 141 additions & 7 deletions app/components/open_project/common/inplace_edit_field_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,46 @@ module Common
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)
field_class.new(
form:,
attribute:,
model:,
show_action_buttons:,
**@system_arguments
)
end
Expand All @@ -70,20 +88,102 @@ def display_field_class
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)

Expand All @@ -95,6 +195,40 @@ def writable?
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
Original file line number Diff line number Diff line change
@@ -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
%>
Original file line number Diff line number Diff line change
Expand Up @@ -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"
end

def close_button_title
fail NoMethodError, "Must be overridden in subclass"
def form_id
"inplace-edit-field-form-#{dialog_id}"
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
Expand Down
Loading
Loading