rebaseline_server: allow JSON to control column filtering
authorepoger <epoger@google.com>
Wed, 9 Jul 2014 13:19:20 +0000 (06:19 -0700)
committerCommit bot <commit-bot@chromium.org>
Wed, 9 Jul 2014 13:19:20 +0000 (06:19 -0700)
Makes the rebaseline_server client more generic, allowing the server to tweak display properties by writing directives into the JSON file.

Adds two new fields to the rebaseline_server JSON file (and thus increments VALUE__HEADER__SCHEMA_VERSION):
1. KEY__ROOT__EXTRACOLUMNORDER: order in which the client should display columns
2. KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER: whether a column should be filtered using a freeform text field or checkboxes

BUG=skia:2230
NOTRY=True
R=rmistry@google.com

Author: epoger@google.com

Review URL: https://codereview.chromium.org/376623002

gm/rebaseline_server/column.py
gm/rebaseline_server/compare_to_expectations.py
gm/rebaseline_server/imagepairset.py
gm/rebaseline_server/imagepairset_test.py
gm/rebaseline_server/results.py
gm/rebaseline_server/static/constants.js
gm/rebaseline_server/static/loader.js
gm/rebaseline_server/static/view.html
gm/rebaseline_server/testdata/outputs/expected/compare_configs_test.CompareConfigsTest.test_gm/gm.json
gm/rebaseline_server/testdata/outputs/expected/compare_rendered_pictures_test.CompareRenderedPicturesTest.test_endToEnd/compare_rendered_pictures.json
gm/rebaseline_server/testdata/outputs/expected/compare_to_expectations_test.CompareToExpectationsTest.test_gm/gm.json

index 07b075c..1b9d0bf 100644 (file)
@@ -15,6 +15,7 @@ KEY__EXTRACOLUMNHEADERS__HEADER_TEXT = 'headerText'
 KEY__EXTRACOLUMNHEADERS__HEADER_URL = 'headerUrl'
 KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE = 'isFilterable'
 KEY__EXTRACOLUMNHEADERS__IS_SORTABLE = 'isSortable'
+KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER = 'useFreeformFilter'
 KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS = 'valuesAndCounts'
 
 
@@ -23,7 +24,7 @@ class ColumnHeaderFactory(object):
 
   def __init__(self, header_text, header_url=None,
                is_filterable=True, is_sortable=True,
-               include_values_and_counts=True):
+               use_freeform_filter=False):
     """
     Args:
       header_text: string; text the client should display within column header.
@@ -32,15 +33,16 @@ class ColumnHeaderFactory(object):
       is_filterable: boolean; whether client should allow filtering on this
           column.
       is_sortable: boolean; whether client should allow sorting on this column.
-      include_values_and_counts: boolean; whether the set of values found
-          within this column, and their counts, should be available for the
-          client to display.
+      use_freeform_filter: boolean; *recommendation* to the client indicating
+          whether to allow freeform text matching, as opposed to listing all
+          values alongside checkboxes.  If is_filterable==false, this is
+          meaningless.
     """
     self._header_text = header_text
     self._header_url = header_url
     self._is_filterable = is_filterable
     self._is_sortable = is_sortable
-    self._include_values_and_counts = include_values_and_counts
+    self._use_freeform_filter = use_freeform_filter
 
   def create_as_dict(self, values_and_counts_dict=None):
     """Creates the header for this column, in dictionary form.
@@ -58,10 +60,11 @@ class ColumnHeaderFactory(object):
         KEY__EXTRACOLUMNHEADERS__HEADER_TEXT: self._header_text,
         KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE: self._is_filterable,
         KEY__EXTRACOLUMNHEADERS__IS_SORTABLE: self._is_sortable,
+        KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER: self._use_freeform_filter,
     }
     if self._header_url:
       asdict[KEY__EXTRACOLUMNHEADERS__HEADER_URL] = self._header_url
-    if self._include_values_and_counts and values_and_counts_dict:
+    if values_and_counts_dict:
       asdict[KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS] = sorted(
           values_and_counts_dict.items())
     return asdict
index 1a93c66..e1a5f5f 100755 (executable)
@@ -12,16 +12,18 @@ Repackage expected/actual GM results as needed by our HTML rebaseline viewer.
 # System-level imports
 import argparse
 import fnmatch
-import json
 import logging
 import os
-import re
-import sys
 import time
 
+# Must fix up PYTHONPATH before importing from within Skia
+# pylint: disable=W0611
+import fix_pythonpath
+# pylint: enable=W0611
+
 # Imports from within Skia
-import fix_pythonpath  # must do this first
 from pyutils import url_utils
+import column
 import gm_json
 import imagediffdb
 import imagepair
@@ -33,6 +35,17 @@ EXPECTATION_FIELDS_PASSED_THRU_VERBATIM = [
     results.KEY__EXPECTATIONS__IGNOREFAILURE,
     results.KEY__EXPECTATIONS__REVIEWED,
 ]
+FREEFORM_COLUMN_IDS = [
+    results.KEY__EXTRACOLUMNS__BUILDER,
+    results.KEY__EXTRACOLUMNS__TEST,
+]
+ORDERED_COLUMN_IDS = [
+    results.KEY__EXTRACOLUMNS__RESULT_TYPE,
+    results.KEY__EXTRACOLUMNS__BUILDER,
+    results.KEY__EXTRACOLUMNS__TEST,
+    results.KEY__EXTRACOLUMNS__CONFIG,
+]
+
 TRUNK_DIRECTORY = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
 DEFAULT_EXPECTATIONS_DIR = os.path.join(TRUNK_DIRECTORY, 'expectations', 'gm')
 DEFAULT_IGNORE_FAILURES_FILE = 'ignored-tests.txt'
@@ -171,7 +184,7 @@ class ExpectationComparisons(results.BaseComparisons):
     if not os.path.isdir(root):
       raise IOError('no directory found at path %s' % root)
     actual_builders_written = []
-    for dirpath, dirnames, filenames in os.walk(root):
+    for dirpath, _, filenames in os.walk(root):
       for matching_filename in fnmatch.filter(filenames, pattern):
         builder = os.path.basename(dirpath)
         per_builder_dict = meta_dict.get(builder)
@@ -211,6 +224,15 @@ class ExpectationComparisons(results.BaseComparisons):
         descriptions=IMAGEPAIR_SET_DESCRIPTIONS,
         diff_base_url=self._diff_base_url)
 
+    # Override settings for columns that should be filtered using freeform text.
+    for column_id in FREEFORM_COLUMN_IDS:
+      factory = column.ColumnHeaderFactory(
+          header_text=column_id, use_freeform_filter=True)
+      all_image_pairs.set_column_header_factory(
+          column_id=column_id, column_header_factory=factory)
+      failing_image_pairs.set_column_header_factory(
+          column_id=column_id, column_header_factory=factory)
+
     all_image_pairs.ensure_extra_column_values_in_summary(
         column_id=results.KEY__EXTRACOLUMNS__RESULT_TYPE, values=[
             results.KEY__RESULT_TYPE__FAILED,
@@ -339,9 +361,12 @@ class ExpectationComparisons(results.BaseComparisons):
           except Exception:
             logging.exception('got exception while creating new ImagePair')
 
+    # pylint: disable=W0201
     self._results = {
-      results.KEY__HEADER__RESULTS_ALL: all_image_pairs.as_dict(),
-      results.KEY__HEADER__RESULTS_FAILURES: failing_image_pairs.as_dict(),
+      results.KEY__HEADER__RESULTS_ALL: all_image_pairs.as_dict(
+          column_ids_in_order=ORDERED_COLUMN_IDS),
+      results.KEY__HEADER__RESULTS_FAILURES: failing_image_pairs.as_dict(
+          column_ids_in_order=ORDERED_COLUMN_IDS),
     }
 
 
index 199fd42..ef9acbc 100644 (file)
@@ -19,6 +19,7 @@ import imagediffdb
 # Keys used within dictionary representation of ImagePairSet.
 # NOTE: Keep these in sync with static/constants.js
 KEY__ROOT__EXTRACOLUMNHEADERS = 'extraColumnHeaders'
+KEY__ROOT__EXTRACOLUMNORDER = 'extraColumnOrder'
 KEY__ROOT__HEADER = 'header'
 KEY__ROOT__IMAGEPAIRS = 'imagePairs'
 KEY__ROOT__IMAGESETS = 'imageSets'
@@ -135,15 +136,35 @@ class ImagePairSet(object):
           values_for_column)
     return asdict
 
-  def as_dict(self):
+  def as_dict(self, column_ids_in_order=None):
     """Returns a dictionary describing this package of ImagePairs.
 
     Uses the KEY__* constants as keys.
+
+    Params:
+      column_ids_in_order: A list of all extracolumn IDs in the desired display
+          order.  If unspecified, they will be displayed in alphabetical order.
+          If specified, this list must contain all the extracolumn IDs!
+          (It may contain extra column IDs; they will be ignored.)
     """
+    all_column_ids = set(self._extra_column_tallies.keys())
+    if column_ids_in_order == None:
+      column_ids_in_order = sorted(all_column_ids)
+    else:
+      # Make sure the caller listed all column IDs, and throw away any extras.
+      specified_column_ids = set(column_ids_in_order)
+      forgotten_column_ids = all_column_ids - specified_column_ids
+      assert not forgotten_column_ids, (
+          'column_ids_in_order %s missing these column_ids: %s' % (
+              column_ids_in_order, forgotten_column_ids))
+      column_ids_in_order = [c for c in column_ids_in_order
+                             if c in all_column_ids]
+
     key_description = KEY__IMAGESETS__FIELD__DESCRIPTION
     key_base_url = KEY__IMAGESETS__FIELD__BASE_URL
     return {
         KEY__ROOT__EXTRACOLUMNHEADERS: self._column_headers_as_dict(),
+        KEY__ROOT__EXTRACOLUMNORDER: column_ids_in_order,
         KEY__ROOT__IMAGEPAIRS: self._image_pair_dicts,
         KEY__ROOT__IMAGESETS: {
             KEY__IMAGESETS__SET__IMAGE_A: {
index c2f17ba..5e17faa 100755 (executable)
@@ -89,6 +89,7 @@ class ImagePairSetTest(unittest.TestCase):
                 'headerText': 'builder',
                 'isFilterable': True,
                 'isSortable': True,
+                'useFreeformFilter': False,
                 'valuesAndCounts': [('MyBuilder', 3)],
             },
             'test': {
@@ -96,8 +97,13 @@ class ImagePairSetTest(unittest.TestCase):
                 'headerUrl': 'http://learn/about/gm/tests',
                 'isFilterable': True,
                 'isSortable': False,
+                'useFreeformFilter': False,
+                'valuesAndCounts': [('test1', 1),
+                                    ('test2', 1),
+                                    ('test3', 1)],
             },
         },
+        'extraColumnOrder': ['builder', 'test'],
         'imagePairs': [
             IMAGEPAIR_1_AS_DICT,
             IMAGEPAIR_2_AS_DICT,
@@ -136,8 +142,7 @@ class ImagePairSetTest(unittest.TestCase):
             header_text='which GM test',
             header_url='http://learn/about/gm/tests',
             is_filterable=True,
-            is_sortable=False,
-            include_values_and_counts=False))
+            is_sortable=False))
     self.assertEqual(image_pair_set.as_dict(), expected_imageset_dict)
 
   def test_mismatched_base_url(self):
@@ -153,6 +158,23 @@ class ImagePairSetTest(unittest.TestCase):
           MockImagePair(base_url=BASE_URL_2,
                         dict_to_return=IMAGEPAIR_3_AS_DICT))
 
+  def test_missing_column_ids(self):
+    """Confirms that passing truncated column_ids_in_order to as_dict()
+    will cause an exception."""
+    image_pair_set = imagepairset.ImagePairSet(
+        diff_base_url=DIFF_BASE_URL)
+    image_pair_set.add_image_pair(
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_1_AS_DICT))
+    image_pair_set.add_image_pair(
+        MockImagePair(base_url=BASE_URL_1, dict_to_return=IMAGEPAIR_2_AS_DICT))
+    # Call as_dict() with default or reasonable column_ids_in_order.
+    image_pair_set.as_dict()
+    image_pair_set.as_dict(column_ids_in_order=['test', 'builder'])
+    image_pair_set.as_dict(column_ids_in_order=['test', 'builder', 'extra'])
+    # Call as_dict() with not enough column_ids.
+    with self.assertRaises(Exception):
+      image_pair_set.as_dict(column_ids_in_order=['builder'])
+
 
 class MockImagePair(object):
   """Mock ImagePair object, which will return canned results."""
index d17bc3d..8e6bc43 100755 (executable)
@@ -14,14 +14,18 @@ import fnmatch
 import os
 import re
 
+# Must fix up PYTHONPATH before importing from within Skia
+# pylint: disable=W0611
+import fix_pythonpath
+# pylint: enable=W0611
+
 # Imports from within Skia
-import fix_pythonpath  # must do this first
 import gm_json
 import imagepairset
 
 # Keys used to link an image to a particular GM test.
 # NOTE: Keep these in sync with static/constants.js
-VALUE__HEADER__SCHEMA_VERSION = 3
+VALUE__HEADER__SCHEMA_VERSION = 4
 KEY__EXPECTATIONS__BUGS = gm_json.JSONKEY_EXPECTEDRESULTS_BUGS
 KEY__EXPECTATIONS__IGNOREFAILURE = gm_json.JSONKEY_EXPECTEDRESULTS_IGNOREFAILURE
 KEY__EXPECTATIONS__REVIEWED = gm_json.JSONKEY_EXPECTEDRESULTS_REVIEWED
@@ -201,7 +205,7 @@ class BaseComparisons(object):
     if not os.path.isdir(root):
       raise IOError('no directory found at path %s' % root)
     meta_dict = {}
-    for dirpath, dirnames, filenames in os.walk(root):
+    for dirpath, _, filenames in os.walk(root):
       for matching_filename in fnmatch.filter(filenames, pattern):
         builder = os.path.basename(dirpath)
         if self._ignore_builder(builder):
@@ -228,7 +232,7 @@ class BaseComparisons(object):
     if not os.path.isdir(root):
       raise IOError('no directory found at path %s' % root)
     meta_dict = {}
-    for abs_dirpath, dirnames, filenames in os.walk(root):
+    for abs_dirpath, _, filenames in os.walk(root):
       rel_dirpath = os.path.relpath(abs_dirpath, root)
       for matching_filename in fnmatch.filter(filenames, pattern):
         abs_path = os.path.join(abs_dirpath, matching_filename)
@@ -293,7 +297,7 @@ class BaseComparisons(object):
     If this would result in any repeated keys, it will raise an Exception.
     """
     output_dict = {}
-    for key, subdict in input_dict.iteritems():
+    for subdict in input_dict.values():
       for subdict_key, subdict_value in subdict.iteritems():
         if subdict_key in output_dict:
           raise Exception('duplicate key %s in combine_subdicts' % subdict_key)
index 5d8ffea..150c895 100644 (file)
@@ -13,6 +13,7 @@ module.constant('constants', (function() {
     KEY__EXTRACOLUMNHEADERS__HEADER_URL: 'headerUrl',
     KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE: 'isFilterable',
     KEY__EXTRACOLUMNHEADERS__IS_SORTABLE: 'isSortable',
+    KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER: 'useFreeformFilter',
     KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS: 'valuesAndCounts',
 
     // NOTE: Keep these in sync with ../imagediffdb.py
@@ -31,6 +32,7 @@ module.constant('constants', (function() {
 
     // NOTE: Keep these in sync with ../imagepairset.py
     KEY__ROOT__EXTRACOLUMNHEADERS: 'extraColumnHeaders',
+    KEY__ROOT__EXTRACOLUMNORDER: 'extraColumnOrder',
     KEY__ROOT__HEADER: 'header',
     KEY__ROOT__IMAGEPAIRS: 'imagePairs',
     KEY__ROOT__IMAGESETS: 'imageSets',
@@ -62,7 +64,7 @@ module.constant('constants', (function() {
     KEY__HEADER__TIME_NEXT_UPDATE_AVAILABLE: 'timeNextUpdateAvailable',
     KEY__HEADER__TIME_UPDATED: 'timeUpdated',
     KEY__HEADER__TYPE: 'type',
-    VALUE__HEADER__SCHEMA_VERSION: 3,
+    VALUE__HEADER__SCHEMA_VERSION: 4,
     //
     KEY__RESULT_TYPE__FAILED: 'failed',
     KEY__RESULT_TYPE__FAILUREIGNORED: 'failure-ignored',
index 9384196..19bc237 100644 (file)
@@ -30,24 +30,29 @@ Loader.directive(
 Loader.filter(
   'removeHiddenImagePairs',
   function(constants) {
-    return function(unfilteredImagePairs, showingColumnValues,
-                    builderSubstring, testSubstring, viewingTab) {
+    return function(unfilteredImagePairs, filterableColumnNames, showingColumnValues,
+                    viewingTab) {
       var filteredImagePairs = [];
       for (var i = 0; i < unfilteredImagePairs.length; i++) {
         var imagePair = unfilteredImagePairs[i];
         var extraColumnValues = imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS];
-        // For performance, we examine the "set" objects directly rather
-        // than calling $scope.isValueInSet().
-        // Besides, I don't think we have access to $scope in here...
-        if (showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE]
-                               [extraColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE]] &&
-            showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG]
-                               [extraColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG]] &&
-            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMNS__BUILDER]
-                    .indexOf(builderSubstring)) &&
-            !(-1 == extraColumnValues[constants.KEY__EXTRACOLUMNS__TEST]
-                    .indexOf(testSubstring)) &&
-            (viewingTab == imagePair.tab)) {
+        var allColumnValuesAreVisible = true;
+        // Loop over all columns, and if any of them contain values not found in
+        // showingColumnValues[columnName], don't include this imagePair.
+        //
+        // We use this same filtering mechanism regardless of whether each column
+        // has USE_FREEFORM_FILTER set or not; if that flag is set, then we will
+        // have already used the freeform text entry block to populate
+        // showingColumnValues[columnName].
+        for (var j = 0; j < filterableColumnNames.length; j++) {
+          var columnName = filterableColumnNames[j];
+          var columnValue = extraColumnValues[columnName];
+          if (!showingColumnValues[columnName][columnValue]) {
+            allColumnValuesAreVisible = false;
+            break;
+          }
+        }
+        if (allColumnValuesAreVisible && (viewingTab == imagePair.tab)) {
           filteredImagePairs.push(imagePair);
         }
       }
@@ -159,6 +164,7 @@ Loader.controller(
 
           $scope.header = dataHeader;
           $scope.extraColumnHeaders = data[constants.KEY__ROOT__EXTRACOLUMNHEADERS];
+          $scope.orderedColumnNames = data[constants.KEY__ROOT__EXTRACOLUMNORDER];
           $scope.imagePairs = data[constants.KEY__ROOT__IMAGEPAIRS];
           $scope.imageSets = data[constants.KEY__ROOT__IMAGESETS];
           $scope.sortColumnSubdict = constants.KEY__IMAGEPAIRS__DIFFERENCES;
@@ -200,41 +206,69 @@ Loader.controller(
           // Arrays within which the user can toggle individual elements.
           $scope.selectedImagePairs = [];
 
+          // Set up filters.
+          //
+          // filterableColumnNames is a list of all column names we can filter on.
           // allColumnValues[columnName] is a list of all known values
-          // for this column.
+          // for a given column.
           // showingColumnValues[columnName] is a set indicating which values
-          // in this column would cause us to show a row, rather than hiding it.
+          // in a given column would cause us to show a row, rather than hiding it.
+          //
+          // columnStringMatch[columnName] is a string used as a pattern to generate
+          // showingColumnValues[columnName] for columns we filter using free-form text.
+          // It is ignored for any columns with USE_FREEFORM_FILTER == false.
+          $scope.filterableColumnNames = [];
           $scope.allColumnValues = {};
           $scope.showingColumnValues = {};
+          $scope.columnStringMatch = {};
 
-          // set allColumnValues/showingColumnValues for RESULT_TYPE;
+          angular.forEach(
+            Object.keys($scope.extraColumnHeaders),
+            function(columnName) {
+              var columnHeader = $scope.extraColumnHeaders[columnName];
+              if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]) {
+                $scope.filterableColumnNames.push(columnName);
+                $scope.allColumnValues[columnName] = $scope.columnSliceOf2DArray(
+                    columnHeader[constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS], 0);
+                $scope.showingColumnValues[columnName] = {};
+                $scope.toggleValuesInSet($scope.allColumnValues[columnName],
+                                         $scope.showingColumnValues[columnName]);
+                $scope.columnStringMatch[columnName] = "";
+              }
+            }
+          );
+
+          // TODO(epoger): Special handling for RESULT_TYPE column:
           // by default, show only KEY__RESULT_TYPE__FAILED results
-          $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] =
-              $scope.columnSliceOf2DArray(
-                  $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMNS__RESULT_TYPE]
-                                           [constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS],
-                  0);
           $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {};
           $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][
               constants.KEY__RESULT_TYPE__FAILED] = true;
 
-          // set allColumnValues/showingColumnValues for CONFIG;
-          // by default, show results for all configs
-          $scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG] =
-              $scope.columnSliceOf2DArray(
-                  $scope.extraColumnHeaders[constants.KEY__EXTRACOLUMNS__CONFIG]
-                                           [constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS],
-                  0);
-          $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG] = {};
-          $scope.toggleValuesInSet($scope.allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG],
-                                   $scope.showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG]);
-
-          // Associative array of partial string matches per category.
-          // TODO(epoger): Rename as columnValueMatch to be more consistent
-          // with allColumnValues/showingColumnValues ?
-          $scope.categoryValueMatch = {};
-          $scope.categoryValueMatch[constants.KEY__EXTRACOLUMNS__BUILDER] = "";
-          $scope.categoryValueMatch[constants.KEY__EXTRACOLUMNS__TEST] = "";
+          // Set up mapping for URL parameters.
+          // parameter name -> copier object to load/save parameter value
+          $scope.queryParameters.map = {
+            'resultsToLoad':         $scope.queryParameters.copiers.simple,
+            'displayLimitPending':   $scope.queryParameters.copiers.simple,
+            'showThumbnailsPending': $scope.queryParameters.copiers.simple,
+            'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple,
+            'imageSizePending':      $scope.queryParameters.copiers.simple,
+            'sortColumnSubdict':     $scope.queryParameters.copiers.simple,
+            'sortColumnKey':         $scope.queryParameters.copiers.simple,
+          };
+          // Some parameters are handled differently based on whether they USE_FREEFORM_FILTER.
+          angular.forEach(
+            $scope.filterableColumnNames,
+            function(columnName) {
+              if ($scope.extraColumnHeaders[columnName]
+                  [constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
+                $scope.queryParameters.map[columnName] =
+                    $scope.queryParameters.copiers.columnStringMatch;
+              } else {
+                $scope.queryParameters.map[columnName] =
+                    $scope.queryParameters.copiers.showingColumnValuesSet;
+              }
+            }
+          );
 
           // If any defaults were overridden in the URL, get them now.
           $scope.queryParameters.load();
@@ -391,15 +425,15 @@ Loader.controller(
         }
       },
 
-      'categoryValueMatch': {
+      'columnStringMatch': {
         'load': function(nameValuePairs, name) {
           var value = nameValuePairs[name];
           if (value) {
-            $scope.categoryValueMatch[name] = value;
+            $scope.columnStringMatch[name] = value;
           }
         },
         'save': function(nameValuePairs, name) {
-          nameValuePairs[name] = $scope.categoryValueMatch[name];
+          nameValuePairs[name] = $scope.columnStringMatch[name];
         }
       },
 
@@ -419,25 +453,6 @@ Loader.controller(
 
     };
 
-    // parameter name -> copier objects to load/save parameter value
-    $scope.queryParameters.map = {
-      'resultsToLoad':         $scope.queryParameters.copiers.simple,
-      'displayLimitPending':   $scope.queryParameters.copiers.simple,
-      'showThumbnailsPending': $scope.queryParameters.copiers.simple,
-      'mergeIdenticalRowsPending': $scope.queryParameters.copiers.simple,
-      'imageSizePending':      $scope.queryParameters.copiers.simple,
-      'sortColumnSubdict':     $scope.queryParameters.copiers.simple,
-      'sortColumnKey':         $scope.queryParameters.copiers.simple,
-    };
-    $scope.queryParameters.map[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] =
-        $scope.queryParameters.copiers.showingColumnValuesSet;
-    $scope.queryParameters.map[constants.KEY__EXTRACOLUMNS__BUILDER] =
-        $scope.queryParameters.copiers.categoryValueMatch;
-    $scope.queryParameters.map[constants.KEY__EXTRACOLUMNS__TEST] =
-        $scope.queryParameters.copiers.categoryValueMatch;
-    $scope.queryParameters.map[constants.KEY__EXTRACOLUMNS__CONFIG] =
-        $scope.queryParameters.copiers.showingColumnValuesSet;
-
     // Loads all parameters into $scope from the URL query string;
     // any which are not found within the URL will keep their current value.
     $scope.queryParameters.load = function() {
@@ -550,6 +565,30 @@ Loader.controller(
       $log.debug("renderStartTime: " + $scope.renderStartTime);
       $scope.displayLimit = $scope.displayLimitPending;
       $scope.mergeIdenticalRows = $scope.mergeIdenticalRowsPending;
+
+      // For each USE_FREEFORM_FILTER column, populate showingColumnValues.
+      // This is more efficient than applying the freeform filter within the
+      // tight loop in removeHiddenImagePairs.
+      angular.forEach(
+        $scope.filterableColumnNames,
+        function(columnName) {
+          var columnHeader = $scope.extraColumnHeaders[columnName];
+          if (columnHeader[constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]) {
+            var columnStringMatch = $scope.columnStringMatch[columnName];
+            var showingColumnValues = {};
+            angular.forEach(
+              $scope.allColumnValues[columnName],
+              function(columnValue) {
+                if (-1 != columnValue.indexOf(columnStringMatch)) {
+                  showingColumnValues[columnValue] = true;
+                }
+              }
+            );
+            $scope.showingColumnValues[columnName] = showingColumnValues;
+          }
+        }
+      );
+
       // TODO(epoger): Every time we apply a filter, AngularJS creates
       // another copy of the array.  Is there a way we can filter out
       // the imagePairs as they are displayed, rather than storing multiple
@@ -569,9 +608,8 @@ Loader.controller(
             $filter("orderBy")(
                 $filter("removeHiddenImagePairs")(
                     $scope.imagePairs,
+                    $scope.filterableColumnNames,
                     $scope.showingColumnValues,
-                    $scope.categoryValueMatch[constants.KEY__EXTRACOLUMNS__BUILDER],
-                    $scope.categoryValueMatch[constants.KEY__EXTRACOLUMNS__TEST],
                     $scope.viewingTab
                 ),
                 [$scope.getSortColumnValue, $scope.getSecondOrderSortValue],
@@ -652,36 +690,40 @@ Loader.controller(
     }
 
     /**
-     * Set $scope.categoryValueMatch[name] = value, and update results.
+     * Set $scope.columnStringMatch[name] = value, and update results.
      *
      * @param name
      * @param value
      */
-    $scope.setCategoryValueMatch = function(name, value) {
-      $scope.categoryValueMatch[name] = value;
+    $scope.setColumnStringMatch = function(name, value) {
+      $scope.columnStringMatch[name] = value;
       $scope.updateResults();
     }
 
     /**
-     * Update $scope.showingColumnValues[columnName] so that ONLY entries with
-     * this columnValue are showing, and update the visible results.
+     * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
+     * so that ONLY entries with this columnValue are showing, and update the visible results.
+     * (We update both of those, so we cover both freeform and checkbox filtered columns.)
      *
      * @param columnName
      * @param columnValue
      */
     $scope.showOnlyColumnValue = function(columnName, columnValue) {
+      $scope.columnStringMatch[columnName] = columnValue;
       $scope.showingColumnValues[columnName] = {};
       $scope.toggleValueInSet(columnValue, $scope.showingColumnValues[columnName]);
       $scope.updateResults();
     }
 
     /**
-     * Update $scope.showingColumnValues[columnName] so that ALL entries are
-     * showing, and update the visible results.
+     * Update $scope.showingColumnValues[columnName] and $scope.columnStringMatch[columnName]
+     * so that ALL entries are showing, and update the visible results.
+     * (We update both of those, so we cover both freeform and checkbox filtered columns.)
      *
      * @param columnName
      */
     $scope.showAllColumnValues = function(columnName) {
+      $scope.columnStringMatch[columnName] = "";
       $scope.showingColumnValues[columnName] = {};
       $scope.toggleValuesInSet($scope.allColumnValues[columnName],
                                $scope.showingColumnValues[columnName]);
index cf0f936..0e964d1 100644 (file)
       </th>
     </tr>
     <tr valign="top">
-      <td>
-        resultType<br>
-        <label ng-repeat="valueAndCount in extraColumnHeaders[constants.KEY__EXTRACOLUMNS__RESULT_TYPE][constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS]">
-          <input type="checkbox"
-                 name="resultTypes"
-                 value="{{valueAndCount[0]}}"
-                 ng-checked="isValueInSet(valueAndCount[0], showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE])"
-                 ng-click="toggleValueInSet(valueAndCount[0], showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE]); setUpdatesPending(true)">
-          {{valueAndCount[0]}} ({{valueAndCount[1]}})<br>
-        </label>
-        <button ng-click="showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {}; toggleValuesInSet(allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE], showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE]); updateResults()"
-                ng-disabled="!readyToDisplay || allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE].length == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE])">
-          all
-        </button>
-        <button ng-click="showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE] = {}; updateResults()"
-                ng-disabled="!readyToDisplay || 0 == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE])">
-          none
-        </button>
-        <button ng-click="toggleValuesInSet(allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE], showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE]); updateResults()">
-          toggle
-        </button>
-      </td>
-      <td ng-repeat="category in [constants.KEY__EXTRACOLUMNS__BUILDER, constants.KEY__EXTRACOLUMNS__TEST]">
-        {{category}}
-        <br>
-        <input type="text"
-               ng-model="categoryValueMatch[category]"
-               ng-change="setUpdatesPending(true)"/>
-        <br>
-        <button ng-click="setCategoryValueMatch(category, '')"
-                ng-disabled="('' == categoryValueMatch[category])">
-          clear (show all)
-        </button>
-      </td>
-      <td>
-        config<br>
-        <label ng-repeat="valueAndCount in extraColumnHeaders[constants.KEY__EXTRACOLUMNS__CONFIG][constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS]">
-          <input type="checkbox"
-                 name="configs"
-                 value="{{valueAndCount[0]}}"
-                 ng-checked="isValueInSet(valueAndCount[0], showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG])"
-                 ng-click="toggleValueInSet(valueAndCount[0], showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG]); setUpdatesPending(true)">
-          {{valueAndCount[0]}} ({{valueAndCount[1]}})<br>
-        </label>
-        <button ng-click="showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG] = {}; toggleValuesInSet(allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG], showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG]); updateResults()"
-                ng-disabled="!readyToDisplay || allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG].length == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG])">
-          all
-        </button>
-        <button ng-click="showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG] = {}; updateResults()"
-                ng-disabled="!readyToDisplay || 0 == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG])">
-          none
-        </button>
-        <button ng-click="toggleValuesInSet(allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG], showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG]); updateResults()">
-          toggle
-        </button>
+
+      <!-- filters -->
+      <td ng-repeat="columnName in orderedColumnNames">
+
+        <!-- Only display filterable columns here... -->
+        <div ng-if="extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__IS_FILTERABLE]">
+          {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}<br>
+
+          <!-- If we filter this column using free-form text match... -->
+          <div ng-if="extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]">
+            <input type="text"
+                   ng-model="columnStringMatch[columnName]"
+                   ng-change="setUpdatesPending(true)"/>
+            <br>
+            <button ng-click="setColumnStringMatch(columnName, '')"
+                    ng-disabled="('' == columnStringMatch[columnName])">
+              clear (show all)
+            </button>
+          </div>
+
+          <!-- If we filter this column using checkboxes... -->
+          <div ng-if="!extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__USE_FREEFORM_FILTER]">
+            <label ng-repeat="valueAndCount in extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__VALUES_AND_COUNTS]">
+              <input type="checkbox"
+                     name="resultTypes"
+                     value="{{valueAndCount[0]}}"
+                     ng-checked="isValueInSet(valueAndCount[0], showingColumnValues[columnName])"
+                     ng-click="toggleValueInSet(valueAndCount[0], showingColumnValues[columnName]); setUpdatesPending(true)">
+              {{valueAndCount[0]}} ({{valueAndCount[1]}})<br>
+            </label>
+            <button ng-click="showingColumnValues[columnName] = {}; toggleValuesInSet(allColumnValues[columnName], showingColumnValues[columnName]); updateResults()"
+                    ng-disabled="!readyToDisplay || allColumnValues[columnName].length == setSize(showingColumnValues[columnName])">
+              all
+            </button>
+            <button ng-click="showingColumnValues[columnName] = {}; updateResults()"
+                    ng-disabled="!readyToDisplay || 0 == setSize(showingColumnValues[columnName])">
+              none
+            </button>
+            <button ng-click="toggleValuesInSet(allColumnValues[columnName], showingColumnValues[columnName]); updateResults()">
+              toggle
+            </button>
+          </div>
+
+        </div>
       </td>
+
+      <!-- settings -->
       <td><table>
         <tr><td>
           <input type="checkbox" ng-model="showThumbnailsPending"
       <table border="1" ng-app="diff_viewer"> <!-- results -->
         <tr>
           <!-- Most column headers are displayed in a common fashion... -->
-          <th ng-repeat="categoryName in [constants.KEY__EXTRACOLUMNS__RESULT_TYPE, constants.KEY__EXTRACOLUMNS__BUILDER, constants.KEY__EXTRACOLUMNS__TEST, constants.KEY__EXTRACOLUMNS__CONFIG]">
+          <th ng-repeat="columnName in orderedColumnNames">
             <input type="radio"
                    name="sortColumnRadio"
-                   value="{{categoryName}}"
-                   ng-checked="(sortColumnKey == categoryName)"
-                   ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__EXTRACOLUMNS, categoryName)">
-            {{categoryName}}
+                   value="{{columnName}}"
+                   ng-checked="(sortColumnKey == columnName)"
+                   ng-click="sortResultsBy(constants.KEY__IMAGEPAIRS__EXTRACOLUMNS, columnName)">
+            {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}
           </th>
           <!-- ... but there are a few columns where we display things differently. -->
           <th>
         <tr ng-repeat="imagePair in limitedImagePairs" valign="top"
             ng-class-odd="'results-odd'" ng-class-even="'results-even'"
             results-updated-callback-directive>
-          <td>
-            {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][constants.KEY__EXTRACOLUMNS__RESULT_TYPE]}}
-            <br>
-            <button class="show-only-button"
-                    ng-show="viewingTab == defaultTab"
-                    ng-disabled="1 == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE])"
-                    ng-click="showOnlyColumnValue(constants.KEY__EXTRACOLUMNS__RESULT_TYPE, imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][constants.KEY__EXTRACOLUMNS__RESULT_TYPE])"
-                    title="show only results of type {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][constants.KEY__EXTRACOLUMNS__RESULT_TYPE]}}">
-              show only
-            </button>
-            <br>
-            <button class="show-all-button"
-                    ng-show="viewingTab == defaultTab"
-                    ng-disabled="allColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE].length == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__RESULT_TYPE])"
-                    ng-click="showAllColumnValues(constants.KEY__EXTRACOLUMNS__RESULT_TYPE)"
-                    title="show results of all types">
-              show all
-            </button>
-          </td>
-          <td ng-repeat="categoryName in [constants.KEY__EXTRACOLUMNS__BUILDER, constants.KEY__EXTRACOLUMNS__TEST]">
-            {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][categoryName]}}
-            <br>
-            <button class="show-only-button"
-                    ng-show="viewingTab == defaultTab"
-                    ng-disabled="imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][categoryName] == categoryValueMatch[categoryName]"
-                    ng-click="setCategoryValueMatch(categoryName, imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][categoryName])"
-                    title="show only results of {{categoryName}} {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][categoryName]}}">
-              show only
-            </button>
-            <br>
-            <button class="show-all-button"
-                    ng-show="viewingTab == defaultTab"
-                    ng-disabled="'' == categoryValueMatch[categoryName]"
-                    ng-click="setCategoryValueMatch(categoryName, '')"
-                    title="show results of all {{categoryName}}s">
-              show all
-            </button>
-          </td>
-          <td>
-            {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][constants.KEY__EXTRACOLUMNS__CONFIG]}}
+
+          <td ng-repeat="columnName in orderedColumnNames">
+            {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName]}}
             <br>
             <button class="show-only-button"
                     ng-show="viewingTab == defaultTab"
-                    ng-disabled="1 == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG])"
-                    ng-click="showOnlyColumnValue(constants.KEY__EXTRACOLUMNS__CONFIG, imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][constants.KEY__EXTRACOLUMNS__CONFIG])"
-                    title="show only results of config {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][constants.KEY__EXTRACOLUMNS__CONFIG]}}">
+                    ng-disabled="1 == setSize(showingColumnValues[columnName])"
+                    ng-click="showOnlyColumnValue(columnName, imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName])"
+                    title="show only results of {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}} {{imagePair[constants.KEY__IMAGEPAIRS__EXTRACOLUMNS][columnName]}}">
               show only
             </button>
             <br>
             <button class="show-all-button"
                     ng-show="viewingTab == defaultTab"
-                    ng-disabled="allColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG].length == setSize(showingColumnValues[constants.KEY__EXTRACOLUMNS__CONFIG])"
-                    ng-click="showAllColumnValues(constants.KEY__EXTRACOLUMNS__CONFIG)"
-                    title="show results of all configs">
+                    ng-disabled="allColumnValues[columnName].length == setSize(showingColumnValues[columnName])"
+                    ng-click="showAllColumnValues(columnName)"
+                    title="show results of all {{extraColumnHeaders[columnName][constants.KEY__EXTRACOLUMNHEADERS__HEADER_TEXT]}}s">
               show all
             </button>
           </td>
+
+          <!-- bugs -->
           <td>
             <a ng-repeat="bug in imagePair[constants.KEY__IMAGEPAIRS__EXPECTATIONS][constants.KEY__EXPECTATIONS__BUGS]"
                href="https://code.google.com/p/skia/issues/detail?id={{bug}}"
index 54918b5..2dc6e6b 100644 (file)
@@ -4,6 +4,7 @@
       "headerText": "builder", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
@@ -23,6 +24,7 @@
       "headerText": "config", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "TODO", 
@@ -34,6 +36,7 @@
       "headerText": "resultType", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "failed", 
@@ -53,6 +56,7 @@
       "headerText": "test", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "3x3bitmaprect", 
       ]
     }
   }, 
+  "extraColumnOrder": [
+    "builder", 
+    "config", 
+    "resultType", 
+    "test"
+  ], 
   "header": {
     "dataHash": "-5829724510169924592", 
     "isEditable": false, 
     "isExported": true, 
-    "schemaVersion": 3
+    "schemaVersion": 4
     "timeNextUpdateAvailable": null, 
     "timeUpdated": 12345678, 
     "type": "all"
index 464b1d4..e741e3e 100644 (file)
@@ -4,6 +4,7 @@
       "headerText": "builder", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "TODO", 
@@ -15,6 +16,7 @@
       "headerText": "config", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "whole-image", 
@@ -26,6 +28,7 @@
       "headerText": "resultType", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "failed", 
@@ -45,6 +48,7 @@
       "headerText": "test", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "changed.skp", 
       ]
     }
   }, 
+  "extraColumnOrder": [
+    "builder", 
+    "config", 
+    "resultType", 
+    "test"
+  ], 
   "header": {
     "dataHash": "-595743736412687673", 
     "isEditable": false, 
     "isExported": true, 
-    "schemaVersion": 3
+    "schemaVersion": 4
     "timeNextUpdateAvailable": null, 
     "timeUpdated": 12345678, 
     "type": "all"
index 1e3d947..d20a3a3 100644 (file)
@@ -4,6 +4,7 @@
       "headerText": "builder", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": true, 
       "valuesAndCounts": [
         [
           "Test-Android-GalaxyNexus-SGX540-Arm7-Release", 
@@ -19,6 +20,7 @@
       "headerText": "config", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "565", 
@@ -46,6 +48,7 @@
       "headerText": "resultType", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": false, 
       "valuesAndCounts": [
         [
           "failed", 
@@ -69,6 +72,7 @@
       "headerText": "test", 
       "isFilterable": true, 
       "isSortable": true, 
+      "useFreeformFilter": true, 
       "valuesAndCounts": [
         [
           "3x3bitmaprect", 
       ]
     }
   }, 
+  "extraColumnOrder": [
+    "resultType", 
+    "builder", 
+    "test", 
+    "config"
+  ], 
   "header": {
     "dataHash": "-7804718549064096650", 
     "isEditable": false, 
     "isExported": true, 
-    "schemaVersion": 3
+    "schemaVersion": 4
     "timeNextUpdateAvailable": null, 
     "timeUpdated": 12345678, 
     "type": "all"