Persistence API,

This is a combination of 10 commits.
Enable widget instanciation from front-end.

Address @minrk 's review comments.

Make API that allows users to persist widget state easily.

Added support for view persistence

Started adding support for model persistence.

Half way there!

Finished persistence API.

Move persistence code into the widget framework.

Fin.

Bug fixes
This commit is contained in:
Jonathan Frederic 2014-10-08 20:08:34 -07:00 committed by Jonathan Frederic
parent 8160308bb2
commit d466601dbb
5 changed files with 249 additions and 30 deletions

View File

@ -101,7 +101,7 @@ define([
this.last_msg_id = null;
this.completer = null;
this.widget_views = [];
var config = utils.mergeopt(CodeCell, this.config);
Cell.apply(this,[{
@ -191,12 +191,19 @@ define([
.addClass('widget-subarea')
.appendTo(widget_area);
this.widget_subarea = widget_subarea;
var that = this;
var widget_clear_buton = $('<button />')
.addClass('close')
.html('&times;')
.click(function() {
widget_area.slideUp('', function(){ widget_subarea.html(''); });
})
widget_area.slideUp('', function(){
for (var i = 0; i < that.widget_views.length; i++) {
that.widget_views[i].remove();
}
that.widget_views = [];
widget_subarea.html('');
});
})
.appendTo(widget_prompt);
var output = $('<div></div>');
@ -210,6 +217,24 @@ define([
this.completer = new completer.Completer(this, this.events);
};
/**
* Display a widget view in the cell.
*/
CodeCell.prototype.display_widget_view = function(view_promise) {
// Display a dummy element
var dummy = $('<div/>');
this.widget_subarea.append(dummy);
// Display the view.
var that = this;
return view_promise.then(function(view) {
dummy.replaceWith(view.$el);
this.widget_views.push(view);
return view;
});
};
/** @method bind_events */
CodeCell.prototype.bind_events = function () {
Cell.prototype.bind_events.apply(this);
@ -322,6 +347,10 @@ define([
this.active_output_area.clear_output();
// Clear widget area
for (var i = 0; i < this.widget_views.length; i++) {
this.widget_views[i].remove();
}
this.widget_views = [];
this.widget_subarea.html('');
this.widget_subarea.height('');
this.widget_area.height('');

View File

@ -291,6 +291,13 @@ define([
// Firefox 22 broke $(window).on("beforeunload")
// I'm not sure why or how.
window.onbeforeunload = function (e) {
// Raise an event that allows the user to execute custom code on unload
try {
that.events.trigger('beforeunload.Notebook', {notebook: that});
} catch(e) {
console.err('Error in "beforeunload.Notebook" event handler.', e);
}
// TODO: Make killing the kernel configurable.
var kill_kernel = false;
if (kill_kernel) {

View File

@ -7,7 +7,8 @@ define([
"jquery",
"base/js/utils",
"base/js/namespace",
], function (_, Backbone, $, utils, IPython) {
"services/kernels/comm"
], function (_, Backbone, $, utils, IPython, comm) {
"use strict";
//--------------------------------------------------------------------
// WidgetManager class
@ -22,10 +23,11 @@ define([
this.keyboard_manager = notebook.keyboard_manager;
this.notebook = notebook;
this.comm_manager = comm_manager;
this.comm_target_name = 'ipython.widget';
this._models = {}; /* Dictionary of model ids and model instances */
// Register with the comm manager.
this.comm_manager.register_target('ipython.widget', $.proxy(this._handle_comm_open, this));
this.comm_manager.register_target(this.comm_target_name, $.proxy(this._handle_comm_open, this));
};
//--------------------------------------------------------------------
@ -53,21 +55,34 @@ define([
* Displays a view for a particular model.
*/
var that = this;
var cell = this.get_msg_cell(msg.parent_header.msg_id);
if (cell === null) {
return Promise.reject(new Error("Could not determine where the display" +
" message was from. Widget will not be displayed"));
} else if (cell.widget_subarea) {
var dummy = $('<div />');
cell.widget_subarea.append(dummy);
return this.create_view(model, {cell: cell}).then(
function(view) {
return new Promise(function(resolve, reject) {
var cell = that.get_msg_cell(msg.parent_header.msg_id);
if (cell === null) {
reject(new Error("Could not determine where the display" +
" message was from. Widget will not be displayed"));
} else {
return that.display_view_in_cell(cell, model);
}
});
};
WidgetManager.prototype.display_view_in_cell = function(cell, model) {
// Displays a view in a cell.
return new Promise(function(resolve, reject) {
if (cell.display_widget_view) {
cell.display_widget_view(that.create_view(model, {cell: cell}))
.then(function(view) {
that._handle_display_view(view);
dummy.replaceWith(view.$el);
view.trigger('displayed');
return view;
}).catch(utils.reject('Could not display view', true));
}
resolve(view);
}, function(error) {
reject(new utils.WrappedError('Could not display view', error));
});
} else {
reject(new Error('Cell does not have a `display_widget_view` method.'));
}
});
};
WidgetManager.prototype._handle_display_view = function (view) {
@ -238,6 +253,8 @@ define([
widget_model.once('comm:close', function () {
delete that._models[model_id];
});
widget_model.name = options.model_name;
widget_model.module = options.model_module;
return widget_model;
}, function(error) {
@ -249,6 +266,100 @@ define([
return model_promise;
};
WidgetManager.prototype.get_state = function(options) {
// Get the state of the widget manager.
//
// This includes all of the widget models and the cells that they are
// displayed in.
//
// Parameters
// ----------
// options: dictionary
// Dictionary of options with the following contents:
// only_displayed: (optional) boolean=false
// Only return models with one or more displayed views.
// not_alive: (optional) boolean=false
// Include models that have comms with severed connections.
return utils.resolve_promise_dict(function(models) {
var state = {};
for (var model_id in models) {
if (models.hasOwnProperty(model_id)) {
var model = models[model_id];
// If the model has one or more views defined for it,
// consider it displayed.
var displayed_flag = !(options && options.only_displayed) || Object.keys(model.views).length > 0;
var alive_flag = (options && options.not_alive) || model.comm_alive;
if (displayed_flag && alive_flag) {
state[model.model_id] = {
model_name: model.name,
model_module: model.module,
views: [],
};
// Get the views that are displayed *now*.
for (var id in model.views) {
if (model.views.hasOwnProperty(id)) {
var view = model.views[id];
var cell_index = this.notebook.find_cell_index(view.options.cell);
state[model.model_id].views.push(cell_index);
}
}
}
}
}
return state;
});
};
WidgetManager.prototype.set_state = function(state) {
// Set the notebook's state.
//
// Reconstructs all of the widget models and attempts to redisplay the
// widgets in the appropriate cells by cell index.
// Get the kernel when it's available.
var that = this;
return (new Promise(function(resolve, reject) {
if (that.kernel) {
resolve(that.kernel);
} else {
that.events.on('kernel_created.Session', function(event, data) {
resolve(data.kernel);
});
}
})).then(function(kernel) {
// Recreate all the widget models for the given state.
that.widget_models = [];
for (var i = 0; i < state.length; i++) {
// Recreate a comm using the widget's model id (model_id == comm_id).
var new_comm = new comm.Comm(kernel.widget_manager.comm_target_name, state[i].model_id);
kernel.comm_manager.register_comm(new_comm);
// Create the model using the recreated comm. When the model is
// created we don't know yet if the comm is valid so set_comm_alive
// false. Once we receive the first state push from the back-end
// we know the comm is alive.
var model = kernel.widget_manager.create_model({
comm: new_comm,
model_name: state[i].model_name,
model_module: state[i].model_module}).then(function(model) {
model.set_comm_alive(false);
model.request_state();
model.received_state.then(function() {
model.set_comm_alive(true);
});
return model;
});
that.widget_models.push(model);
}
return Promise.all(that.widget_models);
});
};
// Backwards compatibility.
IPython.WidgetManager = WidgetManager;

View File

@ -32,6 +32,13 @@ define(["widgets/js/manager",
this.id = model_id;
this.views = {};
// Promise that is resolved when a state is received
// from the back-end.
var that = this;
this.received_state = new Promise(function(resolve) {
that._resolve_received_state = resolve;
});
if (comm !== undefined) {
// Remember comm associated with the model.
this.comm = comm;
@ -40,6 +47,11 @@ define(["widgets/js/manager",
// Hook comm messages up to model.
comm.on_close($.proxy(this._handle_comm_closed, this));
comm.on_msg($.proxy(this._handle_comm_msg, this));
// Assume the comm is alive.
this.set_comm_alive(true);
} else {
this.set_comm_alive(false);
}
return Backbone.Model.apply(this);
},
@ -55,11 +67,34 @@ define(["widgets/js/manager",
}
},
_handle_comm_closed: function (msg) {
/**
* Handle when a widget is closed.
request_state: function(callbacks) {
/**
* Request a state push from the back-end.
*/
this.trigger('comm:close');
if (!this.comm) {
console.error("Could not request_state because comm doesn't exist!");
return;
}
this.comm.send({method: 'request_state'}, callbacks || this.widget_manager.callbacks());
},
set_comm_alive: function(alive) {
/**
* Change the comm_alive state of the model.
*/
if (this.comm_alive === undefined || this.comm_alive != alive) {
this.comm_alive = alive;
this.trigger(alive ? 'comm_is_live' : 'comm_is_dead', {model: this});
}
},
close: function(comm_closed) {
/**
* Close model
*/
if (this.comm && !comm_closed) {
this.comm.close();
}
this.stopListening();
this.trigger('destroy', this);
delete this.comm.model; // Delete ref so GC will collect widget model.
@ -73,6 +108,14 @@ define(["widgets/js/manager",
});
},
_handle_comm_closed: function (msg) {
/**
* Handle when a widget is closed.
*/
this.trigger('comm:close');
this.close(true);
},
_handle_comm_msg: function (msg) {
/**
* Handle incoming comm msg.
@ -104,7 +147,20 @@ define(["widgets/js/manager",
} finally {
that.state_lock = null;
}
}).catch(utils.reject("Couldn't set model state", true));
that._resolve_received_state();
return Promise.resolve();
}, utils.reject("Couldn't set model state", true));
},
get_state: function() {
// Get the serializable state of the model.
state = this.toJSON();
for (var key in state) {
if (state.hasOwnProperty(key)) {
state[key] = this._pack_models(state[key]);
}
}
return state;
},
_handle_status: function (msg, callbacks) {
@ -322,6 +378,9 @@ define(["widgets/js/manager",
this.on('displayed', function() {
this.is_displayed = true;
}, this);
this.on('remove', function() {
delete this.model.views[this.id];
}, this);
},
update: function(){
@ -387,6 +446,12 @@ define(["widgets/js/manager",
} else {
this.on('displayed', callback, context);
}
},
remove: function () {
// Raise a remove event when the view is removed.
WidgetView.__super__.remove.apply(this, arguments);
this.trigger('remove');
}
});

View File

@ -341,19 +341,26 @@ class Widget(LoggingConfigurable):
"""Called when a msg is received from the front-end"""
data = msg['content']['data']
method = data['method']
if not method in ['backbone', 'custom']:
self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
# Handle backbone sync methods CREATE, PATCH, and UPDATE all in one.
if method == 'backbone' and 'sync_data' in data:
sync_data = data['sync_data']
self.set_state(sync_data) # handles all methods
if method == 'backbone':
if 'sync_data' in data:
sync_data = data['sync_data']
self.set_state(sync_data) # handles all methods
# Handle a custom msg from the front-end
# Handle a state request.
elif method == 'request_state':
self.send_state()
# Handle a custom msg from the front-end.
elif method == 'custom':
if 'content' in data:
self._handle_custom_msg(data['content'])
# Catch remainder.
else:
self.log.error('Unknown front-end to back-end widget msg with method "%s"' % method)
def _handle_custom_msg(self, content):
"""Called when a custom msg is received."""
self._msg_callbacks(self, content)
@ -368,7 +375,7 @@ class Widget(LoggingConfigurable):
# Send the state after the user registered callbacks for trait changes
# have all fired (allows for user to validate values).
if self.comm is not None and name in self.keys:
# Make sure this isn't information that the front-end just sent us.
# Make sure this isn't information that the front-end just sent us.
if self._should_send_property(name, new_value):
# Send new state to front-end
self.send_state(key=name)