Commit ac07b108 authored by Jérome Perrin's avatar Jérome Perrin

Repair graph editor

graph_editor was using `rsvp.Monitor` in order to dynamically add new promises to the chain of promise in its `declareService` promise. New DOM elements were added during gadget's lifetime and and event handler on these new elements was added to the chain of promise by using [monitor method](
 https://lab.nexedi.com/nexedi/erp5/blob/76ecef89d0b4f6aa6bc5/bt5/erp5_graph_editor/SkinTemplateItem/portal_skins/erp5_graph_editor/dream_graph_editor/jsplumb/jsplumb.js.js#L607) of a monitor instance which was [returned](https://lab.nexedi.com/nexedi/erp5/blob/76ecef89d0b4f6aa6bc5/bt5/erp5_graph_editor/SkinTemplateItem/portal_skins/erp5_graph_editor/dream_graph_editor/jsplumb/jsplumb.js.js#L835) in `declareService`.

`rsvp.js` included in `erp5_xhml_style` exported `Monitor`, but this was removed in  af9c57db . If I understand correctly, this is now included in renderjs, but it's only internal.

So this old way of doing is not longer possible. We realized that instead of dynamically setting `dblclick` event handlers to each graph node elements, we could  simply rely on event bubbling and use a event handler on the parent DOM element. Also, we used renderjs builtin `onEvent` that makes event callback function executed in the promise chain . 

At this stage we did not try to switch all event handling to this approach of using a "global" event handler on the parent DOM, because the goal here was just repairing the graph editor and making sure we have tests running. Also jsplumb uses its own event system.

To enable tests for this:
 - Running the existing qunit test through Zelenium. As far as I know we cannot run qunit test as part of ERP5 test suite.
 - Install the business template in testXHTML so that it is tested by `jsl`, which by the way produce different messages that the jshint integrated in ERP5's code mirror and jslint from `WebScript_checkSyntax`. For now, this passes jshint and jsl, but jslint complains about some indentation and space problems.

/cc  @romain @vincentB @xiaowu.zhang  @seb @gabriel 

/reviewed-on nexedi/erp5!321
parents ff931799 a6cef4a8
...@@ -9,13 +9,17 @@ ...@@ -9,13 +9,17 @@
<script src="renderjs.js" type="text/javascript"></script> <script src="renderjs.js" type="text/javascript"></script>
<script src="rsvp.js" type="text/javascript"></script> <script src="rsvp.js" type="text/javascript"></script>
--> -->
<!--
FIXME: Including jQuery twice cause the jsplumb to be loaded twice.
For now we assume that it has already been loaded at this point.
<script src="../lib/jquery.js" type="text/javascript"></script> <script src="../lib/jquery.js" type="text/javascript"></script>
-->
<script src="../lib/jquery-ui.js" type="text/javascript"></script> <script src="../lib/jquery-ui.js" type="text/javascript"></script>
<script src="../lib/jquery.jsplumb.js" type="text/javascript"></script> <script src="../lib/jquery.jsplumb.js" type="text/javascript"></script>
<script src="../lib/handlebars.min.js" type="text/javascript"></script> <script src="../lib/handlebars.min.js" type="text/javascript"></script>
<script src="../lib/springy.js" type="text/javascript"></script>
<script src="../dream/mixin_promise.js" type="text/javascript"></script> <script src="../dream/mixin_promise.js" type="text/javascript"></script>
<script src="springy.js" type="text/javascript"></script>
<script src="jsplumb.js" type="text/javascript"></script> <script src="jsplumb.js" type="text/javascript"></script>
<script id="node-template" type="text/x-handlebars-template"> <script id="node-template" type="text/x-handlebars-template">
......
/* =========================================================================== /* ===========================================================================
* Copyright 2013-2015 Nexedi SA and Contributors * Copyright 2013-2015 Nexedi SA and Contributors
* *
* This file is part of DREAM. * This file is part of DREAM.
* *
* DREAM is free software: you can redistribute it and/or modify * DREAM is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by * it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* DREAM is distributed in the hope that it will be useful, * DREAM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details. * GNU Lesser General Public License for more details.
* *
* You should have received a copy of the GNU Lesser General Public License * You should have received a copy of the GNU Lesser General Public License
* along with DREAM. If not, see <http://www.gnu.org/licenses/>. * along with DREAM. If not, see <http://www.gnu.org/licenses/>.
* ==========================================================================*/ * ==========================================================================*/
/*global console, window, RSVP, rJS, $, jsPlumb, Handlebars, /*global console, window, Node, RSVP, rJS, $, jsPlumb, Handlebars,
loopEventListener, promiseEventListener, DOMParser, Springy */ loopEventListener, promiseEventListener, DOMParser, Springy */
/*jslint unparam: true todo: true */ /*jslint vars: true unparam: true nomen: true todo: true */
(function(RSVP, rJS, $, jsPlumb, Handlebars, loopEventListener, promiseEventListener, DOMParser, Springy) { (function (RSVP, rJS, $, jsPlumb, Handlebars, loopEventListener, promiseEventListener, DOMParser, Springy) {
"use strict"; "use strict";
/* TODO: /* TODO:
* less dependancies ( promise event listner ? ) * less dependancies ( promise event listener ? )
* no more handlebars * no more handlebars
* id should not always be modifiable * id should not always be modifiable
* drop zoom level * drop zoom level
* rename draggable() * rename draggable()
* factorize node & edge popup edition * factorize node & edge popup edition
*/ */
/*jslint nomen: true */ var gadget_klass = rJS(window);
var gadget_klass = rJS(window), var domParser = new DOMParser();
domParser = new DOMParser(), var node_template_source = gadget_klass.__template_element.getElementById("node-template").innerHTML;
node_template_source = gadget_klass.__template_element.getElementById("node-template").innerHTML, var node_template = Handlebars.compile(node_template_source);
node_template = Handlebars.compile(node_template_source), var popup_edit_template = gadget_klass.__template_element.getElementById("popup-edit-template").innerHTML;
popup_edit_template = gadget_klass.__template_element.getElementById("popup-edit-template").innerHTML;
function layoutGraph(graph_data) {
function layoutGraph(graph_data) { // Promise returning the graph once springy calculated the layout.
// Promise returning the graph once springy calculated the layout. // If the graph already contain layout, return it as is.
// If the graph already contain layout, return it as is. function resolver(resolve, reject) {
function resolver(resolve, reject) { try {
try { var springy_graph = new Springy.Graph();
var springy_graph = new Springy.Graph(), var max_iterations = 100; // we stop layout after 100 iterations.
max_iterations = 100, // we stop layout after 100 iterations. var loop = 0;
loop = 0, var springy_nodes = {};
springy_nodes = {}, var drawn_nodes = {};
drawn_nodes = {}, var min_x = 100;
min_x=100, max_x=0, min_y=100, max_y=0; var max_x = 0;
// make a Springy graph with our graph var min_y = 100;
$.each(graph_data.node, function(key, value) { var max_y = 0;
if (value.coordinate) { // if graph is empty, no need to layout
// graph already has a layout, no need to layout again if (Object.keys(graph_data.edge).length === 0) {
return resolve(graph_data); resolve(graph_data);
} return;
springy_nodes[key] = springy_graph.newNode({node_id: key}); }
}); // make a Springy graph with our graph
$.each(graph_data.edge, function(key, value) { $.each(graph_data.node, function (key, value) {
springy_graph.newEdge(springy_nodes[value.source], springy_nodes[value.destination]); if (value.coordinate && value.coordinate.top && value.coordinate.left) {
}); // graph already has a layout, no need to layout again
resolve(graph_data);
return;
}
springy_nodes[key] = springy_graph.newNode({node_id: key});
});
$.each(graph_data.edge, function (ignore, value) {
springy_graph.newEdge(springy_nodes[value.source], springy_nodes[value.destination]);
});
var layout = new Springy.Layout.ForceDirected(springy_graph, 400.0, 400.0, 0.5);
var renderer;
renderer = new Springy.Renderer(
layout,
function clear() {
return;
},
function drawEdge() {
return;
},
function drawNode(node, p) {
drawn_nodes[node.data.node_id] = p;
loop += 1;
if (loop > max_iterations) {
renderer.stop();
}
},
function onRenderStop() {
// calculate the min and max of x and y
$.each(graph_data.node, function (key) {
if (drawn_nodes[key].x > max_x) {
max_x = drawn_nodes[key].x;
}
if (drawn_nodes[key].x < min_x) {
min_x = drawn_nodes[key].x;
}
if (drawn_nodes[key].y > max_y) {
max_y = drawn_nodes[key].y;
}
if (drawn_nodes[key].y < min_y) {
min_y = drawn_nodes[key].y;
}
});
// "resample" the positions from 0 to 1, the scale used by this gadget.
// We keep a 5% margin
$.each(graph_data.node, function (key) {
graph_data.node[key].coordinate = {
left: 0.05 + 0.9 * (drawn_nodes[key].x - min_x) / (max_x - min_x),
top: 0.05 + 0.9 * (drawn_nodes[key].y - min_y) / (max_y - min_y)
};
});
resolve(graph_data);
}
);
renderer.start();
} catch (e) {
reject(e);
}
}
return new RSVP.Promise(resolver);
}
var layout = new Springy.Layout.ForceDirected(springy_graph, 400.0, 400.0, 0.5); function loopJsplumbBind(gadget, type, callback) {
var renderer = new Springy.Renderer( //////////////////////////
layout, // Infinite event listener (promise is never resolved)
function clear() {}, // eventListener is removed when promise is cancelled/rejected
function drawEdge(edge, p1, p2) {}, //////////////////////////
function drawNode(node, p) { var handle_event_callback;
drawn_nodes[node.data.node_id] = p; var callback_promise;
if ( ++loop > max_iterations) { var jsplumb_instance = gadget.props.jsplumb_instance;
renderer.stop();
} function cancelResolver() {
}, if (callback_promise !== undefined && typeof callback_promise.cancel === "function") {
function onRenderStop() { callback_promise.cancel();
// calculate the min and max of x and y }
$.each(graph_data.node, function(key, value) { }
if (drawn_nodes[key].x > max_x) {
max_x = drawn_nodes[key].x; function canceller() {
} if (handle_event_callback !== undefined) {
if (drawn_nodes[key].x < min_x) { jsplumb_instance.unbind(type);
min_x = drawn_nodes[key].x; }
} cancelResolver();
if (drawn_nodes[key].y > max_y) {
max_y = drawn_nodes[key].y;
}
if (drawn_nodes[key].y < min_y) {
min_y = drawn_nodes[key].y;
}
});
// "resample" the positions from 0 to 1, the scale used by this gadget.
// We keep a 5% margin
$.each(graph_data.node, function(key, value) {
graph_data.node[key].coordinate = {
left: 0.05 + 0.9 * (drawn_nodes[key].x - min_x) / (max_x - min_x),
top: 0.05 + 0.9 * (drawn_nodes[key].y - min_y) / (max_y - min_y)
};
});
resolve(graph_data);
}
);
renderer.start();
} catch (e) {
reject(e);
}
}
return new RSVP.Promise(resolver);
}
function loopJsplumbBind(gadget, type, callback) {
//////////////////////////
// Infinite event listener (promise is never resolved)
// eventListener is removed when promise is cancelled/rejected
//////////////////////////
var handle_event_callback, callback_promise, jsplumb_instance = gadget.props.jsplumb_instance;
function cancelResolver() {
if (callback_promise !== undefined && typeof callback_promise.cancel === "function") {
callback_promise.cancel();
}
}
function canceller() {
if (handle_event_callback !== undefined) {
jsplumb_instance.unbind(type);
}
cancelResolver();
}
function resolver(resolve, reject) {
handle_event_callback = function() {
var args = arguments;
cancelResolver();
callback_promise = new RSVP.Queue().push(function() {
return callback.apply(jsplumb_instance, args);
}).push(undefined, function(error) {
if (!(error instanceof RSVP.CancellationError)) {
canceller();
reject(error);
}
});
};
jsplumb_instance.bind(type, handle_event_callback);
}
return new RSVP.Promise(resolver, canceller);
}
function getNodeId(gadget, element_id) {
// returns the ID of the node in the graph from its DOM element id
var node_id;
$.each(gadget.props.node_id_to_dom_element_id, function(k, v) {
if (v === element_id) {
node_id = k;
return false;
}
});
return node_id;
}
function generateNodeId(gadget, element) {
// Generate a node id
var n = 1,
class_def = gadget.props.data.class_definition[element._class],
id = class_def.short_id || element._class;
while (gadget.props.data.graph.node[id + n] !== undefined) {
n += 1;
}
return id + n;
}
function generateDomElementId(gadget_element) {
// Generate a probably unique DOM element ID.
var n = 1;
while ($(gadget_element).find("#DreamNode_" + n).length > 0) {
n += 1;
}
return "DreamNode_" + n;
}
function getDefaultEdgeClass(gadget) {
var class_definition = gadget.props.data.class_definition;
for (var key in class_definition) {
if (class_definition.hasOwnProperty(key) && class_definition[key]._class === 'edge') {
return key;
}
}
return "Dream.Edge";
}
function updateConnectionData(gadget, connection, remove) {
if (connection.ignoreEvent) {
// this hack is for edge edition. Maybe there I missed one thing and
// there is a better way.
return;
}
if (remove) {
delete gadget.props.data.graph.edge[connection.id];
} else {
var edge_data = gadget.props.data.graph.edge[connection.id] || {
_class: getDefaultEdgeClass(gadget)
};
edge_data.source = getNodeId(gadget, connection.sourceId);
edge_data.destination = getNodeId(gadget, connection.targetId);
gadget.props.data.graph.edge[connection.id] = edge_data;
}
gadget.notifyDataChanged();
}
function convertToAbsolutePosition(gadget, x, y) {
var zoom_level = gadget.props.zoom_level,
canvas_size_x = $(gadget.props.main).width(),
canvas_size_y = $(gadget.props.main).height(),
size_x = $(gadget.props.element).find(".dummy_window").width() * zoom_level,
size_y = $(gadget.props.element).find(".dummy_window").height() * zoom_level,
top = Math.floor(y * (canvas_size_y - size_y)) + "px",
left = Math.floor(x * (canvas_size_x - size_x)) + "px";
return [left, top];
}
function convertToRelativePosition(gadget, x, y) {
var zoom_level = gadget.props.zoom_level,
canvas_size_x = $(gadget.props.main).width(),
canvas_size_y = $(gadget.props.main).height(),
size_x = $(gadget.props.element).find(".dummy_window").width() * zoom_level,
size_y = $(gadget.props.element).find(".dummy_window").height() * zoom_level,
top = Math.max(Math.min(y.replace("px", "") / (canvas_size_y - size_y), 1), 0),
left = Math.max(Math.min(x.replace("px", "") / (canvas_size_x - size_x), 1), 0);
return [left, top];
}
function updateElementCoordinate(gadget, node_id, coordinate) {
var element_id = gadget.props.node_id_to_dom_element_id[node_id],
element,
relative_position;
if (coordinate === undefined) {
element = $(gadget.props.element).find("#" + element_id);
relative_position = convertToRelativePosition(gadget, element.css("left"), element.css("top"));
coordinate = {
left: relative_position[0],
top: relative_position[1]
};
}
gadget.props.data.graph.node[node_id].coordinate = coordinate;
gadget.notifyDataChanged();
return coordinate;
}
function draggable(gadget) {
var jsplumb_instance = gadget.props.jsplumb_instance,
stop = function(element) {
updateElementCoordinate(gadget, getNodeId(gadget, element.target.id));
};
// XXX This function should only touch the node element that we just added.
jsplumb_instance.draggable(jsplumb_instance.getSelector(".window"), {
containment: "parent",
grid: [10, 10],
stop: stop
});
jsplumb_instance.makeSource(jsplumb_instance.getSelector(".window"), {
filter: ".ep",
anchor: "Continuous",
connector: ["StateMachine", {
curviness: 20
}],
connectorStyle: {
strokeStyle: "#5c96bc",
lineWidth: 2,
outlineColor: "transparent",
outlineWidth: 4
}
});
jsplumb_instance.makeTarget(jsplumb_instance.getSelector(".window"), {
dropOptions: {
hoverClass: "dragHover"
},
anchor: "Continuous"
});
}
function updateNodeStyle(gadget, element_id) {
// Update node size according to the zoom level
// XXX does nothing for now
var zoom_level = gadget.props.zoom_level,
element = $(gadget.props.element).find("#" + element_id),
new_value;
$.each(gadget.props.style_attr_list, function(i, j) {
new_value = element.css(j).replace("px", "") * zoom_level + "px";
element.css(j, new_value);
});
}
function removeElement(gadget, node_id) {
var element_id = gadget.props.node_id_to_dom_element_id[node_id];
gadget.props.jsplumb_instance.removeAllEndpoints($(gadget.props.element).find("#" + element_id));
$(gadget.props.element).find("#" + element_id).remove();
delete gadget.props.data.graph.node[node_id];
delete gadget.props.node_id_to_dom_element_id[node_id];
$.each(gadget.props.data.graph.edge, function(k, v) {
if (node_id === v.source || node_id === v.destination) {
delete gadget.props.data.graph.edge[k];
}
});
gadget.notifyDataChanged();
}
function updateElementData(gadget, node_id, data) {
var element_id = gadget.props.node_id_to_dom_element_id[node_id],
new_id = data.id || data.data.id;
$(gadget.props.element).find("#" + element_id).text(data.data.name || new_id)
.attr("title", data.data.name || new_id)
.append('<div class="ep"></div></div>');
delete data.id;
$.extend(gadget.props.data.graph.node[node_id], data.data);
if (new_id && new_id !== node_id) {
gadget.props.data.graph.node[new_id] = gadget.props.data.graph.node[node_id];
delete gadget.props.data.graph.node[node_id];
gadget.props.node_id_to_dom_element_id[new_id] = gadget.props.node_id_to_dom_element_id[node_id];
delete gadget.props.node_id_to_dom_element_id[node_id];
delete gadget.props.data.graph.node[new_id].id;
$.each(gadget.props.data.graph.edge, function (k, v) {
if (v.source === node_id) {
v.source = new_id;
} }
if (v.destination === node_id) {
v.destination = new_id; function resolver(ignore, reject) {
handle_event_callback = function () {
var args = arguments;
cancelResolver();
callback_promise = new RSVP.Queue().push(function () {
return callback.apply(jsplumb_instance, args);
}).push(undefined, function (error) {
if (!(error instanceof RSVP.CancellationError)) {
canceller();
reject(error);
}
});
};
jsplumb_instance.bind(type, handle_event_callback);
} }
}); return new RSVP.Promise(resolver, canceller);
} }
gadget.notifyDataChanged();
} function getNodeId(gadget, element_id) {
// returns the ID of the node in the graph from its DOM element id
var node_id;
function addEdge(gadget, edge_id, edge_data) { $.each(gadget.props.node_id_to_dom_element_id, function (k, v) {
var overlays = [], if (v === element_id) {
connection; node_id = k;
if (edge_data.name) { return false;
overlays = [ }
["Label", { });
cssClass: "l1 component label", return node_id;
label: edge_data.name }
}]
]; function generateNodeId(gadget, element) {
} // Generate a node id
if (gadget.props.data.graph.node[edge_data.source] === undefined) { var n = 1;
throw new Error("Error adding edge " + edge_id + " Source " + edge_data.source + " does not exist"); var class_def = gadget.props.data.class_definition[element._class];
} var id = class_def.short_id || element._class;
if (gadget.props.data.graph.node[edge_data.destination] === undefined) { while (gadget.props.data.graph.node[id + n] !== undefined) {
throw new Error("Edge adding edge " + edge_id + " Destination " + edge_data.destination + " does not exist"); n += 1;
} }
// If an edge has this data: return id + n;
// { _class: 'Edge', }
// source: 'N1',
// destination: 'N2', function generateDomElementId(gadget_element) {
// jsplumb_source_endpoint: 'BottomCenter', // Generate a probably unique DOM element ID.
// jsplumb_destination_endpoint: 'LeftMiddle', var n = 1;
// jsplumb_connector: 'Flowchart' } while ($(gadget_element).find("#DreamNode_" + n).length > 0) {
// Then it is rendered using a flowchart connector. The difficulty is that n += 1;
// jsplumb does not let you configure the connector type on the edge, but }
// on the source endpoint. One solution seem to create all types of return "DreamNode_" + n;
// endpoints on nodes. }
if (edge_data.jsplumb_connector === "Flowchart") {
connection = gadget.props.jsplumb_instance.connect({ function getDefaultEdgeClass(gadget) {
uuids: [edge_data.source + ".flowChart" + edge_data.jsplumb_source_endpoint, var class_definition = gadget.props.data.class_definition;
edge_data.destination + ".flowChart" + edge_data.jsplumb_destination_endpoint var key;
], for (key in class_definition) {
overlays: overlays if (class_definition.hasOwnProperty(key) && class_definition[key]._class === "edge") {
}); return key;
} else { }
connection = gadget.props.jsplumb_instance.connect({ }
source: gadget.props.node_id_to_dom_element_id[edge_data.source], return "Dream.Edge";
target: gadget.props.node_id_to_dom_element_id[edge_data.destination], }
Connector: ["Bezier", {
curviness: 75 function updateConnectionData(gadget, connection, remove) {
}], if (connection.ignoreEvent) {
overlays: overlays // this hack is for edge edition. Maybe there I missed one thing and
}); // there is a better way.
} return;
// set data for 'connection' event that will be called "later" }
gadget.props.data.graph.edge[edge_id] = edge_data; if (remove) {
// jsplumb assigned an id, but we are controlling ids ourselves. delete gadget.props.data.graph.edge[connection.id];
connection.id = edge_id; } else {
} var edge_data = gadget.props.data.graph.edge[connection.id] || {
_class: getDefaultEdgeClass(gadget)
function expandSchema(class_definition, full_schema) { };
// minimal expanding of json schema, supports merging allOf and $ref edge_data.source = getNodeId(gadget, connection.sourceId);
// references edge_data.destination = getNodeId(gadget, connection.targetId);
// XXX this should probably be moved to fieldset ( and not handle gadget.props.data.graph.edge[connection.id] = edge_data;
// class_definition here) }
gadget.notifyDataChanged();
function resolveReference(ref, schema) { }
// 2 here is for #/
var i, ref_path = ref.substr(2, ref.length), function convertToAbsolutePosition(gadget, x, y) {
parts = ref_path.split("/"); var zoom_level = gadget.props.zoom_level;
for (i = 0; i < parts.length; i += 1) { var canvas_size_x = $(gadget.props.main).width();
schema = schema[parts[i]]; var canvas_size_y = $(gadget.props.main).height();
} var size_x = $(gadget.props.element).find(".dummy_window").width() * zoom_level;
return schema; var size_y = $(gadget.props.element).find(".dummy_window").height() * zoom_level;
} var top = Math.floor(y * (canvas_size_y - size_y)) + "px";
var left = Math.floor(x * (canvas_size_x - size_x)) + "px";
function clone(obj) { return [left, top];
return JSON.parse(JSON.stringify(obj)); }
}
function convertToRelativePosition(gadget, x, y) {
var referenced, var zoom_level = gadget.props.zoom_level;
i, var canvas_size_x = $(gadget.props.main).width();
property, var canvas_size_y = $(gadget.props.main).height();
expanded_class_definition = clone(class_definition) || {}; var size_x = $(gadget.props.element).find(".dummy_window").width() * zoom_level;
var size_y = $(gadget.props.element).find(".dummy_window").height() * zoom_level;
var top = Math.max(Math.min(y.replace("px", "") / (canvas_size_y - size_y), 1), 0);
if (!expanded_class_definition.properties) { var left = Math.max(Math.min(x.replace("px", "") / (canvas_size_x - size_x), 1), 0);
expanded_class_definition.properties = {}; return [left, top];
} }
// expand direct ref
if (class_definition.$ref) { function updateElementCoordinate(gadget, node_id, coordinate) {
referenced = expandSchema(resolveReference(class_definition.$ref, full_schema.class_definition), full_schema); var element_id = gadget.props.node_id_to_dom_element_id[node_id];
$.extend(expanded_class_definition, referenced); var element;
delete expanded_class_definition.$ref; var relative_position;
} if (coordinate === undefined) {
// expand ref in properties element = $(gadget.props.element).find("#" + element_id);
for (property in class_definition.properties) { relative_position = convertToRelativePosition(gadget, element.css("left"), element.css("top"));
if (class_definition.properties.hasOwnProperty(property)) { coordinate = {
if (class_definition.properties[property].$ref) { left: relative_position[0],
referenced = expandSchema(resolveReference(class_definition.properties[property].$ref, full_schema.class_definition), full_schema); top: relative_position[1]
$.extend(expanded_class_definition.properties[property], referenced); };
delete expanded_class_definition.properties[property].$ref; }
} else { gadget.props.data.graph.node[node_id].coordinate = coordinate;
if (class_definition.properties[property].type === "object") { gadget.notifyDataChanged();
// no reference, but we expand anyway because we need to recurse in case there is a ref in an object property return coordinate;
referenced = expandSchema(class_definition.properties[property], full_schema); }
$.extend(expanded_class_definition.properties[property], referenced);
} function draggable(gadget) {
} var jsplumb_instance = gadget.props.jsplumb_instance;
} var stop = function (element) {
} updateElementCoordinate(gadget, getNodeId(gadget, element.target.id));
if (class_definition.oneOf) { };
expanded_class_definition.oneOf = [];
for (i = 0; i < class_definition.oneOf.length; i += 1) { // XXX This function should only touch the node element that we just added.
expanded_class_definition.oneOf.push(expandSchema(class_definition.oneOf[i], full_schema)); jsplumb_instance.draggable(jsplumb_instance.getSelector(".window"), {
} containment: "parent",
} grid: [10, 10],
if (class_definition.allOf) { stop: stop
for (i = 0; i < class_definition.allOf.length; i += 1) { });
referenced = expandSchema(class_definition.allOf[i], full_schema); jsplumb_instance.makeSource(jsplumb_instance.getSelector(".window"), {
if (referenced.properties) { filter: ".ep",
$.extend(expanded_class_definition.properties, referenced.properties); anchor: "Continuous",
delete referenced.properties; connector: ["StateMachine", {
} curviness: 20
$.extend(expanded_class_definition, referenced); }],
} connectorStyle: {
if (expanded_class_definition.allOf) { strokeStyle: "#5c96bc",
delete expanded_class_definition.allOf; lineWidth: 2,
} outlineColor: "transparent",
} outlineWidth: 4
if (expanded_class_definition.$ref) { }
delete expanded_class_definition.$ref; });
} jsplumb_instance.makeTarget(jsplumb_instance.getSelector(".window"), {
return clone(expanded_class_definition); dropOptions: {
} hoverClass: "dragHover"
},
function openEdgeEditionDialog(gadget, connection) { anchor: "Continuous"
var edge_id = connection.id, });
edge_data = gadget.props.data.graph.edge[edge_id], }
edit_popup = $(gadget.props.element).find("#popup-edit-template"),
schema, function updateNodeStyle(gadget, element_id) {
fieldset_element, // Update node size according to the zoom level
delete_promise; // XXX does nothing for now
schema = expandSchema(gadget.props.data.class_definition[edge_data._class], gadget.props.data); var zoom_level = gadget.props.zoom_level;
// We do not edit source & destination on edge this way. var element = $(gadget.props.element).find("#" + element_id);
delete schema.properties.source; var new_value;
delete schema.properties.destination; $.each(gadget.props.style_attr_list, function (ignore, j) {
gadget.props.element.insertAdjacentHTML("beforeend", popup_edit_template); new_value = element.css(j).replace("px", "") * zoom_level + "px";
edit_popup = $(gadget.props.element).find("#edit-popup"); element.css(j, new_value);
edit_popup.find(".node_class").text(connection.name || connection._class); });
fieldset_element = edit_popup.find("fieldset")[0]; }
edit_popup.dialog();
edit_popup.show(); function removeElement(gadget, node_id) {
var element_id = gadget.props.node_id_to_dom_element_id[node_id];
function save_promise(fieldset_gadget, edge_id) { gadget.props.jsplumb_instance.removeAllEndpoints($(gadget.props.element).find("#" + element_id));
return RSVP.Queue().push(function() { $(gadget.props.element).find("#" + element_id).remove();
return promiseEventListener(edit_popup.find(".graph_editor_validate_button")[0], "click", false); delete gadget.props.data.graph.node[node_id];
}).push(function(evt) { delete gadget.props.node_id_to_dom_element_id[node_id];
var data = { $.each(gadget.props.data.graph.edge, function (k, v) {
id: $(evt.target[1]).val(), if (node_id === v.source || node_id === v.destination) {
data: {} delete gadget.props.data.graph.edge[k];
}; }
return fieldset_gadget.getContent().then(function(r) { });
$.extend(data.data, gadget.props.data.graph.edge[connection.id]); gadget.notifyDataChanged();
$.extend(data.data, r); }
// to redraw, we remove the edge and add again.
// but we want to disable events on connection, since event function updateElementData(gadget, node_id, data) {
// handling promise are executed asynchronously in undefined order, var element_id = gadget.props.node_id_to_dom_element_id[node_id];
// we cannot just remove and /then/ add, because the new edge is var new_id = data.id || data.data.id;
// added before the old is removed. $(gadget.props.element).find("#" + element_id).text(data.data.name || new_id)
connection.ignoreEvent = true; .attr("title", data.data.name || new_id)
gadget.props.jsplumb_instance.detach(connection); .append("<div class='ep'></div></div>");
addEdge(gadget, r.id, data.data);
}); delete data.id;
});
} $.extend(gadget.props.data.graph.node[node_id], data.data);
delete_promise = new RSVP.Queue().push(function() { if (new_id && new_id !== node_id) {
return promiseEventListener(edit_popup.find(".graph_editor_delete_button")[0], "click", false); gadget.props.data.graph.node[new_id] = gadget.props.data.graph.node[node_id];
}).push(function() { delete gadget.props.data.graph.node[node_id];
// connectionDetached event will remove the edge from data
gadget.props.jsplumb_instance.detach(connection); gadget.props.node_id_to_dom_element_id[new_id] = gadget.props.node_id_to_dom_element_id[node_id];
}); delete gadget.props.node_id_to_dom_element_id[node_id];
return gadget.declareGadget("../fieldset/index.html", {
element: fieldset_element, delete gadget.props.data.graph.node[new_id].id;
scope: "fieldset" $.each(gadget.props.data.graph.edge, function (ignore, v) {
}).push(function(fieldset_gadget) { if (v.source === node_id) {
return RSVP.all([fieldset_gadget, fieldset_gadget.render({ v.source = new_id;
value: edge_data, }
property_definition: schema if (v.destination === node_id) {
}, edge_id)]); v.destination = new_id;
}).push(function(fieldset_gadget) { }
edit_popup.dialog("open"); });
return fieldset_gadget[0]; }
}).push(function(fieldset_gadget) { gadget.notifyDataChanged();
fieldset_gadget.startService(); // XXX }
return fieldset_gadget;
}).push(function(fieldset_gadget) {
// Expose the dialog handling promise so that we can wait for it in function addEdge(gadget, edge_id, edge_data) {
// test. var overlays = [];
gadget.props.dialog_promise = RSVP.any([save_promise(fieldset_gadget, edge_id), delete_promise]); var connection;
return gadget.props.dialog_promise; if (edge_data.name) {
}).push(function() { overlays = [
edit_popup.dialog("close"); ["Label", {
edit_popup.remove(); cssClass: "l1 component label",
delete gadget.props.dialog_promise; label: edge_data.name
}); }]
} ];
}
function openNodeEditionDialog(gadget, element) { if (gadget.props.data.graph.node[edge_data.source] === undefined) {
var node_id = getNodeId(gadget, element.id), throw new Error("Error adding edge " + edge_id + " Source " + edge_data.source + " does not exist");
node_data = gadget.props.data.graph.node[node_id], }
node_edit_popup = $(gadget.props.element).find("#popup-edit-template"), if (gadget.props.data.graph.node[edge_data.destination] === undefined) {
schema, throw new Error("Edge adding edge " + edge_id + " Destination " + edge_data.destination + " does not exist");
fieldset_element, }
delete_promise; // If an edge has this data:
// If we have no definition for this, we do not allow edition. // { _class: 'Edge',
// XXX incorrect, we need to display this dialog to be able // source: 'N1',
// to delete a node // destination: 'N2',
if (gadget.props.data.class_definition[node_data._class] === undefined) { // jsplumb_source_endpoint: 'BottomCenter',
return; // jsplumb_destination_endpoint: 'LeftMiddle',
} // jsplumb_connector: 'Flowchart' }
schema = expandSchema(gadget.props.data.class_definition[node_data._class], gadget.props.data); // Then it is rendered using a flowchart connector. The difficulty is that
if (node_edit_popup.length !== 0) { // jsplumb does not let you configure the connector type on the edge, but
node_edit_popup.remove(); // on the source endpoint. One solution seem to create all types of
} // endpoints on nodes.
gadget.props.element.insertAdjacentHTML("beforeend", popup_edit_template); if (edge_data.jsplumb_connector === "Flowchart") {
node_edit_popup = $(gadget.props.element).find("#edit-popup"); connection = gadget.props.jsplumb_instance.connect({
// Set the name of the popup to the node class uuids: [
node_edit_popup.find(".node_class").text(node_data.name || node_data._class); edge_data.source + ".flowChart" + edge_data.jsplumb_source_endpoint,
fieldset_element = node_edit_popup.find("fieldset")[0]; edge_data.destination + ".flowChart" + edge_data.jsplumb_destination_endpoint
node_edit_popup.dialog(); ],
node_data.id = node_id; overlays: overlays
});
function save_promise(fieldset_gadget, node_id) { } else {
return RSVP.Queue().push(function() { connection = gadget.props.jsplumb_instance.connect({
return promiseEventListener(node_edit_popup.find(".graph_editor_validate_button")[0], "click", false); source: gadget.props.node_id_to_dom_element_id[edge_data.source],
}).push(function(evt) { target: gadget.props.node_id_to_dom_element_id[edge_data.destination],
var data = { Connector: ["Bezier", {
// XXX id should not be handled differently ... curviness: 75
id: $(evt.target[1]).val(), }],
data: {} overlays: overlays
}; });
return fieldset_gadget.getContent().then(function(r) { }
$.extend(data.data, r); // set data for 'connection' event that will be called "later"
updateElementData(gadget, node_id, data); gadget.props.data.graph.edge[edge_id] = edge_data;
}); // jsplumb assigned an id, but we are controlling ids ourselves.
}); connection.id = edge_id;
} }
delete_promise = new RSVP.Queue().push(function() {
return promiseEventListener(node_edit_popup.find(".graph_editor_delete_button")[0], "click", false); function expandSchema(class_definition, full_schema) {
}).push(function() { // minimal expanding of json schema, supports merging allOf and $ref
return removeElement(gadget, node_id); // references
}); // XXX this should probably be moved to fieldset ( and not handle
return gadget.declareGadget("../fieldset/index.html", { // class_definition here)
element: fieldset_element, function resolveReference(ref, schema) {
scope: "fieldset" var i;
}).push(function(fieldset_gadget) { var ref_path = ref.substr(2, ref.length); // 2 here is for #/
return RSVP.all([fieldset_gadget, fieldset_gadget.render({ var parts = ref_path.split("/");
value: node_data, for (i = 0; i < parts.length; i += 1) {
property_definition: schema schema = schema[parts[i]];
}, node_id)]); }
}).push(function(fieldset_gadget) { return schema;
node_edit_popup.dialog("open"); }
return fieldset_gadget[0];
}).push(function(fieldset_gadget) { function clone(obj) {
fieldset_gadget.startService(); // XXX this should not be needed anymore. return JSON.parse(JSON.stringify(obj));
return fieldset_gadget; }
}).push(function(fieldset_gadget) {
// Expose the dialog handling promise so that we can wait for it in var referenced;
// test. var i;
gadget.props.dialog_promise = RSVP.any([save_promise(fieldset_gadget, node_id), delete_promise]); var property;
return gadget.props.dialog_promise; var expanded_class_definition = clone(class_definition) || {};
}).push(function() {
node_edit_popup.dialog("close");
node_edit_popup.remove(); if (!expanded_class_definition.properties) {
delete gadget.props.dialog_promise; expanded_class_definition.properties = {};
}); }
} // expand direct ref
if (class_definition.$ref) {
function waitForNodeClick(gadget, node) { referenced = expandSchema(resolveReference(class_definition.$ref, full_schema.class_definition), full_schema);
gadget.props.nodes_click_monitor.monitor(loopEventListener(node, "dblclick", false, openNodeEditionDialog.bind(null, gadget, node))); $.extend(expanded_class_definition, referenced);
} delete expanded_class_definition.$ref;
}
function waitForConnection(gadget) { // expand ref in properties
return loopJsplumbBind(gadget, "connection", function(info, originalEvent) { for (property in class_definition.properties) {
updateConnectionData(gadget, info.connection, false); if (class_definition.properties.hasOwnProperty(property)) {
}); if (class_definition.properties[property].$ref) {
} referenced = expandSchema(resolveReference(class_definition.properties[property].$ref, full_schema.class_definition), full_schema);
$.extend(expanded_class_definition.properties[property], referenced);
function waitForConnectionDetached(gadget) { delete expanded_class_definition.properties[property].$ref;
return loopJsplumbBind(gadget, "connectionDetached", function(info, originalEvent) { } else {
updateConnectionData(gadget, info.connection, true); if (class_definition.properties[property].type === "object") {
}); // no reference, but we expand anyway because we need to recurse in case there is a ref in an object property
} referenced = expandSchema(class_definition.properties[property], full_schema);
$.extend(expanded_class_definition.properties[property], referenced);
function waitForConnectionClick(gadget) { }
return loopJsplumbBind(gadget, "click", function(connection) { }
return openEdgeEditionDialog(gadget, connection); }
}); }
} if (class_definition.oneOf) {
expanded_class_definition.oneOf = [];
function addNode(gadget, node_id, node_data) { for (i = 0; i < class_definition.oneOf.length; i += 1) {
var render_element = $(gadget.props.main), expanded_class_definition.oneOf.push(expandSchema(class_definition.oneOf[i], full_schema));
class_definition = gadget.props.data.class_definition[node_data._class], }
coordinate = node_data.coordinate, }
dom_element_id, if (class_definition.allOf) {
box, for (i = 0; i < class_definition.allOf.length; i += 1) {
absolute_position, referenced = expandSchema(class_definition.allOf[i], full_schema);
domElement; if (referenced.properties) {
$.extend(expanded_class_definition.properties, referenced.properties);
dom_element_id = generateDomElementId(gadget.props.element); delete referenced.properties;
gadget.props.node_id_to_dom_element_id[node_id] = dom_element_id; }
node_data.name = node_data.name || class_definition.name; $.extend(expanded_class_definition, referenced);
gadget.props.data.graph.node[node_id] = node_data; }
if (coordinate === undefined) { if (expanded_class_definition.allOf) {
coordinate = { delete expanded_class_definition.allOf;
top: 0, }
left: 0 }
}; if (expanded_class_definition.$ref) {
} delete expanded_class_definition.$ref;
node_data.coordinate = updateElementCoordinate(gadget, node_id, coordinate); }
/*jslint nomen: true*/ return clone(expanded_class_definition);
domElement = domParser.parseFromString(node_template({ }
"class": node_data._class.replace(".", "-"),
element_id: dom_element_id, function openEdgeEditionDialog(gadget, connection) {
title: node_data.name || node_data.id, var edge_id = connection.id;
name: node_data.name || node_data.id var edge_data = gadget.props.data.graph.edge[edge_id];
}), "text/html").querySelector(".window"); var edit_popup = $(gadget.props.element).find("#popup-edit-template");
render_element.append(domElement); var schema;
waitForNodeClick(gadget, domElement); var fieldset_element;
box = $(gadget.props.element).find("#" + dom_element_id); var delete_promise;
absolute_position = convertToAbsolutePosition(gadget, coordinate.left, coordinate.top); schema = expandSchema(gadget.props.data.class_definition[edge_data._class], gadget.props.data);
if (class_definition && class_definition.css) { // We do not edit source & destination on edge this way.
box.css(class_definition.css); delete schema.properties.source;
} delete schema.properties.destination;
box.css("top", absolute_position[1]); gadget.props.element.insertAdjacentHTML("beforeend", popup_edit_template);
box.css("left", absolute_position[0]); edit_popup = $(gadget.props.element).find("#edit-popup");
updateNodeStyle(gadget, dom_element_id); edit_popup.find(".node_class").text(connection.name || connection._class);
draggable(gadget); fieldset_element = edit_popup.find("fieldset")[0];
// XXX make only this element draggable. edit_popup.dialog();
// Add some flowchart endpoints edit_popup.show();
// TODO: add them all !
gadget.props.jsplumb_instance.addEndpoint(dom_element_id, { function save_promise(fieldset_gadget) {
isSource: true, return new RSVP.Queue().push(function () {
maxConnections: -1, return promiseEventListener(edit_popup.find(".graph_editor_validate_button")[0], "click", false);
connector: ["Flowchart", { }).push(function (evt) {
stub: [40, 60], var data = {
gap: 10, id: $(evt.target[1]).val(),
cornerRadius: 5, data: {}
alwaysRespectStubs: true };
}] return fieldset_gadget.getContent().then(function (r) {
}, { $.extend(data.data, gadget.props.data.graph.edge[connection.id]);
anchor: "BottomCenter", $.extend(data.data, r);
uuid: node_id + ".flowchartBottomCenter" // to redraw, we remove the edge and add again.
}); // but we want to disable events on connection, since event
gadget.props.jsplumb_instance.addEndpoint(dom_element_id, { // handling promise are executed asynchronously in undefined order,
isTarget: true, // we cannot just remove and /then/ add, because the new edge is
maxConnections: -1 // added before the old is removed.
}, { connection.ignoreEvent = true;
anchor: "LeftMiddle", gadget.props.jsplumb_instance.detach(connection);
uuid: node_id + ".flowChartLeftMiddle" addEdge(gadget, r.id, data.data);
}); });
gadget.notifyDataChanged(); });
} }
delete_promise = new RSVP.Queue().push(function () {
function waitForDrop(gadget) { return promiseEventListener(edit_popup.find(".graph_editor_delete_button")[0], "click", false);
var callback; }).push(function () {
// connectionDetached event will remove the edge from data
function canceller() { gadget.props.jsplumb_instance.detach(connection);
if (callback !== undefined) { });
gadget.props.main.removeEventListener("drop", callback, false); return gadget.declareGadget("../fieldset/index.html", {
} element: fieldset_element,
} scope: "fieldset"
/*jslint unparam: true*/ }).push(function (fieldset_gadget) {
function resolver(resolve, reject) { return RSVP.all([fieldset_gadget, fieldset_gadget.render({
callback = function(evt) { value: edge_data,
try { property_definition: schema
var class_name, offset = $(gadget.props.main).offset(), }, edge_id)]);
relative_position = convertToRelativePosition(gadget, evt.clientX - offset.left + "px", evt.clientY - offset.top + "px"); }).push(function (fieldset_gadget) {
try { edit_popup.dialog("open");
// html5 compliant browser return fieldset_gadget[0];
class_name = JSON.parse(evt.dataTransfer.getData("application/json")); }).push(function (fieldset_gadget) {
} catch (e) { fieldset_gadget.startService(); // XXX
// internet explorer return fieldset_gadget;
class_name = JSON.parse(evt.dataTransfer.getData("text")); }).push(function (fieldset_gadget) {
// Expose the dialog handling promise so that we can wait for it in
// test.
gadget.props.dialog_promise = RSVP.any([save_promise(fieldset_gadget, edge_id), delete_promise]);
return gadget.props.dialog_promise;
}).push(function () {
edit_popup.dialog("close");
edit_popup.remove();
delete gadget.props.dialog_promise;
});
}
function openNodeEditionDialog(gadget, element) {
var node_id = getNodeId(gadget, element.id);
var node_data = gadget.props.data.graph.node[node_id];
var node_edit_popup = $(gadget.props.element).find("#popup-edit-template");
var schema;
var fieldset_element;
var delete_promise;
// If we have no definition for this, we do not allow edition.
// XXX incorrect, we need to display this dialog to be able
// to delete a node
if (gadget.props.data.class_definition[node_data._class] === undefined) {
return false;
}
schema = expandSchema(gadget.props.data.class_definition[node_data._class], gadget.props.data);
if (node_edit_popup.length !== 0) {
node_edit_popup.remove();
}
gadget.props.element.insertAdjacentHTML("beforeend", popup_edit_template);
node_edit_popup = $(gadget.props.element).find("#edit-popup");
// Set the name of the popup to the node class
node_edit_popup.find(".node_class").text(node_data.name || node_data._class);
fieldset_element = node_edit_popup.find("fieldset")[0];
node_edit_popup.dialog();
node_data.id = node_id;
function save_promise(fieldset_gadget, node_id) {
return new RSVP.Queue().push(function () {
return promiseEventListener(
node_edit_popup.find(".graph_editor_validate_button")[0],
"click",
false
);
}).push(function (evt) {
var data = {
// XXX id should not be handled differently ...
id: $(evt.target[1]).val(),
data: {}
};
return fieldset_gadget.getContent().then(function (r) {
$.extend(data.data, r);
updateElementData(gadget, node_id, data);
});
});
}
delete_promise = new RSVP.Queue().push(function () {
return promiseEventListener(
node_edit_popup.find(".graph_editor_delete_button")[0],
"click",
false
);
}).push(function () {
return removeElement(gadget, node_id);
});
return gadget.declareGadget("../fieldset/index.html", {
element: fieldset_element,
scope: "fieldset"
}).push(function (fieldset_gadget) {
return RSVP.all([
fieldset_gadget,
fieldset_gadget.render(
{
value: node_data,
property_definition: schema
},
node_id
)
]);
}).push(function (fieldset_gadget) {
node_edit_popup.dialog("open");
return fieldset_gadget[0];
}).push(function (fieldset_gadget) {
fieldset_gadget.startService(); // XXX this should not be needed anymore.
return fieldset_gadget;
}).push(function (fieldset_gadget) {
// Expose the dialog handling promise so that we can wait for it in
// test.
gadget.props.dialog_promise = RSVP.any([save_promise(fieldset_gadget, node_id), delete_promise]);
return gadget.props.dialog_promise;
}).push(function () {
node_edit_popup.dialog("close");
node_edit_popup.remove();
delete gadget.props.dialog_promise;
});
}
function waitForConnection(gadget) {
return loopJsplumbBind(gadget, "connection", function (info) {
updateConnectionData(gadget, info.connection, false);
});
}
function waitForConnectionDetached(gadget) {
return loopJsplumbBind(gadget, "connectionDetached", function (info) {
updateConnectionData(gadget, info.connection, true);
});
}
function waitForConnectionClick(gadget) {
return loopJsplumbBind(gadget, "click", function (connection) {
return openEdgeEditionDialog(gadget, connection);
});
}
function addNode(gadget, node_id, node_data) {
var render_element = $(gadget.props.main);
var class_definition = gadget.props.data.class_definition[node_data._class];
var coordinate = node_data.coordinate;
var dom_element_id;
var box;
var absolute_position;
var domElement;
dom_element_id = generateDomElementId(gadget.props.element);
gadget.props.node_id_to_dom_element_id[node_id] = dom_element_id;
node_data.name = node_data.name || class_definition.name;
gadget.props.data.graph.node[node_id] = node_data;
if (coordinate === undefined) {
coordinate = {
top: 0,
left: 0
};
}
node_data.coordinate = updateElementCoordinate(gadget, node_id, coordinate);
domElement = domParser.parseFromString(node_template({
"class": node_data._class.replace(".", "-"),
element_id: dom_element_id,
title: node_data.name || node_data.id,
name: node_data.name || node_data.id
}), "text/html").querySelector(".window");
render_element.append(domElement);
box = $(gadget.props.element).find("#" + dom_element_id);
absolute_position = convertToAbsolutePosition(gadget, coordinate.left, coordinate.top);
if (class_definition && class_definition.css) {
box.css(class_definition.css);
}
box.css("top", absolute_position[1]);
box.css("left", absolute_position[0]);
updateNodeStyle(gadget, dom_element_id);
draggable(gadget);
// XXX make only this element draggable.
// Add some flowchart endpoints
// TODO: add them all !
gadget.props.jsplumb_instance.addEndpoint(dom_element_id, {
isSource: true,
maxConnections: -1,
connector: ["Flowchart", {
stub: [40, 60],
gap: 10,
cornerRadius: 5,
alwaysRespectStubs: true
}]
}, {
anchor: "BottomCenter",
uuid: node_id + ".flowchartBottomCenter"
});
gadget.props.jsplumb_instance.addEndpoint(dom_element_id, {
isTarget: true,
maxConnections: -1
}, {
anchor: "LeftMiddle",
uuid: node_id + ".flowChartLeftMiddle"
});
gadget.notifyDataChanged();
}
function waitForDrop(gadget) {
var callback;
function canceller() {
if (callback !== undefined) {
gadget.props.main.removeEventListener("drop", callback, false);
}
}
function resolver(ignore, reject) {
callback = function (evt) {
try {
var class_name;
var offset = $(gadget.props.main).offset();
var relative_position = convertToRelativePosition(gadget, evt.clientX - offset.left + "px", evt.clientY - offset.top + "px");
try {
// html5 compliant browser
class_name = JSON.parse(evt.dataTransfer.getData("application/json"));
} catch (error_from_drop) {
// internet explorer
class_name = JSON.parse(evt.dataTransfer.getData("text"));
}
addNode(gadget, generateNodeId(gadget, {
_class: class_name
}), {
coordinate: {
left: relative_position[0],
top: relative_position[1]
},
_class: class_name
});
} catch (e) {
reject(e);
}
};
gadget.props.main.addEventListener("drop", callback, false);
}
return RSVP.all([ // loopEventListener adds an event listener that will prevent default for
// dragover
loopEventListener(gadget.props.main, "dragover", false, function () {
return undefined;
}), new RSVP.Promise(resolver, canceller)
]);
}
gadget_klass.ready(function (g) {
g.props = {};
}).ready(function (g) {
return g.getElement().push(function (element) {
g.props.element = element;
});
}).ready(function (g) {
g.props.node_id_to_dom_element_id = {};
g.props.zoom_level = 1;
g.props.style_attr_list = ["width", "height", "padding-top", "line-height"];
g.getElement().then(function (element) {
g.props.element = element;
});
}).declareAcquiredMethod("notifyDataChanged", "notifyDataChanged")
.declareMethod("render", function (data) {
var gadget = this;
this.props.data = {};
if (data.key) {
// Gadget embedded in ERP5
this.props.erp5_key = data.key;
data = data.value;
}
this.props.main = this.props.element.querySelector(".graph_container");
/*
$(this.props.main).resizable({
resize : function (event, ui) {
jsplumb_instance.repaint(ui.helper);
} }
addNode(gadget, generateNodeId(gadget, {
_class: class_name
}), {
coordinate: {
left: relative_position[0],
top: relative_position[1]
},
_class: class_name
});
} catch (e) {
reject(e);
}
};
gadget.props.main.addEventListener("drop", callback, false);
}
return new RSVP.all([ // loopEventListener adds an event listener that will prevent default for
// dragover
loopEventListener(gadget.props.main, "dragover", false, function() {
return undefined;
}), RSVP.Promise(resolver, canceller)
]);
}
gadget_klass.ready(function (g) {
g.props = {};
})
.ready(function (g) {
return g.getElement().push(function (element) {
g.props.element = element;
});
})
.ready(function(g) {
g.props.node_id_to_dom_element_id = {};
g.props.zoom_level = 1;
g.props.style_attr_list = ["width", "height", "padding-top", "line-height"];
g.getElement().then(function(element) {
g.props.element = element;
});
})
.declareAcquiredMethod("notifyDataChanged", "notifyDataChanged")
.declareMethod("render", function(data) {
var gadget = this, jsplumb_instance;
this.props.data = {};
if (data.key) {
// Gadget embedded in ERP5
this.props.erp5_key = data.key;
data = data.value;
}
this.props.main = this.props.element.querySelector(".graph_container");
/*
$(this.props.main).resizable({
resize : function(event, ui) {
jsplumb_instance.repaint(ui.helper);
}
});
*/
if (data) {
this.props.data = JSON.parse(data);
// XXX how to make queue ??
return layoutGraph(this.props.data.graph).then(function(graph_data) {
gadget.props.data.graph = graph_data;
// load the data
$.each(gadget.props.data.graph.node, function(key, value) {
addNode(gadget, key, value);
});
$.each(gadget.props.data.graph.edge, function(key, value) {
addEdge(gadget, key, value);
}); });
}); */
} if (data) {
}) this.props.data = JSON.parse(data);
.declareMethod("getContent", function() {
var ret = {}; // XXX how to make queue ??
if (this.props.erp5_key) { return layoutGraph(this.props.data.graph).then(function (graph_data) {
// ERP5 gadget.props.data.graph = graph_data;
ret[this.props.erp5_key] = JSON.stringify(this.props.data); // load the data
return ret; $.each(gadget.props.data.graph.node, function (key, value) {
} addNode(gadget, key, value);
return JSON.stringify(this.props.data); });
}) $.each(gadget.props.data.graph.edge, function (key, value) {
.declareService(function() { addEdge(gadget, key, value);
var gadget = this, jsplumb_instance; });
this.props.main = this.props.element.querySelector(".graph_container"); });
this.props.jsplumb_instance = jsplumb_instance = jsPlumb.getInstance(); }
if (this.props.data) { })
// load the data .declareMethod("getContent", function () {
$.each(this.props.data.graph.node, function(key, value) { var ret = {};
addNode(gadget, key, value); if (this.props.erp5_key) {
}); // ERP5
$.each(this.props.data.graph.edge, function(key, value) { ret[this.props.erp5_key] = JSON.stringify(this.props.data);
addEdge(gadget, key, value); return ret;
}); }
} return JSON.stringify(this.props.data);
jsplumb_instance.setRenderMode(jsplumb_instance.SVG); })
jsplumb_instance.importDefaults({ .onEvent("dblclick", function (evt) {
HoverPaintStyle: { var node = evt.target;
strokeStyle: "#1e8151", if (
lineWidth: 2 (node.nodeType === Node.ELEMENT_NODE) &&
}, (node.tagName === "DIV") && node.classList.contains(["window"])
Endpoint: ["Dot", { ) {
radius: 2 return openNodeEditionDialog(this, node);
}], }
ConnectionOverlays: [ })
["Arrow", { .declareService(function () {
location: 1, var gadget = this;
id: "arrow", var jsplumb_instance;
length: 14, this.props.main = this.props.element.querySelector(".graph_container");
foldback: 0.8 this.props.jsplumb_instance = jsplumb_instance = jsPlumb.getInstance();
}] if (this.props.data) {
], // load the data
Container: this.props.main $.each(this.props.data.graph.node, function (key, value) {
}); addNode(gadget, key, value);
draggable(gadget); });
$.each(this.props.data.graph.edge, function (key, value) {
this.props.nodes_click_monitor = RSVP.Monitor(); addEdge(gadget, key, value);
return RSVP.all([waitForDrop(gadget), });
waitForConnection(gadget), }
waitForConnectionDetached(gadget), jsplumb_instance.setRenderMode(jsplumb_instance.SVG);
waitForConnectionClick(gadget), jsplumb_instance.importDefaults({
gadget.props.nodes_click_monitor HoverPaintStyle: {
]); strokeStyle: "#1e8151",
}); lineWidth: 2
},
})(RSVP, rJS, $, jsPlumb, Handlebars, loopEventListener, promiseEventListener, DOMParser, Springy); Endpoint: ["Dot", {
\ No newline at end of file radius: 2
}],
ConnectionOverlays: [
["Arrow", {
location: 1,
id: "arrow",
length: 14,
foldback: 0.8
}]
],
Container: this.props.main
});
draggable(gadget);
return RSVP.all([
waitForDrop(gadget),
waitForConnection(gadget),
waitForConnectionDetached(gadget),
waitForConnectionClick(gadget)
]);
});
}(RSVP, rJS, $, jsPlumb, Handlebars, loopEventListener, promiseEventListener, DOMParser, Springy));
\ No newline at end of file
...@@ -15,6 +15,8 @@ ...@@ -15,6 +15,8 @@
<body> <body>
<div id="qunit"></div> <div id="qunit"></div>
<div id="qunit-fixture"></div> <div id="qunit-fixture">
<div id="test-element"/>
</div>
</body> </body>
</html> </html>
/*global window, document, rJS, JSON, QUnit, jQuery, RSVP, console, setTimeout /*jslint vars:true nomen:true */ /* these two options are for compatibility with jslint 2014-04-21 . We'll remove them once we switch to more recent jslint */
/*global window, document, rJS, JSON, QUnit, jQuery, RSVP, console, setTimeout */
*/ (function (rJS, JSON, QUnit, RSVP, $) {
(function(rJS, JSON, QUnit, RSVP, $) {
"use strict"; "use strict";
var start = QUnit.start, var start = QUnit.start;
stop = QUnit.stop, var stop = QUnit.stop;
test = QUnit.test, var test = QUnit.test;
equal = QUnit.equal, var equal = QUnit.equal;
ok = QUnit.ok, var ok = QUnit.ok;
error_handler = function(e) { var error_handler = function (e) {
window.console.error(e); window.console.error(e);
ok(false, e); ok(false, e);
}, };
sample_class_definition = { var sample_class_definition = {
edge: { edge: {
description: "Base definition for edge", description: "Base definition for edge",
properties: { properties: {
_class: { "_class": {
type: "string" type: "string"
}, },
destination: { destination: {
...@@ -25,7 +24,7 @@ ...@@ -25,7 +24,7 @@
name: { name: {
type: "string" type: "string"
}, },
required: [ "name", "_class", "source", "destination" ], required: ["name", "_class", "source", "destination"],
source: { source: {
type: "string" type: "string"
} }
...@@ -33,35 +32,35 @@ ...@@ -33,35 +32,35 @@
type: "object" type: "object"
}, },
"Example.Edge": { "Example.Edge": {
_class: "edge", "_class": "edge",
allOf: [ { allOf: [{
$ref: "#/edge" "$ref": "#/edge"
}, { }, {
properties: { properties: {
color: { color: {
"enum": [ "red", "green", "blue" ] "enum": ["red", "green", "blue"]
} }
} }
} ], }],
description: "An example edge with a color property" description: "An example edge with a color property"
}, },
"Example.Node": { "Example.Node": {
_class: "node", "_class": "node",
allOf: [ { allOf: [{
$ref: "#/node" "$ref": "#/node"
}, { }, {
properties: { properties: {
shape: { shape: {
type: "string" type: "string"
} }
} }
} ], }],
description: "An example node with a shape property" description: "An example node with a shape property"
}, },
node: { node: {
description: "Base definition for node", description: "Base definition for node",
properties: { properties: {
_class: { "_class": {
type: "string" type: "string"
}, },
coordinate: { coordinate: {
...@@ -74,14 +73,15 @@ ...@@ -74,14 +73,15 @@
name: { name: {
type: "string" type: "string"
}, },
required: [ "name", "_class" ] required: ["name", "_class"]
}, },
type: "object" type: "object"
} }
}, sample_graph = { };
var sample_graph = {
edge: { edge: {
edge1: { edge1: {
_class: "Example.Edge", "_class": "Example.Edge",
source: "N1", source: "N1",
destination: "N2", destination: "N2",
color: "blue" color: "blue"
...@@ -89,7 +89,7 @@ ...@@ -89,7 +89,7 @@
}, },
node: { node: {
N1: { N1: {
_class: "Example.Node", "_class": "Example.Node",
name: "Node 1", name: "Node 1",
coordinate: { coordinate: {
top: 0, top: 0,
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
shape: "square" shape: "square"
}, },
N2: { N2: {
_class: "Example.Node", "_class": "Example.Node",
name: "Node 2", name: "Node 2",
shape: "circle", shape: "circle",
coordinate: { coordinate: {
...@@ -107,127 +107,161 @@ ...@@ -107,127 +107,161 @@
} }
} }
} }
}, sample_graph_not_connected = { };
var sample_graph_no_node_coodinate = {
edge: {
edge1: {
"_class": "Example.Edge",
source: "N1",
destination: "N2",
color: "blue"
}
},
node: {
N1: {
"_class": "Example.Node",
name: "Node 1",
shape: "square"
},
N2: {
"_class": "Example.Node",
name: "Node 2",
shape: "circle"
}
}
};
var sample_graph_not_connected = {
edge: {}, edge: {},
node: { node: {
N1: { N1: {
_class: "Example.Node", "_class": "Example.Node",
name: "Node 1", name: "Node 1",
shape: "square" shape: "square"
}, },
N2: { N2: {
_class: "Example.Node", "_class": "Example.Node",
name: "Node 2", name: "Node 2",
shape: "circle" shape: "circle"
} }
} }
}, sample_data_graph = JSON.stringify({ };
var sample_data_graph = JSON.stringify({
class_definition: sample_class_definition, class_definition: sample_class_definition,
graph: sample_graph graph: sample_graph
}), sample_data_graph_not_connected = JSON.stringify({ });
var sample_data_graph_no_node_coordinate = JSON.stringify({
class_definition: sample_class_definition,
graph: sample_graph_no_node_coodinate
});
var sample_data_graph_not_connected = JSON.stringify({
class_definition: sample_class_definition, class_definition: sample_class_definition,
graph: sample_graph_not_connected graph: sample_graph_not_connected
}), sample_data_empty_graph = JSON.stringify({ });
var sample_data_empty_graph = JSON.stringify({
class_definition: sample_class_definition, class_definition: sample_class_definition,
graph: { graph: {
node: {}, node: {},
edge: {} edge: {}
} }
}); });
QUnit.config.testTimeout = 60000; QUnit.config.testTimeout = 60000;
rJS(window).ready(function(g) { rJS(window).ready(function (g) {
test("Sample graph can be loaded and output is equal to input", function() { test("Sample graph can be loaded and output is equal to input", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
return jsplumb_gadget.render(sample_data_graph); return jsplumb_gadget.render(sample_data_graph);
}).then(function() { }).then(function () {
return jsplumb_gadget.getContent(); return jsplumb_gadget.getContent();
}).then(function(content) { }).then(function (content) {
equal(content, sample_data_graph); equal(content, sample_data_graph);
}).fail(error_handler).always(start); }).fail(error_handler).always(start);
}); });
test("New node can be drag & dropped", function() { test("New node can be drag & dropped", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
function runTest() { function runTest() {
// XXX here I used getContent to have a promise, but there must be a // XXX here I used getContent to have a promise, but there must be a
// more elegant way. // more elegant way.
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
// fake a drop event // fake a drop event
var e = new window.Event("drop"); var e = new window.Event("drop");
e.dataTransfer = { e.dataTransfer = {
getData: function(type) { getData: function (type) {
// make sure we are called properly // make sure we are called properly
equal("application/json", type, "The drag&dropped element must have data type application/json"); equal("application/json", type, "The drag&dropped element must have data type application/json");
return JSON.stringify("Example.Node"); return JSON.stringify("Example.Node");
} }
}; };
jsplumb_gadget.props.main.dispatchEvent(e); jsplumb_gadget.props.main.dispatchEvent(e);
}).then(function() { }).then(function () {
return jsplumb_gadget.getContent(); return jsplumb_gadget.getContent();
}).then(function(content) { }).then(function (content) {
var node, graph = JSON.parse(content).graph; var node;
var graph = JSON.parse(content).graph;
equal(1, Object.keys(graph.node).length, "There is one new node class"); equal(1, Object.keys(graph.node).length, "There is one new node class");
node = graph.node[Object.keys(graph.node)[0]]; node = graph.node[Object.keys(graph.node)[0]];
equal("Example.Node", node._class, "Node class is set to Example.?ode"); equal("Example.Node", node._class, "Node class is set to Example.Node");
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_empty_graph); jsplumb_gadget.render(sample_data_empty_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("Node can be dragged", function() { test("Node can be dragged", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
function runTest() { function runTest() {
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
// 100 and 60 are about 10% of the .graph_container div ( set by css, so this // 100 and 60 are about 10% of the .graph_container div ( set by css, so this
// might change ) // might change )
$("div[title='Node 1']").simulate("drag", { $("div[title='Node 1']").simulate("drag", {
dx: 100, dx: 100,
dy: 60 dy: 60
}); });
}).then(function() { }).then(function () {
return jsplumb_gadget.getContent(); return jsplumb_gadget.getContent();
}).then(function(content) { }).then(function (content) {
var graph = JSON.parse(content).graph, node_coordinate = graph.node.N1.coordinate; var graph = JSON.parse(content).graph;
var node_coordinate = graph.node.N1.coordinate;
// Since original coordinates where 0,0 we are now about 0.1,0.1 // Since original coordinates where 0,0 we are now about 0.1,0.1
// as we moved 10% // as we moved 10%
ok(node_coordinate.top - .1 < .1, "Top is ok"); ok(node_coordinate.top - 0.1 < 0.1, "Top is ok");
ok(node_coordinate.left - .1 < .1, "Left is ok"); ok(node_coordinate.left - 0.1 < 0.1, "Left is ok");
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_graph); jsplumb_gadget.render(sample_data_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("Node properties can be edited", function() { test("Node properties can be edited", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
function runTest() { function runTest() {
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
// click on a node to see display the popup // click on a node to see display the popup
$("div[title='Node 1']").simulate("dblclick"); $("div[title='Node 1']").simulate("dblclick");
// Promises that handle the dialog actions are not available // Promises that handle the dialog actions are not available
// immediately after clicking. // immediately after clicking.
var promise = RSVP.Promise(function(resolve) { var promise = new RSVP.Promise(function (resolve) {
var fillDialog = function() { function fillDialog() {
if (!jsplumb_gadget.props.dialog_promise) { if (!jsplumb_gadget.props.dialog_promise) {
// Dialog not ready. Let's retry later. // Dialog not ready. Let's retry later.
// XXX this condition is actually incorrect. We need to wait // XXX this condition is actually incorrect. We need to wait
// for the event listener to have been registered for the // for the event listener to have been registered for the
// dialog buttons. This setTimeout is good enough for now. // dialog buttons. This setTimeout is good enough for now.
return setTimeout(fillDialog, 1e3); setTimeout(fillDialog, 1e3);
return;
} }
// check displayed values // check displayed values
equal($("input[name='id']").val(), "N1"); equal($("input[name='id']").val(), "N1");
...@@ -241,12 +275,13 @@ ...@@ -241,12 +275,13 @@
// resolve our test promise once the dialog handling promise is // resolve our test promise once the dialog handling promise is
// finished. // finished.
jsplumb_gadget.props.dialog_promise.then(resolve); jsplumb_gadget.props.dialog_promise.then(resolve);
}; }
fillDialog(); fillDialog();
}); });
return promise.then(function() { return promise.then(function () {
return jsplumb_gadget.getContent().then(function(content) { return jsplumb_gadget.getContent().then(function (content) {
var graph = JSON.parse(content).graph, node = graph.node.N1; var graph = JSON.parse(content).graph;
var node = graph.node.N1;
equal("Modified Name", node.name, "Data is modified"); equal("Modified Name", node.name, "Data is modified");
equal("Modified Name", $("div#" + jsplumb_gadget.props.node_id_to_dom_element_id.N1).text(), "DOM is modified"); equal("Modified Name", $("div#" + jsplumb_gadget.props.node_id_to_dom_element_id.N1).text(), "DOM is modified");
equal(1, $("div[title='Modified Name']").length, "DOM title attribute is modified"); equal(1, $("div[title='Modified Name']").length, "DOM title attribute is modified");
...@@ -255,27 +290,29 @@ ...@@ -255,27 +290,29 @@
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_graph); jsplumb_gadget.render(sample_data_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("Node can be connected", function() { test("Node can be connected", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
function runTest() { function runTest() {
return jsplumb_gadget.getContent().then(function(content) { return jsplumb_gadget.getContent().then(function (content) {
var node1 = jsplumb_gadget.props.main.querySelector("div[title='Node 1']"), node2 = jsplumb_gadget.props.main.querySelector("div[title='Node 2']"); var node1 = jsplumb_gadget.props.main.querySelector("div[title='Node 1']");
var node2 = jsplumb_gadget.props.main.querySelector("div[title='Node 2']");
equal(0, Object.keys(JSON.parse(content).graph.edge).length, "There are no edge at the beginning"); equal(0, Object.keys(JSON.parse(content).graph.edge).length, "There are no edge at the beginning");
jsplumb_gadget.props.jsplumb_instance.connect({ jsplumb_gadget.props.jsplumb_instance.connect({
source: node1.id, source: node1.id,
target: node2.id target: node2.id
}); });
}).then(function() { }).then(function () {
return jsplumb_gadget.getContent(); return jsplumb_gadget.getContent();
}).then(function(content) { }).then(function (content) {
var edge, graph = JSON.parse(content).graph; var edge;
var graph = JSON.parse(content).graph;
equal(2, Object.keys(graph.node).length, "We still have 2 nodes"); equal(2, Object.keys(graph.node).length, "We still have 2 nodes");
equal(1, Object.keys(graph.edge).length, "We have 1 edge"); equal(1, Object.keys(graph.edge).length, "We have 1 edge");
edge = graph.edge[Object.keys(graph.edge)[0]]; edge = graph.edge[Object.keys(graph.edge)[0]];
...@@ -286,42 +323,43 @@ ...@@ -286,42 +323,43 @@
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_graph_not_connected); jsplumb_gadget.render(sample_data_graph_not_connected);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("Node can be deleted", function() { test("Node can be deleted", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
function runTest() { function runTest() {
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
equal(1, $("div[title='Node 1']").length, "node 1 is visible"); equal(1, $("div[title='Node 1']").length, "node 1 is visible");
equal(1, $("._jsPlumb_connector").length, "there is 1 connection"); equal(1, $("._jsPlumb_connector").length, "there is 1 connection");
// click on node 1 to see display the popup // click on node 1 to see display the popup
$("div[title='Node 1']").simulate("dblclick"); $("div[title='Node 1']").simulate("dblclick");
// Promises that handle the dialog actions are not available // Promises that handle the dialog actions are not available
// immediately after clicking. // immediately after clicking.
var promise = RSVP.Promise(function(resolve) { var promise = new RSVP.Promise(function (resolve) {
var waitForDialogAndDelete = function() { function waitForDialogAndDelete() {
if (!jsplumb_gadget.props.dialog_promise) { if (!jsplumb_gadget.props.dialog_promise) {
// Dialog not ready. Let's retry later. // Dialog not ready. Let's retry later.
// XXX this condition is actually incorrect. We need to wait // XXX this condition is actually incorrect. We need to wait
// for the event listener to have been registered for the // for the event listener to have been registered for the
// dialog buttons. This setTimeout is good enough for now. // dialog buttons. This setTimeout is good enough for now.
return setTimeout(waitForDialogAndDelete, 1e3); setTimeout(waitForDialogAndDelete, 1e3);
return;
} }
equal(1, $("input[value='Delete']").length, "There should be one delete button"); equal(1, $("input[value='Delete']").length, "There should be one delete button");
$("input[value='Delete']").click(); $("input[value='Delete']").click();
// resolve our test promise once the dialog handling promise is // resolve our test promise once the dialog handling promise is
// finished. // finished.
jsplumb_gadget.props.dialog_promise.then(resolve); jsplumb_gadget.props.dialog_promise.then(resolve);
}; }
waitForDialogAndDelete(); waitForDialogAndDelete();
}); });
return promise.then(function() { return promise.then(function () {
return jsplumb_gadget.getContent().then(function(content) { return jsplumb_gadget.getContent().then(function (content) {
var graph = JSON.parse(content).graph; var graph = JSON.parse(content).graph;
equal(1, Object.keys(graph.node).length, "node is removed from data"); equal(1, Object.keys(graph.node).length, "node is removed from data");
equal(0, Object.keys(graph.edge).length, "edge referencing this node is also removed"); equal(0, Object.keys(graph.edge).length, "edge referencing this node is also removed");
...@@ -332,29 +370,30 @@ ...@@ -332,29 +370,30 @@
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_graph); jsplumb_gadget.render(sample_data_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("Node id can be changed (connections are updated and node can be edited afterwards)", function() { test("Node id can be changed (connections are updated and node can be edited afterwards)", function () {
var jsplumb_gadget; var jsplumb_gadget;
stop(); stop();
function runTest() { function runTest() {
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
// click on a node to see display the popup // click on a node to see display the popup
$("div[title='Node 1']").simulate("dblclick"); $("div[title='Node 1']").simulate("dblclick");
// Promises that handle the dialog actions are not available // Promises that handle the dialog actions are not available
// immediately after clicking. // immediately after clicking.
var promise = RSVP.Promise(function(resolve) { var promise = new RSVP.Promise(function (resolve) {
var fillDialog = function() { function fillDialog() {
if (!jsplumb_gadget.props.dialog_promise) { if (!jsplumb_gadget.props.dialog_promise) {
// Dialog not ready. Let's retry later. // Dialog not ready. Let's retry later.
// XXX this condition is actually incorrect. We need to wait // XXX this condition is actually incorrect. We need to wait
// for the event listener to have been registered for the // for the event listener to have been registered for the
// dialog buttons. This setTimeout is good enough for now. // dialog buttons. This setTimeout is good enough for now.
return setTimeout(fillDialog, 1e3); setTimeout(fillDialog, 1e3);
return;
} }
equal($("input[name='id']").val(), "N1"); equal($("input[name='id']").val(), "N1");
// change the id // change the id
...@@ -364,11 +403,11 @@ ...@@ -364,11 +403,11 @@
// resolve our test promise once the dialog handling promise is // resolve our test promise once the dialog handling promise is
// finished. // finished.
jsplumb_gadget.props.dialog_promise.then(resolve); jsplumb_gadget.props.dialog_promise.then(resolve);
}; }
fillDialog(); fillDialog();
}); });
return promise.then(function() { return promise.then(function () {
return jsplumb_gadget.getContent().then(function(content) { return jsplumb_gadget.getContent().then(function (content) {
var graph = JSON.parse(content).graph; var graph = JSON.parse(content).graph;
equal(2, Object.keys(graph.node).length, "We still have two nodes"); equal(2, Object.keys(graph.node).length, "We still have two nodes");
ok(graph.node.N1b !== undefined, "Node Id changed"); ok(graph.node.N1b !== undefined, "Node Id changed");
...@@ -379,52 +418,55 @@ ...@@ -379,52 +418,55 @@
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_graph); jsplumb_gadget.render(sample_data_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("New node can be edited", function() { test("New node can be edited", function () {
var jsplumb_gadget, node_id; var jsplumb_gadget;
var node_id;
stop(); stop();
function runTest() { function runTest() {
// XXX here I used getContent to have a promise, but there must be a // XXX here I used getContent to have a promise, but there must be a
// more elegant way. // more elegant way.
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
// fake a drop event // fake a drop event
var e = new window.Event("drop"); var e = new window.Event("drop");
e.dataTransfer = { e.dataTransfer = {
getData: function(type) { getData: function (type) {
// make sure we are called properly // make sure we are called properly
equal("application/json", type, "The drag&dropped element must have data type application/json"); equal("application/json", type, "The drag&dropped element must have data type application/json");
return JSON.stringify("Example.Node"); return JSON.stringify("Example.Node");
} }
}; };
jsplumb_gadget.props.main.dispatchEvent(e); jsplumb_gadget.props.main.dispatchEvent(e);
}).then(function() { }).then(function () {
return jsplumb_gadget.getContent(); return jsplumb_gadget.getContent();
}).then(function(content) { }).then(function (content) {
var node, graph = JSON.parse(content).graph; var node;
var graph = JSON.parse(content).graph;
equal(1, Object.keys(graph.node).length); equal(1, Object.keys(graph.node).length);
node_id = Object.keys(graph.node)[0]; node_id = Object.keys(graph.node)[0];
node = graph.node[node_id]; node = graph.node[node_id];
equal("Example.Node", node._class); equal("Example.Node", node._class);
}).then(function() { }).then(function () {
// click the new node to see display the popup // click the new node to see display the popup
// XXX at the moment nodes have class window // XXX at the moment nodes have class window
equal(1, $("div.window").length, "We have a new node"); equal(1, $("div.window").length, "We have a new node");
$("div.window").simulate("dblclick"); $("div.window").simulate("dblclick");
// Promises that handle the dialog actions are not available // Promises that handle the dialog actions are not available
// immediately after clicking. // immediately after clicking.
var promise = RSVP.Promise(function(resolve) { var promise = new RSVP.Promise(function (resolve) {
var fillDialog = function() { function fillDialog() {
if (!jsplumb_gadget.props.dialog_promise) { if (!jsplumb_gadget.props.dialog_promise) {
// Dialog not ready. Let's retry later. // Dialog not ready. Let's retry later.
// XXX this condition is actually incorrect. We need to wait // XXX this condition is actually incorrect. We need to wait
// for the event listener to have been registered for the // for the event listener to have been registered for the
// dialog buttons. This setTimeout is good enough for now. // dialog buttons. This setTimeout is good enough for now.
return setTimeout(fillDialog, 1e3); setTimeout(fillDialog, 1e3);
return;
} }
// check displayed values // check displayed values
equal($("input[name='id']").val(), node_id); equal($("input[name='id']").val(), node_id);
...@@ -438,12 +480,13 @@ ...@@ -438,12 +480,13 @@
// resolve our test promise once the dialog handling promise is // resolve our test promise once the dialog handling promise is
// finished. // finished.
jsplumb_gadget.props.dialog_promise.then(resolve); jsplumb_gadget.props.dialog_promise.then(resolve);
}; }
fillDialog(); fillDialog();
}); });
return promise.then(function() { return promise.then(function () {
return jsplumb_gadget.getContent().then(function(content) { return jsplumb_gadget.getContent().then(function (content) {
var graph = JSON.parse(content).graph, node = graph.node[node_id]; var graph = JSON.parse(content).graph;
var node = graph.node[node_id];
equal("Modified Name", node.name, "Data is modified"); equal("Modified Name", node.name, "Data is modified");
equal("Modified Name", $("div.window").text(), "DOM is modified"); equal("Modified Name", $("div.window").text(), "DOM is modified");
}); });
...@@ -451,63 +494,66 @@ ...@@ -451,63 +494,66 @@
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_empty_graph); jsplumb_gadget.render(sample_data_empty_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("New node can be deleted", function() { test("New node can be deleted", function () {
var jsplumb_gadget, node_id; var jsplumb_gadget;
var node_id;
stop(); stop();
function runTest() { function runTest() {
// XXX here I used getContent to have a promise, but there must be a // XXX here I used getContent to have a promise, but there must be a
// more elegant way. // more elegant way.
return jsplumb_gadget.getContent().then(function() { return jsplumb_gadget.getContent().then(function () {
// fake a drop event // fake a drop event
var e = new window.Event("drop"); var e = new window.Event("drop");
e.dataTransfer = { e.dataTransfer = {
getData: function(type) { getData: function (type) {
// make sure we are called properly // make sure we are called properly
equal("application/json", type, "The drag&dropped element must have data type application/json"); equal("application/json", type, "The drag&dropped element must have data type application/json");
return JSON.stringify("Example.Node"); return JSON.stringify("Example.Node");
} }
}; };
jsplumb_gadget.props.main.dispatchEvent(e); jsplumb_gadget.props.main.dispatchEvent(e);
}).then(function() { }).then(function () {
return jsplumb_gadget.getContent(); return jsplumb_gadget.getContent();
}).then(function(content) { }).then(function (content) {
var node, graph = JSON.parse(content).graph; var node;
var graph = JSON.parse(content).graph;
equal(1, Object.keys(graph.node).length); equal(1, Object.keys(graph.node).length);
node_id = Object.keys(graph.node)[0]; node_id = Object.keys(graph.node)[0];
node = graph.node[node_id]; node = graph.node[node_id];
equal("Example.Node", node._class); equal("Example.Node", node._class);
}).then(function() { }).then(function () {
// click the new node to see display the popup // click the new node to see display the popup
// XXX at the moment nodes have class window // XXX at the moment nodes have class window
equal(1, $("div.window").length, "We have a new node"); equal(1, $("div.window").length, "We have a new node");
$("div.window").simulate("dblclick"); $("div.window").simulate("dblclick");
// Promises that handle the dialog actions are not available // Promises that handle the dialog actions are not available
// immediately after clicking. // immediately after clicking.
var promise = RSVP.Promise(function(resolve) { var promise = new RSVP.Promise(function (resolve) {
var waitForDialogAndDelete = function() { function waitForDialogAndDelete() {
if (!jsplumb_gadget.props.dialog_promise) { if (!jsplumb_gadget.props.dialog_promise) {
// Dialog not ready. Let's retry later. // Dialog not ready. Let's retry later.
// XXX this condition is actually incorrect. We need to wait // XXX this condition is actually incorrect. We need to wait
// for the event listener to have been registered for the // for the event listener to have been registered for the
// dialog buttons. This setTimeout is good enough for now. // dialog buttons. This setTimeout is good enough for now.
return setTimeout(waitForDialogAndDelete, 1e3); setTimeout(waitForDialogAndDelete, 1e3);
return;
} }
equal(1, $("input[value='Delete']").length, "There should be one delete button"); equal(1, $("input[value='Delete']").length, "There should be one delete button");
$("input[value='Delete']").click(); $("input[value='Delete']").click();
// resolve our test promise once the dialog handling promise is // resolve our test promise once the dialog handling promise is
// finished. // finished.
jsplumb_gadget.props.dialog_promise.then(resolve); jsplumb_gadget.props.dialog_promise.then(resolve);
}; }
waitForDialogAndDelete(); waitForDialogAndDelete();
}); });
return promise.then(function() { return promise.then(function () {
return jsplumb_gadget.getContent().then(function(content) { return jsplumb_gadget.getContent().then(function (content) {
var graph = JSON.parse(content).graph; var graph = JSON.parse(content).graph;
equal(0, Object.keys(graph.node).length, "node is removed from data"); equal(0, Object.keys(graph.node).length, "node is removed from data");
equal(0, $("div.window").length, "DOM is modified"); equal(0, $("div.window").length, "DOM is modified");
...@@ -516,11 +562,31 @@ ...@@ -516,11 +562,31 @@
}); });
} }
g.declareGadget("./index.html", { g.declareGadget("./index.html", {
element: document.querySelector("#qunit-fixture") element: document.querySelector("#test-element")
}).then(function(new_gadget) { }).then(function (new_gadget) {
jsplumb_gadget = new_gadget; jsplumb_gadget = new_gadget;
jsplumb_gadget.render(sample_data_empty_graph); jsplumb_gadget.render(sample_data_empty_graph);
}).then(runTest).fail(error_handler).always(start); }).then(runTest).fail(error_handler).always(start);
}); });
test("Graph is automatically layout", function () {
var jsplumb_gadget;
stop();
g.declareGadget("./index.html", {
element: document.querySelector("#test-element")
}).then(function (new_gadget) {
jsplumb_gadget = new_gadget;
return jsplumb_gadget.render(sample_data_graph_no_node_coordinate);
}).then(function () {
return jsplumb_gadget.getContent();
}).then(function (content) {
/*jslint unparam: true */
$.each(JSON.parse(content).graph.node, function (ignore, node) {
ok(node.coordinate.top !== undefined, "Node have top coordinate");
ok((0 <= node.coordinate.top) && (node.coordinate.top <= 1), "Node top coordinate is between [0..1]");
ok(node.coordinate.left !== undefined, "Node have left coordinate");
ok((0 <= node.coordinate.left) && (node.coordinate.left <= 1), "Node left coordinate is between [0..1]");
});
}).fail(error_handler).always(start);
});
}); });
})(rJS, JSON, QUnit, RSVP, jQuery); }(rJS, JSON, QUnit, RSVP, jQuery));
\ No newline at end of file \ No newline at end of file
...@@ -14,16 +14,16 @@ ...@@ -14,16 +14,16 @@
}).declareMethod("render", function(options) { }).declareMethod("render", function(options) {
var select = this.element.getElementsByTagName("select")[0], i, template, tmp = ""; var select = this.element.getElementsByTagName("select")[0], i, template, tmp = "";
select.setAttribute("name", options.key); select.setAttribute("name", options.key);
for (i = 0; i < options.property_definition.enum.length; i += 1) { for (i = 0; i < options.property_definition['enum'].length; i += 1) {
if (options.property_definition.enum[i] === options.value) { if (options.property_definition['enum'][i] === options.value) {
template = selected_option_template; template = selected_option_template;
} else { } else {
template = option_template; template = option_template;
} }
// XXX value and text are always same in json schema // XXX value and text are always same in json schema
tmp += template({ tmp += template({
value: options.property_definition.enum[i], value: options.property_definition['enum'][i],
text: options.property_definition.enum[i] text: options.property_definition['enum'][i]
}); });
} }
select.innerHTML += tmp; select.innerHTML += tmp;
......
erp5_trade
\ No newline at end of file
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Zuite" module="Products.Zelenium.zuite"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_objects</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>graph_editor_zuite</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="ZopePageTemplate" module="Products.PageTemplates.ZopePageTemplate"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>content_type</string> </key>
<value> <string>text/html</string> </value>
</item>
<item>
<key> <string>expand</string> </key>
<value> <int>0</int> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>testQunit</string> </value>
</item>
<item>
<key> <string>output_encoding</string> </key>
<value> <string>utf-8</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <unicode>Graph Editor Qunit Test</unicode> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<html>
<head><title>Graph Editor Qunit Test</title></head>
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr><td rowspan="1" colspan="4">
Run existing qunit test in zelenium framework, to easily integrate it in current test suite.
</td></tr>
</thead><tbody>
<tr>
<td>open</td>
<td tal:content="string:${context/portal_url}/dream_graph_editor/jsplumb/test.html"></td>
<td></td>
</tr>
<tr>
<td>waitForTextPresent</td>
<td>Tests completed in </td>
<td>30000</td>
</tr>
<tr>
<td>assertText</td>
<td>css=#qunit-testresult span.failed</td>
<td>0</td>
</tr>
</tbody></table>
</body>
</html>
\ No newline at end of file
##############################################################################
#
# Copyright (c) 2017 Nexedi SA and Contributors. All Rights Reserved.
#
# WARNING: This program as such is intended to be used by professional
# programmers who take the whole responsability of assessing all potential
# consequences resulting from its eventual inadequacies and bugs
# End users who are looking for a ready-to-use solution with commercial
# garantees and support are strongly adviced to contract a Free Software
# Service Company
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
#
##############################################################################
import unittest
from Products.ERP5Type.tests.ERP5TypeFunctionalTestCase import ERP5TypeFunctionalTestCase
class TestGraphEditor(ERP5TypeFunctionalTestCase):
run_only = "graph_editor_zuite"
def getBusinessTemplateList(self):
return (
'erp5_graph_editor',
'erp5_graph_editor_ui_test',
'erp5_ui_test_core',
)
def test_suite():
suite = unittest.TestSuite()
suite.addTest(unittest.makeSuite(TestGraphEditor))
return suite
\ No newline at end of file
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Test Component" module="erp5.portal_type"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_recorded_property_dict</string> </key>
<value>
<persistent> <string encoding="base64">AAAAAAAAAAI=</string> </persistent>
</value>
</item>
<item>
<key> <string>default_reference</string> </key>
<value> <string>testFunctionalGraphEditor</string> </value>
</item>
<item>
<key> <string>description</string> </key>
<value>
<none/>
</value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>test.erp5.testFunctionalGraphEditor</string> </value>
</item>
<item>
<key> <string>portal_type</string> </key>
<value> <string>Test Component</string> </value>
</item>
<item>
<key> <string>sid</string> </key>
<value>
<none/>
</value>
</item>
<item>
<key> <string>text_content_error_message</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>text_content_warning_message</string> </key>
<value>
<tuple/>
</value>
</item>
<item>
<key> <string>version</string> </key>
<value> <string>erp5</string> </value>
</item>
<item>
<key> <string>workflow_history</string> </key>
<value>
<persistent> <string encoding="base64">AAAAAAAAAAM=</string> </persistent>
</value>
</item>
</dictionary>
</pickle>
</record>
<record id="2" aka="AAAAAAAAAAI=">
<pickle>
<global name="PersistentMapping" module="Persistence.mapping"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>data</string> </key>
<value>
<dictionary/>
</value>
</item>
</dictionary>
</pickle>
</record>
<record id="3" aka="AAAAAAAAAAM=">
<pickle>
<global name="PersistentMapping" module="Persistence.mapping"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>data</string> </key>
<value>
<dictionary>
<item>
<key> <string>component_validation_workflow</string> </key>
<value>
<persistent> <string encoding="base64">AAAAAAAAAAQ=</string> </persistent>
</value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</pickle>
</record>
<record id="4" aka="AAAAAAAAAAQ=">
<pickle>
<global name="WorkflowHistoryList" module="Products.ERP5Type.patches.WorkflowTool"/>
</pickle>
<pickle>
<tuple>
<none/>
<list>
<dictionary>
<item>
<key> <string>action</string> </key>
<value> <string>validate</string> </value>
</item>
<item>
<key> <string>validation_state</string> </key>
<value> <string>validated</string> </value>
</item>
</dictionary>
</list>
</tuple>
</pickle>
</record>
</ZopeData>
erp5_graph_editor
erp5_ui_test_core
\ No newline at end of file
portal_tests/graph_editor_zuite
portal_tests/graph_editor_zuite/**
\ No newline at end of file
test.erp5.testFunctionalGraphEditor
\ No newline at end of file
erp5_full_text_mroonga_catalog
\ No newline at end of file
erp5_graph_editor_ui_test
\ No newline at end of file
...@@ -53,7 +53,15 @@ class TestXHTMLMixin(ERP5TypeTestCase): ...@@ -53,7 +53,15 @@ class TestXHTMLMixin(ERP5TypeTestCase):
'renderjs.js','jio.js','rsvp.js','handlebars.js', 'renderjs.js','jio.js','rsvp.js','handlebars.js',
'pdf_js/build/pdf.js', 'pdf_js/build/pdf.worker.js', 'pdf_js/build/pdf.js', 'pdf_js/build/pdf.worker.js',
'pdf_js/compatibility.js', 'pdf_js/debugger.js', 'pdf_js/compatibility.js', 'pdf_js/debugger.js',
'pdf_js/viewer.js', 'pdf_js/l10n.js') 'pdf_js/viewer.js', 'pdf_js/l10n.js',
'dream_graph_editor/lib/handlebars.min.js',
'dream_graph_editor/lib/jquery-ui.js',
'dream_graph_editor/lib/jquery.js',
'dream_graph_editor/lib/jquery.jsplumb.js',
'dream_graph_editor/lib/jquery.simulate.js',
'dream_graph_editor/lib/qunit.js',
'dream_graph_editor/lib/springy.js',
)
JSL_IGNORE_SKIN_LIST = ('erp5_ace_editor', 'erp5_code_mirror', JSL_IGNORE_SKIN_LIST = ('erp5_ace_editor', 'erp5_code_mirror',
'erp5_fckeditor', 'erp5_jquery', 'erp5_jquery_ui', 'erp5_fckeditor', 'erp5_jquery', 'erp5_jquery_ui',
'erp5_svg_editor', 'erp5_xinha_editor') 'erp5_svg_editor', 'erp5_xinha_editor')
...@@ -429,6 +437,7 @@ class TestXHTML(TestXHTMLMixin): ...@@ -429,6 +437,7 @@ class TestXHTML(TestXHTMLMixin):
'erp5_xinha_editor', 'erp5_xinha_editor',
'erp5_svg_editor', 'erp5_svg_editor',
'erp5_jquery_sheet_editor', 'erp5_jquery_sheet_editor',
'erp5_graph_editor',
'erp5_web_ung_core', 'erp5_web_ung_core',
'erp5_web_ung_theme', 'erp5_web_ung_theme',
'erp5_web_ung_role', 'erp5_web_ung_role',
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment