[results feed] Add new filter UI using chips in top bar

Replace the existing filter UI popup with chips in a bar at the top.
This also supports filtering on a test name and on individual configurations.

Change-Id: I6b1bf2af21d3a947018c9a36253e0d41c416cb4c
Fixes: https://github.com/dart-lang/dart_ci/issues/88
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/146180
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/results_feed/lib/src/components/app.html b/results_feed/lib/src/components/app.html
index 852b4ca..5d16881 100644
--- a/results_feed/lib/src/components/app.html
+++ b/results_feed/lib/src/components/app.html
@@ -1,4 +1,4 @@
-<header class="shadow blue">
+<header class="blue">
   <material-button
       icon
       class="material-drawer-button"
@@ -6,7 +6,7 @@
     <material-icon icon="filter_list"></material-icon>
   </material-button>
   <span class="title">{{title}}</span>
-  <span class="space"></span>
+  <results-filter></results-filter>
   <span *ngIf="fetching != null">
     <material-icon icon="autorenew"></material-icon>
     fetching results...
@@ -17,6 +17,10 @@
     {{loginMessage}}
   </span>
 </header>
+<header class="shadow">
+  <filter-row></filter-row>
+</header>
+
 <div class="results-feed-body">
   <modal [(visible)]="showFilter">
     <material-dialog
diff --git a/results_feed/lib/src/components/app_component.css b/results_feed/lib/src/components/app_component.css
index 6ebbf59..7d0bd18 100644
--- a/results_feed/lib/src/components/app_component.css
+++ b/results_feed/lib/src/components/app_component.css
@@ -7,8 +7,11 @@
 header {
     flex: none;
     width: 100%;
+    min-height: 50px;
     margin: 0px;
     z-index: 1;
+    display: flex;
+    align-items: center;
 }
 
 header.shadow {
@@ -16,10 +19,6 @@
     0 1px 10px 0 rgba(0, 0, 0, 0.12);
 }
 
-header span.space {
-    flex: 1;
-}
-
 header span.button {
     font-variant: small-caps;
 }
@@ -36,6 +35,10 @@
     color: white;
 }
 
+results-filter {
+    flex: 1;
+}
+
 .results-feed-body {
     flex: auto;
     overflow-y: scroll;
diff --git a/results_feed/lib/src/components/app_component.dart b/results_feed/lib/src/components/app_component.dart
index e382b84..8017c8a 100644
--- a/results_feed/lib/src/components/app_component.dart
+++ b/results_feed/lib/src/components/app_component.dart
@@ -15,12 +15,15 @@
 import 'package:angular_components/material_icon/material_icon.dart';
 import 'package:angular_components/material_toggle/material_toggle.dart';
 import 'package:angular_router/angular_router.dart';
-import 'package:dart_results_feed/src/services/filter_component.dart';
+import 'package:dart_results_feed/src/components/results_filter_component.dart';
 
 import 'commit_component.dart';
+import 'filter_row_component.dart';
+import 'results_filter_component.dart';
 import '../formatting.dart';
 import '../model/commit.dart';
 import '../model/comment.dart';
+import '../services/filter_component.dart';
 import '../services/filter_service.dart';
 import '../services/firestore_service.dart';
 import '../services/build_service.dart';
@@ -33,12 +36,14 @@
       AutoDismissDirective,
       CommitComponent,
       FilterComponent,
+      FilterRowComponent,
       MaterialIconComponent,
       MaterialButtonComponent,
       MaterialDialogComponent,
       MaterialTemporaryDrawerComponent,
       MaterialToggleComponent,
-      ModalComponent
+      ModalComponent,
+      ResultsFilterComponent,
     ],
     providers: [
       ClassProvider(FilterService),
diff --git a/results_feed/lib/src/components/filter_row_component.css b/results_feed/lib/src/components/filter_row_component.css
new file mode 100644
index 0000000..a0993b4
--- /dev/null
+++ b/results_feed/lib/src/components/filter_row_component.css
@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+ * for details. All rights reserved. Use of this source code is governed by a
+ * BSD-style license that can be found in the LICENSE file.
+ */
+
+material-chip {
+    display: inline-flex;
+}
+
+material-auto-suggest-input {
+    height: 24px;
+    margin-left: 8px;
+}
+
+material-auto-suggest-input ::ng-deep material-input {
+    padding: 0px;
+}
+
+material-icon {
+    color: rgb(63, 81, 181);
+}
\ No newline at end of file
diff --git a/results_feed/lib/src/components/filter_row_component.dart b/results_feed/lib/src/components/filter_row_component.dart
new file mode 100644
index 0000000..ec63789
--- /dev/null
+++ b/results_feed/lib/src/components/filter_row_component.dart
@@ -0,0 +1,94 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:angular/angular.dart';
+import 'package:angular_components/angular_components.dart';
+import 'package:angular_components/material_input/material_auto_suggest_input.dart';
+import 'package:angular_components/material_chips/material_chip.dart';
+import 'package:angular_components/material_chips/material_chips.dart';
+import 'package:angular_components/material_icon/material_icon.dart';
+
+import '../services/build_service.dart';
+import '../services/filter_service.dart';
+
+const allConfigurationGroups = Filter.allConfigurationGroups;
+
+@Component(
+    selector: 'filter-row',
+    exports: [allConfigurationGroups],
+    directives: [
+      coreDirectives,
+      MaterialAutoSuggestInputComponent,
+      MaterialChipComponent,
+      MaterialChipsComponent,
+      MaterialIconComponent,
+      NgModel,
+    ],
+    providers: [
+      popupBindings,
+    ],
+    templateUrl: 'filter_row_component.html',
+    styleUrls: [
+      'package:angular_components/app_layout/layout.scss.css',
+      'filter_row_component.css'
+    ])
+class FilterRowComponent implements OnInit {
+  @ViewChild('addFilterTextInput')
+  MaterialAutoSuggestInputComponent addFilterTextInput;
+
+  final FilterService service;
+  final BuildService buildService;
+  bool addingFilter = false;
+  bool addingTestFilter = false;
+  String filterText = '';
+  List<String> selectionOptions = [];
+
+  Filter get filter => service.filter;
+
+  FilterRowComponent(this.service, this.buildService);
+
+  void ngOnInit() async {
+    final configurations = await buildService.configurations;
+    selectionOptions = ['(any test name)']
+        .followedBy({
+          for (final configuration in configurations)
+            configuration.split('-').first + '-'
+        })
+        .followedBy(configurations)
+        .toList();
+  }
+
+  String get test => filter.singleTest;
+
+  Iterable<String> get configurations =>
+      filter.configurationGroups.followedBy(filter.configurations).toList();
+
+  void addFilter() {
+    addingFilter = true;
+    Future.delayed(Duration(), () => addFilterTextInput.focus());
+  }
+
+  void clear() {
+    filterText = '';
+    addingFilter = false;
+    addingTestFilter = false;
+  }
+
+  void selectionChange(event) {
+    if (event is String) {
+      service.addConfiguration(event);
+      clear();
+    }
+  }
+
+  void inputTextChange(event) {
+    filterText = event;
+    addingTestFilter = event is String && event.contains('/');
+  }
+
+  void addTestFilter() {
+    service.setTestFilter(filterText);
+    clear();
+  }
+}
diff --git a/results_feed/lib/src/components/filter_row_component.html b/results_feed/lib/src/components/filter_row_component.html
new file mode 100644
index 0000000..8c581ed
--- /dev/null
+++ b/results_feed/lib/src/components/filter_row_component.html
@@ -0,0 +1,34 @@
+<material-chip
+    *ngIf="test != null"
+    (remove)="service.clearTestFilter()">
+  t: {{test}}
+</material-chip>
+<material-chip
+    *ngFor="let configuration of configurations"
+    (remove)="service.removeConfiguration(configuration)">
+  c: {{configuration}}
+</material-chip>
+<material-chip
+    *ngIf="!addingFilter"
+    [removable]="false"
+    (click)="addFilter()">
+  Add Filter
+</material-chip>
+<material-icon
+    *ngIf="addingTestFilter"
+    baseline
+    icon="check_circle"
+    (click)="addTestFilter()">
+</material-icon>
+<material-auto-suggest-input
+    #addFilterTextInput
+    *ngIf="addingFilter"
+    [displayBottomPanel]="false"
+    [closeOnEnter]="true"
+    [showClearIcon]="true"
+    [selectionOptions]="selectionOptions"
+    [inputText]="filterText"
+    (inputTextChange)="inputTextChange($event)"
+    (selectionChange)="selectionChange($event)"
+    (clear)="clear()">
+</material-auto-suggest-input>
diff --git a/results_feed/lib/src/components/results_filter_component.dart b/results_feed/lib/src/components/results_filter_component.dart
new file mode 100644
index 0000000..1abf2c5
--- /dev/null
+++ b/results_feed/lib/src/components/results_filter_component.dart
@@ -0,0 +1,76 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:angular/angular.dart';
+import 'package:angular_components/angular_components.dart';
+import 'package:angular_components/material_chips/material_chip.dart';
+import 'package:angular_components/material_chips/material_chips.dart';
+
+import '../services/filter_service.dart';
+
+const allConfigurationGroups = Filter.allConfigurationGroups;
+
+@Component(selector: 'results-filter', exports: [
+  allConfigurationGroups
+], directives: [
+  coreDirectives,
+  MaterialChipComponent,
+  MaterialChipsComponent,
+], template: '''
+<span></span>
+<span
+    *ngFor="let type of resultTypes"
+    [class.selected]="type == selectedType"
+    (click)="select(type)">
+  {{type}}
+</span>
+<span></span>
+''', styles: [
+  '''
+  :host {
+    display: flex;
+    height: 100%;
+    box-sizing: border-box;}
+  span {
+    color: lightgray;
+    display: inline-flex;
+    font-variant: small-caps;
+    font-size: 20px;
+    font-weight: bold;
+    align-items: center;
+    justify-content: center;
+    height: auto;
+    border-bottom-style: solid;
+    border-width: medium;
+    border-color: transparent;
+    flex: 1 1 auto;
+  }
+  span.selected {
+    color: white;
+    border-color: white;
+  }'''
+])
+class ResultsFilterComponent {
+  static const allResults = 'all results';
+  static const activeFailures = 'active failures';
+  static const unapprovedFailures = 'unapproved failures';
+  static const resultTypes = [allResults, activeFailures, unapprovedFailures];
+
+  final FilterService service;
+  Filter get filter => service.filter;
+
+  ResultsFilterComponent(this.service);
+
+  String get selectedType => filter.showLatestFailures
+      ? filter.showUnapprovedOnly ? unapprovedFailures : activeFailures
+      : allResults;
+
+  void select(String type) {
+    if (type == selectedType) return;
+    service.filter = filter.copy(
+        showLatestFailures: type != allResults,
+        showUnapprovedOnly: type == unapprovedFailures)
+      ..updateUrl();
+  }
+}
diff --git a/results_feed/lib/src/services/build_service.dart b/results_feed/lib/src/services/build_service.dart
index 16e9f29..808c677 100644
--- a/results_feed/lib/src/services/build_service.dart
+++ b/results_feed/lib/src/services/build_service.dart
@@ -28,16 +28,19 @@
   BuildService(this._firestoreService);
 
   final FirestoreService _firestoreService;
-  final Map<String, Map<int, FutureOr<Build>>> _lookupBuild = {};
-  Future<Map<String, String>> _builders;
-  Future fetchingBuilders;
+  final Map<String, Map<int, Future<Build>>> _builds = {};
+  Map<String, String> _builders;
+  List<String> _configurations;
+  Future _buildersFetched;
 
-  FutureOr<Build> buildForResult(String configuration, int index) async {
-    _builders ??= _fetchBuilders();
-    final builder = (await _builders)[configuration];
-    final builds = _lookupBuild.putIfAbsent(builder, () => {});
+  Future get _ready => _buildersFetched ??= _fetchBuilders();
 
-    return builds.putIfAbsent(index, _fetchBuild(builder, index));
+  Future<Build> buildForResult(String configuration, int index) async {
+    await _ready;
+    final builder = _builders[configuration];
+    return _builds
+        .putIfAbsent(builder, () => {})
+        .putIfAbsent(index, _fetchBuild(builder, index));
   }
 
   Future<Build> Function() _fetchBuild(String builder, int index) => () async {
@@ -46,9 +49,13 @@
         return Build.fromDocument(buildDocument);
       };
 
-  Future<Map<String, String>> _fetchBuilders() async {
+  Future _fetchBuilders() async {
     await _firestoreService.getFirebaseClient();
     final builderDocs = await _firestoreService.fetchBuilders();
-    return {for (var doc in builderDocs) doc.id: doc.get('builder')};
+    _builders = {for (var doc in builderDocs) doc.id: doc.get('builder')};
   }
+
+  FutureOr<List<String>> get configurations =>
+      _configurations ??
+      _ready.then((_) => _configurations = _builders.keys.toList());
 }
diff --git a/results_feed/lib/src/services/filter_component.dart b/results_feed/lib/src/services/filter_component.dart
index e8497a2..81c9be5 100644
--- a/results_feed/lib/src/services/filter_component.dart
+++ b/results_feed/lib/src/services/filter_component.dart
@@ -68,8 +68,7 @@
 
   void onSelectionChange(_) {
     final values = groupSelector.selectedValues.toList();
-    service.filter = filter.copy(configurationGroups: values);
-    filter.updateUrl();
+    service.changeFilter(filter.copy(configurationGroups: values));
   }
 
   List<Map> changeTypes = [
@@ -94,10 +93,9 @@
 
   void setChangeType(Map event) {
     changeType = event;
-    service.filter = filter.copy(
+    service.changeFilter(filter.copy(
         showLatestFailures: event['latestFailures'],
-        showUnapprovedOnly: event['unapprovedOnly']);
-    filter.updateUrl();
+        showUnapprovedOnly: event['unapprovedOnly']));
   }
 
   String identityFunction(t) => t;
diff --git a/results_feed/lib/src/services/filter_service.dart b/results_feed/lib/src/services/filter_service.dart
index bef557e..2dc2e02 100644
--- a/results_feed/lib/src/services/filter_service.dart
+++ b/results_feed/lib/src/services/filter_service.dart
@@ -11,14 +11,17 @@
   final bool showUnapprovedOnly;
   final String singleTest; // null by default.
 
-  const Filter._(this.configurations, this.configurationGroups,
-      this.showLatestFailures, this.showUnapprovedOnly, this.singleTest);
   Filter(this.configurations, this.configurationGroups, this.showLatestFailures,
       this.showUnapprovedOnly, this.singleTest);
 
-  static const defaultFilter = Filter._(
+  static final defaultFilter = Filter(
       [], [], defaultShowLatestFailures, defaultShowUnapprovedOnly, null);
 
+  bool get hasFilter =>
+      singleTest != null ||
+      configurations.isNotEmpty ||
+      configurationGroups.isNotEmpty;
+
   Filter copy(
           {List<String> configurations,
           List<String> configurationGroups,
@@ -32,6 +35,9 @@
           showUnapprovedOnly ?? this.showUnapprovedOnly,
           singleTest ?? this.singleTest); // Cannot reset singleTest to null.
 
+  Filter clearTest() => Filter(configurations, configurationGroups,
+      showLatestFailures, showUnapprovedOnly, null);
+
   String fragment() => [
         if (showLatestFailures != defaultShowLatestFailures)
           'showLatestFailures=$showLatestFailures',
@@ -94,4 +100,38 @@
   FilterService();
 
   Filter filter = Filter.fromUrl();
+
+  void changeFilter(Filter newFilter) {
+    newFilter.updateUrl();
+    if (newFilter.singleTest != filter.singleTest) {
+      window.location.reload();
+    }
+    filter = newFilter;
+  }
+
+  void addConfiguration(String configuration) {
+    if (configuration.endsWith('-')) {
+      changeFilter(filter.copy(
+          configurationGroups: [configuration, ...filter.configurationGroups]));
+    } else {
+      changeFilter(filter
+          .copy(configurations: [configuration, ...filter.configurations]));
+    }
+  }
+
+  void removeConfiguration(String configuration) {
+    changeFilter(filter.copy(
+        configurationGroups: List.from(filter.configurationGroups)
+          ..remove(configuration),
+        configurations: List.from(filter.configurations)
+          ..remove(configuration)));
+  }
+
+  void setTestFilter(String test) {
+    changeFilter(filter.copy(singleTest: test));
+  }
+
+  void clearTestFilter() {
+    changeFilter(filter.clearTest());
+  }
 }