diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/bower.json | 22 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/css/app.css | 71 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/js/app.js | 1130 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/new-index.html | 37 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/partials/index-view.html | 22 | ||||
-rw-r--r-- | gm/rebaseline_server/static/new/partials/rebaseline-view.html | 207 |
7 files changed, 1490 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore index cedd0baa7d..ceacb7be45 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ platform_tools/chromeos/toolchain third_party/externals tools/bug_chomper/oauth_client_secret.json xcodebuild +bower_components diff --git a/gm/rebaseline_server/static/new/bower.json b/gm/rebaseline_server/static/new/bower.json new file mode 100644 index 0000000000..775213dd56 --- /dev/null +++ b/gm/rebaseline_server/static/new/bower.json @@ -0,0 +1,22 @@ +{ + "name": "rebasline", + "version": "0.1.0", + "authors": [], + "description": "Rebaseline Server", + "license": "BSD", + "private": true, + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "third_party/bower_components", + "test", + "tests" + ], + "dependencies": { + "angular": "1.2.x", + "angular-route": "1.2.x", + "angular-bootstrap": "0.11.x", + "bootstrap": "3.1.x" + } +} diff --git a/gm/rebaseline_server/static/new/css/app.css b/gm/rebaseline_server/static/new/css/app.css new file mode 100644 index 0000000000..fb0cc09e5c --- /dev/null +++ b/gm/rebaseline_server/static/new/css/app.css @@ -0,0 +1,71 @@ +/* app css stylesheet */ + +.formPadding { + padding-left: 2em !important; + padding-right: 0 !important; +} + +.controlBox { + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding-right: 0; + width: 100%; + padding-top: 2em; +} + +.simpleLegend { + font-size: 16px; + margin-bottom: 3px; + width: 95%; +} + +.settingsForm { + padding-left: 1em; + padding-right: 0; +} + + +.resultsHeaderActions { + float: right; +} + +.sticky { + position: fixed; + top: 2px; + box-shadow: -2px 2px 5px 0 rgba(0,0,0,.45); + background: white; + right: 2px; + padding: 10px; + border: 2px solid #222; +} + +.sortDesc { + background:no-repeat left center url(data:image/gif;base64,R0lGODlhCgAKALMAAHFxcYKCgp2dnaampq+vr83NzeHh4f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAAAgAIf/8SUNDUkdCRzEwMTIAAAUwYXBwbAIgAABtbnRyUkdCIFhZWiAH2QACABkACwAaAAthY3NwQVBQTAAAAABhcHBsAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkc2NtAAABCAAAAvJkZXNjAAAD/AAAAG9nWFlaAAAEbAAAABR3dHB0AAAEgAAAABRyWFlaAAAElAAAABRiWFlaAAAEqAAAABRyVFJDAAAEvAAAAA5jcHJ0AAAEzAAAADhjaGFkAAAFBAAAACxn/1RSQwAABLwAAAAOYlRSQwAABLwAAAAObWx1YwAAAAAAAAARAAAADGVuVVMAAAAmAAACfmVzRVMAAAAmAAABgmRhREsAAAAuAAAB6mRlREUAAAAsAAABqGZpRkkAAAAoAAAA3GZyRlUAAAAoAAABKml0SVQAAAAoAAACVm5sTkwAAAAoAAACGG5iTk8AAAAmAAABBHB0QlIAAAAmAAABgnN2U0UAAAAmAAABBGphSlAAAAAaAAABUmtvS1IAAAAWAAACQHpoVFcAAAAWAAABbHpoQ04AAAAWAAAB1HJ1UlUAAAAiAAACpHBsUEwAAAAsAAACxgBZAGwAZQBpAG4AZf8AbgAgAFIARwBCAC0AcAByAG8AZgBpAGkAbABpAEcAZQBuAGUAcgBpAHMAawAgAFIARwBCAC0AcAByAG8AZgBpAGwAUAByAG8AZgBpAGwAIABHAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCTgCCLAAgAFIARwBCACAw1zDtMNUwoTCkMOuQGnUoACAAUgBHAEIAIIJyX2ljz4/wAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8AQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbGZukBoAIABSAEcAQgAgY8+P8GX/h072AEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAGIAZQBzAGsAcgBpAHYAZQBsAHMAZQBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGzHfLwYACAAUgBHAEIAINUEuFzTDMd8AFAAcgBvAGYAaQBsAG8AIABSAEcAQgAgAEcAZQBuAGUAcgBpAGMAbwBHAGUAbgBlAHIAaQBjACAAUgBHAEIAIABQAHIAbwBmAGkAbABlBB4EMQRJBDgEOQAgBD8EQAQ+BEQEOAQ7BEwAIABSAEcAQgBVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBm/wBpAGwAIABSAEcAQgAAZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABadQAArHMAABc0WFlaIAAAAAAAAPNSAAEAAAABFs9YWVogAAAAAAAAdE0AAD3uAAAD0FhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHRleHQAAAAAQ29weXJpZ2h0IDIwMDcgQXBwbGUgSW5jLkMsIGFsbCByaWdodHMgcmVzZXJ2ZWQuAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBsACwAAAAACgAKAAAEJZAMIcakQZjNtyhFxwEIIRofAookUnapu26t+6KFLYe1TgQ5VwQAOw%3D%3D); +} + +.sortAsc { + background:no-repeat left center url(data:image/gif;base64,R0lGODlhCgAKALMAAHFxcYKCgp2dnaampq+vr83NzeHh4f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAAAgAIf/8SUNDUkdCRzEwMTIAAAUwYXBwbAIgAABtbnRyUkdCIFhZWiAH2QACABkACwAaAAthY3NwQVBQTAAAAABhcHBsAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtkc2NtAAABCAAAAvJkZXNjAAAD/AAAAG9nWFlaAAAEbAAAABR3dHB0AAAEgAAAABRyWFlaAAAElAAAABRiWFlaAAAEqAAAABRyVFJDAAAEvAAAAA5jcHJ0AAAEzAAAADhjaGFkAAAFBAAAACxn/1RSQwAABLwAAAAOYlRSQwAABLwAAAAObWx1YwAAAAAAAAARAAAADGVuVVMAAAAmAAACfmVzRVMAAAAmAAABgmRhREsAAAAuAAAB6mRlREUAAAAsAAABqGZpRkkAAAAoAAAA3GZyRlUAAAAoAAABKml0SVQAAAAoAAACVm5sTkwAAAAoAAACGG5iTk8AAAAmAAABBHB0QlIAAAAmAAABgnN2U0UAAAAmAAABBGphSlAAAAAaAAABUmtvS1IAAAAWAAACQHpoVFcAAAAWAAABbHpoQ04AAAAWAAAB1HJ1UlUAAAAiAAACpHBsUEwAAAAsAAACxgBZAGwAZQBpAG4AZf8AbgAgAFIARwBCAC0AcAByAG8AZgBpAGkAbABpAEcAZQBuAGUAcgBpAHMAawAgAFIARwBCAC0AcAByAG8AZgBpAGwAUAByAG8AZgBpAGwAIABHAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCTgCCLAAgAFIARwBCACAw1zDtMNUwoTCkMOuQGnUoACAAUgBHAEIAIIJyX2ljz4/wAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8AQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbGZukBoAIABSAEcAQgAgY8+P8GX/h072AEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAGIAZQBzAGsAcgBpAHYAZQBsAHMAZQBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGzHfLwYACAAUgBHAEIAINUEuFzTDMd8AFAAcgBvAGYAaQBsAG8AIABSAEcAQgAgAEcAZQBuAGUAcgBpAGMAbwBHAGUAbgBlAHIAaQBjACAAUgBHAEIAIABQAHIAbwBmAGkAbABlBB4EMQRJBDgEOQAgBD8EQAQ+BEQEOAQ7BEwAIABSAEcAQgBVAG4AaQB3AGUAcgBzAGEAbABuAHkAIABwAHIAbwBm/wBpAGwAIABSAEcAQgAAZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAABadQAArHMAABc0WFlaIAAAAAAAAPNSAAEAAAABFs9YWVogAAAAAAAAdE0AAD3uAAAD0FhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHRleHQAAAAAQ29weXJpZ2h0IDIwMDcgQXBwbGUgSW5jLkMsIGFsbCByaWdodHMgcmVzZXJ2ZWQuAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBsACwAAAAACgAKAAAEJRBJREKZsxQDsCSGIVzZFnYTGIqktp7fG46uzAn2TAyCMPC9QAQAOw%3D%3D); +} + +.sortableHeader { + padding-right: 3px; + padding-left: 13px; + margin-left: 4px; +} + +.updateBtn { + padding-top: 1em; + margin-left: 0; +} + +.filterBox { + border: 1px solid #DDDDDD; + margin-right: 1em; + padding-top: 5px; + padding-bottom: 5px; +} + +.filterKey { + font-weight: bold; +}
\ No newline at end of file diff --git a/gm/rebaseline_server/static/new/js/app.js b/gm/rebaseline_server/static/new/js/app.js new file mode 100644 index 0000000000..0a1fac0a45 --- /dev/null +++ b/gm/rebaseline_server/static/new/js/app.js @@ -0,0 +1,1130 @@ +'use strict'; + +/** + * TODO (stephana@): This is still work in progress. + * It does not offer the same functionality as the current version, but + * will serve as the starting point for a new backend. + * It works with the current backend, but does not support rebaselining. + */ + +/* + * Wrap everything into an IIFE to not polute the global namespace. + */ +(function () { + + // Declare app level module which contains everything of the current app. + // ui.bootstrap refers to directives defined in the AngularJS Bootstrap + // UI package (http://angular-ui.github.io/bootstrap/). + var app = angular.module('rbtApp', ['ngRoute', 'ui.bootstrap']); + + // Configure the different within app views. + app.config(['$routeProvider', function($routeProvider) { + $routeProvider.when('/', {templateUrl: 'partials/index-view.html', + controller: 'IndexCtrl'}); + $routeProvider.when('/view', {templateUrl: 'partials/rebaseline-view.html', + controller: 'RebaselineCrtrl'}); + $routeProvider.otherwise({redirectTo: '/'}); + }]); + + + // TODO (stephana): Some of these constants are 'gm' specific. In the + // next iteration we need to remove those as we move the more generic + // 'dm' testing tool. + // + // Shared constants used here and in the markup. These are exported when + // when used by a controller. + var c = { + // Define different view states as we load the data. + ST_LOADING: 1, + ST_STILL_LOADING: 2, + ST_READY: 3, + + // These column types are used by the Column class. + COL_T_FILTER: 'filter', + COL_T_IMAGE: 'image', + COL_T_REGULAR: 'regular', + + // Request parameters used to select between subsets of results. + RESULTS_ALL: 'all', + RESULTS_FAILURES: 'failures', + + // Filter types are used by the Column class. + FILTER_FREE_FORM: 'free_form', + FILTER_CHECK_BOX: 'checkbox', + + // Columns either provided by the backend response or added in code. + // TODO (stephana): This should go away once we switch to 'dm'. + COL_BUGS: 'bugs', + COL_IGNORE_FAILURE: 'ignore-failure', + COL_REVIEWED_BY_HUMANS: 'reviewed-by-human', + + // Defines the order in which image columns appear. + // TODO (stephana@): needs to be driven by backend data. + IMG_COL_ORDER: [ + { + key: 'imageA', + urlField: ['imageAUrl'] + }, + { + key: 'imageB', + urlField: ['imageBUrl'] + }, + { + key: 'whiteDiffs', + urlField: ['differenceData', 'whiteDiffUrl'], + percentField: ['differenceData', 'percentDifferingPixels'], + valueField: ['differenceData', 'numDifferingPixels'] + }, + { + key: 'diffs', + urlField: ['differenceData', 'diffUrl'], + percentField: ['differenceData', 'perceptualDifference'], + valueField: ['differenceData', 'maxDiffPerChannel'] + } + ], + + // Choice of availabe image size selection. + IMAGE_SIZES: [ + 100, + 200, + 400 + ], + + // Choice of available number of records selection. + MAX_RECORDS: [ + '100', + '200', + '300' + ] + }; // end constants + + /* + * Index Controller + */ + // TODO (stephana): Remove $timeout since it only simulates loading delay. + app.controller('IndexCtrl', ['$scope', '$timeout', 'dataService', + function($scope, $timeout, dataService) { + // init the scope + $scope.c = c; + $scope.state = c.ST_LOADING; + $scope.qStr = dataService.getQueryString; + + // TODO (stephana): Remove and replace with index data generated by the + // backend to reflect the current "known" image sets to compare. + $scope.allSKPs = [ + { + params: { + setBSection: 'actual-results', + setASection: 'expected-results', + setBDir: 'gs://chromium-skia-skp-summaries/' + + 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug', + setADir: 'repo:expectations/skp/' + + 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug' + }, + title: 'expected vs actuals on ' + + 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug' + }, + { + params: { + setBSection: 'actual-results', + setASection: 'expected-results', + setBDir: 'gs://chromium-skia-skp-summaries/' + + 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release', + setADir: 'repo:expectations/skp/'+ + 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release' + }, + title: 'expected vs actuals on Test-Ubuntu12-ShuttleA-GTX660-x86-Release' + }, + { + params: { + setBSection: 'actual-results', + setASection: 'actual-results', + setBDir: 'gs://chromium-skia-skp-summaries/' + + 'Test-Ubuntu12-ShuttleA-GTX660-x86-Release', + setADir: 'gs://chromium-skia-skp-summaries/' + + 'Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug' + }, + title: 'Actuals on Test-Mac10.8-MacMini4.1-GeForce320M-x86_64-Debug ' + + 'vs Test-Ubuntu12-ShuttleA-GTX660-x86-Release' + } + ]; + + // TODO (stephana): Remove this once we load index data from the server. + $timeout(function () { + $scope.state = c.ST_READY; + }); + }]); + + /* + * RebaselineCtrl + * Controls the main comparison view. + * + * @param {service} dataService Service that encapsulates functions to + * retrieve data from the backend. + * + */ + app.controller('RebaselineCrtrl', ['$scope', '$timeout', 'dataService', + function($scope, $timeout, dataService) { + // determine which to request + // TODO (stephana): This should be extracted from the query parameters. + var target = c.TARGET_GM; + + // process the rquest arguments + // TODO (stephana): This should be determined from the query parameters. + var loadFn = dataService.loadAll; + + // controller state variables + var allData = null; + var filterFuncs = null; + var currentData = null; + var selectedData = null; + + // Index of the column that should provide the sort key + var sortByIdx = 0; + + // Sort in asending (true) or descending (false) order + var sortOrderAsc = true; + + // Array of functions for each column used for comparison during sort. + var compareFunctions = null; + + // Variables to track load and render times + var startTime; + var loadStartTime; + + + /** Load the data from the backend **/ + loadStartTime = Date.now(); + function loadData() { + loadFn().then( + function (serverData) { + $scope.header = serverData.header; + $scope.loadTime = (Date.now() - loadStartTime)/1000; + + // keep polling if the data are not ready yet + if ($scope.header.resultsStillLoading) { + $scope.state = c.ST_STILL_LOADING; + $timeout(loadData, 5000); + return; + } + + // get the filter colunms and an array to hold filter data by user + var fcol = getFilterColumns(serverData); + $scope.filterCols = fcol[0]; + $scope.filterVals = fcol[1]; + + // Add extra columns and retrieve the image columns + var otherCols = [ Column.regular(c.COL_BUGS) ]; + var imageCols = getImageColumns(serverData); + + // Concat to get all columns + // NOTE: The order is important since filters are rendered first, + // followed by regular columns and images + $scope.allCols = $scope.filterCols.concat(otherCols, imageCols); + + // Pre-process the data and get the filter functions. + var dataFilters = getDataAndFilters(serverData, $scope.filterCols, + otherCols, imageCols); + allData = dataFilters[0]; + filterFuncs = dataFilters[1]; + + // Get regular columns (== not image columns) + var regularCols = $scope.filterCols.concat(otherCols); + + // Get the compare functions for regular and image columns. These + // are then used to sort by the respective columns. + compareFunctions = DataRow.getCompareFunctions(regularCols, + imageCols); + + // Filter and sort the results to get them ready for rendering + updateResults(); + + // Data are ready for display + $scope.state = c.ST_READY; + }, + function (httpErrResponse) { + console.log(httpErrResponse); + }); + }; + + /* + * updateResults + * Central render function. Everytime settings/filters/etc. changed + * this function is called to filter, sort and splice the data. + * + * NOTE (stephana): There is room for improvement here: before filtering + * and sorting we could check if this is necessary. But this has not been + * a bottleneck so far. + */ + function updateResults () { + // run digest before we update the results. This allows + // updateResults to be called from functions trigger by ngChange + $scope.updating = true; + startTime = Date.now(); + + // delay by one render cycle so it can be called via ng-change + $timeout(function() { + // filter data + selectedData = filterData(allData, filterFuncs, $scope.filterVals); + + // sort the selected data. + sortData(selectedData, compareFunctions, sortByIdx, sortOrderAsc); + + // only conside the elements that we really need + var nRecords = $scope.settings.nRecords; + currentData = selectedData.slice(0, parseInt(nRecords)); + + DataRow.setRowspanValues(currentData, $scope.mergeIdenticalRows); + + // update the scope with relevant data for rendering. + $scope.data = currentData; + $scope.totalRecords = allData.length; + $scope.showingRecords = currentData.length; + $scope.selectedRecords = selectedData.length; + $scope.updating = false; + + // measure the filter time and total render time (via timeout). + $scope.filterTime = Date.now() - startTime; + $timeout(function() { + $scope.renderTime = Date.now() - startTime; + }); + }); + }; + + /** + * Generate the style value to set the width of images. + * + * @param {Column} col Column that we are trying to render. + * @param {int} paddingPx Number of padding pixels. + * @param {string} defaultVal Default value if not an image column. + * + * @return {string} Value to be used in ng-style element to set the width + * of a image column. + **/ + $scope.getImageWidthStyle = function (col, paddingPx, defaultVal) { + var result = (col.ctype === c.COL_T_IMAGE) ? + ($scope.imageSize + paddingPx + 'px') : defaultVal; + return result; + }; + + /** + * Sets the column by which to sort the data. If called for the + * currently sorted column it will cause the sort to toggle between + * ascending and descending. + * + * @param {int} colIdx Index of the column to use for sorting. + **/ + $scope.sortBy = function (colIdx) { + if (sortByIdx === colIdx) { + sortOrderAsc = !sortOrderAsc; + } else { + sortByIdx = colIdx; + sortOrderAsc = true; + } + updateResults(); + }; + + /** + * Helper function to generate a CSS class indicating whether this column + * is the sort key. If it is a class name with the sort direction (Asc/Desc) is + * return otherwise the default value is returned. In markup we use this + * to display (or not display) an arrow next to the column name. + * + * @param {string} prefix Prefix of the classname to be generated. + * @param {int} idx Index of the target column. + * @param {string} defaultVal Value to return if current column is not used + * for sorting. + * + * @return {string} CSS class name that a combination of the prefix and + * direction indicator ('Asc' or 'Desc') if the column is + * used for sorting. Otherwise the defaultVal is returned. + **/ + $scope.getSortedClass = function (prefix, idx, defaultVal) { + if (idx === sortByIdx) { + return prefix + ((sortOrderAsc) ? 'Asc' : 'Desc'); + } + + return defaultVal; + }; + + /** + * Checkbox to merge identical records has change. Force an update. + **/ + $scope.mergeRowsChanged = function () { + updateResults(); + } + + /** + * Max number of records to display has changed. Force an update. + **/ + $scope.maxRecordsChanged = function () { + updateResults(); + }; + + /** + * Filter settings changed. Force an update. + **/ + $scope.filtersChanged = function () { + updateResults(); + }; + + /** + * Sets all possible values of the specified values to the given value. + * That means all checkboxes are eiter selected or unselected. + * Then force an update. + * + * @param {int} idx Index of the target filter column. + * @param {boolean} val Value to set the filter values to. + * + **/ + $scope.setFilterAll = function (idx, val) { + for(var i=0, len=$scope.filterVals[idx].length; i<len; i++) { + $scope.filterVals[idx][i] = val; + } + updateResults(); + }; + + /** + * Toggle the values of a filter. This toggles all values in a + * filter. + * + * @param {int} idx Index of the target filter column. + **/ + $scope.setFilterToggle = function (idx) { + for(var i=0, len=$scope.filterVals[idx].length; i<len; i++) { + $scope.filterVals[idx][i] = !$scope.filterVals[idx][i]; + } + updateResults(); + }; + + // **************************************** + // Initialize the scope. + // **************************************** + + // Inject the constants into the scope and set the initial state. + $scope.c = c; + $scope.state = c.ST_LOADING; + + // Initial settings + $scope.settings = { + showThumbnails: true, + imageSize: c.IMAGE_SIZES[0], + nRecords: c.MAX_RECORDS[0], + mergeIdenticalRows: true + }; + + // Initial values for filters set in loadData() + $scope.filterVals = []; + + // Information about records - set in loadData() + $scope.totalRecords = 0; + $scope.showingRecords = 0; + $scope.updating = false; + + // Trigger the data loading. + loadData(); + + }]); + + // data structs to interface with markup and backend + /** + * Models a column. It aggregates attributes of all + * columns types. Some might be empty. See convenience + * factory methods below for different column types. + * + * @param {string} key Uniquely identifies this columns + * @param {string} ctype Type of columns. Use COL_* constants. + * @param {string} ctitle Human readable title of the column. + * @param {string} ftype Filter type. Use FILTER_* constants. + * @param {FilterOpt[]} foptions Filter options. For 'checkbox' filters this + is used to render all the checkboxes. + For freeform filters this is a list of all + available values. + * @param {string} baseUrl Baseurl for image columns. All URLs are relative + to this. + * + * @return {Column} Instance of the Column class. + **/ + function Column(key, ctype, ctitle, ftype, foptions, baseUrl) { + this.key = key; + this.ctype = ctype; + this.ctitle = ctitle; + this.ftype = ftype; + this.foptions = foptions; + this.baseUrl = baseUrl; + this.foptionsArr = []; + + // get the array of filter options for lookup in indexOfOptVal + if (this.foptions) { + for(var i=0, len=foptions.length; i<len; i++) { + this.foptionsArr.push(this.foptions[i].value); + } + } + } + + /** + * Find the index of an value in a column with a fixed set + * of options. + * + * @param {string} optVal Value of the column. + * + * @return {int} Index of optVal in this column. + **/ + Column.prototype.indexOfOptVal = function (optVal) { + return this.foptionsArr.indexOf(optVal); + }; + + /** + * Set filter options for this column. + * + * @param {FilterOpt[]} foptions Possible values for this column. + **/ + Column.prototype.setFilterOptions = function (foptions) { + this.foptions = foptions; + }; + + /** + * Factory function to create a filter column. Same args as Column() + **/ + Column.filter = function(key, ctitle, ftype, foptions) { + return new Column(key, c.COL_T_FILTER, ctitle || key, ftype, foptions); + } + + /** + * Factory function to create an image column. Same args as Column() + **/ + Column.image = function (key, ctitle, baseUrl) { + return new Column(key, c.COL_T_IMAGE, ctitle || key, null, null, baseUrl); + }; + + /** + * Factory function to create a regular column. Same args as Column() + **/ + Column.regular = function (key, ctitle) { + return new Column(key, c.COL_T_REGULAR, ctitle || key); + }; + + /** + * Helper class to wrap a single option in a filter. + * + * @param {string} value Option value. + * @param {int} count Number of instances of this option in the dataset. + * + * @return {} Instance of FiltertOpt + **/ + function FilterOpt(value, count) { + this.value = value; + this.count = count; + } + + /** + * Container for a single row in the dataset. + * + * @param {int} rowspan Number of rows (including this and following rows) + that have identical values. + * @param {string[]} dataCols Values of the respective columns (combination + of filter and regular columns) + * @param {ImgVal[]} imageCols Image meta data for the image columns. + * + * @return {DataRow} Instance of DataRow. + **/ + function DataRow(rowspan, dataCols, imageCols) { + this.rowspan = rowspan; + this.dataCols = dataCols; + this.imageCols = imageCols; + } + + /** + * Gets the comparator functions for the columns in this dataset. + * The comparators are then used to sort the dataset by the respective + * column. + * + * @param {Column[]} dataCols Data columns (= non-image columns) + * @param {Column[]} imgCols Image columns. + * + * @return {Function[]} Array of functions that can be used to sort by the + * respective column. + **/ + DataRow.getCompareFunctions = function (dataCols, imgCols) { + var result = []; + for(var i=0, len=dataCols.length; i<len; i++) { + result.push(( function (col, idx) { + return function (a, b) { + return (a.dataCols[idx] < b.dataCols[idx]) ? -1 : + ((a.dataCols[idx] === b.dataCols[idx]) ? 0 : 1); + }; + }(dataCols[i], i) )); + } + + for(var i=0, len=imgCols.length; i<len; i++) { + result.push((function (col, idx) { + return function (a,b) { + var aVal = a.imageCols[idx].percent; + var bVal = b.imageCols[idx].percent; + + return (aVal < bVal) ? -1 : ((aVal === bVal) ? 0 : 1); + }; + }(imgCols[i], i) )); + } + + return result; + }; + + /** + * Set the rowspan values of a given array of DataRow instances. + * + * @param {DataRow[]} data Dataset in desired order (after sorting). + * @param {mergeRows} mergeRows Indicate whether to sort + **/ + DataRow.setRowspanValues = function (data, mergeRows) { + var curIdx, rowspan, cur; + if (mergeRows) { + for(var i=0, len=data.length; i<len;) { + curIdx = i; + cur = data[i]; + rowspan = 1; + for(i++; ((i<len) && (data[i].dataCols === cur.dataCols)); i++) { + rowspan++; + data[i].rowspan=0; + } + data[curIdx].rowspan = rowspan; + } + } else { + for(var i=0, len=data.length; i<len; i++) { + data[i].rowspan = 1; + } + } + }; + + /** + * Wrapper class for image related data. + * + * @param {string} url Relative Url of the image or null if not available. + * @param {float} percent Percent of pixels that are differing. + * @param {int} value Absolute number of pixes differing. + * + * @return {ImgVal} Instance of ImgVal. + **/ + function ImgVal(url, percent, value) { + this.url = url; + this.percent = percent; + this.value = value; + } + + /** + * Extracts the filter columns from the JSON response of the server. + * + * @param {object} data Server response. + * + * @return {Column[]} List of filter columns as described in 'header' field. + **/ + function getFilterColumns(data) { + var result = []; + var vals = []; + var colOrder = data.extraColumnOrder; + var colHeaders = data.extraColumnHeaders; + var fopts, optVals, val; + + for(var i=0, len=colOrder.length; i<len; i++) { + if (colHeaders[colOrder[i]].isFilterable) { + if (colHeaders[colOrder[i]].useFreeformFilter) { + result.push(Column.filter(colOrder[i], + colHeaders[colOrder[i]].headerText, + c.FILTER_FREE_FORM)); + vals.push(''); + } + else { + fopts = []; + optVals = []; + + // extract the different options for this column + for(var j=0, jlen=colHeaders[colOrder[i]].valuesAndCounts.length; + j<jlen; j++) { + val = colHeaders[colOrder[i]].valuesAndCounts[j]; + fopts.push(new FilterOpt(val[0], val[1])); + optVals.push(false); + } + + // ad the column and values + result.push(Column.filter(colOrder[i], + colHeaders[colOrder[i]].headerText, + c.FILTER_CHECK_BOX, + fopts)); + vals.push(optVals); + } + } + } + + return [result, vals]; + } + + /** + * Extracts the image columns from the JSON response of the server. + * + * @param {object} data Server response. + * + * @return {Column[]} List of images columns as described in 'header' field. + **/ + function getImageColumns(data) { + var CO = c.IMG_COL_ORDER; + var imgSet; + var result = []; + for(var i=0, len=CO.length; i<len; i++) { + imgSet = data.imageSets[CO[i].key]; + result.push(Column.image(CO[i].key, + imgSet.description, + ensureTrailingSlash(imgSet.baseUrl))); + } + return result; + } + + /** + * Make sure Url has a trailing '/'. + * + * @param {string} url Base url. + * @return {string} Same url with a trailing '/' or same as input if it + already contained '/'. + **/ + function ensureTrailingSlash(url) { + var result = url.trim(); + + // TODO: remove !!! + result = fixUrl(url); + if (result[result.length-1] !== '/') { + result += '/'; + } + return result; + } + + // TODO: remove. The backend should provide absoute URLs + function fixUrl(url) { + url = url.trim(); + if ('http' === url.substr(0, 4)) { + return url; + } + + var idx = url.indexOf('static'); + if (idx != -1) { + return '/' + url.substr(idx); + } + + return url; + }; + + /** + * Processes that data and returns filter functions. + * + * @param {object} Server response. + * @param {Column[]} filterCols Filter columns. + * @param {Column[]} otherCols Columns that are neither filters nor images. + * @param {Column[]} imageCols Image columns. + * + * @return {[]} Returns a pair [dataRows, filterFunctions] where: + * - dataRows is an array of DataRow instances. + * - filterFunctions is an array of functions that can be used to + * filter the column at the corresponding index. + * + **/ + function getDataAndFilters(data, filterCols, otherCols, imageCols) { + var el; + var result = []; + var lookupIndices = []; + var indexerFuncs = []; + var temp; + + // initialize the lookupIndices + var filterFuncs = initIndices(filterCols, lookupIndices, indexerFuncs); + + // iterate over the data and get the rows + for(var i=0, len=data.imagePairs.length; i<len; i++) { + el = data.imagePairs[i]; + temp = new DataRow(1, getColValues(el, filterCols, otherCols), + getImageValues(el, imageCols)); + result.push(temp); + + // index the row + for(var j=0, jlen=filterCols.length; j < jlen; j++) { + indexerFuncs[j](lookupIndices[j], filterCols[j], temp.dataCols[j], i); + } + } + + setFreeFormFilterOptions(filterCols, lookupIndices); + return [result, filterFuncs]; + } + + /** + * Initiazile the lookup indices and indexer functions for the filter + * columns. + * + * @param {Column} filterCols Filter columns + * @param {[]} lookupIndices Will be filled with datastructures for + fast lookup (output parameter) + * @param {[]} lookupIndices Will be filled with functions to index data + of the column with the corresponding column. + * + * @return {[]} Returns an array of filter functions that can be used to + filter the respective column. + **/ + function initIndices(filterCols, lookupIndices, indexerFuncs) { + var filterFuncs = []; + var temp; + + for(var i=0, len=filterCols.length; i<len; i++) { + if (filterCols[i].ftype === c.FILTER_FREE_FORM) { + lookupIndices.push({}); + indexerFuncs.push(indexFreeFormValue); + filterFuncs.push( + getFreeFormFilterFunc(lookupIndices[lookupIndices.length-1])); + } + else if (filterCols[i].ftype === c.FILTER_CHECK_BOX) { + temp = []; + for(var j=0, jlen=filterCols[i].foptions.length; j<jlen; j++) { + temp.push([]); + } + lookupIndices.push(temp); + indexerFuncs.push(indexDiscreteValue); + filterFuncs.push( + getDiscreteFilterFunc(lookupIndices[lookupIndices.length-1])); + } + } + + return filterFuncs; + } + + /** + * Helper function that extracts the values of free form columns from + * the lookupIndex and injects them into the Column object as FilterOpt + * objects. + **/ + function setFreeFormFilterOptions(filterCols, lookupIndices) { + var temp, k; + for(var i=0, len=filterCols.length; i<len; i++) { + if (filterCols[i].ftype === c.FILTER_FREE_FORM) { + temp = [] + for(k in lookupIndices[i]) { + if (lookupIndices[i].hasOwnProperty(k)) { + temp.push(new FilterOpt(k, lookupIndices[i][k].length)); + } + } + filterCols[i].setFilterOptions(temp); + } + } + } + + /** + * Index a discrete column (column with fixed number of values). + * + **/ + function indexDiscreteValue(lookupIndex, col, dataVal, dataRowIndex) { + var i = col.indexOfOptVal(dataVal); + lookupIndex[i].push(dataRowIndex); + } + + /** + * Index a column with free form text (= not fixed upfront) + * + **/ + function indexFreeFormValue(lookupIndex, col, dataVal, dataRowIndex) { + if (!lookupIndex[dataVal]) { + lookupIndex[dataVal] = []; + } + lookupIndex[dataVal].push(dataRowIndex); + } + + + /** + * Get the function to filter a column with the given lookup index + * for discrete (fixed upfront) values. + * + **/ + function getDiscreteFilterFunc(lookupIndex) { + return function(filterVal) { + var result = []; + for(var i=0, len=lookupIndex.length; i < len; i++) { + if (filterVal[i]) { + // append the indices to the current array + result.push.apply(result, lookupIndex[i]); + } + } + return { nofilter: false, records: result }; + }; + } + + /** + * Get the function to filter a column with the given lookup index + * for free form values. + * + **/ + function getFreeFormFilterFunc(lookupIndex) { + return function(filterVal) { + filterVal = filterVal.trim(); + if (filterVal === '') { + return { nofilter: true }; + } + return { + nofilter: false, + records: lookupIndex[filterVal] || [] + }; + }; + } + + /** + * Filters the data based on the given filterColumns and + * corresponding filter values. + * + * @return {[]} Subset of the input dataset based on the + * filter values. + **/ + function filterData(data, filterFuncs, filterVals) { + var recordSets = []; + var filterResult; + + // run through all the filters + for(var i=0, len=filterFuncs.length; i<len; i++) { + filterResult = filterFuncs[i](filterVals[i]); + if (!filterResult.nofilter) { + recordSets.push(filterResult.records); + } + } + + // If there are no restrictions then return the whole dataset. + if (recordSets.length === 0) { + return data; + } + + // intersect the records returned by filters. + var targets = intersectArrs(recordSets); + var result = []; + for(var i=0, len=targets.length; i<len; i++) { + result.push(data[targets[i]]); + } + + return result; + } + + /** + * Creates an object where the keys are the elements of the input array + * and the values are true. To be used for set operations with integer. + **/ + function arrToObj(arr) { + var o = {}; + var i,len; + for(i=0, len=arr.length; i<len; i++) { + o[arr[i]] = true; + } + return o; + } + + /** + * Converts the keys of an object to an array after converting + * each key to integer. To be used for set operations with integers. + **/ + function objToArr(obj) { + var result = []; + for(var k in obj) { + if (obj.hasOwnProperty(k)) { + result.push(parseInt(k)); + } + } + return result; + } + + /** + * Find the intersection of a set of arrays. + **/ + function intersectArrs(sets) { + var temp, obj; + + if (sets.length === 1) { + return sets[0]; + } + + // sort by size and load the smallest into the object + sets.sort(function(a,b) { return a.length - b.length; }); + obj = arrToObj(sets[0]); + + // shrink the hash as we fail to find elements in the other sets + for(var i=1, len=sets.length; i<len; i++) { + temp = arrToObj(sets[i]); + for(var k in obj) { + if (obj.hasOwnProperty(k) && !temp[k]) { + delete obj[k]; + } + } + } + + return objToArr(obj); + } + + /** + * Extract the column values from an ImagePair (contained in the server + * response) into filter and data columns. + * + * @return {[]} Array of data contained in one data row. + **/ + function getColValues(imagePair, filterCols, otherCols) { + var result = []; + for(var i=0, len=filterCols.length; i<len; i++) { + result.push(imagePair.extraColumns[filterCols[i].key]); + } + + for(var i=0, len=otherCols.length; i<len; i++) { + result.push(get_robust(imagePair, ['expectations', otherCols[i].key])); + } + + return result; + } + + /** + * Extract the image meta data from an Image pair returned by the server. + **/ + function getImageValues(imagePair, imageCols) { + var result=[]; + var url, value, percent, diff; + var CO = c.IMG_COL_ORDER; + + for(var i=0, len=imageCols.length; i<len; i++) { + percent = get_robust(imagePair, CO[i].percentField); + value = get_robust(imagePair, CO[i].valueField); + url = get_robust(imagePair, CO[i].urlField); + if (url) { + url = imageCols[i].baseUrl + url; + } + result.push(new ImgVal(url, percent, value)); + } + + return result; + } + + /** + * Given an object find sub objects for the given index without + * throwing an error if any of the sub objects do not exist. + **/ + function get_robust(obj, idx) { + if (!idx) { + return; + } + + for(var i=0, len=idx.length; i<len; i++) { + if ((typeof obj === 'undefined') || (!idx[i])) { + return; // returns 'undefined' + } + + obj = obj[idx[i]]; + } + + return obj; + } + + /** + * Set all elements in the array to the given value. + **/ + function setArrVals(arr, newVal) { + for(var i=0, len=arr.length; i<len; i++) { + arr[i] = newVal; + } + } + + /** + * Toggle the elements of a boolean array. + * + **/ + function toggleArrVals(arr) { + for(var i=0, len=arr.length; i<len; i++) { + arr[i] = !arr[i]; + } + } + + /** + * Sort the array of DataRow instances with the given compare functions + * and the column at the given index either in ascending or descending order. + **/ + function sortData (allData, compareFunctions, sortByIdx, sortOrderAsc) { + var cmpFn = compareFunctions[sortByIdx]; + var useCmp = cmpFn; + if (!sortOrderAsc) { + useCmp = function ( _ ) { + return -cmpFn.apply(this, arguments); + }; + } + allData.sort(useCmp); + } + + + // ***************************** Services ********************************* + + /** + * Encapsulates all interactions with the backend by handling + * Urls and HTTP requests. Also exposes some utility functions + * related to processing Urls. + */ + app.factory('dataService', [ '$http', function ($http) { + /** Backend related constants **/ + var c = { + /** Url to retrieve failures */ + FAILURES: '/results/failures', + + /** Url to retrieve all GM results */ + ALL: '/results/all' + }; + + /** + * Convenience function to retrieve all results. + * + * @return {Promise} Will resolve to either the data (success) or to + * the HTTP response (error). + **/ + function loadAll() { + return httpGetData(c.ALL); + } + + /** + * Make a HTTP get request with the given query parameters. + * + * @param {} + * @param {} + * + * @return {} + **/ + function httpGetData(url, queryParams) { + var reqConfig = { + method: 'GET', + url: url, + params: queryParams + }; + + return $http(reqConfig).then( + function(successResp) { + return successResp.data; + }); + } + + /** + * Takes an arbitrary number of objects and generates a Url encoded + * query string. + * + **/ + function getQueryString( _params_ ) { + var result = []; + for(var i=0, len=arguments.length; i < len; i++) { + if (arguments[i]) { + for(var k in arguments[i]) { + if (arguments[i].hasOwnProperty(k)) { + result.push(encodeURIComponent(k) + '=' + + encodeURIComponent(arguments[i][k])); + } + } + } + } + return result.join("&"); + } + + // Interface of the service: + return { + getQueryString: getQueryString, + loadAll: loadAll + }; + + }]); + +})(); diff --git a/gm/rebaseline_server/static/new/new-index.html b/gm/rebaseline_server/static/new/new-index.html new file mode 100644 index 0000000000..b7067f19dd --- /dev/null +++ b/gm/rebaseline_server/static/new/new-index.html @@ -0,0 +1,37 @@ +<!DOCTYPE html> +<html lang="en" ng-app="rbtApp"> + +<head> + <meta name="viewport" content="width=device-width"> + <meta charset="utf-8"> + <title>Rebaseline Tool</title> + <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css"> + <link rel="stylesheet" href="css/app.css"> +</head> +<body> + + + <div class="container-fluid"> + <div class="pull-right"> + Instructions, roadmap, etc. are at <a href="http://goo.gl/CqeVHq" target="_blank">http://goo.gl/CqeVHq</a> + </div> + </div> + + <!-- Include the different views here. + Make everything fluid to scale to the maximum size of any screen. --> + <div class="container-fluid"> + <div ng-view></div> + </div> + + <!-- do everything local right now: Move to CDN fix when it's a performance issue --> + <script src="bower_components/angular/angular.js"></script> + <script src="bower_components/angular-route/angular-route.js"></script> + + <!-- Local includes external libs --> + <script src="bower_components/angular-bootstrap/ui-bootstrap.js"></script> + <script src="bower_components/angular-bootstrap/ui-bootstrap-tpls.js"></script> + + <!-- Local JS --> + <script src="js/app.js"></script> +</body> +</html> diff --git a/gm/rebaseline_server/static/new/partials/index-view.html b/gm/rebaseline_server/static/new/partials/index-view.html new file mode 100644 index 0000000000..72db231d08 --- /dev/null +++ b/gm/rebaseline_server/static/new/partials/index-view.html @@ -0,0 +1,22 @@ +<div class="container-fluid ng-cloak" ng-cloak> + <div class="row" ng-show="state === c.ST_LOADING"> + <h4>Loading ...</h4> + </div> + + <div class="row" ng-show="state === c.ST_READY"> + <h4>GM Expectations vs Actuals:</h4> + <ul> + <li><a href="#/view?{{ qStr({ resultsToLoad: c.RESULTS_FAILURES }) }}">Failures</a></li> + <li><a href="#/view?{{ qStr({ resultsToLoad: c.RESULTS_ALL }) }}">All</a></li> + </ul> + + <h4>Rendered SKPs:</h4> + <ul> + <li ng-repeat="oneSKP in allSKPs"> + <a href="#/view?{{ qStr(oneSKP.params) }}"> + {{oneSKP.title}} + </a> + </li> + </ul> + </div> +</div> diff --git a/gm/rebaseline_server/static/new/partials/rebaseline-view.html b/gm/rebaseline_server/static/new/partials/rebaseline-view.html new file mode 100644 index 0000000000..a2c28f7357 --- /dev/null +++ b/gm/rebaseline_server/static/new/partials/rebaseline-view.html @@ -0,0 +1,207 @@ +<div class="container-fluid ng-cloak" ng-cloak> + + <div class="row" ng-show="state === c.ST_LOADING"> + <h4>Loading ...</h4> + </div> + + <div class="row" ng-show="state === c.ST_STILL_LOADING"> + <h4>Still loading from backend.</h4> + <div> + Load time so far: {{ loadTime | number:0 }} s. + </div> + </div> + + <div class="row" ng-show="state === c.ST_READY"> + <tabset> + <tab heading="Unfiled"> + <!-- settings --> + <div class="container controlBox"> + <form class="form-inline settingsForm" novalidate > + <legend class="simpleLegend">Settings</legend> + <div class="checkbox formPadding"> + <label> + <input type="checkbox" + ng-model="settings.showThumbnails">Show thumbnails + </label> + </div> + + <div class="checkbox formPadding"> + <label> + <input type="checkbox" + ng-model="settings.mergeIdenticalRows" + ng-change="mergeRowsChanged(mergeIdenticalRows)"> Merge identical rows + </label> + </div> + + <div class="form-group formPadding"> + <label for="imageWidth">Image Width</label> + <select ng-model="settings.imageSize" + ng-options="iSize for iSize in c.IMAGE_SIZES" + class="form-control input-sm"> + + </select> + </div> + <div class="form-group formPadding"> + <label>Max records</label> + <select ng-model="settings.nRecords" + ng-options="n for n in c.MAX_RECORDS" + ng-change="maxRecordsChanged();" + class="form-control input-sm"> + </select> + </div> + </form> + <br> + + <form class="form settingsForm" novalidate> + <legend class="simpleLegend">Filters</legend> + <div class="container-fluid"> + <div class="col-lg-2 filterBox" ng-repeat="oneCol in filterCols"> + <div class="filterKey">{{ oneCol.key }}</div> + + <!-- If we filter this column using free-form text match... --> + <div ng-if="oneCol.ftype === c.FILTER_FREE_FORM"> + <input type="text" + ng-model="filterVals[$index]" + typeahead="opt.value for opt in oneCol.foptions | filter:$viewValue" + class="form-control input-sm"> + <br> + <a ng-click="filterVals[$index]=''" + ng-disabled="'' === filterVals[$index]" + href=""> + Clear + </a> + </div> + + <!-- If we filter this column using checkboxes... --> + <div ng-if="oneCol.ftype === c.FILTER_CHECK_BOX"> + + <div class="checkbox" ng-repeat="oneOpt in oneCol.foptions"> + <label> + <input type="checkbox" + ng-model="filterVals[$parent.$index][$index]">{{oneOpt.value}} ({{ oneOpt.count }}) + </label> + </div> + <div> + <a ng-click="setFilterAll($index, true)" href="">All</a> - + <a ng-click="setFilterAll($index, False)" href="">None</a> - + <a ng-click="setFilterToggle($index)" href="">Toggle</a> + </div> + </div> + </div> + <br> + </div> + + <div class="container updateBtn"> + <button class="btn btn-success col-lg-4 pull-left" + ng-click="filtersChanged()" + ng-disabled="updating"> + {{ updating && 'Updating ...' || 'Update' }} + </button> + </div> + + </form> + + <br> + + <!-- Rows --> + + <!-- results header --> + <div class="col-lg-12 resultsHeaderActions well"> + <div class="col-lg-6"> + <h4>Showing {{showingRecords}} of {{selectedRecords}} (of {{totalRecords}} total)</h4> + <span ng-show="renderTime > 0"> + Rendered in {{renderTime | number:0 }} ms (filtered and sorted in {{ filterTime | number:0 }} ms). + </span> + <br> + (click on the column header radio buttons to re-sort by that column) + </div> + + + <div class="col-lg-6"> + All tests shown: + <button class="btn btn-default btn-sm" ng-click="selectAllImagePairs()">Select</button> + <button class="btn btn-default btn-sm" ng-click="clearAllImagePairs()">Clear</button> + <button class="btn btn-default btn-sm" ng-click="toggleAllImagePairs()">Toggle</button> + + <div ng-repeat="otherTab in tabs"> + <button class="btn btn-default btn-sm" + ng-click="moveSelectedImagePairsToTab(otherTab)" + ng-disabled="selectedImagePairs.length == 0" + ng-show="otherTab != viewingTab"> + Move {{selectedImagePairs.length}} selected tests to {{otherTab}} tab + </button> + </div> + </div> + <br> + </div> + + <!-- results --> + <table class="table table-bordered"> + <thead> + <tr> + <!-- Most column headers are displayed in a common fashion... --> + <th ng-repeat="oneCol in allCols" ng-style="{ 'min-width': getImageWidthStyle(oneCol, 20, 'auto') }"> + <a ng-class="getSortedClass('sort', $index, '')" + ng-click="sortBy($index)" + href="" + class="sortableHeader"> + {{ oneCol.ctitle }} + </a> + </th> + <th> + <div class="checkbox"> + <label> + <input type="checkbox" ng-model="allChecked" ng-change="checkAll()">All + </label> + </div> + </th> + </tr> + </thead> + <tbody> + <tr ng-repeat="oneRow in data"> + <td ng-repeat="oneColVal in oneRow.dataCols"> + {{oneColVal}} + </td> + + <td ng-repeat="oneCol in oneRow.imageCols" ng-if="oneRow.rowspan > 0" rowspan="{{ oneRow.rowspan }}"> + <div ng-show="oneCol.url"> + <a href="{{ oneCol.url }}" target="_blank">View Image</a><br/> + <img ng-if="settings.showThumbnails" + ng-style="{ width: settings.imageSize+'px' }" + ng-src="{{ oneCol.url }}" /> + <div ng-if="oneCol.percent && oneCol.value"> + {{oneCol.percent}}% ({{ oneCol.value }}) + </div> + </div> + <div ng-hide="oneCol.url" style="text-align:center"> + <span ng-show="oneCol.url === null">–none–</span> + <span ng-hide="oneCol.url === null"> </span> + </div> + </td> + + <td ng-if="oneRow.rowspan > 0" rowspan="{{ oneRow.rowspan }}"> + <div class="checkbox"> + <input type="checkbox" + ng-model="checkRows[$index]" + ng-change="rowCheckChanged($index)"> + </div> + </td> + </tr> + </tbody> + </table> + + </div> + </tab> + + <tab heading="Hidden"> + <h3>Hidden</h3> + </tab> + + <tab heading="Pending Approval"> + <h3>Pending Approval</h3> + </tab> + + </tabset> + + </div> +</div> |