diff --git a/ui/README.md b/ui/README.md index bcf225e47..339bed92e 100644 --- a/ui/README.md +++ b/ui/README.md @@ -29,6 +29,7 @@ UI - [Operation options](#operation-options) - [Quick Query](#quick-query) - [Graph](#graph) + - [Saved Results](#saved-results) - [Feedback](#feedback) 6. [Testing](#testing) @@ -780,6 +781,27 @@ We have all the material design icons [here](https://github.com/gchq/gaffer-tool If you're using a simple string or number as your vertex, use "undefined" as your key. Otherwise you'll need to use the field name specified in the [types section](#types). +### Saved Results + +You can configure the UI to allow results to be saved. The results are stored using the Gaffer ExportToGafferResultCache +and GetGafferResultCacheExport operations, which if configured, will store the results in a separate Gaffer graph. + +This UI feature then adds a cookie 'savedResults' which contains the IDs of the user's saved results. + +```json +"savedResults": { + "enabled": true, + "key": "savedResults", + "timeToLiveInDays": 7 +}, +``` + +| name | type | description +|----------------------|----------|--------------------------------------------------------- +| enabled | boolean | If true then the feature is enabled +| key | string | The cookie key +| timeToLiveInDays | number | The number of days the cookie should be kept for. + ### Feedback diff --git a/ui/dependencies/lib/angular/js/angular-cookies.min.js b/ui/dependencies/lib/angular/js/angular-cookies.min.js new file mode 100644 index 000000000..74a38ec4b --- /dev/null +++ b/ui/dependencies/lib/angular/js/angular-cookies.min.js @@ -0,0 +1,8 @@ +/* + AngularJS v1.6.9 + (c) 2010-2018 Google, Inc. http://angularjs.org + License: MIT +*/ +(function(n,c){'use strict';function l(b,a,g){var d=g.baseHref(),k=b[0];return function(b,e,f){var g,h;f=f||{};h=f.expires;g=c.isDefined(f.path)?f.path:d;c.isUndefined(e)&&(h="Thu, 01 Jan 1970 00:00:00 GMT",e="");c.isString(h)&&(h=new Date(h));e=encodeURIComponent(b)+"="+encodeURIComponent(e);e=e+(g?";path="+g:"")+(f.domain?";domain="+f.domain:"");e+=h?";expires="+h.toUTCString():"";e+=f.secure?";secure":"";f=e.length+1;4096 4096 bytes)!");k.cookie=e}}c.module("ngCookies",["ng"]).info({angularVersion:"1.6.9"}).provider("$cookies",[function(){var b=this.defaults={};this.$get=["$$cookieReader","$$cookieWriter",function(a,g){return{get:function(d){return a()[d]},getObject:function(d){return(d=this.get(d))?c.fromJson(d):d},getAll:function(){return a()},put:function(d,a,m){g(d,a,m?c.extend({},b,m):b)},putObject:function(d,b,a){this.put(d,c.toJson(b),a)},remove:function(a,k){g(a,void 0,k?c.extend({},b,k):b)}}}]}]);c.module("ngCookies").factory("$cookieStore", +["$cookies",function(b){return{get:function(a){return b.getObject(a)},put:function(a,c){b.putObject(a,c)},remove:function(a){b.remove(a)}}}]);l.$inject=["$document","$log","$browser"];c.module("ngCookies").provider("$$cookieWriter",function(){this.$get=l})})(window,window.angular); diff --git a/ui/pom.xml b/ui/pom.xml index 17a653e3f..e6b0a0608 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -109,6 +109,7 @@ ${project.build.directory}/${project.build.finalName}/lib/angular/js/angular-route.min.js ${project.build.directory}/${project.build.finalName}/lib/angular/js/angular-mocks.min.js ${project.build.directory}/${project.build.finalName}/lib/angular/js/angular-sanitize.min.js + ${project.build.directory}/${project.build.finalName}/lib/angular/js/angular-cookies.min.js ${project.build.directory}/${project.build.finalName}/lib/cytoscape/js/cytoscape.min.js ${project.build.directory}/${project.build.finalName}/lib/cytoscape/js/cytoscape-ngraph.forcelayout.js ${project.build.directory}/${project.build.finalName}/lib/jquery/js/jquery.min.js @@ -500,6 +501,7 @@ dependencies/lib/angular/js/angular-route.min.js dependencies/lib/angular/js/angular-mocks.min.js dependencies/lib/angular/js/angular-sanitize.min.js + dependencies/lib/angular/js/angular-cookies.min.js dependencies/lib/cytoscape/js/cytoscape.min.js dependencies/lib/cytoscape/js/cytoscape-ngraph.forcelayout.js dependencies/lib/jquery/js/jquery.min.js diff --git a/ui/src/main/webapp/app/app.js b/ui/src/main/webapp/app/app.js index 93657fb61..87c7eb0ee 100644 --- a/ui/src/main/webapp/app/app.js +++ b/ui/src/main/webapp/app/app.js @@ -16,4 +16,4 @@ 'use strict'; -angular.module('app', ['ngMaterial', 'ngRoute', 'md.data.table', 'ngMessages', 'ngAnimate', 'ngSanitize', 'chart.js']); +angular.module('app', ['ngMaterial', 'ngRoute', 'md.data.table', 'ngMessages', 'ngAnimate', 'ngSanitize', 'chart.js', 'ngCookies']); diff --git a/ui/src/main/webapp/app/config/route-config.js b/ui/src/main/webapp/app/config/route-config.js index 2949009bb..04ffbb3b6 100644 --- a/ui/src/main/webapp/app/config/route-config.js +++ b/ui/src/main/webapp/app/config/route-config.js @@ -56,6 +56,12 @@ angular.module('app').config(['$locationProvider', '$routeProvider', function($l icon: 'raw', inNav: true }) + .when('/saved-data', { + title: 'Saved Data', + template: '', + icon: 'save', + inNav: true + }) .when('/settings', { title: 'Settings', template: '', diff --git a/ui/src/main/webapp/app/saved-results/saved-results-component.js b/ui/src/main/webapp/app/saved-results/saved-results-component.js new file mode 100644 index 000000000..a1a7f4ee4 --- /dev/null +++ b/ui/src/main/webapp/app/saved-results/saved-results-component.js @@ -0,0 +1,168 @@ +/* + * Copyright 2020 Crown Copyright + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +angular.module('app').component('savedResults', savedResults()) + +function savedResults() { + return { + templateUrl: 'app/saved-results/saved-results.html', + controller: SavedResultsController, + controllerAs: 'ctrl' + } +} + +function SavedResultsController(loading, query, graph, error, navigation, events, $cookies, config) { + var saveResultsConfig = { + enabled: false + }; + + var vm = this; + vm.savedResults = []; + + /** + * Loads the saved results + */ + vm.$onInit = function() { + events.subscribe('resultsSaved', function() { + vm.savedResults = loadSavedResults(); + }); + + config.get().then(function(conf) { + if (conf.savedResults) { + saveResultsConfig = conf.savedResults; + if(saveResultsConfig.enabled) { + vm.savedResults = loadSavedResults(); + } + } + }); + } + + vm.updateSavedResults = function() { + updateSavedResults(vm.savedResults); + vm.savedResults = loadSavedResults(); + } + + vm.deleteSavedResults = function(jobId) { + deleteSavedResults(jobId); + vm.savedResults = loadSavedResults(); + } + + vm.deleteAllSavedResults = function() { + vm.savedResults = [] + updateSavedResults(vm.savedResults); + } + + vm.reloadSavedResults = function(jobId) { + loading.load(); + + query.executeQuery( + { + "class": "uk.gov.gchq.gaffer.operation.impl.export.resultcache.GetGafferResultCacheExport", + "jobId": jobId + }, + function(data) { + submitResults(data); + } + ); + + var submitResults = function(data) { + if(!data || data.length < 1) { + error.handle('No results were found - this could be because your results have expired.'); + } else { + graph.deselectAll(); + navigation.goTo('results'); + } + } + } + + vm.isEnabled = function() { + return saveResultsConfig.enabled; + } + + vm.daysToLive = function() { + if(vm.isEnabled() && saveResultsConfig.timeToLiveInDays) { + if(saveResultsConfig.timeToLiveInDays == 1) { + return "1 day" + } + return saveResultsConfig.timeToLiveInDays + " days"; + } + return "0 days"; + } + + var loadSavedResults = function(query) { + var savedResults = $cookies.getObject(saveResultsConfig.key); + if(!savedResults) { + savedResults = []; + } + removeExpired(savedResults); + sortSavedResults(savedResults); + return savedResults; + } + + var deleteSavedResults = function(jobId) { + var savedResults = loadSavedResults(); + for( var i = 0; i < savedResults.length; i++){ + if (savedResults[i].jobId === jobId) { + savedResults.splice(i, 1); + } + } + updateSavedResults(savedResults); + } + + var updateSavedResults = function(savedResults) { + for( var i = 0; i < savedResults.length; i++){ + savedResults[i].edit = false; + } + $cookies.putObject(saveResultsConfig.key, savedResults, getExpiry()); + } + + var sortSavedResults = function(savedResults) { + savedResults.sort(function(a, b) { + return a.timestamp > b.timestamp ? -1 : (a.timestamp < b.timestamp ? 1 : 0); + }) + } + + var removeExpired = function(savedResults) { + var ttl = saveResultsConfig.timeToLiveInDays; + if(!ttl) { + return; + } + var ttlInMillis = ttl * 24 * 60 * 60 * 1000; + var removedItem = false; + var now = new Date().getTime(); + for(var i = 0; i < savedResults.length; i++){ + if (savedResults[i].timestamp + ttlInMillis < now) { + savedResults.splice(i, 1); + removedItem = true; + } + } + if(removedItem) { + updateSavedResults(savedResults); + } + } + + var getExpiry = function() { + var result = new Date(); + var ttl = saveResultsConfig.timeToLiveInDays; + if(!ttl) { + ttl = 7; + } + result.setDate(result.getDate() + ttl); + return result.toUTCString(); + } +} diff --git a/ui/src/main/webapp/app/saved-results/saved-results.html b/ui/src/main/webapp/app/saved-results/saved-results.html new file mode 100644 index 000000000..cff7b05ff --- /dev/null +++ b/ui/src/main/webapp/app/saved-results/saved-results.html @@ -0,0 +1,86 @@ + + +
+
+
+

+ Saved Data + + + Delete all results + +

+

+ Any results you save, using the 'Save' button on the toolbar at the top, will be listed here. + The results are cached on a server and will automatically expire after {{ ctrl.daysToLive() }}. + This list of saved result IDs is cached in your current browser and will not be available in another browser or computer. +

+

Saving results is currently disabled

+ +
+ + + + No saved results + + + + It doesn't look like you've got any saved results. You can use the 'Save' button on the toolbar + at the top to save your results. + + +
+ +
+ + + + + + + {{savedResult.localId}} + + + + ID: {{savedResult.jobId}} + + + + Save + + + + Rename + + + + Delete results + + + + Reload results + + + +
+
+
+
diff --git a/ui/src/main/webapp/app/toolbar/toolbar-component.js b/ui/src/main/webapp/app/toolbar/toolbar-component.js index 297d9fc0d..c24f8619a 100644 --- a/ui/src/main/webapp/app/toolbar/toolbar-component.js +++ b/ui/src/main/webapp/app/toolbar/toolbar-component.js @@ -26,14 +26,19 @@ function toolbar() { }; } -function ToolbarController($rootScope, $mdDialog, operationService, results, query, config, loading, events, properties, error) { +function ToolbarController($rootScope, $mdDialog, operationService, results, query, config, loading, events, properties, error, $mdToast, $cookies) { var vm = this; vm.addMultipleSeeds = false; vm.appTitle; - + var saveResultsConfig = { + enabled: false + }; var defaultTitle = "Gaffer"; vm.$onInit = function() { config.get().then(function(conf) { + if (conf.savedResults) { + saveResultsConfig = conf.savedResults; + } if (conf.title) { vm.appTitle = conf.title; return; @@ -92,6 +97,72 @@ function ToolbarController($rootScope, $mdDialog, operationService, results, que ); } + vm.isSaveResultsEnabled = function() { + return saveResultsConfig.enabled; + } + + vm.saveResults = function() { + if(!saveResultsConfig.enabled) { + error.handle("Saving results is currently disabled"); + return; + } + var rawResults = results.get(); + var resultsArray = rawResults.edges.concat(rawResults.entities).concat(rawResults.other); + if(resultsArray.length < 1) { + error.handle("There are no results to save"); + return; + } + + loading.load(); + query.execute( + { + class: "uk.gov.gchq.gaffer.operation.OperationChain", + operations: [ + { + "class" : "uk.gov.gchq.gaffer.operation.impl.export.resultcache.ExportToGafferResultCache", + "input" : [ + "java.util.ArrayList", + resultsArray + ] + }, + { + "class" : "uk.gov.gchq.gaffer.operation.impl.DiscardOutput" + }, + { + "class" : "uk.gov.gchq.gaffer.operation.impl.job.GetJobDetails" + } + ] + }, + function(data) { + var now = new Date(); + var localId = now.toUTCString(); + var timestamp = now.getTime(); + var jobId = data.jobId; + var savedResults = $cookies.getObject(saveResultsConfig.key); + if(!savedResults) { + savedResults = []; + } + savedResults.push({ + "localId" : localId, + "timestamp": timestamp, + "jobId": jobId + }); + $cookies.putObject(saveResultsConfig.key, savedResults, {expires: getExpiry()}); + events.broadcast('resultsSaved', []); + loading.finish(); + $mdToast.show( + $mdToast.simple() + .textContent("Results saved") + .position('top right') + ) + }, + function(err) { + loading.finish(); + error.handle('Error executing operation', err); + } + ); + } + vm.executeAll = function() { var ops = query.getOperations(); if (ops.length > 0) { @@ -106,4 +177,14 @@ function ToolbarController($rootScope, $mdDialog, operationService, results, que vm.clearResults = function() { results.clear(); } + + var getExpiry = function() { + var result = new Date(); + var ttl = saveResultsConfig.timeToLiveInDays; + if(!ttl) { + ttl = 7; + } + result.setDate(result.getDate() + ttl); + return result.toUTCString(); + } } diff --git a/ui/src/main/webapp/app/toolbar/toolbar.html b/ui/src/main/webapp/app/toolbar/toolbar.html index 4b49ddf82..0526f6c5c 100644 --- a/ui/src/main/webapp/app/toolbar/toolbar.html +++ b/ui/src/main/webapp/app/toolbar/toolbar.html @@ -23,6 +23,16 @@

{{::ctrl.appTitle}}

+ + + + Save Results + + + @@ -102,6 +103,7 @@ + diff --git a/ui/src/test/webapp/app/saved-results/saved-results-component-spec.js b/ui/src/test/webapp/app/saved-results/saved-results-component-spec.js new file mode 100644 index 000000000..ec4a40a4b --- /dev/null +++ b/ui/src/test/webapp/app/saved-results/saved-results-component-spec.js @@ -0,0 +1,244 @@ +describe('The Saved Results component', function() { + + var ctrl; + var $componentController; + var conf = { + "savedResults": { + "enabled": true, + "key": "savedResults", + "timeToLiveInDays": 7 + } + }; + var scope; + var loading, query, graph, error, navigation, events, $cookies; + var now = new Date().getTime(); + var now_minus_1 = now - 24*60*60*1000; + var now_minus_2 = now - 2 * 24*60*60*1000; + + beforeEach(module('app')); + + beforeEach(module(function($provide) { + $provide.factory('config', function($q) { + var get = function() { + return $q.when(conf); + } + + return { + get: get + } + }); + })); + + beforeEach(inject(function(_$componentController_, _loading_, _query_, _graph_, _error_, _navigation_, _events_, _$cookies_, _$rootScope_) { + $componentController =_$componentController_; + loading = _loading_; + query = _query_; + graph = _graph_; + error = _error_; + navigation = _navigation_; + events = _events_; + $cookies = _$cookies_; + scope = _$rootScope_.$new(); + })); + + beforeEach(function() { + ctrl = $componentController('savedResults'); + }); + + describe('ctrl.$onInit()', function() { + it('should get the saved results and order them', function() { + + var savedResults = [ + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2 + }, + { + "localId": "something2", + "jobId": "10002", + "timestamp": now + }, + { + "localId": "something3", + "jobId": "10003", + "timestamp": now_minus_1 + } + ]; + var savedResultsOrdered = [ + { + "localId": "something2", + "jobId": "10002", + "timestamp": now + }, + { + "localId": "something3", + "jobId": "10003", + "timestamp": now_minus_1 + }, + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2 + } + ]; + $cookies.remove(conf.savedResults.key); + $cookies.putObject(conf.savedResults.key, savedResults); + + ctrl.$onInit(); + scope.$digest(); + + expect(ctrl.savedResults).toEqual(savedResultsOrdered); + }); + }); + + describe('ctrl.updateSavedResults()', function() { + it('should updated the saved results', function() { + var savedResults = [ + { + "localId": "something2", + "jobId": "10002", + "timestamp": now + }, + { + "localId": "something3", + "jobId": "10003", + "timestamp": now_minus_1 + }, + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2 + } + ]; + var savedResultsUpdated = [ + { + "localId": "updated id", + "jobId": "10002", + "timestamp": now, + "edit": false + }, + { + "localId": "something3", + "jobId": "10003", + "timestamp": now_minus_1, + "edit": false + }, + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2, + "edit": false + } + ]; + $cookies.remove(conf.savedResults.key); + $cookies.putObject(conf.savedResults.key, savedResults); + ctrl.$onInit(); + scope.$digest(); + ctrl.savedResults[0].edit=true; + ctrl.savedResults[0].localId="updated id"; + + ctrl.updateSavedResults(); + + expect(ctrl.savedResults).toEqual(savedResultsUpdated); + expect($cookies.getObject("savedResults")).toEqual(savedResultsUpdated) + }); + }); + + describe('ctrl.deleteSavedResults()', function() { + it('should delete a saved results item', function() { + var savedResults = [ + { + "localId": "something2", + "jobId": "10002", + "timestamp": now + }, + { + "localId": "something3", + "jobId": "10003", + "timestamp": now_minus_1 + }, + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2 + } + ]; + var savedResultsUpdated = [ + { + "localId": "something2", + "jobId": "10002", + "timestamp": now, + "edit": false + }, + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2, + "edit": false + } + ]; + $cookies.remove(conf.savedResults.key); + $cookies.putObject(conf.savedResults.key, savedResults); + ctrl.$onInit(); + scope.$digest(); + ctrl.deleteSavedResults("10003"); + + expect(ctrl.savedResults).toEqual(savedResultsUpdated); + expect($cookies.getObject("savedResults")).toEqual(savedResultsUpdated); + }); + }); + + describe('ctrl.deleteAllSavedResults()', function() { + it('should delete all saved results', function() { + var savedResults = [ + { + "localId": "something2", + "jobId": "10002", + "timestamp": now + }, + { + "localId": "something3", + "jobId": "10003", + "timestamp": now_minus_1 + }, + { + "localId": "something1", + "jobId": "10001", + "timestamp": now_minus_2 + } + ]; + var savedResultsUpdated = []; + $cookies.remove(conf.savedResults.key); + $cookies.putObject(conf.savedResults.key, savedResults); + ctrl.$onInit(); + scope.$digest(); + ctrl.deleteAllSavedResults(); + + expect(ctrl.savedResults).toEqual(savedResultsUpdated); + expect($cookies.getObject("savedResults")).toEqual(savedResultsUpdated) + }); + }); + + describe('ctrl.reloadSavedResults()', function() { + it('should reload saved results successfully', function() { + spyOn(loading, 'load'); + spyOn(query, 'executeQuery'); + var jobId = "10003" + + ctrl.$onInit(); + scope.$digest(); + ctrl.reloadSavedResults(jobId); + + expect(loading.load).toHaveBeenCalledTimes(1); + expect(query.executeQuery).toHaveBeenCalledTimes(1); + expect(query.executeQuery).toHaveBeenCalledWith( + { + "class": "uk.gov.gchq.gaffer.operation.impl.export.resultcache.GetGafferResultCacheExport", + "jobId": jobId + }, + jasmine.any(Function) + ); + }); + }); +}); diff --git a/ui/src/test/webapp/app/toolbar/toolbar-component-spec.js b/ui/src/test/webapp/app/toolbar/toolbar-component-spec.js index 029b041ee..26f0719ee 100644 --- a/ui/src/test/webapp/app/toolbar/toolbar-component-spec.js +++ b/ui/src/test/webapp/app/toolbar/toolbar-component-spec.js @@ -60,17 +60,19 @@ describe('The Toolbar Component', function() { describe('The Controller', function() { var $componentController; - var navigation, query, graph;; + var navigation, query, graph, loading, results; var $rootScope; var scope; - beforeEach(inject(function(_$componentController_, _navigation_, _$rootScope_, _query_, _graph_) { + beforeEach(inject(function(_$componentController_, _navigation_, _$rootScope_, _query_, _graph_, _loading_, _results_) { $componentController = _$componentController_; navigation = _navigation_; $rootScope = _$rootScope_; scope = $rootScope.$new(); query = _query_; graph = _graph_; + loading = _loading_; + results = _results_; })); it('should exist', function() { @@ -107,5 +109,53 @@ describe('The Toolbar Component', function() { expect(ctrl.appTitle).toEqual('Gaffer'); }); + + describe('ctrl.saveResults()', function() { + it('should execute a save query', function() { + configForTesting = { + "savedResults": { + "enabled": true, + "key": "savedResults", + "timeToLiveInDays": 7 + } + }; + var ctrl = $componentController('toolbar', {$scope: scope}); + ctrl.$onInit(); + scope.$digest(); + + spyOn(loading, 'load'); + spyOn(query, 'execute'); + var jobId = "10003" + spyOn(results, 'get').and.returnValue({"edges":[1,2,3], "entities": [4,5,6], "other": [7,8,9]}); + var resultsArray = [1,2,3,4,5,6,7,8,9]; + + ctrl.saveResults(); + + expect(loading.load).toHaveBeenCalledTimes(1); + expect(query.execute).toHaveBeenCalledTimes(1); + expect(query.execute).toHaveBeenCalledWith( + { + class: "uk.gov.gchq.gaffer.operation.OperationChain", + operations: [ + { + "class" : "uk.gov.gchq.gaffer.operation.impl.export.resultcache.ExportToGafferResultCache", + "input" : [ + "java.util.ArrayList", + resultsArray + ] + }, + { + "class" : "uk.gov.gchq.gaffer.operation.impl.DiscardOutput" + }, + { + "class" : "uk.gov.gchq.gaffer.operation.impl.job.GetJobDetails" + } + ] + }, + jasmine.any(Function), + jasmine.any(Function) + ); + }); + }); }); });