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">★</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 id
s here, which will be used by the components defined below.
Using id
s 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
alert
s 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.