diff --git a/src/kibana/apps/discover/controllers/discover.js b/src/kibana/apps/discover/controllers/discover.js index bd5b610499568..41981385d506d 100644 --- a/src/kibana/apps/discover/controllers/discover.js +++ b/src/kibana/apps/discover/controllers/discover.js @@ -1,5 +1,6 @@ define(function (require) { var _ = require('utils/mixins'); + var angular = require('angular'); var settingsHtml = require('text!../partials/settings.html'); @@ -11,9 +12,10 @@ define(function (require) { ]); require('directives/timepicker'); - require('services/state'); require('directives/fixed_scroll'); require('filters/moment'); + require('apps/settings/services/index_patterns'); + require('factories/synced_state'); require('routes') .when('/discover/:id?', { @@ -23,20 +25,8 @@ define(function (require) { savedSearch: function (savedSearches, $route) { return savedSearches.get($route.current.params.id); }, - patternList: function (es, configFile, $location, $q) { - // TODO: This is inefficient because it pulls down all of the cached mappings for every - // configured pattern instead of only the currently selected one. - return es.search({ - index: configFile.kibanaIndex, - type: 'mapping', - size: 50000, - body: { - query: {match_all: {}}, - } - }) - .then(function (res) { - return res.hits.hits; - }); + indexPatternList: function (indexPatterns) { + return indexPatterns.getIds(); } } }); @@ -50,59 +40,70 @@ define(function (require) { { display: 'Yearly', val: 'yearly' } ]; - app.controller('discover', function ($scope, config, $q, $route, savedSearches, courier, createNotifier, $location, - state, es, configFile) { - var notify = createNotifier({ + app.controller('discover', function ($scope, config, $route, savedSearches, Notifier, $location, SyncedState) { + var notify = new Notifier({ location: 'Discover' }); // the saved savedSearch var savedSearch = $route.current.locals.savedSearch; + // list of indexPattern id's + var indexPatternList = $route.current.locals.indexPatternList; + // the actual courier.SearchSource var searchSource = savedSearch.searchSource; /* Manage state & url state */ var initialQuery = searchSource.get('query'); - - function loadState() { - $scope.state = state.get(); - $scope.state = _.defaults($scope.state, { - query: initialQuery ? initialQuery.query_string.query : '', - columns: ['_source'], - sort: ['_score', 'desc'], - index: config.get('defaultIndex') - }); - } - - loadState(); + var $state = $scope.state = new SyncedState({ + query: initialQuery ? initialQuery.query_string.query : '', + columns: ['_source'], + sort: ['_score', 'desc'], + index: config.get('defaultIndex') + }); // Check that we have any index patterns before going further, and that index being requested // exists. - if (!$route.current.locals.patternList.length || - !_.find($route.current.locals.patternList, {_id: $scope.state.index})) { + if (!indexPatternList || !_.contains(indexPatternList, $state.index)) { $location.path('/settings/indices'); return; } - function init() { - setFields(); - updateDataSource(); - } - $scope.opts = { // number of records to fetch, then paginate through sampleSize: 500, // max length for summaries in the table maxSummaryLength: 100, // Index to match - index: $scope.state.index, + index: $state.index, savedSearch: savedSearch, - patternList: $route.current.locals.patternList, + indexPatternList: indexPatternList, time: {} }; + var onStateChange = function () { + updateDataSource(); + searchSource.fetch(); + }; + + var init = _.once(function () { + return setFields() + .then(updateDataSource) + .then(function () { + // changes to state.columns don't require a refresh + var ignore = ['columns']; + + $state.onUpdate().then(function filterStateUpdate(changed) { + if (_.difference(changed, ignore).length) onStateChange(); + $state.onUpdate().then(filterStateUpdate); + }); + + $scope.$emit('application.load'); + }); + }); + $scope.opts.saveDataSource = function () { savedSearch.id = savedSearch.title; @@ -124,32 +125,10 @@ define(function (require) { // the index to use when they don't specify one $scope.$on('change:config.defaultIndex', function (event, val) { - if (!$scope.opts.index) { - $scope.opts.index = val; - $scope.fetch(); - } - }); - - // If the URL changes, we re-fetch, no matter what changes. - $scope.$on('$locationChangeSuccess', function () { - $scope.state = state.get(); - - // We have no state, don't try to refresh until we do - if (_.isEmpty($scope.state)) return; - - updateDataSource(); - // TODO: fetch just this savedSearch - courier.fetch(); - }); - - // the index to use when they don't specify one - $scope.$watch('opts.index', function (val) { - if (!val) return; - updateDataSource(); - $scope.fetch(); + if (!$state.index) $state.index = val; }); - // Bind a result handler. Any time scope.fetch() is executed this gets called + // Bind a result handler. Any time searchSource.fetch() is executed this gets called // with the results searchSource.onResults().then(function onResults(resp) { $scope.rows = resp.hits.hits; @@ -171,19 +150,26 @@ define(function (require) { console.log('An error'); }); - $scope.$on('$destroy', savedSearch.destroy); + $scope.$on('$destroy', _.bindKey(searchSource, 'destroy')); - $scope.getSort = function () { - return $scope.state.sort; + $scope.fetch = function () { + var changed = $state.commit(); + // when none of the fields updated, we need to call fetch ourselves + if (changed.length === 0) onStateChange(); }; - $scope.setSort = function (field, order) { - var sort = {}; - sort[field] = order; - searchSource.sort([sort]); - $scope.state.sort = [field, order]; - $scope.fetch(); - }; + // $scope.$watch('state.index', $scope.fetch); + // $scope.$watch('state.query', $scope.fetch); + $scope.$watch('state.sort', function (sort) { + if (!sort) return; + + // get the current sort from {key: val} to ["key", "val"]; + var currentSort = _.pairs(searchSource.get('sort')).pop(); + + // if the searchSource doesn't know, tell it so + if (!angular.equals(sort, currentSort)) onStateChange(); + }); + // $scope.$watch('state.columns', $scope.fetch); $scope.toggleConfig = function () { // Close if already open @@ -205,7 +191,7 @@ define(function (require) { }; $scope.resetQuery = function () { - $scope.state.query = initialQuery ? initialQuery.query_string.query : ''; + $state.query = initialQuery ? initialQuery.query_string.query : ''; $scope.fetch(); }; @@ -214,7 +200,7 @@ define(function (require) { // set the index on the savedSearch searchSource.index($scope.opts.index); - $scope.state.index = $scope.opts.index; + $state.index = $scope.opts.index; delete $scope.fields; delete $scope.columns; @@ -224,17 +210,14 @@ define(function (require) { //$scope.state.columns = $scope.fields = null; } - var sort = {}; - sort[$scope.state.sort[0]] = $scope.state.sort[1]; - searchSource .size($scope.opts.sampleSize) + .sort(_.zipObject([$state.sort])) .query(!$scope.state.query ? null : { query_string: { query: $scope.state.query } - }) - .sort([sort]); + }); if (!!$scope.opts.timefield) { searchSource @@ -250,11 +233,6 @@ define(function (require) { } } - $scope.fetch = function () { - // We only set the state on data refresh - state.set($scope.state); - }; - // This is a hacky optimization for comparing the contents of a large array to a short one. function arrayToKeys(array, value) { var obj = {}; @@ -265,46 +243,46 @@ define(function (require) { } function setFields() { - var fields = _.findLast($scope.opts.patternList, {_id: $scope.opts.index})._source; - - var currentState = _.transform($scope.fields || [], function (current, field) { - current[field.name] = { - display: field.display - }; - }, {}); - - if (!fields) return; - - var columnObjects = arrayToKeys($scope.state.columns); - - $scope.fields = []; - $scope.state.columns = $scope.state.columns || []; - - // Inject source into list; - $scope.fields.push({name: '_source', type: 'source', display: false}); - - _(fields) - .keys() - .sort() - .each(function (name) { - var field = fields[name]; - field.name = name; - - _.defaults(field, currentState[name]); - $scope.fields.push(_.defaults(field, {display: columnObjects[name] || false})); - }); - - // TODO: timefield should be associated with the index pattern, this is a hack - // to pick the first date field and use it. - var timefields = _.find($scope.fields, {type: 'date'}); - if (!!timefields) { - $scope.opts.timefield = timefields.name; - } else { - delete $scope.opts.timefield; - } - - refreshColumns(); + return searchSource.getFields($scope.opts.index) + .then(function (fields) { + var currentState = _.transform($scope.fields || [], function (current, field) { + current[field.name] = { + display: field.display + }; + }, {}); + + if (!fields) return; + + var columnObjects = arrayToKeys($scope.state.columns); + + $scope.fields = []; + $scope.state.columns = $scope.state.columns || []; + + // Inject source into list; + $scope.fields.push({name: '_source', type: 'source', display: false}); + + _(fields) + .keys() + .sort() + .each(function (name) { + var field = fields[name]; + field.name = name; + + _.defaults(field, currentState[name]); + $scope.fields.push(_.defaults(field, {display: columnObjects[name] || false})); + }); + + // TODO: timefield should be associated with the index pattern, this is a hack + // to pick the first date field and use it. + var timefields = _.find($scope.fields, {type: 'date'}); + if (!!timefields) { + $scope.opts.timefield = timefields.name; + } else { + delete $scope.opts.timefield; + } + refreshColumns(); + }); } // TODO: On array fields, negating does not negate the combination, rather all terms @@ -353,7 +331,10 @@ define(function (require) { // If no columns remain, use _source if (!$scope.state.columns.length) { $scope.toggleField('_source'); + return; } + + $state.commit(); } // TODO: Move to utility class @@ -373,6 +354,5 @@ define(function (require) { }; init(); - $scope.$emit('application.load'); }); }); \ No newline at end of file diff --git a/src/kibana/apps/discover/index.html b/src/kibana/apps/discover/index.html index fd41f55f5f8f1..e862639e343a9 100644 --- a/src/kibana/apps/discover/index.html +++ b/src/kibana/apps/discover/index.html @@ -41,10 +41,9 @@ + max-length="opts.maxSummaryLength"> diff --git a/src/kibana/apps/discover/partials/settings.html b/src/kibana/apps/discover/partials/settings.html index fb838e77d23f5..bdb6362e95758 100644 --- a/src/kibana/apps/discover/partials/settings.html +++ b/src/kibana/apps/discover/partials/settings.html @@ -17,7 +17,7 @@ Time field: {{opts.timefield || 'not configured'}} diff --git a/src/kibana/controllers/kibana.js b/src/kibana/controllers/kibana.js index 33c78c0199f35..40d6d72e15ceb 100644 --- a/src/kibana/controllers/kibana.js +++ b/src/kibana/controllers/kibana.js @@ -36,10 +36,12 @@ define(function (require) { var orig = Notifier.prototype.fatal; return function () { orig.apply(this, arguments); - $scope.$on('$routeChangeStart', function (event, next) { + function forceReload(event, next) { // reload using the current route, force re-get window.location.reload(false); - }); + } + $scope.$on('$routeUpdate', forceReload); + $scope.$on('$routeChangeStart', forceReload); Notifier.prototype.fatal = orig; }; }()); @@ -54,7 +56,17 @@ define(function (require) { config.init() ]).then(function () { $injector.invoke(function ($rootScope, courier, config, configFile, $timeout, $location) { - $scope.apps = configFile.apps; + // get/set last path for an app + var lastPathFor = function (app, path) { + var key = 'lastPath:' + app.id; + if (path === void 0) return localStorage.getItem(key); + else return localStorage.setItem(key, path); + }; + + $scope.apps = configFile.apps.map(function (app) { + app.lastPath = lastPathFor(app); + return app; + }); function updateAppData() { var route = $location.path().split(/\//); @@ -62,6 +74,7 @@ define(function (require) { // Record the last URL w/ state of the app, use for tab. app.lastPath = $location.url().substring(1); + lastPathFor(app, app.lastPath); // Set class of container to application- $scope.activeApp = route ? route[1] : null; diff --git a/src/kibana/directives/table.js b/src/kibana/directives/table.js index 2057a264e10c1..d6e604c31f947 100644 --- a/src/kibana/directives/table.js +++ b/src/kibana/directives/table.js @@ -16,17 +16,17 @@ define(function (require) { restrict: 'A', scope: { columns: '=', - getSort: '=', - setSort: '=', + sorting: '=' }, template: headerHtml, controller: function ($scope) { - $scope.headerClass = function (column) { - if (!$scope.getSort) return []; - var sort = $scope.getSort(); - if (column === sort[0]) { - return ['fa', sort[1] === 'asc' ? 'fa-sort-up' : 'fa-sort-down']; + var sorting = $scope.sorting; + + if (!sorting) return []; + + if (column === sorting[0]) { + return ['fa', sorting[1] === 'asc' ? 'fa-sort-up' : 'fa-sort-down']; } else { return ['fa', 'fa-sort', 'table-header-sortchange']; } @@ -43,9 +43,8 @@ define(function (require) { }; $scope.sort = function (column) { - var sort = $scope.getSort(); - console.log('dir', sort); - $scope.setSort(column, sort[1] === 'asc' ? 'desc' : 'asc'); + var sorting = $scope.sorting || []; + $scope.sorting = [column, sorting[1] === 'asc' ? 'desc' : 'asc']; }; } @@ -79,9 +78,8 @@ define(function (require) { scope: { columns: '=', rows: '=', + sorting: '=', refresh: '=', - getSort: '=', - setSort: '=', maxLength: '=?', mapping: '=?' }, diff --git a/src/kibana/factories/synced_state.js b/src/kibana/factories/synced_state.js new file mode 100644 index 0000000000000..5af06f34e8957 --- /dev/null +++ b/src/kibana/factories/synced_state.js @@ -0,0 +1,103 @@ +define(function (require) { + var module = require('modules').get('kibana/factories'); + var angular = require('angular'); + var _ = require('lodash'); + var rison = require('utils/rison'); + + module.factory('SyncedState', function ($rootScope, $route, $location, Promise) { + function SyncedState(defaults) { + var state = this; + + var updateHandlers = []; + var abortHandlers = []; + + // serialize the defaults so that they are easily used in onPossibleUpdate + var defaultRison = rison.encode(defaults); + + // store the route matching regex so we can determine if we match later down the road + var routeRegex = $route.current.$$route.regexp; + // this will be used to store the state in local storage + var routeName = $route.current.$$route.originalPath; + + var set = function (obj) { + var changed = []; + // all the keys that the object will have at the end + var newKeys = Object.keys(obj).concat(baseKeys); + + // the keys that got removed + _.difference(Object.keys(state), newKeys).forEach(function (key) { + delete state[key]; + changed.push(key); + }); + + newKeys.forEach(function (key) { + // don't overwrite object methods + if (typeof state[key] !== 'function') { + if (!angular.equals(state[key], obj[key])) { + state[key] = obj[key]; + changed.push(key); + } + } + }); + + if (changed.length) { + updateHandlers.splice(0).forEach(function (handler, i, list) { + // micro optimizations! + handler(list.length > 1 ? _.clone(changed) : changed); + }); + } + + return changed; + }; + + var onPossibleUpdate = function (qs) { + if (routeRegex.test($location.path())) { + qs = qs || $location.search(); + + if (!qs._r) { + qs._r = defaultRison; + $location.search(qs); + } + + return set(rison.decode(qs._r)); + } + }; + + var unwatch = []; + unwatch.push($rootScope.$on('$locationChangeSuccess', _.partial(onPossibleUpdate, null))); + unwatch.push($rootScope.$on('$locationUpdate', _.partial(onPossibleUpdate, null))); + + this.onUpdate = function () { + var defer = Promise.defer(); + + updateHandlers.push = defer.resolve; + abortHandlers.push = defer.reject; + + return defer.promise; + }; + + /** + * Commit the state as a history item + */ + this.commit = function () { + var qs = $location.search(); + qs._r = rison.encode(this); + $location.search(qs); + return onPossibleUpdate(qs) || []; + }; + + this.destroy = function () { + unwatch.splice(0).concat(abortHandlers.splice(0)).forEach(function (fn) { fn(); }); + }; + + // track the "known" keys that state objects have + var baseKeys = Object.keys(this); + + // set the defaults on state + onPossibleUpdate(); + } + + return SyncedState; + }); + +}); \ No newline at end of file diff --git a/src/kibana/partials/table.html b/src/kibana/partials/table.html index 3040889a7979e..67f935f915a41 100644 --- a/src/kibana/partials/table.html +++ b/src/kibana/partials/table.html @@ -1,5 +1,5 @@ - +
\ No newline at end of file diff --git a/src/kibana/require.config.js b/src/kibana/require.config.js index cf106840df340..8dba9a3242c98 100644 --- a/src/kibana/require.config.js +++ b/src/kibana/require.config.js @@ -45,7 +45,10 @@ require.config({ 'angular-mocks': ['angular'], 'elasticsearch': ['angular'], 'angular-bootstrap': ['angular'], - 'angular-bindonce': ['angular'] + 'angular-bindonce': ['angular'], + 'utils/rison': { + exports: 'rison' + } }, waitSeconds: 60 }); \ No newline at end of file diff --git a/src/kibana/services/state.js b/src/kibana/services/state.js deleted file mode 100644 index 391f0aaaad798..0000000000000 --- a/src/kibana/services/state.js +++ /dev/null @@ -1,20 +0,0 @@ -define(function (require) { - var _ = require('lodash'); - require('utils/rison'); - - require('modules') - .get('kibana/services') - .service('state', function ($location) { - this.set = function (state) { - var search = $location.search(); - search._r = rison.encode(state); - $location.search(search); - return search; - }; - - this.get = function () { - var search = $location.search(); - return _.isUndefined(search._r) ? {} : rison.decode(search._r); - }; - }); -}); \ No newline at end of file diff --git a/test/unit/specs/services/state.js b/test/unit/specs/services/state.js deleted file mode 100644 index f5b4a2ac5f24a..0000000000000 --- a/test/unit/specs/services/state.js +++ /dev/null @@ -1,52 +0,0 @@ -define(function (require) { - var angular = require('angular'); - var mocks = require('angular-mocks'); - var _ = require('lodash'); - var $ = require('jquery'); - - // Load the kibana app dependencies. - require('angular-route'); - - // Load the code for the directive - require('services/state'); - - describe('State service', function () { - var state, location; - - beforeEach(function () { - module('kibana/services'); - - // Create the scope - inject(function (_state_, $location) { - - state = _state_; - location = $location; - - }); - - }); - - afterEach(function () { - location.search({}); - }); - - it('should have no state by default', function (done) { - expect(state.get()).to.eql({}); - done(); - }); - - it('should have a set(Object) that writes state to the search string', function (done) { - state.set({foo: 'bar'}); - expect(location.search()._r).to.be('(foo:bar)'); - done(); - }); - - it('should have a get() that deserializes rison from the search string', function (done) { - location.search({_r: '(foo:bar)'}); - expect(state.get()).to.eql({foo: 'bar'}); - done(); - }); - - }); - -}); \ No newline at end of file