Core Functionality

Interface

In this guide we make use of JavaScript classes to model components for the four data types: string, date, phone and id_number. We use the string implementation, StringInput, as a basis for the other types, which will extend that class in the later chapters of this guide.

HTML

To begin, we'll introduce the HTML for an instance of the StringInput class, which we'll use for taking the user's first name:

<div class="field string">
<span class="required" id="first-name-required">&starf;</span>
<label for="first-name">First</label>
<input type="text" id="first-name" name="first-name" />
<span class="status" id="first-name-status"></span>
<span class="issue" id="first-name-issue"></span>
<input type="submit" hidden />
</div>

Note that we include a hidden submit button to allow the parent form to be submitted when the user types Enter in the input field.

Note the use of ids here, which will be used by the components defined below. Using ids in this way will generally be unnecessary when using a component library, which will often make use of two-way bindings or other types of references to access the elements of a component.

StringInput

Constructor

We instantiate component by getting references to all of the elements that the component will use.

class StringInput {
constructor(id, remoteState) {
this.remoteState = remoteState;
this.inputEl = document.getElementById(id);
this.requiredEl = document.getElementById(id + "-required");
this.statusEl = document.getElementById(id + '-status');
this.issueEl = document.getElementById(id + '-issue');
this.inputEls = [this.inputEl];
}
}

Note the definition of this.inputEls. This property contains references to all of the input elements for the component. In the case of the string data type this will only be one element, but for data types like date this will be multiple elements.

Finally, remoteState will contain the latest data for this field that was retrieved from the API.

The logic of the constructor is actually split into a second setup() method. This will be necessary in a later section of this guide, but for now we simply use it to set the initial value and layout of the component, based on the latest API values retrieved from the API:

class StringInput {
// ...
setup() {
this.update(this.remoteState);
}
update(remoteState) {}
}

Rendering

The rendering of the component will primarily be handled by two methods, update and refresh. update is used to update the state of the component based on the latest state returned from the API. refresh is used to update the UI of the component based on its state:

class StringInput {
// ...
// `update` updates the UI of this field based on its new remote value.
update(remoteState) {
this.remoteState = remoteState;
if (this.remoteState.required_now) {
this.showElement(this.requiredEl);
} else {
this.hideElement(this.requiredEl);
}
this.reset();
this.refresh(true);
}
// `reset` resets the input of this field to its remote value.
reset() {
this.inputEl.value = this.remoteState.value;
}
showElement(el) {
el.classList.remove('hidden');
}
hideElement(el) {
el.classList.add('hidden');
}
// `refresh` updates the UI of this field based on its current and remote
// value.
//
// Passing `false` for `highlightErrors` allows us to avoid highlighting
// errors until the user has finished editing the field.
refresh(highlightErrors) {
this.statusEl.innerText = statusSymbols[this.remoteState.status];
}
}

Later sections of this guide will update this section to add error highlighting and other improvements to the user experience for this component.

Note the use of innerText when setting the content of the status field. A big security benefit of using modern frontend frameworks, especially in a JavaScript context, is that updates to elements are performed safely so that XSS attacks are prevented. Even though we have control over the text in this case, we avoid using innerHTML as a practice to reduce the scope for XSS attacks.

const statusSymbols = {
"set": symbols.TICK,
"unset": symbols.NBSP,
"invalid": symbols.CROSS,
"verifying": symbols.CLOCK,
"verified_and_verifying": symbols.CLOCK,
"verified": symbols.TICK,
};
const symbols = {
CLOCK: '\u{01f551}',
CROSS: '\u2715',
NBSP: '\u00a0',
TICK: '\u2713',
};

A simple CSS rule for the hidden class can be the following:

.field .hidden {
visibility: hidden;
}

This approach doesn't remove the element from its place in the way that display: none does. Instead, the element keeps its place, so that toggling its visibility will not move the position of the elements surrounding it.

Value retrieval

Here we add a validatedValue method that is used to retrieve the current value of the component in the format that the API expects. For the string data type the value is straightforward to retrieve, because it's just the value of the single input field. For multi-input fields like date, validatedValue will be responsible for combining and rendering the input values to generate the appropriate value for the API.

class StringInput {
// ...
validatedValue() {
const remoteValue = this.remoteState.value;
const value = this.inputEl.value;
if (value === remoteValue) {
return {isUnchanged: true};
}
if (value.length === 0) {
if (this.remoteState.validation.cannot_unset) {
return {error: `this value can't be unset`};
}
return {value};
}
const validation = this.remoteState.validation;
if (validation.min_length && value.length < validation.min_length) {
return {error: `must be at least ${validation.min_length} characters`};
}
return {value};
}
}

validatedValue returns an object in one of 3 forms:

  • {isUnchanged: bool} indicates that the value of the component is the same as the latest value returned from the API. It will be skipped when calculating the request to send to the API.
  • {error: string} indicates a validation error encountered with the current value of the component. If such an error is encountered then we prevent submitting an update to the API, as requests containing validation errors won't be saved.
  • {value: any} indicates that the value is valid, and different to the latest value returned from the API. This will be included when calculating the request to send to the API.

Disabling the component

Finally, we add a method for enabling and disabling the component elements while an update request is being processed:

class StringInput {
// ...
setEnabled(enabled) {
for (const inputEl of this.inputEls) {
inputEl.disabled = !enabled;
}
}
}

Note the use of this.inputEls here, to abstract over the input elements. Using this, we don't need to re-implement the setEnabled method for the classes that extend StringInput.

NOTE The definition of this.inputEls in this manner effectively makes this class aware of the classes that extend it, which goes against code cleanliness principles. We take this approach here to simplify this guide and its code, but it is worth considering a more decoupled approach when adapting this code to a production context.

Using StringInput

With the code presented so far we can now present the remaining code of the payout details page, which ties the presented pieces together. We start with a main method, which will perform a call to the API to retrieve the initial remote data. This is then used to construct the components, which we'll store in fields. Note that we don't discuss the authentication of the calls to the API, which are covered in another guide.

NOTE We make use of alerts for error handling in this guide. This is not recommended for a user-facing implementation, and so should ideally be replaced with a more appropriate mechanism in a production context.

const fields = {};
async function main() {
const resp = await fetch(
"https://www.stage.trustap.com/api/v1/me/personal/details",
{
// Authentication details.
}
);
if (resp.status !== 200) {
const body = await resp.json();
alert("couldn't get payout details: " + JSON.stringify(body));
return;
}
const payoutDetails = await resp.json();
for (const [id, key] of Object.entries(payoutDetailFieldIds)) {
const field = new StringInput(id, payoutDetails[key]);
field.setup();
fields[key] = field;
}
document.getElementById('loading').style.display = "none";
document.getElementById('form').style.display = "block";
}
const payoutDetailFieldIds = {
'first-name': 'name_first',
'last-name': 'name_last',
'address-line1': 'address_line1',
'address-line2': 'address_line2',
'address-city': 'address_city',
'address-postal-code': 'address_postal_code',
'address-state': 'address_state',
};

With this code for initialising the form in place, we now move on to handling the event where the form gets submitted, and define the main layout of our demo page:

<div id="main">
<div id="loading">
Loading
</div>
<form id="form" style="display: none" onsubmit="onSubmit(event)">
<!--
We populate the form using a copy of the HTML presented in the
"HTML" section, above, for each string field we want to capture.
-->
</form>
<script>
async function main() {
// ...
}
async function onSubmit(e) {
// ...
}
// ...
<script>
</div>
async function onSubmit(e) {
e.preventDefault();
const updates = {};
for (const [key, field] of Object.entries(fields)) {
const result = field.validatedValue();
if (result.error) {
alert(`'${key}' is invalid: ${result.error}`);
return;
} else if (result.isUnchanged) {
continue;
}
updates[key] = result.value;
}
if (Object.keys(updates).length === 0) {
alert('No Changes');
return;
}
alert('Saving...');
for (const field of Object.values(fields)) {
field.setEnabled(false);
}
const resp = await fetch(
"/api/me/personal_details",
{
method: "PATCH",
// Authentication details.
body: JSON.stringify(updates),
},
);
if (resp.status !== 200) {
const body = await resp.json();
alert("couldn't get payout details: " + JSON.stringify(body));
return;
}
const payoutDetails = await resp.json();
for (const [key, field] of Object.entries(fields)) {
field.update(payoutDetails[key]);
}
alert('Saved!');
for (const field of Object.values(fields)) {
field.setEnabled(true);
}
}

This should provide enough functionality to gather all string-type fields, and provide a basic UX to the user.