Forms
A form is a series of components that require user input that will then be submitted. Forms are notably common in settings pages but are also present elsewhere like in individual modals (edit project attribute section, edit meeting details) and the filter panel.
Overview
Form elements
A form may be composed of these elements:
- Text input
- Text area
- Rich text area
- Select
- Toggle switch
- Checkbox
- Checkbox group
- Radio button
- Radio groups
Additional elements like Banners might also be used.
Grouping and hierarchy
Form elements that are related need to be grouped together. For this, use a form group.
If a form is particularly long, split it into different form groups and use a Subhead
at the start of each to give it a title. When using Subheads, we recommend implementing individual Save buttons for each section (using the Secondary style). If a section only contains Toggle switch
elements, a separate Save button is not necessarily (since the Toggle sends its own server request on interaction).
If a form does not use Subhead sections, then there should be a single 'Save' (using the Primary style) button at the end.
Form width
In Primer, form elements automatically take the width of the container. In certain cases (especially Settings pages), full-width input fields will look strange. For these wide settings pages, we introduced a pattern that wraps the form inside container that limits the width of the form.
Individual form elements can however be sized to be smaller than the width the container. A good rule of thumb is to choose a width for a field based on the expected length of the user input: date fields can for example be rather short but a name field has the potential to be quite long. While the input field can be limited in width, the caption and validation messages will always extend to the full width of the form (or the container, if one is used).
In OpenProject, each form element also has its own container. It is thus possible to define the container width for each
input with the :input_width
parameter.
The options are:
input_width: :auto
=>width: auto
input_width: :small
=>max-width: min(256px, 100vw - 2rem)
input_width: :medium
=>max-width: min(320px, 100vw - 2rem)
input_width: :large
=>max-width: min(480px, 100vw - 2rem)
input_width: :xlarge
=>max-width: min(640px, 100vw - 2rem)
input_width: :xxlarge
=>max-width: min(960px, 100vw - 2rem)
Technical notes
Usage
To create forms, you need 2 basic things:
- A form instance to render fields
- A
primer_form_with
call to get a form builder and render the form instance
class TextFieldAndCheckboxForm < ApplicationForm form do |my_form| my_form.text_field( name: :ultimate_answer, label: "Ultimate answer", required: true, caption: "The answer to life, the universe, and everything" ) my_form.check_box( name: :enable_ipd, label: "Enable the Infinite Improbability Drive", caption: "Cross interstellar distances in a mere nothingth of a second." ) endend
<%= primer_form_with(url: "/foo") do |f| %> <%= render(TextFieldAndCheckboxForm.new(f)) %><% end %>
Multiple form instances can be rendered within the same primer_form_with
call,
allowing to put some content in between:
<%= primer_form_with(url: "/foo") do |f| %> <%= render(TextFieldAndCheckboxForm.new(f)) %> <%= render(MessageComponent.new(icon: :info, message: "This will be fine!")) %> <%= render(SubmitButtonForm.new(f)) %><% end %>
This is the regular way of using Primer forms.
Basic Interactivity
In many cases, you want to show or hide a certain field based on the value of another field. For this purpose, there is the Stimulus controller show-when-value-selected
.
To use it, pass the controller to the form, and then use cause
and effect
targets together with a data-value
.
Basic example:
primer_form_with( ... data: { controller: "show-when-value-selected" }) do |f| render_inline_form(f) do |my_form| my_form.select_list( name: "frequency", label: "Choose frequency", data: { target_name: "frequency", "show-when-value-selected-target": "cause" } ) do |list| list.option(label: "Foo", value: "foo") list.option(label: "Bar", value: "bar") list.option(label: "Third option", value: "other") end # Will be shown when "Foo" is selected my_form.text_field( name: :interval, type: :number, data: { target_name: "frequency", value: "foo" "show-when-value-selected-target": "cause" } end # Will be shown when "Bar" is selected my_form.text_field( name: :random, type: :text, data: { target_name: "frequency", value: "bar" "show-when-value-selected-target": "effect" } end # Will be shown when not "other" is selected my_form.text_field( name: :random, type: :text, hidden: @my_object.state == 'other' data: { target_name: "frequency", not_value: "other" "show-when-value-selected-target": "effect" } endend
Important data inputs:
"show-when-value-selected-target": "cause"
marks a field as the emitter of a change. Whenever this input changes, the other fields visibility will be toggled."show-when-value-selected-target": "effect"
is the input that will get hidden or shown depending on the selected valuedata: { value: 'XYZ'}
ordata-value="XYZ"
the value to be checked against the causedata: { not_value: 'XYZ'}
ordata-not-value="XYZ"
the value to be checked against the cause, resulting in it being hidden if the selected value is NOT the given one.- data:
{ target_name: "abc"}
ordata-target-name="abc"
allows you to define multiple sets of cause/effect handlers - 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:
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:
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.
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.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();}}The definition of the controller and the preview url should also point to the new controller:
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 thecustom-form-preview#previewForm
.
- The
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.
First, we'll create a
PreviewAttributesService
that inherits from theSetAttributes
service. This new service is nearly identical toSetAttributes
, 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.module WorkPackagesclass PreviewAttributesService < ::BaseServices::SetAttributesdef perform(*)super.tap do |service_call|clear_unchanged_fields(service_call)endendprivatedef clear_unchanged_fields(service_call)work_package = service_call.resultwork_package.errors.select { |error| work_package.changed.exclude?(error.attribute.to_s) }.each do |error|work_package.errors.delete(error.attribute)endendendendThen we define a new controller member action called
work_package_form
alongside the crud actions and use the newly definedPreviewAttributesService
.class WorkPackagesController < ApplicationControllerdef work_package_formservice_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)endend- 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.
- For a smoother user experience, it is recommended to respond with the
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
When defining a form, the model sometimes needs to be accessed, for instance to remove or add some fields depending on the state of the model.
One way to do so is to pass the model to the form instance at initialization:
<%= primer_form_with(model: post, url: "/foo") do |f| %> <%= render(MyForm.new(f, model: post)) %><% end %>
class MyForm < ApplicationForm def initialize(model:) super() @model = model end form do |f| f.text_field name: :name, disabled: @model.is_readonly f.check_box name: :is_readonly endend
Actually, it is not necessary: to access the model object, use model
directly.
It returns the model which was passed as parameter when primer_form_with
was called. It is defined in ApplicationForm
and is available on all forms.
Here is an example of an inline form where the name
field is disabled if the
model is read-only. This is done without having to create an intermediary class
with model given as parameter.
<%=primer_form_with(model: post, url: "/foo") do |f| render_inline_form do |form| form.text_field name: :name, disabled: model.is_readonly form.check_box name: :is_readonly endend%>
OpenProject helpers
OpenProject provides some helpers to make building and rendering forms easier.
render_inline_form
to avoid creating form classes
This helper allows to render an anymous form instance, avoiding the need to create a dedicated form class. This can be useful for simple forms or when you don't want to pollute the form class namespace.
The above example which was needing a dedicated TextFieldAndCheckboxForm
form
class can be rewritten like this:
<%=primer_form_with(url: "/foo") do |f| render_inline_form(f) do |my_form| my_form.text_field( name: :ultimate_answer, label: "Ultimate answer", required: true, caption: "The answer to life, the universe, and everything" ) my_form.check_box( name: :enable_ipd, label: "Enable the Infinite Improbability Drive", caption: "Cross interstellar distances in a mere nothingth of a second." ) endend%>
FormObject#html_content
to mix form fields and html content
This helper allows to render non-form content in a form. For instance it can be used to render a description box inside a form, an image, or whatever makes sense for the form being built.
class TextFieldWithWarningForm < ApplicationForm attr_reader :warning def initialize(warning: nil) super() @warning = warning end form do |my_form| my_form.text_field( name: :full_name, label: "Full name", required: true ) if warning my_form.html_content do tag.div(class: "flash flash-warn") { warning } end end my_form.submit(name: :submit, label: "Save") endend
Forms for administration pages
Administration pages forms are used to change the values of Settings
. The name
and labels being used are standardized making them very repetitive.
Here is how the form of the General tab of the system administration page could look like:
class Admin::Settings::GeneralSettingsForm < ApplicationForm attr_reader :guessed_host def initialize(guessed_host:) super() @guessed_host = guessed_host end form do |general_form| general_form.text_field( name: :app_title, label: I18n.t("setting_app_title"), value: Setting[:app_title], disabled: !Setting.app_title_writable? ) general_form.text_field( name: :per_page_options, label: I18n.t("setting_per_page_options"), value: Setting[:per_page_options], caption: "#{I18n.t(:text_comma_separated)}<br/>" \ "#{I18n.t(:text_notice_too_many_values_are_inperformant)}".html_safe) disabled: !Setting.per_page_options_writable? ) general_form.text_field( name: :activity_days_default, label: I18n.t("setting_activity_days_default"), value: Setting[:activity_days_default], type: :number, disabled: !Setting.activity_days_default_writable? ) general_form.text_field( name: :host_name, label: I18n.t("setting_host_name"), value: Setting[:host_name], caption: "#{I18n.t(:label_example)}: #{guessed_host}"), disabled: !Setting.host_name_writable? ) # # and so on... # general_form.submit( name: 'submit', label: I18n.t('button_save'), scheme: :primary ) endend
There is a lot of repetition in the form above: the field can be disabled for
read-only settings (which happens for settings set through environment variables
or configuration files), the field name has to be translated and the value must
be read from Settings
. Entering all this information manually is tedious and
error prone.
In this case, settings_form
can be used instead of form
to get a form
instance with knowledge about how render fields for settings.
The above example then becomes:
class Admin::Settings::GeneralSettingsForm < ApplicationForm attr_reader :guessed_host def initialize(guessed_host:) super() @guessed_host = guessed_host end settings_form do |general_form| general_form.text_field(name: :app_title) general_form.text_field(name: :per_page_options, caption: "#{I18n.t(:text_comma_separated)}<br/>" \ "#{I18n.t(:text_notice_too_many_values_are_inperformant)}".html_safe) general_form.text_field(name: :activity_days_default, type: :number) general_form.text_field(name: :host_name, caption: "#{I18n.t(:label_example)}: #{guessed_host}") # # and so on... # general_form.submit endend
It is easier to write and read.
Under the hood, the form object is decorated with SettingsFormDecorator
.
That's where all the helper methods are defined. There aren't many for now, but
this is intended to grow to support more advanced form features for
administration pages.
So far, the following helpers are available:
text_field(name:, **options)
: renders a text field for the setting calledname
, automatically setting the label, value, and disabled state from the setting's attributes.check_box(name:, **options)
: renders a checkbox for the setting calledname
, automatically setting the label, checked state, and disabled state from the setting's attributes.radio_button_group(name:, values:, button_options: {}, **options)
: renders a radio button group for the setting calledname
and radio button for each element ofvalues
, automatically setting the label, checked state, html caption, and disabled state from the setting's attributes.submit
: renders a submit button with the label "Save" and the primary scheme.form
: the form builder instance if you need to render some form elements normally handled by the settings form decorator in another way than intended. Any call to a method that is not defined on the settings form decorator will be forwarded to this form builder instance so its usage is transparent.
Rich text editor
OpenProject uses CKEditor for rich text editing. See https://www.openproject.org/docs/development/concepts/wysiwyg-editor/ for more information on how the editor works and how it is used in frontend code.
To add a rich text field to a primer form, use the form.rich_text_area
input:
form do |agenda_item_form| agenda_item_form.rich_text_area( name: :notes, label: MeetingAgendaItem.human_attribute_name(:notes), disabled: @disabled, rich_text_options: { resource:, showAttachments: false } )end
Under the hood, this will render a textarea and next to it, a custom element called opce-ckeditor-augmented-textarea
.
This custom element will sync the markdown output from the rich text editor to the textarea, so that on submission,
markdown is sent as if it was contained in the textarea.
Options
Here are the most common options you'd pass to the rich text editor:
- resource: An APIv3 represented resource to attach attachments to. For example, if you are editing a work package,
you would pass
API::V3::WorkPackages::WorkPackageRepresenter.new(work_package, current_user: User.current, embed_links: false)
- showAttachments: (false) Whether to show attachments below the editor. They will only be present if
resource
is present - editorType: (full) The type of editor to use. Currently, only
full
(all macros) andconstrained
(limited functionality) is supported.