Mixing lightning-input-fields with custom data aware fields
Salesforce lightning offers a developer various ways to design custom forms when page layouts are not enough. The record-edit-form strikes a nice balance: it uses Lightning data service and allows one to design your own layout and field selection.
Beyond lightning-input-fields
Most of the time lightning-input-field is all you need for this forms. They auto-magically talk to the UI API and display the right input type.
However there are cases, where that's not what your users want. A recent example from a project: Phone numbers are stored as text field in Salesforce, but the users wanted a guided input: a country picker, then showing the area code picker (if the country has those) and an checker for field length for the main number (which varies greatly by country) and an eventual extension field (popular in the US, but not elsewhere).
So I started digging. Shouldn't it be possible to have something like <c-phone-helper field-name="Phone" />
and the same data magic as for lightning-input-field
would happen? Turns out: not so fast. With events and a little code it would be possible, but that glue code needed to be applied to any custom field.
This got me thinking. The solution, it turns out, was to "extend" the record-edit-form
to handle "rough" input components. You can give the result a try in experiment 8
Design goals
- The component should be a drop-in "replacement" for
record-edit-form
- Structure of a page should be similar to they way one builds
record-edit-form
based forms - All
lightning-input-fields
should work out of the box - No additional glue code should be required in the component hosting the new form
- Custom input field types should be easy to build. Once I figure out extensions, based on a base component
- Opinionated: form layout is using a lightning-layout
Results
The replacement for lightning-record-form
is c-extended-form (from experiment 8).
"Replacement" is a mouth-full, since the component just wraps around a lightning-record-form
. A few components are ready to be used for it:
- specialInput a little test component. It just returns the input in upper case. Not very useful other than studying the boiler plate
- uxDebouncedInput returns changed values after a debounce period. Default is 300ms, the attribute
delay
allows to specify duration. The component shows different behavior depending on the attributefield-name
being present with a value. The original purpose of the field is to be used in uxQuickLookup, now you can use it standalone - uxQickLookup which allows you to lookup an object. It works in lightning apps, mobile and communities and can serve as a stop-gap for the missing lookup on mobile. I recently updated it to show additional fields beside the object name
How it works
A sample form looks like this:
<template>
<lightning-card title="Form Sample">
<div class="slds-p-horizontal_small">
<c-extended-form object-api-name="Account">
<lightning-layout-item size="6" padding="around-medium">
<lightning-input-field
field-name="Name"
></lightning-input-field>
</lightning-layout-item>
<lightning-layout-item size="6" padding="around-medium"
><lightning-input-field
field-name="AccountSource"
></lightning-input-field
></lightning-layout-item>
<lightning-layout-item size="6" padding="around-medium"
><lightning-input-field
field-name="AccountNumber"
></lightning-input-field
></lightning-layout-item>
<lightning-layout-item size="6" padding="around-medium"
><c-special-input
field-name="Phone"
data-field
></c-special-input>
</lightning-layout-item>
</c-extended-form>
</div>
</lightning-card>
</template>
The extended form supports the same properties as lightning-input-form
and follows the same structure. You might miss the submit button and the lightning-messages
elements. They are already contained inside the extended form, as it the lightning-layout
.
The above sample shows the setup for new Account objects, since the record-id
attribute is missing. The form itself is quite light, the magic is in the handler functions:
<template>
<lightning-record-edit-form
record-type-id={recordTypeId}
record-id={recordId}
object-api-name={objectApiName}
onload={formLoadHandler}
onsubmit={formSubmitHandler}
onsuccess={formSuccessHandler}
>
<lightning-layout multiple-rows="true">
<lightning-layout-item size="12">
<lightning-messages> </lightning-messages>
</lightning-layout-item>
<slot></slot>
<lightning-button
class="slds-m-top_small"
variant="brand"
type="submit"
name="save"
label="Save"
>
</lightning-button>
</lightning-layout>
</lightning-record-edit-form>
</template>
There are just 6 elements in the form:
- lightning-record-edit-form: doing the heavy lifting to connect to the lightning data service
lightning-messages
to show error messages- lightning-layout and lightning-layout-item to provide structure
- lightning-button to submit the form
- slot The place holder for the component content. In Aura that has been
{!v.body}
. A component can have multiple slots, which then need to be named and their content need to be tagged with aslot
attribute. In the case of one unnamed slot, it is the destination of anything inside the component.<slot></slot>
is part of the official Web Components standard
Building custom input fields
When you want to build a custom input field, there are only a few requirements
- Have a
@api fieldName
property for the field name you want to update - Have a
@api value
property for receiving the current value - Fire a Custom Event with the name
valueChanged
and a detail object that is name and value:{name: this.fieldName, value: this.value}
- Add it to the form with an attribute
data-field
. This was necessary, so we can find them using a querySelector. I could have used a specific class name, but that's harder to understand when looking at it later (what's the class for? Is it style or selection - bad for maintenance). Initially I thought to useextended-field-name
as attribute and use that in the querySelector, but that didn't work. Anyway having a consistent name for the field name attribute lowers your error rate.
So when adding input components (structure omitted for readability) you would see something like this:
<c-special-input data-field field-name="Phone"></c-special-input>
<c-ux-debounced-input data-field field-name="FirstName"></c-ux-debounced-input>
<c-ux-quick-lookup data-field field-name="Account"
object-api-name="Account"
label="Pick an account"
fields="Industry,Type,AccountNumber"></c-ux-quick-lookup>
Loading Data
This is handled in the formLoadHandler
. Lightning data service provides all information we might need. In this component we pull the field values. Those values are either the current value of the field, for an existing record or the default for a new record.
Thereafter we grab all fields with the data-field
attribute and provide a value if we have one in the record for that field-name
(called as node.fieldName
). It takes a while to get used to the permanent switch between kebab-case and CamelCase.
formLoadHandler(event) {
let fields = event.detail.record.fields;
const defaultValues = {};
for (let f in fields) {
if (fields.hasOwnProperty(f)) {
let elem = fields[f];
let val = elem.displayValue || elem.value;
if (val) {
defaultValues[f] = val;
}
}
}
// Now the input fields
let specialNodes = this.querySelectorAll('[data-field]');
for (let i = 0; i < specialNodes.length; i++) {
let specialNode = specialNodes[i];
let fieldName = specialNode.fieldName;
if (defaultValues[fieldName]) {
specialNode.value = defaultValues[fieldName];
}
}
}
Handling Changes
Handling changes has 2 components. First we need to attach a event listener watching out for the valueChanged
event. This is done in the constructor. Since events bubble, we don't need to bind one to each of our special input fields.
constructor() {
super();
// Holds our fields
this.specialFieldMap = {};
this.addEventListener('valueChanged', this.listenDataChange, true);
}
One of the little stumbling blocks for the listenDataChange
was that in a normal event handler function the this
keyword binds to the event target. So this.specialFieldMap
in the component would be unaccessible. The solution here is to use an ES6 arrow function that doesn't rebind this
. Once you make friends with ES6, there is no way back.
/* Capturing all changed values from our data control */
listenDataChange = event => {
let fieldInfo = event.detail;
this.specialFieldMap[fieldInfo.name] = fieldInfo.value;
};
The syntax is something to get used to, especially when you have function that don't take input parameters and you see let x = () => { return Math.random()}
- which just returns a random when you call x()
Saving back
The final piece is saving data back to Salesforce. The onsubmit
event provides access to the whole form and we can easily mix in the values we have collected from the fields' valueChanged
event. The beauty: no special server code required. We operate in the context of the lightning data service.
formSubmitHandler = event => {
event.preventDefault();
const fields = event.detail.fields;
for (let key in this.specialFieldMap) {
if (this.specialFieldMap.hasOwnProperty(key)) {
fields[key] = this.specialFieldMap[key];
}
}
this.template
.querySelector('lightning-record-edit-form')
.submit(fields);
};
For good measure we indicate success and redirect to the record form:
formSuccessHandler(event) {
const record = event.detail;
const evt = new ShowToastEvent({
title: 'Record created',
message:
this.objectApiName +
' created: ' +
(record.fields.Name.displayValue || record.fields.Name.value),
variant: 'success'
});
this.dispatchEvent(evt);
// Now navigate
let destination = {
type: 'standard__recordPage',
attributes: {
recordId: record.id,
objectApiName: this.objectApiName,
actionName: 'view'
}
};
this[NavigationMixin.Navigate](destination);
}
Go check it out and don't forget: YMMY!
Posted by Stephan H Wissel on 06 April 2019 | Comments (3) | categories: Lightning Salesforce WebComponents