Remove class hierarchy for groups of changes with same result or configs

This simplifies the code, and lets us filter changes down to the lowest level

Change-Id: Id1d999d8fe8acecc1a7286507037ea612ca17453
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/118021
Reviewed-by: Jonas Termansen <sortie@google.com>
diff --git a/results_feed/lib/src/commit.dart b/results_feed/lib/src/commit.dart
index a86dcbc..527f472 100644
--- a/results_feed/lib/src/commit.dart
+++ b/results_feed/lib/src/commit.dart
@@ -92,16 +92,6 @@
   /// Sort in reverse chronological order.
   int compareTo(other) => (other as ChangeGroup).range.compareTo(range);
 
-  void addChanges(Iterable<Change> newChanges) {
-    newChanges.forEach(changes.add);
-    computeFilteredChanges(_filter);
-  }
-
-  void addLatestChanges(Iterable<Change> newChanges) {
-    newChanges.forEach(latestChanges.add);
-    computeFilteredChanges(_filter);
-  }
-
   bool show(Filter filter) =>
       filter.showAllCommits || filteredChanges(filter).isNotEmpty;
 
@@ -133,6 +123,7 @@
         summaries = _summarize(configurations);
 
   factory Configurations(List configs) {
+    if (configs.isEmpty) return null;
     configs.sort();
     return _instances.putIfAbsent(
         configs.join(' '), () => Configurations._(configs.toList()));
@@ -216,72 +207,73 @@
   String get resultStyle => result == expected ? 'success' : 'failure';
 }
 
-class ResultGroup with IterableMixin<Change> {
-  final String changesText;
-  final Map<String, Change> _map = SplayTreeMap();
+class Changes with IterableMixin<List<List<Change>>> {
+  final List<List<List<Change>>> changes;
 
-  ResultGroup(this.changesText);
+  Changes(Iterable<Change> newChanges) : this.grouped(group(newChanges));
 
-  get iterator => _map.values.iterator;
+  Changes.grouped(List<List<List<Change>>> this.changes);
 
-  void operator []=(Object resultText, Object change) {
-    _map[resultText] = change;
-  }
+  get iterator => changes.iterator;
 
-  bool show(Filter filter) =>
-      !filter.showLatestFailures || first.resultStyle == 'failure';
-}
-
-class ConfigGroup with IterableMixin<ResultGroup> {
-  final Configurations configurations;
-  final Map<String, ResultGroup> _map;
-
-  ConfigGroup(this.configurations, {Map<String, ResultGroup> map})
-      : _map = map ?? SplayTreeMap();
-
-  ResultGroup operator [](String resultText) =>
-      _map.putIfAbsent(resultText, () => ResultGroup(resultText));
-
-  get iterator => _map.values.iterator;
-
-  ConfigGroup filtered(Filter filter) {
-    if (!configurations.show(filter)) {
-      return ConfigGroup(configurations, map: {});
+  /// The changes, grouped first by Configurations object, then by
+  /// changesText (the change in the results). Should not be modified
+  /// after it is created by this call.
+  static List<List<List<Change>>> group(Iterable<Change> newChanges) {
+    final map = SplayTreeMap<String, SplayTreeMap<String, List<Change>>>();
+    for (final change in newChanges) {
+      map
+          .putIfAbsent(change.configurations.text,
+              () => SplayTreeMap<String, List<Change>>())
+          .putIfAbsent(change.changesText, () => List<Change>())
+          .add(change);
     }
-    final newMap =
-        Map.fromEntries(_map.entries.where((e) => e.value.show(filter)));
-    return newMap.length == _map.length
-        ? this
-        : ConfigGroup(configurations, map: newMap);
-  }
-}
-
-class Changes with IterableMixin<ConfigGroup> {
-  final Map<String, ConfigGroup> _map;
-
-  Changes(Iterable<Change> changes, {Map map}) : _map = map ?? SplayTreeMap() {
-    changes.forEach(add);
+    return [
+      for (final configuration in map.keys)
+        [
+          for (final change in map[configuration].keys)
+            map[configuration][change]
+        ]
+    ];
   }
 
-  get iterator => _map.values.iterator;
-
-  ConfigGroup operator [](Configurations configuration) =>
-      _map.putIfAbsent(configuration.text, () => ConfigGroup(configuration));
-
-  void add(Change change) {
-    this[change.configurations][change.changesText][change.name] = change;
-  }
+  static bool empty(x, f) => x.isEmpty;
 
   Changes filtered(Filter filter) {
-    final newMap = <String, ConfigGroup>{};
+    final newChanges =
+        applyFilter<List<List<Change>>>(configurationFilter, changes, filter);
+    return newChanges == changes ? this : Changes.grouped(newChanges);
+  }
+
+  /// Process a list by applying elementFilter to its elements, then throw
+  /// out empty or otherwise filtered elements with the emptyTest filter.
+  /// If no elements are modified or dropped, returns the input list object.
+  static applyFilter<T>(
+      T elementFilter(T t, Filter f), List<T> list, Filter filter,
+      [bool emptyTest(dynamic, Filter) = empty]) {
+    final newList = <T>[];
     bool changed = false;
-    for (final key in _map.keys) {
-      final newValue = _map[key].filtered(filter);
-      changed = changed || newValue != _map[key];
-      if (!newValue.isEmpty) {
-        newMap[key] = newValue;
-      }
+    for (final element in list) {
+      final T newElement = elementFilter(element, filter);
+      changed = changed || newElement != element;
+      if (!emptyTest(newElement, filter)) newList.add(newElement);
     }
-    return changed ? Changes([], map: newMap) : this;
+    return changed ? newList : list;
+  }
+
+  List<List<Change>> configurationFilter(
+      List<List<Change>> list, Filter filter) {
+    if (list.first.first.configurations.show(filter)) {
+      return applyFilter<List<Change>>(resultFilter, list, filter);
+    }
+    return [];
+  }
+
+  List<Change> resultFilter(List<Change> list, Filter filter) {
+    if (filter.showLatestFailures && list.first.resultStyle != 'failure') {
+      return [];
+    }
+    return applyFilter<Change>((c, f) => c, list, filter,
+        (change, filter) => filter.showUnapprovedOnly && !change.approved);
   }
 }
diff --git a/results_feed/lib/src/filter_service.dart b/results_feed/lib/src/filter_service.dart
index 61a345d..160c877 100644
--- a/results_feed/lib/src/filter_service.dart
+++ b/results_feed/lib/src/filter_service.dart
@@ -8,24 +8,30 @@
   final List<String> configurationGroups;
   final bool showAllCommits;
   final bool showLatestFailures;
+  final bool showUnapprovedOnly;
 
-  const Filter._(
-      this.configurationGroups, this.showAllCommits, this.showLatestFailures);
-  Filter(this.configurationGroups, showAllCommits, this.showLatestFailures)
+  const Filter._(this.configurationGroups, this.showAllCommits,
+      this.showLatestFailures, this.showUnapprovedOnly);
+  Filter(this.configurationGroups, showAllCommits, this.showLatestFailures,
+      this.showUnapprovedOnly)
       : showAllCommits = configurationGroups.isEmpty || showAllCommits;
 
   static const defaultFilter = Filter._(
-      allConfigurationGroups, defaultShowAllCommits, defaultShowLatestFailures);
-  static const noneFilter = Filter._(allConfigurationGroups, true, false);
+      allConfigurationGroups,
+      defaultShowAllCommits,
+      defaultShowLatestFailures,
+      defaultShowUnapprovedOnly);
 
   Filter copy(
           {List<String> configurationGroups,
           bool showAllCommits,
-          bool showLatestFailures}) =>
+          bool showLatestFailures,
+          bool showUnapprovedOnly}) =>
       Filter(
           configurationGroups ?? this.configurationGroups,
           showAllCommits ?? this.showAllCommits,
-          showLatestFailures ?? this.showLatestFailures);
+          showLatestFailures ?? this.showLatestFailures,
+          showUnapprovedOnly ?? this.showUnapprovedOnly);
 
   bool get allGroups =>
       configurationGroups.length == allConfigurationGroups.length;
@@ -67,6 +73,7 @@
 
   static const defaultShowAllCommits = true;
   static const defaultShowLatestFailures = false;
+  static const defaultShowUnapprovedOnly = false;
   static const allConfigurationGroups = <String>[
     'analyzer',
     'app_jitk',
diff --git a/results_feed/lib/src/results_panel.dart b/results_feed/lib/src/results_panel.dart
index 88259e4..25f45a2 100644
--- a/results_feed/lib/src/results_panel.dart
+++ b/results_feed/lib/src/results_panel.dart
@@ -45,6 +45,9 @@
   @Input()
   IntRange range;
 
+  Map<String, List<String>> summaries(List<List<Change>> group) =>
+      group.first.first.configurations.summaries;
+
   int resultLimit = 10;
 
   final preferredTooltipPositions = [
diff --git a/results_feed/lib/src/results_panel.html b/results_feed/lib/src/results_panel.html
index 578175e..9078f2b 100644
--- a/results_feed/lib/src/results_panel.html
+++ b/results_feed/lib/src/results_panel.html
@@ -3,7 +3,7 @@
     <template
         ngFor
         let-configuration
-        [ngForOf]="configurationGroup.configurations.summaries.keys">
+        [ngForOf]="summaries(configurationGroup).keys">
       <material-chip
           tooltipTarget
           #chip="tooltipTarget"
@@ -13,7 +13,7 @@
       </material-chip>
       <material-tooltip-card [for]="chip">
         <div *deferredContent>
-              <span *ngFor="let singleConfiguration of configurationGroup.configurations.summaries[configuration]">
+              <span *ngFor="let singleConfiguration of summaries(configurationGroup)[configuration]">
                 {{singleConfiguration}}<br>
               </span>
         </div>
@@ -24,7 +24,7 @@
       *ngFor="let resultGroup of configurationGroup"
       style="margin-left: 16px">
         <span [class]="resultGroup.first.resultStyle">
-          {{resultGroup.changesText}}
+          {{resultGroup.first.changesText}}
         </span><br>
     <span *ngFor="let change of resultGroup.take(resultLimit)"
           tooltipTarget #logs="tooltipTarget"
diff --git a/results_feed/lib/src/results_selector_panel.dart b/results_feed/lib/src/results_selector_panel.dart
index 8299599..d3a1fb6 100644
--- a/results_feed/lib/src/results_selector_panel.dart
+++ b/results_feed/lib/src/results_selector_panel.dart
@@ -48,9 +48,15 @@
   @Input()
   set changes(Changes changes) {
     _changes = changes;
-    selectableChanges = [
-      for (ConfigGroup c in changes) SelectableConfigurationGroup(c, this)
-    ];
+    for (final configurationGroup in changes) {
+      configurationCheckboxes[configurationGroup] = FixedMixedCheckbox();
+      for (final resultGroup in configurationGroup) {
+        resultCheckboxes[resultGroup] = FixedMixedCheckbox();
+        for (final change in resultGroup) {
+          checked[change] = true;
+        }
+      }
+    }
     initializeSelected();
   }
 
@@ -58,6 +64,9 @@
   ChangeGroup commit;
 
   @Input()
+  IntRange range;
+
+  @Input()
   set selected(Set<Change> selected) {
     _selected = selected;
     initializeSelected();
@@ -65,7 +74,10 @@
 
   Changes _changes;
   Set<Change> _selected;
-  List<SelectableConfigurationGroup> selectableChanges;
+
+  final checked = Map<Change, bool>();
+  final resultCheckboxes = Map<List<Change>, FixedMixedCheckbox>();
+  final configurationCheckboxes = Map<List<List<Change>>, FixedMixedCheckbox>();
   int resultLimit = 10;
 
   Changes get changes => _changes;
@@ -75,89 +87,58 @@
     RelativePosition.OffsetTopLeft
   ];
 
+  Map<String, List<String>> summaries(List<List<Change>> group) =>
+      group.first.first.configurations.summaries;
+
   void initializeSelected() {
     if (_selected != null && _changes != null) {
-      _selected.addAll([
-        for (final c in selectableChanges)
-          for (SelectableResultGroup r in c.resultGroups)
-            for (SelectableChange h in r.changes) h.change
-      ]);
+      _selected.addAll(checked.keys);
     }
   }
-}
 
-class SelectableChange {
-  final Change change;
-  bool selected = true;
-  final SelectableResultGroup resultGroup;
-  final SelectableConfigurationGroup configurationGroup;
-  final ResultsSelectorPanel panel;
-
-  SelectableChange(
-      this.change, this.resultGroup, this.configurationGroup, this.panel);
-
-  void onChange(bool event) {
-    if (selected == event) return;
-    selected = event;
+  bool setCheckbox(Change change, bool event) {
+    if (checked[change] == event) return false;
+    checked[change] = event;
     if (event) {
-      panel._selected.add(this.change);
+      _selected.add(change);
     } else {
-      panel._selected.remove(this.change);
+      _selected.remove(change);
     }
-    resultGroup.checkbox.setMixed();
-    configurationGroup.checkbox.setMixed();
-  }
-}
-
-class SelectableResultGroup {
-  List<SelectableChange> changes;
-  SelectableConfigurationGroup configurationGroup;
-  FixedMixedCheckbox checkbox = FixedMixedCheckbox();
-
-  SelectableResultGroup(
-      ResultGroup group, this.configurationGroup, ResultsSelectorPanel panel) {
-    changes = [
-      for (final change in group)
-        SelectableChange(change, this, configurationGroup, panel)
-    ];
+    return true;
   }
 
-  void onChange(String event) {
+  void onChange(bool event, Change change, List<Change> resultGroup,
+      List<List<Change>> configurationGroup) {
+    if (setCheckbox(change, event)) {
+      configurationCheckboxes[configurationGroup].setMixed();
+      resultCheckboxes[resultGroup].setMixed();
+    }
+  }
+
+  void onResultChange(String event, List<Change> resultGroup,
+      List<List<Change>> configurationGroup) {
+    final checkbox = resultCheckboxes[resultGroup];
     if (checkbox.eventMatchesState(event)) return;
     assert(event != 'mixed');
     bool newChecked = event == 'true';
     checkbox.setState(newChecked, false);
-    for (final change in changes) {
-      change.selected = newChecked;
+    for (final change in resultGroup) {
+      setCheckbox(change, newChecked);
     }
-    configurationGroup.checkbox.setMixed();
-  }
-}
-
-class SelectableConfigurationGroup {
-  List<SelectableResultGroup> resultGroups;
-  FixedMixedCheckbox checkbox = FixedMixedCheckbox();
-
-  SelectableConfigurationGroup(
-      ConfigGroup configGroup, ResultsSelectorPanel panel) {
-    resultGroups = [
-      for (final resultGroup in configGroup)
-        SelectableResultGroup(resultGroup, this, panel)
-    ];
+    configurationCheckboxes[configurationGroup].setMixed();
   }
 
-  get summaries =>
-      resultGroups.first.changes.first.change.configurations.summaries;
-
-  void onChange(String event) {
+  void onConfigurationChange(
+      String event, List<List<Change>> configurationGroup) {
+    final checkbox = configurationCheckboxes[configurationGroup];
     if (checkbox.eventMatchesState(event)) return;
     assert(event != 'mixed');
     bool newChecked = event == 'true';
     checkbox.setState(newChecked, false);
-    for (final group in resultGroups) {
-      group.checkbox.setState(newChecked, false);
-      for (final change in group.changes) {
-        change.selected = newChecked;
+    for (final subgroup in configurationGroup) {
+      resultCheckboxes[subgroup].setState(newChecked, false);
+      for (final change in subgroup) {
+        setCheckbox(change, newChecked);
       }
     }
   }
@@ -166,6 +147,7 @@
 class FixedMixedCheckbox {
   bool checked = true;
   bool indeterminate = false;
+
   // Model change indeterminate <-> checked generates a bad 'unchecked' event.
   // Workaround for issue https://github.com/dart-lang/angular_components/issues/434
   bool expectBadEvent = false;
diff --git a/results_feed/lib/src/results_selector_panel.html b/results_feed/lib/src/results_selector_panel.html
index f3d8c14..c8daf2f 100644
--- a/results_feed/lib/src/results_selector_panel.html
+++ b/results_feed/lib/src/results_selector_panel.html
@@ -1,15 +1,15 @@
-<div *ngFor="let configurationGroup of selectableChanges">
+<div *ngFor="let configurationGroup of changes">
   <material-chips>
     <material-checkbox
         class="result-checkbox"
-        [checked]="configurationGroup.checkbox.checked"
-        (change)="configurationGroup.onChange($event)"
-        [indeterminate]="configurationGroup.checkbox.indeterminate">
+        [checked]="configurationCheckboxes[configurationGroup].checked"
+        [indeterminate]="configurationCheckboxes[configurationGroup].indeterminate"
+        (change)="onConfigurationChange($event, configurationGroup)">
     </material-checkbox>
     <template
         ngFor
         let-configuration
-        [ngForOf]="configurationGroup.summaries.keys">
+        [ngForOf]="summaries(configurationGroup).keys">
       <material-chip
           tooltipTarget
           #chip="tooltipTarget"
@@ -19,53 +19,53 @@
       </material-chip>
       <material-tooltip-card [for]="chip">
         <div *deferredContent>
-              <span *ngFor="let singleConfiguration of configurationGroup.summaries[configuration]">
+              <span *ngFor="let singleConfiguration of summaries(configurationGroup)[configuration]">
                 {{singleConfiguration}}<br>
               </span>
         </div>
       </material-tooltip-card>
     </template>
   </material-chips>
-  <span *ngFor="let resultGroup of configurationGroup.resultGroups"
+  <span *ngFor="let resultGroup of configurationGroup"
         style="margin-left: 16px">
     <material-checkbox
         no-ink
-        [checked]="resultGroup.checkbox.checked"
-        (change)="resultGroup.onChange($event)"
-        [indeterminate]="resultGroup.checkbox.indeterminate">
+        [checked]="resultCheckboxes[resultGroup].checked"
+        (change)="onResultChange($event, resultGroup, configurationGroup)"
+        [indeterminate]="resultCheckboxes[resultGroup].indeterminate">
     </material-checkbox>
-    <span [class]="resultGroup.changes.first.change.resultStyle">
-          {{resultGroup.changes.first.change.changesText}}
+    <span [class]="resultGroup.first.resultStyle">
+          {{resultGroup.first.changesText}}
     </span><br>
-    <span class="nonbreaking" *ngFor="let change of resultGroup.changes.take(resultLimit)"
+    <span class="nonbreaking" *ngFor="let change of resultGroup.take(resultLimit)"
           tooltipTarget
           #logs="tooltipTarget"
           style="cursor: pointer; margin-left: 16px; height: 16px">
       <material-checkbox
-              no-ink
-              [checked]="change.selected"
-              (checkedChange)="change.onChange($event)">
+          no-ink
+          [checked]="checked[change]"
+          (checkedChange)="onChange($event, change, resultGroup, configurationGroup)">
       </material-checkbox>
-      <span tooltipTarget #logs="tooltipTarget">{{change.change.name}}</span><br>
+      <span tooltipTarget #logs="tooltipTarget">{{change.name}}</span><br>
       <material-tooltip-card
-              [for]="logs"
-              [preferredPositions]="preferredTooltipPositions">
+          [for]="logs"
+          [preferredPositions]="preferredTooltipPositions">
         <div *deferredContent>
           <h4>Logs</h4>
           <dart-log
-                  *ngFor="let configuration of change.change.configurations.configurations"
-                  [configuration]="configuration"
-                  [index]="commit.range.end"
-                  [test]="change.change.name">
+              *ngFor="let configuration of change.configurations.configurations"
+              [configuration]="configuration"
+              [index]="range.end"
+              [test]="change.name">
           </dart-log>
         </div>
       </material-tooltip-card>
     </span>
     <div
-        *ngIf="resultGroup.changes.length > resultLimit"
-        (click)="resultLimit = resultGroup.changes.length"
+        *ngIf="resultGroup.length > resultLimit"
+        (click)="resultLimit = resultGroup.length"
         style="cursor: pointer">
-      &vellip; {{resultGroup.changes.length - resultLimit}} more changed results
+      &vellip; {{resultGroup.length - resultLimit}} more changed results
     </div>
   </span>
 </div>