mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-09-30 12:10:48 +00:00
321 lines
12 KiB
JavaScript
321 lines
12 KiB
JavaScript
|
var _ = require('underscore');
|
||
|
var Promise = require('bluebird');
|
||
|
var assert = require('assert');
|
||
|
var gutil = require('app/common/gutil');
|
||
|
var ko = require('knockout');
|
||
|
var koUtil = require('../lib/koUtil');
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Adds a family of 'save' methods to an observable. It accepts a callback for saving a value
|
||
|
* (presumably to the server), and adds the following methods:
|
||
|
* @method save() Saves the current value of the observable to the server.
|
||
|
* @method saveOnly(obj) Saves the given value, without changing the observable's value.
|
||
|
* @method setAndSave(obj) Sets a new value for the observable and saves it.
|
||
|
* @returns {Observable} Returns the passed-on observable.
|
||
|
*/
|
||
|
function addSaveInterface(observable, saveFunc) {
|
||
|
observable.saveOnly = function(value) {
|
||
|
// Calls saveFunc and notifies subscribers of 'save' events.
|
||
|
return Promise.try(() => saveFunc.call(this, value))
|
||
|
.tap(() => observable.notifySubscribers(value, "save"));
|
||
|
};
|
||
|
observable.save = function() {
|
||
|
return this.saveOnly(this.peek());
|
||
|
};
|
||
|
observable.setAndSave = function(value) {
|
||
|
this(value);
|
||
|
return this.saveOnly(value);
|
||
|
};
|
||
|
return observable;
|
||
|
}
|
||
|
exports.addSaveInterface = addSaveInterface;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Creates a pureComputed with a read/write/save interface. The argument is an object with two
|
||
|
* properties: `read` is the same as for a computed or a pureComputed. `write` is different: it is
|
||
|
* a callback called as write(setter, value), where `setter(obs, value)` can be used with another
|
||
|
* observable to write or save to it. E.g. if `foo` is an observable:
|
||
|
*
|
||
|
* let bar = savingComputed({
|
||
|
* read: () => foo(),
|
||
|
* write: (setter, val) => setter(foo, val.toUpperCase())
|
||
|
* })
|
||
|
*
|
||
|
* Now `bar()` has the value of foo, calling `bar("hello")` will call `foo("HELLO")`, and
|
||
|
* `bar.saveOnly("hello")` will call `foo.saveOnly("HELLO")`.
|
||
|
*/
|
||
|
function savingComputed(options) {
|
||
|
return addSaveInterface(ko.pureComputed({
|
||
|
read: options.read,
|
||
|
write: val => options.write(_writeSetter, val)
|
||
|
}), val => options.write(_saveSetter, val));
|
||
|
}
|
||
|
exports.savingComputed = savingComputed;
|
||
|
|
||
|
function _writeSetter(obs, val) { return obs(val); }
|
||
|
function _saveSetter(obs, val) { return obs.saveOnly(val); }
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Set and save the observable to the given value if it would change the value of the observable.
|
||
|
* If the observable has no .save() interface, then the saving is skipped. If the save() call
|
||
|
* fails, then the observable gets reset to its previous value.
|
||
|
* @param {Observable} observable: Observable which may support the 'save' interface.
|
||
|
* @param {Object} value: An arbitrary value. If identical to the current value of the observable,
|
||
|
* then the call is a no-op.
|
||
|
* @param {Object} optOrigValue: If given, will use it as the original value of the observable: if
|
||
|
* it matches value, will skip saving; if save fails, will revert to this original.
|
||
|
* @returns {undefined|Promise} If saving, a promise for when save() completes, else undefined.
|
||
|
*/
|
||
|
function setSaveValue(observable, value, optOrigValue) {
|
||
|
let orig = (optOrigValue === undefined) ? observable.peek() : optOrigValue;
|
||
|
if (value !== orig) {
|
||
|
observable(value);
|
||
|
if (observable.save) {
|
||
|
return Promise.try(() => observable.save())
|
||
|
.catch(err => {
|
||
|
console.warn("setSaveValue %s -> %s failed: %s", orig, value, err);
|
||
|
observable(orig);
|
||
|
throw err;
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
exports.setSaveValue = setSaveValue;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Creates an observable for a field value. It accepts a callback for saving its value to the
|
||
|
* server, and adds a family of 'save' methods to the returned observable (see docs for
|
||
|
* addSaveInterface() above).
|
||
|
*/
|
||
|
function createField(saveFunc) {
|
||
|
return addSaveInterface(ko.observable(), saveFunc);
|
||
|
}
|
||
|
exports.createField = createField;
|
||
|
|
||
|
/**
|
||
|
* Returns an observable that mirrors another one but returns a default value if the underlying
|
||
|
* field is falsy. Supports writing and saving, which translates directly to writing to the
|
||
|
* underlying field. If the default value is a function, it's evaluated as in `computed()`, with
|
||
|
* the given context.
|
||
|
*/
|
||
|
function fieldWithDefault(fieldObs, defaultOrFunc, optContext) {
|
||
|
var obsWithDef = koUtil.observableWithDefault(fieldObs, defaultOrFunc, optContext);
|
||
|
if (fieldObs.saveOnly) {
|
||
|
addSaveInterface(obsWithDef, fieldObs.saveOnly);
|
||
|
}
|
||
|
return obsWithDef;
|
||
|
}
|
||
|
exports.fieldWithDefault = fieldWithDefault;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Helper to create an observable for a single property of a jsonObservable. It updates whenever
|
||
|
* the jsonObservable is updated, and it allows setting the property, which sets the entire object
|
||
|
* of the jsonObservable. Also supports 'save' methods.
|
||
|
*/
|
||
|
function _createJsonProp(jsonObservable, propName) {
|
||
|
var jsonProp = ko.pureComputed({
|
||
|
read: function() { return jsonObservable()[propName]; },
|
||
|
write: function(value) {
|
||
|
var obj = jsonObservable.peek();
|
||
|
obj[propName] = value;
|
||
|
jsonObservable(obj);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Add save methods (if underlying jsonObservable supports them)
|
||
|
if (jsonObservable.saveOnly) {
|
||
|
addSaveInterface(jsonProp, function(value) {
|
||
|
var obj = _.clone(jsonObservable.peek());
|
||
|
obj[propName] = value;
|
||
|
return jsonObservable.saveOnly(obj);
|
||
|
});
|
||
|
}
|
||
|
return jsonProp;
|
||
|
}
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Creates an observable for an object represented by an observable JSON string. It automatically
|
||
|
* parses the JSON string when it changes, and stringifies on setting the object. It also supports
|
||
|
* 'save' methods, forwarding calls to the .saveOnly function of the underlying string observable.
|
||
|
*
|
||
|
* @param {observable[String]} stringObservable: observable for a string that should contain JSON.
|
||
|
* @param [Function] modifierFunc: function called with parsed object, which can modify it
|
||
|
* at will, e.g. to set defaults. It's OK to modify in-place; only the return value is used.
|
||
|
* @param [Object] optContext: Optionally a context to call modifierFunc with.
|
||
|
*
|
||
|
* The returned observable supports these methods:
|
||
|
* @method save() Saves the current value of the observable to the server.
|
||
|
* @method saveOnly(obj) Saves the given value, without changing the observable's value.
|
||
|
* @method setAndSave(obj) Sets a new value for the observable and saves it.
|
||
|
* @method update(obj) Updates json with new properties (caller can .save() afterwards).
|
||
|
* @method prop(name) Returns an observable for the given property of the JSON object,
|
||
|
* which also supports saving. Multiple calls to prop('foo') return the same observable.
|
||
|
*/
|
||
|
function jsonObservable(stringObservable, modifierFunc, optContext) {
|
||
|
modifierFunc = modifierFunc || function(obj) { return obj || {}; };
|
||
|
|
||
|
// Create the jsonObservable itself
|
||
|
var obs = ko.pureComputed({
|
||
|
read: function() { // reads the underlying string, parses, and passes through modFunc
|
||
|
var json = stringObservable();
|
||
|
return modifierFunc.call(optContext, json ? JSON.parse(json) : null);
|
||
|
},
|
||
|
write: function(obj) { // stringifies the given obj and sets the underlying string to that
|
||
|
stringObservable(JSON.stringify(obj));
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Create save interface if possible
|
||
|
if (stringObservable.saveOnly) {
|
||
|
addSaveInterface(obs, function(obj) {
|
||
|
return stringObservable.saveOnly(JSON.stringify(obj));
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return objObservable(obs);
|
||
|
}
|
||
|
exports.jsonObservable = jsonObservable;
|
||
|
|
||
|
/**
|
||
|
* Creates an observable for an object.
|
||
|
*
|
||
|
* @param {observable[Object]} objectObservable: observable for an object.
|
||
|
*
|
||
|
* The returned observable supports these methods:
|
||
|
* @method update(obj) Updates object with new properties.
|
||
|
* @method prop(name) Returns an observable for the given property of the object.
|
||
|
*/
|
||
|
function objObservable(objectObservable) {
|
||
|
objectObservable.update = function(obj) {
|
||
|
this(_.extend(this.peek(), obj)); // read self, _.extend, writeback
|
||
|
};
|
||
|
objectObservable._props = {};
|
||
|
objectObservable.prop = function(propName) {
|
||
|
// If created, return cached prop. Else _createJsonProp
|
||
|
return this._props[propName] || (this._props[propName] = _createJsonProp(this, propName));
|
||
|
};
|
||
|
return objectObservable;
|
||
|
}
|
||
|
exports.objObservable = objObservable;
|
||
|
|
||
|
// Special value that indicates that a customValueField isn't set and is using the saved value.
|
||
|
var _sentinel = {};
|
||
|
|
||
|
/**
|
||
|
* Creates a observable that reflects savedObservable() but may diverge from it when set, and has
|
||
|
* a methods to revert to the saved value. Additionally, the saving methods
|
||
|
* (.save/.saveOnly/.setAndSave) save savedObservable() and synchronize the values.
|
||
|
*/
|
||
|
function customValue(savedObservable) {
|
||
|
var options = { read: () => savedObservable() };
|
||
|
if (savedObservable.saveOnly) {
|
||
|
options.save = (val => savedObservable.saveOnly(val));
|
||
|
}
|
||
|
return customComputed(options);
|
||
|
}
|
||
|
exports.customValue = customValue;
|
||
|
|
||
|
/**
|
||
|
* Creates an observable whose value defaults to options.read() but may diverge from it when set,
|
||
|
* and has a method to revert to the default value. If options.save(val) is provided, the saving
|
||
|
* methods (.save/.saveOnly/.setAndSave) call it and reset the observable to its default value.
|
||
|
* @param {Function} options.read: Returns the default value for the observable.
|
||
|
* @param {Function} options.save(val): Saves a new value of the observable. May return a Promise.
|
||
|
*
|
||
|
* @returns {Observable} A writable observable value with some extra properties:
|
||
|
* @property {Observable} isSaved: Computed for whether customComputed() has its default value.
|
||
|
* @method revert(): Revert the customComputed() to its default value.
|
||
|
* @method save(val): If val is different from the current value of read(), call
|
||
|
* options.save(val), then revert the observable to its (possibly new) default value.
|
||
|
*/
|
||
|
function customComputed(options) {
|
||
|
var current = ko.observable(_sentinel);
|
||
|
var read = options.read;
|
||
|
var save = options.save;
|
||
|
|
||
|
// This is our main interface: just an observable, which defaults to the one at fieldName.
|
||
|
var active = ko.pureComputed({
|
||
|
read: () => (current() !== _sentinel ? current() : read()),
|
||
|
write: val => current(val !== read() ? val : _sentinel),
|
||
|
});
|
||
|
|
||
|
// .isSaved is an observable that returns whether the saved value has not been overridden.
|
||
|
active.isSaved = ko.pureComputed(() => (current() === _sentinel));
|
||
|
|
||
|
// .revert reverts to the saved value, discarding whatever custom value was set.
|
||
|
active.revert = function() { current(_sentinel); };
|
||
|
|
||
|
// When any of the .save/.saveOnly/.setAndSave functions are called on the customValueField,
|
||
|
// they save the underlying value and (when that resolves), discard the current value.
|
||
|
if (save) {
|
||
|
addSaveInterface(active, val => (
|
||
|
Promise.try(() => val !== read() ? save(val) : null).finally(active.revert)
|
||
|
));
|
||
|
}
|
||
|
return active;
|
||
|
}
|
||
|
exports.customComputed = customComputed;
|
||
|
|
||
|
|
||
|
function bulkActionExpand(bulkAction, callback, context) {
|
||
|
assert(gutil.startsWith(bulkAction[0], "Bulk"));
|
||
|
|
||
|
var rowIds = bulkAction[2];
|
||
|
var columnValues = bulkAction[3];
|
||
|
var indivAction = bulkAction.slice(0);
|
||
|
indivAction[0] = indivAction[0].slice(4);
|
||
|
var colValues = indivAction[3] = columnValues && _.clone(columnValues);
|
||
|
for (var i = 0; i < rowIds.length; i++) {
|
||
|
indivAction[2] = rowIds[i];
|
||
|
if (colValues) {
|
||
|
for (var col in colValues) {
|
||
|
colValues[col] = columnValues[col][i];
|
||
|
}
|
||
|
}
|
||
|
callback.call(context, indivAction);
|
||
|
}
|
||
|
}
|
||
|
exports.bulkActionExpand = bulkActionExpand;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Helper class which provides a `dispatchAction` method that can be subscribed to listen to
|
||
|
* actions received from the server. It dispatches each action to `this._process_{ActionType}`
|
||
|
* method, e.g. `this._process_UpdateRecord`.
|
||
|
*
|
||
|
* Implementation methods `_process_*` are called with the action as the first argument, and with
|
||
|
* the action arguments as additional method arguments, for convenience.
|
||
|
*/
|
||
|
var ActionDispatcher = {
|
||
|
dispatchAction: function(action) {
|
||
|
console.assert(!(typeof this.isDisposed === 'function' && this.isDisposed()),
|
||
|
`Dispatching action ${action[0]} on disposed object`, this);
|
||
|
|
||
|
var methodName = "_process_" + action[0];
|
||
|
var func = this[methodName];
|
||
|
if (typeof func === 'function') {
|
||
|
var args = action.slice(0);
|
||
|
args[0] = action;
|
||
|
return func.apply(this, args);
|
||
|
} else {
|
||
|
console.warn("Received unknown action %s", action[0]);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Generic handler for bulk actions (Bulk{Add,Remove,Update}Record) which forwards the bulk call
|
||
|
* to multiple per-record calls. Intended to be used as:
|
||
|
* Foo.prototype._process_BulkUpdateRecord = Foo.prototype.dispatchBulk;
|
||
|
*/
|
||
|
dispatchBulk: function(action, tableId, rowIds, columnValues) {
|
||
|
bulkActionExpand(action, this.dispatchAction, this);
|
||
|
},
|
||
|
};
|
||
|
exports.ActionDispatcher = ActionDispatcher;
|