Skip to content

Commit

Permalink
Merge pull request #17589 from opf/documentation/add-form-preview-doc…
Browse files Browse the repository at this point in the history
…umentation

Add form preview documentation and create a generic form preview controller
  • Loading branch information
HDinger authored Jan 16, 2025
2 parents bf7395c + a417bfe commit 2960bc5
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 21 deletions.
2 changes: 1 addition & 1 deletion app/forms/projects/life_cycles/form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,31 +28,19 @@
* ++
*/

import { Controller } from '@hotwired/stimulus';
import FormPreviewController from '../../form-preview.controller';

export default class ProjectLifeCyclesFormController extends Controller {
static targets = ['form'];

declare readonly formTarget:HTMLFormElement;

handleChange(event:Event) {
export default class ProjectLifeCyclesFormController extends FormPreviewController {
previewForm(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();
void this.submit();
}

datePickerVisible(element:HTMLElement) {
const nextElement = element.nextElementSibling;
return nextElement
&& nextElement.classList.contains('flatpickr-calendar')
&& nextElement.classList.contains('open');
return element.classList.contains('active');
}
}
48 changes: 48 additions & 0 deletions frontend/src/stimulus/controllers/form-preview.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* -- 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 { Controller } from '@hotwired/stimulus';

export default class FormPreviewController extends Controller<HTMLFormElement> {
static values = { url: String };

declare urlValue:string;

async submit():Promise<void> {
if (!this.urlValue) {
return;
}

const form = this.element;
form.action = this.urlValue;

form.requestSubmit();
}
}
2 changes: 2 additions & 0 deletions frontend/src/stimulus/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
122 changes: 122 additions & 0 deletions lookbook/docs/patterns/02-forms.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,128 @@ 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 OpPrimer::FormPreview, :default %>

#### How it works:

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(
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 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 calls the `submit()` function from the parent controller when the date range picker is not visible anymore.

```typescript
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.submit();
}
}
```
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",
"custom-form-preview-url-value": preview_path
}
) do |f|
f.text_field(name: :answer, data: { action: "change->custom-form-preview#previewForm" })
end
```
- 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`.


#### 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

Expand Down
18 changes: 18 additions & 0 deletions lookbook/previews/op_primer/form_preview.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions lookbook/previews/op_primer/form_preview/default.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<%
form_preview = Class.new(ApplicationForm) do
form do |f|
f.text_field(
name: :answer,
value: answer,
label: "Answer",
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 %>


Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
method: :put,
data: {
"controller": "overview--project-life-cycles-form",
"overview--project-life-cycles-form-target": "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,
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 2960bc5

Please sign in to comment.