DEV Community

Toni Petov for interop.io

Posted on • Originally published at community.interop.io

Salesforce - Lightning Locker and io.Connect platform connected components - Standalone

Overview

⚠️ Note that the following sections are relevant only for:

  • If you intend on using Salesforce within the io.Connect Platform (io.Connect Desktop or io.Connect Browser)
  • Salesforce sessions with active Lightning Locker (in Session Settings)
  • the latest versions of the Salesforce Adapter (5.13 and later). For details on the legacy versions (3.0 and 4.0), see the Legacy Adapter section.

As 'Lightning Locker' does not allow direct usage of components packaged in an external namespace you will need to use Lightning Message Service (LMS) to communicate with the io.Connect Platform. Thus, we provide free of charge an LMS middleware package and a Plugin-based template that could serve as a reference point for further development. For simplicity of this documentation we will not focus on structuring the component, if you want you could do so on your own. Note that such custom components can be used both in your Lightning Our apps, or from within the Salesforce platform (with certain limitations and requirements).

⚠️ Note that using Salesforce within the io.Connect Platform has the advantage of not having to attach an io.Connect Utility Bar component to your Lightning App and not having to provide additional package configuration after installation in order to enable interoperability. These steps, however, are required when using Salesforce in a web browser.

Requirements

For the use of the InteropConsumerMixin to make sense, Lightning Web Security must be disabled:

  1. Log in to your Salesforce organization as an administrator.

  2. Go to Setup > Security > Session Settings.

  3. Make sure that Lightning Web Security is disabled in the "Lightning Web Security" section.

⚠️ If Lightning Web Security is already enabled, consider using the LWS approach instead.

⚠️ You will need to use Tick42__lmsStandalone in one of your UtilityBars in the Lightning Applications.

Limitations

  • The LMS-based approach is not yet evaluated and tested in Salesforce Classic applications.
  • Currently, the LMS-based approach does not support streaming and other more complex interop methods functionality.
  • Due to the security restrictions imposed by Salesforce, it's impossible to embed the Salesforce platform in an <iframe> in io.Connect Browser. To avoid this limitation, you can use the InteropConsumerMixin component in Lightning Out apps or Visualforce pages that run in io.Connect Browser instead.
  • If using more than one instance of the component in the same app, all interop-enabled LWCs will have the same application name. This means that you won't be able to register more than one event listener for the same type of event. To avoid this limitation, it's recommended to either use the InteropConsumerMixin component with Plugins or in different Lightning Out apps instead.

Examples

An example Lightning Application is available in the LMS[T42] Salesforce package. To retrieve the package into your project, execute the following command using the Salesforce CLI vs your organization where you have installed the packages:

sf project retrieve start --package-name "LMS[T42]"
Enter fullscreen mode Exit fullscreen mode

Salesforce App Definition

The following sections describe how to create app definitions for the Salesforce platform or for your Lightning Out apps depending on whether you are using the Salesforce Adapter with io.Connect Desktop or with io.Connect Browser.

io.Connect Desktop

To be able to open Salesforce in io.Connect Windows or Workspaces in io.Connect Desktop, you must provide a valid app definition for it. Besides the standard required definition properties ("name", "type", and "url"), it's also required to enable auto injection of the @interopio/desktop library and to configure the usage of preload scripts. Enabling auto injection is necessary because the @interopio/desktop library provides the interoperability capabilities of the InteropConsumerMixin component. The preload scripts configuration is necessary because it enables using the InteropConsumerMixin component.

⚠️ Note that the preload scripts configuration also allows you to enable the usage of a Utility Bar component. Using a Utility Bar component isn't necessary when Salesforce is running in **io.Connect Desktop, but if you decide to use one, you must provide the respective preload script. For details on how to attach one of the available io.Connect Utility Bar components, see the Usage > Web Browser > Configuration section.

The following is an example configuration for defining the Salesforce platform as an io.Connect app:

{
    "name": "salesforce",
    "type": "window",
    "title": "Salesforce",
    "details": {
        // URL pointing to your Salesforce organization.
        "url": "https://your-salesforce-url.salesforce.com/",
        // This is required to enable usage of preload scripts.
        "security": {
            "webSecurity": false,
            "allowRunningInsecureContent": true,
            "allowedExternalURISchemes": ["data", "https", "http"]
        },
        // This is required to enable interoperability and library usage in production.
        "preloadScripts": {
            "scripts": [
                // This preload script is required to enable using the `InteropConsumerMixin` component.
                "https://enterprise-demos.dev.interop.io/preload-scripts/salesforce-request-factory-from-platform.js",
                // This preload script is only necessary if you want to use a Utility Bar component when Salesforce is running in io.Connect Desktop.
                "https://enterprise-demos.dev.interop.io/preload-scripts/salesforce-utility-bar.js"
            ],
            "useBase64PreloadScripts": false
        },
        // Enable auto injection of the `@interopio/desktop` library.
        "autoInjectAPI": {
            "enabled": true,
            // If set to `false` (default), will enable you to provide initialization settings from your component.
            // If `true`, the library will be auto initialized with the default settings (e.g., you won't be able
            // to use features that are disabled by default such as the Workspaces API and the Search API).
            "autoInit": false
        }
    },
    "customProperties": {
        // Use this if you want to add Salesforce to the "Add App" menu in a Workspace.
        // This will enable users to open Salesforce in Workspaces.
        "includeInWorkspaces": true
    }
}
Enter fullscreen mode Exit fullscreen mode

io.Connect Browser

To be able to open your Lightning Out apps in io.Connect Windows or Workspaces in io.Connect Browser, you must provide a valid app definitions for them when initializing your Main app.

The following is an example configuration for defining a Lightning Out app as an io.Connect app:

import IOBrowserPlatform from "@interopio/browser-platform";

const config = {
    licenseKey: "my-license-key",
    applications: {
        local: [
            {
                name: "salesforce",
                type: "window",
                title: "Salesforce",
                details: {
                    // URL pointing to your Lightning Out app.
                    url: "https://example.com/your-lightning-out-app/"
                },
                customProperties: {
                    // Use this if you want to add your app to the "Add App" menu in a Workspace.
                    // This will enable users to open the app in Workspaces.
                    includeInWorkspaces: true
                }
            }
        ]
    }
};

const { io } = await IOBrowserPlatform(config);
Enter fullscreen mode Exit fullscreen mode

Usage

The following sections describe the InteropConsumerMixin component API and provide examples on how to implement an interop-enabled LWC.

Component API

The InteropConsumerMixin mixin provides a set of properties and methods that you can use to interop-enable your LWC.

The mixin has the following methods that can be accessed via the this object upon successful connection to the io.Connect framework:

Method Accepts Description
requestConnectionStatus() () Use this method to request the current connection status. It sends an LMS message to the lmsStandalone which will return {isConnected} object as a response.
executePlatformMessageCallback() (string, boolean, object) Use this method to send a response to the InteropConsumerMixin component about the result from the Interop method invocation. The component will relay the response to the calling app. Accepts three required arguments - the ID of the invocation (which can be extracted from event details), a Boolean value denoting whether the invocation Promise should be resolved (true) or rejected (false), and the actual result from the method invocation to be passed to the calling app.
registerMethod() (string, string) Can be used instead of the connected-methods property to register Interop methods for handling DOM events. Accepts as required arguments the name of the Interop method and the name of the DOM event.
triggerOutbound() (object) Invokes an Interop method registered by other interop-enabled apps. Accepts as a required argument an object with method and payload properties specifying the name of the Interop method to invoke and arguments for the invocation.
unregisterMethod() (string) Unregisters an Interop method. Accepts as a required argument the name of the Interop method to unregister.

The mixin provides the following hooks:
| Method | Arguments | Description |
|--------|-----------|-------------|
| connectedCallback() | () | Used by the LWC rendering engine when the LWC component is initialized. This implementation subscribes for the correct LMS channels. |
| renderedCallback() | () | Used by the LWC rendering engine when the LWC component is rendered. This implementation requests a connection status. |
| disconnectedCallback() | () | Used by the LWC rendering engine when the LWC component is disconnected from the DOM. This implementation requests a connection status. |
| handlePlatformMessage() | (object) | Used when a registered method is called from outside LWC's SF-application. |

The LMSPlugin class provides the following properties:
| Property | Type | Description |
|----------|------|------------|
| listenToAPI | String | Sets the interop API method to listen for, e.g., "My.LMS.Test.Inbound.Method". |

The LMSPlugin class provides the following hooks:
| Method | Arguments | Description |
|--------|-----------|-------------|
| onConnected() | () | Registers the dedicated API method. |
| handlePlatformMessage() | (object) | Determine if this Plugin instance is registering the API method and forwards to onInbound() hook. |
| onInbound() | (string, object, string) | Executes custom logic on inbound API call. Accepts three required arguments - the ID of the invocation method (which can be extracted from event details), a serializable payload object and a sting with the callbackID of the the invocation Promise |

The example myInteropEnabledLms component provides the following hooks:
| Method | Arguments | Description |
|--------|-----------|-------------|
| onConnected() | () | Initializes the Plugin instances. |
| handlePlatformMessage() | (object) | Extends the mixin's method and forwards to onInbound in the registering Plugin. |

Example Implementation

To interop-enable your LWC, you must:

  • create a standard LWC with XML, HTML, and JavaScript files;
  • use the InteropConsumerMixin mixin in your LWC's JavaScript file to add the required functionality to your LWC;
  • to use Tick42__lmsStandalone in your home or record page or for best experience in one of your UtilityBars in the Lightning Applications;

The following examples demonstrate how to create an interop-enabled LWC that will be used in an io.Connect Desktop platform. The component registers an Interop method that can be invoked by other interop-enabled apps, and also invokes an Interop method already registered by other interop-enabled apps.

Example XML configuration for a LWC:

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>60.0</apiVersion>
    <isExposed>true</isExposed>
    <description>This component demonstrates inbound and outbound interoperability between Salesforce and io.Connect apps.</description>
    <masterLabel>My LMS Interop-Enabled Component</masterLabel>
    <targets>
        <!-- Salesforce pages targeted by the component. -->
        <target>lightning__RecordPage</target>
        <target>lightning__AppPage</target>
        <target>lightning__HomePage</target>
    </targets>
</LightningComponentBundle>
Enter fullscreen mode Exit fullscreen mode

Example myInteropEnabledLms.html structure of an interop-enabled LWC that uses the InteropConsumerMixin mixin:

<template>
    <lightning-card title="My Interop-Enabled LMS Component">
        <div class="slds-p-horizontal_medium slds-p-bottom_small">
            <div>
                <p>
                    Active plugins: {pluginStack}
                </p>
                <p>state: {state}</p>
                <p>last received value: {lastReceived}</p>
            </div>
        </div>

    </lightning-card>

</template>
Enter fullscreen mode Exit fullscreen mode

Example myInteropEnabledLms.js implementation of an interop-enabled LWC:

import { LightningElement, track } from 'lwc';
import {LMSPlugin, ListenPlugin, BroadcastPlugin} from './plugins.js';

import {InteropConsumerMixin} from './lmsMixin.js';

export default class MyInteropEnabledLms extends InteropConsumerMixin(LightningElement) {
    pluginInstances = [];
    plugins = [ListenPlugin, BroadcastPlugin];
    @track
    state = "start";
    lastReceived = "";
    get pluginStack() {
        return this.plugins.map((cls)=>cls.name).join(", ")
    }

    // This is a simple check that the plugins are of the correct type
    connectedCallback() {
        if (super.connectedCallback) super.connectedCallback();
        if (!this.plugins) {return;}

        this.plugins.forEach((plugin) => {

            if (! (plugin.prototype instanceof LMSPlugin)) {
                throw new Error(`io.Connect: the plugin ${plugin.name} must extend the ${LMSPlugin.name} class!`);
            }
            if (typeof plugin.prototype.onConnected !== 'function') {
                throw new Error('io.Connect: Error configuring WorkspaceUtilityBar.\n A Plugin Class must have an onConnected method!');
            }
        });
    }


    init = false;
    isConnected = false;
    handleConnectionStatusUpdate(message) {
        super.handleConnectionStatusUpdate({...message});
        if (this.isConnected && !this.init) {
            this.init = true;
            this.onConnected();
            this.state = "ready";
        }
    }

    onConnected() {
        if (super.onConnected) super.onConnected();
        if (!this.plugins) {return;}

        this.plugins.forEach((PluginClass)=>{
            const instance = new PluginClass(this.io, this);
            this.pluginInstances.push(instance);
            instance.onConnected();
        });
    }

    handlePlatformMessage(message) {
        if (super.handlePlatformMessage) super.handlePlatformMessage({...message});
        if (!this.plugins) {return;}

        let lastValue = false;
        this.pluginInstances.forEach((instance)=>{
            lastValue = lastValue || instance.handlePlatformMessage({...message});
        });
        this.lastReceived = lastValue;
    }

    // disconnectedCallback() {
    //     if (super.disconnectedCallback) super.disconnectedCallback();
    // }

    // connectedCallback() {
    //     if (super.connectedCallback) super.connectedCallback();
    // }

    // renderedCallback() {
    //     if (super.renderedCallback) super.renderedCallback();
    // }

}
Enter fullscreen mode Exit fullscreen mode

Example plugins.js implementation of an interop-enabled LWC:

const TEST_INBOUND_METHOD = "My.LMS.Test.Inbound.Method";
const TEST_OUTBOUND_METHOD = "My.LMS.Test.Outbound.Method";


export class LMSPlugin {
    listenToAPI = null;
    registered = false;
    constructor(_io, parent) {
        this.parent = parent;
    }
    onConnected() {
        if (this.listenToAPI && !this.registered) {
            this.registered = true;
            this.registerMethod(this.listenToAPI);
        }

    }
    /**
     * @description handles interop incoming message from other connected component, i.e. 'T42.SF.Test.Inbound'
     * @param {Object} message - invocation message with:
     *  - {string} method - the name of the remote method to be executed in the connected component
     *  - {Object} payload - the payload to be passed to the remote method
     *  - {string} callbackID - the internal auto generated callback ID
     */
    handlePlatformMessage(message) {
        if (this.listenToAPI && message.method === this.listenToAPI) {
            return this.onInbound(message.method, message.payload, message.callbackID);
        }
        return '';
    }
    /**
     * @description handles interop incoming message from other connected component, i.e. 'T42.SF.Test.Inbound'
     * @param {string} method - the name of the remote method to be executed in the connected component
     * @param {Object} payload - the payload to be passed to the remote method
     * @param {string} callbackID - the internal auto generated callback ID
     */
    // eslint-disable-next-line no-unused-vars
    onInbound(method, payload, callbackID) {
        console.debug(`ioConnect: platform message (${method}) received`);
    }

    // it is @api in InteropConsumerMixin
    requestConnectionStatus(...args) {
        this.parent.requestConnectionStatus(...args);
    }
    // it is @api in InteropConsumerMixin
    executePlatformMessageCallback(...args) {
        this.parent.executePlatformMessageCallback(...args);
    }
    // it is @api in InteropConsumerMixin
    triggerOutbound(...args) {
        this.parent.triggerOutbound(...args);
    }
    // it is @api in InteropConsumerMixin
    registerMethod(...args) {
        this.parent.registerMethod(...args);
    }
    // it is @api in InteropConsumerMixin
    unregisterMethod(...args) {
        this.parent.unregisterMethod(...args);
    }
}


export class ListenPlugin extends LMSPlugin {
    listenToAPI = TEST_INBOUND_METHOD
    onConnected() {
        super.onConnected();
        console.log(`ioConnect: ${this.constructor.name}`);
    }

    /**
     * @description handles interop incoming message from other io-connected application, i.e. 'My.LMS.Test.Inbound.Method'
     * @param {string} method - the name of the remote method to be executed in the connected component
     * @param {Object} payload - the payload to be passed to the remote method
     * @param {string} callbackID - the internal auto generated callback ID
     */
    onInbound(method, payload, callbackID) {
        const { value } = payload;
        if (method !== this.listenToAPI) {
            throw new Error(`Expected to receive (${this.listenToAPI}) it's own registered method, but received ${method}`);
        }
        // If `true`, the `lmsStandalone` component will resolve the invocation `Promise`.
        // If `false`, the `lmsStandalone` component will reject the invocation `Promise`.
        const isSuccessful = value ? true : false;
        // Result that will be returned from invoking the Interop method.
        const invocationResult = { "OK": value ? true : false };

        // Send a response to the `lmsStandalone` component.
        // The `lmsStandalone` component will relay the response to the calling app.
        super.executePlatformMessageCallback(callbackID, isSuccessful, invocationResult);

        console.log(`onInbound: data received: ${value}.`);

        const lastValue = value;
        return lastValue;
    }

}

export class BroadcastPlugin extends LMSPlugin {
    onConnected() {
        super.onConnected();
        console.log(`ioConnect: ${this.constructor.name}`);
        // Invoke an outbound Interop method.
        this.triggerOutbound();
        console.log(`onConnected: sending ${TEST_OUTBOUND_METHOD}`);
    }

    triggerOutbound() {
        // Providing the name of the Interop method to invoke and arguments for the invocation.
        const args = {
            method: TEST_OUTBOUND_METHOD,
            payload: { value: "value" }
        };

        // Invoke an outbound Interop method.
        // Alternatively, you can use the io.Connect API directly: this.io.interop.invoke(args.method, args.payload);
        super.triggerOutbound(args);
    }
}
Enter fullscreen mode Exit fullscreen mode

Example lmsMixin.js implementation of an interop-enabled LWC:

// import {EnclosingUtilityId, getInfo, getUtilityInfo, open, minimize, updatePanel, updateUtility} from 'lightning/platformUtilityBarApi';
import { api } from "lwc";

import { APPLICATION_SCOPE, createMessageContext, publish, releaseMessageContext, subscribe, unsubscribe } from "lightning/messageService";

import CONNECTION_STATUS_UPDATE from "@salesforce/messageChannel/Tick42__ConnectionStatusUpdate__c";
import CONNECTION_STATUS_REQUEST from "@salesforce/messageChannel/Tick42__ConnectionStatusUpdateRequest__c";
import PLATFORM_MESSAGE from "@salesforce/messageChannel/Tick42__PlatformMessage__c";
import PLATFORM_MESSAGE_CALLBACK from "@salesforce/messageChannel/Tick42__PlatformMessageCallback__c";
import PLATFORM_MESSAGE_REGISTER from "@salesforce/messageChannel/Tick42__PlatformMessageRegister__c";
import PLATFORM_MESSAGE_UNREGISTER from "@salesforce/messageChannel/Tick42__PlatformMessageUnregister__c";
import SALESFORCE_MESSAGE from "@salesforce/messageChannel/Tick42__SalesforceMessage__c";

export const InteropConsumerMixin = (Base)=>class extends Base {
    messageContext = createMessageContext(); // LMS context
    subscriptions = []; // the LMS subscriptions

    connectedCallback() {
        if (super.connectedCallback) super.connectedCallback();
        this.subscribeToMessageChannels();
    }

    subscribeToMessageChannels() {
        if (this.subscriptions.length) {
            return;
        }

        this.subscriptions.push(subscribe(
            this.messageContext,
            PLATFORM_MESSAGE,
            (message) => this.handlePlatformMessage(message),
            { scope: APPLICATION_SCOPE }
        ));
        this.subscriptions.push(subscribe(
            this.messageContext,
            CONNECTION_STATUS_UPDATE,
            (message) => this.handleConnectionStatusUpdate(message),
            { scope: APPLICATION_SCOPE }
        ));
    }

    /**
     * @description handles interop incoming message from other connected component, i.e. 'T42.SF.Test.Inbound'
     * @param {Object} message - invocation message with:
     *  - {string} method - the name of the remote method to be executed in the connected component
     *  - {Object} payload - the payload to be passed to the remote method
     *  - {string} callbackID - the internal auto generated callback ID
     */
    handlePlatformMessage(message) {
        console.debug(`ioConnect: platform message (${message.method}) received`);
    }

    handleConnectionStatusUpdate(message) {
        const {isConnected} = message;
        if (!isConnected) return;
        this.isConnected = isConnected;
    }

    unsubscribeFromMessageChannels() {
        if (this.subscriptions?.length > 0) {
            this.subscriptions.forEach(unsubscribe);
            this.subscriptions.length = 0;
        }
    }

    disconnectedCallback() {
        if (super.disconnectedCallback) super.disconnectedCallback();
        this.unsubscribeFromMessageChannels();
        releaseMessageContext(this.messageContext);
    }

    renderedCallback() {
        // Note: all @wire are executed before renderedCallback()
        if (super.renderedCallback) super.renderedCallback();
        this.requestConnectionStatus();
    }

    @api
    requestConnectionStatus() {
        publish(this.messageContext, CONNECTION_STATUS_REQUEST);
    }

    /**
     * A Platform-call result callback
     * @param {String} callbackID the callback ID
     * @param {Boolean} isSuccess the state of result
     * @param {Object} payload the data to send to the callback
     */
    @api
    executePlatformMessageCallback(callbackID, isSuccess, payload) {
        if (!callbackID || !(typeof callbackID=== 'string') || typeof isSuccess !== 'boolean') {
            throw Error('ioConnect: callbackID and status are required\n callbackID is an auto generated system ID for the Promise callbacks\n and status is a boolean where you need to program it\'s value as true or false.');
        }
        publish(this.messageContext, PLATFORM_MESSAGE_CALLBACK, {callbackID, status: isSuccess, payload});
    }

    /**
     * Sends a Platform message request
     * @param {Object} payload the data to send to the platform
     */
    @api
    triggerOutbound(payload) {
        publish(this.messageContext, SALESFORCE_MESSAGE, {...payload});
    }

    @api
    registerMethod(method) {
        publish(this.messageContext, PLATFORM_MESSAGE_REGISTER, { method });
    }

    @api
    unregisterMethod(method) {
        publish(this.messageContext, PLATFORM_MESSAGE_UNREGISTER, { method });
    }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)