Forms
Table of contents

Custom fields

Custom fields allow you to create components that implement specific logic or UI using JavaScript code.

With this feature you can easily pass data around, make client and server-side validations and handle common events like focus or blur. Some use cases for using custom fields could be:

  • Fields with a custom data structure (e.g. objects, array of strings...).
  • Fields that use 3rd party widgets (e.g. Google Address autocomplete).
  • Fields with logic to hide/show other fields.
  • Fields which require external APIs to get a value.

Getting started

Let's start imagining that we want to implement a text input that allows the user to type an IBAN account number, with the following features:

  • Length validation: 16-34 characters.
  • Country validation: the IBAN must begin with the country code that the user selected in a previous step.
  • Input placeholder: it will start with the selected country code so the user is hinted on the correct format.
  • Removal of spaces.
  • Tokenization of the account number through an external API.

We will be checking only the length and the country of the account number. In the real world you should probably check the validity of the number itself with the available algorithms, but that's a bit out of scope for this introduction.

Let's start with the minimal implementation: in the "source code" field we have to type a function that returns an object with some specific methods, like so:

function myCustomField(context) {
  const ibanInput = document.createElement('input');

  return {
    init() {
      return ibanInput;
    },

    block() {
      ibanInput.disabled = true;
    },

    unblock() {
      ibanInput.disabled = false;
    },

    getValue() {
      return ibanInput.value.replaceAll(' ', '');
    },
  };
}

These methods are "hooks" that get called at different times in the lifecycle of the component that your custom field implements. They get called one or more times, in any order, depending on the structure of your form and the way the user interacts with it.

We won't go through all of the available methods now, but in this example you can see we implemented these 4:

  • init(): called once, when the field is created.
  • block(): called when the field must be blocked from all input. This happens when the SDK is gathering all the data to send it to Arengu, for example. Since the SDK doesn't know how your field does this, it relies in your implementation to do so.
  • unblock(): same as block(), but for unblocking.
  • getValue(): called when the SDK needs the value of your field. This can be used to send it to Arengu, to emit DOM events or to make validations.

Let's now implement the placeholder to hint the user on the expected IBAN format.

Parameters

Inside a custom field code you don't have access to all the {{references}} like in the rest of Arengu. In order to make some external value available inside, you have to use the "Params" section inside the custom field settings.

In this case, just before our custom field there was be a previous step where the user selected their country in a dropdown field, and its identifier was countryCode. Thus we can make available  that reference inside the custom field, adding the param countryCode as a reference to {{fields.countryCode}}:

Custom field "params" configuration pointing countryCode to {{fields.countryCode}}

We then would access the parameter using {{params.countryCode}} like so:

function myCustomField(context) {
  const ibanInput = document.createElement('input');

+ function updatePlaceholder() {
+   const params = context.custom.getParams();
+
+   ibanIinput.placeholder = `${params.countryCode}XX...`;
+ }

  return {
    init() {
+     updatePlaceholder();

      return ibanInput;
    },

    block() {
      ibanInput.disabled = true;
    },

    unblock() {
      ibanInput.disabled = false;
    },

    getValue() {
      return ibanInput.value.replaceAll(' ', ''); // remove whitespaces
    },
  };
}

This code works but it has a problem: since init() is called only once, if params.countryCode changes in a subsequent render of the field (for example, because the user went back and selected a different country), we will not be showing the updated placeholder text!

Luckily we can make it work properly implementing the update() method, that will be called as soon as there's a change in the parameters:

function myCustomField(context) {
  const ibanInput = document.createElement('input');

  function updatePlaceholder() {
    const params = context.custom.getParams();

    ibanInput.placeholder = `${params.countryCode}XX...`;
  }

  return {
    init() {
      updatePlaceholder();

      return ibanInput;
    },

    block() {
      ibanInput.disabled = true;
    },

    unblock() {
      ibanInput.disabled = false;
    },

    getValue() {
      return ibanInput.value.replaceAll(' ', ''); // remove whitespaces
    },

+   update() {
+     updatePlaceholder();
+   },
  };
}

Validation

We can now add some validation to the field.

Mind that this should be some light, client-side validation, in order to improve the experience for the user: if you want to be sure that your form receives the correct data shape, you must use the JSON schema feature, which validates the data server-side, and thus cannot be tampered with.

But for our case, it's useful to quickly check client-side the length or the country of the IBAN, for example.

Let's change the getValue() method a bit:

function myCustomField(context) {
  const ibanInput = document.createElement('input');

  function updatePlaceholder() {
    const params = context.custom.getParams();

    ibanInput.placeholder = `${params.countryCode}XX...`;
  }

  return {
    init() {
      updatePlaceholder();

      return ibanInput;
    },

    block() {
      ibanInput.disabled = true;
    },

    unblock() {
      ibanInput.disabled = false;
    },

    getValue() {
-     return ibanInput.value.replaceAll(' ', ''); // remove whitespaces
+     const value = ibanInput.value.replaceAll(' ', '');
+     const params = context.custom.getParams();
+
+     if (!value.startsWith(params.countryCode)) {
+       throw new Error(`Invalid country code, ${params.countryCode} was expected.`);
+     }
+
+     if (value.length < 16) {
+       throw new Error('This IBAN is too short');
+     }
+
+     if (value.length > 34) {
+       throw new Error('This IBAN is too long');
+     }
+
+     return value;
    },

    update() {
      updatePlaceholder();
    },
  };
}

Note that any Error that is thrown inside the getValue() method will be shown as a validation error below your custom field, just in like a normal Arengu form field.

Asynchronous values

Let's suppose that for security reasons we don't want to receive the real IBAN number, but we want to delegate and send it to an external API that tokenizes it for us. That way Arengu only receives the tokenized value instead.

This call to an external API presents us with a problem: it's asynchronous, so we don't know how much will it take, and in the meantime we don't want the user to think something is broken because of the lack of visual feedback.

But it can be fixed easily: if your implementation of the getValue() returns a Promise, the Arengu SDK will wait for it to resolve, showing the user a spinner in any submit buttons in order to indicate progress.

Make these changes in your code:

function myCustomField(context) {
  const ibanInput = document.createElement('input');

  function updatePlaceholder() {
    const params = context.custom.getParams();

    ibanInput.placeholder = `${params.countryCode}XX...`;
  }

+ function tokenizeIban(value) {
+   // this example API returns an object like so: { accountToken: 'abcdef...' }
+   return fetch(
+     'https://www.example.com/api/tokenizeIban',
+     { method: 'POST', body: new URLSearchParams({ iban: value }) },
+   ).then((res) => res.json());
+  }

  return {
    init() {
      updatePlaceholder();

      return ibanInput;
    },

    block() {
      ibanInput.disabled = true;
    },

    unblock() {
      ibanInput.disabled = false;
    },

-   getValue() {
+   async getValue() {
      const value = ibanInput.value.replaceAll(' ', '');
      const params = context.custom.getParams();

      if (!value.startsWith(params.countryCode)) {
        throw new Error(`Invalid country code, ${params.countryCode} was expected.`);
      }

      if (value.length < 16) {
        throw new Error('This IBAN is too short');
      }

      if (value.length > 34) {
        throw new Error('This IBAN is too long');
      }

-     return value;
+     try {
+       // wait until response is received
+       const result = await tokenizeIban(ibanValue);
+
+       return result.accountToken;
+     } catch (err) {
+       throw new Error('Error tokenizing account');
+     }
    },

    update() {
      updatePlaceholder();
    },
  };
}

Final result

For your convenience, here is the complete source code for the custom field that we just implemented:

function myCustomField(context) {
  const ibanInput = document.createElement('input');

  function updatePlaceholder() {
    const params = context.custom.getParams();

    ibanInput.placeholder = `${params.countryCode}XX...`;
  }

  function tokenizeIban(value) {
    // this example API returns an object like so: { accountToken: 'abcdef...' }
    return fetch(
      'https://www.example.com/api/tokenizeIban',
      { method: 'POST', body: new URLSearchParams({ iban: value }) },
    ).then((res) => res.json());
   }

  return {
    init() {
      updatePlaceholder();

      return ibanInput;
    },

    block() {
      ibanInput.disabled = true;
    },

    unblock() {
      ibanInput.disabled = false;
    },

    async getValue() {
      const value = ibanInput.value.replaceAll(' ', '');
      const params = context.custom.getParams();

      if (!value.startsWith(params.countryCode)) {
        throw new Error(`Invalid country code, ${params.countryCode} was expected.`);
      }

      if (value.length < 16) {
        throw new Error('This IBAN is too short');
      }

      if (value.length > 34) {
        throw new Error('This IBAN is too long');
      }

      try {
        // wait until response is received
        const result = await tokenizeIban(ibanValue);

        return result.accountToken;
      } catch(err) {
        throw new Error('Error tokenizing account');
      }
    },

    update() {
      updatePlaceholder();
    },
  };
}