Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically indicate required input fields #502

Merged
merged 41 commits into from May 9, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
cc095d4
Automatically indicate required text fields
spokenbird Feb 2, 2024
56392be
Adds error handling changes for the date input
spokenbird Feb 3, 2024
d5140ac
Update money input to show required attribute
spokenbird Feb 6, 2024
7759557
Update number, phone and ssn inputs to show required attribute
spokenbird Feb 6, 2024
7f14ae9
Update select input to show required attribute
spokenbird Feb 13, 2024
be34480
Update radio input to show required attribute in fieldset
spokenbird Feb 13, 2024
086265f
Only render error element if errors are present and add appropriate m…
spokenbird Feb 14, 2024
1662c15
Indidcate required attribute on fieldset not individual input
spokenbird Feb 14, 2024
61987fc
Add tests
spokenbird Feb 14, 2024
31d7717
Do not show required asterisk if single input screen
spokenbird Feb 14, 2024
d07c290
Fix broken check on date errors in test
spokenbird Feb 14, 2024
ccdcd2e
Initialize required inputs list once in ValidationService
spokenbird Feb 14, 2024
a812a87
Use map for instant lookup of required inputs
spokenbird Feb 14, 2024
93e301c
Add required input check for screenWithOneInput
spokenbird Feb 15, 2024
775133f
Add documentation for screen with one input
spokenbird Feb 15, 2024
7c6e739
Fix path
sree-cfa Feb 22, 2024
db296c3
Update required fields to use message properties and (required) language
spokenbird May 2, 2024
2d5eb2c
Add optional required field to all input types
spokenbird May 2, 2024
815b6d3
Add required indication to screen with one input pattern
spokenbird May 3, 2024
4c2f714
Update README
spokenbird May 3, 2024
10e397a
Fix failing tests
spokenbird May 3, 2024
f9c3b4e
Fix failing tests
spokenbird May 6, 2024
1104f60
Fix failing tests and remove aria-required attribute
spokenbird May 6, 2024
82d14ad
Hopefully fix tests failing in CI
spokenbird May 6, 2024
c3f8800
Hopefully fix tests failing in CI Part 2
spokenbird May 6, 2024
e6ba57e
Hopefully fix tests failing in CI Part 3
spokenbird May 6, 2024
a78249f
Hopefully fix tests failing in CI Part 4
spokenbird May 6, 2024
872648b
Hopefully fix tests failing in CI Part 5
spokenbird May 6, 2024
788d454
Hopefully fix tests failing in CI Part 6
spokenbird May 6, 2024
c627343
Convert failing test to use Selenium instead
spokenbird May 7, 2024
467b397
Fix screenWithOneInput.html
spokenbird May 7, 2024
04a22d6
PR Review changes
spokenbird May 7, 2024
8076382
Await client to see if it fixes CI
spokenbird May 7, 2024
cf17a28
Try a different CI Fix
spokenbird May 7, 2024
be1ed9e
Try yet anothing thing
spokenbird May 7, 2024
c9373f4
Try specifying test profile
spokenbird May 7, 2024
8ffc1fe
Try specifying test profile 2
spokenbird May 7, 2024
06d6626
Dont test -- will do in starter-app instead
spokenbird May 7, 2024
4682c5a
Account for multiple flows when generating required inputs map
spokenbird May 7, 2024
b5805b8
Fix null issue
spokenbird May 7, 2024
1146b5a
Changes from PR review
spokenbird May 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
109 changes: 108 additions & 1 deletion README.md
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.
Specific scenarios where this might be the case include:
- A field is validated through an action in the flows-config file and that validation makes it required
- A field uses cross validtation int he flows-config file and that validation makes it required

bseeger marked this conversation as resolved.
Show resolved Hide resolved
In these cases, 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 library will treat the field as required and append the
red `(required)`.

spokenbird marked this conversation as resolved.
Show resolved Hide resolved
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.

bseeger marked this conversation as resolved.
Show resolved Hide resolved
#### 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 in the
which will appear below `more content` above. This name is used to check for required field annotations
spokenbird marked this conversation as resolved.
Show resolved Hide resolved
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,40 @@ 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, but for this screen pattern
the label is the header of the page. To handle this, we have a special fragment called `screenWithOneInput`.
spokenbird marked this conversation as resolved.
Show resolved Hide resolved
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
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
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
32 changes: 29 additions & 3 deletions src/main/java/formflow/library/ValidationService.java
Expand Up @@ -9,13 +9,15 @@
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;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.units.qual.A;
bseeger marked this conversation as resolved.
Show resolved Hide resolved
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.apache.commons.lang3.StringUtils;
Expand All @@ -38,8 +40,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, 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 +61,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 +163,25 @@ private Map<String, List<String>> performFieldLevelValidation(String flowName, F

return validationMessages;
}

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

try {
flowClass = Class.forName(inputConfigPath + StringUtils.capitalize(flowName));
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
bseeger marked this conversation as resolved.
Show resolved Hide resolved

Field[] declaredFields = flowClass.getDeclaredFields();
for (Field field : declaredFields) {
if (Arrays.stream(field.getAnnotations())
.anyMatch(annotation -> requiredAnnotationsList.contains(annotation.annotationType().getName()))) {
requiredInputs.put(field.getName(), true);
bseeger marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
return requiredInputs;
}
}
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
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
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
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
@@ -0,0 +1,14 @@
<header
th:fragment="cardHeaderForSingleInputScreen"
th:with="
hasSubtext=${!#strings.isEmpty(subtext)},
isRequiredInput=${requiredInputs.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>
2 changes: 2 additions & 0 deletions src/main/resources/templates/fragments/inputs/checkbox.html
Expand Up @@ -3,6 +3,7 @@
th:with="
hasHelpText=${!#strings.isEmpty(checkboxHelpText)},
hasIcon=${!#strings.isEmpty(checkboxIcon)},
isRequiredInput=${requiredInputs.getOrDefault(inputName, false) || required != null && required},
name=${inputName} + '[]',
hasError=${
errorMessages != null &&
Expand Down Expand Up @@ -31,6 +32,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
Expand Up @@ -4,6 +4,7 @@
hasHelpText=${!#strings.isEmpty(fieldsetHelpText)},
hasLabel=${!#strings.isEmpty(label)},
hasAriaLabel=${!#strings.isEmpty(ariaLabel)},
isRequiredInput=${requiredInputs.getOrDefault(inputName, false) || required != null && required},
hasError=${
errorMessages != null &&
errorMessages.get(inputName) != null &&
Expand All @@ -20,7 +21,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