Perhaps The Only OTHER SharePoint Framework Web Part You Will Ever Need

Posted by: Rob Windsor on June 14, 2017

A few months ago Mikael Svenson blogged about a SharePoint Framework Script Editor web part he'd written: Perhaps the only SharePoint Framework web part you will ever need

Since the introduction of the SharePoint Framework (SPFx) there's been lots of discussion about the use of JavaScript embedding and the need for SPFx versions of the Script Editor and Content Editor web parts.

No matter which side of the argument you're on, you can't deny the fact that JavaScript embedding is a technique that has been used extensively to customize SharePoint sites. So what's the path for migration of these customizations to the modern experience?

One option is to use the techniques described by my colleague Mark Rackley in his blog post: Converting your CEWP Customizations to the SharePoint Framework.

This is a perfectly valid option and, besides a complete rewrite of the customization, is probably the most robust and future looking way to go. But what if you have hundreds or even thousands of these types of customization in your organization. Migrating every single one is going to be quite a bit of work.

With this in mind I took the core of Mikael's Script Editor web part and I built a prototype Content Editor web part. One that works as closely as possible to the "Classic" Content Editor web part. My goal is to enable organizations to reuse as many of their existing Content Editor (CEWP) customizations as possible. It is important to note that this goal does not include customization that utilize SharePoint DOM manipulation, the DOM for classic and modern pages are just too different. 

Here are the steps I took during the building of the prototype:

Step 1: Create a new client web part.
I chose not to use a UI framework.

Step 2: Add the Content Link property
I didn't really add the property. I just changed the property that is part of the template from Description to Content Link.

Step 3: Choose to use non-reactive properties
By default, the web part tries to update as you are entering property values. The content link property is the URL to a document so it doesn't make sense to try and update the web part until the full URL has been entered so I wanted to change the property window to become non-reactive. That is, I wanted the user to click an Apply button once the content link had been entered. To do this I needed to override the disableReactivePropertyChanges property.

protected get disableReactivePropertyChanges(): boolean {
  return true;
}

Step 4: Add the code to execute embedded JavaScript
I just copied the code from Mikael's blog post as-is.

// Finds and executes scripts in a newly added element's body.
// Needed since innerHTML does not run scripts.
//
// Argument element is an element in the dom.
private executeScript(element: HTMLElement) {
  // Define global name to tack scripts on in case script to be loaded is not AMD/UMD
  (window).ScriptGlobal = {};

  function nodeName(elem, name) {
    return elem.nodeName && elem.nodeName.toUpperCase() === name.toUpperCase();
  }

  function evalScript(elem) {
    var data = (elem.text || elem.textContent || elem.innerHTML || ""),
      head = document.getElementsByTagName("head")[0] ||
        document.documentElement,
      script = document.createElement("script");

    script.type = "text/javascript";
    if (elem.src && elem.src.length > 0) {
      return;
    }
    if (elem.onload && elem.onload.length > 0) {
      script.onload = elem.onload;
    }

    try {
      // doesn't work on ie...
      script.appendChild(document.createTextNode(data));
    } catch (e) {
      // IE has funky script nodes
      script.text = data;
    }

    head.insertBefore(script, head.firstChild);
    head.removeChild(script);
  }

  // main section of function
  var scripts = [],
    script,
    children_nodes = element.childNodes,
    child,
    i;

  for (i = 0; children_nodes[i]; i++) {
    child = children_nodes[i];
    if (nodeName(child, "script") &&
      (!child.type || child.type.toLowerCase() === "text/javascript")) {
      scripts.push(child);
    }
  }

  const urls = [];
  const onLoads = [];
  for (i = 0; scripts[i]; i++) {
    script = scripts[i];
    if (script.src && script.src.length > 0) {
      urls.push(script.src);
    }
    if (script.onload && script.onload.length > 0) {
      onLoads.push(script.onload);
    }
  }

  // Execute promises in sequentially - https://hackernoon.com/functional-javascript-resolving-promises-sequentially-7aac18c4431e
  // Use "ScriptGlobal" as the global namein case script is AMD/UMD
  const allFuncs = urls.map(url => () => SPComponentLoader.loadScript(url, { globalExportsName: "ScriptGlobal" }));

  const promiseSerial = funcs =>
    funcs.reduce((promise, func) =>
      promise.then(result => func().then(Array.prototype.concat.bind(result))),
      Promise.resolve([]));

  // execute Promises in serial
  promiseSerial(allFuncs)
    .then(() => {
      // execute any onload people have added
      for (i = 0; onLoads[i]; i++) {
        onLoads[i]();
      }
      // execute script blocks
      for (i = 0; scripts[i]; i++) {
        script = scripts[i];
        if (script.parentNode) { script.parentNode.removeChild(script); }
        evalScript(scripts[i]);
      }
    }).catch(console.error);
};  

Step 5: Add test embed code used when running in local workbench
You can't link to a file in SharePoint when you're running in the local workbench so I hard-coded the sample Mikael used in his blog post.

public render(): void {
  if (Environment.type == EnvironmentType.Local) {
    this.domElement.innerHTML = `
    <div id="weather"></div>
    <script src="//code.jquery.com/jquery-2.1.1.min.js"></script>
    <script src="//cdnjs.cloudflare.com/ajax/libs/jquery.simpleWeather/3.1.0/jquery.simpleWeather.min.js"></script>
    <script>
      jQuery.simpleWeather({
      location: 'Oslo, Norway',
      woeid: '',
      unit: 'c',
      success: function(weather) {
        html = '<h2>'+weather.temp+'°'+weather.units.temp+'</h2>';
        html += '<ul><li>'+weather.city+', '+weather.region+'</li>';
        html += '<li>'+weather.currently+'</li></ul>';
      
        jQuery("#weather").html(html);
      },
      error: function(error) {
        jQuery("#weather").html('<p>'+error+'</p>');
      }
      });
    </script>`;
    this.executeScript(this.domElement);
  } else {
    // See code in next step
  }
}

 

Step 6: Add the code to embed the content from the linked file
I used the REST API to get the file contents. The REST API needs a server relative URL so I added code to check if the property value is an absolute URL and, if it is, to convert it to a server relative URL.

} else {
if (this.properties.contentLink) {
  // Get server relative URL to content link file
  let filePath = this.properties.contentLink;
  if (filePath.toLowerCase().substr(0, 4) == "http") {
    let parts = filePath.replace("://", "").split("/");
    parts.shift();
    filePath = "/" + parts.join("/");
  }

  // Get file and read script
  let webUrl = this.context.pageContext.web.absoluteUrl;
  this.context.spHttpClient.get(webUrl + 
    "/_api/Web/getFileByServerRelativeUrl('" + filePath + "')/$value", 
    SPHttpClient.configurations.v1)
    .then((response) => {
      return response.text();
    })
    .then((value) => {
      this.domElement.innerHTML = value;
      this.executeScript(this.domElement);
    });
}

Step 7: Also embed the _spPageContextInfo global variable and the form digest hidden field
The _spPageContextInfo global variable and the __REQUESTDIGEST hidden field are commonly used in CEWP customizations but they are not included in modern pages. It took me a while to figure out how to easily populate _spPageContextInfo. I started by creating an new object and populating with different properties from the context. Then I stumbled across the legacyPageContext property which was exactly what I needed.  The code below replaces the code shown for Step 6.

} else {
if (this.properties.contentLink) {
  // Add _spPageContextInfo global variable 
  let w = (window as any);
  if (!w._spPageContextInfo) {
    w._spPageContextInfo = this.context.pageContext.legacyPageContext;
  }

  // Add form digest hidden field
  if (!document.getElementById('__REQUESTDIGEST')) {
    let digestValue = this.context.pageContext.legacyPageContext.formDigestValue;
    let requestDigestInput: Element = document.createElement('input');
    requestDigestInput.setAttribute('type', 'hidden');
    requestDigestInput.setAttribute('name', '__REQUESTDIGEST');
    requestDigestInput.setAttribute('id', '__REQUESTDIGEST');
    requestDigestInput.setAttribute('value', digestValue);
    document.body.appendChild(requestDigestInput);        
  }        

  // Get server relative URL to content link file
  let filePath = this.properties.contentLink;
  if (filePath.toLowerCase().substr(0, 4) == "http") {
    let parts = filePath.replace("://", "").split("/");
    parts.shift();
    filePath = "/" + parts.join("/");
  }

  // Get file and read script
  let webUrl = this.context.pageContext.web.absoluteUrl;
  this.context.spHttpClient.get(webUrl + 
    "/_api/Web/getFileByServerRelativeUrl('" + filePath + "')/$value", 
    SPHttpClient.configurations.v1)
    .then((response) => {
      return response.text();
    })
    .then((value) => {
      this.domElement.innerHTML = value;
      this.executeScript(this.domElement);
    });
}

 

That completes what I've done to this point on the web part. I'm certain it will evolve and improve as I use it more regularly. I'll make sure to blog about changes as I make them.

You can download the code for the web part from this GitHub repository.

I've tested the web part with several "legacy" CEWP customizations and most of them have only required minor tweaks. I plan on doing a YouTube video that shows these customizations running in a classic page using SharePoint's Content Editor, running in a classic page using my SPFx Content Editor, and running in a modern page using my SPFx Content Editor.

Categories