UNPKG

@mason-api/javascript-sdk

Version:

Mason component rendering library

422 lines (388 loc) 13.7 kB
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;