diff --git a/README.md b/README.md index eb7a31865..037e75ba7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Table of Contents * [Actions](#actions) * [Defining Inputs](#defining-inputs) * [Inputs Class](#inputs-class) + * [Dynamic Input Fields](#dynamic-input-fields) * [Custom Annotations](#custom-annotations) * [Input Data JSON Structure](#input-data-json-structure) * [General Information](#general-information) @@ -231,7 +232,8 @@ with `subflow: subflowName` in the `flows-config.yaml`. #### Review Screen -This screen summarizes all the completed iterations of a subflow and is shown after each iteration is completed. +This screen summarizes all the completed iterations of a subflow and is shown after each iteration +is completed. It lists the data from each iteration and provides options to edit or delete them individually. This screen does not need to be denoted with `subflow: subflowName` in the `flows-config.yaml`. It @@ -248,9 +250,13 @@ This page is not technically part of the subflow and as such, does not need to b with `subflow: subflowName` in the `flows-config.yaml`. ### Subflows Data -Subflow information will be saved in your applications database within the larger JSON `inputData` as an array of subflow iterations + +Subflow information will be saved in your applications database within the larger JSON `inputData` +as an array of subflow iterations like this example of a household subflow with two iterations: + ```JSON +{ "household": [ { "uuid": "e2c18dfe-98e9-430f-9a4f-3511966d3128", @@ -268,16 +274,24 @@ like this example of a household subflow with two iterations: "householdMemberRelationship": "Child", "householdMemberRecentlyMovedToUS": "No" } - ], + ] +} ``` -Note that all information submitted for a single loop (iteration) through your subflow will show up within a single array index. -The index includes a `uuid` field, which is a unique identifier for that iteration within the subflow. + +Note that all information submitted for a single loop (iteration) through your subflow will show up +within a single array index. +The index includes a `uuid` field, which is a unique identifier for that iteration within the +subflow. #### Completed iterations + The `iterationIsComplete` field will indicate if an iteration was completed, meaning the person -filling out the subflow made it all the way through all screens within the subflow and clicked submit(POST)/continue(GET) on the -final screen of the iteration (heading to the review page). If that person backs out of the subflow before completing it, then `iterationIsComplete` will remain false. -Incomplete iterations will not be included in the generated PDF of the submission, but are still accessible in the +filling out the subflow made it all the way through all screens within the subflow and clicked +submit(POST)/continue(GET) on the +final screen of the iteration (heading to the review page). If that person backs out of the subflow +before completing it, then `iterationIsComplete` will remain false. +Incomplete iterations will not be included in the generated PDF of the submission, but are still +accessible in the database for error resolution and debugging. ## Submission Object @@ -487,25 +501,105 @@ class ApplicationInformation extends FlowInputs { } ``` +### Dynamic Input Fields + +A field is dynamic if it is unknown exactly how many of them will be form submitted in a page. + +For example, if a user uploads a number of files on one screen, and you need to attach data to +each file on another screen, the exact number of files is unknown to the template generating +the follow-up page. These input fields will need to be dynamic fields. + +The way the Form Flow library accommodates this scenario is by having an annotation, `@DynamicField` +to mark this field as dynamic. + +To create a dynamic field you need to do two things: + +1. create the field in the inputs file and apply the `@DynamicField` annotation to it, as well as + any other annotations that apply to the field. +2. create the input fields in the thymeleaf template and make sure they are named appropriately, as + described below. + +Here is an example of how to set up a dynamic field. + +In this example, the `docTypeLabel` is a dynamic field. On page a user has uploaded any number of +document files. On the next page the user needs to pick a document type for each file to indicate +what type of document it is (license, birth certificate, etc.). + +```java +class ApplicationInformation extends FlowInputs { + + MultipartFile documents; + + @NotBlank + @DynamicField + String docTypeLabel; + + @NotBlank(message = "{personal-info.provide-first-name}") + String someOtherField; +} +``` + +The `@DynamicField` annotation tells the library that this field will +have multiple field submissions in one form. `docTypeLabel` ends up being considered as a prefix +for the field name, and the system will expect multiple fields with the `docTypeLabel` as the +beginning part of their names. + +To submit a dynamic field, the template must do the following in naming the input fields: +(for reference, this template code might be in a foreach where the `fileId` changes on each loop, +resulting in multiple input fields being rendered with slightly different names.) + +```html + + +``` + +The resulting form submission to the library would look like this `HashMap` + +```text + Map formSubmissionData = + + "someOtherField"->"Test Field Value", + "docTypeLabel_wildcard_123-4"->"License", + "docTypeLabel_wildcard_123-0"->"Birth Certificate", + "docTypeLabel_wildcard_123-2"->"Divorce Decree", + "docTypeLabel_wildcard_123-1"->"Pay Stub", + "docTypeLabel_wildcard_123-3"->"Other" +``` + +When the library does validation on this set of fields, it will look for the `DYNAMIC_FIELD_MARKER` +in a field's name. If it exists, then the logic will grab the prefix (`docTypeLabel`) and use the +annotations applied to that field on all the fields submitted with the proper prefix. + +The suffix value (`123-4`, `123-0`, etc.) must be unique. It has no semantic meaning to the library, +but the application should use it to help align data, either via an action or via post-processing. +In the example case, it would be the `fileId` which would help correlate the doc type to the +document stored in the `user_files` table. + +Note that if a field happens to have the `DYNAMIC_FIELD_MARKER` (`_wildcard_`) in its name, but +the field doesn't have the `@DynamicField` annotation, the system will not treat that field as a +dynamic field and will throw an exception. + ### Validating Inputs -Validations for inputs use the Java Bean Validation, more specifically, Hibernate -validations. For a list of validation decorators, -see [Hibernate's documentation.](https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#section-builtin-constraints) +Validations for inputs use the Java Bean Validation,more specifically,Hibernate +validations.For a list of validation decorators, +see[Hibernate's documentation.](https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single/#section-builtin-constraints) -Note that our implementation does not make a field required, unless `@NotEmpty`, `@NotBlank`, or -`@NotNull` is used. If a validation annotation such as `@Email` is used, it will not -actually validate the annotated input unless a user actually enters a value for that input. If you -use `@Email` and `@NotBlank` together, that causes both validations to run even if the user did not -enter a value, validating both that they need to enter a value due to `@NotBlank` and because the +Note that our implementation does not make a field required,unless `@NotEmpty`, `@NotBlank`,or +`@NotNull` is used.If a validation annotation such as `@Email` is used,it will not +actually validate the annotated input unless a user actually enters a value for that input.If you +use `@Email` and `@NotBlank` together,that causes both validations to run even if the user did not +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`. ### Custom Annotations #### Marker Annotations -Marker annotations are used to mark a field for certain functionality. These annotations may or may -not have any validation associated with them; they may simply mark the field for some usage. +Marker annotations are used to mark a field for certain functionality.These annotations may or may +not have any validation associated with them;they may simply mark the field for some usage. ##### @Encrypted @@ -1315,7 +1409,7 @@ once the upload is complete it will become a delete link. If the file has an extension of .jpg, .png, .bmp or .gif, DropZone will create a thumbnail for that image and display it on the screen. -For files with extensions other than the ones listed (like tif files and various document formats), +For files with extensions other than the ones listed (like .tif files and various document formats), we will display a default image for those thumbnails. We do not store the thumbnails on the server side at all. @@ -1327,24 +1421,55 @@ The resulting file information will be stored in the database in two places: 1. the `submissions` table 2. the `user_files` table -The saved JSON is store in the `submissions` table with the field name being the key. +The `submissions` table is where the file is associated with a submission as part of the +JSON data. It is stored in the `input_data` JSON and the key name is the input field name that the +Dropzone instance was associated with. Example JSON: ```JSON { /* other content */ - "license_upload": "[34,47]" + "uploadDocuments": "[\"36ecebb5-c74b-430c-abd7-86b9c211aa71\",\"d2fb9fb6-510a-4cf6-bf2f-04e81daf6a13\",\"c578bf3b-af04-4c8e-9dc0-3afa6946ceb6\"]" /* other content */ } ``` -This indicates that there were two files uploaded via the `license_upload` widget. Their file ids -are `34` and `47`, and can be looked up in the `user_files` table (detailed below) using those ids. +This indicates that there were three files uploaded via the `uploadDocuments` widget. Their file ids +are `36ecebb5-c74b-430c-abd7-86b9c211aa71`, `d2fb9fb6-510a-4cf6-bf2f-04e81daf6a13`, +and `c578bf3b-af04-4c8e-9dc0-3afa6946ceb6`. These files can be looked up in the `user_files` table +using those ids. -The `user_files` table holds information about a submission's uploaded user files, which includes: -`file_id`, the corresponding submission's `submission_id`, a `created_at` time, the `original_name` -of the file, and the S3 `repository_path`. +The `user_files` table holds information about files a user uploaded as part of their submission. +The table includes: + +* `file_id` - the ID of the file (UUID) +* `filesize` - size of file, in bytes +* `submission_id` - ID of the submission the file relates to +* `created_at` - time file was uploaded +* `original_name` - original name of the file at the time of upload +* `doc_type_label` - the document type label for the file +* `repository_path` - the location of the file in cloud storage +* `virus_scanned` - boolean field indicating if the file was scanned for viruses. `false` means the + file was not scanned when it was uploaded. `true` means it was scanned and free of viruses. + +#### Document Use Type + +The `user_files` table has a column which leaves space for an application to store a document type +label. The Form Flow library doesn't use this field, but provides it so applications have a place +to store this information. + +The `doc_type_label` column will be set to `NULL`, by default, when a new record is added without +the field being set. To override this default add the following field to an application's +`application.yaml` file, like so: + +```yaml +form-flow: + uploads: + default-doc-type-label: "NotSet" +``` + +Now when a new record is added without a value for `doc_type_label` it will now be set to `NotSet`. ### Deleting Uploaded Files @@ -1381,7 +1506,8 @@ There is a field `virus_scanned` in the `user_files` table with `Boolean` as its * `true` if the file was scanned by the service and did not have a virus. * `false` if not scanned, either because the service is down or disabled. -> ⚠️ If a file has a virus, it is rejected and not saved in our systems. +> ⚠️ If virus scanning is enabled and a virus is detected in a file, it is rejected and not saved in +> our systems. ## Document Download @@ -1391,8 +1517,7 @@ Form flow library allows users to either: 2. Download a zipped archive with all the files associated with a submission. In order to download from these endpoints, the HTTP session must have an attribute of "id" that -matches -the submission's UUID. +matches the submission's UUID. ### Downloading individual files @@ -1406,9 +1531,8 @@ the `user_files` table in order for the file to be retrieved. In order to download all the files associated with a submission a user needs to use the download-all endpoint: `/file-download/{submissionId}`. The download-all endpoint requires that the `id` attribute for the HTTP session matches the `submissionId` for the group of files in the submission -that -you would like to download. When you use the download-all endpoint, every file in the `user_files` -table associated with a submission are zipped together and downloaded. +that you would like to download. When you use the download-all endpoint, every file in +the `user_files` table associated with a submission are zipped together and downloaded. ## Address Validation @@ -1769,7 +1893,8 @@ These preparers are: - `SubflowFieldPreparer` - Handles the mapping of subflow fields from your application's subflows and their inputs to the correct fields in your PDF template file. - - **Note that subflow iterations which have not been marked with `iterationIsComplete: true` will not be mapped to the generated PDF** + - **Note that subflow iterations which have not been marked with `iterationIsComplete: true` + will not be mapped to the generated PDF** All of these preparers will run by default against the `inputFields` you have indicated in your `pdf-map.yaml` file. Should you want to customize or inject fields you can do so @@ -2338,10 +2463,12 @@ that this page is an entry point into a flow. ### Disabled Flow Interceptor The `DisabledFlowInterceptor` is an interceptor that prevents users from accessing a flow that has -been disabled through configuration. This interceptor will redirect users to a configured static screen -when they attempt to access a screen within a disabled flow. +been disabled through configuration. This interceptor will redirect users to a configured static +screen +when they attempt to access a screen within a disabled flow. -For more information on disabling a flow, please see the [Disabling a Flow](#disabling-a-flow) section. +For more information on disabling a flow, please see the [Disabling a Flow](#disabling-a-flow) +section. # How to use @@ -2401,14 +2528,17 @@ The form flow library provides a configuration mechanism to disable a flow. This from being able to access a given flow, and will redirect the user to a configured screen if they attempt to reach a page within a disabled flow. -Here are two examples of disabled flows, the first goes to the home page and the second goes to a +Here are two examples of disabled flows, the first goes to the home page and the second goes to a unique static page. If no `staticRedirectScreen` is configured the library will default to the [disabled feature](https://github.com/codeforamerica/form-flow/tree/main/src/main/resources/templates/disabledFeature.html) -template. This template provides a basic message stating the feature being accessed is currently unavailable -and to try again later if the user believes they have reached this page by mistake. - -When disabling a flow, we recommend creating your own page with proper messaging about the disabled flow -to present to clients when they attempt to access the disabled flow. The configured `staticRedirectScreen` +template. This template provides a basic message stating the feature being accessed is currently +unavailable +and to try again later if the user believes they have reached this page by mistake. + +When disabling a flow, we recommend creating your own page with proper messaging about the disabled +flow +to present to clients when they attempt to access the disabled flow. The +configured `staticRedirectScreen` will be used when provided and the default disabled feature template is only meant as a fallback. ```yaml @@ -2610,8 +2740,9 @@ any one flow, the `Submission` for each flow will be stored in the database. point to go back to the other flow. Otherwise, the flows may not get completed properly.** -The form flow library provides a configuration mechanism to disable a flow. This is useful if you are -still developing a flow, or if a flow has reached its end of life. For more information on +The form flow library provides a configuration mechanism to disable a flow. This is useful if you +are +still developing a flow, or if a flow has reached its end of life. For more information on disabling flows see: [Disabling a Flow](#disabling-a-flow) ### Screens diff --git a/src/main/java/formflow/library/FileController.java b/src/main/java/formflow/library/FileController.java index 07ba12b8e..859c81fcd 100644 --- a/src/main/java/formflow/library/FileController.java +++ b/src/main/java/formflow/library/FileController.java @@ -60,6 +60,12 @@ public class FileController extends FormFlowController { private final String SESSION_USERFILES_KEY = "userFiles"; private final Integer maxFiles; + @Value("${form-flow.uploads.default-doc-type-label:#{null}}") + private String defaultDocType; + + @Value("${form-flow.uploads.virus-scanning.enabled:false}") + private boolean isVirusScanningEnabled; + private final ObjectMapper objectMapper = new ObjectMapper(); public FileController( @@ -123,19 +129,22 @@ public ResponseEntity upload( return new ResponseEntity<>(message, HttpStatus.UNSUPPORTED_MEDIA_TYPE); } - boolean wasScannedForVirus = true; - try { - if (fileVirusScanner.virusDetected(file)) { - String message = messageSource.getMessage("upload-documents.error-virus-found", null, locale); - return new ResponseEntity<>(message, HttpStatus.UNPROCESSABLE_ENTITY); - } - } catch (WebClientResponseException | TimeoutException e) { - if (blockIfClammitUnreachable) { - log.error("The virus scan service could not be reached. Blocking upload."); - String message = messageSource.getMessage("upload-documents.error-virus-scanner-unavailable", null, locale); - return new ResponseEntity<>(message, HttpStatus.SERVICE_UNAVAILABLE); + boolean wasScannedForVirus = false; + if (isVirusScanningEnabled) { + try { + if (fileVirusScanner.virusDetected(file)) { + String message = messageSource.getMessage("upload-documents.error-virus-found", null, locale); + return new ResponseEntity<>(message, HttpStatus.UNPROCESSABLE_ENTITY); + } + wasScannedForVirus = true; + } catch (WebClientResponseException | TimeoutException e) { + wasScannedForVirus = false; + if (blockIfClammitUnreachable) { + log.error("The virus scan service could not be reached. Blocking upload."); + String message = messageSource.getMessage("upload-documents.error-virus-scanner-unavailable", null, locale); + return new ResponseEntity<>(message, HttpStatus.SERVICE_UNAVAILABLE); + } } - wasScannedForVirus = false; } if (fileValidationService.isTooLarge(file)) { @@ -174,6 +183,7 @@ public ResponseEntity upload( .filesize((float) file.getSize()) .mimeType(file.getContentType()) .virusScanned(wasScannedForVirus) + .docTypeLabel(defaultDocType) .build(); uploadedFile = userFileRepositoryService.save(uploadedFile); diff --git a/src/main/java/formflow/library/ScreenController.java b/src/main/java/formflow/library/ScreenController.java index eaf93b2a8..374bc7bf0 100644 --- a/src/main/java/formflow/library/ScreenController.java +++ b/src/main/java/formflow/library/ScreenController.java @@ -14,7 +14,6 @@ import formflow.library.data.SubmissionRepositoryService; import formflow.library.data.UserFileRepositoryService; import formflow.library.file.FileValidationService; -import formflow.library.inputs.UnvalidatedField; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import java.io.IOException; @@ -43,6 +42,8 @@ import org.springframework.web.servlet.ModelAndView; import org.springframework.web.servlet.view.RedirectView; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS; + /** * A controller to render any screen in flows, including subflows. */ @@ -686,7 +687,7 @@ private void handleAddressValidation(Submission submission, FormSubmission formS formSubmission.setValidatedAddress(validatedAddresses); // clear lingering address(es) from the submission stored in the database. formSubmission.getAddressValidationFields().forEach(item -> { - String inputName = item.replace(UnvalidatedField.VALIDATE_ADDRESS, ""); + String inputName = item.replace(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS, ""); submission.clearAddressFields(inputName); }); } diff --git a/src/main/java/formflow/library/ValidationService.java b/src/main/java/formflow/library/ValidationService.java index 8b0cfb99c..1fbdad732 100644 --- a/src/main/java/formflow/library/ValidationService.java +++ b/src/main/java/formflow/library/ValidationService.java @@ -8,6 +8,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -17,7 +18,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; +import org.apache.commons.lang3.StringUtils; + +import static formflow.library.inputs.FieldNameMarkers.DYNAMIC_FIELD_MARKER; /** * A service that validates flow inputs based on input definition. @@ -45,8 +48,8 @@ public class ValidationService { /** * Autoconfigured constructor. * - * @param validator Validator from Jakarta package. - * @param actionManager the ActionManager that manages the logic to be run at specific points + * @param validator Validator from Jakarta package. + * @param actionManager the ActionManager that manages the logic to be run at specific points * @param inputConfigPath the package path where inputs classes are located */ public ValidationService(Validator validator, ActionManager actionManager, @@ -95,6 +98,7 @@ private Map> performFieldLevelValidation(String flowName, F } formSubmission.getFormData().forEach((key, value) -> { + boolean dynamicField = false; var messages = new ArrayList(); List annotationNames = null; @@ -102,11 +106,40 @@ private Map> performFieldLevelValidation(String flowName, F key = key.replace("[]", ""); } + String originalKey = key; + + if (key.contains(DYNAMIC_FIELD_MARKER)) { + dynamicField = true; + key = StringUtils.substringBefore(key, DYNAMIC_FIELD_MARKER); + } + try { annotationNames = Arrays.stream(flowClass.getDeclaredField(key).getDeclaredAnnotations()) .map(annotation -> annotation.annotationType().getName()).toList(); } catch (NoSuchFieldException e) { - throw new RuntimeException(e); + if (dynamicField) { + throw new RuntimeException( + String.format( + "Input field '%s' has dynamic field marker '%s' in its name, but we are unable to " + + "find the field in the input file. Is it a dynamic field?", + originalKey, DYNAMIC_FIELD_MARKER + ) + ); + } else { + throw new RuntimeException(e); + } + } + + // if it's acting like a dynamic field, then ensure that it is marked as one + if (dynamicField) { + if (!annotationNames.contains("formflow.library.data.annotations.DynamicField")) { + throw new RuntimeException( + String.format( + "Field name '%s' (field: '%s') acts like it's a dynamic field, but the field does not contain the @DynamicField annotation", + key, originalKey + ) + ); + } } if (Collections.disjoint(annotationNames, requiredAnnotationsList) && value.equals("")) { @@ -118,7 +151,8 @@ private Map> performFieldLevelValidation(String flowName, F .forEach(violation -> messages.add(violation.getMessage())); if (!messages.isEmpty()) { - validationMessages.put(key, messages); + // uses original key to accommodate dynamic input names + validationMessages.put(originalKey, messages); } }); diff --git a/src/main/java/formflow/library/address_validation/ValidationRequestFactory.java b/src/main/java/formflow/library/address_validation/ValidationRequestFactory.java index 41512a6ca..4ba8d377b 100644 --- a/src/main/java/formflow/library/address_validation/ValidationRequestFactory.java +++ b/src/main/java/formflow/library/address_validation/ValidationRequestFactory.java @@ -5,12 +5,13 @@ import com.smartystreets.api.us_street.Batch; import com.smartystreets.api.us_street.Lookup; import formflow.library.data.FormSubmission; -import formflow.library.inputs.UnvalidatedField; import formflow.library.inputs.AddressParts; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS; + @Slf4j @Component public class ValidationRequestFactory { @@ -21,8 +22,11 @@ public ValidationRequestFactory() { public Batch create(FormSubmission formSubmission) { Batch smartyBatch = new Batch(); List addressInputNames = formSubmission.getFormData().keySet().stream() - .filter(key -> key.startsWith(UnvalidatedField.VALIDATE_ADDRESS) && formSubmission.getFormData().get(key).equals("true")) - .map(key -> key.substring(UnvalidatedField.VALIDATE_ADDRESS.length())).toList(); + .filter(key -> + key.startsWith(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS.toString()) && + formSubmission.getFormData().get(key).equals("true") + ) + .map(key -> key.substring(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS.toString().length())).toList(); addressInputNames.forEach(inputName -> { Lookup lookup = new Lookup(); diff --git a/src/main/java/formflow/library/data/FormSubmission.java b/src/main/java/formflow/library/data/FormSubmission.java index c0c31ae5e..4355fba66 100644 --- a/src/main/java/formflow/library/data/FormSubmission.java +++ b/src/main/java/formflow/library/data/FormSubmission.java @@ -1,7 +1,6 @@ package formflow.library.data; import formflow.library.address_validation.ValidatedAddress; -import formflow.library.inputs.UnvalidatedField; import formflow.library.inputs.AddressParts; import java.util.ArrayList; import java.util.List; @@ -11,11 +10,18 @@ import lombok.Data; import org.springframework.util.MultiValueMap; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_CSRF; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATED; + @Data public class FormSubmission { public Map formData; - private List unvalidatedFields = List.of(UnvalidatedField.CSRF, UnvalidatedField.VALIDATE_ADDRESS); + private List unvalidatedFields = List.of( + UNVALIDATED_FIELD_MARKER_CSRF, + UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + ); public FormSubmission(MultiValueMap formData) { this.formData = removeEmptyValuesAndFlatten(formData); @@ -54,7 +60,7 @@ public Map getValidatableFields() { */ public List getAddressValidationFields() { return formData.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(UnvalidatedField.VALIDATE_ADDRESS)) + .filter(entry -> entry.getKey().startsWith(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS)) .filter(entry -> entry.getValue().toString().equalsIgnoreCase("true")) .map(Entry::getKey).toList(); } @@ -62,11 +68,11 @@ public List getAddressValidationFields() { public void setValidatedAddress(Map validatedAddresses) { validatedAddresses.forEach((key, value) -> { if (value != null) { - formData.put(key + AddressParts.STREET_ADDRESS_1 + UnvalidatedField.VALIDATED, value.getStreetAddress()); - formData.put(key + AddressParts.STREET_ADDRESS_2 + UnvalidatedField.VALIDATED, value.getApartmentNumber()); - formData.put(key + AddressParts.CITY + UnvalidatedField.VALIDATED, value.getCity()); - formData.put(key + AddressParts.STATE + UnvalidatedField.VALIDATED, value.getState()); - formData.put(key + AddressParts.ZIPCODE + UnvalidatedField.VALIDATED, value.getZipCode()); + formData.put(key + AddressParts.STREET_ADDRESS_1 + UNVALIDATED_FIELD_MARKER_VALIDATED, value.getStreetAddress()); + formData.put(key + AddressParts.STREET_ADDRESS_2 + UNVALIDATED_FIELD_MARKER_VALIDATED, value.getApartmentNumber()); + formData.put(key + AddressParts.CITY + UNVALIDATED_FIELD_MARKER_VALIDATED, value.getCity()); + formData.put(key + AddressParts.STATE + UNVALIDATED_FIELD_MARKER_VALIDATED, value.getState()); + formData.put(key + AddressParts.ZIPCODE + UNVALIDATED_FIELD_MARKER_VALIDATED, value.getZipCode()); } }); } diff --git a/src/main/java/formflow/library/data/Submission.java b/src/main/java/formflow/library/data/Submission.java index c7305f043..5b612ad0a 100644 --- a/src/main/java/formflow/library/data/Submission.java +++ b/src/main/java/formflow/library/data/Submission.java @@ -1,9 +1,9 @@ package formflow.library.data; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATED; import static jakarta.persistence.TemporalType.TIMESTAMP; import formflow.library.inputs.AddressParts; -import formflow.library.inputs.UnvalidatedField; import io.hypersistence.utils.hibernate.type.json.JsonType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -203,11 +203,11 @@ public Boolean getIterationIsCompleteStatus(String subflow, String iterationUuid */ public void clearAddressFields(String inputName) { // we want to clear out - inputData.remove(inputName + AddressParts.STREET_ADDRESS_1 + UnvalidatedField.VALIDATED); - inputData.remove(inputName + AddressParts.STREET_ADDRESS_2 + UnvalidatedField.VALIDATED); - inputData.remove(inputName + AddressParts.CITY + UnvalidatedField.VALIDATED); - inputData.remove(inputName + AddressParts.STATE + UnvalidatedField.VALIDATED); - inputData.remove(inputName + AddressParts.ZIPCODE + UnvalidatedField.VALIDATED); + inputData.remove(inputName + AddressParts.STREET_ADDRESS_1 + UNVALIDATED_FIELD_MARKER_VALIDATED); + inputData.remove(inputName + AddressParts.STREET_ADDRESS_2 + UNVALIDATED_FIELD_MARKER_VALIDATED); + inputData.remove(inputName + AddressParts.CITY + UNVALIDATED_FIELD_MARKER_VALIDATED); + inputData.remove(inputName + AddressParts.STATE + UNVALIDATED_FIELD_MARKER_VALIDATED); + inputData.remove(inputName + AddressParts.ZIPCODE + UNVALIDATED_FIELD_MARKER_VALIDATED); } /** diff --git a/src/main/java/formflow/library/data/UserFile.java b/src/main/java/formflow/library/data/UserFile.java index 3a67d9ede..1b08a2fc3 100644 --- a/src/main/java/formflow/library/data/UserFile.java +++ b/src/main/java/formflow/library/data/UserFile.java @@ -41,8 +41,6 @@ public class UserFile { @Id @GeneratedValue - //@Type(type = "org.hibernate.type.UUIDCharType") - //@Type(type = "pg-uuid") private UUID fileId; @ManyToOne @@ -65,10 +63,13 @@ public class UserFile { @Column private Float filesize; - + @Column(name = "virus_scanned") private boolean virusScanned; + @Column(name = "doc_type_label") + private String docTypeLabel; + @Override public boolean equals(Object o) { if (this == o) { @@ -102,6 +103,7 @@ public static HashMap createFileInfo(UserFile userFile, String t fileInfo.put("filesize", userFile.getFilesize().toString()); fileInfo.put("thumbnailUrl", thumbBase64String); fileInfo.put("type", userFile.getMimeType()); + fileInfo.put("docTypeLabel", userFile.getDocTypeLabel()); return fileInfo; } -} +} \ No newline at end of file diff --git a/src/main/java/formflow/library/data/annotations/DynamicField.java b/src/main/java/formflow/library/data/annotations/DynamicField.java new file mode 100644 index 000000000..334e94714 --- /dev/null +++ b/src/main/java/formflow/library/data/annotations/DynamicField.java @@ -0,0 +1,24 @@ +package formflow.library.data.annotations; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, TYPE, ANNOTATION_TYPE}) +@Retention(RUNTIME) +@Documented +public @interface DynamicField { + + String message() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/formflow/library/data/validators/Money.java b/src/main/java/formflow/library/data/annotations/Money.java similarity index 86% rename from src/main/java/formflow/library/data/validators/Money.java rename to src/main/java/formflow/library/data/annotations/Money.java index c8d66ba91..47f1d07d7 100644 --- a/src/main/java/formflow/library/data/validators/Money.java +++ b/src/main/java/formflow/library/data/annotations/Money.java @@ -1,8 +1,9 @@ -package formflow.library.data.validators; +package formflow.library.data.annotations; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.RetentionPolicy.RUNTIME; +import formflow.library.data.validators.MoneyValidator; import java.lang.annotation.Documented; import java.lang.annotation.Retention; import java.lang.annotation.Target; diff --git a/src/main/java/formflow/library/data/validators/Phone.java b/src/main/java/formflow/library/data/annotations/Phone.java similarity index 86% rename from src/main/java/formflow/library/data/validators/Phone.java rename to src/main/java/formflow/library/data/annotations/Phone.java index 939ae48df..ff91c82fb 100644 --- a/src/main/java/formflow/library/data/validators/Phone.java +++ b/src/main/java/formflow/library/data/annotations/Phone.java @@ -1,8 +1,9 @@ -package formflow.library.data.validators; +package formflow.library.data.annotations; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.RetentionPolicy.RUNTIME; +import formflow.library.data.validators.PhoneValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; import java.lang.annotation.Documented; diff --git a/src/main/java/formflow/library/data/validators/MoneyValidator.java b/src/main/java/formflow/library/data/validators/MoneyValidator.java index b63668432..511444db9 100644 --- a/src/main/java/formflow/library/data/validators/MoneyValidator.java +++ b/src/main/java/formflow/library/data/validators/MoneyValidator.java @@ -1,5 +1,6 @@ package formflow.library.data.validators; +import formflow.library.data.annotations.Money; import java.util.regex.Pattern; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; @@ -8,6 +9,7 @@ * This class validates that Money has been passed in the correct format - one or more digits, a dot and 2 digits after the dot. */ public class MoneyValidator implements ConstraintValidator { + @Override public boolean isValid(String value, ConstraintValidatorContext context) { return Pattern.matches("(^(0|([1-9]\\d*))?(\\.\\d{1,2})?$)?", value); diff --git a/src/main/java/formflow/library/data/validators/PhoneValidator.java b/src/main/java/formflow/library/data/validators/PhoneValidator.java index f294f8955..aaaeb85d2 100644 --- a/src/main/java/formflow/library/data/validators/PhoneValidator.java +++ b/src/main/java/formflow/library/data/validators/PhoneValidator.java @@ -1,5 +1,6 @@ package formflow.library.data.validators; +import formflow.library.data.annotations.Phone; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.util.regex.Pattern; diff --git a/src/main/java/formflow/library/inputs/FieldNameMarkers.java b/src/main/java/formflow/library/inputs/FieldNameMarkers.java new file mode 100644 index 000000000..df07b708f --- /dev/null +++ b/src/main/java/formflow/library/inputs/FieldNameMarkers.java @@ -0,0 +1,9 @@ +package formflow.library.inputs; + +public class FieldNameMarkers { + + public static final String UNVALIDATED_FIELD_MARKER_CSRF = "_csrf"; + public static final String UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS = "validate_"; + public static final String UNVALIDATED_FIELD_MARKER_VALIDATED = "_validated"; + public static final String DYNAMIC_FIELD_MARKER = "_wildcard_"; +} diff --git a/src/main/java/formflow/library/inputs/UnvalidatedField.java b/src/main/java/formflow/library/inputs/UnvalidatedField.java deleted file mode 100644 index 576f0868b..000000000 --- a/src/main/java/formflow/library/inputs/UnvalidatedField.java +++ /dev/null @@ -1,8 +0,0 @@ -package formflow.library.inputs; - -public enum UnvalidatedField { - ; - public static final String CSRF = "_csrf"; - public static final String VALIDATE_ADDRESS = "validate_"; - public static final String VALIDATED = "_validated"; -} diff --git a/src/main/resources/application-form-flow-library.yaml b/src/main/resources/application-form-flow-library.yaml index 7d91f14d4..d5827ef67 100644 --- a/src/main/resources/application-form-flow-library.yaml +++ b/src/main/resources/application-form-flow-library.yaml @@ -2,6 +2,7 @@ spring: flyway: placeholders: uuid_function: "gen_random_uuid" + user_file_doc_type_default_label: ${form-flow.uploads.default-doc-type-label:#{null}} messages: encoding: ISO-8859-1 basename: messages, messages-form-flow diff --git a/src/main/resources/db/migration/V2023.10.25.08.53.44__create_doc_type_label_for_user_files.sql b/src/main/resources/db/migration/V2023.10.25.08.53.44__create_doc_type_label_for_user_files.sql new file mode 100644 index 000000000..2c86e7764 --- /dev/null +++ b/src/main/resources/db/migration/V2023.10.25.08.53.44__create_doc_type_label_for_user_files.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_files + ADD COLUMN doc_type_label VARCHAR(255) DEFAULT '${user_file_doc_type_default_label}'; \ No newline at end of file diff --git a/src/main/resources/templates/fragments/inputs/address.html b/src/main/resources/templates/fragments/inputs/address.html index 8c876a31d..e296b6b33 100644 --- a/src/main/resources/templates/fragments/inputs/address.html +++ b/src/main/resources/templates/fragments/inputs/address.html @@ -6,7 +6,7 @@ city=${inputName + T(formflow.library.inputs.AddressParts).CITY.toString()}, state=${inputName + T(formflow.library.inputs.AddressParts).STATE.toString()}, zipCode=${inputName + T(formflow.library.inputs.AddressParts).ZIPCODE.toString()}, - validateAddressInputName=${T(formflow.library.inputs.UnvalidatedField).VALIDATE_ADDRESS + inputName}" + validateAddressInputName=${T(formflow.library.inputs.FieldNameMarkers).UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + inputName}" > + th:text="${text}" + th:classappend="${isHidden}? 'display-none'"> \ No newline at end of file diff --git a/src/test/java/formflow/library/ValidationServiceTest.java b/src/test/java/formflow/library/ValidationServiceTest.java index 55ee10b1b..366e9cff2 100644 --- a/src/test/java/formflow/library/ValidationServiceTest.java +++ b/src/test/java/formflow/library/ValidationServiceTest.java @@ -1,6 +1,7 @@ package formflow.library; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import formflow.library.config.ActionManager; @@ -10,10 +11,14 @@ import jakarta.validation.Validation; import jakarta.validation.Validator; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static formflow.library.inputs.FieldNameMarkers.DYNAMIC_FIELD_MARKER; + class ValidationServiceTest { private Validator validator; @@ -49,4 +54,64 @@ void validateReturnsEmptyIfValidationsPass() { Map.of() ); } + + @Test + public void dynamicFieldShouldValidateCorrectly() { + Map formSubmissionData = new HashMap<>(); + formSubmissionData.put("firstName", "OtherFirstName"); + IntStream.range(0, 5).forEach(n -> { + formSubmissionData.put("dynamicField" + DYNAMIC_FIELD_MARKER + "123-" + n, "value " + n); + }); + + String badField = "dynamicField" + DYNAMIC_FIELD_MARKER + "123-5"; + formSubmissionData.put(badField, ""); + + FormSubmission formSubmission = new FormSubmission(formSubmissionData); + + Map> validationMessages = validationService.validate(new ScreenNavigationConfiguration(), "testFlow", + formSubmission, submission); + assertThat(validationMessages.size()).isEqualTo(1); + assertThat(validationMessages.containsKey(badField)).isTrue(); + assertThat(validationMessages.get(badField).get(0)).isEqualTo("must not be blank"); + } + + @Test + public void fieldIsDynamicFieldButDoesntHaveAnnotation() { + Map formSubmissionData = new HashMap<>(); + String dynamicFieldName = "dynamicFieldNoAnnotation"; + formSubmissionData.put("firstName", "OtherFirstName"); + formSubmissionData.put(dynamicFieldName + DYNAMIC_FIELD_MARKER, "some value here"); + + FormSubmission formSubmission = new FormSubmission(formSubmissionData); + + Throwable throwable = assertThrows(Throwable.class, () -> { + validationService.validate(new ScreenNavigationConfiguration(), "testFlow", formSubmission, submission); + }); + assertThat(throwable.getClass()).isEqualTo(RuntimeException.class); + assertThat(throwable.getMessage()).isEqualTo( + String.format( + "Field name '%s' (field: '%s') acts like it's a dynamic field, but the field does not contain the @DynamicField annotation", + dynamicFieldName, dynamicFieldName + DYNAMIC_FIELD_MARKER) + ); + } + + @Test() + public void fieldWithWildcardButIsntDynamicFieldShouldFailValidation() { + Map formSubmissionData = new HashMap<>(); + String fieldName = "notDynamicField" + DYNAMIC_FIELD_MARKER; + formSubmissionData.put("firstName", "OtherFirstName"); + formSubmissionData.put(fieldName, "some value here"); + + FormSubmission formSubmission = new FormSubmission(formSubmissionData); + + Throwable throwable = assertThrows(Throwable.class, () -> { + validationService.validate(new ScreenNavigationConfiguration(), "testFlow", formSubmission, submission); + }); + assertThat(throwable.getClass()).isEqualTo(RuntimeException.class); + assertThat(throwable.getMessage()).isEqualTo( + String.format( + "Input field '%s' has dynamic field marker '%s' in its name, but we are unable to find the field in the input file. Is it a dynamic field?", + fieldName, DYNAMIC_FIELD_MARKER) + ); + } } diff --git a/src/test/java/formflow/library/address_validation/ValidationRequestFactoryTest.java b/src/test/java/formflow/library/address_validation/ValidationRequestFactoryTest.java index 897691135..95e9d3272 100644 --- a/src/test/java/formflow/library/address_validation/ValidationRequestFactoryTest.java +++ b/src/test/java/formflow/library/address_validation/ValidationRequestFactoryTest.java @@ -1,13 +1,13 @@ package formflow.library.address_validation; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; - import com.smartystreets.api.us_street.Batch; import formflow.library.data.FormSubmission; -import formflow.library.inputs.UnvalidatedField; import java.util.Map; import org.junit.jupiter.api.Test; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + class ValidationRequestFactoryTest { ValidationRequestFactory validationRequestFactory = new ValidationRequestFactory(); @@ -36,8 +36,8 @@ void shouldCreateBatchFromFormSubmission() { Map.entry("otherAddressCity", secondCity), Map.entry("otherAddressState", secondState), Map.entry("otherAddressZipCode", secondZipcode), - Map.entry(UnvalidatedField.VALIDATE_ADDRESS + "testAddress", "true"), - Map.entry(UnvalidatedField.VALIDATE_ADDRESS + "otherAddress", "true") + Map.entry(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + "testAddress", "true"), + Map.entry(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + "otherAddress", "true") )); Batch batch = validationRequestFactory.create(formSubmission); diff --git a/src/test/java/formflow/library/controllers/ScreenControllerTest.java b/src/test/java/formflow/library/controllers/ScreenControllerTest.java index b663eb203..516dc0058 100644 --- a/src/test/java/formflow/library/controllers/ScreenControllerTest.java +++ b/src/test/java/formflow/library/controllers/ScreenControllerTest.java @@ -84,7 +84,7 @@ public class UrlParameterPersistence { public void passedUrlParametersShouldBeSaved() throws Exception { when(submissionRepositoryService.findById(submission.getId())).thenReturn(Optional.of(submission)); mockMvc.perform(get(getUrlForPageName("test")).queryParam("lang", "en").session(session)) - .andExpect(status().isOk()); + .andExpect(status().isOk()); assert (submission.getUrlParams().equals(Map.of("lang", "en"))); } } @@ -102,7 +102,7 @@ public void modelIncludesCurrentSubflowItem() throws Exception { submission.setInputData(Map.of("testSubflow", List.of(subflowItem))); getPageExpectingSuccess("subflowAddItem/aaa-bbb-ccc"); } - + @Test public void shouldUpdateIterationIsCompleteOnSubflowsWhereLastScreenIsAGetRequest() throws Exception { setFlowInfoInSession(session, "testSubflowLogic", submission.getId()); @@ -115,7 +115,8 @@ public void shouldUpdateIterationIsCompleteOnSubflowsWhereLastScreenIsAGetReques UUID testSubflowLogicUUID = ((Map) session.getAttribute(SUBMISSION_MAP_NAME)).get("testSubflowLogic"); Submission submissionBeforeSubflowIsCompleted = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> iterationsBeforeSubflowIsCompleted = (List>) submissionBeforeSubflowIsCompleted.getInputData().get("subflowWithGetAtEnd"); + List> iterationsBeforeSubflowIsCompleted = (List>) submissionBeforeSubflowIsCompleted.getInputData() + .get("subflowWithGetAtEnd"); String uuidString = (String) iterationsBeforeSubflowIsCompleted.get(0).get("uuid"); mockMvc.perform(get("/flow/testSubflowLogic/otherGetScreen/" + uuidString).session(session)) .andExpect(status().isOk()); @@ -123,7 +124,8 @@ public void shouldUpdateIterationIsCompleteOnSubflowsWhereLastScreenIsAGetReques .andExpect(status().is3xxRedirection()); Submission submissionAfterSubflowIsCompleted = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> subflowIterationsAfterSubflowIsCompleted = (List>) submissionAfterSubflowIsCompleted.getInputData().get("subflowWithGetAtEnd"); + List> subflowIterationsAfterSubflowIsCompleted = (List>) submissionAfterSubflowIsCompleted.getInputData() + .get("subflowWithGetAtEnd"); assertTrue((Boolean) subflowIterationsAfterSubflowIsCompleted.get(0).get("iterationIsComplete")); } @@ -138,7 +140,8 @@ public void shouldNotUpdateIterationIsCompleteBeforeSubflowHasFinished() throws ); UUID testSubflowLogicUUID = ((Map) session.getAttribute(SUBMISSION_MAP_NAME)).get("testSubflowLogic"); Submission submissionBeforeSubflowIsCompleted = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> iterationsBeforeSubfowIsCompleted = (List>) submissionBeforeSubflowIsCompleted.getInputData().get("subflowWithGetAtEnd"); + List> iterationsBeforeSubfowIsCompleted = (List>) submissionBeforeSubflowIsCompleted.getInputData() + .get("subflowWithGetAtEnd"); String uuidString = (String) iterationsBeforeSubfowIsCompleted.get(0).get("uuid"); mockMvc.perform(get("/flow/testSubflowLogic/getScreen/" + uuidString).session(session)) .andExpect(status().isOk()); @@ -146,18 +149,20 @@ public void shouldNotUpdateIterationIsCompleteBeforeSubflowHasFinished() throws .andExpect(status().is3xxRedirection()); Submission submissionBetweenGetScreens = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> subflowIterationsBetweenGetScreens = (List>) submissionBetweenGetScreens.getInputData().get("subflowWithGetAtEnd"); + List> subflowIterationsBetweenGetScreens = (List>) submissionBetweenGetScreens.getInputData() + .get("subflowWithGetAtEnd"); assertThat((Boolean) subflowIterationsBetweenGetScreens.get(0).get("iterationIsComplete")).isFalse(); - + mockMvc.perform(get("/flow/testSubflowLogic/otherGetScreen/" + uuidString).session(session)) .andExpect(status().isOk()); mockMvc.perform(get("/flow/testSubflowLogic/otherGetScreen/navigation?uuid=" + uuidString).session(session)) .andExpect(status().is3xxRedirection()); Submission submissionAfterSubflowIsCompleted = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> subflowIterationsAfterSubflowIsCompleted = (List>) submissionAfterSubflowIsCompleted.getInputData().get("subflowWithGetAtEnd"); + List> subflowIterationsAfterSubflowIsCompleted = (List>) submissionAfterSubflowIsCompleted.getInputData() + .get("subflowWithGetAtEnd"); assertThat((Boolean) subflowIterationsAfterSubflowIsCompleted.get(0).get("iterationIsComplete")).isTrue(); } - + @Test public void shouldSetIterationIsCompleteWhenLastScreenInSubflowIsAPost() throws Exception { setFlowInfoInSession(session, "otherTestFlow", submission.getId()); @@ -167,7 +172,8 @@ public void shouldSetIterationIsCompleteWhenLastScreenInSubflowIsAPost() throws "textInput", List.of("textInputValue"), "numberInput", List.of("10")))) ); - Map iterationAfterFirstSubflowScreeen = getMostRecentlyCreatedIterationData(session, "otherTestFlow", "testSubflow"); + Map iterationAfterFirstSubflowScreeen = getMostRecentlyCreatedIterationData(session, "otherTestFlow", + "testSubflow"); String iterationUuid = (String) iterationAfterFirstSubflowScreeen.get("uuid"); assertThat((Boolean) iterationAfterFirstSubflowScreeen.get("iterationIsComplete")).isFalse(); @@ -176,11 +182,13 @@ public void shouldSetIterationIsCompleteWhenLastScreenInSubflowIsAPost() throws postToUrlExpectingSuccess("/flow/otherTestFlow/subflowAddItemPage2", navigationUrl, new HashMap<>(), iterationUuid); assertThat(followRedirectsForUrl(navigationUrl)).isEqualTo("/flow/otherTestFlow/test"); - Map iterationAfterSecondSubflowScreeen = getMostRecentlyCreatedIterationData(session, "otherTestFlow", "testSubflow"); + Map iterationAfterSecondSubflowScreeen = getMostRecentlyCreatedIterationData(session, "otherTestFlow", + "testSubflow"); assertThat((Boolean) iterationAfterSecondSubflowScreeen.get("iterationIsComplete")).isTrue(); } private record Result(UUID testSubflowLogicUUID, List> iterationsAfterFirstPost, String uuidString) { + } @Test @@ -194,7 +202,8 @@ public void shouldHandleSubflowsWithAGetAndThenAPost() throws Exception { ); UUID testSubflowLogicUUID = ((Map) session.getAttribute(SUBMISSION_MAP_NAME)).get("yetAnotherTestFlow"); Submission submissionAfterFirstPost = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> iterationsAfterFirstPost = (List>) submissionAfterFirstPost.getInputData().get("subflowWithAGetAndThenAPost"); + List> iterationsAfterFirstPost = (List>) submissionAfterFirstPost.getInputData() + .get("subflowWithAGetAndThenAPost"); String uuidString = (String) iterationsAfterFirstPost.get(0).get("uuid"); mockMvc.perform(get("/flow/yetAnotherTestFlow/getScreen/navigation?uuid=" + uuidString).session(session)) @@ -202,10 +211,11 @@ public void shouldHandleSubflowsWithAGetAndThenAPost() throws Exception { String navigationUrl = "/flow/yetAnotherTestFlow/subflowAddItemPage2/navigation?uuid=" + uuidString; postToUrlExpectingSuccess("/flow/yetAnotherTestFlow/subflowAddItemPage2", navigationUrl, - Map.of(), uuidString); + Map.of(), uuidString); assertThat(followRedirectsForUrl(navigationUrl)).isEqualTo("/flow/yetAnotherTestFlow/testReviewScreen"); Submission submissionAfterSecondPost = submissionRepositoryService.findById(testSubflowLogicUUID).get(); - List> iterationsAfterSecondPost = (List>) submissionAfterSecondPost.getInputData().get("subflowWithAGetAndThenAPost"); + List> iterationsAfterSecondPost = (List>) submissionAfterSecondPost.getInputData() + .get("subflowWithAGetAndThenAPost"); assertThat((Boolean) iterationsAfterSecondPost.get(0).get("iterationIsComplete")).isTrue(); } } @@ -410,13 +420,13 @@ public void fieldsStillHaveValuesWhenFieldValidationFailsInSubflowPage2() throws paramsPage1.put("dateSubflowYear", List.of("2012")); ResultActions resultActions = postToUrlExpectingSuccessRedirectPattern( - "/flow/testFlow/subflowAddItem/new", - "/flow/testFlow/subflowAddItem/navigation?uuid=" + UUID_PATTERN_STRING, - paramsPage1); + "/flow/testFlow/subflowAddItem/new", + "/flow/testFlow/subflowAddItem/navigation?uuid=" + UUID_PATTERN_STRING, + paramsPage1); String redirectedUrl = resultActions.andReturn().getResponse().getRedirectedUrl(); String iterationUuid = redirectedUrl.substring(redirectedUrl.lastIndexOf('=') + 1); assertThat(followRedirectsForUrl("/flow/testFlow/subflowAddItem/navigation?uuid=" + iterationUuid)) - .isEqualTo("/flow/testFlow/subflowAddItemPage2/" + iterationUuid); + .isEqualTo("/flow/testFlow/subflowAddItemPage2/" + iterationUuid); var paramsPage2 = new HashMap>(); paramsPage2.put("firstNameSubflowPage2", List.of("tester")); @@ -449,4 +459,6 @@ public void fieldsStillHaveValuesWhenFieldValidationFailsInSubflowPage2() throws assertEquals("Radio B", page2.getRadioValue("radioInputSubflowPage2")); assertEquals("Select C", page2.getSelectValue("selectInputSubflowPage2")); } + + } diff --git a/src/test/java/formflow/library/data/SubmissionTests.java b/src/test/java/formflow/library/data/SubmissionTests.java index 773695f61..fa2228f40 100644 --- a/src/test/java/formflow/library/data/SubmissionTests.java +++ b/src/test/java/formflow/library/data/SubmissionTests.java @@ -39,4 +39,5 @@ public void shouldMarkSubmissionIterationComplete() { assertThat(subflowData.containsKey(Submission.ITERATION_IS_COMPLETE_KEY)).isTrue(); assertThat(subflowData.get(Submission.ITERATION_IS_COMPLETE_KEY)).isEqualTo(true); } + } diff --git a/src/test/java/formflow/library/framework/InputsTest.java b/src/test/java/formflow/library/framework/InputsTest.java index 922c8227b..be229e079 100644 --- a/src/test/java/formflow/library/framework/InputsTest.java +++ b/src/test/java/formflow/library/framework/InputsTest.java @@ -6,7 +6,6 @@ import formflow.library.address_validation.AddressValidationService; import formflow.library.address_validation.ValidatedAddress; -import formflow.library.inputs.UnvalidatedField; import formflow.library.utilities.AbstractMockMvcTest; import formflow.library.utilities.FormScreen; import java.util.HashMap; @@ -21,6 +20,8 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.annotation.DirtiesContext; +import static formflow.library.inputs.FieldNameMarkers.UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS; + @SpringBootTest(properties = {"form-flow.path=flows-config/test-flow.yaml"}) @DirtiesContext() public class InputsTest extends AbstractMockMvcTest { @@ -64,7 +65,7 @@ void shouldPersistInputValuesWhenNavigatingBetweenScreens() throws Exception { Map.entry("phoneInput", List.of(phoneInput)), Map.entry("ssnInput", List.of(ssnInput)), Map.entry("stateInput", List.of(stateInput))) - ); + ); assertThat(nextPage.getTitle()).isEqualTo("Test"); var inputsScreen = new FormScreen(getPage("inputs")); @@ -140,7 +141,7 @@ void beforeEach() throws Exception { Map.entry(inputName + "City", List.of(city)), Map.entry(inputName + "State", List.of(state)), Map.entry(inputName + "ZipCode", List.of(zipCode)), - Map.entry(UnvalidatedField.VALIDATE_ADDRESS + inputName, List.of("true")) + Map.entry(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + inputName, List.of("true")) )); } @@ -176,7 +177,7 @@ void removesPreviousSuggestionWhenGoingBackAndEnteringInvalidAddress() throws Ex Map.entry(inputName + "City", List.of("Fake")), Map.entry(inputName + "State", List.of(state)), Map.entry(inputName + "ZipCode", List.of(zipCode)), - Map.entry(UnvalidatedField.VALIDATE_ADDRESS + inputName, List.of("true")) + Map.entry(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + inputName, List.of("true")) )); assertThat(nextScreen.getTitle()).isEqualTo("testAddressValidationNotFound"); @@ -208,7 +209,7 @@ void updatesSuggestionWhenGoingBackAndUpdatingNewAddress() throws Exception { Map.entry(inputName + "City", List.of("Other City")), Map.entry(inputName + "State", List.of("OZ")), Map.entry(inputName + "ZipCode", List.of("12345")), - Map.entry(UnvalidatedField.VALIDATE_ADDRESS + inputName, List.of("true")) + Map.entry(UNVALIDATED_FIELD_MARKER_VALIDATE_ADDRESS + inputName, List.of("true")) )); assertThat(nextScreen.getTitle()).isEqualTo("Validation Is On"); diff --git a/src/test/java/formflow/library/inputs/OtherTestFlow.java b/src/test/java/formflow/library/inputs/OtherTestFlow.java index bb030d3ef..598fb8395 100644 --- a/src/test/java/formflow/library/inputs/OtherTestFlow.java +++ b/src/test/java/formflow/library/inputs/OtherTestFlow.java @@ -1,8 +1,8 @@ package formflow.library.inputs; import formflow.library.data.FlowInputs; -import formflow.library.data.validators.Money; -import formflow.library.data.validators.Phone; +import formflow.library.data.annotations.Money; +import formflow.library.data.annotations.Phone; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; diff --git a/src/test/java/formflow/library/inputs/TestFlow.java b/src/test/java/formflow/library/inputs/TestFlow.java index 9eac37f13..382fca582 100644 --- a/src/test/java/formflow/library/inputs/TestFlow.java +++ b/src/test/java/formflow/library/inputs/TestFlow.java @@ -1,8 +1,6 @@ package formflow.library.inputs; import formflow.library.data.FlowInputs; -import formflow.library.data.validators.Money; -import formflow.library.data.validators.Phone; import jakarta.validation.constraints.*; import java.util.ArrayList; import java.util.List; @@ -10,6 +8,10 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.web.multipart.MultipartFile; +import formflow.library.data.annotations.Money; +import formflow.library.data.annotations.Phone; +import formflow.library.data.annotations.DynamicField; + @TestConfiguration @SuppressWarnings("unused") public class TestFlow extends FlowInputs { @@ -152,6 +154,14 @@ public class TestFlow extends FlowInputs { @NotBlank @Phone String phoneInputSubflowPage2; - + String doYouWantToEnterTheTestSubflow; + + @DynamicField + @NotBlank + String dynamicField; + + String dynamicFieldNoAnnotation; + + String notDynamicField_wildcard_; } diff --git a/src/test/java/formflow/library/inputs/TestLandmarkFlow.java b/src/test/java/formflow/library/inputs/TestLandmarkFlow.java index 59b7b7986..90f28b87b 100644 --- a/src/test/java/formflow/library/inputs/TestLandmarkFlow.java +++ b/src/test/java/formflow/library/inputs/TestLandmarkFlow.java @@ -1,19 +1,8 @@ package formflow.library.inputs; import formflow.library.data.FlowInputs; -import formflow.library.data.validators.Money; -import formflow.library.data.validators.Phone; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.Size; -import java.util.ArrayList; -import java.util.List; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.web.multipart.MultipartFile; @TestConfiguration @SuppressWarnings("unused") diff --git a/src/test/java/formflow/library/inputs/TestSubflowLogic.java b/src/test/java/formflow/library/inputs/TestSubflowLogic.java index 7a7293a98..404638550 100644 --- a/src/test/java/formflow/library/inputs/TestSubflowLogic.java +++ b/src/test/java/formflow/library/inputs/TestSubflowLogic.java @@ -1,19 +1,16 @@ package formflow.library.inputs; import formflow.library.data.FlowInputs; -import formflow.library.data.validators.Money; -import formflow.library.data.validators.Phone; -import jakarta.validation.constraints.Email; +import formflow.library.data.annotations.Money; +import formflow.library.data.annotations.Phone; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.util.ArrayList; import java.util.List; import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.web.multipart.MultipartFile; @TestConfiguration @SuppressWarnings("unused") diff --git a/src/test/java/formflow/library/inputs/YetAnotherTestFlow.java b/src/test/java/formflow/library/inputs/YetAnotherTestFlow.java index 9d6957c48..cafc726de 100644 --- a/src/test/java/formflow/library/inputs/YetAnotherTestFlow.java +++ b/src/test/java/formflow/library/inputs/YetAnotherTestFlow.java @@ -1,11 +1,9 @@ package formflow.library.inputs; import formflow.library.data.FlowInputs; -import formflow.library.data.validators.Money; -import formflow.library.data.validators.Phone; +import formflow.library.data.annotations.Money; +import formflow.library.data.annotations.Phone; import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import java.util.ArrayList; @@ -15,7 +13,7 @@ @TestConfiguration @SuppressWarnings("unused") public class YetAnotherTestFlow extends FlowInputs { - + String firstName; String textInput; diff --git a/src/test/java/formflow/library/repository/UserFileRepositoryServiceTests.java b/src/test/java/formflow/library/repository/UserFileRepositoryServiceTests.java new file mode 100644 index 000000000..639e0a5d1 --- /dev/null +++ b/src/test/java/formflow/library/repository/UserFileRepositoryServiceTests.java @@ -0,0 +1,89 @@ +package formflow.library.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import formflow.library.data.Submission; +import formflow.library.data.SubmissionRepositoryService; +import formflow.library.data.UserFile; +import formflow.library.data.UserFileRepositoryService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.util.MimeTypeUtils; + +@ActiveProfiles("test") +@SpringBootTest(properties = {"form-flow.path=flows-config/test-flow.yaml"}) +public class UserFileRepositoryServiceTests { + + /* + Note: These tests are disabled because the test db doesn't work like + expected. Many of the fields are not getting their defaults set properly and so the + default value for the field tested below is not getting populated. Until it is + populated these tests will not function. Committing this code so we don't lose it. + */ + @Autowired + private UserFileRepositoryService userFileRepositoryService; + @Autowired + private SubmissionRepositoryService submissionRepositoryService; + + @Value("${form-flow.uploads.default-doc-type-label:'OTHER'}") + private String docTypeDefaultValue; + + private Submission submission; + + @BeforeEach + void setup() { + var inputData = Map.of( + "testKey", "this is a test value", + "otherTestKey", List.of("A", "B", "C") + ); + submission = Submission.builder() + .inputData(inputData) + .urlParams(new HashMap<>()) + .flow("testFlow") + .build(); + submission = submissionRepositoryService.save(submission); + } + + @Test + @Disabled + void shouldUseProperDefaultForDocTypeLabel() { + UserFile testFile = UserFile.builder() + .submission(submission) + .originalName("originalName.jpg") + .repositoryPath("/some/path/here") + .filesize((float) 1000.0) + .mimeType(MimeTypeUtils.IMAGE_JPEG_VALUE) + .virusScanned(true) + .build(); + + UserFile savedFile = userFileRepositoryService.save(testFile); + assertThat(savedFile.getDocTypeLabel()).isEqualTo(docTypeDefaultValue); + } + + @Test + @Disabled + void shouldUserDocTypeSetInUserFileBuilderCall() { + UserFile testFile = UserFile.builder() + .submission(submission) + .originalName("originalName.jpg") + .repositoryPath("/some/path/here") + .filesize((float) 1000.0) + .mimeType(MimeTypeUtils.IMAGE_JPEG_VALUE) + .virusScanned(true) + .docTypeLabel("BirthCertificate") + .build(); + + UserFile savedFile = userFileRepositoryService.save(testFile); + assertThat(savedFile.getDocTypeLabel()).isEqualTo("BirthCertificate"); + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 2ce9b3c3f..a3688b6cc 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -1,8 +1,8 @@ form-flow: -# disabled-flows: path: 'test-flow.yaml' inputs: 'formflow.library.inputs.' uploads: + default-doc-type-label: "NotSet" accepted-file-types: '.jpeg, .fake, .heic, .tif, .tiff, .pdf' max-files: '5' max-file-size: '17' @@ -40,6 +40,7 @@ spring: clean-on-validation-error: true placeholders: uuid_function: "gen_random_uuid" + user_file_doc_type_default_label: ${form-flow.uploads.default-doc-type-label:#{null}} clean-disabled: false messages: encoding: ISO-8859-1