Developing an External Module


Developers can access external module hookup scripts to debug and troubleshoot their own modules integrated with the CMS. While external modules integrate with the CMS, these modules are separate from the CMS.

For security, this model uses CORS (cross-origin resource sharing) scripting. All external modules require CORS support to integrate with the CMS.

Hookup Script

Access the hookup script for an external module by opening ExternalModules.json in [Drive]:[path-to-CMS-root-folder]\site\App_Data and copying the URL value for the respective external module. Enter the URL in an open web browser.

Review the hookup script file. Navigate to document.body.onload = async () =>, and note the @src attribute. When documents load in the CMS with the external module, the system takes the @src attribute from the correlating element @id. If developers inspect the source within the CMS with web browser development tools, they will see tags with the @src attribute applied, along with the correlating @id. This tagging gets invoked, and the system uses them to create the CMSHookup object.

The constructor passes in the external module URL, which is pure JavaScript ES6.

As for the async setup code, this data resides in the respective external module repository. Developers can take this directly from the repository. Developers can locate the hookup script via const scriptsFinal = scripts and use the script as a boiler template.

This script creates an iframe for the external module.

See the following sample hookup script for an example:

External Module Hookup Script Sample
/**
 * The script that looks like module to CMS
 */

class CMSHookup {
    _moduleUri;

    constructor(moduleUri) {

        this._moduleUri = moduleUri.toLowerCase();

        //create a iframe to hold the module
        this.iframe = document.createElement('iframe');
        this.iframe.style.position = "absolute";
        this.iframe.style.left = "-9999px";
        document.body.appendChild(this.iframe);


    }

    async setup() {
        const scripts = [
            "polyfills.js",
            "main.js"
        ];

        //get version from server
        const rootUrl = this._moduleUri
        const ver = "0";
        const scriptsFinal = scripts
            .map(s => `${s}?${ver}`)
            .join(";");

        this.iframe.src = `CMS/ExternalModule?root=app-root`
            + `&url=${encodeURIComponent(this._moduleUri)}`
            + `&scripts=${encodeURIComponent(scriptsFinal)}`;
    }

    createScriptElement(src) {
        const scriptEle = document.createElement("script");
        scriptEle.setAttribute("src", src);
        scriptEle.setAttribute("type", "module");

        this.iframe.contentWindow.document.body.appendChild(scriptEle);
    }
}

//create hookup instance when body finished loading
document.body.onload = async () => {
    let url = document.getElementById("TestModule")?.getAttribute("src") || "";

    let pos = url.indexOf("/hookup/");
    if (pos > -1) {
        url = url.substring(0, pos);
    }

    window.modules_CMSHookup = new CMSHookup(url);
    await window.modules_CMSHookup.setup();
}

IFrame

The iframe points to the external module view and passes the component URL and all required scripts. The component URL is the base URL where the iframe will be hosted. The system injects hookup instance in front of the iframe.

Additionally, keep in mind that each script appends the external module version.

Knowing the version number can help with troubleshooting external module caching issues. The scripts fetch the version number from the server via the route rootUrl/Common/ver.

Note that the final script parameter includes the scripts. All the external module views pass through this final script parameter. Keep in mind that the scripts reside within the iframe and not within the CMS. The system requires developers to pass in all style modifications separately via the external module's Styles URL value in ExternalModules.json.

Component Development

The required scripts are a result of the external module build. The actual loaded script resides within the corresponding component.

For Angular development, keep in mind checks in the following files.

Important

The external modules system is not part of the CMS, so the system will not perform automatic Angular change detections. Be sure to inject ChangeDetectRef into the component and perform manual change detections along with the injection.

app.component.ts

This app component defined by app.component.ts listens to the CMS events. In particular, note the componentInitalized event. The NG reference is defined within const ngref = topWindow.NG_REF as CMSAppComponent;, which is, in a sense, the root component for the CMS from the top window. This code lives within the context of the iframe. The system requires the reference to the top window for all CMS functionality.

Keep in mind that the code filters out what needs to be handled in the system. If no element data is available, then this filtering gets bypassed.

The NG reference resides within the app component via the following code:

NG Reference Sample
const ngref = topWindow.NG_REF as CMSAppComponent;
                if (!ngref)
                return;

Developers can review this in the Google Chrome development tools Console tab. The tab provides developers with a listing of what the object includes (e.g., how IntelliSense works with the object). Developers can work with this directly within the Console tab or within a script editor. They can add a new window.top.ng_ref watch in the source debugging.

In other cases where, for example, developers want to access a toolbar to insert a new utility button, they can run a query selector with the parent element to locate a particular button. Then, they can insert code to add the new utility button before the existing one. This injects a new component into the existing UI that's already initialized.

Button Creation

To create the external module button, the system uses a dynamic component creator to insert the button on, for example, an ICE field. The created button is then associated with an anchor.

After creating the button, the system adds a mutation observer, an object that allows developers to monitor changes with the correlating node. Particular changes to monitor include @class attribute changes. If a class changes based on the eraser buttons, then the change will be matched with an anchor. This means that, if the class is hidden, the class will remain as hidden.

When the mutation observer initializes, developers can see the node. In the case of the external module button, this allows the button state to be synced with other objects.

See the following code for a custom button example:

Important
Review the comments within this example for additional information about creating the custom button.
Custom Button Sample
export class AppComponent {
	observableSubTeardowns = [];

	constructor() {
		const ngref = window.top['NG_REF']
		
		this.observableSubTeardowns.push(ngref.componentInitialized.subscribe(evt => {
			//get the whole edit form model
			const allElesData = (window.top as any).NG_REF.editFormDataService.serializedModel;

			if (!allElesData)
				return; //no edit form yet, skip

			//create button for edit form index fields
			this.createButtonOnField(evt, allElesData);
		}));

		this.observableSubTeardowns.push(ngref.componentDestroyed.subscribe(comp => {
			if (comp.constructor.name === "StructuralField") {
				//remove the button from the field upon it being destroyed
				evt.component.theButton?.parentElement?.removeChild(theButton);
				delete evt.component.theButton;
			}
		}));
	}
	
	private _mutationObserver;
	
	private createButtonOnField(evt: ViewInitModel, allElesData: any) {
		if (evt.component.constructor.name === "StructuralField") {
			//if text or html field, create an button next to the eraser button
			if ((evt.component as any).isText() || (evt.component as any).isHtml()) {
				const fieldNode = (evt.component as any).theButtonElement.nativeElement;
				const toolbarNode = fieldNode?.querySelector(".titleBarButtons");
				const eraserButtonNode = toolbarNode?.querySelector(".igx-fa-eraser");

				if (!!eraserButtonNode) {
					//create a node before the eraser button
					theButton = document.createElement("button");
					theButton.innerHTML = "The Button";
					theButton.disabled = true;
					
					//TODO: hook up event on the button to do something...

					toolbarNode.insertBefore(theButton, eraserButtonNode);

					//add classes after component created, since the host attribute will override the classes
					theButton.classList.add("igx-fa");
					theButton.classList.add("hidden");
					theButton.style.marginRight = "6px";

					this._mutationObserver = new MutationObserver((mutations) => {
						mutations.forEach((mutation) => {
							if (mutation.type === "attributes" && mutation.attributeName === "class") {
								const eraserButton = mutation.target as HTMLElement;
								if (eraserButton.classList.contains("hidden")) {
									theButton.classList.add("hidden");
								} else {
									theButton.classList.remove("hidden");
								}
							}
						});
					});

					//only enable the button when the details are created
					const sub = (evt.component as any).detailsCreated
						.subscribe(() => {
							sub.unsubscribe();
							if ((evt.component as any).isHtml())
								this.delay(1000); //wait for tinymce to complete building

							theButton.disabled = false;
						});

					//add the prop on the edit form field
					evt.component.theButton = theButton;
				}
			}
		}
	}	
}