department-of-veterans-affairs / vets-design-system-documentation

Repository for design.va.gov website
https://design.va.gov
36 stars 55 forks source link

Add list loop + summary pattern documentation #1697

Open Mottie opened 1 year ago

Mottie commented 1 year ago

Duplicate check

This update is for:

Pattern

What is the name?

List loop + summary pattern

What is the nature of this update?

What problem does this solve?

When our Supplemental Claim form was designed, we created a few new patterns, one of which is a variation on the add item list loop pattern in that the summary page is the main page and the add item page appears when adding a new entry. This pattern works from the add entry page and shows the summary after additions are made. This change required some additional challenging coding changes which should be documented

flowchart TD

Prev[Previous page] --> Loop
Loop[Add entry, build list] -. back .-> Prev

Loop -. add another entry .-> Loop

Loop -- continue --> Summary[Summary of all entries]
Summary -. edit indexed entry .-> Loop

Summary --> Next[Next page]

Additional Context


TOC:


VA Forms Library - How to add a custom array loop plus summary page

Before you get started, it’s helpful to know how to work with Array Data (aka-list-loops).


This array loop pattern allows you to add entries in a page loop followed by a summary page.

flowchart TD

Prev[Previous page] --> Loop
Loop[Add entry, build list] -. back .-> Prev

Loop -. add another entry .-> Loop

Loop -- continue --> Summary[Summary of all entries]
Summary -. edit indexed entry .-> Loop

Summary --> Next[Next page]

Notes:

  1. Navigation between pages is complicated, see the loop behavior section for more details.
  2. While navigating through a list of entries, the url will update the URL search parameter index (e.g. ?index=2)
  3. The save-in-progress component does not include a search parameter index, so using "finish this application later" and returning will always return to the first entry.
  4. Adding another entry will inject a new entry immediately after the current index. This fixes an issue where appending the new entry and jumping to the last index may leave invalid previous entries. These invalid entries only become apparent upon submitting the form.
  5. The page after the entry loop page (summary page in this flow), must control the back button destination so that the user navigates to the last entry - use a CustomPage with a custom back button to achieve this.

Setting up the form config

With this pattern, we're going to set up the config/form.js file with two pages. One for filling in array entries and the second showing a summary of all entries. This pattern is reverse of the flow described in How to use "Add item" link in Array Data where you do all the work from the summary page.

// when true, initial focus is on H3s by default, and enable the page's
// scrollAndFocusTarget (page selector string or function to scroll & focus)
// which allows you to customize the scroll & focus behavior
useCustomScrollAndFocus: true,

// Show links in generic validation error message on review & submit; it also
// uses definitions within reviewErrors to customize link text
// showReviewErrors: true,

// When required fields are present, adding an `_override` callback allows the
// review & submit accordions to display the error in the correct chapter/page
reviewErrors: {
  _override: () => null,
  /* ... */
},

chapters: {
  // ... other pages
  locations: {
    title: 'Locations',
    pages: {
      addLocation: {
        title: 'Add location',
        path: 'add-location',
        CustomPage: AddLocation,
        // Don't render anything on review & submit page
        CustomPageReview: null,
        uiSchema: {
          // needed if not using CustomPageReview
          'ui:options': { hideOnReview: true },
        },
        // Empty properties here; required properties included in summary page
        schema: { type: 'object', properties: {} },
        scrollAndFocusTarget: focusLocationPage,
      },
      locationSummary: {
        title: 'Summary of added locations',
        path: 'summary-locations',
        CustomPage: LocationSummary,
        // Summary page will appear on the review & submit page
        CustomPageReview: LocationSummaryReview,
        uiSchema: {
          locations: {
            items: {
              'ui:validations': [
                // include the validation rules for required properties!
              ]
            },
          },
        },
        schema: {
          type: 'object',
          properties: {
            locations: {
              type: 'array',
              items: {
                type: 'object',
                // add required properties so the error shows on review & submit
                required: [/* ... */],
                properties: { /* ... */ },
              },
            },
          },
        },
      },
    },
  },
},

There are a few important things going on here:

  1. CustomPage in the page's config renders a custom component.
  2. CustomPageReview in the page's config is for the review & submit page.
  3. The schema and uiSchema are mostly ignored, but
    • The uiSchema can include a 'ui:reviewField' that can take the place of the CustomPageReview; you'll likely have to deal with focus management in either case.
    • For required fields, a set of ui:validations rules must be included on the summary page, so that form submission validation checks are done prior to making the submit API call.
  4. Setting useCustomScrollAndFocus will require that all form pages include a unique H3. And, including scrollAndFocusTarget will add additional focus management behavior to a page; but only on initial render. See focus management section for more details.
  5. For required fields, add an _override to the reviewErrors so the accordions on the review & submit page show error states correctly.

List loop page

Loop header

The main page H3 should always be unique. You can use the numberToWords utility function ('platform/forms-system/src/js/utilities/data/numberToWords) to render headers with ordinal numbers ("first", "second", "fifth", etc). Even this may not be ideal for header text. Ideally, the header should include a key part of the data from the page, and still be unique across all entries.

When the modal appears while trying to navigate back with partial or invalid data, the title needs to also include part of the data from the page. We want screen reader users to know which page they are on when making a decision about keeping or removing the entry.

Loop navigation

We weren't able to figure out now to integrate routing to use indexed paths (/:index), which is available with showPagePerItem form config. The built-in list loop is set up to work with existing array data, so it won't build a new array on the fly.

We opted to set up routing within the page using a URL search parameter (e.g. ?index=2). When navigating to the loop page from the previous page, the undefined index parameter defaults to zero. We did try to use react-router links to pass data, but it seems like the form system interferes with the process, so the link data is lost. Using this index and the array, we are able to set up the desired routing behavior.

The navigation behavior is based on the state of the data:

Here is a summary of the behavior:

Data Forward Back Add another
All valid Next page Prev page New page (empty)
Empty Focus on error Prev page & remove Focus on error
Partial Focus on error Modal & Prev page Focus on error

Code example:

useEffect(
  () => {
    const entry = locations?.[currentIndex] || defaultData;
    setCurrentData(entry); // store current index data
    setAddOrEdit(getPageType(entry)); // page title to use "add" or "edit"
    setCurrentState(defaultState); // dirty fields, show modal & submitted flags
    focusLocationPage(); // control focus after page change
    setForceReload(false); // make sure this useEffect is called on new pages
    debounce(() => setIsBusy(false)); // prevent blur event call on new page
  },
  // don't include locations or we clear state & move focus every time
  // eslint-disable-next-line react-hooks/exhaustive-deps
  [currentIndex, forceReload],
);

const goToPageIndex = index => {
  setCurrentIndex(index); // useState has currentIndex
  setForceReload(true); // useState tied to useEffect
  goToPath(`${PATH}?index=${index}`);
};

const hasErrors = () => {
  // check data validations; return boolean
  return false;
}

handlers = {
  onGoForward: event => {
    event.preventDefault(); // continue button is "submit" type (usually)
    updateState({ submitted: true }); // set submit flag
    if (hasErrors()) {
      focusLocationPage(); // focus on first error
      return;
    }

    setIsBusy(true);
    const nextIndex = currentIndex + 1;
    if (currentIndex < locations.length - 1) {
      goToPageIndex(nextIndex);
    } else {
      // passing data is needed, including nextIndex for unit testing
      goForward(data, nextIndex);
    }
  },
  onGoBack: () => {
    // show modal if there are errors; don't show _immediately after_ adding
    // a new empty entry
    if (isEmptyEntry(currentData)) {
       const newLocations = [...locations];
       newLocations.splice(currentIndex, 1);
       setCurrentData(newData); // useState
       setFormData({ ...data, locations: newLocations }); // CustomPage prop
    } else if (hasErrors()) {
      updateState({ submitted: true, showModal: true });
      return;
    }

    setIsBusy(true);
    const prevIndex = currentIndex - 1;
    if (currentIndex > 0) {
      goToPageIndex(prevIndex);
    } else {
      // index only passed here for testing purposes
      goBack(prevIndex);
    }
  },
  onAddAnother: event => {
    event.preventDefault();
    if (hasErrors()) {
      // don't show modal
      updateState({ submitted: true });
      focusLocationPage(); // focus on first error
      return;
    }
    // clear state and insert a new entry after the current index (previously
    // added new entry to the end). This change prevents the situation where
    // an invalid entry in the middle of the array can get bypassed by adding
    // a new entry
    const newLocations = [...locations];
    if (!isEmptyEntry(locations[currentIndex + 1])) {
      // only insert a new entry if the existing entry isn't empty
      newLocations.splice(currentIndex + 1, 0, defaultData);
    }
    setFormData({ ...data, locations: newLocations }); // CustomPage prop
    goToPageIndex(currentIndex + 1);
  },
};

Loop modals

When the user tries to navigate back from a partially filled out entry (see the loop navigation section), a modal opens and asks if you want to keep or discard the current entry. This adds the ability to remove partial entries without waiting to get to the summary page. The summary page does allow editing and removing entries. If canceled, the focus must return to the back navigation button (the action that initiated the modal). If accepted, move focus to the unique page header (H3).

If there is a need to limit the number of entries, then show a modal after the Veteran uses the "add another entry" action link. The modal content should contain a message that reports the maximum number of entries has been reached, and include any followup action, e.g. remove one before adding another. Once the modal closes, focus moves back to the add another action link.

Loop validation

On the review & submit page, you'll only show the summary page. If using a CustomPage for the list loop, set the CustomPageReview to null. And, don't include any ui:validations rules on this page.

For required fields, you will need to add validation checks within this page. Use the same validation functions that would be used within ui:validation (since we're adding it to the summary page), and use a custom function (search the code base for checkValidations) to process form errors. And because we show validation errors after a field is blurred, keep track of the field dirty status (focused, then blurred), page submit status (you may or may not use the formContext for this), and the error state for each field.

Make sure to only show the error when a field is blurred. One accessibility issue we encountered was after loop page switching. Focus within a field would fire a blur event after (somehow delayed?) a navigation button was clicked, and the page index & content changed. To fix this, we set an isBusy state before the index change, and ignored any blur events when that state is true.

Loop focus management

Focus management is straight-forward here. Focus and scroll to the H3 on initial load and after switching pages, or focus and scroll to the first form error.

If using the useCustomScrollAndFocus feature, the initial focus will be handled by the scrollAndFocusTarget callback; but page updates and error handling will require using the same callback within the page component (within a useEffect) to control focus.

Focus callback example code

// _index is defined when `showPagePerItem` is set for an array list loop (used
// when the array already exists within the form data)
// The root parameter is for testing. Pass in the document or rendered container
export const focusLocationPage = (_index, root) => {
  // Include a delay to allow for render changes
  setTimeout(() => {
    const error = $('[error]', root);
    if (error) {
      scrollToFirstError();
      focusElement(error);
    } else {
      scrollTo('topPageElement');
      // Find first H3 within the main content, without the #main, this could
      // target the footer headers
      focusElement('#main h3', null, root);
    }
  });
};

In the page component, call the focus callback:

Summary page

Summary headers

If including more than one H3 on the page, make sure to check the review & submit page heading levels because that page renders the summary inside an accordion with an H3 header. The page title (in form config) will be an H4 with an edit button. After clicking on the edit button, the H4 disappears! We worked around this issue by rendering the same H4 seen in the review & submit page header on the summary page, but make sure to adjust the heading level.

const Header = onReviewPage ? 'h4' : 'h3';

return (
  <div>
    // ...
    <Header className="vads-u-font-size--h3 vads-u-margin--0">
      Summary Page Title
    </Header>
  </div>
)

Summary navigation

It is important that this page alters the back button behavior to return the user to the last entry index. Doing this allows the user to navigate back through all the entries. The save-in-progress platform component doesn't maintain the location search (?index=) parameter, so the user is always returned to the first loop entry when returning to the loop page after a save.

const handlers = {
  onGoBack: () => {
    const index = Math.min(0, data.locations.length - 1);
    // go to last location entry
    goToPath(`/${PATH}?index=${index}`);
  },
  onGoForward: () => {
    if (hasErrors()) {
      setError('Some error');
      scrollToFirstError();
    } else {
      setError(null);
      goForward(data);
    }
  },
};

When editing an entry (event on the review & submit page), we opted to always return the user to the indexed page within the form flow (using React router Link). This does require them to step through the rest of the form to get back to the review & submit page. Doing this ensures that the form remains valid.

If you determine that a better user experience would be to return the user to the review & submit page after editing, then change the navigation buttons to "update" and "cancel" and jump back. In this case, you may need to use session storage to save a flag that the edit occurred on the review & submit page (examine Supplemental Claim's EditContactInfo component for example code)

Removing an entry requires a modal. See the "Summary modal" section for more detail.

Summary modal

When removing an item, the destructive action needs a confirmation step or screen reader feedback (per collaboration cycle review), before removing the entry. We opted to use a modal.

After choosing a modal action, focus management is important:

Summary validation

On the review & submit page, you'll only want to show the summary page. Make sure to include the schema and ui:validations rules for all the data. Doing this will prevent form submission with missing or invalid data, should related data get modified on the review & submit page. For example, in form 0995 (Supplemental Claims), the list loop pages depends on issues selected earlier in the form flow. If the issues get modified on the review & submit page, the changes may alter the list loop data; and any error states within the list loop data must show in the correct accordion.

To prevent submission of missing or invalid data, add ui:validations rules to the summary page. To ensure the accordion error is shown on the correct chapter, add an _override callback function to the reviewErrors form config. This function needs to check the error message, and return the appropriate chapter and page key of the summary page (these keys match the config/form object keys for the chapter & page).

// add to config/form
reviewErrors: {
  _override: err => {
    if (typeof err === 'string' && err.startsWith('locations[')) {
      return { chapterKey: 'evidence', pageKey: 'evidenceSummary' };
    }
    return null;
  },
},

Other validations checks you might need to include:

Summary focus management

If using the useCustomScrollAndFocus config setting, initial focus will be on the unique H3

The only other focus management that needs to be address is with the remove entry modal (see Summary modal section). After the remove button is activated, a modal appears asking if you want to keep or remove the entry:

Use the modal onPrimaryButtonClick or onSecondaryButtonClick callback to apply the focus

Summary review page

Summary review headers

On the review & submit page, the CustomPageReview is rendered in "review" mode (non-edit mode). Once the edit button is activated, the CustomPage is rendered, and after the page is updated, we return back to "review" mode (CustomPageReview). At this point, we must focus on the edit button again.

Here's an example we used for the header (see Summary headers to learn why) and the edit button with a reference:

return (
  <div className="form-review-panel-page">
    <div name="evidenceSummaryScrollElement" />
    <div className="form-review-panel-page-header-row">
      <h4 className="form-review-panel-page-header vads-u-font-size--h5">
        Summary Page Title
      </h4>
      <button
        type="button"
        ref={editRef}
        className="edit-page usa-button-secondary"
        onClick={onEditPage}
        aria-label={content.editLabel}
      >
        {content.edit}
      </button>
    </div>
    // ....
  </div>
);

Summary review focus management

To set up the focus on the edit button after editing, save the button edit click to session storage then check it within a useEffect so you know to move focus to the edit button

const editRef = useRef(null);

useEffect(
  () => {
    if (
      window.sessionStorage.getItem('editing') === 'true' &&
      editRef?.current
    ) {
      // focus on edit button _after_ editing and returning
      window.sessionStorage.removeItem('editing');
      // this setTimeout is added (maybe not needed?) to get around the h2 focus
      // on the review & submit page
      setTimeout(() => focusElement(editRef.current));
    }
  },
  [editRef],
);

const onEditPage = () => {
  // maintain state using session storage
  window.sessionStorage.setItem('editing', 'true');
  props.editPage();
};

Testing

Events & callback testing

Currently testing-library React does not support querying elements in the shadow DOM. Inside of the web components, an __events object is available and contains the available callback functions to use for testing.

Here are some examples on how to use these methods:

// va-modal
document.querySelector('va-modal').__events.closeEvent();
document.querySelector('va-modal').__events.primaryButtonClick();
document.querySelector('va-modal').__events.primaryButtonClick();

// va-button-pair may need an event object
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
document.querySelector('va-button-pair').__events.primaryClick(clickEvent)
document.querySelector('va-button-pair').__events.secondaryClick(clickEvent)

In this example, we need to test the page after an input is blurred. Within the component code, we use the field name to determine which field was blurred, but passing in a target proves to be difficult, so we add optional chaining to the target and fallback on the event detail

// CustomPage onBlur handler
const fieldName = event.target?.getAttribute('name') || event.detail;

Then in the unit test, we create an event with the field name in the detail:

// va-memorable-date blur event testing
const blurFromEvent = new CustomEvent('blur', { detail: 'from' });
document.querySelector('va-memorable-date').__events.dateBlur(blurFromEvent);

Focus testing

If using testing-library/react, querying elements inside the shadow DOM isn't possible, so you can either skip these tests, or use the enzyme library to test the focus.

caw310 commented 1 year ago

@Mottie , I know you are a regular DSC attendee. :) But want to check that you are available Friday, April 28 or next Friday, May 5? We might have a meeting this Friday.

Mottie commented 1 year ago

I'm on PTO this Friday, but I plan on being at the meeting after.

caw310 commented 1 year ago

Follow up actions from DSC discussion on 5/5.