Milimetric has submitted this change and it was merged.

Change subject: Bootstrapping from url. Keeping state.
......................................................................


Bootstrapping from url. Keeping state.

Dashboard is able to bootstrap itself from a url like:
http://dashiki.com/something#project=enwiki,dewiki/metric=newlyregister,rollingactive

Changes on selection are published to url

Testing is required some contortions to avoid access
to window.location via URI.js

Bug: 70887
Change-Id: I454de470fa39d59ace9e95255856b27f4fb4b3ac
---
M src/app/require.config.js
M src/components/wikimetrics-layout/wikimetrics-layout.js
M src/lib/logger.js
A src/lib/stateManager.js
A src/lib/window.js
M test/SpecRunner.browser.js
M test/SpecRunner.karma.js
A test/lib/state-manager.js
A test/mocks/URIProxy/URI.js
A test/mocks/window.js
M test/require.config.js
11 files changed, 419 insertions(+), 27 deletions(-)

Approvals:
  Milimetric: Verified; Looks good to me, approved



diff --git a/src/app/require.config.js b/src/app/require.config.js
index a0728c0..701e912 100644
--- a/src/app/require.config.js
+++ b/src/app/require.config.js
@@ -5,27 +5,29 @@
 var require = {
     baseUrl: '.',
     paths: {
-        'jquery'                : 'bower_modules/jquery/dist/jquery',
+        'jquery': 'bower_modules/jquery/dist/jquery',
         // NOTE: the minified ko build is broken in 3.2.0
         // (Issue reported https://github.com/knockout/knockout/issues/1528)
-        'knockout'              : 'bower_modules/knockout/dist/knockout.debug',
-        'knockout-projections'  : 
'bower_modules/knockout-projections/dist/knockout-projections',
-        'text'                  : 'bower_modules/requirejs-text/text',
-        'd3'                    : 'bower_modules/d3/d3',
-        'vega'                  : 'bower_modules/vega/vega',
-        'topojson'              : 'bower_modules/topojson/topojson',
-        'moment'                : 'bower_modules/moment/moment',
-        'semantic-dropdown'     : 
'bower_modules/semantic/build/uncompressed/modules/dropdown',
+        'knockout': 'bower_modules/knockout/dist/knockout.debug',
+        'knockout-projections': 
'bower_modules/knockout-projections/dist/knockout-projections',
+        'text': 'bower_modules/requirejs-text/text',
+        'd3': 'bower_modules/d3/d3',
+        'vega': 'bower_modules/vega/vega',
+        'topojson': 'bower_modules/topojson/topojson',
+        'moment': 'bower_modules/moment/moment',
+        'semantic-dropdown': 
'bower_modules/semantic/build/uncompressed/modules/dropdown',
         // NOTE: if you want functions like uri.expand, you must include both
         // URI and URITemplate like define(['uri/URI', 'uri/URITemplate'] ...
         // because URITemplate modifies URI when it's parsed
-        'uri'                   : 'bower_modules/URIjs/src',
-        'config'                : 'app/config',
-        'logger'                : 'lib/logger',
-        'wikimetricsApi'        : 'app/apis/wikimetrics',
-        'typeahead'             : 
'bower_modules/typeahead.js/dist/typeahead.bundle',
-        'ajaxWrapper'           : 'lib/ajaxWrapper',
-        'utils'                 : 'lib/utils'
+        'uri': 'bower_modules/URIjs/src',
+        'config': 'app/config',
+        'logger': 'lib/logger',
+        'wikimetricsApi': 'app/apis/wikimetrics',
+        'typeahead': 'bower_modules/typeahead.js/dist/typeahead.bundle',
+        'ajaxWrapper': 'lib/ajaxWrapper',
+        'utils': 'lib/utils',
+        'window': 'lib/window',
+        'stateManager': 'lib/stateManager'
     },
     shim: {
         'ajaxWrapper': {
diff --git a/src/components/wikimetrics-layout/wikimetrics-layout.js 
b/src/components/wikimetrics-layout/wikimetrics-layout.js
index a407234..ac1e6fc 100644
--- a/src/components/wikimetrics-layout/wikimetrics-layout.js
+++ b/src/components/wikimetrics-layout/wikimetrics-layout.js
@@ -1,4 +1,4 @@
-define(['knockout', 'text!./wikimetrics-layout.html', 'wikimetricsApi'], 
function (ko, templateMarkup, wikimetricsApi) {
+define(['knockout', 'text!./wikimetrics-layout.html', 'wikimetricsApi', 
'stateManager'], function (ko, templateMarkup, wikimetricsApi, 
stateManagerFactory) {
     'use strict';
 
     function WikimetricsLayout() {
@@ -23,10 +23,12 @@
             self.reverseLookup(config.reverseLookup);
         });
 
-        wikimetricsApi.getDefaultDashboard(function (config) {
-            self.defaultProjects(config.defaultProjects);
-            self.defaultMetrics(config.defaultMetrics);
-        });
+
+        // state manager should be observing the selections of project and 
metric
+        // returns a statemanager obj if you need it
+        stateManagerFactory.getManager(this.selectedProjects, 
this.selectedMetric,
+            this.defaultProjects, this.defaultMetrics);
+
 
         wikimetricsApi.getCategorizedMetrics(function (config) {
             self.metrics(config.categorizedMetrics);
diff --git a/src/lib/logger.js b/src/lib/logger.js
index 3c253bd..ad52767 100644
--- a/src/lib/logger.js
+++ b/src/lib/logger.js
@@ -7,7 +7,7 @@
      * but it could also do a post request to a known endpoint
      * where we collect logs.
      *
-     * Since logging does not alter ourcodepath and it is a dependency of the
+     * Since logging does not alter our code path and it is a dependency of the
      * whole codebase the logger is a static function available site wide.
      *
      * Sample usage:
diff --git a/src/lib/stateManager.js b/src/lib/stateManager.js
new file mode 100644
index 0000000..456cdb9
--- /dev/null
+++ b/src/lib/stateManager.js
@@ -0,0 +1,236 @@
+/**
+ * Knows how to translate from the URL to an application state
+ * and (in the future) will mutate the url reading the application state
+ * as user adds/removes metrics.
+ *
+ * We are not trying to support back/forwards functionallity client side
+ * but rather easy bookmarking on dashboard state.
+ *
+ * If the url is 'plain' (no state) it falls back to wikimetrics api to 
retrieve
+ * the default settings for bootstrap.
+ *
+ * State urls are of the form:
+ * http: 
//dashiki.com/something#projects=enwiki,dewiki/metrics=newlyregister,rollingactive
+ **/
+define(['knockout', 'wikimetricsApi', 'uri/URI', 'window'], function (ko, 
wikimetricsApi, URI, window) {
+    'use strict';
+
+    /**
+     * NullObject for the state
+     * will represent empty state eventually
+     **/
+    function EmptyState() {
+
+    }
+
+
+    EmptyState.prototype.toString = function () {
+        // placeholder for emptystate
+        // needs to return something to put after the hash or
+        // a plain reset of window.location will reload forever
+        return 'empty';
+    };
+
+    /**
+     * @param projects Array of string
+     * @param metrics Array of strings
+     **/
+    function State(projects, metrics) {
+
+        if (!projects) {
+            projects = [];
+        }
+        if (!metrics) {
+            metrics = [];
+        } else if (typeof metrics === 'string') {
+            // if metrics is not an array, make it so
+            metrics = [metrics];
+        }
+        this.metrics = metrics;
+        this.projects = projects;
+    }
+
+    /**
+    *
+    * From: {
+         "defaultProjects": [
+             "enwiki", "eswiki", "dewiki", "frwiki", "ruwiki", "jawiki", 
"itwiki"
+            ],
+        "defaultMetrics": [
+             "RollingActiveEditor", "NewlyRegistered"
+        ]
+    }
+    * To: projects = enwiki, dewiki / metrics = newlyregister, rollingactive
+    */
+    State.prototype.buildHashFragment = function () {
+
+        var projects = 'projects=' + this.projects.join(',');
+        var metrics = 'metrics=' + this.metrics.join(',');
+        return projects + '/' + metrics;
+    };
+
+    /**
+     * Returns the state as a hash fragment, like: 
projects=euwiki/metrics=RollingActiveEditor
+     **/
+    State.prototype.toString = function () {
+        return this.buildHashFragment();
+    };
+
+    /**
+    * Static function that from a url gets a state object
+    * only used in bootstrap
+    * From projects = enwiki, dewiki / metrics = newlyregister, rollingactive
+    * To: {
+        "defaultProjects": [
+             "enwiki", "eswiki", "dewiki", "frwiki", "ruwiki", "jawiki", 
"itwiki"
+        ],
+        "defaultMetrics": [
+             "RollingActiveEditor", "NewlyRegistered"
+        ]
+    }
+     **/
+    State.splitStateURL = function (hash) {
+        var projects = [];
+        var metrics = [];
+        var defaults = hash.split('/');
+        defaults.forEach(function (item) {
+            var choice = item.split("=");
+            //some translation needed from 'projects' to 'defaultProjects'
+            if (choice[0] == 'projects' && choice.length > 1) {
+                projects = choice[1].split(',');
+            } else if (choice[0] == 'metrics' && choice.length > 1) {
+                metrics = choice[1].split(',');
+            }
+        });
+        return new State(projects, metrics);
+    };
+
+
+    /**
+     * In order to change the url as state changes are happening
+     * state manager needs to know about selected projects and selected metrics
+     * params:
+     *  selectedProjects
+     *  selectedMetric
+     *  callback(state): function that gets executed when the initialization 
of the state is completed
+     **/
+    function StateManager(selectedProjects, selectedMetric, defaultProjects, 
defaultMetrics) {
+
+        //selected projects and selected metrics are observables
+        this.selectedProjects = selectedProjects;
+        this.selectedMetric = selectedMetric;
+        this.defaultProjects = defaultProjects;
+        this.defaultMetrics = defaultMetrics;
+
+        this.state = null;
+
+        this._init();
+
+    }
+
+    /**
+     * Looks at url to see if we need to bootsrap from the url hash
+     * if not it bootstraps the state of the application
+     * from the config.
+     **/
+    StateManager.prototype._init = function () {
+        var self = this;
+        //gives you a nice object from window.location
+        var uri = new URI().normalizeHash();
+        // hash without '#'
+        var hash = uri.fragment();
+
+        var _state;
+
+        if (!hash) {
+            wikimetricsApi.getDefaultDashboard(function (config) {
+                _state = new State(config.defaultProjects, 
config.defaultMetrics);
+                this.defaultProjects(config.defaultProjects);
+                this.defaultMetrics(config.defaultMetrics);
+            }.bind(this));
+
+        } else {
+            _state = State.splitStateURL(hash);
+            this.defaultProjects(_state.projects);
+            this.defaultMetrics(_state.metrics);
+        }
+
+        // now define the computation for the state
+        self.state = ko.computed(function () {
+
+            var metric = ko.unwrap(self.selectedMetric),
+                projects = ko.unwrap(self.selectedProjects),
+                metricName, projectNames;
+
+            if (metric || projects) {
+                if (metric) {
+                    metricName = metric.name;
+                }
+                if (projects) {
+                    projectNames = projects.map(function (p) {
+                        return p.database;
+                    });
+                }
+
+                _state = new State(projectNames, metricName);
+
+
+            } else {
+                // user must have cleared up the dashboard
+                // but has a prior state, clean it up
+                _state = new EmptyState();
+            }
+            StateManager._publish(_state);
+            return _state;
+        });
+
+    };
+
+    StateManager.prototype.getState = function () {
+        return this.state();
+
+    };
+
+    /**
+     * Publishes state changes to the url
+     * @param State object
+     **/
+    StateManager._publish = function (state) {
+        var fragment = state.toString();
+        var uri = new URI().normalizeHash();
+        //reset fragment
+        uri.fragment(fragment);
+        // careful, only reseting '#' to avoid reloads
+        window.location.assign(uri.toString());
+
+    };
+
+
+    /**
+     * Returns a state manager factory, this is is just to do lazy 
instantiation
+     * of the state manager
+     **/
+    var stateManagerFactory = {
+        instance: null,
+
+        getManager: function (selectedProjects, selectedMetrics, 
defaultProjects, defaultMetrics) {
+            if (!this.instance) {
+                this.instance = new StateManager(selectedProjects, 
selectedMetrics, defaultProjects, defaultMetrics);
+            }
+            return this.instance;
+        },
+
+        /**
+         * Intended for unit tests, we could not unload the module
+         * and recycle the instance thus tests need to destroy it explicitily
+         * hurts have to do this ... but what else?
+         **/
+        _destroy: function () {
+            this.instance = null;
+        }
+
+    };
+
+    return stateManagerFactory;
+
+});
diff --git a/src/lib/window.js b/src/lib/window.js
new file mode 100644
index 0000000..d84713e
--- /dev/null
+++ b/src/lib/window.js
@@ -0,0 +1,10 @@
+/**
+ * It is quite useful to have window as stand alone module to be able to mock 
it in testing
+ * Methods in window are not easy to mock as they are mostly read-only
+ * only
+ * **/
+define(function () {
+    'use strict';
+
+    return window;
+});
diff --git a/test/SpecRunner.browser.js b/test/SpecRunner.browser.js
index d9fe40f..524f8ec 100644
--- a/test/SpecRunner.browser.js
+++ b/test/SpecRunner.browser.js
@@ -7,6 +7,7 @@
         'components/project-selector',
         'app/data-converters',
         'app/apis',
+        'lib/state-manager'
     ];
 
     // After the 'jasmine-boot' module creates the Jasmine environment, load 
all test modules then run them
diff --git a/test/SpecRunner.karma.js b/test/SpecRunner.karma.js
index 6bcb6e2..74bce60 100644
--- a/test/SpecRunner.karma.js
+++ b/test/SpecRunner.karma.js
@@ -2,8 +2,7 @@
     file;
 for (file in window.__karma__.files) {
     if (window.__karma__.files.hasOwnProperty(file)) {
-        if (/test\/components\/.*\.js$/.test(file)
-         || /test\/app\/.*\.js$/.test(file)) {
+        if (/test\/components\/.*\.js$/.test(file) || 
/test\/app\/.*\.js$/.test(file) || /test\/lib\/.*\.js$/.test(file)) {
             tests.push(file);
         }
     }
diff --git a/test/lib/state-manager.js b/test/lib/state-manager.js
new file mode 100644
index 0000000..bb855ee
--- /dev/null
+++ b/test/lib/state-manager.js
@@ -0,0 +1,75 @@
+/*var studentsStub = { getEnrolled: function(subject){} };
+define("students", [], studentsStub);
+*/
+
+define(['knockout', 'stateManager'], function (ko, stateManagerFactory) {
+    'use strict';
+
+    describe('State Manager URL parsing functions', function () {
+        beforeEach(function () {
+
+        });
+        afterEach(function () {
+            stateManagerFactory._destroy();
+        });
+
+        it('Creates state object from fragment URL', function () {
+
+            // see require.config.js for tests to see why does this work
+            setFakeLocation("http: 
//dashiki.com/#projects=enwiki,dewiki/metrics=newlyregister,rollingactive");
+
+            var defaultProjects = ['enwiki', 'dewiki'];
+            var defaultMetric = 'newlyregister';
+
+
+            var stateManager = 
stateManagerFactory.getManager(ko.observable([]), ko.observable(),
+                ko.observable([]), ko.observable());
+
+            expect(stateManager.defaultProjects()[0]).toEqual('enwiki');
+            expect(stateManager.defaultMetrics()[0]).toEqual('newlyregister');
+
+        });
+
+
+        it('Creates right url fragment from state', function () {
+            // see require.config.js for tests to see why does this work
+            setFakeLocation("http: //dashiki.com/");
+            var fragment = "projects=enwiki,dewiki/metrics=newlyregister";
+
+            //constructing things as they come from the API
+            var defaultProjects = [{
+                name: 'blah',
+                database: 'enwiki'
+            }, {
+                name: 'blah',
+                database: 'dewiki'
+            }];
+            var defaultMetric = {
+                name: 'newlyregister'
+            };
+
+            var stateManager = 
stateManagerFactory.getManager(ko.observable(defaultProjects), 
ko.observable(defaultMetric),
+                ko.observable([]), ko.observable());
+
+            // this should trigger computation
+            var state = stateManager.getState();
+            expect(state.toString()).toEqual(fragment);
+        });
+
+        it('Zero case', function () {
+
+            // see require.config.js for tests to see why does this work
+            setFakeLocation("http: //dashiki.com/");
+            var defaultProjects = ko.observable([]);
+            var defaultMetric = ko.observable();
+
+
+            var stateManager = stateManagerFactory.getManager(defaultProjects, 
defaultMetric,
+                ko.observable([]), ko.observable());
+            // this should trigger computation
+            var state = stateManager.getState();
+            expect(state.toString()).toEqual('empty');
+        });
+
+    });
+});
diff --git a/test/mocks/URIProxy/URI.js b/test/mocks/URIProxy/URI.js
new file mode 100644
index 0000000..f93bccc
--- /dev/null
+++ b/test/mocks/URIProxy/URI.js
@@ -0,0 +1,29 @@
+/**
+ * Proxy for URI.js as when running unit tests
+ * we are not running on a browser context
+ * we are trying to bypass URI access to window.location
+ * We set a fake location in our tests and make URI.js work
+ * with that in the context we load it.
+ **/
+define(['uri/URI'], function (_URI) {
+    'use strict';
+
+
+    window.URI = function () {
+
+        // is there a uri on the global scope?
+        if (window.fakeLocation) {
+            return new _URI(window.fakeLocation);
+        }
+        return new _URI();
+    }
+
+    // setingup handy mocking method to bypass URI access of window.location
+    // to be able to set URIs... this is like ugly
+    window.fakeLocation = '';
+    window.setFakeLocation = function (location) {
+        window.fakeLocation = location
+    }
+    return window.URI;
+
+});
diff --git a/test/mocks/window.js b/test/mocks/window.js
new file mode 100644
index 0000000..277388d
--- /dev/null
+++ b/test/mocks/window.js
@@ -0,0 +1,18 @@
+/**
+ * Mock window module to ease testing
+ **/
+define(function () {
+
+    function MockWindow() {}
+
+    MockWindow.prototype.location = {
+
+        assign: function () {
+            // no op, mock to make sure we are not changing location of tests
+        }
+
+    }
+
+
+    return new MockWindow();
+});
diff --git a/test/require.config.js b/test/require.config.js
index 3dc6423..9950fce 100644
--- a/test/require.config.js
+++ b/test/require.config.js
@@ -3,13 +3,33 @@
     // same behavior that occurs at runtime
     require.baseUrl = '../src/';
 
+
+    require.map = {
+        '*': {
+            'uri': 'bower_modules/URIjs/src'
+        },
+
+        'stateManager': {
+            'uri': '../test/mocks/URIProxy',
+            'window': '../test/mocks/window'
+
+        }
+    }
     // It's not obvious, but this is a way of making Jasmine load and run in 
an AMD environment
     // Credit: http://stackoverflow.com/a/20851265
     var jasminePath = '../test/bower_modules/jasmine/lib/jasmine-core/';
     require.paths['jasmine'] = jasminePath + 'jasmine';
     require.paths['jasmine-html'] = jasminePath + 'jasmine-html';
     require.paths['jasmine-boot'] = jasminePath + 'boot';
-    require.shim['jasmine'] = { exports: 'window.jasmineRequire' };
-    require.shim['jasmine-html'] = { deps: ['jasmine'], exports: 
'window.jasmineRequire' };
-    require.shim['jasmine-boot'] = { deps: ['jasmine', 'jasmine-html'], 
exports: 'window.jasmineRequire' };
+    require.shim['jasmine'] = {
+        exports: 'window.jasmineRequire'
+    };
+    require.shim['jasmine-html'] = {
+        deps: ['jasmine'],
+        exports: 'window.jasmineRequire'
+    };
+    require.shim['jasmine-boot'] = {
+        deps: ['jasmine', 'jasmine-html'],
+        exports: 'window.jasmineRequire'
+    };
 }());

-- 
To view, visit https://gerrit.wikimedia.org/r/160685
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I454de470fa39d59ace9e95255856b27f4fb4b3ac
Gerrit-PatchSet: 14
Gerrit-Project: analytics/dashiki
Gerrit-Branch: master
Gerrit-Owner: Nuria <[email protected]>
Gerrit-Reviewer: Milimetric <[email protected]>
Gerrit-Reviewer: Nuria <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to