derby
Version:
MVC framework making it easy to write realtime, collaborative applications that run in both Node.js and browsers.
601 lines (600 loc) • 26.5 kB
JavaScript
/*
* components.js
*
* Components associate custom script functionality with a view. They can be
* distributed as standalone modules containing templates, scripts, and styles.
* They can also be used to modularize application functionality.
*
*/
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.extendComponent = exports.ComponentFactory = exports.ComponentModelData = exports.createFactory = exports.ComponentAttributeBinding = exports.ComponentAttribute = exports.Component = void 0;
var racer_1 = require("racer");
var Controller_1 = require("./Controller");
var derbyTemplates = require("./templates");
var templates_1 = require("./templates/templates");
var util_1 = require("./templates/util");
var expressions = derbyTemplates.expressions, templates = derbyTemplates.templates;
var slice = [].slice;
var Component = /** @class */ (function (_super) {
__extends(Component, _super);
function Component(context, data) {
var _this = this;
var parent = context.controller;
var id = context.id();
var scope = ['$components', id];
var model = parent.model.root.eventContext(id);
model._at = scope.join('.');
data.id = id;
model._set(scope, data);
// Store a reference to the component's scope such that the expression
// getters are relative to the component
model.data = data;
// IMPORTANT: call super _after_ model created
_this = _super.call(this, context.controller.app, context.controller.page, model) || this;
_this.parent = parent;
_this.context = context.componentChild(_this);
_this.id = id;
_this._scope = scope;
// Add reference to this component on the page so that all components
// associated with a page can be destroyed when the page transitions
_this.page._components[id] = _this;
_this.isDestroyed = false;
return _this;
}
Component.prototype.destroy = function () {
this.emit('destroy');
this.model.removeContextListeners();
this.model.destroy();
delete this.page._components[this.id];
if (this.page._eventModel.object) {
var components = this.page._eventModel.object.$components;
if (components)
delete components.object[this.id];
}
this.isDestroyed = true;
};
/**
* Generate a function, bound function to the component instance's `this`.
* The returned function will no longer be invoked once the component is destroyed.
*
* @param fn - A function to be invoked with the component as its `this` value.
* @returns a bound function, similar to JavaScript's Function.bind()
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_objects/Function/bind
*/
Component.prototype.bind = function (fn) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
var _component = this;
var _fn = fn;
this.on('destroy', function () {
// Reduce potential for memory leaks by removing references to the component
// and the passed in callback, which could have closure references
_component = null;
// Cease calling back after component is removed from the DOM
_fn = null;
});
return function componentBindWrapper() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
if (!_fn)
return;
return _fn.apply(_component, args);
};
};
/**
* Generate a function that, when passing in a numeric delay, calls the function at
* most once per that many milliseconds. Additionally, implements an interface
* intended to be used with window.requestAnimationFrame, process.nextTick, or window.setImmediate.
*
* @param fn - A function to be invoked with the component instance as its `this` value.
* @param delayArg - Amount of time (in ms) to wait until invoking `fn` again. Default '0'.
*
* When passing in a numeric delay, calls the function at most once per that
* many milliseconds. Like Underscore, the function will be called on the
* leading and the trailing edge of the delay as appropriate. Unlike Underscore,
* calls are consistently called via setTimeout and are never synchronous. This
* should be used for reducing the frequency of ongoing updates, such as scroll
* events or other continuous streams of events.
*
* Additionally, implements an interface intended to be used with
* window.requestAnimationFrame or process.nextTick. If one of these is passed,
* it will be used to create a single async call following any number of
* synchronous calls. This mode is typically used to coalesce many synchronous
* events (such as multiple model events) into a single async event.
* Like component.bind(), will no longer call back once the component is
- * destroyed, which avoids possible bugs and memory leaks.
*
* @returns a bound function
*/
Component.prototype.throttle = function (fn, delayArg) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
var _component = this;
this.on('destroy', function () {
// Reduce potential for memory leaks by removing references to the component
// and the passed in callback, which could have closure references
_component = null;
// Cease calling back after component is removed from the DOM
fn = null;
});
// throttle(callback)
// throttle(callback, 150)
if (delayArg == null || typeof delayArg === 'number') {
var delay_1 = delayArg || 0;
var nextArgs_1;
var previous_1;
var boundCallback_1 = function () {
var args = nextArgs_1;
nextArgs_1 = null;
previous_1 = +new Date();
if (fn && args) {
fn.apply(_component, args);
}
};
return function componentThrottleWrapper() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var queueCall = !nextArgs_1;
nextArgs_1 = slice.call(args);
if (queueCall) {
var now = +new Date();
var remaining = Math.max(previous_1 + delay_1 - now, 0);
setTimeout(boundCallback_1, remaining);
}
};
}
// throttle(callback, window.requestAnimationFrame)
// throttle(callback, process.nextTick)
if (typeof delayArg === 'function') {
var nextArgs_2;
var boundCallback_2 = function () {
var args = nextArgs_2;
nextArgs_2 = null;
if (fn && args) {
fn.apply(_component, args);
}
};
return function componentThrottleWrapper() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
var queueCall = !nextArgs_2;
nextArgs_2 = slice.call(args);
if (queueCall)
delayArg(boundCallback_2);
};
}
throw new Error('Second argument must be a delay function or number');
};
/**
* Safe wrapper around `window.requestAnimationFrame` that ensures function not invoked
* when component has been destroyed
* @param fn - A function to be invoked with the component instance as its `this` value.
*/
Component.prototype.requestAnimationFrame = function (fn) {
var safeCallback = _safeWrap(this, fn);
window.requestAnimationFrame(safeCallback);
};
/**
* Safe wrapper around `process.nextTick` that ensures function not invoked
* when component has been destroyed
* @param fn - A function to be invoked with the component instance as its `this` value.
*/
Component.prototype.nextTick = function (fn) {
var safeCallback = _safeWrap(this, fn);
process.nextTick(safeCallback);
};
/**
* Suppresses calls until the function is no longer called for that many milliseconds.
* This should be used for delaying updates triggered by user input or typing text.
*
* @param fn - A function to be invoked with the component instance as its `this` value.
* @param delay - Amount of time (in ms) to wait until invoking `fn`. Default '0'.
*
* @returns a bound function
*/
Component.prototype.debounce = function (fn, delay) {
delay = delay || 0;
if (typeof delay !== 'number') {
throw new Error('Second argument must be a number');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
var component = this;
this.on('destroy', function () {
// Reduce potential for memory leaks by removing references to the component
// and the passed in callback, which could have closure references
component = null;
// Cease calling back after component is removed from the DOM
fn = null;
});
var nextArgs;
var timeout;
var boundCallback = function () {
var args = nextArgs;
nextArgs = null;
timeout = null;
if (fn && args) {
fn.apply(component, args);
}
};
return function componentDebounceWrapper() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
nextArgs = slice.call(args);
if (timeout)
clearTimeout(timeout);
timeout = setTimeout(boundCallback, delay);
};
};
/**
* Like debounce(), suppresses calls until the function is no longer called for
* that many milliseconds. In addition, suppresses calls while the callback
* function is running. In other words, the callback will not be called again
* until the supplied done() argument is called. When the debounced function is
* called while the callback is running, the callback will be called again
* immediately after done() is called. Thus, the callback will always receive
* the last value passed to the debounced function.
*
* This avoids the potential for multiple callbacks to execute in parallel and
* complete out of order. It also acts as an adaptive rate limiter. Use this
* method to debounce any field that triggers an async call as the user types.
*
* Like component.bind(), will no longer call back once the component is
* destroyed, which avoids possible bugs and memory leaks.
*
* Forked from: https://github.com/juliangruber/async-debounce
*
* @param fn - A function to be invoked with the component instance as its `this` value.
* @param delay - Amount of time (in ms) to wait until invoking `fn`. Default '0'.
*
* @returns a bound function
*/
Component.prototype.debounceAsync = function (fn, delay) {
var applyArguments = fn.length !== 1;
delay = delay || 0;
if (typeof delay !== 'number') {
throw new Error('Second argument must be a number');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
var component = this;
this.on('destroy', function () {
// Reduce potential for memory leaks by removing references to the component
// and the passed in callback, which could have closure references
component = null;
// Cease calling back after component is removed from the DOM
fn = null;
});
var running = false;
var nextArgs;
var timeout;
function done() {
var args = nextArgs;
nextArgs = null;
timeout = null;
if (fn && args) {
running = true;
args.push(done);
fn.apply(component, args);
}
else {
running = false;
}
}
return function componentDebounceAsyncWrapper() {
var args = [];
for (var _i = 0; _i < arguments.length; _i++) {
args[_i] = arguments[_i];
}
nextArgs = (applyArguments) ? slice.call(args) : [];
if (timeout)
clearTimeout(timeout);
if (running)
return;
timeout = setTimeout(done, delay);
};
};
Component.prototype.get = function (viewName, unescaped) {
var view = this.getView(viewName);
return view.get(this.context, unescaped);
};
Component.prototype.getFragment = function (viewName, _ns) {
var view = this.getView(viewName);
return view.getFragment(this.context);
};
Component.prototype.getView = function (viewName, _ns) {
var contextView = this.context.getView();
return (viewName) ?
this.app.views.find(viewName, contextView.namespace) : contextView;
};
/**
* Retrieve the appropriate view attribute's value for a given view instance.
* If the value is a template, it will be rendered prior to being returned.
*
* @param attrName the name of the view attribute used with a component instance
* @returns any of the possible values that can be expressed with a view attribute
*
* @see https://derbyjs.github.io/derby/views/template-syntax/view-attributes
*/
Component.prototype.getAttribute = function (attrName) {
var attributeContext = this.context.forAttribute(attrName);
if (!attributeContext)
return;
var value = attributeContext.attributes[attrName];
if (value instanceof expressions.Expression) {
value = value.get(attributeContext);
}
return expressions.renderValue(value, this.context);
};
Component.prototype.setAttribute = function (key, value) {
(0, util_1.checkKeyIsSafe)(key);
this.context.parent.attributes[key] = value;
};
Component.prototype.setNullAttribute = function (key, value) {
(0, util_1.checkKeyIsSafe)(key);
var attributes = this.context.parent.attributes;
if (attributes[key] == null)
attributes[key] = value;
};
return Component;
}(Controller_1.Controller));
exports.Component = Component;
function _safeWrap(component, callback) {
return function () {
if (component.isDestroyed)
return;
callback.call(component);
};
}
var ComponentAttribute = /** @class */ (function () {
function ComponentAttribute(expression, model, key) {
(0, util_1.checkKeyIsSafe)(key);
this.expression = expression;
this.model = model;
this.key = key;
}
ComponentAttribute.prototype.update = function (context, binding) {
var value = this.expression.get(context);
// @ts-expect-error Unsure what type binding should be w condition
binding.condition = value;
this.model.setDiff(this.key, value);
};
return ComponentAttribute;
}());
exports.ComponentAttribute = ComponentAttribute;
var ComponentAttributeBinding = /** @class */ (function (_super) {
__extends(ComponentAttributeBinding, _super);
function ComponentAttributeBinding(expression, model, key, context) {
var _this = _super.call(this) || this;
_this.template = new ComponentAttribute(expression, model, key);
_this.context = context;
_this.condition = expression.get(context);
return _this;
}
return ComponentAttributeBinding;
}(templates_1.Binding));
exports.ComponentAttributeBinding = ComponentAttributeBinding;
function setModelAttributes(context, model) {
var attributes = context.parent.attributes;
if (!attributes)
return;
// Set attribute values on component model
for (var key in attributes) {
var value = attributes[key];
setModelAttribute(context, model, key, value);
}
}
function setModelAttribute(context, model, key, value) {
// If an attribute is an Expression, set its current value in the model
// and keep it up to date. When it is a resolvable path, use a Racer ref,
// which makes it a two-way binding. Otherwise, set to the current value
// and create a binding that will set the value in the model as the
// expression's dependencies change.
if (value instanceof expressions.Expression) {
var segments = value.pathSegments(context);
if (segments) {
model.root.ref(model._at + '.' + key, segments.join('.'), { updateIndices: true });
}
else {
var binding = new ComponentAttributeBinding(value, model, key, context);
context.addBinding(binding);
model.set(key, binding.condition);
}
return;
}
// If an attribute is a Template, set a template object in the model.
// Eagerly rendering a template can cause excessive rendering when the
// developer wants to pass in a complex chunk of HTML, and if we were to
// set a string in the model that represents the template value, we'd lose
// the ability to use the value in the component's template, since HTML
// would be escaped and we'd lose the ability to create proper bindings.
//
// This may be of surprise to developers, since it may not be intuitive
// whether a passed in value will produce an expression or a template. To
// get the rendered value consistently, the component's getAttribute(key)
// method may be used to get the value that would be rendered.
if (value instanceof templates.Template) {
var template = new templates.ContextClosure(value, context);
model.set(key, template);
return;
}
// For all other value types, set the passed in value directly. Passed in
// values will only be set initially, so model paths should be used if
// bindings are desired.
model.set(key, value);
}
function createFactory(constructor) {
// DEPRECATED: constructor.prototype.singleton is deprecated. "singleton"
// static property on the constructor is preferred
return (constructor.singleton === true) ?
new SingletonComponentFactory(constructor) :
new ComponentFactory(constructor);
}
exports.createFactory = createFactory;
function emitInitHooks(context, component) {
if (!context.initHooks)
return;
// Run initHooks for `on` listeners immediately before init
for (var i = 0, len = context.initHooks.length; i < len; i++) {
context.initHooks[i].emit(context, component);
}
}
var ComponentModelData = /** @class */ (function () {
function ComponentModelData() {
}
return ComponentModelData;
}());
exports.ComponentModelData = ComponentModelData;
var ComponentFactory = /** @class */ (function () {
function ComponentFactory(constructorFn) {
this.constructorFn = constructorFn;
}
ComponentFactory.prototype.init = function (context) {
var DataConstructor = this.constructorFn.DataConstructor || ComponentModelData;
var data = new DataConstructor();
// eslint-disable-next-line new-cap
var component = new this.constructorFn(context, data);
// Detect whether the component constructor already called super by checking
// for one of the properties it sets. If not, call the Component constructor
if (!component.context) {
Component.call(component, context, data);
}
setModelAttributes(component.context, component.model);
// Do the user-specific initialization. The component constructor should be
// an empty function and the actual initialization code should be done in the
// component's init method. This means that we don't have to rely on users
// properly calling the Component constructor method and avoids having to
// play nice with how CoffeeScript extends class constructors
emitInitHooks(context, component);
component.emit('init', component);
if (component.init) {
if (racer_1.util.isProduction) {
component.init(component.model);
}
else {
var initReturn = component.init(component.model);
if (initReturn instanceof Promise) {
console.warn("Component ".concat(component.constructor.name, " init() should not be an async function:"), component.init);
}
}
}
return component.context;
};
ComponentFactory.prototype.create = function (context) {
var component = context.controller;
component.emit('create', component);
// Call the component's create function after its view is rendered
if (component.create) {
component.create(component.model, component.dom);
}
};
return ComponentFactory;
}());
exports.ComponentFactory = ComponentFactory;
function noop() { }
var SingletonComponentFactory = /** @class */ (function () {
function SingletonComponentFactory(constructorFn) {
this.constructorFn = constructorFn;
this.component = null;
// Disable component from being destroyed, since it is intended to
// be used multiple times
constructorFn.prototype.destroy = noop;
}
SingletonComponentFactory.prototype.init = function (context) {
// eslint-disable-next-line new-cap
if (!this.component)
this.component = new this.constructorFn();
return context.componentChild(this.component);
};
// Don't call the init or create methods for singleton components
SingletonComponentFactory.prototype.create = function () { };
return SingletonComponentFactory;
}());
function isBasePrototype(object) {
return (object === Object.prototype) ||
(object === Function.prototype) ||
(object === null);
}
function getRootPrototype(object) {
// eslint-disable-next-line no-constant-condition
while (true) {
var prototype = Object.getPrototypeOf(object);
if (isBasePrototype(prototype))
return object;
object = prototype;
}
}
var _extendComponent = (Object.setPrototypeOf && Object.getPrototypeOf) ?
// Modern version, which supports ES6 classes
function (constructor) {
// Find the end of the prototype chain
var rootPrototype = getRootPrototype(constructor.prototype);
// This guard is a workaround to a bug that has occurred in Chakra when
// app.component() is invoked twice on the same constructor. In that case,
// the `instanceof Component` check in extendComponent incorrectly returns
// false after the prototype has already been set to `Component.prototype`.
// Then, this code proceeds to set the prototype of Component.prototype
// to itself, which throws a "Cyclic __proto__ value" error.
// https://github.com/Microsoft/ChakraCore/issues/5915
if (rootPrototype === Component.prototype)
return;
// Establish inheritance with the pattern that Node's util.inherits() uses
// if Object.setPrototypeOf() is available (all modern browsers & IE11).
// This inhertance pattern is not equivalent to class extends, but it does
// work to make instances of the constructor inherit the desired prototype
// https://github.com/nodejs/node/issues/4179
Object.setPrototypeOf(rootPrototype, Component.prototype);
} :
// Fallback for older browsers
function (constructor) {
// In this version, we iterate over all of the properties on the
// constructor's prototype and merge them into a new prototype object.
// This flattens the prototype chain, meaning that instanceof will not
// work for classes from which the current component inherits
var prototype = constructor.prototype;
// Otherwise, modify constructor.prototype. This won't work with ES6
// classes, since their prototype property is non-writeable. However, it
// does work in older browsers that don't support Object.setPrototypeOf(),
// and those browsers don't support ES6 classes either
constructor.prototype = Object.create(Component.prototype);
constructor.prototype.constructor = constructor;
racer_1.util.mergeInto(constructor.prototype, prototype);
};
function extendComponent(constructor) {
if (constructor.singleton) {
if (constructor.prototype instanceof Component) {
throw new Error('Singleton compoment must not extend the Component class');
}
else {
return;
}
}
// Normal components' constructors must extend Component.
if (constructor.prototype instanceof Component) {
return;
}
// For backwards compatibility, if a normal component doesn't already extend Component,
// then append Component.prototype to the constructor's prototype chain
_extendComponent(constructor);
}
exports.extendComponent = extendComponent;