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
