diff --git a/res/app/device-list/device-column-service.js b/res/app/device-list/device-column-service.js index 64e1aa26..ee03fe2b 100644 --- a/res/app/device-list/device-column-service.js +++ b/res/app/device-list/device-column-service.js @@ -191,6 +191,12 @@ function compareIgnoreCase(a, b) { return la === lb ? 0 : (la < lb ? -1 : 1) } +function filterIgnoreCase(a, filterValue) { + var va = (a || '').toLowerCase() + , vb = filterValue.toLowerCase() + return va.indexOf(vb) !== -1 +} + function compareRespectCase(a, b) { return a === b ? 0 : (a < b ? -1 : 1) } @@ -212,6 +218,9 @@ function TextCell(options) { , compare: function(a, b) { return compareIgnoreCase(options.value(a), options.value(b)) } + , filter: function(item, filter) { + return filterIgnoreCase(options.value(item), filter.query) + } }) } @@ -232,6 +241,9 @@ function NumberCell(options) { , compare: function(a, b) { return options.value(a) - options.value(b) } + , filter: function(item, filter) { + return filterIgnoreCase(options.value(item), filter.query) + } }) } @@ -265,6 +277,9 @@ function DateCell(options) { , vb = options.value(b) || 0 return va - vb } + , filter: function(item, filter) { + return filterIgnoreCase(options.value(item) + '', filter.query) + } }) } @@ -296,6 +311,9 @@ function LinkCell(options) { , compare: function(a, b) { return compareIgnoreCase(options.value(a), options.value(b)) } + , filter: function(item, filter) { + return filterIgnoreCase(options.value(item), filter.query) + } }) } @@ -328,6 +346,9 @@ function DeviceModelCell(options) { , compare: function(a, b) { return compareRespectCase(options.value(a), options.value(b)) } + , filter: function(device, filter) { + return filterIgnoreCase(options.value(device), filter.query) + } }) } @@ -396,5 +417,8 @@ function DeviceStatusCell(options) { return order[deviceA.state] - order[deviceB.state] } })() + , filter: function(device, filter) { + return device.state === filter.query + } }) } diff --git a/res/app/device-list/device-list-details-directive.js b/res/app/device-list/device-list-details-directive.js index 1a0a3e64..dcf9c0a2 100644 --- a/res/app/device-list/device-list-details-directive.js +++ b/res/app/device-list/device-list-details-directive.js @@ -8,11 +8,13 @@ module.exports = function DeviceListDetailsDirective(DeviceColumnService) { tracker: '&tracker' , columns: '&columns' , sort: '=sort' + , filter: '&filter' } , link: function (scope, element) { var tracker = scope.tracker() , activeColumns = [] , activeSorting = [] + , activeFilters = [] , table = element.find('table')[0] , tbody = table.createTBody() , rows = tbody.rows @@ -114,6 +116,71 @@ module.exports = function DeviceListDetailsDirective(DeviceColumnService) { return patchAll(patch) } + // Updates filters on visible items. + function updateFilters(filters) { + activeFilters = filters + return filterAll() + } + + // Applies filteRow() to all rows. + function filterAll() { + for (var i = 0, l = rows.length; i < l; ++i) { + filterRow(rows[i], mapping[rows[i].id]) + } + } + + // Filters a row, perhaps removing it from view. + function filterRow(row, device) { + if (match(device)) { + row.classList.remove('filter-out') + } + else { + row.classList.add('filter-out') + } + } + + // Checks whether the device matches the currently active filters. + function match(device) { + for (var i = 0, l = activeFilters.length; i < l; ++i) { + var filter = activeFilters[i] + , column + if (filter.field) { + column = scope.columnDefinitions[filter.field] + if (column && !column.filter(device, filter)) { + return false + } + } + else { + var found = false + for (var j = 0, k = activeColumns.length; j < k; ++j) { + column = scope.columnDefinitions[activeColumns[j]] + if (column && column.filter(device, filter)) { + found = true + break + } + } + if (!found) { + return false + } + } + } + return true + } + + // Update now so we're up to date. + updateFilters(scope.filter()) + + // Watch for filter updates. + scope.$watch( + function() { + return scope.filter() + } + , function(newValue) { + updateFilters(newValue) + } + , true + ) + // Calculates a DOM ID for the device. Should be consistent for the // same device within the same table, but unique among other tables. function calculateId(device) { diff --git a/res/app/device-list/device-list.css b/res/app/device-list/device-list.css index fe7355b2..c70e5221 100644 --- a/res/app/device-list/device-list.css +++ b/res/app/device-list/device-list.css @@ -45,6 +45,10 @@ transition: none; } +.stf-device-list .filter-out { + display: none; +} + .stf-device-list .device-search:focus { width: 25em; } diff --git a/res/app/device-list/device-list.jade b/res/app/device-list/device-list.jade index 739fd5c9..7e8d5dd6 100644 --- a/res/app/device-list/device-list.jade +++ b/res/app/device-list/device-list.jade @@ -14,12 +14,13 @@ span(translate) Devices div.device-list-devices-content(ng-if='activeTabs.icons') .filtering-buttons - input(type='search',results='5', autosave='deviceSearch' - name='deviceSearch', ng-model='deviceSearch', ng-change='deviceSearchChanged()', + input(type='search', results='5', autosave='deviceFilter' + name='deviceFilter', ng-model='deviceFilter', ng-change='applyFilter(deviceFilter)', + ng-model-options='{debounce: 150}' autocorrect='off', autocapitalize='off', spellcheck='false').form-control.input-sm.device-search.pull-right .clear-filtering-buttons - device-list-icons(tracker='tracker', sort='sort') + device-list-icons(tracker='tracker', sort='sort', filter='filter') tab(active='activeTabs.details') tab-heading @@ -27,8 +28,9 @@ span(translate) Details div.device-list-details-content(ng-if='activeTabs.details') .filtering-buttons - input(type='search',results='5', autosave='deviceSearch' - name='deviceSearch', ng-model='deviceSearch', ng-change='deviceSearchChanged()', + input(type='search', results='5', autosave='deviceFilter' + name='deviceFilter', ng-model='deviceFilter', ng-change='applyFilter(deviceFilter)', + ng-model-options='{debounce: 150}' autocorrect='off', autocapitalize='off', spellcheck='false').form-control.input-sm.device-search.pull-right span.pull-right @@ -43,4 +45,4 @@ input(type='checkbox', ng-model='column.selected') span(ng-bind='columnDefinitions[column.name].title | translate') - device-list-details(tracker='tracker', columns='columns', sort='sort').selectable + device-list-details(tracker='tracker', columns='columns', sort='sort', filter='filter').selectable diff --git a/res/app/device-list/util/query-parser-test.js b/res/app/device-list/util/query-parser-test.js new file mode 100644 index 00000000..6e7f0b05 --- /dev/null +++ b/res/app/device-list/util/query-parser-test.js @@ -0,0 +1,107 @@ +var assert = require('assert') + +var QueryParser = require('./query-parser') + +var tests = [ + function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('a'), [ + { + field: null + , query: 'a' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('a b c'), [ + { + field: null + , query: 'a' + } + , { + field: null + , query: 'b' + } + , { + field: null + , query: 'c' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('serial:foo'), [ + { + field: 'serial' + , query: 'foo' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('name:"Galaxy S2 LTE"'), [ + { + field: 'name' + , query: 'Galaxy S2 LTE' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('name:"Galaxy S2 LTE" black'), [ + { + field: 'name' + , query: 'Galaxy S2 LTE' + } + , { + field: null + , query: 'black' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('"foo bar"'), [ + { + field: null + , query: 'foo bar' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('version:>=4.1'), [ + { + field: 'version' + , query: '>=4.1' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('version: >=4.1'), [ + { + field: 'version' + , query: '>=4.1' + } + ]) + } +, function() { + var parser = new QueryParser() + assert.deepEqual(parser.parse('Galaxy operator: DOCOMO'), [ + { + field: null + , query: 'Galaxy' + } + , { + field: 'operator' + , query: 'DOCOMO' + } + ]) + } +] + +tests.forEach(function(test) { + test() +}) diff --git a/res/app/device-list/util/query-parser.js b/res/app/device-list/util/query-parser.js new file mode 100644 index 00000000..4866307e --- /dev/null +++ b/res/app/device-list/util/query-parser.js @@ -0,0 +1,102 @@ +var State = { + TERM_START: 1 +, FIELD_OR_QUERY: 2 +, QUERY_START: 3 +, QUERY: 4 +, DOUBLEQUOTED_QUERY: 5 +} + +function Term() { + this.field = null + this.query = '' +} + +function QueryParser() { + this.terms = [] + this.currentTerm = new Term() + this.state = State.TERM_START +} + +QueryParser.parse = function(input) { + var parser = new QueryParser() + return parser.parse(input) +} + +QueryParser.prototype.parse = function(input) { + var chars = input.split('') + for (var i = 0, l = chars.length; i < l; ++i) { + this.consume(chars[i]) + } + return this.terms +} + +QueryParser.prototype.consume = function(input) { + switch (this.state) { + case State.TERM_START: + if (this.isWhitespace(input)) { + // Preceding whitespace, ignore. + return + } + this.terms.push(this.currentTerm) + if (input === '"') { + this.state = State.DOUBLEQUOTED_QUERY + return + } + this.state = State.FIELD_OR_QUERY + this.currentTerm.query += input + return + case State.FIELD_OR_QUERY: + if (this.isWhitespace(input)) { + return this.concludeTerm() + } + if (input === ':') { + this.currentTerm.field = this.currentTerm.query + this.currentTerm.query = '' + this.state = State.QUERY_START + return + } + this.currentTerm.query += input + return + case State.QUERY_START: + if (this.isWhitespace(input)) { + // Preceding whitespace, ignore. + return + } + if (input === '"') { + this.state = State.DOUBLEQUOTED_QUERY + return + } + this.currentTerm.query += input + return + case State.QUERY: + if (this.isWhitespace(input)) { + return this.concludeTerm() + } + if (input === '"') { + this.state = State.DOUBLEQUOTED_QUERY + return + } + this.currentTerm.query += input + return + case State.DOUBLEQUOTED_QUERY: + if (input === '\\') { + return + } + if (input === '"') { + return this.concludeTerm() + } + this.currentTerm.query += input + return + } +} + +QueryParser.prototype.concludeTerm = function() { + this.currentTerm = new Term() + this.state = State.TERM_START +} + +QueryParser.prototype.isWhitespace = function(input) { + return input === ' ' || input === '\t' || input === '\n' || input === '' +} + +module.exports = QueryParser