TL; DR;
Allowing the Label and Helptext of lightning-input-field
to be customised while maintaining all the other standard features as part of a record-edit-form
. Long story, mostly solved, long way still to go.
Setting the Scene
A very interesting challenge came my way recently. We have a custom (fairly complicated) component built on top of a Custom Object. One of the Record Types is used to represent a parent record which has many child records of the other Record Types (the point of the component is to make working with these easier). No need to go into a lot of detail but there is a custom json config to get a dynamic list of fields which are added to a record-view/edit-form. Each specific Record Type will show different fields, all from the same object of though. A little hack is added where field-name is blank to insert an empty space in the form.
const getUiFieldMapping = () => {
let uiFieldMap = {
RecordType1: [
{ apiname: Field1__c, required: true },
{ apiname: 'Name', required: true },
{ apiname: '', required: false },
{ apiname: 'OwnerId', required: false },
...
<lightning-record-edit-form
object-api-name="OBJECT"
record-id={recordId}
...
>
<template for:each={fieldSet.fields} for:item="field">
<div key={field.apiname} class="slds-col slds-size_1-of-2 output-col">
<template if:true={field.apiname}>
<lightning-output-field field-name={field.apiname}>
<lightning-icon
size="x-small"
class="edit-icon"
icon-name="utility:edit"
onclick={handleEditModeChange}
></lightning-icon>
</lightning-output-field>
</template>
<template if:false={field.apiname}>
<div class="vertical-whitespace"></div>
</template>
</div>
</template>
...
The Problem
And here’s the requirement: we need to be able to specify a different Helptext (and maybe Label) for the same field depending on the Record Type. But the lightning-input-field
base component does not allow to override these.
Googling around I stumbled on this very cool idea on Salesforce Stack Exchange. In short it suggests wrapping the lighting-input-field
in a custom component containing markup based on the LDS template of a form element with a slot for the lightning-input-field
. The provided input field would still be bound to the record-edit-form
but hidden. With this I would only need to replace the fields that need the custom Helptext and the rest of the component remains unchanged.
The idea shows only a checkbox
. Once I get this to work though I’m sure adding variations for other types is a breeze. We can even add them later as needs present themselves. Sure there are bound to be many surprises on the way, but the alternative would have been to rebuild the original component from scratch. So I dived in.
How It Went
Of course the surprises were more than many. I still continue to find them. My advice is to not do this. But if you don’t really have a choice, then check out this repo for a decent head start. It supports Text, Text Area, Email, Phone, Date/Time and Checkbox. Though to be perfectly honest I’ve not tested them all very thoroughly. I only needed Text Area. A demo component is added for your convenience. 😉
Below I describe a couple of interesting challenges that I faced and how I went about finding suitable solutions. It felt overly complicated given the seeming simplicity of the problem at hand. But now that it’s done, I think it’s quite useful. Continue at your own discretion though, it’s long and probably only interesting if you want to build something like this yourself.
There is a lot of room for improvement and even some blatant gaps, maybe you can help?
Oh and if you only need the label, I just found this.
Getting the Pre-existing Value
I have a custom textarea
element which of course is not bound to the record-edit-form
. I have to make sure to copy the value over. It seems though that during connectedCallback
(or even renderedCallback
) the edit form has not yet loaded fully and the value is not available. I solved that by exposing an @api
method on my custom component and calling it for each instance once the form had fully loaded.
@api
fetchCurrentValueOnLoad() {
if (!this.lightningInputField) {
console.warn(`Custom Field ${this.fieldName} not loaded`);
return;
}
if (this.isTextArea) {
this.customInput = this.template.querySelector('lightning-textarea');
this.customInput.value = this.lightningInputField.value;
} else if(this.isGeneric) {
this.customInput = this.template.querySelector('lightning-input');
this.customInput.value = this.lightningInputField.value;
}
}
//parent component
handleRecordEditFromOnLoad() {
this.template.querySelectorAll('c-custom-input-field').forEach((element) => {
element.fetchCurrentValueOnLoad();
});
}
Respecting Screen Density Settings
This was much harder than I thought it should be. The lighting-input-field
is rendered out into markup which is basically the same as my custom input component. The wrapping div gets a css class .slds-form-element_horizontal
or .slds-form-element_stacked
based on the Org or User Density Settings (unless set on the form itself).
Finding the Right Setting
There is a UI API endpoint available to get the active theme including the density setting. But it doesn’t seem to be available via a @wire adapter. Instead I created an Apex Controller based on another suggestion (Stack Exchange is just awesome) looking into the UserPreference
object. The only issue is that I didn’t find a way to get the Org Default, just any user specific override. Which can of course be missing. I chose to hard-code the default for now, but it’s probably better to fall back to the Get Theme API.
Good thing here is that changing the Density Setting seems to clear cache. So the change is visible right away even though the method is cached so we don’t always end up calling Apex.
public with sharing class UserDensitySettingController {
public static final String DEFAULT_DENSITY = 'VIEW_TWO'; //can't access programatically
@AuraEnabled(cacheable=true)
public static String getRunningUserUiDensitySetting() {
Id runningUserId = UserInfo.getUserId();
try {
List<UserPreference> userDensityPreferences = [SELECT Value FROM UserPreference WHERE Preference = '108'];
if (!userDensityPreferences.isEmpty()) {
return userDensityPreferences[0].Value;
}
return DEFAULT_DENSITY;
} catch (Exception e) {
throw new AuraHandledException(e.getMessage());
}
}
}
Tiny Screens
Our default setting is compact. I’m often working on a fairly small screen so it didn’t take long that I noticed the horizontal arrangement of label and field gets pushed to stacked anyway once the viewport is small enough.
Here I ran into my limited from end dev experience. I had an idea about form factors, screen size breakpoints or the fact it should not be hard to react to a screen resize event. I had to call out for help and google around quite a bit to make any of that work though.
Some Good suggestions and ideas
Jason Clark (author of the wrapper idea on Stack Exchange) made me aware of the possibility of the FlexiPage telling the component how big the section it finds itself in is. My component isn’t a “top-level” one though and I didn’t fancy passing this around.
It also makes it possible to add custom css to a “size” class, but not set a css class based on it. Or I didn’t know how anyway.
div.LARGE {
# I need to make this .slds-form-element_horizontal
}
div.SMALL {
# I need to make this .slds-form-element_stacked
}
Finding other components with the expected classes ending _horizontal
or _stacked
and setting mine to the same was a promising idea I found. It worked well for the initial rendering. But I still had to find a way to re-decide when the screen resizes. Reading a specific html component and its classes would be the way to go probably, but I didn’t like this too much.
Bounding Rectangle and Resize Event
On screen resize getting the size of the component – or the space it has available (as suggested by tsalb#114 on SFXD discord server). Figuring out what the width is where horizontal and vertical get switched by trial and error with console output of width and using that in the controller to decide which class to set.
Still feels like this should be a lot more professional, but I had to move on 🙂 It very much assuming that there are 2 columns and all the fields are the same size.
@api stackedBreakpoint = 674;
connectedCallback() {
window.addEventListener('resize', this.checkWidthChange);
}
checkWidthChange = () => {
const rect = this.template.querySelector('lightning-card').getBoundingClientRect();
this.width = rect.width;
this.template.querySelectorAll('c-custom-input-field').forEach((element) => {
if (this.width < this.stackedBreakpoint) {
element.setFormDensityStacked();
} else {
element.setFormDensityHorizontal();
}
});
};
Slot vs Own lightning-input-field
In order to simplify the parent component markup I thought I could just create the lightning-input-field
inside my custom wrapper component instead of passing it into a slot. This would reduce every use from 3 lines into 1. I have to pass in a field-type parameter anyway to show the right template so I could just add one for “no custom type” creating lightning-input-field
directly.
It didn’t work though as I couldn’t get the input to bind to the record-edit-form
. Maybe I missed something, but given the isolation between different components it made sense this would not work. Back to the slot then.
I could keep one idea from this though: having a “standard” template. When I added attributes for “required” and “read-only” as well (instead of reading them from the passed in lightning-input-field
) I was able to simplify the form from this:
<template if:true={field.required}>
<template if:true={field.custom}>
<c-custom-input-field
label={field.label}
custom-field-type={field.customType}
helptext={field.helpText}
>
<lightning-input-field
field-name={field.apiname}
class="input-field"
required
></lightning-input-field>
</c-custom-input-field>
</template>
<template if:false={field.custom}>
<lightning-input-field field-name={field.apiname} required></lightning-input-field>
</template>
</template>
<template if:false={field.required}>
<template if:true={field.readonly}>
<lightning-input-field field-name={field.apiname} disabled></lightning-input-field>
</template>
<template if:false={field.readonly}>
<lightning-input-field field-name={field.apiname}></lightning-input-field>
</template>
</template>
To this:
<c-custom-input-field
label={field.label}
custom-field-type={field.customType}
helptext={field.helpText}
required={field.required}
disabled={field.disabled}
>
<lightning-input-field field-name={field.apiname} class="input-field"></lightning-input-field>
</c-custom-input-field>
Helptext Popover
It was around this time that I had an epiphany. Why bother with the custom HTML markup and popovers for the Helptext? Just use lightning-input
or lightning-textarea
! OMG how did i miss that. Simples!
<template>
<div>
<lightning-textarea
read-only={disabled}
required={required}
label={label}
variant={variant}
field-level-help={helptext}
onchange={handleChange}
></lightning-textarea>
</div>
<slot class="slds-hide" onslotchange={handleSlotChange}></slot>
</template>
Output
The form should have a read only version too, right. i.e. record-view-form
and lightning-output-field
.
This again took me a lot longer than it should have. I won’t bore you with details, but lighting-output-field
does not expose a value
attribute. And you cannot read the DOM of what it is ultimately rendered as (you know, to get the value something like this.querySelector(‘textarea’).innerText()
). So I get the value in the onLoad
event of the record-view-form
and pass it down to my component via a public setter (similar to the fetch from before).
Also this time I had to use the full form element markup from SLDS. No equivalent lightning-output
component available.
<template>
<div class={formDensityClass}>
<span class="slds-form-element__label">{label}</span>
<div class="slds-form-element__icon">
<button class="slds-button slds-button_icon" aria-describedby="help" type="button">
...
<span class="slds-assistive-text">{Label} Help</span>
</button>
<div
class="slds-popover slds-popover_tooltip slds-nubbin_bottom-left"
role="tooltip"
id="help"
style="position: absolute; top: -60px; left: -16px; width: 170px"
>
<div class="slds-popover__body">{helptext}</div>
</div>
</div>
<div class="slds-form-element__control">
<lightning-formatted-text class="slds-form-element__static">{value}</lightning-formatted-text>
<slot name="visible"></slot>
</div>
</div>
<slot name="output-field" class="slds-hide" onslotchange={handleSlotChange}></slot>
</template>
A keen eye spotted the “visible” slot there. That’s where we inject the inline edit icon.
Interesting find: use this.template.querySelector(‘xxx’)
to search through markup which is part of your component. But use this.querySelector(‘xxx’)
to search through markup passed into slots in your component. Only their template though, not the full html they actually render as. You don’t have access to that!
Error Handling
By this point the effort spent was getting ridiculous but without at least some basic error handling this would not be useful at all (and I was too invested). The main thing was to recognise Errors that should be shown at a specific field. Luckily the record-edit-form
is quite well suited for this.
The only trick needed was a data-field
added to each field to be able to find it with the querySelector
.
handleSubmitError(event) {
this.isLoading = false;
let fieldErrors = event.detail?.output?.fieldErrors;
if (fieldErrors) {
for (const [fieldName, errorDetails] of Object.entries(fieldErrors)) {
let fieldQuery = 'c-custom-input-field[data-field-id=' + fieldName + ']';
let customField = this.template.querySelector(fieldQuery);
if (customField) {
customField.setError(errorDetails[0].message);
}
}
}
let topLevelError = event.detail?.message;
if (topLevelError) {
topLevelError = 'Error';
} else {
topLevelError = event.detail?.detail;
}
this.dispatchEvent(
new ShowToastEvent({
title: 'Error while saving.',
message: topLevelError,
variant: 'error',
mode: 'sticky'
})
);
}
The End
If you got this far… you’re my kind of person. Best of luck if you are going to take the baton and move further. Let me know how it goes.