XGovFormBuilder / digital-form-builder

Exploring how to quickly and easily design/prototype/deploy high quality digital forms for UK Gov. Based on the excellent work by DEFRA. Currently maintained by jen+forms@cautionyourblast.com at Caution Your Blast and a community collaboration between FCDO, HO, GDS, DfE, DIT, Version 1, UKHSA
https://digital-form-builder-designer.herokuapp.com/app
MIT License
54 stars 33 forks source link

Add back conditionally revealed components #1251

Open jenbutongit opened 1 month ago

jenbutongit commented 1 month ago

Is your feature request related to a problem? Please describe.

Conditional components were previously removed because it was reported to be an accessibility issue. GDS and W3C have since confirmed that conditional components are OK as long as the revealed field is "simple" and there is only one field revealed.

Describe the solution you'd like Add support for conditional components

Describe alternatives you've considered

Additional context

jenbutongit commented 1 month ago

IIRC, the conditional components were loaded in via the list property which was confusing. It also meant that lists couldn't be reused.

It might be a better idea to make this a part of the component definition.

{
  "startPage": "/start",
  "pages": [
    {
      "path": "/start",
      "title": "Start",
      "components": [
        {
          "type": "RadiosField",
          "title": "How would you prefer to be contacted?",
          "list": "contactTypes"
          "options": {
            "conditionallyRevealedComponents": {
              "email": {
                "type": "EmailAddressField",
                "name": "email",
                "title": "Your email address",
                "options": {},
                "schema": {}
                },
              "phone": {
                "type": "TelephoneNumberField",
                "name": "phoneNumber",
                "title": "Your phone number",
                "options": {},
                "schema": {}
              }
            }
          },
          "schema": {}
        }
      ],
      "next": []
    }
  ],
  "lists": [
    {
      "name": "contactTypes",
      "title": "Contact Types",
      "type": "string",
      "items": [
        {
          "text": "Email",
          "value": "email"
        },
        {
          "text": "Phone",
          "value": "phone"
        }
      ]
    }
  ],
  "sections": [
  ],
  "phaseBanner": {},
  "fees": [],
  "payApiKey": "",
  "outputs": [
  ],
  "declaration": "",
  "version": 2,
  "conditions": []
}

where conditionallyRevealedComponents is an object. The key, or property name, must match the list value.

In the above example, the list values are email and phone, so those must be the keys.

"conditionallyRevealedComponents": {
  "email": {}
  "phone": {}
}

The values must be component definitions, but the only allowed components should be Input text fields, specifically TextField, NumberField, EmailAddressField, TelephoneNumberField. Date fields are also allowed, but that might be somewhat complex to implement. Worth a try, if not it can be added later down the line.

Functionality was removed in this PR: https://github.com/XGovFormBuilder/digital-form-builder/pull/549/files#diff-3c71b7c94d2f3869938661b678fe09f906e8b8cca086b1000ab46abc82480541

It might be more "proper" (according to Joi functionality) to use joi's any.alter, and joi's any.tailor. It may also be useful to use joi's reference/relative selectors rather than iterating through the schema keys. https://joi.dev/api/?v=17.13.0#refkey-options

The general gist for implementation would be

  1. In SelectionControlField's constructor, first check if the component has anything defined in options.conditionallyRevealedComponents.
  2. Create these components and store these fields internally, e.g this.conditionallyRevealedComponents
    1. It might also be helpful to create a helper flag, e.g. this.hasConditionallyRevealedComponents
  3. in getViewModel, check if this.hasConditionallyRevealedComponents, if so, render the html and insert it into the viewModel.
emilyjevans commented 1 month ago

@jenbutongit Here's what I have so far on the SelectionControlField. The joi validation doesn't seem to work for email and text field here but I can't understand why they'd be different. Unfortunately getting slightly different behaviour on our forked repo which is out of sync, where the validation does work 🤔

I can't find many examples online of using joi.alter and joi.tailor online so having trouble envisaging what that would look like - do you have any more examples or docs on that?

import joi from "joi";
import nunjucks from "nunjucks";
import { ListFormComponent } from "server/plugins/engine/components/ListFormComponent";
import { FormData, FormSubmissionErrors } from "server/plugins/engine/types";
import { ListItem, ViewModel } from "server/plugins/engine/components/types";
import { ComponentCollection } from "./ComponentCollection";

/**
 * "Selection controls" are checkboxes and radios (and switches), as per Material UI nomenclature.
 */

const getSchemaKeys = Symbol("getSchemaKeys");
export class SelectionControlField extends ListFormComponent {
  conditionallyRevealedComponents?: any;
  hasConditionallyRevealedComponents: boolean = false;

  constructor(def, model) {
    super(def, model);
    const { options } = def;

    const { items } = this;

    if (options.conditionallyRevealedComponents) {
      this.conditionallyRevealedComponents =
        options.conditionallyRevealedComponents;

      items.map((item: any) => {
        if (this.conditionallyRevealedComponents![item.value]) {
          item.hasConditionallyRevealedComponents = true;
          item.conditionallyRevealedComponents = new ComponentCollection(
            [this.conditionallyRevealedComponents![item.value]],
            item.model
          );
        }
      });
    }
  }

  getViewModel(formData: FormData, errors: FormSubmissionErrors) {
    const { name, items } = this;
    const options: any = this.options;
    const viewModel: ViewModel = super.getViewModel(formData, errors);

    viewModel.fieldset = {
      legend: viewModel.label,
    };

    viewModel.items = items.map((item: any) => {
      const itemModel: ListItem = {
        text: item.text,
        value: item.value,
        checked: `${item.value}` === `${formData[name]}`,
      };

      if (options.bold) {
        itemModel.label = {
          classes: "govuk-label--s",
        };
      }

      if (item.description) {
        itemModel.hint = {
          html: this.localisedString(item.description),
        };
      }

      if (options.conditionallyRevealedComponents[item.value]) {
        // The gov.uk design system Nunjucks examples for conditional reveal reference variables from macros. There does not appear to
        // to be a way to do this in JavaScript. As such, render the conditional components with Nunjucks before the main view is rendered.
        // The conditional html tag used by the gov.uk design system macro will reference HTML rarther than one or more additional
        // gov.uk design system macros.

        itemModel.conditional = {
          html: nunjucks.render(
            "../views/partials/conditional-components.html",
            {
              components: item.conditionallyRevealedComponents.getViewModel(
                formData,
                errors
              ),
            }
          ),
        };
      }

      return itemModel;
    });

    return viewModel;
  }

  getStateSchemaKeys() {
    return this[getSchemaKeys]("state");
  }

  getFormSchemaKeys() {
    return this[getSchemaKeys]("form");
  }

  [getSchemaKeys](schemaType) {
    const schemaName = `${schemaType}Schema`;
    const schemaKeysFunctionName = `get${schemaType
      .substring(0, 1)
      .toUpperCase()}${schemaType.substring(1)}SchemaKeys`;
    const filteredItems = this.items.filter(
      (item: any) => item.hasConditionallyRevealedComponents
    );
    const conditionalName = this.name;
    const schemaKeys = { [conditionalName]: this[schemaName] };
    // const schema = this[schemaName];
    // All conditional component values are submitted regardless of their visibilty.
    // As such create Joi validation rules such that:
    // a) When a conditional component is visible it is required.
    // b) When a conditional component is not visible it is optional.
    filteredItems?.forEach((item: any) => {
      const conditionalSchemaKeys = item.conditionallyRevealedComponents[
        schemaKeysFunctionName
      ]();
      // Iterate through the set of components handled by conditional reveal adding Joi validation rules
      // based on whether or not the component controlling the conditional reveal is selected.
      Object.keys(conditionalSchemaKeys).forEach((key) => {
        Object.assign(schemaKeys, {
          [key]: joi.alternatives().conditional(joi.ref(conditionalName), {
            is: key,
            then: conditionalSchemaKeys[key],
            otherwise: joi.optional(),
            // TODO: modify for checkboxes
          }),
        });
      });
    });
    return schemaKeys;
  }
}
jenbutongit commented 1 month ago

Sorry! I was misremembering. It should be https://joi.dev/api/?v=17.13.0#anyforkpaths-adjuster. After initialising the component's schema, you'd then want to alter it to accept the new fields. I've done this in RepeatingFieldPageController