Skip to content

SPAC: Controller Self-Initialization & Object API

By Sebastian Günther

Posted in Javascript, Framework, Spac_framework

SPAC is a custom JavaScript framework for client-side, single-page web applications. It stands for "Stateful Pages, Actions and Components". Its design goal is to provide robust and simple entities that help you to structure apps. Pages and components provide the HTML, JavaScript functions and UI interactions. Actions govern external API calls. You define these entities in plain JavaScript, load up the central controller, and your app is ready to be served. Read the development journey of SPAC in my series: https://admantium.com/category/spac-framework/.

The core entity of SPAC is the controller: A self-initializing object that assembles your web application from its pages, actions and components. This article details the self-initialization phase, how it works, how it creates an internal objects API and its bindings to the browser.

Ongoing Example: ApiBlaze Index Page

To explain the concepts in detail, we will use the example of ApiBlaze, an ongoing development project that enables blazing-fast searches in API descriptions. You can read more about ApiBlaze in the project kickoff article.

ApiBlaze first screen consists of a search bar and a search result popup. When you execute a search, the appropriate action will be triggered. The directory layout for this screen is as follows:

src
└── actions
│   └──  SearchApiSpecAction.js
└── components
    ├── ApiSearchComponent.js
    └── ApiSearchResultsComponent.js
└── pages
│   ├── IndexPage.js
│   ├── SearchApiElementsPage.js
└── index.js

Before starting the app, you need to provide an inventory.json file, which contains file links to all pages, actions and components. This file is generated by npm run bootstrap. For this example, it looks as follows:

{
  "pages": ["/src/pages/IndexPage.js", "/src/pages/SearchApiSpecAction.js"],
  "components": [
    "/src/components/ApiSearchComponent.js",
    "/src/components/ApiSearchResultsComponent.js"
  ],
  "actions": ["/src/actions/SearchApiSpecAction.js"]
}

Self-Initialization Process

The file index.js contains code for importing the controller and starting the self-initialization. Typically, it looks like this:

import { Controller } from 'spac'
import inventory from './inventory.json'

const controller = new Controller({ inventory })

controller.init()

As you see, the controller is initialized by receiving the inventory, and then calling the async function init(). During the initialization, the controller makes the following steps:

  • For each file listed in the inventory, check...
    • That the file name conforms with the naming pattern (/.*Page.js/, /.*Action.js/ or *Component.js/)
    • That the file exports a class of the appropriate type
  • Each of these classes is added to an internal Map object:
    • pagesMap: Define entries with route and obj properties
    • actionsMap: Define entries with obj properties
    • componentsMap: Define entries with obj properties

Files that do not conform to the naming patterns, or files for which the type check fails, are ignored.

Let’s see the details by following an example. The following excerpt shows init method and how the /pages directory will be traversed.

init () {
  this._initMap(Page, 'pages', /Page.js/)
  // ....
}

_initMap (parentClass, mapType, pattern) {
  this.inventory[mapType].forEach(async filePath => {
    try {
      if (!filePath.match(pattern)) {
        throw new Error()
      }
      const name = filePath.split('/').pop().replace(pattern, '')
      const clazz = (await import(`${filePath}`)).default

      if (clazz.prototype instanceof parentClass) {
        if (parentClass === Page) {
          const route = `/${name.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase()}`
          this[mapType].set(name, { route, clazz })
        } else {
          this[mapType].set(name, { clazz })
        }
      }
    } catch (e) {
      // ...
    }
  })
}

In this method:

  • Line 2: The init function calls an internal helper _initMap()
  • Line 6: For each file inside the inventory...
    • Line 8: ... check that it matches the given pattern
    • Line 11: ... attempt a dynamic import of the file
    • Line 13: ... check the file exports a class of the given parentClass
  • Line 16/18: Store the name and an object containing the export in the given mapType

Internal Objects API

When the initialization phase for the above mentioned example is completed, we obtain map objects that can be accessed and used in the application directly from the controller.

Pages

The page map object:

pages = {
  Index: {
    route: '/index',
    clazz: IndexPage()
  },
  SearchApiElements: {
    route: '/search_api_elements',
    clazz: SearchApiElementsPage()
  },
  SearchApiSpec: {
    route: '/search_api_spec',
    clazz: SearchApiSpecPage()
  }
}

Pages can be accessed with controller.page('PageName'), and the method controller.display('PageName') renders the page.

Components

The components map object:

components = {
  ApiSearch: {
    clazz: ApiSearchComponent()
  },
  ApiSearchResults: {
    clazz: ApiSearchResultsComponent()
  }
}

Components can be accessed with controller.component('componentName). This method is used by page objects to fetch their components.

Actions

The actions map object:

actions = {
  SearchApiSpec: {
    clazz: SearchApiSpecAction()
  }
}

Actions are accessed controller.action('ActionName').

Assembling Pages

When designing pages objects, you can choose to either manually import your components, or access the component via the controller.

The manual import looks like this:

import { Page } from 'spac'
import SearchBarComponent from '../components/SearchBarComponent.js'
import SearchResultsComponent from '../components/SearchResultsComponent.js'

export default class IndexPage extends Page {
  render = () => {
    return `
      <h1>ApiBlaze Explorer</h1>
      <section class='api-search-page'>
          <div id='search-api-spec' class='search-api-spec'></div>
          <div id="search-api-results" class="search-api-results"></div>
      </section>
    `
  }

  constructor (rootDom) {
    super(rootDom)
    this.addComponents(
      new SearchBarComponent('#search-api-spec'),
      new SearchResultsComponent('#search-api-results')
    )
  }
}

Alternatively, the objects API can be used to import components (and actions). For this, you need to add the special method _preloadComponents() and pass an object with component names and their arguments, e.g. the querySelector.

import { Page } from 'spac'

export default class IndexPage extends Page {
  render = () => {
    return `<h1>Hello</h1>`
  }

  _preloadComponents = () => {
    return {
      SearchBarComponent: { querySelector: '#search-api-spec' },
      SearchResultsComponent: { querySelector: '#search-api-results' }
    }
  }
}

During initialization, the Page class will check if this special method is defined, and if yes, use the Controllers component method, to retrieve the class definition and create an instance of the particular component.

class Page extends PageInterface {
  mount(querySelector) {
    super.mount(querySelector)
    // ...
    if (this._preloadComponents) {
      for (let [name, params] of this._preloadComponents()) {
        const instance = this.controller.component(name, params)
        this.components.set(name, instance)
      }
    }
  }
}

Conclusion

This article explained how the controller's self-initialization phase works. First, we create an inventory of all pages, components and actions using the command npm run bootstrap. Then, when the controllers’ instance is created, it will use the inventory to define internal map objects that point to all defined classes. The controller rigorously checks that each file is named correctly and that it exports a class which is of type Page, Component or Action. Then, these map objects can be used to dynamically load the entities for assembling the pages.