Repository: department-of-veterans-affairs/va.gov-cms
Pull request #21237: VAGOV-TEAM-106494 index iteration
Author: login: tjheffner
Created: 2025-04-29T19:02:26Z | Merged: 2025-04-30T18:07:07Z

## Description

Relates to https://github.com/department-of-veterans-affairs/va.gov-team/issues/106494

Updates the $min parameter to be a startingIndex instead. Updates logic to use this value instead of always starting at 0. This should allow the edit page to define how many objects exist already and then add ajax items from there.

Also updated the form validation to not trigger on the ajax add button, only the actual form submit for the page.


## Testing done
Add this Trait to the `FormInfo.php` for testing:
```php
use Drupal\va_gov_form_builder\Traits\RepeatableFieldGroup;

class FormInfo extends FormBuilderFormBase {

use RepeatableFieldGroup;
```

Added this code block to `FormInfo.php` for testing:
```php
    $field_definitions = [
      'label' => ['#type' => 'textfield', '#title' => 'Label for radio item', '#required' => TRUE],
      'description' => ['#type' => 'textfield', '#title' => 'Description for radio item'],
      'required' => [
        '#type' => 'checkbox',
        '#title' => $this->t('Required for the submitter'),
        '#displayIndexCount' => FALSE
      ],
    ];

    $this->addRepeatableFieldGroup($form, $form_state, 'dynamic_radio', $field_definitions, 1, 5, 'Radio Item');
```

Visit `/form-builder/76353/form-info` to see the repeatable fields + button


## Screenshots
Count starting at a higher index:
![Screenshot 2025-04-29 at 12 02 50 PM](https://github.com/user-attachments/assets/3b8956f6-6faf-440e-b0ce-223aa4f52371)

Button disappearing when max items reached:
![Screenshot 2025-04-29 at 12 02 53 PM](https://github.com/user-attachments/assets/6ddc6d6b-568c-414a-8e48-4b9cbd06aba3)

Validation only triggered when save & continue hit:
![Screenshot 2025-04-29 at 12 02 56 PM](https://github.com/user-attachments/assets/3fb6798f-fa96-4d5c-991e-c416962c7f1a)


## QA steps

- [ ] Checkout branch locally
- [ ] Add the code from above to the necessary file
- [ ] Verify functionality works as expected for 5 new items
- [ ] Update startIndex to `3` and `ddev drush cr`
- [ ] Refresh the page, see the item count start at 3 and add items.
- [ ] Verify form validation does not trigger on the ajax button, but on the `Save & Continue` button.
- [ ] Updated startIndex to `5` and `ddev drush cr`
- [ ] Verify no button appears on the page as the max limit has been reached already.



Diff:
diff --git a/docroot/modules/custom/va_gov_form_builder/css/repeatable-field-groups.css b/docroot/modules/custom/va_gov_form_builder/css/repeatable-field-groups.css
new file mode 100644
index 00000000000..70df653d3f5
--- /dev/null
+++ b/docroot/modules/custom/va_gov_form_builder/css/repeatable-field-groups.css
@@ -0,0 +1,15 @@
+/* stylelint-disable-next-line selector-no-qualifying-type */
+input[type="submit"].form-builder-add-item  {
+  background-color: unset;
+  box-shadow: unset;
+  color: var(--vads-color-primary);
+  padding-left: var(--units-1);
+  text-transform: uppercase;
+}
+
+/* input elements don't support pseudo elements so put the icon on the wrapper */
+.form-builder-add-item-wrapper::before {
+  align-self: center;
+  content: url("data:image/svg+xml,%3Csvg width='17' height='17' viewBox='0 0 17 17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16.96 7.32364V9.63636C16.96 10.2748 16.442 10.7927 15.8036 10.7927H10.7927V15.8036C10.7927 16.442 10.2748 16.96 9.63636 16.96H7.32364C6.68523 16.96 6.16727 16.442 6.16727 15.8036V10.7927H1.15636C0.517955 10.7927 0 10.2748 0 9.63636V7.32364C0 6.68523 0.517955 6.16727 1.15636 6.16727H6.16727V1.15636C6.16727 0.517955 6.68523 0 7.32364 0H9.63636C10.2748 0 10.7927 0.517955 10.7927 1.15636V6.16727H15.8036C16.442 6.16727 16.96 6.68523 16.96 7.32364Z' fill='%23005EA2'/%3E%3C/svg%3E%0A");
+  display: inline-block;
+}
diff --git a/docroot/modules/custom/va_gov_form_builder/src/Controller/VaGovFormBuilderController.php b/docroot/modules/custom/va_gov_form_builder/src/Controller/VaGovFormBuilderController.php
index 8fee5f61e05..5d1dc1ae1fd 100644
--- a/docroot/modules/custom/va_gov_form_builder/src/Controller/VaGovFormBuilderController.php
+++ b/docroot/modules/custom/va_gov_form_builder/src/Controller/VaGovFormBuilderController.php
@@ -1184,6 +1184,7 @@ public function customSingleQuestionResponseKind($nid, $stepParagraphId) {
       'response_kind',
       'expanded_radio',
       'expanded_radio__help_text_optional_image',
+      'repeatable_field_groups',
     ];
 
     return $this->getFormPage($formName, $subtitle, $breadcrumbs, $libraries);
@@ -1373,6 +1374,7 @@ public function customSingleQuestionSingleDateResponse($nid, $stepParagraphId, $
       'two_column_with_buttons',
       'expanded_radio',
       'custom_single_question_response',
+      'repeatable_field_groups',
     ];
 
     return $this->getFormPage($formName, $subtitle, $breadcrumbs, $libraries);
@@ -1439,6 +1441,7 @@ public function customSingleQuestionDateRangeResponse($nid, $stepParagraphId, $p
       'two_column_with_buttons',
       'expanded_radio',
       'custom_single_question_response',
+      'repeatable_field_groups',
     ];
 
     return $this->getFormPage($formName, $subtitle, $breadcrumbs, $libraries);
diff --git a/docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php b/docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
index c00ff9d4d4f..13bed0f6ec7 100644
--- a/docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
+++ b/docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
@@ -15,13 +15,25 @@ trait RepeatableFieldGroup {
   /**
    * Creates repeatable fields dynamically using AJAX.
    */
-  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $min = 1, int $max = 10) {
-    // Get the number of items in the form already.
+  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $min = 1, int $max = 10, $startIndex = 1, $button_name = 'Add another') {
+    // This should never happen, but you never know.
+    if ($min > $max) {
+      return;
+    }
+    // If an additional item would be over the limit, bail early.
+    if ($startIndex > $max) {
+      return;
+    }
+
+    // Get the number of ajax items in the form already.
     $num_items = $form_state->get($element_name . '_count');
-    // We have to ensure that there is at least one item field.
+
+    // If null, this is the first render.
+    // We need to render at least $min items, and possibly
+    // more if there are existing items.
     if ($num_items === NULL) {
-      $form_state->set($element_name . '_count', 1);
-      $num_items = $form_state->get($element_name . '_count');
+      $num_items = max($min, $startIndex - 1);
+      $form_state->set($element_name . '_count', $num_items);
     }
 
     // Create the container to hold our dynamic items.
@@ -33,31 +45,44 @@ public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_s
     // Fields get their own values instead of copying the first group.
     $form[$element_name . '_fieldset'][$element_name]['#tree'] = TRUE;
 
-    for ($i = 0; $i < $num_items; $i++) {
+    for ($i = 0; $i < $num_items - $startIndex + 1; $i++) {
       // Create a new fields for each item.
       foreach ($field_definitions as $field_key => $definition) {
         $form[$element_name . '_fieldset'][$element_name][$i][$field_key] = $definition;
 
-        // Track given field title to iterate visible counter.
-        $label = $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#title'];
-        $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#title'] = $label . ' ' . $i + 1;
+        // Append index counter to the input label for a given set of fields.
+        // - Custom key isn't set, so hideIndex === FALSE.
+        // - Or if custom key exists and value isn't FALSE.
+        $hideIndex = isset($form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#displayIndexCount']);
+        if ($hideIndex === FALSE || $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#displayIndexCount'] !== FALSE) {
+          $label = $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#title'];
+          $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#title'] = $label . ' ' . $i + $startIndex;
+        }
       }
     }
 
     if ($num_items < $max) {
       $form[$element_name . '_fieldset']['actions'] = [
         '#type' => 'actions',
+        '#attributes' => [
+          'class' => ['form-builder-add-item-wrapper'],
+        ],
       ];
 
       $form[$element_name . '_fieldset']['actions']['add_item'] = [
         '#type' => 'submit',
         '#name' => $element_name . '_add_item',
-        '#value' => $this->t('Add another'),
+        '#value' => $this->t('@button', ['@button' => $button_name]),
         '#submit' => ['::addOne'],
         '#ajax' => [
           'callback' => '::addMoreCallback',
           'wrapper' => $element_name . '-wrapper',
         ],
+        '#attributes' => [
+          'class' => ['form-builder-add-item'],
+        ],
+        // Don't validate on ajax add, only on full page submission.
+        '#limit_validation_errors' => [],
       ];
     }
   }
diff --git a/docroot/modules/custom/va_gov_form_builder/va_gov_form_builder.libraries.yml b/docroot/modules/custom/va_gov_form_builder/va_gov_form_builder.libraries.yml
index fa61f01954f..ca1c838f1c0 100644
--- a/docroot/modules/custom/va_gov_form_builder/va_gov_form_builder.libraries.yml
+++ b/docroot/modules/custom/va_gov_form_builder/va_gov_form_builder.libraries.yml
@@ -85,3 +85,8 @@ expanded_radio__help_text_optional_image:
   css:
     theme:
       css/expanded-radio--help-text-optional-image.css: {}
+repeatable_field_groups:
+  version: 1.x
+  css:
+    theme:
+      css/repeatable-field-groups.css: {}


Reviews:
user: login: derekhouck
body: LGTM, but I'm not in the weeds, so maybe wait for one more approval before merge.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T19:06:14Z
user: login: ryguyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T19:33:41Z
user: login: ryguyk
body: This looks great! Nothing here should need changing.

I do think we need one more feature, though.

![image](https://github.com/user-attachments/assets/6fa23128-61f4-4b91-a248-0bae178916f4)

I'm thinking we can update things in one of two ways:

1.
```
    $repeatableTextInputFieldDefinitions = [
      'label' => [
        '#type' => 'textfield',
        '#title' => $this->t($labelText),
        '#required' => TRUE,
      ],
      'hint_text' => [
        '#type' => 'textfield',
        '#title' => $this->t($hintText),
      ],
      'required' => [
        '#type' => 'checkbox',
        '#title' => $this->t('Required for the submitter'),
        // ACCEPT THIS CUSTOM KEY.
        // DEFAULTS TO TRUE.
        '#displayIndexCount' => FALSE
      ],
    ];
```
2.
```
    $this->addRepeatableFieldGroup(
      $form,
      $form_state,
      'dynamic_text_input_fields',
      $repeatableTextInputFieldDefinitions,
      2,
      self::MAX_REPEATABLE_FIELDS - $existingComponentCount,
      // ACCEPT THIS PARAMETER THAT DEFINES
      // WHICH KEYS SHOULD HAVE THE COUNTER
      // APPENDED.
      [
        'label',
        'hint_text',
      ]
    );
```
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T19:41:15Z
user: login: ryguyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T20:48:36Z
user: login: ryguyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T20:58:14Z
user: login: ryguyk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T21:07:27Z
user: login: ryguyk
body: This looks great! I think (read: hope) this covers all of the requirements now. If any others pop up, we can address in another follow-up PR, but that feels unlikely now. I think we can get this merged.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T17:58:43Z

Review comments:
diff_hunk: @@ -15,12 +15,17 @@ trait RepeatableFieldGroup {
   /**
    * Creates repeatable fields dynamically using AJAX.
    */
-  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $min = 1, int $max = 10) {
-    // Get the number of items in the form already.
+  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $startIndex = 1, int $max = 10) {
+    // If an additional item would be over the limit, bail early.
+    if ($startIndex >= $max) {
path: docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
user: login: ryguyk
body: Just a note that callers will _not_ need to do any subtraction when calling, as I previously asserted. I was envisioning this in the caller:
```
    $this->addRepeatableFieldGroup(
      $form,
      $form_state,
      'dynamic_text_input_fields',
      $repeatableTextInputFieldDefinitions,
      2,
      self::MAX_REPEATABLE_FIELDS - $existingComponentCount,  // HERE
    );
```

But we should not need to do this, as this "plugin" is treating things in a way that effectively says, "Give me the _total_ number of inputs you want, *including the ones you have rendered manually*. Also, tell me how many you've rendered there (`$startingIndex`)."

I think this works well.
created_at: 2025-04-29T19:33:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,12 +15,17 @@ trait RepeatableFieldGroup {
   /**
    * Creates repeatable fields dynamically using AJAX.
    */
-  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $min = 1, int $max = 10) {
-    // Get the number of items in the form already.
+  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $startIndex = 1, int $max = 10) {
+    // If an additional item would be over the limit, bail early.
+    if ($startIndex >= $max) {
path: docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
user: login: ryguyk
body: I think `$startIndex` can be equal to `$max`, just not greater than it. So:
```
if ($startIndex > $max)
```
That said, I think we need to update a few other things to accommodate some other requirements, and those might play into this logic. So perhaps not useful to think of this in isolation right now. In either case, I think this general idea should hold, where the index can equal the max, but it's an error once it goes beyond that.
created_at: 2025-04-29T20:48:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,7 +38,7 @@ public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_s
     // Fields get their own values instead of copying the first group.
     $form[$element_name . '_fieldset'][$element_name]['#tree'] = TRUE;
 
-    for ($i = 0; $i < $num_items; $i++) {
+    for ($i = $startIndex - 1; $i < $num_items; $i++) {
       // Create a new fields for each item.
       foreach ($field_definitions as $field_key => $definition) {
         $form[$element_name . '_fieldset'][$element_name][$i][$field_key] = $definition;
path: docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
user: login: ryguyk
body: I'm wondering if `$i` here is still ok:
```
$form[$element_name . '_fieldset'][$element_name][$i][$field_key]
```

We will start our count at something greater than zero.

I think this might work:
```
   for ($i = 0; $i < $num_items - $startIndex + 1; $i++) {
      // Create a new fields for each item.
      foreach ($field_definitions as $field_key => $definition) {
        $form[$element_name . '_fieldset'][$element_name][$i][$field_key] = $definition;

        // Track given field title to iterate visible counter.
        $label = $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#title'];
        $form[$element_name . '_fieldset'][$element_name][$i][$field_key]['#title'] = $label . ' ' . $i + $startIndex;
      }
    }
```

(But, importantly, with the check for `#displayIndexCount` added back in here).
created_at: 2025-04-29T20:58:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,12 +15,17 @@ trait RepeatableFieldGroup {
   /**
    * Creates repeatable fields dynamically using AJAX.
    */
-  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $min = 1, int $max = 10) {
-    // Get the number of items in the form already.
+  public function addRepeatableFieldGroup(array &$form, FormStateInterface $form_state, string $element_name, array $field_definitions, int $startIndex = 1, int $max = 10) {
+    // If an additional item would be over the limit, bail early.
+    if ($startIndex >= $max) {
path: docroot/modules/custom/va_gov_form_builder/src/Traits/RepeatableFieldGroup.php
user: login: ryguyk
body: I've put some thought into how we can perhaps most easily accommodate the additional requirements. First, here's the brainstorming:

```
// Our API
addRepeatableFieldGroup(
  $form,
  $form_state,
  $element_name,
  $field_definitions,
  $min,
  $max,
  $start,
);

$start
  1-based index that should be the index displayed for the first element rendered by this call.
  Importantly, this element will only be rendered on the first render if $start <= $min.

$min
  The greatest 1-based index that *must* be displayed on the page. 

$max
  The greatest 1-based index that *can* be displayed on the page.

$numItems (not passed in, but calculated within)
  The number of elements that are displayed currently (including the manually rendered existing components).

Examples:
=========
1. If $min > $max, exit early.
This is nonsense.

2. If $start > $max, exit early.
This marks the edge case where additional elements are not allowed.

3. If $start == $max, this call can continue. 
The $start index will be the one added to the form on the first button click.

5. If $start = 1 and $min = 2, this call must render TWO (2) elements.
This critical example captures the initial case for a create-mode form for radio/checkbox inputs.

6. If $start = 3 and $min = 2 and $max = 7, this call must not render any elements on initial form render, but will allow up to FIVE (5) button clicks. 
This example captures the case for an edit-mode form for
radio/checkbox inputs that had exactly two items previously added, and now the user has returned to edit. Again, in this case, there should be no empty fields displayed unless the user clicks the "Add" button.

```
Then, here is the code I think we'd need in order to kick things off correctly:
```
    if ($min > $max) {
      return;
    }

    if ($start > $max) {
      return;
    }

    // Get the number of items in the form already.
    $num_items = $form_state->get($element_name . '_count');

    // If null, this is the first render.
    // We need to render at least $min items, and possibly
    // more if there are existing items.
    if ($num_items === NULL) {
      $num_items = max($min, $start- 1);
      $form_state->set($element_name . '_count', $num_items);
    }
```
created_at: 2025-04-29T21:07:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-29T19:02:52Z
body: Checking composer.lock changes...
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-29T21:48:35Z
body: Checking composer.lock changes...
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-30T17:32:14Z
body: Checking composer.lock changes...
author_association: NONE
user: login: va-cms-bot
created_at: 2025-04-30T18:06:13Z
body: <!-- Cypress Accessibility Errors -->
## Cypress Accessibility Violations


### `/test-data-sapiente`
**ID**: `button-name`
**Impact**: critical
**Tags**: `cat.name-role-value, wcag2a, wcag412, section508, section508.22.a, TTv5, TT6.a, EN-301-549, EN-9.4.1.2, ACT`
**Description**: Ensure buttons have discernible text
**Help**: [Buttons must have discernible text](https://dequeuniversity.com/rules/axe/4.10/button-name?application=axeAPI)
**Nodes**: 
- **HTML**: `<button class="proofing-element-help" role="tooltip" data-proofing-help-title="About 'Page introduction' field" data-proofing-help="Add an introduction that helps visitors understand if information on the page is relevant to them.">          <span aria-hidden="true">i</span>
        </button>`
  **Impact**: critical
  **Target**: `.field--name-field-intro-text-limited-html > .field__label > .proofing-element-help[role="tooltip"]`
  **Summary**: Fix any of the following:
  Element does not have inner text that is visible to screen readers
  aria-label attribute does not exist or is empty
  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
  Element has no title attribute
  Element does not have an implicit (wrapped) &lt;label&gt;
  Element does not have an explicit &lt;label&gt;
  Element's default semantics were not overridden with role=&#34;none&#34; or role=&#34;presentation&#34;


- **HTML**: `<button class="proofing-element-help" role="tooltip" data-proofing-help-title="About 'Generate a table of contents from major headings' field" data-proofing-help="By checking this box, all h2's below this point on the page will be linked with with anchor links. This helps users navigate content on very long pages. Do not check this box unless there is at least 2 h2's on the page.">`
  **Impact**: critical
  **Target**: `.field--name-field-table-of-contents-boolean > .field__label > .proofing-element-help[role="tooltip"]`
  **Summary**: Fix any of the following:
  Element does not have inner text that is visible to screen readers
  aria-label attribute does not exist or is empty
  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
  Element has no title attribute
  Element does not have an implicit (wrapped) &lt;label&gt;
  Element does not have an explicit &lt;label&gt;
  Element's default semantics were not overridden with role=&#34;none&#34; or role=&#34;presentation&#34;


- **HTML**: `<button class="proofing-element-help" role="tooltip" data-proofing-help-title="About 'Main content' field" data-proofing-help="The main body of the page, which appears below the featured content.">          <span aria-hidden="true">i</span>
        </button>`
  **Impact**: critical
  **Target**: `button[data-proofing-help-title="About 'Main content' field"]`
  **Summary**: Fix any of the following:
  Element does not have inner text that is visible to screen readers
  aria-label attribute does not exist or is empty
  aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty
  Element has no title attribute
  Element does not have an implicit (wrapped) &lt;label&gt;
  Element does not have an explicit &lt;label&gt;
  Element's default semantics were not overridden with role=&#34;none&#34; or role=&#34;presentation&#34;




  
author_association: COLLABORATOR