@mason-api/javascript-sdk
Version:
Mason component rendering library
422 lines (388 loc) • 13.7 kB
JavaScript
import _ from 'lodash';
import update from 'immutability-helper';
import serialize from 'form-serialize';
import { API, CSS, DOM, HTTP, OBJECT, TREE } from '@mason-api/utils';
import integrations, { applyIntegrations } from './integrations';
import defaultTransformations, { applyTransformations } from './transformations';
export const versionNumber = '3.0.0';
class _Mason {
constructor({
apiKey,
bucket,
draft,
host,
projectId,
transformations,
}) {
if (!apiKey) {
throw new Error('Please provide an apiKey in Mason() call');
}
Mason.instance = this;
this.callback = this.callback.bind(this);
this.findComponentInstance = this.findComponentInstance.bind(this);
this.handleAnchorClick = this.handleAnchorClick.bind(this);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.getContext = this.getContext.bind(this);
this.getProps = this.getProps.bind(this);
this.receiveComponents = this.receiveComponents.bind(this);
this.receiveProjects = this.receiveProjects.bind(this);
this.render = this.render.bind(this);
this.api = API.urls({ apiKey, bucket, draft, host });
this.bucket = bucket || 'component-cache';
this.callbacks = {};
this.components = {};
this.componentInstances = {};
this.projects = {};
this.transformations = applyTransformations(...(transformations || defaultTransformations))(this.getContext);
if (projectId) {
this.fetchProjects(projectId);
this.fetchStylesheets(projectId);
}
this.observe.bind(this)();
}
addCallback(name, f, componentId) {
if (componentId) {
const $operation = _.has(this.callbacks, componentId) ? '$merge' : '$set';
this.callbacks = update(this.callbacks, {
[componentId]: {
[$operation]: {
[name]: f,
},
},
});
} else {
this.callbacks = update(this.callbacks, {
$merge: {
[name]: f,
},
});
}
}
callback(name, fromComponentId, target) {
const callbacks = [
_.get(this.callbacks, `${fromComponentId}.${name}`, _.identity),
_.get(this.callbacks, name, _.identity),
];
if (target) {
callbacks.unshift(_.get(this.componentInstances, `${target.getAttribute('id') || fromComponentId}.props.${name}`, _.identity));
}
// callbacks all take the form of a payload as the first argument
// and some metadata as the other args
// the metadata needs to be passed through the chain unmutated
// while the data will be returned from each callback
// and flows through the chain
return (first, ...rest) => {
const funcs = _.map(callbacks, callback => _.partialRight(callback, ...rest));
return _.flow(funcs)(first);
};
}
fetchProjects(projectId, useApi) {
const pIds = OBJECT.makeArray(projectId);
const url = this.api.components({ pIds, extra: { s3Miss: useApi } }, useApi);
HTTP.makeCall({ url, verb: HTTP.GET })
.then((response) => {
this.receiveProjects(response.projects);
this.receiveComponents(response.components);
})
.catch(() => {
if (!useApi && _.includes(url, 'amazonaws.com')) {
this.fetchProjects(projectId, true);
}
});
}
fetchStylesheets(projectId) {
CSS.insertProjectStylesheet(projectId, this.api);
}
findComponentInstance(target) {
const root = target.tagName === 'MASON-CANVAS' ? target : this.findTarget(target);
return _.get(this.componentInstances, root.getAttribute('id') || root.getAttribute('data-id'));
}
findTarget(element) {
for (let parent = element; parent; parent = parent.parentElement) {
if (parent.tagName === 'MASON-CANVAS') {
return parent;
}
}
return null;
}
getContext(target) {
const context = _.pick(
this,
'callback',
'components',
'findTarget',
'projects',
'transformations',
);
if (target) {
const root = this.findTarget(target);
const component = _.get(this.components, root.getAttribute('data-id'));
const instance = _.get(this.componentInstances, root.getAttribute('id') || component.id);
const project = _.get(this.projects, component.projectId);
return {
...context,
component,
instance,
project,
render: Mason.render,
};
}
return context;
}
getProps(target) {
return _.get(this.findComponentInstance(target), 'props', {});
}
handleAnchorClick(e) {
if (e.target.tagName === 'A') {
const href = e.target.getAttribute('href');
if (_.startsWith(href, 'mason:')) {
e.preventDefault();
const root = this.findTarget(e.target);
root.setAttribute('data-config-subpath', _.replace(href, 'mason:', ''));
Mason.render(root, this.getProps(root));
}
}
}
handleFormSubmit(e) {
e.preventDefault();
const form = e.target;
const root = this.findTarget(form);
const fromComponentId = root.getAttribute('data-id');
const data = serialize(form, { hash: true });
const { id, method, name } = DOM.attributeValues(form, 'id', 'method', 'name');
let { action } = DOM.attributeValues(form, 'action');
// add componentId as query param to form action
if (_.toUpper(action) === HTTP.GET) {
data.componentId = fromComponentId;
} else {
if (!action.includes('?')) {
action += '?';
} else {
action += '&';
}
action += `componentId=${fromComponentId}`;
}
const ownData = {
action,
data,
headers: {},
}; // Expose action and headers in case they want to modify them in willSendData
Promise.resolve(this.callback('willSendData', fromComponentId, root)(ownData, name, fromComponentId))
.then((d) => {
if (d === false) {
return;
}
form.dispatchEvent(new CustomEvent('willSendData', {
bubbles: true,
detail: { componentId: fromComponentId, data: d, name },
}));
HTTP.makeCall({
url: d.action,
data: d.data,
headers: d.headers,
verb: _.toUpper(method),
})
.then(response => this.callback('didReceiveData', fromComponentId, root)(response, name, fromComponentId))
.then((response) => {
form.dispatchEvent(new CustomEvent('didReceiveData', {
bubbles: true,
detail: { componentId: fromComponentId, data: response, name },
}));
const errorNode = document.getElementById(`${id}-error`);
if (errorNode) {
form.removeChild(errorNode);
}
const root = this.findTarget(form);
const component = _.get(this.components, fromComponentId);
const path = _.last(_.split(id, '-'));
const node = TREE.findNodeAtPath(component.config.data.default.tree, path);
if (_.has(node.p, '_events.success')) {
_.forEach(node.p._events.success, (successAction) => {
if (successAction.type === 'form') {
if (successAction.action === 'reset') {
Mason.render(form, this.getProps(root));
}
} else if (successAction.type === 'page') {
if (_.has(component.config.data, successAction.id)) {
root.setAttribute('data-config-subpath', successAction.id);
Mason.render(root, this.getProps(root));
}
}
});
}
})
.catch((response) => {
let errorMessage = 'Whoops! An unexpected error occurred.';
if (_.isString(response)) {
errorMessage = response;
} else if (_.has(response, 'error') && _.isString(response.error)) {
errorMessage = response.error;
}
let errorNode = document.getElementById(`${id}-error`);
if (!errorNode) {
errorNode = document.createElement('p');
errorNode.setAttribute('id', `${id}-error`);
form.prepend(errorNode);
}
errorNode.innerText = errorMessage;
});
});
}
observe(root = document) {
// Observe for changes in standalone components
if (typeof MutationObserver !== 'undefined') {
this.observer = new MutationObserver((mutations) => {
// Wait for document to load and async config to initialize
if (typeof masonAsyncInit !== 'undefined') {
return false;
}
_.forEach(mutations, (mutation) => {
if (mutation.type === 'childList') {
_.forEach(mutation.addedNodes, addedNode =>
addedNode.tagName === 'MASON-CANVAS' && Mason.render(addedNode));
} else {
Mason.render(mutation.target);
}
});
return true;
});
this.observer.config = {
attributeFilter: ['data-id', 'data-config-subpath', 'data-render'],
attributes: true, // listen to changes in specific attributes
childList: true, // listen to node insertions/removals
subtree: true, // listen to all nodes in document
};
this.observer.observe(root, this.observer.config);
}
}
receiveComponents(components) {
this.components = update(this.components, { $merge: _.keyBy(components, 'id') });
const withIntegrations = _.map(components, (component) => {
const appliedIntegrations = _.filter(integrations, integration => integration.has(component.config));
return {
...component,
config: applyIntegrations(_.map(appliedIntegrations, 'init'))(this.getContext)(_.identity)(component.config),
render: applyIntegrations(_.map(appliedIntegrations, 'render'))(this.getContext)(this.render),
};
});
this.components = update(this.components, { $merge: _.keyBy(withIntegrations, 'id') });
_.forEach(withIntegrations, (component) => {
const targets = document.querySelectorAll(`mason-canvas[data-id="${component.id}"]`);
_.forEach(targets, target => Mason.render(target, this.getProps(target)));
});
window.dispatchEvent(new CustomEvent('mason.didReceiveComponents'));
}
receiveProjects(projects) {
this.projects = update(this.projects, { $merge: _.keyBy(projects, 'id') });
}
renderAllInstancesOfComponent(component) {
const targets = document.querySelectorAll(`mason-canvas[data-id="${component.id}"]`);
_.forEach(targets, target => Mason.render(target, this.getProps(target)));
}
render(config, configSubpath, target, props) {
const subConfig = config.data[configSubpath];
const tree = TREE.render(subConfig.tree, {
...props,
config,
configSubpath,
transformations: this.transformations,
});
if (target) {
const root = this.findTarget(target);
if (root !== target) {
const partial = tree.querySelector(target.getAttribute('id'));
document.replaceChild(partial, target);
target.dispatchEvent(new CustomEvent('render', {
bubbles: true,
}));
return partial;
}
const id = target.getAttribute('id') || config.componentId;
this.componentInstances = update(this.componentInstances, {
$merge: {
[id]: {
config,
configSubpath,
props,
target,
},
},
});
target.addEventListener('click', this.handleAnchorClick);
target.addEventListener('submit', this.handleFormSubmit);
target.innerHTML = '';
_.forEach(tree, node => target.appendChild(node));
target.dispatchEvent(new CustomEvent('render', {
bubbles: true,
}));
return target;
}
return tree;
}
setOptions({ projectId }) {
const pIds = OBJECT.makeArray(projectId);
const newProjects = _.reject(pIds, pId => _.has(this.projects, pId));
if (newProjects.length) {
this.fetchProjects(newProjects);
this.fetchStylesheets(newProjects);
}
}
}
function Mason(options = {}) {
if (Mason.instance) {
Mason.instance.setOptions(options);
} else {
CSS.prependBoilerplateStylesheet();
return new _Mason(options);
}
}
Mason.instance = undefined;
Mason.initGuard = () => {
if (!Mason.instance) {
throw new Error('Please call Mason() with your api key before performing other actions');
}
};
Mason.render = (target, props = {}) => {
const el = (() => {
if (_.isString(target)) {
return document.querySelector(target);
}
if (_.isElement(target)) {
return target;
}
return null;
})();
const id = props.id || (el && el.getAttribute('data-id'));
const component = _.get(Mason.instance.components, id);
if (component) {
let configSubpath = el.getAttribute('data-config-subpath') || 'default';
if (!_.has(component.config.data, configSubpath)) {
configSubpath = 'default';
}
return component.render(component.config, configSubpath, el, props);
}
window.addEventListener('mason.didReceiveComponents', () => {
Mason.render(target, props);
});
return null;
};
Mason.callback = (name, f, componentId) => {
Mason.initGuard();
if (_.isFunction(f) && _.includes([
'didFetchData',
'didReceiveData',
'willFetchData',
'willSendData',
], name)) {
Mason.instance.addCallback(name, f, componentId);
return true;
}
return false;
};
if (typeof window !== 'undefined') {
window.Mason = Mason;
} else {
global.Mason = Mason;
}
export { integrations };
export default Mason;