Source: engine/universal/ui/src/ui.js

/* eslint-disable no-undef */
const { network } = require('@proceed/system');
const DisplayItem = require('./display-item.js');
const uiHTML = require('./uiHTML.js');

function hasWindow() {
  // window global is present and we are allowed to use it
  return typeof window === 'object' && !window.PROCEED_DONT_WRITE_WINDOW;
}

function escapeScriptTags(s) {
  // https://stackoverflow.com/questions/14780858/escape-in-script-tag-contents
  return s.replace(/<\/script>/gi, '</scr\\ipt>');
}

function generateUI(displayItems) {
  const nav = `<ul id="nav">
  ${displayItems
    .map(
      (dI) =>
        `<li class="item" data-key="${dI.key}"><span>${dI.title}</span><span class="badge">${dI.badge}</span></li>`
    )
    .join('\n')}
  </ul>`;

  return uiHTML.header + nav + uiHTML.content;
}

function validateEndpointArgs(method, path, body, query) {
  if (!path || typeof path !== 'string') {
    throw new Error('Path is required to be a string!');
  }
  if (path.length < 3 || path.indexOf('/', 1) === -1) {
    throw new Error('Path has to be of form `/key/[endpoint]`!');
  }
  if (method === 'get' && body !== null) {
    throw new Error('A body payload with GET requests is not supported!');
  }
  if (query && typeof query !== 'object') {
    throw new Error('Query has to be an object!');
  }
}

/**
 * @module @proceed/ui
 */
const ui = {
  /**
   * The display items the UI module is managing.
   * @type {module:@proceed/ui.DisplayItem[]}
   * @memberof module:@proceed/ui
   * @private
   */
  _displayItems: [],

  /**
   * An endpoint object for specifying both a GET and a POST function for one
   * path.
   * @typedef {Object} EndpointObject
   * @property {Function} get The endpoint function for GET requests
   * @property {Function} post The endpoint function for POST requests
   * @memberof module:@proceed/ui
   */

  /**
   * Endpoints of a display item consisting of a path and the corresponding
   * function (or a EndpointObject with GET and POST functions) which the UI
   * module can provide to the SPA in order to retrieve some data.
   * @typedef {Object} Endpoints
   * @property {(Function|module:@proceed/ui.EndpointObject)} {path} {path} is
   * the string identifying the route to this endpoint (prepended by the display
   * item's key). The value is either a function (for only GET requests) or an
   * EndpointObject for specifying both, a GET and a POST function for this
   * path.
   * @memberof module:@proceed/ui
   */

  /**
   * The endpoints that belong to the registered display items. Keys are the
   * display item keys, values their endpoints array. Retrieved by calling the
   * getEndpoints() method on the display items.
   * @type {Map<String,module:@proceed/ui.Endpoints>}
   * @memberof module:@proceed/ui
   * @private
   */
  _endpoints: new Map(),

  /**
   * Boolean indicating whether the UI module has already been initialilzed or
   * not.
   * @type {Boolean}
   * @memberof module:@proceed/ui
   * @private
   */
  _displayed: false,

  /**
   * Initialize the UI module. This method generates the HTML/CSS/JS needed to
   * display the SPA with all the registered display items. It automatically
   * checks if a window object is present (WebView environment) and directly
   * manipulates it or it opens HTTP endpoints (other environment). It is
   * (currently) not possible to add display items after the init() call was
   * made.
   * @memberof module:@proceed/ui
   */
  init() {
    this._displayed = true;

    const html = generateUI(this._displayItems);
    const { script, css: uiStyle } = uiHTML;

    // Set the content data as an object
    const content = {};
    this._displayItems.forEach((dI) => {
      // Set the getter for each display item to avoid copying all the contents
      // Important: enumerable: true for JSON.stringify in non-WebView case
      Object.defineProperty(content, dI.key, { enumerable: true, get: () => dI.content });
    });

    if (hasWindow()) {
      // Directly manipulate the window object of the browser environment this
      // engine is running in.
      const style = window.document.createElement('style');
      style.type = 'text/css';
      if (style.styleSheet) {
        // IE
        style.styleSheet.cssText = uiStyle;
      } else {
        style.innerHTML = uiStyle;
      }
      window.document.head.appendChild(style);

      const viewport = window.document.createElement('meta');
      viewport.name = 'viewport';
      viewport.content = 'width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no';
      window.document.head.appendChild(viewport);

      window.document.body.innerHTML = html;

      // Insert the content data
      window.PROCEED_UI_CONTENTS = content;

      window.PROCEED_DATA = {
        get: (path, query) => this._handleEndpointRequest('get', path, null, query),
        post: (path, body, query) => this._handleEndpointRequest('post', path, body, query),
        put: (path, body, query) => this._handleEndpointRequest('put', path, body, query),
      };

      // Execute site script
      script();
    } else {
      // No WebView environment, open HTTP endpoints instead.
      this._endpoints.forEach((endpoints, key) => {
        Object.entries(endpoints).forEach(([path, endpoint]) => {
          if (typeof endpoint === 'function' || typeof endpoint.get === 'function') {
            const cb = typeof endpoint === 'function' ? endpoint : endpoint.get;
            network.get(`/${key + path}`, { cors: true }, (req) =>
              cb(req.query).then(JSON.stringify)
            );
          }
          if (typeof endpoint.post === 'function') {
            network.post(`/${key + path}`, { cors: true }, (req) =>
              endpoint.post(req.body, req.query).then(JSON.stringify)
            );
          }

          if (typeof endpoint.put === 'function') {
            network.put(`/${key + path}`, { cors: true }, (req) =>
              endpoint.put(req.body, req.query).then(JSON.stringify)
            );
          }
        });
      });

      // Wrap with a html skeleton
      const wrapper = `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="UTF-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
          <meta
            name="description"
            content="This is the tasklist from the PROCEED BPMS where you can see and work on your tasks."
          />
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAB3BAMAAAAeDIOYAAAAD1BMVEUAAABZWVlgYGBmZmZsbGy1A1kgAAAAAXRSTlMAQObYZgAAAAlwSFlzAAC4jAAAuIwBzPa7LwAAAWRJREFUaN7tmu0NgzAMRMPHAEAYAFoGCBsA+w/VDQqW41wSLv8tWSfn9ZHaudvTj+qzONXZ1Q14XQOtPoJD18GqbmCGR+DQESjHsFE3MOkaqOEmKiPo9BEEwogwIozKh9FJGNUAo4C+iRthtKAjIIwyhFH3uT3BFkayEgMYnbKS+GbUy0oM5HCXlcSHUScrMYDRKioxgFErKznRERjASBgBYWRgRoTRk8keCKOsYNQTRi81o29lMBp0ZmTwZrQTRjQjmtGTyfa2MEJrQXI3nOQzcJjOwAoegTb1JfCZkTA5CCfdHYz+dSL0gfg/hg8idZZG1KWG0Ph6CAXx/5K2b8ayACqAkJLChBAggM0UQhfahtEyXIAJeZqQ5deIEEIXGkKIAOo2oQd2Hfd5cMpNBApbZooPocJ2SC5wAPA9oiY6hArbJYOv9q6EEDYAQggdQFs+hWdCiBDCBkATogkRQlgR+AOhHxPPunGOG5/7AAAAAElFTkSuQmCC" />
          <title>PROCEED Tasklist</title>
          <script type="text/javascript">
            ${validateEndpointArgs.toString()}
            window.PROCEED_UI_CONTENTS = ${escapeScriptTags(JSON.stringify(content))};
            window.PROCEED_DATA = {
              get: async (path, query) => {
                ${validateEndpointArgs.name}('get', path, null, query);
                return new Promise((resolve, reject) => {
                  const xhr = new XMLHttpRequest();
                  xhr.addEventListener('loadend', (req) => {resolve(JSON.parse(req.target.responseText)); });
                  const url = query ? path + '?' + Object.entries(query).map(([key, value]) => key + '=' + encodeURIComponent(value)).join('&') : path;
                  xhr.open('GET', url, true);
                  xhr.send();
                });
              },
              post: async (path, body, query) => {
                ${validateEndpointArgs.name}('post', path, body, query);
                return new Promise((resolve, reject) => {
                  const xhr = new XMLHttpRequest();
                  xhr.addEventListener('loadend', (req) => {resolve(JSON.parse(req.target.responseText)); });
                  const url = query ? path + '?' + Object.entries(query).map(([key, value]) => key + '=' + encodeURIComponent(value)).join('&') : path;
                  xhr.open('POST', url, true);
                  xhr.setRequestHeader('Content-Type', 'application/json');
                  xhr.send(JSON.stringify(body));
                });
              },
              put: async (path, body, query) => {
                ${validateEndpointArgs.name}('put', path, body, query);
                return new Promise((resolve, reject) => {
                  const xhr = new XMLHttpRequest();
                  xhr.addEventListener('loadend', (req) => {resolve(JSON.parse(req.target.responseText)); });
                  const url = query ? path + '?' + Object.entries(query).map(([key, value]) => key + '=' + encodeURIComponent(value)).join('&') : path;
                  xhr.open('PUT', url, true);
                  xhr.setRequestHeader('Content-Type', 'application/json');
                  xhr.send(JSON.stringify(body));
                });
              }
            };
          </script>
          <style type="text/css">${uiStyle}</style>
        </head>
        <body>
          ${html}
          <script type="text/javascript">(${
            /* Make the script an IIFE */ script.toString()
          })()</script>
        </body>
      </html>`;

      // Serve as root page
      network.get('/', async () => ({ response: wrapper, mimeType: 'html' }));
    }
  },

  /**
   * Add a display item to the UI module.
   * @param {module:@proceed/ui.DisplayItem} displayItem The display item which
   * has to be an instance of the DisplayItem class
   * @memberof module:@proceed/ui
   */
  addDisplayItem(displayItem) {
    if (this._displayed) {
      throw new Error(
        "Trying to add a display item after the UI module's init() call!\nDynamically adding display items is not yet supported!"
      );
    }
    if (!displayItem) {
      throw new Error('No display item was given!');
    }
    if (!(displayItem instanceof DisplayItem)) {
      throw new Error('The given argument is not an instance of DisplayItem!');
    }
    if (this._displayItems.some((dI) => dI.key === displayItem.key)) {
      throw new Error(`There already exists a display item with that key! (${displayItem.key})`);
    }

    this._displayItems.push(displayItem);

    const newEndpoints = displayItem.getEndpoints();

    if (!newEndpoints || typeof newEndpoints !== 'object') {
      throw new Error('Endpoints have to be an object!');
    }

    this._endpoints.set(displayItem.key, newEndpoints);
  },

  /**
   * Handle an endpoint request. This method finds and executes the endpoint
   * function that was given by a registered display item for the `path`
   * parameter.
   * @param {String} method Either `get` or `post`
   * @param {String} path The path for the requested endpoint (including the
   * display item's key)
   * @param {object} [body] The optional body object
   * @param {object} [query] The optional query for the endpoint
   * @memberof module:@proceed/ui
   */
  async _handleEndpointRequest(method, path, body, query) {
    if (!method || typeof method !== 'string' || !['get', 'post', 'put'].includes(method)) {
      throw new Error('Method has to be either `get` or `post`!');
    }

    validateEndpointArgs(method, path, body, query);

    const key = path.substring(1, path.indexOf('/', 1));
    const endpoints = this._endpoints.get(key);

    if (!endpoints) {
      throw new Error(`There are no endpoints registered for \`${key}!\``);
    }

    const endpointPath = path.substring(key.length + 1, path.length);
    const endpoint = endpoints[endpointPath];

    if (!endpoint || (typeof endpoint !== 'function' && typeof endpoint[method] !== 'function')) {
      throw new Error(`No function for the requested endpoint \`${path}\` registered!`);
    }
    if (
      method !== 'get' &&
      (typeof endpoint === 'function' || typeof endpoint[method] !== 'function')
    ) {
      throw new Error(
        `No function for the requested \`${method}\` endpoint \`${path}\` registered!`
      );
    }

    const endpointFunc = typeof endpoint === 'function' ? endpoint : endpoint[method];
    if (body === null) {
      return endpointFunc(query);
    }
    return endpointFunc(body, query);
  },
};

module.exports = ui;