From 91bafb367c4e601642683043adc21bddb65d50ca Mon Sep 17 00:00:00 2001 From: "Emanuel F. Oliveira" Date: Sun, 20 Nov 2016 23:56:54 -0200 Subject: [PATCH] LT-259 New proactive UI (three options) for unsaved changes dialog + New API for unsaved changes module (now handlers can be attached to unsaved changes namespace) + Save measurements integration. --- LesionTracker/client/components/app/app.js | 38 +- .../dialog/unsavedChangesDialog.html | 35 ++ .../bootstrap/dialog/unsavedChangesDialog.js | 133 ++++ .../dialog/unsavedChangesDialog.styl | 7 + .../client/components/bootstrap/index.js | 2 + .../client/ui/dialog/unsavedChangesDialog.js | 14 + Packages/ohif-core/client/ui/index.js | 1 + .../ui/unsavedChanges/unsavedChanges.js | 572 +++++++++++++----- Packages/ohif-core/package.js | 1 + .../components/caseProgress/caseProgress.js | 41 +- 10 files changed, 671 insertions(+), 173 deletions(-) create mode 100644 Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.html create mode 100644 Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.js create mode 100644 Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.styl create mode 100644 Packages/ohif-core/client/ui/dialog/unsavedChangesDialog.js diff --git a/LesionTracker/client/components/app/app.js b/LesionTracker/client/components/app/app.js index 6f795067a..776f5887a 100644 --- a/LesionTracker/client/components/app/app.js +++ b/LesionTracker/client/components/app/app.js @@ -18,21 +18,37 @@ Template.app.onRendered(() => { }); Template.app.events({ - 'click .js-toggle-studyList'() { + 'click .js-toggle-studyList'(event) { const contentId = Session.get('activeContentId'); - OHIF.ui.unsavedChanges.checkBeforeAction('viewer.*', function(shouldProceed, hasChanges) { - if (shouldProceed) { - // Drop signaled unsaved changes if any... - if (hasChanges) { - OHIF.ui.unsavedChanges.clear('viewer.*'); + OHIF.ui.unsavedChanges.presentProactiveDialog('viewer.*', (hasChanges, userChoice) => { + + if (hasChanges) { + + switch (userChoice) { + case 'abort-action': + return; + case 'save-changes': + OHIF.ui.unsavedChanges.trigger('viewer', 'save', false); + OHIF.ui.unsavedChanges.clear('viewer.*'); + break; + case 'abandon-changes': + OHIF.ui.unsavedChanges.clear('viewer.*'); + break; } - if (contentId !== studylistContentId) { - switchToTab(studylistContentId); - } else { - switchToTab(viewerContentId); - } + } + + if (contentId !== studylistContentId) { + switchToTab(studylistContentId); + } else { + switchToTab(viewerContentId); + } + + }, { + position: { + x: event.clientX + 15, + y: event.clientY + 15 } }); diff --git a/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.html b/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.html new file mode 100644 index 000000000..92531d7c4 --- /dev/null +++ b/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.html @@ -0,0 +1,35 @@ + diff --git a/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.js b/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.js new file mode 100644 index 000000000..1e42a20df --- /dev/null +++ b/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.js @@ -0,0 +1,133 @@ +import { Blaze } from 'meteor/blaze'; +import { Template } from 'meteor/templating'; +import { OHIF } from 'meteor/ohif:core'; +import { _ } from 'meteor/underscore'; +import { $ } from 'meteor/jquery'; + +const MARGIN_RIGHT = 15; +const MARGIN_BOTTOM = 15; + +Template.unsavedChangesDialog.onRendered(function() { + + const instance = Template.instance(); + const $modal = instance.$('.modal.unsavedChangesDialog'); + + // Routine which effectivelly displays the BS modal... + instance.displayModal = () => { + + // Make modal options extensible... + const modalOptions = _.extend({ + backdrop: 'static', + keyboard: false + }, instance.data.modalOptions); + + // Set handler for "hidden" event... Simply remove the view! + $modal.on('hidden.bs.modal', () => { + Blaze.remove(instance.view); + }); + + // Create the bootstrap modal + $modal.modal(modalOptions); + + }; + + // Routine which repositions the modal before display... + instance.displayModalWithPosition = (position) => { + + // Preserve original CSS rules... + const origCSS = { + display: $modal.css('display'), + visibility: $modal.css('visibility') + }; + + // Make sure modal is propperly rendered before proceeding with math... + if (origCSS.display === 'none') { + $modal.css({ + visibility: 'hidden', + display: 'block' + }); + } + + // Run presentation code on next tick... + setTimeout(() => { + + let dimension; + const $dialog = $modal.find('.modal-dialog'); + + const dialogRect = { + position: { + x: parseInt(position.x) || 0, + y: parseInt(position.y) || 0 + }, + size: { + width: $dialog.outerWidth(), + height: $dialog.outerHeight() + } + }; + + const modalSize = { + width: $modal.width(), + height: $modal.height() + }; + + dimension = dialogRect.position.x + dialogRect.size.width + MARGIN_RIGHT; + if (dimension > modalSize.width) { + dialogRect.position.x -= dimension - modalSize.width; + } + + if (dialogRect.position.x < 0) { + dialogRect.position.x = 0; + } + + dimension = dialogRect.position.y + dialogRect.size.height + MARGIN_BOTTOM; + if (dimension > modalSize.height) { + dialogRect.position.y -= dimension - modalSize.height; + } + + if (dialogRect.position.y < 0) { + dialogRect.position.y = 0; + } + + // Restore original CSS... + $modal.css(origCSS); + + // Set new position... + $dialog.css({ + position: 'fixed', + margin: 0, + left: dialogRect.position.x, + top: dialogRect.position.y + }); + + instance.displayModal(); + + }, 0); + + }; + + // Check if modal will be presented with custom positioning... + let position = instance.data.position; + if (position && 'x' in position && 'y' in position) { + instance.displayModalWithPosition(position); + } else { + instance.displayModal(); + } + +}); + +Template.unsavedChangesDialog.events({ + + 'click button[data-choice]'(event) { + + const instance = Template.instance(); + const callback = instance.data.callback; + const choice = $(event.currentTarget).attr('data-choice') || ''; + + // if callback is a function, call it passing user choice... + if (typeof callback === 'function') { + callback.call(instance, choice); + } + + } + +}); diff --git a/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.styl b/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.styl new file mode 100644 index 000000000..d1e3f0226 --- /dev/null +++ b/Packages/ohif-core/client/components/bootstrap/dialog/unsavedChangesDialog.styl @@ -0,0 +1,7 @@ +.modal.unsavedChangesDialog + + .modal-dialog + width: 400px + + .modal-footer + border-top: 0 none diff --git a/Packages/ohif-core/client/components/bootstrap/index.js b/Packages/ohif-core/client/components/bootstrap/index.js index 0dcb8c3bd..bdfd260e9 100644 --- a/Packages/ohif-core/client/components/bootstrap/index.js +++ b/Packages/ohif-core/client/components/bootstrap/index.js @@ -3,6 +3,8 @@ import './dialog/form.html'; import './dialog/form.js'; import './dialog/login.html'; import './dialog/login.js'; +import './dialog/unsavedChangesDialog.html'; +import './dialog/unsavedChangesDialog.js'; import './dropdown/backdrop.html'; import './dropdown/form.html'; diff --git a/Packages/ohif-core/client/ui/dialog/unsavedChangesDialog.js b/Packages/ohif-core/client/ui/dialog/unsavedChangesDialog.js new file mode 100644 index 000000000..8d341e365 --- /dev/null +++ b/Packages/ohif-core/client/ui/dialog/unsavedChangesDialog.js @@ -0,0 +1,14 @@ +import { Template } from 'meteor/templating'; +import { Blaze } from 'meteor/blaze'; +import { _ } from 'meteor/underscore'; +import { OHIF } from 'meteor/ohif:core'; + +OHIF.ui.unsavedChangesDialog = function(callback, options) { + + // Render the dialog with the given template passing the promise object and callbacks + const templateData = _.extend({}, options, { + callback: callback + }); + Blaze.renderWithData(Template.unsavedChangesDialog, templateData, document.body); + +}; diff --git a/Packages/ohif-core/client/ui/index.js b/Packages/ohif-core/client/ui/index.js index 184e729de..95817613d 100644 --- a/Packages/ohif-core/client/ui/index.js +++ b/Packages/ohif-core/client/ui/index.js @@ -1,6 +1,7 @@ import './bounded/bounded.js'; import './dialog/form.js'; import './dialog/spatial.js'; +import './dialog/unsavedChangesDialog.js'; import './draggable/draggable.js'; import './dropdown/form.js'; import './resizable/resizable.js'; diff --git a/Packages/ohif-core/client/ui/unsavedChanges/unsavedChanges.js b/Packages/ohif-core/client/ui/unsavedChanges/unsavedChanges.js index 9db53b180..b5d22330a 100644 --- a/Packages/ohif-core/client/ui/unsavedChanges/unsavedChanges.js +++ b/Packages/ohif-core/client/ui/unsavedChanges/unsavedChanges.js @@ -1,210 +1,476 @@ import { OHIF } from 'meteor/ohif:core'; import { _ } from 'meteor/underscore'; -OHIF.ui.unsavedChanges = (function(OHIF, _) { +const FUNCTION = 'function'; +const STRING = 'string'; +const UNDEFINED = 'undefined'; +const WILDCARD = '*'; // "*" is a special name which means "all children". +const SEPARATOR = '.'; - // Root of the internal Namespace tree. - const rootTree = {}; +/** + * Main Namespace Component Class + */ - // Create an unattached namespace node be later appended to the Namespace tree. - function createNode(name, hasChildren) { +class Node { - let node = { name }; + constructor() { + this.value = 0; + this.children = {}; + this.handlers = {}; + } - if (hasChildren) { - node.type = 'tree'; - node.children = {}; - } else { - node.type = 'leaf'; - node.value = 1; + getPathComponents(path) { + return typeof path === STRING ? path.split(SEPARATOR) : null; + } + + getNodeUpToIndex(path, index) { + + let node = this; + + for (let i = 0; i < index; ++i) { + let item = path[i]; + if (node.children.hasOwnProperty(item)) { + node = node.children[item]; + } else { + node = null; + break; + } } return node; } - // Attach a new node (identified by a namespace string) to the Namespace tree or increment its internal signal count. - function addNode(tree, path) { + append(name, value) { - let node, - result = false, - name = path.shift(); + const children = this.children; + let node = null; - if (name !== '*') { // "*" is a special name which means "all children" and thus CANNOT be used. - if (name in tree) { - node = tree[name]; - if (path.length > 0) { - result = node.type === 'tree' && addNode(node.children, path); - } else if (node.type === 'leaf') { - node.value++; - result = true; - } - } else { - node = createNode(name, path.length > 0); - if (node.type !== 'tree' || addNode(node.children, path)) { - tree[name] = node; - result = true; + if (children.hasOwnProperty(name)) { + node = children[name]; + } else if (typeof name === STRING && name !== WILDCARD) { + node = new Node(); + children[name] = node; + } + + if (node !== null) { + node.value += value > 0 ? parseInt(value) : 0; + } + + return node; + + } + + probe(recursively) { + + let value = this.value; + + // Calculate entire tree value recursively? + if (recursively === true) { + const children = this.children; + for (let item in children) { + if (children.hasOwnProperty(item)) { + value += children[item].probe(recursively); } } } - return result; + return value; } - // Detach a node (identified by a namespace string) from the internal Namespace tree consequently clearing its internal signal count. - // ... The supplied namespace can be a wildcard, in which case all sub-nodes are removed as well. - function removeNode(tree, path) { + clear(recursively) { - let result = false, - name = path.shift(); + this.value = 0; - if (name === '*') { - for (name in tree) { - if (tree.hasOwnProperty(name)) { - delete tree[name]; + // Clear entire tree recursively? + if (recursively === true) { + const children = this.children; + for (let item in children) { + if (children.hasOwnProperty(item)) { + children[item].clear(recursively); + } + } + } + + } + + appendPath(path, value) { + + path = this.getPathComponents(path); + + if (path !== null) { + const last = path.length - 1; + let node = this; + for (let i = 0; i < last; ++i) { + node = node.append(path[i], 0); + if (node === null) { + return false; } } - result = true; - } else if (name in tree) { - if (path.length === 0) { - delete tree[name]; + return (node.append(path[last], value) !== null); + } + + return false; + + } + + clearPath(path, recursively) { + + path = this.getPathComponents(path); + + if (path !== null) { + const last = path.length - 1; + let node = this.getNodeUpToIndex(path, last); + if (node !== null) { + let item = path[last]; + if (item !== WILDCARD) { + if (node.children.hasOwnProperty(item)) { + node.children[item].clear(recursively); + return true; + } + } else { + const children = node.children; + for (item in children) { + if (children.hasOwnProperty(item)) { + children[item].clear(recursively); + } + } + + return true; + } + } + } + + return false; + + } + + probePath(path, recursively) { + + path = this.getPathComponents(path); + + if (path !== null) { + const last = path.length - 1; + let node = this.getNodeUpToIndex(path, last); + if (node !== null) { + let item = path[last]; + if (item !== WILDCARD) { + if (node.children.hasOwnProperty(item)) { + return node.children[item].probe(recursively); + } + } else { + const children = node.children; + let value = 0; + for (item in children) { + if (children.hasOwnProperty(item)) { + value += children[item].probe(recursively); + } + } + + return value; + } + } + } + + return 0; + + } + + attachHandler(type, handler) { + + let result = false; + + if (typeof type === STRING && typeof handler === FUNCTION) { + + const handlers = this.handlers; + const list = handlers.hasOwnProperty(type) ? handlers[type] : (handlers[type] = []); + const length = list.length; + + let notFound = true; + + for (let i = 0; i < length; ++i) { + if (handler === list[i]) { + notFound = false; + break; + } + } + + if (notFound) { + list[length] = handler; result = true; - } else { - let node = tree[name]; - if (node.type === 'tree') { - result = removeNode(node.children, path); - } } + } return result; } - // Count the amount of signals registered for a given node. - // ... The supplied namespace can be a wildcard, in which case the resulting value will consider the counter of all sub-nodes. - function probeNode(tree, path) { + removeHandler(type, handler) { - let node, - result = 0, - name = path.shift(); + let result = false; - if (name === '*') { - for (name in tree) { - if (tree.hasOwnProperty(name)) { - node = tree[name]; - if (node.type === 'leaf') { - result += node.value; - } else { - path.unshift('*'); - result += probeNode(node.children, path); + if (typeof type === STRING && typeof handler === FUNCTION) { + + const handlers = this.handlers; + if (handlers.hasOwnProperty(type)) { + const list = handlers[type]; + const length = list.length; + for (let i = 0; i < length; ++i) { + if (handler === list[i]) { + list.splice(i, 1); + result = true; + break; } } } - } else if (name in tree) { - node = tree[name]; - if (node.type === 'tree') { - if (path.length === 0) { - path.unshift('*'); - } - result = probeNode(node.children, path); - } else { - result = path.length === 0 ? node.value : 0; - } } return result; } - // return the exposed interface of UnsavedChanges object - return { + trigger(type, nonRecursively) { - /** - * Signal an unsaved change for a given namespace. - * @param {String} name A string (e.g., "viewer.studyViewer.measurements.targets") that identifies the namespace of the signaled changes. - * @return {Boolean} Returns false if the signal could not be saved or the supplied namespace is invalid. Otherwise, true is returned. - */ - set: function(name) { - return typeof name === 'string' ? addNode(rootTree, name.split('.')) : false; - }, + if (typeof type === STRING) { - /** - * Clear all signaled unsaved changes for a given namespace. If the supplied namespace is a wildcard, all signals below that namespace - * are cleared. - * @param {String} name A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" - * for clearing the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" to specify all signaled - * changes for the "viewer.studyViewer" namespace). - * @return {Boolean} Returns false if the signal could not be removed or the supplied namespace is invalid. Otherwise, true is returned. - */ - clear: function(name) { - return typeof name === 'string' ? removeNode(rootTree, name.split('.')) : false; - }, + const handlers = this.handlers; - /** - * Count the amount of signaled unsaved changes for a given namespace. If the supplied namespace is a wildcard, all signals below that - * namespace will also be accounted. - * @param {String} name A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" - * for counting the amount of signals for the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" - * to count all signaled changes for the "viewer.studyViewer" namespace). - * @return {Number} Returns the amount of signaled changes for a given namespace. If the supplied namespace is a wildcard, the sum of all - * changes for that namespace are returned. - */ - probe: function(name) { - return typeof name === 'string' ? probeNode(rootTree, name.split('.')) : 0; - }, - - /** - * UI utility that presents a confirmation dialog to the user if any unsaved changes where sinaled for the given namespace. - * @param {String} name A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" - * for considering only the signals for the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" - * to consider all signaled changes for the "viewer.studyViewer" namespace). - * @param {Function} callback A callback function (e.g, function(shouldProceed, hasChanges) { ... }) that will be executed after assessment. - * Upon execution, the callback will receive two boolean arguments (shouldProceed and hasChanges) indicating if the action can be performed - * or not and if changes that need to be cleared exist. - * @param {Object} options (Optional) An object with UI presentation options. - * @param {String} options.title The string that will be used as a title for confirmation dialog. - * @param {String} options.message The string that will be used as a message for confirmation dialog. - * @return {void} No value is returned. - */ - checkBeforeAction: function(name, callback, options) { - - let probe, hasChanges, shouldProceed; - - if (typeof callback !== 'function') { - // nothing to do if no callback function is supplied... - return; + if (handlers.hasOwnProperty(type)) { + const list = handlers[type]; + const length = list.length; + for (let i = 0; i < length; ++i) { + list[i].call(null); + } } - probe = this.probe(name); - if (probe > 0) { - // Unsaved changes exist... - hasChanges = true; - let dialogOptions = _.extend({ - title: 'You have unsaved changes!', - message: "Your changes will be lost if you don't save them before leaving the current page... Are you sure you want to proceed?" - }, options); - OHIF.ui.showFormDialog('dialogConfirm', dialogOptions).then(function() { - // Unsaved changes exist but user confirms action... - shouldProceed = true; - callback.call(null, shouldProceed, hasChanges); - }, function() { - // Unsaved changes exist and user does NOT confirm action... - shouldProceed = false; - callback.call(null, shouldProceed, hasChanges); - }); - } else { - // No unsaved changes, action can be performed... - hasChanges = false; - shouldProceed = true; - callback.call(null, shouldProceed, hasChanges); + if (nonRecursively !== true) { + const children = this.children; + for (let item in children) { + if (children.hasOwnProperty(item)) { + children[item].trigger(type); + } + } } } - }; + } -}(OHIF, _)); + attachHandlerForPath(path, type, handler) { + + path = this.getPathComponents(path); + + if (path !== null) { + let node = this.getNodeUpToIndex(path, path.length); + if (node !== null) { + return node.attachHandler(type, handler); + } + } + + return false; + + } + + removeHandlerForPath(path, type, handler) { + + path = this.getPathComponents(path); + + if (path !== null) { + let node = this.getNodeUpToIndex(path, path.length); + if (node !== null) { + return node.removeHandler(type, handler); + } + } + + return false; + + } + + triggerHandlersForPath(path, type, nonRecursively) { + + path = this.getPathComponents(path); + + if (path !== null) { + let node = this.getNodeUpToIndex(path, path.length); + if (node !== null) { + node.trigger(type, nonRecursively); + } + } + + } + +} + +/** + * Root Namespace Node and API + */ + +const rootNode = new Node(); + +export const unsavedChanges = { + + rootNode: rootNode, + + /** + * Signal an unsaved change for a given namespace. + * @param {String} path A string (e.g., "viewer.studyViewer.measurements.targets") that identifies the namespace of the signaled changes. + * @return {Boolean} Returns false if the signal could not be saved or the supplied namespace is invalid. Otherwise, true is returned. + */ + set: function(path) { + return rootNode.appendPath(path, 1); + }, + + /** + * Clear all signaled unsaved changes for a given namespace. If the supplied namespace is a wildcard, all signals below that namespace + * are cleared. + * @param {String} path A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" + * for clearing the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" to specify all signaled + * changes for the "viewer.studyViewer" namespace). + * @param {Boolean} recursively Clear node and all its children recursively. If not specified defaults to true. + * @return {Boolean} Returns false if the signal could not be removed or the supplied namespace is invalid. Otherwise, true is returned. + */ + clear: function(path, recursively) { + return rootNode.clearPath(path, typeof recursively === UNDEFINED ? true : recursively); + }, + + /** + * Count the amount of signaled unsaved changes for a given namespace. If the supplied namespace is a wildcard, all signals below that + * namespace will also be accounted. + * @param {String} path A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" + * for counting the amount of signals for the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" + * to count all signaled changes for the "viewer.studyViewer" namespace). + * @param {Boolean} recursively Probe node and all its children recursively. If not specified defaults to true. + * @return {Number} Returns the amount of signaled changes for a given namespace. If the supplied namespace is a wildcard, the sum of all + * changes for that namespace are returned. + */ + probe: function(path, recursively) { + return rootNode.probePath(path, typeof recursively === UNDEFINED ? true : recursively); + }, + + /** + * Attach an event handler to the specified namespace. + * @param {String} name A string that identifies the namespace to which the event handler will be attached (e.g., + * "viewer.studyViewer.measurements" to attach an event handler for that namespace). + * @param {String} type A string that identifies the event type to which the event handler will be attached. + * @param {Function} handler The handler that will be executed when the specifed event is triggered. + * @return {Boolean} Returns true on success and false on failure. + */ + attachHandler: function(path, type, handler) { + return (rootNode.appendPath(path, 0) && rootNode.attachHandlerForPath(path, type, handler)); + }, + + /** + * Detach an event handler from the specified namespace. + * @param {String} name A string that identifies the namespace from which the event handler will be detached (e.g., + * "viewer.studyViewer.measurements" to remove an event handler from that namespace). + * @param {String} type A string that identifies the event type to which the event handler was attached. + * @param {Function} handler The handler that will be removed from execution list. + * @return {Boolean} Returns true on success and false on failure. + */ + removeHandler: function(path, type, handler) { + return rootNode.removeHandlerForPath(path, type, handler); + }, + + /** + * Trigger all event handlers for the specified namespace and type. + * @param {String} name A string that identifies the namespace from which the event handler will be detached (e.g., + * "viewer.studyViewer.measurements" to remove an event handler from that namespace). + * @param {String} type A string that identifies the event type which will be triggered. + * @param {Boolean} nonRecursively If set to true, prevents triggering event handlers from descending tree. + * @return {Void} No value is returned. + */ + trigger: function(path, type, nonRecursively) { + rootNode.triggerHandlersForPath(path, type, nonRecursively); + }, + + /** + * UI utility that presents a confirmation dialog to the user if any unsaved changes where signaled for the given namespace. + * @param {String} path A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" + * for considering only the signals for the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" + * to consider all signaled changes for the "viewer.studyViewer" namespace). + * @param {Function} callback A callback function (e.g, function(shouldProceed, hasChanges) { ... }) that will be executed after assessment. + * Upon execution, the callback will receive two boolean arguments (shouldProceed and hasChanges) indicating if the action can be performed + * or not and if changes that need to be cleared exist. + * @param {Object} options (Optional) An object with UI presentation options. + * @param {String} options.title The string that will be used as a title for confirmation dialog. + * @param {String} options.message The string that will be used as a message for confirmation dialog. + * @return {void} No value is returned. + */ + checkBeforeAction: function(path, callback, options) { + + let probe, hasChanges, shouldProceed; + + if (typeof callback !== 'function') { + // nothing to do if no callback function is supplied... + return; + } + + probe = this.probe(path); + if (probe > 0) { + // Unsaved changes exist... + hasChanges = true; + let dialogOptions = _.extend({ + title: 'You have unsaved changes!', + message: "Your changes will be lost if you don't save them before leaving the current page... Are you sure you want to proceed?" + }, options); + OHIF.ui.showFormDialog('dialogConfirm', dialogOptions).then(function() { + // Unsaved changes exist but user confirms action... + shouldProceed = true; + callback.call(null, shouldProceed, hasChanges); + }, function() { + // Unsaved changes exist and user does NOT confirm action... + shouldProceed = false; + callback.call(null, shouldProceed, hasChanges); + }); + } else { + // No unsaved changes, action can be performed... + hasChanges = false; + shouldProceed = true; + callback.call(null, shouldProceed, hasChanges); + } + + }, + + /** + * UI utility that presents a "proactive" dialog (with three options: stay, abandon-changes, save-changes) to the user if any unsaved changes where signaled for the given namespace. + * @param {String} path A string that identifies the namespace of the signaled changes (e.g., "viewer.studyViewer.measurements.targets" + * for considering only the signals for the "targets" item of the "viewer.studyViewer.measurements" namespace or "viewer.studyViewer.*" + * to consider all signaled changes for the "viewer.studyViewer" namespace). + * @param {Function} callback A callback function (e.g, function(hasChanges, userChoice) { ... }) that will be executed after assessment. + * Upon execution, the callback will receive two arguments: one boolean (hasChanges) indicating that unsaved changes exist and one string with the ID of the + * option picked by the user on the dialog ('abort-action', 'abandon-changes' and 'save-changes'). If no unsaved changes exist, the second argument is null. + * @param {Object} options (Optional) An object with UI presentation options. + * @param {Object} options.position An object with optimal position (e.g., { x: ..., y: ... }) for the dialog. + * @return {void} No value is returned. + */ + presentProactiveDialog: function(path, callback, options) { + + let probe, hasChanges; + + if (typeof callback !== 'function') { + // nothing to do if no callback function is supplied... + return; + } + + probe = this.probe(path, true); + if (probe > 0) { + // Unsaved changes exist... + hasChanges = true; + OHIF.ui.unsavedChangesDialog(function(choice) { + callback.call(null, hasChanges, choice); + }, options); + } else { + // No unsaved changes, action can be performed... + hasChanges = false; + callback.call(null, hasChanges, null); + } + + } + +}; + +OHIF.ui.unsavedChanges = unsavedChanges; diff --git a/Packages/ohif-core/package.js b/Packages/ohif-core/package.js index 474af7cd7..419e195fe 100644 --- a/Packages/ohif-core/package.js +++ b/Packages/ohif-core/package.js @@ -25,6 +25,7 @@ Package.onUse(function(api) { // UI Styles api.addFiles([ 'client/ui/resizable/resizable.styl', + 'client/components/bootstrap/dialog/unsavedChangesDialog.styl', 'client/components/bootstrap/dropdown/dropdown.styl' ], 'client'); diff --git a/Packages/ohif-measurements/client/components/caseProgress/caseProgress.js b/Packages/ohif-measurements/client/components/caseProgress/caseProgress.js index 1442b0495..056895d72 100644 --- a/Packages/ohif-measurements/client/components/caseProgress/caseProgress.js +++ b/Packages/ohif-measurements/client/components/caseProgress/caseProgress.js @@ -8,6 +8,36 @@ Template.caseProgress.onCreated(() => { instance.progressPercent = new ReactiveVar(); instance.progressText = new ReactiveVar(); instance.isLocked = new ReactiveVar(); + + instance.saveData = () => { + + const timepointApi = instance.data.timepointApi; + const timepoints = timepointApi.all(); + OHIF.log.info('Saving Measurements for timepoints:'); + OHIF.log.info(timepoints); + instance.data.measurementApi.storeMeasurements(timepoints); + + // Clear signaled unsaved changes... + OHIF.ui.unsavedChanges.clear('viewer.studyViewer.measurements.*'); + + }; + + instance.unsavedChangesHandler = () => { + const isNotDisabled = !instance.$('.js-finish-case').hasClass('disabled'); + if (isNotDisabled && instance.progressPercent.get() === 100) { + instance.saveData(); + } + }; + + // Attach handler for unsaved changes dialog... + OHIF.ui.unsavedChanges.attachHandler('viewer.studyViewer.measurements', 'save', instance.unsavedChangesHandler); + +}); + +Template.caseProgress.onDestroyed(() => { + const instance = Template.instance(); + // Remove unsaved changes handler after this view has been destroyed... + OHIF.ui.unsavedChanges.removeHandler('viewer.studyViewer.measurements', 'save', instance.unsavedChangesHandler); }); Template.caseProgress.onRendered(() => { @@ -132,15 +162,8 @@ Template.caseProgress.events({ return; } - const timepointApi = instance.data.timepointApi; - const timepoints = timepointApi.all(); - OHIF.log.info('Saving Measurements for timepoints:') - OHIF.log.info(timepoints); - instance.data.measurementApi.storeMeasurements(timepoints); - - // Clear signaled unsaved changes... - OHIF.ui.unsavedChanges.clear('viewer.studyViewer.measurements.*'); - + instance.saveData(); switchToTab('studylistTab'); + } });