Structure and extend our documentation on forms

This commit is contained in:
Henriette Darge
2026-03-31 15:25:06 +02:00
parent b61112534d
commit d703f98dc0
7 changed files with 739 additions and 583 deletions
@@ -0,0 +1,55 @@
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), the filter panel and in multi-step configuration screens (such as admin file storage or authentication setup pages).
## Overview
<%= embed Patterns::FormsPreview, :custom_width_fields_form %>
## 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
- Fieldset group
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](https://primer-lookbook.github.com/view-components/lookbook/pages/forms/groups_layouts).
If a form is particularly long, split it into different form groups using `fieldset_group`. Each group gets a title and renders a semantic `<fieldset>`. See the [OpenProject helpers](./usage#fieldset-group-to-group-related-inputs) section for usage details.
Each group should only have its own Save button if the groups are semantically independent — meaning saving one has no effect on another. If the groups only serve visual structure within the same context, a single Save button at the end of the form is sufficient. Toggle switches are an exception: they always save immediately on interaction and never need an explicit Save button.
## 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](./settings_pages)
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: :xsmall` => `max-width: min(144px, 100vw - 2rem)`
- `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(680px, 100vw - 2rem)`
- `input_width: :xxlarge` => `max-width: min(960px, 100vw - 2rem)`
<%= embed Patterns::FormsPreview, :default, panels: %i[] %>
@@ -0,0 +1,192 @@
## 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
```ruby
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."
)
end
end
```
```erb
<%%= 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, combined using `Primer::Forms::FormList`:
```erb
<%%= primer_form_with(url: "/foo") do |f| %>
<%%= render(Primer::Forms::FormList.new(
TextFieldAndCheckboxForm.new(f),
SubmitButtonForm.new(f)
)) %>
<%% end %>
```
## 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. However, the recommended way to access the model object, is to 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.
```erb
<%%=
primer_form_with(model: post, url: "/foo") do |f|
render_inline_form(f) do |form|
form.text_field name: :name, disabled: model.is_readonly
form.check_box name: :is_readonly
end
end
%>
```
## 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 anonymous 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:
```erb
<%%=
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."
)
end
end
%>
```
### `fieldset_group` to group related inputs
Use `fieldset_group` to visually and semantically group related form inputs under a shared heading. It renders a `<fieldset>` element, which improves readability and accessibility for longer or more complex forms.
```ruby
class NotificationSettingsForm < ApplicationForm
form do |my_form|
my_form.fieldset_group(title: "Email notifications") do |group|
group.check_box(
name: :notify_on_mention,
label: "Notify on mention"
)
group.check_box(
name: :notify_on_assignment,
label: "Notify on assignment"
)
end
my_form.fieldset_group(title: "In-app notifications", mt: 3) do |group|
group.check_box(
name: :in_app_enabled,
label: "Enable in-app notifications"
)
end
end
end
```
The block yields a group builder that supports the same input helpers as the form object (`text_field`, `check_box`, `select_list`, etc.).
For an example implementation, see the [Fieldset Group preview](/lookbook/inspect/primer/forms/fieldset_group_form).
### `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. Use it sparingly — if content feels out of place
as a form field, consider whether it belongs outside the form instead.
```ruby
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")
end
end
```
## Caption templates
Captions and caption templates are basic part of the Primer form system. The following bit is mostyl copied from the [Primer docs](https://primer.style/view-components/lookbook/pages/forms/caption_templates/).
All inputs support the `caption:` argument as a string, which renders explainer text underneath the input and validation message.
```rb
class ExampleForm < ApplicationForm
form do |example_form|
example_form.text_field(
name: :disco_name,
label: "Disco name",
caption: "Enter your grooviest name"
)
end
end
```
### Defining caption templates
In cases where a lot of logic or markup is necessary, or where the caption text is awkward to express in Ruby code, forms may define caption content in separate template files. Caption templates are located in a directory named after the form.
For example, if `ExampleForm` lives in `app/forms/example_form.rb`, its caption templates should be created in the `app/forms/example_form/ directory.
Caption template files must be named after the field they describe. For example, a caption template for the `disco_name` field above would live in `app/forms/example_form/disco_name_caption.html.erb`.
In cases where the caption: argument and a caption template are provided, the caption: argument takes precedence.
For detailed examples, please refer to the [Primer docs](https://primer-lookbook.github.com/view-components/lookbook/inspect/primer/forms/caption_template_form).
@@ -0,0 +1,190 @@
## Basic Interactivity
An agreed pattern in OpenProject is to hide fields that only make sense when a precondition is met. For example, if a checkbox enables a feature, all configuration fields for that feature should be hidden until the checkbox is checked. This reduces cognitive load and prevents users from filling in fields that will have no effect.
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:
```ruby
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"
})
# 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"
})
# 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"
})
end
end
```
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 value
- `data: { value: 'XYZ'}` or `data-value="XYZ"` the value to be checked against the cause
- `data: { not_value: 'XYZ'}` or `data-not-value="XYZ"` the value to be checked against the cause, resulting in it being hidden if the selected value is the given one.
- data: `{ target_name: "abc"}` or `data-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.
### 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 programmatically 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.
@@ -0,0 +1,158 @@
## 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:
```ruby
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],
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],
disabled: !Setting.host_name_writable?
)
#
# and so on...
#
general_form.submit(
name: 'submit',
label: I18n.t('button_save'),
scheme: :primary
)
end
end
```
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:
```ruby
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)
general_form.text_field(name: :activity_days_default,
type: :number)
general_form.text_field(name: :host_name)
#
# and so on...
#
general_form.submit
end
end
```
It is easier to write and read.
Under the hood, the form object is decorated with `Settings::FormObjectDecorator`.
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 called
`name`, automatically setting the label, value, and disabled state from the
setting's attributes.
* `check_box(name:, **options)`: renders a checkbox for the setting called
`name`, 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 called `name` and radio button for each
element of `values`, automatically setting the label, checked state, html
caption, and disabled state from the setting's attributes.
* `fieldset_group(title:, **options) { |group| ... }`: groups multiple related
form inputs under a shared heading and semantic `<fieldset>`. The block yields
a group builder that supports the same input helpers (`text_field`,
`check_box`, `select_list`, etc.). Fieldset groups improve readability and
accessibility for longer or more complex forms. For an example implementation, see the [Fieldset Group preview](/lookbook/inspect/primer/forms/fieldset_group_form).
* `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.
## `settings_primer_form_with` for administration pages
Use `settings_primer_form_with` instead of `primer_form_with` on administration and project settings pages. It wraps the form in a width-limiting container (`op-admin-settings-form-wrapper`) for a consistent look and feel across all settings pages.
All arguments and the block are passed through to `primer_form_with` unchanged:
```erb
<%%= settings_primer_form_with(
url: admin_settings_foo_path,
scope: :settings,
method: :patch
) do |form| %>
<%%= render(Primer::Forms::FormList.new(FooSettingsForm.new(form))) %>
<%% end %>
```
## `render_inline_settings_form` to avoid creating settings form classes
Just as `render_inline_form` avoids creating a dedicated form class for regular forms,
`render_inline_settings_form` does the same for settings forms. It combines the
anonymous form pattern with the `settings_form` decorator.
```erb
<%%= settings_primer_form_with(url: admin_settings_foo_path, method: :patch) do |form| %>
<%%= render_inline_settings_form(form) do |f| %>
<%%= f.check_box(name: :ical_enabled) %>
<%%= f.text_field(name: :attachment_max_size) %>
<%%= f.submit %>
<%% end %>
<%% end %>
```
Both helpers are available in views and ViewComponents that include `OpPrimer::FormHelpers`.
@@ -0,0 +1,38 @@
## 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:
```ruby
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) and `constrained` (limited functionality) is supported.
### Examples
<%= embed OpenProject::Common::RichTextPreview, :default, panels: %i[source] %>
@@ -0,0 +1,106 @@
## Best practices
As a general principle, follow the [Primer form guidelines](https://primer.style/product/ui-patterns/forms) as closely as possible. They define how form elements should be structured, labeled, validated, and composed. If a design deviates from these guidelines, question it before implementing — the deviation may be intentional, but it may also be an oversight worth raising with the designer.
The practices below are OpenProject-specific additions and conventions on top of Primer.
### Use `fieldset_group` to structure longer forms
Use `fieldset_group` to group related fields under a shared heading. If the groups only serve visual structure, use a single Save button at the end of the form. Only give each group its own Save button when the groups are semantically independent — meaning saving one has no effect on another.
```ruby
# Single submit for the whole form
settings_form do |f|
f.fieldset_group(title: "Notifications") do |group|
group.check_box(name: :notify_on_mention)
group.check_box(name: :notify_on_assignment)
end
f.fieldset_group(title: "Privacy", mt: 4) do |group|
group.check_box(name: :hide_email)
end
f.submit
end
# Each group saved independently
settings_form do |f|
f.fieldset_group(title: "Notifications") do |group|
group.check_box(name: :notify_on_mention)
group.submit
end
f.fieldset_group(title: "Privacy", mt: 4) do |group|
group.check_box(name: :hide_email)
group.submit
end
end
```
### Use `html_content` sparingly
`html_content` allows mixing arbitrary HTML into a form, but it should be used as a last resort. If a piece of content does not relate directly to a form field (caption, validation message), consider placing it outside the form altogether. Overusing `html_content` makes forms harder to read and maintain.
### Choose `input_width` based on expected input length
Full-width inputs look appropriate in narrow containers but awkward in wide settings pages. Match the field width to the expected content: short for dates or numbers, wider for names or URLs.
```ruby
form.text_field(name: :port, type: :number, input_width: :xsmall)
form.text_field(name: :host_name, input_width: :large)
form.text_area(name: :description) # full width is fine for free text
```
### Prefer `render_inline_form` over dedicated form classes for simple forms
Only create a dedicated `ApplicationForm` subclass when the form is reused in multiple places or contains enough complexity to warrant its own file. For one-off forms, `render_inline_form` (or `render_inline_settings_form` for settings) keeps the code local and avoids polluting the form class namespace.
```erb
<%# Prefer this for simple, one-off forms %>
<%%= primer_form_with(url: "/foo") do |f| %>
<%%= render_inline_form(f) do |form| %>
<%%= form.text_field(name: :title, label: "Title") %>
<%%= form.submit(name: :submit, label: "Save") %>
<%% end %>
<%% end %>
```
### For administration pages, always use `settings_primer_form_with`
On admin and project settings pages, use `settings_primer_form_with` instead of plain `primer_form_with`. This ensures the form is wrapped in the standard width-limiting container (`op-admin-settings-form-wrapper`) for a consistent look across all settings pages.
Pair it with `render_inline_settings_form` or a `settings_form`-based class to avoid repeating label lookups and writable checks for each field.
```erb
<%%= settings_primer_form_with(url: admin_settings_foo_path, method: :patch) do |form| %>
<%%= render_inline_settings_form(form) do |f| %>
<%%= f.check_box(name: :foo_enabled) %>
<%%= f.submit %>
<%% end %>
<%% end %>
```
### Use `Primer::Forms::FormList` to combine multiple form instances
When rendering multiple form instances inside a single `primer_form_with`, always wrap them in `Primer::Forms::FormList` rather than calling `render` on each one individually.
```erb
<%%= primer_form_with(url: "/foo") do |f| %>
<%%= render(Primer::Forms::FormList.new(
GeneralSettingsForm.new(f),
AdvancedSettingsForm.new(f)
)) %>
<%% end %>
```
### Hide fields that only make sense when a precondition is met
If a field only becomes relevant when another field has a specific value — for example, configuration options that only apply when a feature is enabled — hide the dependent fields until the precondition is met. This reduces cognitive load and prevents users from filling in fields that will have no effect.
Use the `show-when-value-selected` Stimulus controller for this. It covers the most common cases (show field when value X is selected, hide when value Y is selected) without custom JavaScript. See the [interactivity documentation](./interactivity) for usage details.
Only write a custom Stimulus controller when the built-in mechanism is insufficient — for example, when the trigger is a date range picker or requires debouncing.
### Use form preview for complex validation and dependent fields
When multiple fields depend on each other or you need to show validation errors incrementally as the user fills in the form, use the [form preview pattern](./interactivity#advanced-interactivity-by-previewing-forms-before-submission) rather than managing each field's state individually on the frontend.
-583
View File
@@ -1,583 +0,0 @@
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), the filter panel and in multi-step configuration screens (such as admin file storage or authentication setup pages).
## Overview
<%= embed Patterns::FormsPreview, :custom_width_fields_form %>
## 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
- Fieldset group
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](https://primer-lookbook.github.com/view-components/lookbook/pages/forms/groups_layouts).
If a form is particularly long, split it into different form groups and use a [`Subhead`](https://primer-lookbook.github.com/view-components/lookbook/inspect/primer/beta/subhead/default) at the start of each to give it a title. When using Subheads, we recommend implementing individual Save buttons for each section (using the <em>Secondary</em> 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 <em>Primary</em> 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](./settings_pages)
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: :xsmall` => `max-width: min(144px, 100vw - 2rem)`
- `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(680px, 100vw - 2rem)`
- `input_width: :xxlarge` => `max-width: min(960px, 100vw - 2rem)`
<%= embed Patterns::FormsPreview, :default, panels: %i[] %>
## 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
```ruby
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."
)
end
end
```
```erb
<%%= 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:
```erb
<%%= 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:
```ruby
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"
}
end
end
```
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 value
- `data: { value: 'XYZ'}` or `data-value="XYZ"` the value to be checked against the cause
- `data: { not_value: 'XYZ'}` or `data-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"}` or `data-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:
<%= 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
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:
```erb
<%%= primer_form_with(model: post, url: "/foo") do |f| %>
<%%= render(MyForm.new(f, model: post)) %>
<%% end %>
```
```ruby
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
end
end
```
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.
```erb
<%%=
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
end
end
%>
```
### 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:
```erb
<%%=
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."
)
end
end
%>
```
#### `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.
```ruby
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")
end
end
```
### 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:
```ruby
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: safe_join [I18n.t(:text_comma_separated), helpers.tag(:br), I18n.t(:text_notice_too_many_values_are_inperformant)],
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
)
end
end
```
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:
```ruby
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: safe_join [I18n.t(:text_comma_separated),
helpers.tag(:br),
I18n.t(:text_notice_too_many_values_are_inperformant)])
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
end
end
```
It is easier to write and read.
Under the hood, the form object is decorated with `Settings::FormObjectDecorator`.
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 called
`name`, automatically setting the label, value, and disabled state from the
setting's attributes.
* `check_box(name:, **options)`: renders a checkbox for the setting called
`name`, 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 called `name` and radio button for each
element of `values`, automatically setting the label, checked state, html
caption, and disabled state from the setting's attributes.
* `fieldset_group(title:, **options) { |group| ... }`: groups multiple related
form inputs under a shared heading and semantic `<fieldset>`. The block yields
a group builder that supports the same input helpers (`text_field`,
`check_box`, `select_list`, etc.). Fieldset groups improve readability and
accessibility for longer or more complex forms. For an example implementation, see the [Fieldset Group preview](/lookbook/inspect/primer/forms/fieldset_group_form).
* `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:
```ruby
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) and `constrained` (limited functionality) is supported.
### Examples
<%= embed OpenProject::Common::RichTextPreview, :default, panels: %i[source] %>