aquameta-widget
Version:
Widget rendering framework built on top of Aquameta
684 lines (556 loc) • 19 kB
JavaScript
const dot = require('dot/doT.js')
//const $ = require('zepto')
import datum from 'aquameta-datum'
//dot.templateSettings.strip = false;
/************************/
/*
import { render, sync, setRenderer } from 'Widget'
// usage
render('core:list_view', {})
sync(rows, '.row_container', row =>
widget('core:list_item', { row, selected: row === selected_row })
)
setRenderer('riot.tag', RiotRenderer)
// not sure about these next two
setRendererExt('.tag', 'riot.tag', RiotRenderer)
setRendererExt('.tag', RiotRenderer)
// RiotRenderer.js
export default RiotRenderer = {
compile,
render
}
// end RiotRenderer.js
// Renderer.js
export default WidgetRenderer = {
compile,
render
}
// end Renderer.js
*/
const curry = function(f) {
const l = f.length
return function cb(...args) {
return args.length >= l ? f(...args) : cb.bind(null, ...args)
}
}
//const datum = require('aquameta-datum')
const uuid = require('uuid')
import { requireSource, requireWidgetRow } from 'require-datum'
const _widgets = {}
const caches = {}
const sourceUrlRegex = /^\/db\/.*/
const endpoint = new datum()
const callerId = () => id
function render(/* curry the caller: caller, */ selector, options) {
assert(selector, 'widget selector is required')
const id = uuid()
const context = Object.assign({}, options, { id, namespace })
// const { datum, renderer, preRendered } = resolve(selector, context)
// gets results of require
const { module } = resolve(selector, options)
// widget has resolve to find correct call
// if source url (or any non-function call, for now), use require
// else use loaders
// require().then(_getWidgetData).then(compile)?
// dont think about this now
// Compiled server-side?
if (preRendered) {
const sourceUrl = '/db/schema/table/row.rendered'
// TODO: passing in variables? is this even feasible?
// mimetype problems?
return '<script src="' + sourceUrl + '"></script>'
}
// for later: !inputs, Xdeps, Xviews
// TODO: need to fetch associated data
// Compiled client-side
//_widgets[id] = datum.then(compile(context)).catch(error)
datum.then(staticRequires)
return
`<script id="widget-stub_${context.id}" data-widget_id="${context.id}">` +
`widget._swap(${context.id})` +
`</script>`
// Can't guarantee script tag will be on page when rendering is finished
// Need to apply widget to page as above (old method)
// return '<script id="' + id + '"></script>'
}
function staticRequires( datum ) {
return datum
}
/**
* RESOLVE step
* Resolve request to a specific loader
*/
function resolve(selector, datum) {
// Get extension
let [ widget, ext, extra ] = selector.split('.')
assert(!extra, 'invalid extension')
// Source url lookup -- /db/schema/relation/widgetName.column
if (sourceUrlRegex.test(widget)) {
return loaders.source(widget)
}
// Semantics lookup -- semantics/widgetPurpose
if ( ~widget.indexOf('/') ) {
let [ semantics, purpose ] = widget.split('/')
return loaders.semantics(purpose, datum)
}
// Bundle-centric lookup -- bundleName:widgetName
if ( ~widget.indexOf(':') ) {
let [ bundleName, widgetName ] = widget.split(':')
return loaders.bundle(bundleName, widgetName)
}
// Default widget lookup
// return loaders.bundle(bundleName, widget)
return loaders.default(widget, ext)
}
/**
* LOADER step
* Load the widget and its dependencies
* Cache results
*/
const loaders = {
source( path ) {
return requireSource(path)
},
semantics( purpose, /* not datum */ datum ) {
assert(datum, 'semantics requires a datum')
const context = extra || {}
const semantics = selector.split('/')
const args_object = {}
// If datum is a promise (that will resolve as a Rowset or a Row), resolve it first
if (datum instanceof Promise) {
var url_id
const widgetRequest = datum.then(function(input) {
context.datum = input;
// These are the same for both Rowset and Row
var endpoint = input.relation.schema.database;
var fn = 'relation_widget';
var type = 'meta.relation_id';
args_object.relation_id = input.relation.id;
if (input instanceof AQ.Rowset) {
context.rows = input;
url_id = input.relation.to_url(true);
}
else if (input instanceof AQ.Row) {
context.row = input;
url_id = input.to_url(true);
}
args_object.widget_purpose = semantics[1];
args_object.default_bundle = semantics.length >= 3 ? semantics[2] : 'com.aquameta.core.semantics';
return endpoint.schema('semantics').function({
name: fn,
parameters: [type,'text','text']
}, args_object, { use_cache: true, meta_data: false });
});
}
// Else, check which type it is
else {
context.datum = datum;
const { endpoint } = datum
if (datum instanceof AQ.Relation || datum instanceof AQ.Table || datum instanceof AQ.View) {
var endpoint = datum.schema.database;
var fn = 'relation_widget';
var type = 'meta.relation_id';
args_object.relation_id = datum.id;
var url_id = datum.to_url(true);
context.relation = datum;
}
else if (datum instanceof AQ.Row) {
var endpoint = datum.relation.schema.database;
var fn = 'relation_widget';
var type = 'meta.relation_id';
args_object.relation_id = datum.relation.id;
var url_id = datum.to_url(true);
context.row = datum;
}
else if (datum instanceof AQ.Rowset) {
var endpoint = datum.relation.schema.database;
var fn = 'relation_widget';
var type = 'meta.relation_id';
args_object.relation_id = datum.relation.id;
var url_id = datum.relation.to_url(true);
context.rows = datum;
}
else if (datum instanceof AQ.Column) {
var endpoint = datum.relation.schema.database;
var fn = 'column_widget';
var type = 'meta.column_id';
args_object.column_id = datum.id;
var url_id = datum.relation.to_url(true);
context.column = datum;
}
else if (datum instanceof AQ.Field) {
var endpoint = datum.row.relation.schema.database;
var fn = 'column_widget';
var type = 'meta.column_id';
args_object.column_id = datum.column.id;
var url_id = datum.to_url(true);
context.field = datum;
}
args_object.widget_purpose = semantics[1];
args_object.default_bundle = semantics.length >= 3 ? semantics[2] : 'com.aquameta.core.semantics';
var widgetRequest = endpoint.schema('semantics').function({
name: fn,
parameters: [type,'text','text']
}, args_object, { use_cache: true, meta_data: false })
}
// Go get this widget - retrieve_promises don't change for calls to the same widget - they are cached by the widget name
const widgetDatum = widgetRequest
.catch(err => {
throw 'Widget not found from semantic lookup with ' + selector.semanticSelector + ' on ' + selector.urlId
})
return requireWidgetRow(widgetDatum)
/*
.then(getWidgetData)
*/
},
bundle( bundleName, widgetName ) {
assert(bundleName in namespaces, `widget namespace ${ bundleName } has not been imported`)
const widgetDatum = namespaces[bundleName].endpoint.schema('widget').function('bundled_widget',
[ namespaces[context.namespace].bundleName, context.name ], {
use_cache: true,
meta_data: false
})
/*
.then(getWidgetData)
.catch(err => {
throw 'Widget does not exist, ' + bundleName + ':' + widgetName
})
*/
return requireWidgetRow(widgetDatum)
},
default( widgetName, extension ) {
// TODO: Need to be able to look up in different table with different column for plugins
//return datum.schema('widget').relation('widget').row('name', widgetName)
return this.bundle(context.namespace, widgetName)
}
}
// Get all related widget data
function getWidgetData( row ) {
//const semanticLookup = 'semanticSelector' in selector
const options = { useCache: true, metaData: true }
return Promise.all([
// widget row
row,
// widget input rows
row.relatedRows('id', 'widget.input', 'widget_id', options),
// widget views
row.relatedRows('id', 'widget.widget_view', 'widget_id', options)
.then(widgetViews =>
widgetViews.map(widget_view => {
const viewId = widget_view.get('view_id')
return row.schema.database.schema(viewId.schema_id.name).view(viewId.name)
})),
// widget dependencies
row.relatedRows('id', 'widget.widget_dependency_js', 'widget_id', options)
.then(depsJs => {
if (depsJs.length) {
return depsJs.related_rows('dependency_js_id', 'widget.dependency_js', 'id', options)
}
})
.then(deps =>
Promise.all(
deps.map(dep =>
System.import(dep.field('content').toUrl())
.then(depModule => ({
url: dep.field('content').toUrl(),
name: dep.get('variable') || 'non_amd_module',
/* TODO: This value thing is a hack. For some reason, jwerty doesn't load properly here */
value: typeof depModule == 'object' ? depModule[Object.keys(depModule)[0]] : depModule
//value: dep_module
}))
)))
.then(all => {
const [ widgetRow, inputs, views, depsJs ] = all
return {
widgetRow,
inputs,
views,
depsJs
}
})
])
}
// combine compile and _prepare
const compile = curry(( context, request ) => {
return request.then(widgetData => {
const { widgetRow, inputs, views, depsJs } = widgetData
context.name = widgetRow.get('name')
// Process inputs
if (inputs) {
inputs.forEach(input => {
const inputName = input.get('name');
if (inputName in context) {
if (input.get('optional')) {
const defaultCode = input.get('default_value')
try {
if (defaultCode) {
context[inputName] = eval('(' + defaultCode + ')')
}
else {
context[inputName] = undefined
}
}
catch (e) {
error(e, context.name, "Widget default eval failure: " + defaultCode)
}
}
else {
error('Missing required input ' + inputName, context.name, 'Inputs')
}
}
context.input[inputName] = context[inputName]
})
}
// Load views into context
if (views) {
views.reduce((acc, view) => {
return acc[view.schema.name + '_' + view.name] = view
}, context)
}
// Create html template
const htmlTemplate = dot.template(widgetRow.get('html') || '')
// Compile html template
let html = null
try {
html = htmlTemplate(context)
} catch(e) {
error(e, context.name, 'HTML')
}
// Render html
html = document.createElement(html)
try {
html.dataset['data-widget'] = context.name
html.dataset['data-widget_id'] = context.id
//.data('help', widgetRow.get('help'))
} catch(e) {
error(e, context.name, 'HTML (adding data-* attributes)')
}
// If CSS exists and has not yet been applied
if (widgetRow.get('css') != null && document.querySelectorAll('style[data-widget="' + context.name + '"]').length == 0) {
// Create css template
const cssTemplate = dot.template(widgetRow.get('css') || '')
// Try to run css template
try {
var css = cssTemplate(context)
} catch(e) {
error(e, context.name, 'CSS')
}
// Add css to dom
const styleTag = document.createElement('<style type="text/css">' + css + '</style>')
styleTag.dataset['data-widget'] = context.name
document.head.appendChild(styleTag)
}
// Get context values
const contextKeys = Object.keys(context).sort()
const contextVals = contextKeys.map(key => context[key])
// Dependency names and values
const depNames = [], depValues = []
if (depsJs) {
depsJs.forEach(dep_js => {
depNames.push(depJs.name)
depValues.push(depJs.value)
})
}
try {
/*
* Creating an script that looks like this
* function(dep1_name, dep2_name, ...) {
* function(input1, input2) {
* post_js
* }.apply(this.this.context_vals);
* }.apply(this, this.dep_vals);
*/
const js = Function(
'(function(' + depNames.join(',') + ') { \n' +
'(function(' + contextKeys.join(',') + ') { \n' +
'var w = $("#"+id);\n\n' +
widgetRow.get('post_js') +
'\n//# sourceURL=' + widgetRow.get('id') + '/' + widgetRow.get('name') + '/post_js\n' +
'}).apply(this, this.contextVals)' +
'}).apply(this, this.depValues)'
).bind({ contextVals: contextVals, depValues: depValues })
}
catch(e) {
error(e, widgetRow.get('name'), 'Creating post_js function')
}
// Return rendered widget and post_js function
return {
html,
widgetId: context.id,
widgetName: context.name,
js
}
})
})
// detect svg widgets by tag name. might be better to check the dom to see if we're inside an svg tag?
function isSvg( e ) {
// TODO: add more, or change approach?
const svgTags = ['circle','rect','polygon','g']
return ~svgTags.indexOf(e.tagName.toLowerCase())
}
function _swap( id ) {
const el = document.getElementById(`widget-stub_${id}`)
_widgets[id].then(renderedWidget => {
const { html, widgetId, widgetName, js } = renderedWidget
// Replace stub
// special case for svg elements - http://stackoverflow.com/questions/3642035/jquerys-append-not-working-with-svg-element
if (isSvg(html)) { // TODO: is there ever a case where there is more than one element in this array?
const div = document.createElementNS('http://www.w3.org/1999/xhtml', 'div')
div.innerHTML= `<svg xmlns="http://www.w3.org/2000/svg">${html.outerHTML}</svg>`
const frag = document.createDocumentFragment()
while (div.firstChild.firstChild)
frag.appendChild(div.firstChild.firstChild)
el.replaceWith(frag)
}
else {
el.replaceWith(html)
}
// Run post_js - or this may have to be done with a script tag appended to the widget
try {
js()
}
catch(e) {
error(e, widgetName, 'Running post_js function')
}
const w = document.getElementById(widgetId)
// notify the world that a widget has loaded. debugger uses this to detect widget tree changes
w.dispatchEvent(new CustomEvent('widgetLoaded', { widget: w }))
// Delete compiled widget
// delete _widgets[id]
})
.catch(error => {
//console.error('Widget swap failed - ', error);
console.error(error)
// Remove stub
el.remove()
// Delete compiled widget
delete _widget[id]
})
}
function error( err, widgetName, stepName ) {
console.error(`widget ${ widgetName } failed at step ${ stepName }`)
throw err
}
/*export */function sync( rowset_promise, container, widget_maker, handlers ) {
if (handlers === undefined) {
handlers = {}
}
if (widget_maker === undefined) {
throw 'widget.sync missing widget_maker argument'
}
if (container.length < 1) {
throw 'widget.sync failed: The specified container is empty or not found'
return
}
if (container.length > 1) {
throw 'widget.sync failed: The specified container contains multiple elements'
return
}
/*
if (!container instanceof jQuery) {
throw 'widget.sync failed: The specified container is not a jQuery object'
return
}
*/
/*
if (typeof rowset_promise == 'undefined' ||
(!(rowset_promise instanceof Promise) && !(rowset_promise instanceof AQ.Rowset))) {
throw 'widget.sync failed: rowset_promise must be a "thenable" promise or a resolved AQ.Rowset'
}
*/
if ( !(rowset_promise instanceof Promise) ) {
rowset_promise = Promise.resolve(rowset_promise)
}
rowset_promise.then(rowset => {
if (typeof rowset == 'undefined' || typeof rowset.forEach == 'undefined') {
throw 'Rowset is not defined. First argument to widget.sync must return a Rowset'
}
var containerId = uuid()
container.attr('data-container_id', containerId)
containers[containerId] = {
container: container,
widget_maker: widget_maker,
handlers: handlers
}
rowset.forEach(row => {
container.append(widget_maker(row))
})
}).catch(function(error) {
console.error('widget.sync failed: ', error)
})
}
module.exports.sync = sync
/*
var widget = new Widget()
widget()
widget.render()
widget.sync()
export function Widget( config ) {
const defaultConfig = {
default: WidgetRenderer
}
// Object.assign
this.config = Object.keys(config)
.reduce((acc, key) => {
acc[key] = config[key]
return acc
}, defaultConfig)
this._renderers = {
default: new this.config.default(this.config)
}
// Backwards compatibility
const backwardCompatibility = () {
console.warn('widget() is deprecated. Please use widget.render() instead')
return this.render(arguments)
}
backwardCompatibility.render = this.render
backwardCompatibility.sync = this.sync
return backwardCompatibility
}
function WidgetResolver() { }
Widget.prototype.widget = function() { }
Widget.prototype.render = function() {}
Widget.prototype.sync = function() {}
export function WidgetRenderer( config ) {
this.config = config
this._widgets = {}
}
export function semantics() {
}
WidgetRenderer.prototype.fetch = function( bundleName, name ) {
return datum.schema('widget').table('widget').row('name', selector)
.then()
return Promise()
}
WidgetRenderer.prototype.prepare = function() {
}
WidgetRenderer.prototype.render = function() {
return this.retrieve()
.then(this.prepare)
.then(this.compile)
.catch(error)
}
*/
/* Import a bundle name to a local namespace */
/*export function*/ module.exports.import = function( bundleName, namespace, endpoint ) {
namespaces[namespace] = {
endpoint: endpoint,
bundleName: bundleName
}
}
/* Return an array bundle of imported bundle names */
var bundles = function() {
return Object.keys(namespaces).map(function(key) {
return namespaces[key].bundle_name;
});
};
/* Find the bundle that was imported to this namespace */
var bundle = function( namespace ) {
return namespaces[namespace].bundle_name;
};
/* Find the namespace that uses this bundle */
var namespace = function( bundle_name ) {
return Object.keys(namespaces).find(function(namespace) {
return namespaces[namespace].bundle_name == bundle_name;
});
};