Repository: single-spa/single-spa-layout
Pull request #30 (2020-06-01 22:52:48)

Support defining loaders and props in HTML
This provides a way to define props and loaders in your HTML


Patch:
diff --git a/src/constructRoutes.js b/src/constructRoutes.js
index 90fdec3..70596ea 100644
--- a/src/constructRoutes.js
+++ b/src/constructRoutes.js
@@ -56,12 +56,26 @@ import { resolvePath } from "./matchRoute.js";
  * loader?: string | import('single-spa').ParcelConfig;
  * }} Application
  *
+ * @typedef {{
+ * loaders: {
+ *   [key]: any;
+ * };
+ * props: {
+ *  [key]: any;
+ * }
+ * }} HTMLLayoutData
+ *
  * @param {RoutesConfig} routesConfig
+ * @param {HTMLLayoutData=} htmlLayoutData
  * @returns {ResolvedRoutesConfig}
  */
-export function constructRoutes(routesConfig) {
+export function constructRoutes(routesConfig, htmlLayoutData) {
   if (routesConfig && routesConfig.nodeName) {
-    routesConfig = domToRoutesConfig(routesConfig);
+    routesConfig = domToRoutesConfig(routesConfig, htmlLayoutData);
+  } else if (htmlLayoutData) {
+    throw Error(
+      `constructRoutes should be called either with an HTMLElement and layoutData, or a single json object.`
+    );
   }
 
   validateAndSanitize(routesConfig);
@@ -72,9 +86,10 @@ export function constructRoutes(routesConfig) {
  * Converts a domElement to a json object routes config
  *
  * @param {HTMLElement} domElement
+ * @param {HTMLLayoutData} htmlLayoutData
  * @returns {InputRoutesConfigObject}
  */
-function domToRoutesConfig(domElement) {
+function domToRoutesConfig(domElement, htmlLayoutData = {}) {
   if (domElement.nodeName.toLowerCase() !== "single-spa-router") {
     throw Error(
       `single-spa-layout: The HTMLElement passed to constructRoutes must be <single-spa-router>. Received ${domElement.nodeName}`
@@ -95,7 +110,7 @@ function domToRoutesConfig(domElement) {
 
   for (let i = 0; i < domElement.childNodes.length; i++) {
     result.routes.push(
-      ...elementToJson(domElement.childNodes[i], domElement.parentNode)
+      ...elementToJson(domElement.childNodes[i], htmlLayoutData)
     );
   }
 
@@ -123,9 +138,10 @@ function hasAttribute(element, attrName) {
 
 /**
  * @param {HTMLElement} element
+ * @param {HTMLLayoutData} htmlLayoutData
  * @returns {Array<Route>}
  */
-function elementToJson(element) {
+function elementToJson(element, htmlLayoutData) {
   if (element.nodeName.toLowerCase() === "application") {
     if (element.childNodes.length > 0) {
       throw Error(
@@ -136,7 +152,20 @@ function elementToJson(element) {
       type: "application",
       name: getAttribute(element, "name"),
     };
-    setProps(element, application, ["name", "loader"]);
+    const loaderKey = getAttribute(element, "loader");
+    if (loaderKey) {
+      if (
+        htmlLayoutData.loaders &&
+        htmlLayoutData.loaders.hasOwnProperty(loaderKey)
+      ) {
+        application.loader = htmlLayoutData.loaders[loaderKey];
+      } else {
+        throw Error(
+          `Application loader '${loaderKey}' was not defined in the htmlLayoutData`
+        );
+      }
+    }
+    setProps(element, application, ["name", "loader"], htmlLayoutData);
     return [application];
   } else if (element.nodeName.toLowerCase() === "route") {
     const route = {
@@ -150,9 +179,11 @@ function elementToJson(element) {
     if (hasAttribute(element, "default")) {
       route.default = true;
     }
-    setProps(element, route, ["path", "default"]);
+    setProps(element, route, ["path", "default"], htmlLayoutData);
     for (let i = 0; i < element.childNodes.length; i++) {
-      route.routes.push(...elementToJson(element.childNodes[i]));
+      route.routes.push(
+        ...elementToJson(element.childNodes[i], htmlLayoutData)
+      );
     }
     return [route];
   } else if (typeof Node !== "undefined" && element instanceof Node) {
@@ -165,7 +196,9 @@ function elementToJson(element) {
       if (element.childNodes && element.childNodes.length > 0) {
         element.routes = [];
         for (let i = 0; i < element.childNodes.length; i++) {
-          element.routes.push(...elementToJson(element.childNodes[i]));
+          element.routes.push(
+            ...elementToJson(element.childNodes[i], htmlLayoutData)
+          );
         }
       }
       return [element];
@@ -176,7 +209,9 @@ function elementToJson(element) {
       routes: [],
     };
     for (let i = 0; i < element.childNodes.length; i++) {
-      result.routes.push(...elementToJson(element.childNodes[i]));
+      result.routes.push(
+        ...elementToJson(element.childNodes[i], htmlLayoutData)
+      );
     }
     return [result];
   } else {
@@ -188,9 +223,10 @@ function elementToJson(element) {
  * @param {HTMLElement} element
  * @param {Route} route
  * @param {string[]} ignoredAttributes
+ * @param {HTMLLayoutData} htmlLayoutData
  */
-function setProps(element, route, ignoredAttributes) {
-  const attributes = element.attributes || entries(element.attrs);
+function setProps(element, route, ignoredAttributes, htmlLayoutData) {
+  const attributes = element.attributes || element.attrs;
   for (let i = 0; i < attributes.length; i++) {
     const { name, value } = attributes[i];
     if (ignoredAttributes.includes(name)) {
@@ -199,15 +235,21 @@ function setProps(element, route, ignoredAttributes) {
       if (!route.props) {
         route.props = {};
       }
-      route.props[name] = value;
+      const propName = value.trim() !== "" ? value : name;
+      if (
+        htmlLayoutData.props &&
+        htmlLayoutData.props.hasOwnProperty(propName)
+      ) {
+        route.props[name] = htmlLayoutData.props[propName];
+      } else {
+        throw Error(
+          `Prop '${name}' was not defined in the htmlLayoutData. Either remove this attribute from the HTML element or provide the prop's value`
+        );
+      }
     }
   }
 }
 
-function entries(obj) {
-  return Object.keys(obj).map((key) => ({ name: key, value: obj[key] }));
-}
-
 function validateAndSanitize(routesConfig) {
   validateObject("routesConfig", routesConfig);
 


Test patch:
diff --git a/test/constructApplications.test.js b/test/constructApplications.test.js
index 604c713..4f92671 100644
--- a/test/constructApplications.test.js
+++ b/test/constructApplications.test.js
@@ -201,6 +201,74 @@ describe(`constructApplications`, () => {
     ).toBe(true);
   });
 
+  it(`can merge route props and application props together`, () => {
+    const routes = constructRoutes({
+      mode: "history",
+      base: "/",
+      containerEl: "body",
+      routes: [
+        {
+          type: "route",
+          path: "route1",
+          props: { foo: 1, bar: 1, baz: 1 },
+          routes: [
+            {
+              type: "route",
+              path: "subroute",
+              props: { foo: 2, bar: 2, other: 2 },
+              routes: [
+                { type: "application", name: "app1", props: { foo: 3 } },
+              ],
+            },
+          ],
+        },
+        {
+          type: "route",
+          path: "route2",
+          props: { foo: -1, bar: -1, baz: -1 },
+          routes: [
+            {
+              type: "route",
+              path: "subroute",
+              props: { foo: -2, bar: -2, other: -2 },
+              routes: [
+                { type: "application", name: "app1", props: { foo: -3 } },
+              ],
+            },
+          ],
+        },
+      ],
+    });
+
+    const loadApp = (name) => {};
+
+    const applications = constructApplications({ routes, loadApp });
+
+    expect(applications[0].name).toEqual("app1");
+    expect(
+      applications[0].customProps(
+        "app1",
+        new URL("https://localhost/route1/subroute")
+      )
+    ).toEqual({
+      foo: 3,
+      bar: 2,
+      baz: 1,
+      other: 2,
+    });
+    expect(
+      applications[0].customProps(
+        "app1",
+        new URL("https://localhost/route2/subroute")
+      )
+    ).toEqual({
+      foo: -3,
+      bar: -2,
+      baz: -1,
+      other: -2,
+    });
+  });
+
   it(`places a loader in the dom while loading the code`, async () => {
     const routes = constructRoutes({
       routes: [
diff --git a/test/constructRoutes.test.js b/test/constructRoutes.test.js
index 5ef0ff3..eba9606 100644
--- a/test/constructRoutes.test.js
+++ b/test/constructRoutes.test.js
@@ -629,4 +629,76 @@ describe("constructRoutes", () => {
       });
     });
   });
+
+  describe("Loaders defined in HTML", () => {
+    it(`can parse and apply loaders from HTML files`, () => {
+      const { document, routerElement } = parseFixture("loaders.html");
+      const data = {
+        loaders: {
+          headerLoader: {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          },
+          mainContentLoader: `<img src="loading.gif">`,
+        },
+      };
+      const routes = constructRoutes(routerElement, data);
+
+      expect(routes.routes[0].loader).toBe(data.loaders.headerLoader);
+      expect(routes.routes[0].props).not.toBeDefined();
+      expect(routes.routes[1].routes[0].routes[0].loader).toBe(
+        data.loaders.mainContentLoader
+      );
+      expect(routes.routes[1].routes[0].routes[0].props).not.toBeDefined();
+    });
+  });
+
+  describe("props defined in HTML", () => {
+    it(`can assign valid props`, () => {
+      const { document, routerElement } = parseFixture("props.html");
+      const data = {
+        props: {
+          portalMode: "client",
+          prop1: "value1",
+          prop2: "value2",
+          prop3: "value3",
+        },
+      };
+      const routes = constructRoutes(routerElement, data);
+      expect(routes.routes[0].props).toEqual({
+        mode: data.props.portalMode,
+        prop1: data.props.prop1,
+      });
+      expect(routes.routes[1].routes[0].routes[0].props).toEqual({
+        mode: data.props.portalMode,
+        prop2: data.props.prop2,
+      });
+      expect(routes.routes[1].routes[0].props).toEqual({
+        prop3: "value3",
+      });
+    });
+
+    it(`throws with invalid props`, () => {
+      const { document, routerElement } = parseFixture("props.html");
+      expect(() => {
+        constructRoutes(routerElement);
+      }).toThrowError(/Prop '.+' was not defined/);
+    });
+
+    it(`throws when defining props for non-HTML routes`, () => {
+      const data = {
+        props: {
+          portalMode: "client",
+          prop1: "value1",
+          prop2: "value2",
+        },
+      };
+      expect(() => {
+        constructRoutes({ routes: [] }, data);
+      }).toThrowError(
+        /constructRoutes should be called either with an HTMLElement and layoutData, or a single json object./
+      );
+    });
+  });
 });
diff --git a/test/fixtures/loaders.html b/test/fixtures/loaders.html
new file mode 100644
index 0000000..b760b08
--- /dev/null
+++ b/test/fixtures/loaders.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Document</title>
+  </head>
+  <body>
+    <single-spa-router mode="history" base="/">
+      <application name="header" loader="headerLoader"></application>
+      <div class="main-content">
+        <route path="app1">
+          <application name="app1" loader="mainContentLoader"></application>
+        </route>
+      </div>
+    </single-spa-router>
+  </body>
+</html>
diff --git a/test/fixtures/props.html b/test/fixtures/props.html
new file mode 100644
index 0000000..3ea2f5c
--- /dev/null
+++ b/test/fixtures/props.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Document</title>
+  </head>
+  <body>
+    <single-spa-router mode="history" base="/">
+      <application name="header" mode="portalMode" prop1></application>
+      <div class="main-content">
+        <route path="app1" prop3>
+          <application name="app1" mode="portalMode" prop2></application>
+        </route>
+      </div>
+    </single-spa-router>
+  </body>
+</html>
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index d018aab..20d1d4f 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -30,6 +30,21 @@ const loaderParcelConfig = {
   async update() {},
 };
 
+constructRoutes(
+  {
+    routes: [],
+  },
+  {
+    loaders: {
+      headerLoader: `<img src="loading.gif"`,
+      mainContentLoader: loaderParcelConfig,
+    },
+    props: {
+      prop1: "val1",
+    },
+  }
+);
+
 const routes = constructRoutes({
   routes: [
     {