From 9431f9d6f7c901a77f1024881950b1b987665a43 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Fri, 10 Jan 2025 17:00:39 +0200 Subject: [PATCH 1/5] Add form preview documentation and create a generic form preview stimulus controller --- .../project-life-cycles-form.controller.ts | 13 +- .../controllers/form-preview.controller.ts | 56 ++++++++ frontend/src/stimulus/setup.ts | 2 + lookbook/docs/patterns/02-forms.md.erb | 124 ++++++++++++++++++ lookbook/previews/patterns/forms_preview.rb | 11 ++ .../forms_preview/form_preview.html.erb | 35 +++++ .../sections/edit_component.html.erb | 6 +- 7 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 frontend/src/stimulus/controllers/form-preview.controller.ts create mode 100644 lookbook/previews/patterns/forms_preview/form_preview.html.erb diff --git a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts index 1cc2421b730f..ec075b748f56 100644 --- a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts @@ -31,22 +31,13 @@ import { Controller } from '@hotwired/stimulus'; export default class ProjectLifeCyclesFormController extends Controller { - static targets = ['form']; - - declare readonly formTarget:HTMLFormElement; - handleChange(event:Event) { const target = event.target as HTMLElement; - const previewUrl = this.formTarget.dataset.previewUrl; - - if (!previewUrl || this.datePickerVisible(target)) { + if (this.datePickerVisible(target)) { return; // flatpickr is still open, do not submit yet. } - const form = this.formTarget; - form.action = previewUrl; - - form.requestSubmit(); + this.dispatch('triggerFormPreview'); } datePickerVisible(element:HTMLElement) { diff --git a/frontend/src/stimulus/controllers/form-preview.controller.ts b/frontend/src/stimulus/controllers/form-preview.controller.ts new file mode 100644 index 000000000000..ce6560fc894d --- /dev/null +++ b/frontend/src/stimulus/controllers/form-preview.controller.ts @@ -0,0 +1,56 @@ +/* + * -- 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. + * ++ + */ + +import { ApplicationController } from 'stimulus-use'; + +export default class FormPreviewController extends ApplicationController { + static values = { url: String }; + + declare readonly formTarget:HTMLFormElement; + declare urlValue:string; + + connect() { + // Ensure this.element is a form element + if (!(this.element instanceof HTMLFormElement)) { + throw new Error('The controller must be bound to a
element'); + } + } + + async submit():Promise { + if (!this.urlValue) { + return; + } + + const form = this.element as HTMLFormElement; + form.action = this.urlValue; + + form.requestSubmit(); + } +} diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts index b1b052b5e8c8..022ec08d973b 100644 --- a/frontend/src/stimulus/setup.ts +++ b/frontend/src/stimulus/setup.ts @@ -5,6 +5,7 @@ import MainMenuController from './controllers/dynamic/menus/main.controller'; import OpDisableWhenCheckedController from './controllers/disable-when-checked.controller'; import PrintController from './controllers/print.controller'; import RefreshOnFormChangesController from './controllers/refresh-on-form-changes.controller'; +import FormPreviewController from './controllers/form-preview.controller'; import AsyncDialogController from './controllers/async-dialog.controller'; import PollForChangesController from './controllers/poll-for-changes.controller'; import TableHighlightingController from './controllers/table-highlighting.controller'; @@ -37,6 +38,7 @@ instance.register('password-confirmation-dialog', PasswordConfirmationDialogCont instance.register('poll-for-changes', PollForChangesController); instance.register('print', PrintController); instance.register('refresh-on-form-changes', RefreshOnFormChangesController); +instance.register('form-preview', FormPreviewController); instance.register('show-when-checked', OpShowWhenCheckedController); instance.register('show-when-value-selected', OpShowWhenValueSelectedController); instance.register('table-highlighting', TableHighlightingController); diff --git a/lookbook/docs/patterns/02-forms.md.erb b/lookbook/docs/patterns/02-forms.md.erb index b757110c9417..3e05c957bc87 100644 --- a/lookbook/docs/patterns/02-forms.md.erb +++ b/lookbook/docs/patterns/02-forms.md.erb @@ -166,6 +166,130 @@ Important data inputs: - If you want the target to be visibly blocked, not hidden, then use `data: { set_visibility: "true" }`. By default, the field will be hidden and removed from DOM computation. +### Advanced Interactivity by previewing forms before submission + +In order to implement complex form interactions other than hiding or showing fields, we can use the form preview pattern. A few examples of such interactions include displaying instant form validation errors on a changed field, or on its related fields. Another example is updating the caption of an input field based on its value. Handling each element update separately in these scenarios would be too cumbersome. A much better approach is to re-render the whole form when a field changes, preferably using a turbo streams morph response. + +In the example below we can see how the preview mechanism can be applied to forms: + +<%= embed Patterns::FormsPreview, :form_preview %> + +#### How it works: + +The form preview mechanism can be applied to any form by binding the `form-preview` stimulus controller to it and then watch the input fields for changes: + + ```ruby + primer_form_with( + url: "/foo", + method: :get, + data: { + "controller": "form-preview", + "form-preview-url-value": preview_path + } + ) do |f| + f.text_field(name: :answer, data: { action: "change->form-preview#submit" }) + end + ``` +- `"controller": "form-preview"` will activate the stimulus controller that processes the form refresh. +- `"form-preview-url-value"` defines the path to be used for submitting the form preview. +- Setting the `data: { action: "change->form-preview#submit" }` on an individual input field will trigger the form preview action when the field is changed. + +#### Customizing the triggering mechanism: + +In some cases we might want to further customize the triggering behaviour, for example when using a date range picker input field. For date range pickers we want to trigger the form preview only when the both the start and end dates are chosen and the datepicker is closed. The solution is to create a form specific controller that will decide if the `form-preview` controller should be called. + +1. The form specific controller handles the input changes on the form. It also dispatches the arbitrarily chosen `triggerFormPreview` event when the date range picker is not visible anymore. + + ```typescript + export default class CustomFormPreviewFormController extends Controller { + handleChange(event:Event) { + const target = event.target as HTMLElement; + if (this.datePickerVisible(target)) { + return; // The datepicker is still open, do not submit yet. + } + + this.dispatch('triggerFormPreview'); + } + } + ``` +2. In order to chain the `custom-form-preview` and `form-preview` controller events, the following definition needs to be added on the form: + + ```ruby + primer_form_with( + url: "/foo", + method: :get, + data: { + "controller": "custom-form-preview form-preview", + "action": "custom-form-preview:triggerFormPreview->form-preview#submit", + "form-preview-url-value": preview_path + } + ) do |f| + f.text_field(name: :answer, data: { action: "change->custom-form-preview#handleChange" }) + end + ``` + - The `"action": "custom-form-preview:triggerFormPreview->form-preview#submit"` will route the `custom-form-preview#handleChange` action to the `form-preview#submit` action via the dispatched `triggerFormPreview` event. + - The `data: { action: "change->custom-form-preview#handleChange" }` watches the input changes and calls the `custom-form-preview#handleChange`. + +**Important note:** Javascript rendered elements inside the form such as the datepicker above, could be broken after the form update. This happens, because the datepicker input get replaced without re-initializing the datepicker library. To fix the issue, we can either avoid updating the datepicker input elements using "data-turbo-permanent", or we can programatically re-initialize them after the form update. In case of angular components, this issue is solved automatically by not updating them the components. For more info see the `turbo:before-morph-element` eventlistener in the `turbo-global-listeners.ts`. + + +#### How to handle the form preview actions on the backend? + +The form preview mechanism shown above can be nicely tied with our existing ActiveRecord object saving services. + +1. First, we'll create a `PreviewAttributesService` that inherits from the `SetAttributes` service. This new service is nearly identical to `SetAttributes`, with one key difference: it clears validation errors for fields that the user hasn't modified. This is particularly important when creating new objects. For instance, if the user modifies the first input field, all fields will be validated and errors will be displayed, which is undesirable. Instead, we want to display errors incrementally as the user progresses through the form. With this approach, users will experience instant validation as they complete each field. + + ```ruby + module WorkPackages + class PreviewAttributesService < ::BaseServices::SetAttributes + def perform(*) + super.tap do |service_call| + clear_unchanged_fields(service_call) + end + end + + private + + def clear_unchanged_fields(service_call) + work_package = service_call.result + work_package + .errors + .select { |error| work_package.changed.exclude?(error.attribute.to_s) } + .each do |error| + work_package.errors.delete(error.attribute) + end + end + end + end + ``` + +2. Then we define a new controller member action called `work_package_form` alongside the crud actions and use the newly defined `PreviewAttributesService`. + + ```ruby + class WorkPackagesController < ApplicationController + def work_package_form + service_call = ::WorkPackages::PreviewAttributesService + .new(user: current_user, + model: @work_package, + contract_class: WorkPackage::UpdateContract) + .call(permitted_params.work_package) + + update_via_turbo_stream( + component: WorkPackages::EditComponent.new(service_call.result), + method: "morph" + ) + # TODO: :unprocessable_entity is not nice, change the dialog logic to accept :ok + # without dismissing the dialog, alternatively use turbo frames instead of streams. + respond_to_with_turbo_streams(status: :unprocessable_entity) + + end + end + ``` + - For a smoother user experience, it is recommended to respond with the `method: "morph"` via turbo streams. This will ensure the user's input focus is maintained between field updates. It is useful for form previews that are triggered on a keystroke event instead of the change event. + - The turbo stream response could be replaced with a plain turbo drive html response, once we have the turbo drive morphing enabled. + - Responding with a `status: :unprocessable_entity` is also important, because we intend to display validation errors on the form. + +3. Having the service and the controller action in place, we can defined form preview path on the form by adding the `"form-preview-url-value": work_packages_form_path(@work_package)` attribute. ### Accessing the form model diff --git a/lookbook/previews/patterns/forms_preview.rb b/lookbook/previews/patterns/forms_preview.rb index 0929b50f4e61..d84545474d1f 100644 --- a/lookbook/previews/patterns/forms_preview.rb +++ b/lookbook/previews/patterns/forms_preview.rb @@ -9,5 +9,16 @@ def default; end # @display min_height 300px # @label Overview def custom_width_fields_form; end + + # @label Preview + # @param answer + def form_preview(answer: nil) + preview_path = + Lookbook::Engine + .routes + .url_helpers + .lookbook_preview_path(path: "patterns/forms/form_preview") + render_with_template(locals: { answer:, preview_path: }) + end end end diff --git a/lookbook/previews/patterns/forms_preview/form_preview.html.erb b/lookbook/previews/patterns/forms_preview/form_preview.html.erb new file mode 100644 index 000000000000..e148298888d6 --- /dev/null +++ b/lookbook/previews/patterns/forms_preview/form_preview.html.erb @@ -0,0 +1,35 @@ +<% + form_preview = Class.new(ApplicationForm) do + form do |f| + f.text_field( + name: :answer, + value: answer, + label: "Auto", + data: { action: "change->form-preview#submit" }, + required: true, + validation_message: answer.nil? ? "Field is required" : nil, + caption: if answer.nil? + "Fill in the field to make the validation error instantly go away." + else + "Yay, no more errors!" + end, + input_width: :auto + ) + end + end +%> + +<%= + primer_form_with( + url: "/foo", + method: :get, + data: { + "controller": "form-preview", + "form-preview-url-value": preview_path + } + ) do |f| +%> + <%= render(form_preview.new(f)) %> +<% end %> + + diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb index 506d398e3a76..886b1573bb76 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -6,12 +6,12 @@ model:, method: :put, data: { - "controller": "overview--project-life-cycles-form", - "overview--project-life-cycles-form-target": "form", + "controller": "overview--project-life-cycles-form form-preview", + "action": "overview--project-life-cycles-form:triggerFormPreview->form-preview#submit", + "form-preview-url-value": project_life_cycles_form_path(project_id: model.id), "application-target": "dynamic", turbo: true, turbo_stream: true, - preview_url: project_life_cycles_form_path(project_id: model.id), "test-selector": "async-dialog-content" }, url: update_project_life_cycles_path(project_id: model.id), From 6932aaf655fc5f3909ba3f0e82f5d02d8c1e78de Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:43:56 +0200 Subject: [PATCH 2/5] Move Form Preview from patterns to primer lookbook previews. This way the live example can be accessed from the previews library too, and not just from the form pattern documentation. --- lookbook/docs/patterns/02-forms.md.erb | 2 +- lookbook/previews/op_primer/form_preview.rb | 18 ++++++++++++++++++ .../form_preview/default.html.erb} | 2 +- lookbook/previews/patterns/forms_preview.rb | 11 ----------- 4 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 lookbook/previews/op_primer/form_preview.rb rename lookbook/previews/{patterns/forms_preview/form_preview.html.erb => op_primer/form_preview/default.html.erb} (96%) diff --git a/lookbook/docs/patterns/02-forms.md.erb b/lookbook/docs/patterns/02-forms.md.erb index 3e05c957bc87..e890ac656a6d 100644 --- a/lookbook/docs/patterns/02-forms.md.erb +++ b/lookbook/docs/patterns/02-forms.md.erb @@ -172,7 +172,7 @@ In order to implement complex form interactions other than hiding or showing fie In the example below we can see how the preview mechanism can be applied to forms: -<%= embed Patterns::FormsPreview, :form_preview %> +<%= embed OpPrimer::FormPreview, :default %> #### How it works: diff --git a/lookbook/previews/op_primer/form_preview.rb b/lookbook/previews/op_primer/form_preview.rb new file mode 100644 index 000000000000..b1dd75585c1a --- /dev/null +++ b/lookbook/previews/op_primer/form_preview.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module OpPrimer + # @logical_path OpenProject/Primer + # @display min_height 300px + class FormPreview < Lookbook::Preview + # @label Preview + # @param answer + def default(answer: nil) + preview_path = + Lookbook::Engine + .routes + .url_helpers + .lookbook_preview_path(path: "OpenProject/Primer/form/default") + render_with_template(locals: { answer:, preview_path: }) + end + end +end diff --git a/lookbook/previews/patterns/forms_preview/form_preview.html.erb b/lookbook/previews/op_primer/form_preview/default.html.erb similarity index 96% rename from lookbook/previews/patterns/forms_preview/form_preview.html.erb rename to lookbook/previews/op_primer/form_preview/default.html.erb index e148298888d6..872b31bee8d2 100644 --- a/lookbook/previews/patterns/forms_preview/form_preview.html.erb +++ b/lookbook/previews/op_primer/form_preview/default.html.erb @@ -4,7 +4,7 @@ f.text_field( name: :answer, value: answer, - label: "Auto", + label: "Answer", data: { action: "change->form-preview#submit" }, required: true, validation_message: answer.nil? ? "Field is required" : nil, diff --git a/lookbook/previews/patterns/forms_preview.rb b/lookbook/previews/patterns/forms_preview.rb index d84545474d1f..0929b50f4e61 100644 --- a/lookbook/previews/patterns/forms_preview.rb +++ b/lookbook/previews/patterns/forms_preview.rb @@ -9,16 +9,5 @@ def default; end # @display min_height 300px # @label Overview def custom_width_fields_form; end - - # @label Preview - # @param answer - def form_preview(answer: nil) - preview_path = - Lookbook::Engine - .routes - .url_helpers - .lookbook_preview_path(path: "patterns/forms/form_preview") - render_with_template(locals: { answer:, preview_path: }) - end end end From c7fa8b3f582b30dd5e230ffeb323f97daa1a4a8d Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Tue, 14 Jan 2025 22:55:04 +0200 Subject: [PATCH 3/5] Use type definition for the controller's element. --- .../controllers/form-preview.controller.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/frontend/src/stimulus/controllers/form-preview.controller.ts b/frontend/src/stimulus/controllers/form-preview.controller.ts index ce6560fc894d..cd22b91e15b8 100644 --- a/frontend/src/stimulus/controllers/form-preview.controller.ts +++ b/frontend/src/stimulus/controllers/form-preview.controller.ts @@ -28,27 +28,19 @@ * ++ */ -import { ApplicationController } from 'stimulus-use'; +import { Controller } from '@hotwired/stimulus'; -export default class FormPreviewController extends ApplicationController { +export default class FormPreviewController extends Controller { static values = { url: String }; - declare readonly formTarget:HTMLFormElement; declare urlValue:string; - connect() { - // Ensure this.element is a form element - if (!(this.element instanceof HTMLFormElement)) { - throw new Error('The controller must be bound to a element'); - } - } - async submit():Promise { if (!this.urlValue) { return; } - const form = this.element as HTMLFormElement; + const form = this.element; form.action = this.urlValue; form.requestSubmit(); From f2fd1fa1cccb6af7c153e1972d8d6de58e845e54 Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 15 Jan 2025 13:11:52 +0200 Subject: [PATCH 4/5] [#60578] Unable to use the date picker for stages https://community.openproject.org/work_packages/60578 --- app/forms/projects/life_cycles/form.rb | 2 +- .../overview/project-life-cycles-form.controller.ts | 7 ++----- .../life_cycle/overview_page/dialog/update_spec.rb | 4 ++++ 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/forms/projects/life_cycles/form.rb b/app/forms/projects/life_cycles/form.rb index 0783a0a85090..06a7ec6212d0 100644 --- a/app/forms/projects/life_cycles/form.rb +++ b/app/forms/projects/life_cycles/form.rb @@ -54,7 +54,7 @@ def base_input_attributes leading_visual: { icon: :calendar }, datepicker_options: { inDialog: ProjectLifeCycles::Sections::EditDialogComponent::DIALOG_ID, - data: { action: "change->overview--project-life-cycles-form#handleChange" } + data: { action: "change->overview--project-life-cycles-form#previewForm" } }, wrapper_data_attributes: { "qa-field-name": qa_field_name diff --git a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts index ec075b748f56..e5e7d4d73258 100644 --- a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts @@ -31,7 +31,7 @@ import { Controller } from '@hotwired/stimulus'; export default class ProjectLifeCyclesFormController extends Controller { - handleChange(event:Event) { + previewForm(event:Event) { const target = event.target as HTMLElement; if (this.datePickerVisible(target)) { return; // flatpickr is still open, do not submit yet. @@ -41,9 +41,6 @@ export default class ProjectLifeCyclesFormController extends Controller { } datePickerVisible(element:HTMLElement) { - const nextElement = element.nextElementSibling; - return nextElement - && nextElement.classList.contains('flatpickr-calendar') - && nextElement.classList.contains('open'); + return element.classList.contains('active'); } } diff --git a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb index cbbccb896b25..504ff134baa5 100644 --- a/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb +++ b/spec/features/projects/life_cycle/overview_page/dialog/update_spec.rb @@ -87,9 +87,13 @@ # Retrying due to a race condition between filling the input vs submitting the form preview. original_dates = [life_cycle_initiating.start_date, life_cycle_initiating.end_date] dialog.set_date_for(life_cycle_initiating, value: original_dates) + + page.driver.clear_network_traffic dialog.set_date_for(life_cycle_initiating, value: initiating_dates) dialog.expect_caption(life_cycle_initiating, text: "Duration: 8 working days") + # Ensure that only 1 ajax request is triggered after setting the date range. + expect(page.driver.browser.network.traffic.size).to eq(1) end ready_for_planning_date = start_date + 1.day From a417bfeba504b284d20528469a6f9bb62ee90dda Mon Sep 17 00:00:00 2001 From: Dombi Attila <83396+dombesz@users.noreply.github.com> Date: Wed, 15 Jan 2025 18:57:34 +0200 Subject: [PATCH 5/5] Inherit child controllers from FormPreviewController instead of composing. Update docs. --- .../project-life-cycles-form.controller.ts | 6 ++--- lookbook/docs/patterns/02-forms.md.erb | 24 +++++++++---------- .../sections/edit_component.html.erb | 5 ++-- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts index e5e7d4d73258..6b525477046f 100644 --- a/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/overview/project-life-cycles-form.controller.ts @@ -28,16 +28,16 @@ * ++ */ -import { Controller } from '@hotwired/stimulus'; +import FormPreviewController from '../../form-preview.controller'; -export default class ProjectLifeCyclesFormController extends Controller { +export default class ProjectLifeCyclesFormController extends FormPreviewController { previewForm(event:Event) { const target = event.target as HTMLElement; if (this.datePickerVisible(target)) { return; // flatpickr is still open, do not submit yet. } - this.dispatch('triggerFormPreview'); + void this.submit(); } datePickerVisible(element:HTMLElement) { diff --git a/lookbook/docs/patterns/02-forms.md.erb b/lookbook/docs/patterns/02-forms.md.erb index e890ac656a6d..f90afb24c514 100644 --- a/lookbook/docs/patterns/02-forms.md.erb +++ b/lookbook/docs/patterns/02-forms.md.erb @@ -176,7 +176,7 @@ In the example below we can see how the preview mechanism can be applied to form #### How it works: -The form preview mechanism can be applied to any form by binding the `form-preview` stimulus controller to it and then watch the input fields for changes: +The form preview mechanism can be applied to any form by binding the `form-preview` global stimulus controller to it and then watch the input fields for changes: ```ruby primer_form_with( @@ -196,39 +196,37 @@ The form preview mechanism can be applied to any form by binding the `form-previ #### Customizing the triggering mechanism: -In some cases we might want to further customize the triggering behaviour, for example when using a date range picker input field. For date range pickers we want to trigger the form preview only when the both the start and end dates are chosen and the datepicker is closed. The solution is to create a form specific controller that will decide if the `form-preview` controller should be called. +In some cases we might want to further customize the triggering behaviour, for example when using a date range picker input field. For date range pickers we want to trigger the form preview only when the both the start and end dates are chosen and the datepicker is closed. The solution is to create a form specific controller that will inherit from the `FormPreviewController` controller, and it decides if the `submit` action needs to be called. -1. The form specific controller handles the input changes on the form. It also dispatches the arbitrarily chosen `triggerFormPreview` event when the date range picker is not visible anymore. +1. The form specific controller handles the input changes on the form. It also calls the `submit()` function from the parent controller when the date range picker is not visible anymore. ```typescript - export default class CustomFormPreviewFormController extends Controller { - handleChange(event:Event) { + export default class CustomFormPreviewFormController extends FormPreviewController { + previewForm(event:Event) { const target = event.target as HTMLElement; if (this.datePickerVisible(target)) { return; // The datepicker is still open, do not submit yet. } - this.dispatch('triggerFormPreview'); + this.submit(); } } ``` -2. In order to chain the `custom-form-preview` and `form-preview` controller events, the following definition needs to be added on the form: +2. The definition of the controller and the preview url should also point to the new controller: ```ruby primer_form_with( url: "/foo", method: :get, data: { - "controller": "custom-form-preview form-preview", - "action": "custom-form-preview:triggerFormPreview->form-preview#submit", - "form-preview-url-value": preview_path + "controller": "custom-form-preview", + "custom-form-preview-url-value": preview_path } ) do |f| - f.text_field(name: :answer, data: { action: "change->custom-form-preview#handleChange" }) + f.text_field(name: :answer, data: { action: "change->custom-form-preview#previewForm" }) end ``` - - The `"action": "custom-form-preview:triggerFormPreview->form-preview#submit"` will route the `custom-form-preview#handleChange` action to the `form-preview#submit` action via the dispatched `triggerFormPreview` event. - - The `data: { action: "change->custom-form-preview#handleChange" }` watches the input changes and calls the `custom-form-preview#handleChange`. + - The `data: { action: "change->custom-form-preview#previewForm" }` watches the input changes and calls the `custom-form-preview#previewForm`. **Important note:** Javascript rendered elements inside the form such as the datepicker above, could be broken after the form update. This happens, because the datepicker input get replaced without re-initializing the datepicker library. To fix the issue, we can either avoid updating the datepicker input elements using "data-turbo-permanent", or we can programatically re-initialize them after the form update. In case of angular components, this issue is solved automatically by not updating them the components. For more info see the `turbo:before-morph-element` eventlistener in the `turbo-global-listeners.ts`. diff --git a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb index 886b1573bb76..63d2c6cf5a9b 100644 --- a/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb +++ b/modules/overviews/app/components/project_life_cycles/sections/edit_component.html.erb @@ -6,9 +6,8 @@ model:, method: :put, data: { - "controller": "overview--project-life-cycles-form form-preview", - "action": "overview--project-life-cycles-form:triggerFormPreview->form-preview#submit", - "form-preview-url-value": project_life_cycles_form_path(project_id: model.id), + "controller": "overview--project-life-cycles-form", + "overview--project-life-cycles-form-url-value": project_life_cycles_form_path(project_id: model.id), "application-target": "dynamic", turbo: true, turbo_stream: true,