gristlabs_grist-core/app/client/lib/koUtil.js

232 lines
7.9 KiB
JavaScript
Raw Normal View History

var _ = require('underscore');
var ko = require('knockout');
/**
* This is typed to declare that the observable/computed supports subscribable.fn methods
* added in this utility.
*/
function withKoUtils(obj) {
return obj;
}
exports.withKoUtils = withKoUtils;
/**
* subscribeInit is a convenience method, equivalent to knockout's observable.subscribe(), but
* also calls the callback immediately with the observable's current value.
*
* It is added to the prototype for all observables, as long as this module is included anywhere.
*/
ko.subscribable.fn.subscribeInit = function(callback, target, event) {
var sub = this.subscribe(callback, target, event);
callback.call(target, this.peek());
return sub;
};
/**
* Add a named method `assign` to knockout subscribables (including observables) to assign a new
* value. This way we can move away from using callable objects for everything, since callable
* objects require hacking at prototypes.
*/
ko.subscribable.fn.assign = function(value) {
this(value);
};
/**
* Convenience method to modify a non-primitive value and assign it back. E.g. if foo() is an
* observable whose value is an array, then
*
* foo.modifyAssign(function(array) { array.push("test"); });
*
* is one-liner equivalent to:
*
* var array = foo.peek();
* array.push("text");
* foo(array);
*
* Whenever using a non-primitive value, be careful that it's not shared with other code, which
* might modify it without any observable subscriptions getting triggered.
*/
ko.subscribable.fn.modifyAssign = function(modifierFunc) {
var value = this.peek();
modifierFunc(value);
this(value);
};
/**
* Tells a computed observable which may return non-primitive values (e.g. objects) that it should
* only notify subscribers when the computed value is not equal to the last one (using "===").
*/
ko.subscribable.fn.onlyNotifyUnequal = function() {
this.equalityComparer = function(a, b) { return a === b; };
return this;
};
/**
* Notifies only about distinct defined values. If the first value is undefined it will still be
* returned.
*/
ko.subscribable.fn.previousOnUndefined = function() {
this.equalityComparer = function(a, b) { return a === b || b === undefined; };
return this;
};
let _handlerFunc = (err) => {};
let _origKoComputed = ko.computed;
/**
* If setComputedErrorHandler is used, this wrapper catches and swallows errors from the
* evaluation of any computed. Any exception gets passed to _handlerFunc, and the computed
* evaluates successfully to its previous value (or _handlerFunc may rethrow the error).
*/
function _wrapComputedRead(readFunc) {
let lastValue;
return function() {
try {
return (lastValue = readFunc.call(this));
} catch (err) {
console.error("ERROR in ko.computed: %s", err);
_handlerFunc(err);
return lastValue;
}
};
}
/**
* If called, exceptions thrown while evaluating any ko.computed observable will get passed to
* handlerFunc and swallowed. Unless the handlerFunc rethrows them, the computed will evaluate
* successfully to its previous value.
*
* Note that this is merely an attempt to do the best we can to keep going in the face of
* application bugs. The returned value is not actually correct, and relying on this incorrect
* value may cause even worse bugs elsewhere in the application. It is important that any errors
* caught via this mechanism get reported, debugged, and fixed.
*/
function setComputedErrorHandler(handlerFunc) {
_handlerFunc = handlerFunc;
// Note that ko.pureComputed calls to ko.computed, so doesn't need its own override.
ko.computed = function(funcOrOptions, funcTarget, options) {
if (typeof funcOrOptions === 'function') {
funcOrOptions = _wrapComputedRead(funcOrOptions);
} else {
funcOrOptions.read = _wrapComputedRead(funcOrOptions.read);
}
return _origKoComputed(funcOrOptions, funcTarget, options);
};
}
exports.setComputedErrorHandler = setComputedErrorHandler;
/**
* Returns an observable which mirrors the passed-in argument, but returns a default value if the
* underlying field is falsy and has non-boolean type. Writes to the returned observable translate
* directly to writes to the underlying one. The default may be a function, evaluated as for computed
* observables, with optContext as the context.
*/
function observableWithDefault(obs, defaultOrFunc, optContext) {
if (typeof defaultOrFunc !== 'function') {
var def = defaultOrFunc;
defaultOrFunc = function() { return def; };
}
return ko.pureComputed({
read: function() {
const value = obs();
if (typeof value === 'boolean') {
return value;
}
return value || defaultOrFunc.call(this);
},
write: function(val) { obs(val); },
owner: optContext
});
}
exports.observableWithDefault = observableWithDefault;
/**
* Return an observable which mirrors the passed-in argument, but convert to Number value. Write to
* to the returned observable translate to write to the underlying one a Number value.
*/
function observableNumber(obs) {
return ko.pureComputed({
read: () => Number(obs()),
write: (val) => {
obs(Number(val));
}
});
}
exports.observableNumber = observableNumber;
/**
* Same interface as ko.computed(), except that it disposes the values it evaluates to. If an
* observable is set to values which are created on the fly and need to be disposed (e.g.
* components), use foo = computedAutoDispose(...). Whenever the value of foo() changes (and when
* foo itself is disposed), the previous value's `dispose` method gets called.
*/
function computedAutoDispose(optionsOrReadFunc, target, options) {
// Note: this isn't quite possible to do as a knockout extender, specifically to get correct the
// pure vs non-pure distinction (sometimes the computed must be pure to avoid evaluation;
// sometimes it has side-effects and must not be pure).
var value = null;
function setNewValue(newValue) {
if (value && value !== newValue) {
ko.ignoreDependencies(value.dispose, value);
}
value = newValue;
return newValue;
}
var origRead;
if (typeof optionsOrReadFunc === "object") {
// Single-parameter syntax.
origRead = optionsOrReadFunc.read;
options = _.clone(optionsOrReadFunc);
} else {
origRead = optionsOrReadFunc;
options = _.defaults({ owner: target }, options || {});
}
options.read = function() {
return setNewValue(origRead.call(this));
};
var result = ko.computed(options);
var origDispose = result.dispose;
result.dispose = function() {
setNewValue(null);
origDispose.call(result);
};
return result;
}
exports.computedAutoDispose = computedAutoDispose;
/**
* Helper for building disposable components that depend on a few observables. The callback is
* evaluated as for a knockout computed observable, which creates dependencies on any observables
* mentioned in it. But the return value of the callback should be a function ("builder"), which
* is called to build the resulting value. Observables mentioned in the evaluation of the builder
* do NOT create dependencies. In addition, the built value gets disposed automatically when it
* changes.
*
* The optContext argument serves as the context for the callback.
*
* For example,
* var foo = ko.observable();
* koUtil.computedBuilder(function() {
* return MyComponent.create.bind(MyComponent, foo());
* }, this);
*
* In this case, whenever foo() changes, MyComponent.create(foo()) gets called, and
* previously-returned component gets disposed. Observables mentioned during MyComponent's
* construction do not trigger its rebuilding (as they would if a plain ko.computed() were used).
*/
function computedBuilder(callback, optContext) {
return computedAutoDispose(function() {
var builder = callback.call(optContext);
return builder ? ko.ignoreDependencies(builder) : null;
}, null, { pure: false });
}
exports.computedBuilder = computedBuilder;