@alethio/cms
v1.0.0-beta.12
Published
Alethio Content Management System
Downloads
104
Keywords
Readme
Alethio CMS
Alethio CMS is a front-end content management system designed for real-time and frequently updating data. It features a plugin-based architecture, supporting independent content modules that can be configured and loaded at runtime.
- Features
- Concepts
- Getting started
- Plugin tutorial
- Prerequisites
- Writing a simple module
- Adding real data and module context
- Reusing data adapters
- State transitions and optional adapters
- Page critical modules
- Reacting to data changes
- Reacting to MobX observable changes
- Adding plugin configuration
- Configuration per module/page
- Plugin localization and translation strings
- Plugin runtime API
- TypeScript support
- Linking between internal pages
- Creating dynamic contexts
- Data adapter dependencies
- Inline modules
- Data Sources
- Data dependencies between plugins
- Inline plugins
- Deployment with plugins
- Help System
- CMS Development
Features
- Dynamic content loading (both code / markup and data) with loading state management
- Plugins can be baked in at build-time / deployment or dynamically loaded at runtime from an external repository
- React/MobX-based rendering stack
- Customizable pages, routes and internal linking
- Supports plugins with multiple locales/languages
Concepts
Plugins and URIs
A plugin is a separate JavaScript bundle, that is dynamically loaded by the CMS at runtime (with JSONP). It can publicly export a collection of entities (pages, modules etc.; detailed below) usable by other plugins or by the CMS itself. The user can then reference them in the configuration file of the CMS to form complex content structures.
Each exported entity type is assigned a public URI, by which it is uniquely identified outside the plugin or even within the plugin itself. A custom URI schema is defined for each entity type:
e.g. page://aleth.io/explorer/block
or module://aleth.io/explorer/block/details
As it can be seen above, each URI has a differentiating domain name and path, which is specific to each plugin publisher. It is strongly recommended to use the following guidelines when name-spacing your entities:
- the domain component should be the publisher's tld
- the path component should convey what the entity does, as simply as possible, while reducing the chance of naming collisions
This will make it a lot easier for other people to configure the CMS and understand what does what at a glance.
Modules
A module is the basic building block of the CMS, which defines how data is rendered. At its core, the module is nothing more than a wrapper over a React component that contains HTML. It doesn't handle data fetching or domain logic, but acts more as a dumb template (data is spoon-fed into it by the CMS).
Pages
A page definition represents content that can be seen at a specified URL / route inside the application. It contains routing information (react-router is used in the background) and an HTML template with static content into which modules can be inserted dynamically.
Slots
A slot is a named placeholder that is placed inside a React component, in either a module or a page, allowing hierarchical (tree) structures of modules to be created dynamically at runtime. The module that contains the placeholder has no knowledge of the content that is being inserted in it, but simply renders whatever content the CMS decides to place inside that slot.
Example:
The following diagram shows how the above elements can be combined to form a complete page, the Alethio Explorer account page.
Note: This hierarchy is also reflected in the React component render tree and the CMS configuration.
Data sources
A data source is a wrapper over an external data provider (e.g. web3 or a server API). The purpose of this abstraction is to provide low-level access to data primitives, which can then be shared with other plugins.
Data adapters
A data adapter aggregates data from one or more data sources. It sits as a middleman between the data source and the content module that displays the aggregated data. It defines how the data is loaded and when to trigger a refresh of that data.
Contexts
Context can be thought of as an indirect means of parametrising modules, so that we could reuse them into different pages or areas of a page at runtime, without altering their original implementation. More specifically, it represents the pathing / routing information required to render a module. It is the set of query parameters that a data adapter uses to retrieve its data and thus enabling dynamic content modules to be rendered. Context can either be specified through URL route params (root context) or derived from another context, through a pure transformation function. (e.g. context2 = f(rootContext, otherData)
)
Example:
The following diagram show the data flow in the Alethio Explorer account page.
Notes:
- Each module declares a list of data adapters (by URI) that it requires. Based on this information, the CMS internally creates a data loader which queries the data adapters and feeds the results to each module. As it can be seen, a single data loader instance is created per context as an optimization, instead of a separate instance for each module.
- A nested context is created so we can insert modules that are tailored for contracts. This is done through a pure function that receives the parent context and data from the account details adapter as an input and produces a new context with the contractId as output.
The next diagram shows how a module is rendered based on data that it depends on.
Notes:
- The module definitions contains references to some data adapters, delegating the responsibility of fetching the required data, and also a react component that can be loaded asynchronously
- The CMS will re-render and update the module content every time the data/component loading state changes
Getting started
Prerequisites
You will need a local installation of the CMS. You can start with your favorite React boilerplate and use the CMS component directly in your root component, as described in the next section. Or, you can have a look at the Alethio Lite Explorer for an example setup (more specifically, App.tsx ).
Creating a CMS instance in a fresh app
With an existing app already set up, you will need to render the Cms
component into the root React component of your app:
import React from "react";
import { Cms } from "@alethio/cms";
import { createTheme } from "@alethio/ui/lib/theme/createTheme";
import { createPalette } from "@alethio/ui/lib/theme/createPalette";
export class App extends React.Component {
constructor(props) {
super(props);
this.theme = createTheme(createPalette());
}
render() {
return <Cms
// Ideally, the config should sit in a separate json file, which we load at runtime
config={{
// We'll load the plugins from the same base URL as the app
"pluginsBaseUrl": "/plugins",
"plugins": [],
"pages": [],
"rootModules": {
// Named slot that we use in our root component to insert global modules
"toolbar": []
}
}}
theme={this.theme}
logger={console}
locale="en-US"
defaultLocale="en-US"
renderErrorPage={() => <div>404</div>}
renderErrorPlaceholder={() => <div>An error has occurred</div>}
renderLoadingPlaceholder={() => <div>Loading...</div>}
>{ ({ slots, routes }) => {
return <div>
{ /* Route-independent content */ }
<div>{slots && slots["toolbar"]}</div>
{ /* Route-dependent content */ }
<div>{ routes }</div>
</div>;
}}</Cms>;
}
}
See Cms.tsx for props documentation.
Configuration
Dynamic runtime configuration can be passed to the Cms
instance via the config
prop. We recommend placing this configuration in a separate config.json
file, loaded from the app's base URL at runtime. This allows decoupling the configuration from the packaged source code and avoid a rebuild whenever the config is changed. In the future, we intent to provide a GUI-based configuration editor that avoids editing the raw JSON config altogether.
For full reference on the structure of the config object, please see IConfigData.
For examples, see the plugin tutorial.
Creating a new plugin
You can start by creating the plugin boilerplate with the CMS plugin tool:
$ npm i -g @alethio/cms-plugin-tool@^1.0.0-beta.4
Create a new empty folder where the plugin sources will be generated (this can be a git repo checkout):
$ mkdir <pluginName> && cd <pluginName>
IMPORTANT: You must be in the plugin folder to run the next command.
$ acp init <publisher> <pluginName> [npm_package_name]
Or, if you prefer JavaScript instead of TypeScript:
$ acp init --js <publisher> <pluginName> [npm_package_name]
<publisher>
is a namespace specific to the publisher of the plugin. We recommend setting this to your org's domain name, if you have one, or your github user handle, otherwise.<pluginName>
, together with the<publisher>
, is the name under which the CMS will reference the plugin. The CMS forms an unique plugin URI, likeplugin://<publisher>/<pluginName>
.[npm_package_name]
is the name that will be used in the generatedpackage.json
(used if the plugin will be published to npm).
Next, install the plugin dependencies and build the distributables with:
$ npm install
$ npm run watch
for development or $ npm run build
for minified/production build.
Switch back to your main app folder and link the plugin for development:
$ acp link <plugin_repo_folder>
This command will symlink the plugin distributable to the dist/plugins
folder for development. You can customize this folder with the --target
option. Call acp
with no arguments for detailed usage.
Finally, edit the CMS config object to enable the plugin (or the config.dev.jsonc file if using the lite explorer setup):
{
// ...
"plugins": [{
"uri": "plugin://<publisher>/<pluginName>"
}]
// ...
}
Plugin tutorial
Prerequisites
This section assumes that you already have a host app set up with a properly configured CMS instance and you have a freshly generated plugin boilerplate. If you haven't done so, please refer to the previous sections.
NOTE 1: We used $ acp init --js my.company.tld my-plugin @company/my-plugin
for our examples.
NOTE 2: For the sake of keeping things short and simple, all our examples are written in plain JavaScript (ES2015+). However, we do recommend switching to TypeScript later, for production. This will help catching errors and malformed plugin entities at compile-time and avoid cryptic runtime errors. See the TypeScript support section on how to properly type plugins.
Writing a simple module
We'll start with an example, which we'll use throughout the tutorial. Suppose we want to create a component that shows information about a user's profile. We'll create a new page with a single module that shows just the user's name.
Let's create a new module definition. For testing, we can add this directly in our plugin's entry file (make sure it has a proper jsx/tsx file extension), but later we will want to create a separate file for it and use an import instead.
import React from "react";
// in plugin's init method:
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
// We don't use context for now, so it's an empty object
contextType: {},
// We don't use data adapters yet, we'll pass an empty array
dataAdapters: [],
// The React component is loaded asynchronously and then cached by the CMS until a full page reload
// It can either be a component class or a functional component
getContentComponent: async () => (props) => <div>Hello, {props.name}!</div>,
// We receive some props from the cms and we map them to the props that our component needs
getContentProps: cmsProps => ({ name: "Joe" })
});
Right now, our module is not visible anywhere so let's also create a simple page for it:
// in plugin's init method:
api.addPageDef("page://my.company.tld/my-plugin/profile-page", {
contextType: {},
paths: {
// We'll map the page to an application path. The left-hand side is a react-router path expression.
// The right hand side receives params extracted from the path expression and
// resolves to an empty context object, which we won't use for now
"/profile": params => ({})
},
// Function that resolves to a React component class/ functional component. It can also be async.
getPageTemplate: () => ({ slots }) => <div>
<h1>User profile</h1>
{ /* We'll define a single named slot ("content"), where the CMS will inject our module */ }
{ slots && slots.content }
</div>
});
Finally, let's tie the pieces together by editing the configuration again:
{
// ...
"pages": [{
"def": "page://my.company.tld/my-plugin/profile-page",
"children": {
"content": [
{ "def": "module://my.company.tld/my-plugin/profile" }
]
}
}]
// ...
}
If we navigate to the /profile
path in our app, we should see everything correctly rendered. In the developer console you should see:
Loading plugin plugin://my.company.tld/my-plugin...
Plugins loaded.
Adding real data and module context
We currently have a module that shows dummy data. We'd like to be able to fetch that data from an external service. Let's assume our service fetches a user profile based on a user ID. This ID will effectively become the context of the profile page. We'll start by creating a data adapter for the profile data and then see how we can pass the context information through the page and to our module.
// ...
// in plugin's init method
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
// We declare that our module needs to be inserted into a context that has a userId
contextType: {
userId: "number"
},
// We can define data adapters inline, or reference global ones by their URI
// For this example we'll just define the adapter inline
dataAdapters: [{
// The alias is required so we can reference the result later in getContentProps.
alias: "profile",
// This is the actual adapter definition
def: {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
// This can be an XHR
return Promise.resolve({ name: "Joe", id: context.userId });
}
}
}],
// Here we define our React component inline. As best practice, we should move it to a separate file and
// use a dynamic import to extract it into a separate bundle. Otherwise, we'll slow down the initial
// page load while the CMS loads our plugin
getContentComponent: async () => (props) => <div>
Hello, {props.name}! Your user ID is {props.id}.
</div>,
getContentProps: ({ context, asyncData }) => ({
id: context.userId,
// AsyncData is a wrapper over a data adapter result that also contains loading state info.
// We get access to the wrapped data through the `data` property
name: asyncData.get("profile").data.name
})
});
Now that we have our updated module, we need to make the context information available to it, otherwise it will no longer work since we require a userId to exist. We'll modify the profile page definition to accept a userId parameter in its URL and create a root context with it.
api.addPageDef("page://my.company.tld/my-plugin/profile-page", {
contextType: {
userId: "number"
},
paths: {
// The left hand side is a react-router url matcher, which resolves on the right hand side to a context object or we could even use a context definition object to create a dynamic context
"/profile/:userId": params => ({ userId: Number(params.userId) }),
// We'll also allow redirects from profile_%d to profile/%d
"/profile_:userId": params => `/profile/${params.userId}`
},
// ...
});
Now, if we go to /profile/1
, we should see the data from the API displayed in our module.
Reusing data adapters
What if we have more than one module that depends on the same data? Inlining our data adapter is simple enough, but that prevents us from reusing it in another module or plugin. Let's create a public data adapter which we can then reference in our module.
// in plugin's init method:
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
// This can be an XHR
return Promise.resolve({ name: "Joe", id: context.userId });
}
});
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
dataAdapters: [{
ref: "adapter://my.company.tld/my-plugin/profile"
}],
getContentComponent: async () => (props) => <div>
Hello, {props.name}! Your user ID is {props.id}.
</div>,
getContentProps: ({ context, asyncData }) => ({
id: context.userId,
name: asyncData.get("adapter://my.company.tld/my-plugin/profile").data.name
})
});
State transitions and optional adapters
The CMS handles data loading in the background and makes the result directly available in our component. However, what happens while the module is being loaded or if some error occurred?
By default, any data adapter that we specify in our module definition must return a value before our module is rendered. That means that asyncData.get("<adapter-uri-or-alias>").data
will always contain the adapter result.
If we want to render something while loading the data or handle data errors in a special way, we can define custom elements for these cases:
import { ErrorBox } from "@alethio/ui/lib/ErrorBox";
import { LoadingBox } from "@alethio/ui/lib/LoadingBox";
// in plugin's init method:
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
// ...
getErrorPlaceholder: ({ translation }) =>
<ErrorBox>An error has occurred</ErrorBox>,
getLoadingPlaceholder: ({ translation }) =>
<LoadingBox>Loading...</LoadingBox>
});
Important notes:
- Error and loading placeholders also come into effect when loading the module's main content component (e.g. if it's loaded with a dynamic import)
- If a module throws an error, the error placeholder is rendered, if it exists. Otherwise nothing is rendered at all. There is no sensible default for the placeholder.
If we have an adapter that is slow to load, but we can already partially render the module, we can mark that adapter as optional and then manually handle the state changes in our adapter result:
import { observer } from "mobx-react";
import { LoadStatus } from "plugin-api/LoadStatus";
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
dataAdapters: [{
ref: "adapter://my.company.tld/my-plugin/profile",
optional: true
}],
// Important: use MobX observer to react to state changes
getContentComponent: async () => observer(({ asyncProfile }) => <div>
{ asyncProfile.loadStatus === LoadStatus.Loading ?
"Loading..." :
asyncProfile.loadStatus === LoadStatus.Error ?
"An error has occured" :
`Hello, ${asyncProfile.data.name}!`
}
</div>),
getContentProps: ({ asyncData }) => ({
asyncProfile: asyncData.get("adapter://my.company.tld/my-plugin/profile")
})
});
Page critical modules
It is common to have a "main" content module on the page, or even more than one. If that module hasn't loaded yet, we might not want to render the rest of the page at all and wait until everything has stabilized by showing a page-wide spinner. To achieve this, we'll define loading and error placeholders in the page itself, while marking our module with pageCritical
in the configuration.
Important: pageCritical
modules can only be defined in the root context of each page
{
// ...
"pages": [{
"def": "page://my.company.tld/my-plugin/profile-page",
"children": {
"content": [
{ "def": "module://my.company.tld/my-plugin/profile", "pageCritical": true }
]
}
}]
}
Reacting to data changes
We can react to data changes by triggering events in our data loaders when we detect a change. For this purpose a special kind of object called a data watcher is created. This object must define an onData
event, watch
and unwatch
methods. After every data loader successful load()
, a new watcher is created and watch()
is called. Any previous watcher is destroyed by calling unwatch()
. Whenever an onData
event is triggered, the data loader does a reload on that adapter.
import { EventDispatcher } from "@puzzl/core/lib/event/EventDispatcher";
class CustomDataWatcher {
constructor(interval) {
this.interval = interval;
this.onData = new EventDispatcher();
}
watch() {
// Just for an auto-refresh every 5 seconds
this.timeoutId = setTimeout(() => this.onData.dispatch(), this.interval);
}
unwatch() {
clearTimeout(this.timeoutId);
}
}
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
return Promise.resolve({ name: `Joe (@${Date.now()})`, id: context.userId });
},
createWatcher(context, lastDataResult) {
return new CustomDataWatcher(5000);
}
});
Reacting to MobX observable changes
For this purpose we can use a predefined watcher, which is available through the runtime plugin-api
module.
import { observable } from "mobx";
import { ObservableWatcher } from "plugin-api/watcher/ObservableWatcher";
const myStore = observable({
myObservable: 2
});
// in plugin's init method
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
return Promise.resolve({ name: `Joe (@${Date.now()})`, id: context.userId });
},
createWatcher(context, lastDataResult) {
return new ObservableWatcher(() => myStore.myObservable);
}
});
setInterval(() => { myStore.myObservable++; }, 3000);
Adding plugin configuration
Let's take our profile example. We might want for instance to pass the API endpoint URL by configuration, instead of hardcoding it in the plugin. We'll modify the CMS config as follows:
{
"plugins": [{
"uri": "plugin://my.company.tld/my-plugin",
"config": {
"profileApiUrl": "https://api.my.company.tld/profile/%d"
}
}]
}
Then, we can update our data adapter to use that URL from the config:
import { HttpRequest } from "@puzzl/browser/lib/network/HttpRequest";
// in plugin's init method:
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
return await new HttpRequest().fetchJson(config.profileApiUrl.replace("%d", context.userId));
}
});
Configuration per module/page
Global plugin configuration is useful when shared by multiple entities, but in other cases we might just want to pass some simple options to a single module or page, without changing its implementation. We can do this by providing an options
key together with the entity URI, in the CMS pages
config.
{
"pages": [{
"def": "page://my.company.tld/my-plugin/profile-page",
"children": {
"content": [
{
"def": "module://my.company.tld/my-plugin/profile",
"options": {
"showTime": true
},
"pageCritical": true
}
]
}
}]
}
// in plugin's init method
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
contextType: {},
dataAdapters: [],
getContentComponent: async () => ({ name, showTime }) => <div>Hello, {name}! { showTime ? `Time is ${Date.now()}` : null }</div>,
getContentProps: ({ options }) => ({ name: "Joe", showTime: options && options.showTime })
});
Plugin localization and translation strings
The locale string that is passed to the Cms
component will propagate to every plugin and can be accessed inside each module's content component. Plugins can define translation strings per language, which are loaded by the CMS and accessible via a translation service.
The plugin will need to be modified like this:
const myPlugin = {
//...
getAvailableLocales() {
return ["en-US", "zh-CN"];
},
async loadTranslations(locale: string) {
return await import("./translation/" + locale + ".json");
}
}
An example translation file might look like this:
{
"general.loading": "Loading",
"profile.header": "Hello, {name}!"
}
Translation strings can be accessed from the translation
prop, which is available in module's react components (main content, loading and error placeholders):
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
dataAdapters: [{
ref: "adapter://my.company.tld/my-plugin/profile"
}],
getContentComponent: async () => (props) => <div>
{ props.translation.get("profile.header", { "{name}": props.name }) }
</div>,
getLoadingPlaceholder: ({ translation }) => <div>{ translation.get("general.loading") }</div>,
getContentProps: ({ context, asyncData, translation, locale }) => ({
translation,
name: asyncData.get("adapter://my.company.tld/my-plugin/profile").data.name
})
});
Overriding plugin translations by configuration
There are some cases where we would like to override some translation strings from configuration, without rebuilding a plugin. We can easily do this by adding a translations
key in the plugins
section of the CMS config:
{
"plugins": [{
"uri": "plugin://my.company.tld/my-plugin?v=1.0.0",
"config": {
// ...
},
"translations": {
"en-US": {
"someKey": "My custom translation string"
}
}
}]
}
Plugin runtime API
Each plugin has access to a runtime public API. The CMS exposes various functions and classes under the plugin-api
package. This is not a real npm package, but a faux package, created at runtime. The plugin can get access to it by doing require("plugin-api")
. If using webpack, make sure to mark everything starting with plugin-api
as an external, to allow the CMS to resolve it instead at runtime. Besides this API, the CMS also exposes the react
, react-dom
, mobx
, mobx-react
and styled-components
packages, to save the hassle of including separate copies in each plugin. For a comprehensive list of exported items, please see the plugin-api TypeScript definitions.
TypeScript support
The CMS is a TypeScript first-class citizen. The plugin-api npm package exports TypeScript definitions for the corresponding runtime as well as interfaces for defining plugins, modules, pages, contexts, adapters etc.
import React from "react";
import { IPlugin, IModuleDef, ITranslation } from "plugin-api";
interface IProfileProps {
translation: ITranslation;
name: string;
}
interface IUserContext {
userId: number;
}
interface IProfileData {
name: string;
}
const profileModule: IModuleDef<IProfileProps, IUserContext> = {
contextType: {
userId: "number"
},
dataAdapters: [{
ref: "adapter://my.company.tld/my-plugin/profile"
}],
getContentComponent: async () => (props) => <div>
{ props.translation.get("profile.header", { name: props.name }) }
</div>,
getLoadingPlaceholder: ({ translation }) => <div>{ translation.get("general.loading") }</div>,
getContentProps: ({ context, asyncData, translation, locale }) => ({
translation,
name: (asyncData.get("adapter://my.company.tld/my-plugin/profile")!.data as IProfileData).name
})
};
const myPlugin: IPlugin = {
init(config, api, logger, publicPath) {
// ...
api.addModuleDef("module://my.company.tld/my-plugin/profile", profileModule);
}
}
// tslint:disable-next-line:no-default-export
export default myPlugin;
Linking between internal pages
Due to the dynamic nature of the pages and the mapped routes, linking is done with a special Link
component that uses page URIs instead of real URLs. This component also disables a link if the target page is not present in the configuration.
Example:
import { Link } from "plugin-api/component/Link";
const myModule = {
// ...
getContentComponent: async () => () => <div>
{ /* Notice the query string which is a serialized version of the context object of that page */ }
{ /* If the page URI cannot be resolved, the link is deactivated */ }
<Link to={`page://my.company.tld/my-plugin/profile-page?userId=1234`}>
This is a link
</Link>
</div>
};
Important: for links to work, we also need to define a way of transforming the page URI back into a full URL. Because of this we need to add one more method to our page object:
api.addPageDef("page://my.company.tld/my-plugin/profile-page", {
// ...
buildCanonicalUrl: ({ userId }) => `/profile/${userId}`,
// ...
});
We can also access the linking logic directly with the withInternalNav
higher-order component:
import { withInternalNav } from "plugin-api/withInternalNav";
class MyComponent extends React.Component {
render() {
return <button onClick={() => this.handleClick()}>A Link</button>
}
handleClick() {
// Try to redirect to target page if it exist
if (this.props.internalNav.goTo("page://my.company.tld/my-plugin/non-existent")) {
// doesn't get here because we used a non-existent page URI
} else {
console.log("Target page doesn't exist");
let resolvedUrl = this.props.internalNav.resolve("page://my.company.tld/my-plugin/profile-page?userId=1");
if (resolvedUrl) {
location.href = resolvedUrl;
}
}
}
}
const MyComponentWithNav = withInternalNav(MyComponent);
Creating dynamic contexts
In our profile example page we might want to add a module that shows information about an external service associated with our user. Our profile API returns an userId
and could also return a serviceId
, which we can use to query an external app for additional info. Our new module doesn't really care about the userId
and, ideally, it should only depend on the serviceId
. This also allows us to put the module anywhere we have access to the serviceId
without tightly coupling it with the profile page. Given that now the module context is different from the page context, how we can go about adding the module in the profile page? With a new context definition:
api.addContextDef("context://my.company.tld/my-plugin/service-context", {
parentContextType: {
userId: "number"
},
contextType: {
serviceId: "string"
},
dataAdapters: [{
ref: "adapter://my.company.tld/my-plugin/profile"
}],
create(parentContext, parentData) {
let serviceId = parentData.get("adapter://my.company.tld/my-plugin/profile").data.serviceId;
return { serviceId };
}
});
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
return Promise.resolve({ name: "Joe", serviceId: 15 })
}
});
api.addModuleDef("module://my.company.tld/my-plugin/service-details", {
contextType: {
serviceId: "number"
},
dataAdapters: [],
getContentComponent: async () => ({serviceId}) => <div>
Service ID: {serviceId}
</div>,
getContentProps: ({ context }) => ({ serviceId: context.serviceId })
});
We'll then update the config as follows:
{
"pages": [{
"def": "page://my.company.tld/my-plugin/profile-page",
"children": {
"content": [
{ "def": "module://my.company.tld/my-plugin/profile" },
{
"def": "context://my.company.tld/my-plugin/service-context",
"children": [
{ "def": "module://my.company.tld/my-plugin/service-details" }
]
}
]
}
}]
}
Refresh the browser page and you should see everything rendered correctly.
Data adapter dependencies
In some case dynamic context could be an unnecessary overhead, which can simply be solved by directly coupling several data adapters together. Building on the example given in the previous section, we could have an adapter service-details
that returns data for a service, based on the serviceId
. Instead of creating a { userId, serviceId }
context, we could simply get the profile data (that contains the serviceId
) by making the profile
adapter a dependency of the service-details
adapter.
api.addDataAdapter("adapter://my.company.tld/my-plugin/profile", {
contextType: {
userId: "number"
},
async load(context, cancelToken) {
return Promise.resolve({ name: "Joe", serviceId: 15 })
}
});
api.addDataAdapter("adapter://my.company.tld/my-plugin/service-details", {
contextType: {
userId: "number"
},
dependencies: [
"adapter://my.company.tld/my-plugin/profile"
],
async load(context, cancelToken, depData) {
let { serviceId } = depData.get("adapter://my.company.tld/my-plugin/profile");
return Promise.resolve({ serviceId, someRemotelyFetchedData: {} });
}
});
Inline modules
Sometimes, we might want to take advantage of the CMS module rendering, without having to actually define one with a public URI and adding a separate entry in the CMS config. In this case, we can use the plugin-api/component/InlineModule
component.
import { InlineModule } from "plugin-api/component/InlineModule";
const inlineModuleDef = {
dataAdapters: [{
alias: "someAdapter",
def: {
contextType: {
someData: "number"
},
load: async ctx => ctx.someData
}
}],
getContentComponent: async () => ({ someData }) => <div>Some data: {someData}</div>,
getContentProps: ({ asyncData }) => ({
someData: asyncData.get("someAdapter").data
})
};
class SomeComponent extends React.Component {
render() {
return <InlineModule
moduleDef={inlineModuleDef}
context={{
someData: 2
}}
logger={console}
extraContentProps={{
translation: this.props.translation
}}
/>;
}
}
api.addModuleDef("module://my.company.tld/my-plugin/profile", {
// ...
getContentComponent: async () => (props) => <div>
{ /* ... */ }
<SomeComponent translation={props.translation} />
</div>,
// ...
});
Notes:
- This also allows us to create a context from our UI data, which is not otherwise available outside the rendering layer.
- Inline modules can only use inline data adapters
Data Sources
We might want to share the data fetching primitives between adapters or even with external plugins. For this purpose we can define a data source. The only requirement for a data source is that it must be an object with an async init()
method. In this method we can do things such as opening an initial socket connection, or logging into an external API. The CMS will call this method after all plugins have been loaded, before bootstrapping the app. Please note the blocking nature of this call. The CMS will not continue until all data sources were successfully initialized.
const myPlugin = {
init(config, api, logger, publicUrl) {
// This method is synchronous and we're only allowed to declare entities. If we want to do side-effects,
// such as external API calls, establishing connections etc, those should be done in the data source init()
let dataSource = {
async init() {
// do stuff
},
fetchSomeData() {
return {
a: 2
};
}
};
api.addDataSource("source://my.company.tld/my-plugin/my-data-source", dataSource);
// an adapter that uses the above data source
api.addDataAdapter("adapter://my.company.tld/my-plugin/my-adapter", {
contextType: {},
load: async () => Promise.resolve(dataSource.fetchSomeData())
});
}
}
(TODO) Make data sources accessible between plugins using their URIs.
Data dependencies between plugins
There are cases when plugins depend on each other's data for initialization. Simple data adapter dependencies are supported when initializing a plugin's data source, like so:
// in plugin1
api.addDataAdapter("adapter://my.company.tld/plugin1/some-data", { /*...*/ load: async () => 2 });
// in plugin2
api.addDataSource("source://my.company.tld/plugin-2/some-source", {
dependencies: [{
ref: "adapter://my.company.tld/plugin1/some-data",
alias: "someData"
}],
init: async (depData) => {
console.log(depData.get("someData"));
// Or depData.get("adapter://my.company.tld/plugin1/some-data")
});
});
Important: Only a subset of data adapter features are supported. For instance, it must not have nested dependencies or a contextType other than {}
(root context).
Inline plugins
In some cases, we might want to make simple additions or overrides to existing plugins, or just prototype without going through the overhead of creating a new plugin repo + boilerplate, installing and deploying it.
We'll first create a new baked-in plugin, by adding a file myPlugin.js
(or myPlugin.ts
is using TypeScript) in our host app. We'll assume this to be in the same folder as our App
component:
The structure for inline plugins is similar to regular ones, except that init method no longer receives a publicPath
parameter. See IInlinePlugin for reference.
export const myPlugin = {
init(config, api, logger) {
// ... regular plugin stuff ...
},
getAvailableLocales: () => ["en-US", "zh-CN"],
// We assume we can use the same translations as the host app
loadTranslations: (locale) => import("../translation/" + locale + ".json")
};
IMPORTANT: We also need to add the following to our webpack config externals
section:
externals: [
function(context, request, callback) {
if (/^plugin-api\/.+$/.test(request)) {
return callback(null, 'commonjs ' + request);
}
callback();
}
]
Now let's pass the plugin to the Cms
component instance in our App
component:
//...
class App /*...*/{
//...
render() {
return <Cms
//...
inlinePlugins={{
"inline-plugin://my.company.tld/my-plugin": () => import("./myPlugin").then(({ myPlugin }) => myPlugin)
}}
>
{/*...*/}
</Cms>
}
}
Note: Inline plugins use a different inline-plugin://
scheme, to differentiate them from externally loaded plugins.
Lastly, we need to add our plugin to the CMS config object:
{
//...
"plugins": [{
//...
"uri": "inline-plugin://my.company.tld/my-plugin"
//...
}]
//...
}
Deployment with plugins
Option 1. Install the plugins in the base app at build-time
Assuming our app has a dist
folder for the built assets, we can install the plugins inside this folder, then package and deploy the entire folder.
The following changes will be needed:
- In the CMS config we'll specify
"pluginsBaseUrl": "/plugins"
. - In the app repo we'll execute
$ acp install <plugin_npm_package_name_or_local_folder>
. See@alethio/cms-plugin-tool
for usage.
This will copy the built plugins inside dist/plugins
folder.
- Lastly, we need to update the plugin URIs in the config to also include the plugin version string. This version string can be found in the plugin manifest (package.json). In production mode, each plugin version is installed inside a separate subfolder (per version). In contrast, when we linked the plugins for development, a single folder was generated and the version string was not required.
{
"plugins": [{
"uri": "plugin://my.company.tld/my-plugin?v=1.0.0"
}]
}
Option 2. Load the plugins from an external CDN
This is achieved by copying the built plugin files to an external CDN and changing pluginsBaseUrl
config to point to that CDN. This means the base app can be deployed independently from the plugins. Whenever we publish a new version of the plugin we only need to update the version string in the config.
We can first install the plugins in a temporary folder:
$ acp install --target /tmp/plugins <plugin_npm_package_or_local_folder>
And then sync the target folder structure with the external CDN via preferred method.
Help System
Help content can be defined per module by adding a getHelpComponent
method in the module definition.
Example:
let module = {
//...
getHelpComponent: () => ({ translation, /* ... */ }) => translation.get("my.help.content.key")
}
Help can be displayed by turning on/off a special "help mode". In help mode, the user can hover a module and if that module has help, it will be highlighted with a border and "help" mouse cursor. When clicked, the help content for that module is rendered with a user-defined component. Help mode can be controlled from the callback which is passed to Cms
component as child.
Example:
import React from "react";
import ReactDOM from "react-dom";
import { Observer } from "mobx-react";
import { Cms } from "@alethio/cms";
import { Layer } from "@alethio/ui/lib/overlay/Layer";
import { Mask } from "@alethio/ui/lib/overlay/Mask";
import { HelpIcon } from "@alethio/ui/lib/icon/HelpIcon";
import { Toolbar } from "@alethio/ui/lib/layout/toolbar/Toolbar";
import { ToolbarItem } from "@alethio/ui/lib/layout/toolbar/ToolbarItem";
import { ToolbarIconButton } from "@alethio/ui/lib/layout/toolbar/ToolbarIconButton";
import { Button } from "@alethio/ui/lib/control/Button";
export class App extends React.Component {
// ...
render() {
return <Cms
// ...
HelpComponent={({ children, module, onRequestClose }) => ReactDOM.createPortal(<>
<Mask />
<Layer>
{children}
<Button onClick={onRequestClose}>Got it</Button>
</Layer>
</>, document.body)}
>{ ({ slots, routes, helpMode }) => {
return <div>
<Toolbar>
{ /* Observer prevents the whole page from reacting whenever helpMode is toggled */}
<Observer>{() =>
<ToolbarItem title={helpMode.isActive() ? "Toggle off help mode" : "Toggle on help mode"}>
<ToolbarIconButton
active={helpMode.isActive()}
Icon={HelpIcon}
onClick={() => helpMode.toggle()} />
</ToolbarItem>
}</Observer>
{slots && slots["toolbar"]}
</Toolbar>
{ /* ... */ }
<div>{ routes }</div>
</div>;
}}</Cms>;
}
}
CMS Development
- Clone the repo
npm install
npm run build-dev
ornpm run watch
.- Link the library in your app with npm link or npm install with local path.