Skip to content

Commit

Permalink
Automatically indicate required input fields (#502)
Browse files Browse the repository at this point in the history
Automatically show required fields in form fields. Uses @notblank, @notempty and @NotNull annotations on input fields to decide which fields should be marked as required in the client side form.

Adds a mechanism to mark an input as required using an attribute `required` passed to the thymeleaf fragment for the input. 

---------

Co-authored-by: Sree P <sprasad@codeforamerica.org>
  • Loading branch information
spokenbird and sree-cfa committed May 9, 2024
1 parent 30dfa2b commit 0d4b4ac
Show file tree
Hide file tree
Showing 24 changed files with 351 additions and 113 deletions.
111 changes: 110 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Table of Contents
* [Submission Object](#submission-object)
* [Inputs Class](#inputs-class)
* [Validating Inputs](#validating-inputs)
* [Required Inputs](#required-inputs)
* [Dynamic Input Fields](#dynamic-input-fields)
* [Custom Annotations](#custom-annotations)
* [Validation Annotations](#validation-annotations)
Expand Down Expand Up @@ -596,6 +597,78 @@ use `@Email` and `@NotBlank` together, that causes both validations to run even
enter a value, validating both that they need to enter a value due to `@NotBlank` and because the
blank value needs to be a validly formatted email address due to `@Email`.

### Required Inputs

As mentioned above in the [Validating Inputs](#validating-inputs) section, the annotations `@NotEmpty`,
`@NotBlank`, and `@NotNull` are used to make a field required. The library will automatically
append a red '(required)' to the end of your input labels.

#### Special Required Field Situations

Sometimes you may have a field that is required, but not through an annotation such as those mentioned above.
The specific scenario where this might be the case is when a field uses cross validation in
the flows-config file and that validation makes it required.

For this situation, we have provided an attribute which can be added to inputs, `required` which is a boolean
that defaults to false. If set to true, the field will be marked as required in the UI and append the
red `(required)` text.

For example:
```html
<th:block th:replace="~{fragments/inputs/text ::
text(inputName='firstName',
label=#{personal-info.first-name-label},
required=true,
helpText=#{personal-info.first-name-help})}"/>
```

Note that this attribute is optional and intended to be used in these special situations. It will only
append the red `(required)` to the appropriate place but will not actually validate the field as required.

#### Required Fields on Single Input Screens
The library provides a convenience template fragment for single input screens that will make such
screens accessible to screen readers.

When using this template and you want a field to be indicated as required, you need to provide the
input name when defining the fragment so that Thymeleaf can access the field name when checking required
field annotations.

Example:
```html
<th:block>
<th:block
th:replace="~{fragments/screens/screenWithOneInput ::
screenWithOneInput(
title=#{economic-hardship.title},
header=#{economic-hardship.header},
subtext=#{economic-hardship.subheader},
formAction=${formAction},
inputName='economicHardshipTypes',
inputContent=~{::inputContent})}">
... more content
```
Note the `inputName='economicHardshipTypes'` attribute. This is the name of the input field
which will appear below `more content` above. This name is used to check for required field annotations
so that the template will know the field is required.

We've also provided the same `required` attribute for this template fragment as well. Again this is
an optional attribute for situations where the field is required but not through an annotation.
Here is an example of using it with the `screenWithOneInput` fragment:
```html
<th:block
th:replace="~{fragments/screens/screenWithOneInput ::
screenWithOneInput(
title=#{economic-hardship.title},
required=true,
header=#{economic-hardship.header},
subtext=#{economic-hardship.subheader},
formAction=${formAction},
inputName='economicHardshipTypes',
inputContent=~{::inputContent})}">
```

Note that passing `required=true` here will append the red `(required)` to the label.

## Dynamic Input Fields

A field is dynamic if it is unknown exactly how many of them will be submitted on a given form
Expand Down Expand Up @@ -1034,7 +1107,7 @@ page is labeled by the page header. In this case we recommend using the `cfa:scr
live
template which we have provided. This template makes use of an `aria-label` referencing the header
of
the page instead of a traditional label.
the page instead of a traditional label. For more information on see [Screen with One Input](#screen-with-one-input).
#### Text
Expand Down Expand Up @@ -1505,6 +1578,42 @@ There is an optional field called `isHidden` available on both buttons. If set t
the `display-none` class will be added to the widget's classes resulting in the button being hidden
when the page loads.

### Screen with One Input

Screens with a single input are tricky because all inputs need a label associated with them so that
screen readers and other assistive technologies can identify those elements on a screen that label an input.
For this screen pattern the label is the header of the page, which needs to be programmatically
associated with the input. To handle this, we have a special fragment called `screenWithOneInput`.
You can use the `cfa:screenWithOneInput` live template to quickly create a screen with a single input
that utilizes this fragment.

Note that the live template will ask you for a `title`, `header`, and `inputName`. This input name should
match the input name you give the actual input you use. There is also an optional `subtext` which can
display optional text below the header.

The live template looks like this:
```html
<th:block
th:replace="~{fragments/screens/screenWithOneInput ::
screenWithOneInput(
title=#{},
header=#{},
inputName='',
subtext=#{},
formAction=${formAction},
inputContent=~{::inputContent})}">
<th:block th:ref="inputContent">
<!-- Be sure to have `ariaLabel='header'` to label the input with the header -->
<th:block th:replace="~{fragments/inputs/text ::
text(inputName='',
ariaLabel='header')}"/>
</th:block>
</th:block>
```

Note in this example we use a text input, but you can use any input type you like. Just make sure the
input name matches the `inputName` you give the `screenWithOneInput` fragment.

## Document Upload

The library provides a file upload feature using the client side JavaScript
Expand Down
22 changes: 11 additions & 11 deletions intellij-settings/LiveTemplates.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@
<option name="HTML" value="true" />
</context>
</template>
<template name="cfa:checkbox" value="&lt;th:block th:replace=&quot;~{fragments/inputs/checkbox ::&#10; checkbox(inputName='$INPUT_NAME$',&#10; label=#{$LABEL$},&#10; value=#{$VALUE$},&#10; helpText=#{$CHECKBOX_HELP_TEXT$})}&quot;/&gt;" description="A field checkbox with label, name, optional help text and optional icon" toReformat="false" toShortenFQNames="true">
<variable name="INPUT_NAME" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="VALUE" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="LABEL" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="CHECKBOX_HELP_TEXT" expression="" defaultValue="" alwaysStopAt="true" />
<context>
<option name="HTML" value="true" />
</context>
</template>
<template name="cfa:conditionMethod" value="public static Boolean $METHOD_NAME$(Submission submission) {&#10; if (submission.getInputData().containsKey(&quot;$FIELD_NAME$&quot;)) {&#10; // Change logic to suit your needs&#10; return submission.getInputData().get(&quot;$FIELD_NAME$&quot;)$CURSOR$;&#10; }&#10; return false;&#10;}" description="Create a condition method that first check for db field existance then your conditional logic" toReformat="false" toShortenFQNames="true">
<variable name="METHOD_NAME" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="FIELD_NAME" expression="" defaultValue="" alwaysStopAt="true" />
Expand Down Expand Up @@ -108,15 +117,6 @@
<option name="HTML" value="true" />
</context>
</template>
<template name="cfa:checkbox" value="&lt;th:block th:replace=&quot;~{fragments/inputs/checkbox ::&#10; checkbox(inputName='$INPUT_NAME$',&#10; label=#{$LABEL$},&#10; value=#{$VALUE$},&#10; helpText=#{$CHECKBOX_HELP_TEXT$})}&quot;/&gt;" description="A field checkbox with label, name, optional help text and optional icon" toReformat="false" toShortenFQNames="true">
<variable name="INPUT_NAME" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="VALUE" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="LABEL" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="CHECKBOX_HELP_TEXT" expression="" defaultValue="" alwaysStopAt="true" />
<context>
<option name="HTML" value="true" />
</context>
</template>
<template name="cfa:inputFieldsetWithRadio" value="&lt;th:block th:replace=&quot;~{fragments/inputs/radioFieldset ::&#10; radioFieldset(inputName='$INPUT_NAME$',&#10; label=#{$LABEL$},&#10; fieldsetHelpText=#{$OPTIONAL_HELP_TEXT$},&#10; content=~{::$CONTENTREF$})}&quot;&gt;&#10; &lt;th:block th:ref=&quot;$CONTENTREF$&quot;&gt;&#10; &lt;!-- Copy the below input if you want to add more --&gt;&#10; &lt;th:block&#10; th:replace=&quot;~{fragments/inputs/radio :: radio(inputName='$INPUT_NAME$',value='$VALUE$', label=#{$VALUE_LABEL$})}&quot;/&gt;&#10; &lt;/th:block&gt;&#10; &lt;/th:block&gt;" description="A fieldset with legend and radio input(s) with optional help text." toReformat="false" toShortenFQNames="true">
<variable name="INPUT_NAME" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="LABEL" expression="" defaultValue="" alwaysStopAt="true" />
Expand Down Expand Up @@ -241,7 +241,7 @@
<option name="HTML" value="true" />
</context>
</template>
<template name="cfa:screenWithOneInput" value="&lt;th:block&#10; th:replace=&quot;~{fragments/screens/screenWithOneInput ::&#10; screenWithOneInput(&#10; title=#{$TITLE$},&#10; iconFragment=~{fragments/icons :: $ICON_NAME$},&#10; header=#{$HEADER$},&#10; subtext=#{$SUBTEXT$},&#10; formAction=${formAction},&#10; inputContent=~{::inputContent})}&quot;&gt;&#10; &lt;th:block th:ref=&quot;inputContent&quot;&gt;&#10; &lt;!-- Be sure to have `ariaLabel='header'` to label the input with the header --&gt;&#10; &lt;th:block th:replace=&quot;~{fragments/inputs/text ::&#10; text(inputName='$INPUT_NAME$',&#10; ariaLabel='header')}&quot;/&gt;&#10; &lt;/th:block&gt;&#10;&lt;/th:block&gt;" description="An entire screen that has one input and is labelled by the page header." toReformat="false" toShortenFQNames="true">
<template name="cfa:screenWithOneInput" value="&lt;th:block&#10; th:replace=&quot;~{fragments/screens/screenWithOneInput ::&#10; screenWithOneInput(&#10; title=#{$TITLE$},&#10; iconFragment=~{fragments/icons :: $ICON_NAME$},&#10; header=#{$HEADER$},&#10; subtext=#{$SUBTEXT$},&#10; inputName='$INPUT_NAME$',&#10; formAction=${formAction},&#10; inputContent=~{::inputContent})}&quot;&gt;&#10; &lt;th:block th:ref=&quot;inputContent&quot;&gt;&#10; &lt;!-- Be sure to have `ariaLabel='header'` to label the input with the header --&gt;&#10; &lt;th:block th:replace=&quot;~{fragments/inputs/text ::&#10; text(inputName='$INPUT_NAME$',&#10; ariaLabel='header')}&quot;/&gt;&#10; &lt;/th:block&gt;&#10;&lt;/th:block&gt;" description="An entire screen that has one input and is labelled by the page header." toReformat="false" toShortenFQNames="true">
<variable name="TITLE" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="ICON_NAME" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="HEADER" expression="" defaultValue="" alwaysStopAt="true" />
Expand Down Expand Up @@ -317,4 +317,4 @@
<context>
<option name="HTML" value="true" />
</context>
</template>
</template>
2 changes: 2 additions & 0 deletions src/main/java/formflow/library/ScreenController.java
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,8 @@ private Map<String, Object> createModel(String flow, String screen, HttpSession
if (RequestContextUtils.getInputFlashMap(request) != null) {
model.put("lockedSubmissionMessage", RequestContextUtils.getInputFlashMap(request).get("lockedSubmissionMessage"));
}

model.put("requiredInputs", ValidationService.getRequiredInputs(flow));

return model;
}
Expand Down
36 changes: 33 additions & 3 deletions src/main/java/formflow/library/ValidationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
Expand Down Expand Up @@ -38,8 +39,11 @@ public class ValidationService {

private final Validator validator;
private final ActionManager actionManager;
private final String inputConfigPath;
private final List<String> requiredAnnotationsList = List.of(
private static String inputConfigPath;

private static final Map<String, Map<String, Boolean>> requiredInputs = new HashMap<>();

private static final List<String> requiredAnnotationsList = List.of(
NotNull.class.getName(),
NotEmpty.class.getName(),
NotBlank.class.getName()
Expand All @@ -56,7 +60,7 @@ public ValidationService(Validator validator, ActionManager actionManager,
@Value("${form-flow.inputs: 'formflow.library.inputs.'}") String inputConfigPath) {
this.validator = validator;
this.actionManager = actionManager;
this.inputConfigPath = inputConfigPath;
ValidationService.inputConfigPath = inputConfigPath;
}

/**
Expand Down Expand Up @@ -158,4 +162,30 @@ private Map<String, List<String>> performFieldLevelValidation(String flowName, F

return validationMessages;
}

public static Map<String, Map<String, Boolean>> getRequiredInputs(String flowName) {
if (requiredInputs.isEmpty()) {
Class<?> flowClass;

try {
flowClass = Class.forName(inputConfigPath + StringUtils.capitalize(flowName));
} catch (ReflectiveOperationException e) {
log.error("Error while trying to get the inputs class for flow {}. Make sure the inputs file for your application uses the same name as it's flow.", flowName, e);
throw new RuntimeException(e);
}

Field[] declaredFields = flowClass.getDeclaredFields();
for (Field field : declaredFields) {
if (Arrays.stream(field.getAnnotations())
.anyMatch(annotation -> requiredAnnotationsList.contains(annotation.annotationType().getName()))) {
if (requiredInputs.containsKey(flowName)) {
requiredInputs.get(flowName).put(field.getName(), true);
} else {
requiredInputs.put(flowName, new HashMap<>(Map.of(field.getName(), true)));
}
}
}
}
return requiredInputs;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -741,3 +741,12 @@ body,
display: inline-flex;
align-items: center;
}

.required-input {
color: #a72f10;
font-weight: normal;
}

.form-group .text--help+.text--error {
margin-top: -2.5rem;
}
1 change: 1 addition & 0 deletions src/main/resources/messages-form-flow.properties
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ general.files.file-added.one=1 file added
general.files.file-added.other=files added
general.files.uploaded-documents=Uploaded documents
general.locked-submission=You've already submitted this application. To protect your data, we can't let you go back at this time.
general.required-field=(required)
#
address-validation.check-your-address=Check your address
address-validation.header.make-sure-your-address-is-correct=Make sure your address is correct
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/messages-form-flow_es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ general.files.add-your-files=Agregue sus archivos
general.files.file-added.one=1 archivo agregado
general.files.file-added.other=archivos agregados
general.files.uploaded-documents=Documentos subidos
general.required-field=(requerido)
#
address-validation.check-your-address=Verifique su dirección
address-validation.header.make-sure-your-address-is-correct=Asegúrese de que su dirección sea correcta
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/messages-form-flow_vi.properties
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ general.files.file-added.one=\u0110\u00E3 th\u00EAm 1 t\u1EC7p
general.files.file-added.other=\u0111\u00E3 th\u00EAm t\u1EADp tin
general.files.uploaded-documents=T\u00E0i li\u1EC7u \u0111\u00E3 t\u1EA3i l\u00EAn
general.locked-submission=Qu\u00FD v\u1ECB \u0111\u00E3 g\u1EEDi \u0111\u01A1n \u0111\u0103ng k\u00FD n\u00E0y. \u0110\u1EC3 b\u1EA3o v\u1EC7 d\u1EEF li\u1EC7u, hi\u1EC7n qu\u00FD v\u1ECB kh\u00F4ng \u0111\u01B0\u1EE3c ph\u00E9p quay l\u1EA1i.
general.required-field=(b\u1EAFt bu\u1ED9c)
#
address-validation.check-your-address=Ki\u1EC3m tra \u0111\u1ECBa ch\u1EC9 c\u1EE7a qu\u00FD v\u1ECB
address-validation.header.make-sure-your-address-is-correct=H\u00E3y ch\u1EAFc ch\u1EAFn \u0111\u1ECBa ch\u1EC9 \u0111\u00E3 \u0111\u01B0\u1EE3c nh\u1EADp \u0111\u00FAng
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<header
th:fragment="cardHeaderForSingleInputScreen"
th:with="
hasSubtext=${!#strings.isEmpty(subtext)},
requiredInputsForFlow=${requiredInputs.get(flow)},
isRequiredInput=${(requiredInputsForFlow != null && requiredInputsForFlow.getOrDefault(inputName, false)) || (required != null && required)}"
th:assert="${!#strings.isEmpty(header)}"
class="form-card__header">
<h1 id="header" class="h2" >
<span th:text="${header + ' '}"></span><span th:if="${isRequiredInput}" class="required-input" th:text="#{general.required-field}"></span>
</h1>
<p id="header-help-message"
th:if="${hasSubtext}"
th:utext="${subtext}"></p>
</header>
3 changes: 3 additions & 0 deletions src/main/resources/templates/fragments/inputs/checkbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
th:with="
hasHelpText=${!#strings.isEmpty(checkboxHelpText)},
hasIcon=${!#strings.isEmpty(checkboxIcon)},
requiredInputsForFlow=${requiredInputs.get(flow)},
isRequiredInput=${(requiredInputsForFlow != null && requiredInputsForFlow.getOrDefault(inputName, false)) || (required != null && required)},
name=${inputName} + '[]',
hasError=${
errorMessages != null &&
Expand Down Expand Up @@ -31,6 +33,7 @@
</div>
<div>
<span th:text="${label}"></span>
<span th:if="${isRequiredInput}" class="required-input" th:text="#{general.required-field}"></span>
<p th:if="${hasHelpText}"
th:id="${name} + '-' + ${value} + '-help-text'"
th:text="${checkboxHelpText}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
hasHelpText=${!#strings.isEmpty(fieldsetHelpText)},
hasLabel=${!#strings.isEmpty(label)},
hasAriaLabel=${!#strings.isEmpty(ariaLabel)},
requiredInputsForFlow=${requiredInputs.get(flow)},
isRequiredInput=${(requiredInputsForFlow != null && requiredInputsForFlow.getOrDefault(inputName, false)) || (required != null && required)},
hasError=${
errorMessages != null &&
errorMessages.get(inputName) != null &&
Expand All @@ -20,7 +22,8 @@
th:if="${hasLabel}"
th:id="${inputName + '-legend'}"
th:inline="text">
[[${label}]]
<span th:text="${label}"></span>
<span th:if="${isRequiredInput}" class="required-input" th:text="#{general.required-field}"></span>
<p class="text--help spacing-below-0 text--normal"
th:if="${hasHelpText}"
th:id="${inputName + '-help-text'}"
Expand Down

0 comments on commit 0d4b4ac

Please sign in to comment.