Repository: ministryofjustice/opg-paper-identity
Pull request #481: Feature/id 554 kbv form error message#minor
Author: login: spinhalf-com
Created: 2025-04-03T16:30:02Z | Merged: 2025-04-04T09:48:30Z

## Purpose

_Briefly describe the purpose of the change._

Fixes ###-####

## Approach

_Explain how your code addresses the purpose of the change._

## Learning

_Any tips and tricks, blog posts or tools which helped you._

## Checklist

* [ x] I have performed a self-review of my own code
* [ x] I have added relevant logging with appropriate levels to my code
* [ x] I have updated documentation where relevant
* [ x] I have added tests to prove my work
* [ x] I have run an accessibility tool against the changes and applied fixes
* [ ] The team have tested these changes



Diff:
diff --git a/e2e-tests/cypress/e2e/donor/donor-journey-locks.cy.ts b/e2e-tests/cypress/e2e/donor/donor-journey-locks.cy.ts
index 2d37dc89d..1958c0b98 100644
--- a/e2e-tests/cypress/e2e/donor/donor-journey-locks.cy.ts
+++ b/e2e-tests/cypress/e2e/donor/donor-journey-locks.cy.ts
@@ -135,4 +135,31 @@ describe("Identify a Donor", () => {
         cy.jumpToPage("how-will-you-confirm");
         cy.contains("National Insurance number could not be verified over the phone, choose an alternate ID method below.");
     });
+
+    it("form validation on KBV pages throws error on every non-selection attempt", () => {
+        cy.visit("/start?personType=donor&lpas[]=M-XYXY-YAGA-35G3");
+
+        cy.contains("How will you confirm your identity?");
+        cy.get("label").contains("National insurance number").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.getInputByLabel("National Insurance number").type("NP 112233 C");
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.contains("Select an answer");
+        cy.selectKBVAnswer({correct: true});
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.contains("Select an answer");
+        cy.selectKBVAnswer({correct: true});
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.contains("Select an answer");
+        cy.selectKBVAnswer({correct: true});
+        cy.get(".govuk-button").contains("Continue").click();
+        cy.contains(".moj-alert", "Identity check passed");
+    });
 });
diff --git a/service-front/module/Application/src/Controller/KbvController.php b/service-front/module/Application/src/Controller/KbvController.php
index 08fdd5cd0..4ead7430e 100644
--- a/service-front/module/Application/src/Controller/KbvController.php
+++ b/service-front/module/Application/src/Controller/KbvController.php
@@ -12,6 +12,7 @@
 use Laminas\Mvc\Controller\AbstractActionController;
 use Laminas\Stdlib\Parameters;
 use Laminas\View\Model\ViewModel;
+use Laminas\InputFilter\InputFilter;
 
 /**
  * @psalm-import-type Question from OpgApiServiceInterface
@@ -51,8 +52,8 @@ public function idVerifyQuestionsAction(): ViewModel|Response
         /**
          * @psalm-suppress PossiblyInvalidArrayAccess
          */
-        $firstQuestion = $questionsData[0]['question'];
-        $view->setVariable('first_question', $firstQuestion);
+        $firstQuestion = $questionsData[0];
+        $view->setVariable('first_question', $firstQuestion['question']);
 
         if ($questionsData === false) {
             throw new HttpException(500, 'Could not load KBV questions');
@@ -65,38 +66,62 @@ public function idVerifyQuestionsAction(): ViewModel|Response
         $questionsData = array_filter($questionsData, fn (array $question) => $question['answered'] !== true);
 
         $form = new Form();
+        $inputFilter = new InputFilter();
+        $form->setInputFilter($inputFilter);
 
         foreach ($questionsData as $question) {
             $form->add(new Element($question['externalId']));
+
+            $inputFilter->add([
+                'name' => $question['externalId'],
+                'required' => true,
+            ]);
         }
 
         $view->setVariable('questions_data', $questionsData);
 
         $formData = $this->getRequest()->getPost();
+        $form->setData($formData);
+
+        /** @psalm-suppress InvalidArgument */
         $nextQuestion = $this->getNextQuestion($questionsData, $formData);
+        $view->setVariable('form_valid', $form->isValid());
         $view->setVariable('question', $nextQuestion);
 
-        if (count($formData) > 0) {
-            if ($nextQuestion === null) {
-                $check = $this->opgApiService->checkIdCheckAnswers($uuid, ['answers' => $formData->toArray()]);
+        if ($this->getRequest()->isGet()) {
+            $view->setVariable('form_valid', true);
+        }
+
+        // this check look weird, but it works for preventing a spurious form error on page 2
+        foreach ($formData as $postVar) {
+            if (
+                (is_null($nextQuestion) || $firstQuestion['question'] !== $nextQuestion['question']) &&
+                    $postVar === ""
+            ) {
+                $view->setVariable('form_valid', true);
+            }
+        }
 
-                if (! $check['complete']) {
-                    return $this->redirect()->refresh();
-                }
+        /** @psalm-suppress InvalidArgument */
+        if (count($formData) > 0 && $nextQuestion === null) {
+            /** @psalm-suppress InvalidMethodCall */
+            $check = $this->opgApiService->checkIdCheckAnswers($uuid, ['answers' => $formData->toArray()]);
 
-                if ($check['passed'] === true) {
-                    $caseProgressData = $detailsData['caseProgress'] ?? [];
-                    $caseProgressData['kbvs'] = [
-                        'result' => true
-                    ];
-                    $this->opgApiService->updateCaseProgress($uuid, $caseProgressData);
+            if (! $check['complete']) {
+                return $this->redirect()->refresh();
+            }
 
-                    return $this->redirect()->toRoute($passRoute[$detailsData['personType']], ['uuid' => $uuid]);
-                }
+            if ($check['passed'] === true) {
+                $caseProgressData = $detailsData['caseProgress'] ?? [];
+                $caseProgressData['kbvs'] = [
+                    'result' => true
+                ];
+                $this->opgApiService->updateCaseProgress($uuid, $caseProgressData);
 
-                return $this->redirect()->toRoute($failRoute, ['uuid' => $uuid]);
+                return $this->redirect()->toRoute($passRoute[$detailsData['personType']], ['uuid' => $uuid]);
             }
-            $form->setData($formData);
+
+            return $this->redirect()->toRoute($failRoute, ['uuid' => $uuid]);
         }
         $view->setVariable('form', $form);
 
diff --git a/service-front/module/Application/view/application/pages/identity_check_questions.twig b/service-front/module/Application/view/application/pages/identity_check_questions.twig
index 0e746b88c..4ec3734ba 100755
--- a/service-front/module/Application/view/application/pages/identity_check_questions.twig
+++ b/service-front/module/Application/view/application/pages/identity_check_questions.twig
@@ -6,8 +6,8 @@
 
 {% block content %}
     <div class="moj-alert__content moj-hidden">{% block title %}Identity check questions{% endblock %}</div>
-
     <form method="post" target="">
+
         {% for questionBlock in questions_data %}
 
             {% if first_question == questionBlock.question and questionBlock.externalId == question.externalId %}
@@ -43,6 +43,7 @@
                 />
             {% else %}
                 <div class="govuk-form-group">
+
                     <h1 class="govuk-heading-xl">{{ questionBlock.question }}</h1>
                     <div class="govuk-form-group">
                         <fieldset class="govuk-fieldset">
@@ -51,7 +52,13 @@
                                     Select answer
                                 </h2>
                             </legend>
-                            <div class="govuk-radios" data-module="govuk-radios">
+                            {% if not form_valid %}
+                                <p id="kbv-error" class="govuk-error-message">
+                                    <span class="govuk-visually-hidden">Error:</span>
+                                        Select an answer
+                                </p>
+                            {% endif %}
+                            <div class="govuk-radios {% if not form_valid %}govuk-form-group--error{% endif %}" data-module="govuk-radios">
                                 {% for datum in questionBlock.prompts %}
                                     <div class="govuk-radios__item">
                                         <input
@@ -77,7 +84,9 @@
                     <div class="govuk-form-group">
                         <input class="govuk-button" type="submit" value="Continue">
                     </div>
+
                 </div>
+
             {% endif %}
         {% endfor %}
     </form>


Reviews:
user: login: davidbridgwood
body: definitely not the most obvious solution as you said but looks good.
sonarcloud has a couple of suggestions which i think would be worth implementing to reduce complexity slightly.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:15:25Z
user: login: davidbridgwood
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T09:30:11Z

Review comments:
diff_hunk: @@ -65,19 +66,45 @@ public function idVerifyQuestionsAction(): ViewModel|Response
         $questionsData = array_filter($questionsData, fn (array $question) => $question['answered'] !== true);
 
         $form = new Form();
+        $inputFilter = new InputFilter();
+        $form->setInputFilter($inputFilter);
 
         foreach ($questionsData as $question) {
             $form->add(new Element($question['externalId']));
+
+            $inputFilter->add([
+                'name' => $question['externalId'],
+                'required' => true,
+            ]);
         }
 
         $view->setVariable('questions_data', $questionsData);
 
         $formData = $this->getRequest()->getPost();
+        $form->setData($formData);
+
+        /** @psalm-suppress InvalidArgument */
         $nextQuestion = $this->getNextQuestion($questionsData, $formData);
+        $view->setVariable('form_valid', $form->isValid());
         $view->setVariable('question', $nextQuestion);
 
+        if ($this->getRequest()->isGet()) {
+            $view->setVariable('form_valid', true);
+        }
+
+        // this check look weird, but it works for preventing a spurious form error on page 2
+        foreach ($formData as $postVar) {
+            if (is_null($nextQuestion) || $firstQuestion['question'] !== $nextQuestion['question']) {
+                if ($postVar === "") {
path: service-front/module/Application/src/Controller/KbvController.php
user: login: davidbridgwood
body: sonarcloud suggests merging these conditions to reduce complexity/nesting level which i think would be a good idea.

i.e.
```php
if (
    (is_null($nextQuestion) || $firstQuestion['question'] !== $nextQuestion['question']) &
    ($postVar === "")
) {
```
created_at: 2025-04-04T09:13:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -65,19 +66,45 @@ public function idVerifyQuestionsAction(): ViewModel|Response
         $questionsData = array_filter($questionsData, fn (array $question) => $question['answered'] !== true);
 
         $form = new Form();
+        $inputFilter = new InputFilter();
+        $form->setInputFilter($inputFilter);
 
         foreach ($questionsData as $question) {
             $form->add(new Element($question['externalId']));
+
+            $inputFilter->add([
+                'name' => $question['externalId'],
+                'required' => true,
+            ]);
         }
 
         $view->setVariable('questions_data', $questionsData);
 
         $formData = $this->getRequest()->getPost();
+        $form->setData($formData);
+
+        /** @psalm-suppress InvalidArgument */
         $nextQuestion = $this->getNextQuestion($questionsData, $formData);
+        $view->setVariable('form_valid', $form->isValid());
         $view->setVariable('question', $nextQuestion);
 
+        if ($this->getRequest()->isGet()) {
+            $view->setVariable('form_valid', true);
+        }
+
+        // this check look weird, but it works for preventing a spurious form error on page 2
+        foreach ($formData as $postVar) {
+            if (is_null($nextQuestion) || $firstQuestion['question'] !== $nextQuestion['question']) {
+                if ($postVar === "") {
+                    $view->setVariable('form_valid', true);
+                }
+            }
+        }
+
+        /** @psalm-suppress InvalidArgument */
         if (count($formData) > 0) {
             if ($nextQuestion === null) {
path: service-front/module/Application/src/Controller/KbvController.php
user: login: davidbridgwood
body: same suggestion from sonarcloud to merge conditions and reduce level of nesting.
created_at: 2025-04-04T09:13:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-04-03T16:43:38Z
body: ## [Codecov](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/pull/481?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 74.37%. Comparing base [(`8ec7eab`)](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/commit/8ec7eabba8cc1f9dcf0380acf1de24b11359c3eb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice) to head [(`1cd88f4`)](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/commit/1cd88f45e6c22d50caa0cdea52f0e41308dcd5cd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice).
> Report is 4 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##               main     #481   +/-   ##
=========================================
  Coverage     74.37%   74.37%           
  Complexity     1243     1243           
=========================================
  Files           134      134           
  Lines          5678     5678           
=========================================
  Hits           4223     4223           
  Misses         1455     1455           
```

| [Flag](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/pull/481/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice) | Coverage Δ | |
|---|---|---|
| [paper-id-api](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/pull/481/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice) | `63.80% <ø> (ø)` | |
| [paper-id-front](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/pull/481/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice) | `84.99% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ministryofjustice/opg-paper-identity/pull/481?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ministryofjustice).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-04T09:25:26Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=ministryofjustice_opg-paper-identity&pullRequest=481) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=ministryofjustice_opg-paper-identity&pullRequest=481&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=ministryofjustice_opg-paper-identity&pullRequest=481&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ministryofjustice_opg-paper-identity&pullRequest=481&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ministryofjustice_opg-paper-identity&pullRequest=481&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=ministryofjustice_opg-paper-identity&pullRequest=481&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ministryofjustice_opg-paper-identity&pullRequest=481)


author_association: NONE