Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/framework/table_view_helpers.py b/framework/table_view_helpers.py
new file mode 100644
index 0000000..3fa07c2
--- /dev/null
+++ b/framework/table_view_helpers.py
@@ -0,0 +1,793 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""Classes and functions for displaying lists of project artifacts.
+
+This file exports classes TableRow and TableCell that help
+represent HTML table rows and cells.  These classes make rendering
+HTML tables that list project artifacts much easier to do with EZT.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import collections
+import itertools
+import logging
+
+from functools import total_ordering
+
+import ezt
+
+from framework import framework_constants
+from framework import template_helpers
+from framework import timestr
+from proto import tracker_pb2
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+
+
+def ComputeUnshownColumns(results, shown_columns, config, built_in_cols):
+  """Return a list of unshown columns that the user could add.
+
+  Args:
+    results: list of search result PBs. Each must have labels.
+    shown_columns: list of column names to be used in results table.
+    config: harmonized config for the issue search, including all
+        well known labels and custom fields.
+    built_in_cols: list of other column names that are built into the tool.
+      E.g., star count, or creation date.
+
+  Returns:
+    List of column names to append to the "..." menu.
+  """
+  unshown_set = set()  # lowercases column names
+  unshown_list = []  # original-case column names
+  shown_set = {col.lower() for col in shown_columns}
+  labels_already_seen = set()  # whole labels, original case
+
+  def _MaybeAddLabel(label_name):
+    """Add the key part of the given label if needed."""
+    if label_name.lower() in labels_already_seen:
+      return
+    labels_already_seen.add(label_name.lower())
+    if '-' in label_name:
+      col, _value = label_name.split('-', 1)
+      _MaybeAddCol(col)
+
+  def _MaybeAddCol(col):
+    if col.lower() not in shown_set and col.lower() not in unshown_set:
+      unshown_list.append(col)
+      unshown_set.add(col.lower())
+
+  # The user can always add any of the default columns.
+  for col in config.default_col_spec.split():
+    _MaybeAddCol(col)
+
+  # The user can always add any of the built-in columns.
+  for col in built_in_cols:
+    _MaybeAddCol(col)
+
+  # The user can add a column for any well-known labels
+  for wkl in config.well_known_labels:
+    _MaybeAddLabel(wkl.label)
+
+  phase_names = set(itertools.chain.from_iterable(
+      (phase.name.lower() for phase in result.phases) for result in results))
+  # The user can add a column for any custom field
+  field_ids_alread_seen = set()
+  for fd in config.field_defs:
+    field_lower = fd.field_name.lower()
+    field_ids_alread_seen.add(fd.field_id)
+    if fd.is_phase_field:
+      for name in phase_names:
+        phase_field_col = name + '.' + field_lower
+        if (phase_field_col not in shown_set and
+            phase_field_col not in unshown_set):
+          unshown_list.append(phase_field_col)
+          unshown_set.add(phase_field_col)
+    elif field_lower not in shown_set and field_lower not in unshown_set:
+      unshown_list.append(fd.field_name)
+      unshown_set.add(field_lower)
+
+    if fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+      approval_lower_approver = (
+          field_lower + tracker_constants.APPROVER_COL_SUFFIX)
+      if (approval_lower_approver not in shown_set and
+          approval_lower_approver not in unshown_set):
+        unshown_list.append(
+            fd.field_name + tracker_constants.APPROVER_COL_SUFFIX)
+        unshown_set.add(approval_lower_approver)
+
+  # The user can add a column for any key-value label or field in the results.
+  for r in results:
+    for label_name in tracker_bizobj.GetLabels(r):
+      _MaybeAddLabel(label_name)
+    for field_value in r.field_values:
+      if field_value.field_id not in field_ids_alread_seen:
+        field_ids_alread_seen.add(field_value.field_id)
+        fd = tracker_bizobj.FindFieldDefByID(field_value.field_id, config)
+        if fd:  # could be None for a foreign field, which we don't display.
+          field_lower = fd.field_name.lower()
+          if field_lower not in shown_set and field_lower not in unshown_set:
+            unshown_list.append(fd.field_name)
+            unshown_set.add(field_lower)
+
+  return sorted(unshown_list)
+
+
+def ExtractUniqueValues(columns, artifact_list, users_by_id,
+                        config, related_issues, hotlist_context_dict=None):
+  """Build a nested list of unique values so the user can auto-filter.
+
+  Args:
+    columns: a list of lowercase column name strings, which may contain
+        combined columns like "priority/pri".
+    artifact_list: a list of artifacts in the complete set of search results.
+    users_by_id: dict mapping user_ids to UserViews.
+    config: ProjectIssueConfig PB for the current project.
+    related_issues: dict {issue_id: issue} of pre-fetched related issues.
+    hotlist_context_dict: dict for building a hotlist grid table
+
+  Returns:
+    [EZTItem(col1, colname1, [val11, val12,...]), ...]
+    A list of EZTItems, each of which has a col_index, column_name,
+    and a list of unique values that appear in that column.
+  """
+  column_values = {col_name: {} for col_name in columns}
+
+  # For each combined column "a/b/c", add entries that point from "a" back
+  # to "a/b/c", from "b" back to "a/b/c", and from "c" back to "a/b/c".
+  combined_column_parts = collections.defaultdict(list)
+  for col in columns:
+    if '/' in col:
+      for col_part in col.split('/'):
+        combined_column_parts[col_part].append(col)
+
+  unique_labels = set()
+  for art in artifact_list:
+    unique_labels.update(tracker_bizobj.GetLabels(art))
+
+  for label in unique_labels:
+    if '-' in label:
+      col, val = label.split('-', 1)
+      col = col.lower()
+      if col in column_values:
+        column_values[col][val.lower()] = val
+      if col in combined_column_parts:
+        for combined_column in combined_column_parts[col]:
+          column_values[combined_column][val.lower()] = val
+    else:
+      if 'summary' in column_values:
+        column_values['summary'][label.lower()] = label
+
+  # TODO(jrobbins): Consider refacting some of this to tracker_bizobj
+  # or a new builtins.py to reduce duplication.
+  if 'reporter' in column_values:
+    for art in artifact_list:
+      reporter_id = art.reporter_id
+      if reporter_id and reporter_id in users_by_id:
+        reporter_username = users_by_id[reporter_id].display_name
+        column_values['reporter'][reporter_username] = reporter_username
+
+  if 'owner' in column_values:
+    for art in artifact_list:
+      owner_id = tracker_bizobj.GetOwnerId(art)
+      if owner_id and owner_id in users_by_id:
+        owner_username = users_by_id[owner_id].display_name
+        column_values['owner'][owner_username] = owner_username
+
+  if 'cc' in column_values:
+    for art in artifact_list:
+      cc_ids = tracker_bizobj.GetCcIds(art)
+      for cc_id in cc_ids:
+        if cc_id and cc_id in users_by_id:
+          cc_username = users_by_id[cc_id].display_name
+          column_values['cc'][cc_username] = cc_username
+
+  if 'component' in column_values:
+    for art in artifact_list:
+      all_comp_ids = list(art.component_ids) + list(art.derived_component_ids)
+      for component_id in all_comp_ids:
+        cd = tracker_bizobj.FindComponentDefByID(component_id, config)
+        if cd:
+          column_values['component'][cd.path] = cd.path
+
+  if 'stars' in column_values:
+    for art in artifact_list:
+      star_count = art.star_count
+      column_values['stars'][star_count] = star_count
+
+  if 'status' in column_values:
+    for art in artifact_list:
+      status = tracker_bizobj.GetStatus(art)
+      if status:
+        column_values['status'][status.lower()] = status
+
+  if 'project' in column_values:
+    for art in artifact_list:
+      project_name = art.project_name
+      column_values['project'][project_name] = project_name
+
+  if 'mergedinto' in column_values:
+    for art in artifact_list:
+      if art.merged_into and art.merged_into != 0:
+        merged_issue = related_issues[art.merged_into]
+        merged_issue_ref = tracker_bizobj.FormatIssueRef((
+            merged_issue.project_name, merged_issue.local_id))
+        column_values['mergedinto'][merged_issue_ref] = merged_issue_ref
+
+  if 'blocked' in column_values:
+    for art in artifact_list:
+      if art.blocked_on_iids:
+        column_values['blocked']['is_blocked'] = 'Yes'
+      else:
+        column_values['blocked']['is_not_blocked'] = 'No'
+
+  if 'blockedon' in column_values:
+    for art in artifact_list:
+      if art.blocked_on_iids:
+        for blocked_on_iid in art.blocked_on_iids:
+          blocked_on_issue = related_issues[blocked_on_iid]
+          blocked_on_ref = tracker_bizobj.FormatIssueRef((
+              blocked_on_issue.project_name, blocked_on_issue.local_id))
+          column_values['blockedon'][blocked_on_ref] = blocked_on_ref
+
+  if 'blocking' in column_values:
+    for art in artifact_list:
+      if art.blocking_iids:
+        for blocking_iid in art.blocking_iids:
+          blocking_issue = related_issues[blocking_iid]
+          blocking_ref = tracker_bizobj.FormatIssueRef((
+              blocking_issue.project_name, blocking_issue.local_id))
+          column_values['blocking'][blocking_ref] = blocking_ref
+
+  if 'added' in column_values:
+    for art in artifact_list:
+      if hotlist_context_dict and hotlist_context_dict[art.issue_id]:
+        issue_dict = hotlist_context_dict[art.issue_id]
+        date_added = issue_dict['date_added']
+        column_values['added'][date_added] = date_added
+
+  if 'adder' in column_values:
+    for art in artifact_list:
+      if hotlist_context_dict and hotlist_context_dict[art.issue_id]:
+        issue_dict = hotlist_context_dict[art.issue_id]
+        adder_id = issue_dict['adder_id']
+        adder = users_by_id[adder_id].display_name
+        column_values['adder'][adder] = adder
+
+  if 'note' in column_values:
+    for art in artifact_list:
+      if hotlist_context_dict and hotlist_context_dict[art.issue_id]:
+        issue_dict = hotlist_context_dict[art.issue_id]
+        note = issue_dict['note']
+        if issue_dict['note']:
+          column_values['note'][note] = note
+
+  if 'attachments' in column_values:
+    for art in artifact_list:
+      attachment_count = art.attachment_count
+      column_values['attachments'][attachment_count] = attachment_count
+
+  # Add all custom field values if the custom field name is a shown column.
+  field_id_to_col = {}
+  for art in artifact_list:
+    for fv in art.field_values:
+      field_col, field_type = field_id_to_col.get(fv.field_id, (None, None))
+      if field_col == 'NOT_SHOWN':
+        continue
+      if field_col is None:
+        fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
+        if not fd:
+          field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
+          continue
+        field_col = fd.field_name.lower()
+        field_type = fd.field_type
+        if field_col not in column_values:
+          field_id_to_col[fv.field_id] = 'NOT_SHOWN', None
+          continue
+        field_id_to_col[fv.field_id] = field_col, field_type
+
+      if field_type == tracker_pb2.FieldTypes.ENUM_TYPE:
+        continue  # Already handled by label parsing
+      elif field_type == tracker_pb2.FieldTypes.INT_TYPE:
+        val = fv.int_value
+      elif field_type == tracker_pb2.FieldTypes.STR_TYPE:
+        val = fv.str_value
+      elif field_type == tracker_pb2.FieldTypes.USER_TYPE:
+        user = users_by_id.get(fv.user_id)
+        val = user.email if user else framework_constants.NO_USER_NAME
+      elif field_type == tracker_pb2.FieldTypes.DATE_TYPE:
+        val = fv.int_value  # TODO(jrobbins): convert to date
+      elif field_type == tracker_pb2.FieldTypes.BOOL_TYPE:
+        val = 'Yes' if fv.int_value else 'No'
+
+      column_values[field_col][val] = val
+
+  # TODO(jrobbins): make the capitalization of well-known unique label and
+  # status values match the way it is written in the issue config.
+
+  # Return EZTItems for each column in left-to-right display order.
+  result = []
+  for i, col_name in enumerate(columns):
+    # TODO(jrobbins): sort each set of column values top-to-bottom, by the
+    # order specified in the project artifact config. For now, just sort
+    # lexicographically to make expected output defined.
+    sorted_col_values = sorted(column_values[col_name].values())
+    result.append(template_helpers.EZTItem(
+        col_index=i, column_name=col_name, filter_values=sorted_col_values))
+
+  return result
+
+
+def MakeTableData(
+    visible_results, starred_items, lower_columns, lower_group_by,
+    users_by_id, cell_factories, id_accessor, related_issues,
+    viewable_iids_set, config, context_for_all_issues=None):
+  """Return a list of list row objects for display by EZT.
+
+  Args:
+    visible_results: list of artifacts to display on one pagination page.
+    starred_items: list of IDs/names of items in the current project
+        that the signed in user has starred.
+    lower_columns: list of column names to display, all lowercase.  These can
+        be combined column names, e.g., 'priority/pri'.
+    lower_group_by: list of column names that define row groups, all lowercase.
+    users_by_id: dict mapping user IDs to UserViews.
+    cell_factories: dict of functions that each create TableCell objects.
+    id_accessor: function that maps from an artifact to the ID/name that might
+        be in the starred items list.
+    related_issues: dict {issue_id: issue} of pre-fetched related issues.
+    viewable_iids_set: set of issue ids that can be viewed by the user.
+    config: ProjectIssueConfig PB for the current project.
+    context_for_all_issues: A dictionary of dictionaries containing values
+        passed in to cell factory functions to create TableCells. Dictionary
+        form: {issue_id: {'rank': issue_rank, 'issue_info': info_value, ..},
+        issue_id: {'rank': issue_rank}, ..}
+
+  Returns:
+    A list of TableRow objects, one for each visible result.
+  """
+  table_data = []
+
+  group_cell_factories = [
+      ChooseCellFactory(group.strip('-'), cell_factories, config)
+      for group in lower_group_by]
+
+  # Make a list of cell factories, one for each column.
+  factories_to_use = [
+      ChooseCellFactory(col, cell_factories, config) for col in lower_columns]
+
+  current_group = None
+  for idx, art in enumerate(visible_results):
+    row = MakeRowData(
+        art, lower_columns, users_by_id, factories_to_use, related_issues,
+        viewable_iids_set, config, context_for_all_issues)
+    row.starred = ezt.boolean(id_accessor(art) in starred_items)
+    row.idx = idx  # EZT does not have loop counters, so add idx.
+    table_data.append(row)
+    row.group = None
+
+    # Also include group information for the first row in each group.
+    # TODO(jrobbins): This seems like more overhead than we need for the
+    # common case where no new group heading row is to be inserted.
+    group = MakeRowData(
+        art, [group_name.strip('-') for group_name in lower_group_by],
+        users_by_id, group_cell_factories, related_issues, viewable_iids_set,
+        config, context_for_all_issues)
+    for cell, group_name in zip(group.cells, lower_group_by):
+      cell.group_name = group_name
+    if group == current_group:
+      current_group.rows_in_group += 1
+    else:
+      row.group = group
+      current_group = group
+      current_group.rows_in_group = 1
+
+  return table_data
+
+
+def MakeRowData(
+    art, columns, users_by_id, cell_factory_list, related_issues,
+    viewable_iids_set, config, context_for_all_issues):
+  """Make a TableRow for use by EZT when rendering HTML table of results.
+
+  Args:
+    art: a project artifact PB
+    columns: list of lower-case column names
+    users_by_id: dictionary {user_id: UserView} with each UserView having
+        a "display_name" member.
+    cell_factory_list: list of functions that each create TableCell
+        objects for a given column.
+    related_issues: dict {issue_id: issue} of pre-fetched related issues.
+    viewable_iids_set: set of issue ids that can be viewed by the user.
+    config: ProjectIssueConfig PB for the current project.
+    context_for_all_issues: A dictionary of dictionaries containing values
+        passed in to cell factory functions to create TableCells. Dictionary
+        form: {issue_id: {'rank': issue_rank, 'issue_info': info_value, ..},
+        issue_id: {'rank': issue_rank}, ..}
+
+  Returns:
+    A TableRow object for use by EZT to render a table of results.
+  """
+  if context_for_all_issues is None:
+    context_for_all_issues = {}
+  ordered_row_data = []
+  non_col_labels = []
+  label_values = collections.defaultdict(list)
+
+  flattened_columns = set()
+  for col in columns:
+    if '/' in col:
+      flattened_columns.update(col.split('/'))
+    else:
+      flattened_columns.add(col)
+
+  # Group all "Key-Value" labels by key, and separate the "OneWord" labels.
+  _AccumulateLabelValues(
+      art.labels, flattened_columns, label_values, non_col_labels)
+
+  _AccumulateLabelValues(
+      art.derived_labels, flattened_columns, label_values,
+      non_col_labels, is_derived=True)
+
+  # Build up a list of TableCell objects for this row.
+  for i, col in enumerate(columns):
+    factory = cell_factory_list[i]
+    kw = {
+        'col': col,
+        'users_by_id': users_by_id,
+        'non_col_labels': non_col_labels,
+        'label_values': label_values,
+        'related_issues': related_issues,
+        'viewable_iids_set': viewable_iids_set,
+        'config': config,
+        }
+    kw.update(context_for_all_issues.get(art.issue_id, {}))
+    new_cell = factory(art, **kw)
+    new_cell.col_index = i
+    ordered_row_data.append(new_cell)
+
+  return TableRow(ordered_row_data)
+
+
+def _AccumulateLabelValues(
+    labels, columns, label_values, non_col_labels, is_derived=False):
+  """Parse OneWord and Key-Value labels for display in a list page.
+
+  Args:
+    labels: a list of label strings.
+    columns: a list of column names.
+    label_values: mutable dictionary {key: [value, ...]} of label values
+        seen so far.
+    non_col_labels: mutable list of OneWord labels seen so far.
+    is_derived: true if these labels were derived via rules.
+
+  Returns:
+    Nothing.  But, the given label_values dictionary will grow to hold
+    the values of the key-value labels passed in, and the non_col_labels
+    list will grow to hold the OneWord labels passed in.  These are shown
+    in label columns, and in the summary column, respectively
+  """
+  for label_name in labels:
+    if '-' in label_name:
+      parts = label_name.split('-')
+      for pivot in range(1, len(parts)):
+        column_name = '-'.join(parts[:pivot])
+        value = '-'.join(parts[pivot:])
+        column_name = column_name.lower()
+        if column_name in columns:
+          label_values[column_name].append((value, is_derived))
+    else:
+      non_col_labels.append((label_name, is_derived))
+
+
+@total_ordering
+class TableRow(object):
+  """A tiny auxiliary class to represent a row in an HTML table."""
+
+  def __init__(self, cells):
+    """Initialize the table row with the given data."""
+    self.cells = cells
+    # Used by MakeTableData for layout.
+    self.idx = None
+    self.group = None
+    self.rows_in_group = None
+    self.starred = None
+
+  def __eq__(self, other):
+    """A row is == if each cell is == to the cells in the other row."""
+    return other and self.cells == other.cells
+
+  def __ne__(self, other):
+    return not other and self.cells != other.cells
+
+  def __lt__(self, other):
+    return other and self.cells < other.cells
+
+  def DebugString(self):
+    """Return a string that is useful for on-page debugging."""
+    return 'TR(%s)' % self.cells
+
+
+# TODO(jrobbins): also add unsortable... or change this to a list of operations
+# that can be done.
+CELL_TYPE_ID = 'ID'
+CELL_TYPE_SUMMARY = 'summary'
+CELL_TYPE_ATTR = 'attr'
+CELL_TYPE_UNFILTERABLE = 'unfilterable'
+CELL_TYPE_NOTE = 'note'
+CELL_TYPE_PROJECT = 'project'
+CELL_TYPE_URL = 'url'
+CELL_TYPE_ISSUES = 'issues'
+
+
+@total_ordering
+class TableCell(object):
+  """Helper class to represent a table cell when rendering using EZT."""
+
+  # Should instances of this class be rendered with whitespace:nowrap?
+  # Subclasses can override this constant.
+  NOWRAP = ezt.boolean(True)
+
+  def __init__(self, cell_type, explicit_values,
+               derived_values=None, non_column_labels=None, align='',
+               sort_values=True):
+    """Store all the given data for later access by EZT."""
+    self.type = cell_type
+    self.align = align
+    self.col_index = 0  # Is set afterward
+    self.values = []
+    if non_column_labels:
+      self.non_column_labels = [
+          template_helpers.EZTItem(value=v, is_derived=ezt.boolean(d))
+          for v, d in non_column_labels]
+    else:
+      self.non_column_labels = []
+
+    for v in (sorted(explicit_values) if sort_values else explicit_values):
+      self.values.append(CellItem(v))
+
+    if derived_values:
+      for v in (sorted(derived_values) if sort_values else derived_values):
+        self.values.append(CellItem(v, is_derived=True))
+
+  def __eq__(self, other):
+    """A row is == if each cell is == to the cells in the other row."""
+    return other and self.values == other.values
+
+  def __ne__(self, other):
+    return not other and self.values != other.values
+
+  def __lt__(self, other):
+    return other and self.values < other.values
+
+  def DebugString(self):
+    return 'TC(%r, %r, %r)' % (
+        self.type,
+        [v.DebugString() for v in self.values],
+        self.non_column_labels)
+
+
+def CompositeFactoryTableCell(factory_col_list_arg):
+  """Cell factory that combines multiple cells in a combined column."""
+
+  class FactoryClass(TableCell):
+    factory_col_list = factory_col_list_arg
+
+    def __init__(self, art, **kw):
+      TableCell.__init__(self, CELL_TYPE_UNFILTERABLE, [])
+
+      for sub_factory, sub_col in self.factory_col_list:
+        kw['col'] = sub_col
+        sub_cell = sub_factory(art, **kw)
+        self.non_column_labels.extend(sub_cell.non_column_labels)
+        self.values.extend(sub_cell.values)
+  return FactoryClass
+
+
+def CompositeColTableCell(columns_to_combine, cell_factories, config):
+  """Cell factory that combines multiple cells in a combined column."""
+  factory_col_list = []
+  for sub_col in columns_to_combine:
+    sub_factory = ChooseCellFactory(sub_col, cell_factories, config)
+    factory_col_list.append((sub_factory, sub_col))
+  return CompositeFactoryTableCell(factory_col_list)
+
+
+@total_ordering
+class CellItem(object):
+  """Simple class to display one part of a table cell's value, with style."""
+
+  def __init__(self, item, is_derived=False):
+    self.item = item
+    self.is_derived = ezt.boolean(is_derived)
+
+  def __eq__(self, other):
+    """A row is == if each cell is == to the item in the other row."""
+    return other and self.item == other.item
+
+  def __ne__(self, other):
+    return not other and self.item != other.item
+
+  def __lt__(self, other):
+    return other and self.item < other.item
+
+  def DebugString(self):
+    if self.is_derived:
+      return 'CI(derived: %r)' % self.item
+    else:
+      return 'CI(%r)' % self.item
+
+
+class TableCellKeyLabels(TableCell):
+  """TableCell subclass specifically for showing user-defined label values."""
+
+  def __init__(self, _art, col=None, label_values=None,  **_kw):
+    label_value_pairs = label_values.get(col, [])
+    explicit_values = [value for value, is_derived in label_value_pairs
+                       if not is_derived]
+    derived_values = [value for value, is_derived in label_value_pairs
+                      if is_derived]
+    TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values,
+                       derived_values=derived_values)
+
+
+class TableCellProject(TableCell):
+  """TableCell subclass for showing an artifact's project name."""
+
+  def __init__(self, art, **_kw):
+    TableCell.__init__(
+        self, CELL_TYPE_PROJECT, [art.project_name])
+
+
+class TableCellStars(TableCell):
+  """TableCell subclass for showing an artifact's star count."""
+
+  def __init__(self, art, **_kw):
+    TableCell.__init__(
+        self, CELL_TYPE_ATTR, [art.star_count], align='right')
+
+
+class TableCellSummary(TableCell):
+  """TableCell subclass for showing an artifact's summary."""
+
+  def __init__(self, art, non_col_labels=None, **_kw):
+    TableCell.__init__(
+        self, CELL_TYPE_SUMMARY, [art.summary],
+        non_column_labels=non_col_labels)
+
+
+class TableCellDate(TableCell):
+  """TableCell subclass for showing any kind of date timestamp."""
+
+  # Make instances of this class render with whitespace:nowrap.
+  NOWRAP = ezt.boolean(True)
+
+  def __init__(self, timestamp, days_only=False):
+    values = []
+    if timestamp:
+      date_str = timestr.FormatRelativeDate(timestamp, days_only=days_only)
+      if not date_str:
+        date_str = timestr.FormatAbsoluteDate(timestamp)
+      values = [date_str]
+
+    TableCell.__init__(self, CELL_TYPE_UNFILTERABLE, values)
+
+
+class TableCellCustom(TableCell):
+  """Abstract TableCell subclass specifically for showing custom fields."""
+
+  def __init__(self, art, col=None, users_by_id=None, config=None, **_kw):
+    explicit_values = []
+    derived_values = []
+    cell_type = CELL_TYPE_ATTR
+    phase_names_by_id = {
+        phase.phase_id: phase.name.lower() for phase in art.phases}
+    phase_name = None
+    # Check if col represents a phase field value in the form <phase>.<field>
+    if '.' in col:
+      phase_name, col = col.split('.', 1)
+    for fv in art.field_values:
+      # TODO(jrobbins): for cross-project search this could be a list.
+      fd = tracker_bizobj.FindFieldDefByID(fv.field_id, config)
+      if not fd:
+        # TODO(jrobbins): This can happen if an issue with a custom
+        # field value is moved to a different project.
+        logging.warn('Issue ID %r has undefined field value %r',
+                     art.issue_id, fv)
+      elif fd.field_name.lower() == col and (
+          phase_names_by_id.get(fv.phase_id) == phase_name):
+        if fd.field_type == tracker_pb2.FieldTypes.URL_TYPE:
+          cell_type = CELL_TYPE_URL
+        if fd.field_type == tracker_pb2.FieldTypes.STR_TYPE:
+          self.NOWRAP = ezt.boolean(False)
+        val = tracker_bizobj.GetFieldValue(fv, users_by_id)
+        if fv.derived:
+          derived_values.append(val)
+        else:
+          explicit_values.append(val)
+
+    TableCell.__init__(self, cell_type, explicit_values,
+                       derived_values=derived_values)
+
+  def ExtractValue(self, fv, _users_by_id):
+    return 'field-id-%d-not-implemented-yet' % fv.field_id
+
+class TableCellApprovalStatus(TableCell):
+  """Abstract TableCell subclass specifically for showing approval fields."""
+
+  def __init__(self, art, col=None, config=None, **_kw):
+    explicit_values = []
+    for av in art.approval_values:
+      fd = tracker_bizobj.FindFieldDef(col, config)
+      ad = tracker_bizobj.FindApprovalDef(col, config)
+      if not (ad and fd):
+        logging.warn('Issue ID %r has undefined field value %r',
+                     art.issue_id, av)
+      elif av.approval_id == fd.field_id:
+        explicit_values.append(av.status.name)
+        break
+
+    TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values)
+
+
+class TableCellApprovalApprover(TableCell):
+  """TableCell subclass specifically for showing approval approvers."""
+
+  def __init__(self, art, col=None, config=None, users_by_id=None, **_kw):
+    explicit_values = []
+    approval_name = col[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
+    for av in art.approval_values:
+      fd = tracker_bizobj.FindFieldDef(approval_name, config)
+      ad = tracker_bizobj.FindApprovalDef(approval_name, config)
+      if not (ad and fd):
+        logging.warn('Issue ID %r has undefined field value %r',
+                     art.issue_id, av)
+      elif av.approval_id == fd.field_id:
+        explicit_values = [users_by_id.get(approver_id).display_name
+                           for approver_id in av.approver_ids
+                           if users_by_id.get(approver_id)]
+        break
+
+    TableCell.__init__(self, CELL_TYPE_ATTR, explicit_values)
+
+def ChooseCellFactory(col, cell_factories, config):
+  """Return the CellFactory to use for the given column."""
+  if col in cell_factories:
+    return cell_factories[col]
+
+  if '/' in col:
+    return CompositeColTableCell(col.split('/'), cell_factories, config)
+
+  is_approver_col = False
+  possible_field_name = col
+  if col.endswith(tracker_constants.APPROVER_COL_SUFFIX):
+    possible_field_name = col[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
+    is_approver_col = True
+  # Check if col represents a phase field value in the form <phase>.<field>
+  elif '.' in possible_field_name:
+    possible_field_name = possible_field_name.split('.')[-1]
+
+  fd = tracker_bizobj.FindFieldDef(possible_field_name, config)
+  if fd:
+    # We cannot assume that non-enum_type field defs do not share their
+    # names with label prefixes. So we need to group them with
+    # TableCellKeyLabels to make sure we catch appropriate labels values.
+    if fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
+      if is_approver_col:
+        # Combined cell for 'FieldName-approver' to hold approvers
+        # belonging to FieldName and values belonging to labels with
+        # 'FieldName-approver' as the key.
+        return CompositeFactoryTableCell(
+          [(TableCellApprovalApprover, col), (TableCellKeyLabels, col)])
+      return CompositeFactoryTableCell(
+          [(TableCellApprovalStatus, col), (TableCellKeyLabels, col)])
+    elif fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
+      return CompositeFactoryTableCell(
+          [(TableCellCustom, col), (TableCellKeyLabels, col)])
+
+  return TableCellKeyLabels