Part 2: Enhancing Salesforce Flow Screens with Dynamic Forms

This is a continuation of Part 1, found here . Please read through that article before diving into this one! Now that we’ve explored how our Apex class fetches the Dynamic Form...

This is a continuation of Part 1, found here. Please read through that article before diving into this one!

Building the Lightning Web Component: Bringing the Form to Life

Now that we’ve explored how our Apex class fetches the Dynamic Form metadata, it’s time to dive into the Lightning Web Component (LWC) that brings everything together. This component is the heart of our solution - it consumes the metadata, renders the form dynamically within a Flow screen, and handles user interactions.

We’ll break down the LWC file by file, explaining what each part does and how it interacts with the Flow container and the Apex class.

1. JavaScript Controller (flexipageEditForm.js)

This is the main JavaScript file that controls the behavior of our component. It handles data fetching, parsing, state management, and user interactions.

Key Responsibilities:

  • Fetching FlexiPage metadata using the Apex class.
  • Parsing the metadata and building the form dynamically.
  • Handling field value changes and visibility rules.
  • Communicating with the Flow container and updating variables.

Let’s break it down step by step.

Imports and Constants

import { LightningElement, api, track, wire } from 'lwc';
import getFieldValues from '@salesforce/apex/FlexiPageToolingService.getFieldValues';
import getFlexiPageMetadata from '@salesforce/apex/FlexiPageToolingService.getFlexiPageMetadata';
import { parseFlexiPageJson } from './utils';
import { FlowAttributeChangeEvent } from 'lightning/flowSupport';
import { getObjectInfo } from 'lightning/uiObjectInfoApi';
const invalidFields = ['CreatedById', 'LastModifiedById', 'Id'];
  • Standard LWC Modules: We import necessary modules from lwc.
  • Apex Methods: Importing getFieldValues and getFlexiPageMetadata from our Apex class.
  • Utility Function: Importing parseFlexiPageJson from utils.js.
  • Flow Support: Importing FlowAttributeChangeEvent to interact with the Flow.
  • UI API: Importing getObjectInfo to retrieve object metadata.
  • Constants: Define invalidFields to exclude certain fields.

Component Declaration and Properties

export default class FlexipageEditForm extends LightningElement {
    // Public properties exposed to the Flow or parent components
    @api recordId;
    @api objectApiName;
    @api flexiPageName;
    @api fieldPageName; // Alternative FlexiPage name
    @api altField; // Field to use as an alternative recordId
    @api debugEnabled = false;
    @api cardTitle = '';
    @api showIcon = false;
    @api varRecord; // Variable to hold the record data
    @api flowContext; // Indicates if the component is used in a Flow
    @api saveLabel = 'Save';
    @api excludedFields = ''; // Fields to exclude from rendering

    // Tracked properties for reactivity
    @track sections = [];
    @track iconUrl = '';
    @track objectIcon = '';
    @track recordData = {};
    @track error;
    dataLoaded = false;
    _recOutput = {};
    // Internal properties
    parsedSections = {};
    excludedFieldsArray = [];
    fields = [];
}
  • Public Properties (@api): These are properties that can be set from the parent context, such as the Flow. They allow customization and data passing.
  • Tracked Properties (@track): Properties that need to be reactive so the UI updates when they change.
  • Internal Variables: Used for internal logic and data management.

Getters and Setters

@api
get recOutput() {
    return this._recOutput;
}
set recOutput(value) {
    this._recOutput = value;
}
  • recOutput: This is a getter and setter for the recOutput property, allowing the component to expose data back to the Flow.

Lifecycle Hooks

connectedCallback() {
    console.log('ConnectedCallback: Initializing FlexiPageEditForm component');
    if (!this.objectApiName) {
        console.log('objectApiName not provided. Defaulting to {{OBJECT API NAME HERE}}');
        this.objectApiName = '{{OBJECT API NAME HERE}}';
    }
    this.loadFlexiPageConfig();
}
  • connectedCallback(): This method runs when the component is inserted into the DOM.
  • Checks if objectApiName is provided; if not, defaults to ‘Opportunity_Readiness__c’.
  • Calls loadFlexiPageConfig() to start fetching the FlexiPage metadata.

Wire Adapters

@wire(getObjectInfo, { objectApiName: '$objectApiName' })
handleObjectInfo({ error, data }) {
    if (data) {
        // Extracts object icon information for display
    } else if (error) {
        console.error('Error fetching object info:', error);
    }
}
  • getObjectInfo: Retrieves metadata about the object specified by objectApiName.
  • handleObjectInfo(): Processes the data to extract the object’s icon URL, which can be displayed in the component.

Loading FlexiPage Configuration

loadFlexiPageConfig() {
    const flexiPageNameToUse = this.fieldPageName || this.flexiPageName;
    getFlexiPageMetadata({ developerName: flexiPageNameToUse })
        .then(result => {
            this.config = JSON.parse(result);
            this.fetchFieldValues();
        })
        .catch(error => {
            console.error('Error loading FlexiPage config:', error);
            this.error = error;
        });
}
  • Purpose: Fetches the FlexiPage metadata using the Apex method.
  • Process:
  • Determines which FlexiPage name to use.
  • Calls getFlexiPageMetadata() with the developer name.
  • Parses the JSON response and stores it in this.config.
  • Calls fetchFieldValues() to retrieve field values.

Fetching Field Values

fetchFieldValues() {
    this.excludedFieldsArray = this.excludedFields
        ? this.excludedFields.split(',').map(field => field.trim().toLowerCase())
        : [];
    getFieldValues({ recordId: this.recordId, objectApiName: this.objectApiName })
        .then(data => {
            this.recordData = this.mapFieldValues(data.fieldValues);
            this.parsedSections = parseFlexiPageJson(this.config, this.recordData);
            this.calculateVisibility(this.parsedSections);
            this.sections = this.processSections(this.parsedSections, this.excludedFieldsArray);
            this.fields = this.collectFields(this.parsedSections, this.excludedFieldsArray);
            this.checkAltFieldAndRefetch();
            this.dataLoaded = true;
        })
        .catch(error => {
            console.error('Error fetching field values:', error);
            this.error = error;
            this.dataLoaded = true;
        });
}
  • Purpose: Retrieves field values for the specified record and object.
  • Process:
  • Converts excludedFields into an array for easy checking.
  • Calls getFieldValues() Apex method.
  • Maps the returned field values for easy access.
  • Parses the FlexiPage JSON to structure the sections and fields.
  • Calculates field visibility based on visibility rules.
  • Processes sections and collects fields for rendering.
  • Checks for an alternate record ID (altField) and refetches if necessary.
  • Sets dataLoaded to true to indicate that data is ready.

Mapping Field Values

mapFieldValues(data) {
    let mappedValues = {};
    for (const key in data) {
        if (data.hasOwnProperty(key)) {
            const normalizedKey = key.toLowerCase();
            mappedValues[normalizedKey] = data[key];
        }
    }
    return mappedValues;
}
  • Purpose: Normalizes field names to lowercase for consistent access.

Handling Alternative Field (altField)

checkAltFieldAndRefetch() {
    const altFieldValue = this.recordData[this.altField?.toLowerCase()];
    if (altFieldValue) {
        this.recordId = altFieldValue;
        this.fetchFieldValues();
    }
}
  • Purpose: If altField is set and has a value, updates recordId and refetches field values.
  • Use Case: Useful when the initial recordId is a placeholder and the actual ID is stored in another field.

Calculating Visibility

calculateVisibility(parsedSections) {
    Object.keys(parsedSections).forEach(sectionKey => {
        const section = parsedSections[sectionKey];
        Object.keys(section.columns).forEach(columnKey => {
            const column = section.columns[columnKey];
            Object.keys(column.fields).forEach(fieldKey => {
                const field = column.fields[fieldKey];
                if (field.visibilityRule) {
                    field.isVisible = this.evaluateVisibilityRule(field.visibilityRule);
                } else {
                    field.isVisible = true;
                }
            });
        });
    });
}
  • Purpose: Iterates over all fields and calculates their visibility based on the defined visibility rules.
  • Process:
  • Checks if a field has a visibilityRule.
  • Calls evaluateVisibilityRule() to determine if the field should be visible.

Evaluating Visibility Rules

evaluateVisibilityRule(visibilityRule) {
    if (!visibilityRule || !visibilityRule.criteria) {
        return true;
    }

const results = visibilityRule.criteria.map(criterion => {
        const leftFieldApiName = criterion.leftValue.replace('{!Record.', '').replace('}', '').toLowerCase();
        const leftValue = this.recordData[leftFieldApiName];
        const rightValue = criterion.rightValue;
        let conditionMet = false;
        // Evaluate the condition based on the operator
        switch (criterion.operator) {
            case 'CONTAINS':
                conditionMet = typeof leftValue === 'string' && leftValue.includes(rightValue);
                break;
            case 'EQUAL':
                conditionMet = leftValue === rightValue;
                break;
            // Additional cases...
        }
        return conditionMet;
    });
    // Combine results based on booleanFilter
    let isVisible = true;
    const booleanFilter = visibilityRule.booleanFilter ? visibilityRule.booleanFilter.toUpperCase() : 'AND';
    if (booleanFilter === 'AND') {
        isVisible = results.every(result => result);
    } else if (booleanFilter === 'OR') {
        isVisible = results.some(result => result);
    }
    return isVisible;
}
  • Purpose: Evaluates the visibility criteria for a field.
  • Process:
  • Parses each criterion in the visibilityRule.
  • Compares the field values based on the operator (e.g., EQUAL, CONTAINS).
  • Combines the results using the booleanFilter (AND/OR).

Processing Sections for Rendering

processSections(parsedSections, excludedFieldsArray) {
    let processedSections = [];
    Object.keys(parsedSections).forEach(sectionKey => {
        const section = parsedSections[sectionKey];
        let processedColumns = [];
        Object.keys(section.columns).forEach(columnKey => {
            const column = section.columns[columnKey];
            const visibleFields = Object.keys(column.fields).filter(
                fieldId => column.fields[fieldId].isVisible && !excludedFieldsArray.includes(fieldId.toLowerCase())
            );
            const fieldsToRender = visibleFields.map(fieldId => ({
                fieldId: fieldId,
                isRequired: column.fields[fieldId].isRequired
            }));
            if (fieldsToRender.length > 0) {
                const className = `slds-col slds-size_1-of-2`;
                processedColumns.push({
                    side: column.side,
                    fields: fieldsToRender,
                    class: className
                });
            }
        });
        if (processedColumns.length > 0) {
            processedSections.push({
                sectionName: section.label || 'Section',
                sectionId: sectionKey,
                columns: processedColumns,
                isOpen: true,
                class: 'slds-section slds-is-open'
            });
        }
    });
    return processedSections;
}
  • Purpose: Prepares the sections and fields for rendering in the template.
  • Process:
  • Filters out fields that are not visible or are excluded.
  • Structures the data into sections and columns with appropriate classes.

Collecting Fields

collectFields(parsedSections, excludedFieldsArray) {
    let fields = [];
    Object.values(parsedSections).forEach(section => {
        Object.values(section.columns).forEach(column => {
            Object.keys(column.fields).forEach(fieldId => {
                if (!excludedFieldsArray.includes(fieldId.toLowerCase())) {
                    fields.push(fieldId);
                }
            });
        });
    });
    const uniqueFields = [...new Set(fields)];
    return uniqueFields;
}

Purpose: Collects all the field API names that need to be rendered.

Handling User Interactions

Toggling Sections:

toggleSection(event) {
    const sectionName = event.target.dataset.name;
    this.sections = this.sections.map(section => {
        if (section.sectionName === sectionName) {
            const isOpen = !section.isOpen;
            return {
                ...section,
                isOpen,
                class: `slds-section ${isOpen ? 'slds-is-open' : ''}`
            };
        }
        return section;
    });
}
  • Purpose: Handles the expand/collapse functionality for sections.

Handling Field Changes:

handleFieldChange(event) {
    const fieldName = event.target.fieldName;
    const value = event.target.value;
    // Update output and recordData
    this._recOutput = { ...this._recOutput, [fieldName]: value };
    this.recordData = { ...this.recordData, [fieldName.toLowerCase()]: value };
    // Recalculate visibility and update sections
    this.calculateVisibility(this.parsedSections);
    this.sections = this.processSections(this.parsedSections, this.excludedFieldsArray);
    this.fields = this.collectFields(this.parsedSections, this.excludedFieldsArray);
    // Dispatch event to Flow if necessary
    if (this.flowContext) {
        this.dispatchEvent(new FlowAttributeChangeEvent('recOutput', this._recOutput));
    }
}
  • Purpose: Updates internal data structures when a field value changes.
  • Process:
  • Updates recOutput and recordData with the new value.
  • Recalculates visibility rules.
  • Updates the sections and fields for rendering.
  • Dispatches FlowAttributeChangeEvent to update Flow variables if in Flow context.

2. HTML Template (flexipageEditForm.html)

This file defines the structure and layout of the component’s user interface.

<template>
    <lightning-card title={cardTitle} icon-name={objectIcon}>
        <template if:true={isDataAvailable}>
            <template for:each={sections} for:item="section">
                <section key={section.sectionId} class={section.class}>
                    <div class="slds-section__title slds-m-around_small">
                        <button aria-expanded={section.isOpen} class="slds-button slds-section__title-action" data-name={section.sectionName} onclick={toggleSection}>
                            <svg class="slds-section__title-action-icon slds-button__icon slds-button__icon_left" aria-hidden="true">
                                <use xlink:href="/_slds/icons/utility-sprite/svg/symbols.svg#switch"></use>
                            </svg>
                            <span class="slds-truncate" title={section.sectionName}>{section.sectionName}</span>
                        </button>
                    </div>
                    <div class="slds-section__content slds-m-around_medium">
                        <lightning-record-edit-form record-id={recordId} object-api-name={objectApiName}>
                            <div class="slds-grid slds-wrap slds-gutters">
                                <template for:each={section.columns} for:item="column">
                                    <div key={column.columnId} class={column.class}>
                                        <template for:each={column.fields} for:item="field">
                                            <lightning-input-field
                                                key={field.fieldId}
                                                field-name={field.fieldId}
                                                onchange={handleFieldChange}
                                                required={field.isRequired}
                                            ></lightning-input-field>
                                        </template>
                                    </div>
                                </template>
                            </div>
                        </lightning-record-edit-form>
                    </div>
                </section>
            </template>
        </template>
        <template if:false={isDataAvailable}>
            <c-stencil iterations="4" columns="2"></c-stencil>
        </template>
    </lightning-card>
</template>

Key Points:

  • Conditional Rendering:
  • Uses