Add approval entry UI to try results

Change-Id: Ibc3c14bac1b53de2a1c77fc5ec21e3c8630421cc
Reviewed-on: https://dart-review.googlesource.com/c/dart_ci/+/126520
Reviewed-by: Alexander Thomas <athom@google.com>
diff --git a/.gitignore b/.gitignore
index 0faa90f..98b9306 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@
 functions/node_modules
 github-label-notifier/functions/node_modules
 results_feed/results_feed.iml
+results_feed/test/page_objects/*.g.dart
diff --git a/results_feed/lib/src/components/app_component.dart b/results_feed/lib/src/components/app_component.dart
index e3fc2d2..575bdc9 100644
--- a/results_feed/lib/src/components/app_component.dart
+++ b/results_feed/lib/src/components/app_component.dart
@@ -271,7 +271,7 @@
 
   AppComponentTest(this.appComponent);
 
-  StagingFirestoreService get firestoreService =>
+  TestingFirestoreService get firestoreService =>
       appComponent._firestoreService;
   ApplicationRef get applicationRef => appComponent._applicationRef;
 }
diff --git a/results_feed/lib/src/components/blamelist_component.html b/results_feed/lib/src/components/blamelist_component.html
index 2c83999..b900c21 100644
--- a/results_feed/lib/src/components/blamelist_component.html
+++ b/results_feed/lib/src/components/blamelist_component.html
@@ -13,8 +13,9 @@
     </div>
   </div>
   <div *ngFor="let c of comments" class="comment">
-    <b>{{c.approvedText()}}</b> {{formattedEmail(c.author)}}
-    <span class="nowrap">{{formattedDate(c.created)}}</span><br>
+    <b>{{c.approvedText()}}</b>
+    <span class="nowrap">{{formattedDate(c.created)}}</span>
+    {{formattedEmail(c.author)}}<br>
     <div class="comment-body">{{c.comment}}</div>
   </div>
 </div>
diff --git a/results_feed/lib/src/components/blamelist_picker.html b/results_feed/lib/src/components/blamelist_picker.html
index f821658..b953576 100644
--- a/results_feed/lib/src/components/blamelist_picker.html
+++ b/results_feed/lib/src/components/blamelist_picker.html
@@ -12,8 +12,8 @@
   </material-radio>
   <div *ngFor="let c of comments" class="comment">
     <b>{{c.approvedText()}}</b>
-    {{formattedEmail(c.author)}}
     <span class="nowrap">{{formattedDate(c.created)}}</span><br>
+    {{formattedEmail(c.author)}}
     <div class="comment-body">{{c.comment}}</div>
   </div>
 </material-radio-group>
diff --git a/results_feed/lib/src/components/commit_component.dart b/results_feed/lib/src/components/commit_component.dart
index 0804900..4b076b6 100644
--- a/results_feed/lib/src/components/commit_component.dart
+++ b/results_feed/lib/src/components/commit_component.dart
@@ -73,12 +73,12 @@
     return firestoreService.saveApproval(
         approval,
         commentText,
-        [for (Change result in selected) result.id],
         changeGroup.comments.isEmpty
             ? null
             : changeGroup.comments.last.baseComment ??
                 changeGroup.comments.last.id,
-        changeGroup.range.start,
-        changeGroup.range.end);
+        resultIds: [for (Change result in selected) result.id],
+        blamelistStart: changeGroup.range.start,
+        blamelistEnd: changeGroup.range.end);
   }
 }
diff --git a/results_feed/lib/src/components/results_selector_panel.html b/results_feed/lib/src/components/results_selector_panel.html
index 0e25759..762ba52 100644
--- a/results_feed/lib/src/components/results_selector_panel.html
+++ b/results_feed/lib/src/components/results_selector_panel.html
@@ -53,6 +53,7 @@
         {{approvalContent(change)}}{{change.name}}
       </span><br>
       <material-tooltip-card
+          *ngIf="range.length > 0"
           [for]="logs"
           [preferredPositions]="preferredTooltipPositions">
         <div *deferredContent>
diff --git a/results_feed/lib/src/components/try_results_component.dart b/results_feed/lib/src/components/try_results_component.dart
index 19b242b..09de21d 100644
--- a/results_feed/lib/src/components/try_results_component.dart
+++ b/results_feed/lib/src/components/try_results_component.dart
@@ -3,20 +3,37 @@
 // 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_button/material_button.dart';
+import 'package:angular_components/material_input/material_input.dart';
+import 'package:angular_components/material_input/material_input_multiline.dart';
+import 'package:angular_forms/angular_forms.dart' show formDirectives;
 import 'package:angular_router/angular_router.dart';
 
+import '../formatting.dart';
 import '../model/comment.dart';
 import '../model/commit.dart';
-import 'results_panel.dart';
 import '../services/try_data_service.dart';
+import 'results_panel.dart';
+import 'results_selector_panel.dart';
 
 @Component(
     selector: 'try-results',
-    directives: [coreDirectives, routerDirectives, ResultsPanel],
+    directives: [
+      coreDirectives,
+      formDirectives,
+      materialInputDirectives,
+      MaterialButtonComponent,
+      MaterialMultilineInputComponent,
+      ResultsPanel,
+      ResultsSelectorPanel
+    ],
     providers: [
       ClassProvider(TryDataService),
+      overlayBindings,
     ],
     templateUrl: 'try_results_component.html',
+    exports: [formattedDate, formattedEmail],
     styles: ['div {padding: 8px;}'])
 class TryResultsComponent implements OnActivate {
   TryDataService _tryDataService;
@@ -33,9 +50,41 @@
   IntRange range = IntRange(1, 0);
   bool updating = false;
   bool updatePending = false;
+  bool _approving = false;
+  final selected = Set<Change>();
+  String commentText;
 
   TryResultsComponent(this._tryDataService, this._applicationRef);
 
+  bool get approveEnabled => changeGroup.changes.flat
+      .any((change) => change.result != change.expected);
+
+  bool get approving => _approving;
+  set approving(bool approve) {
+    if (approve) {
+      _tryDataService.logIn().then((_) {
+        _approving = _tryDataService.isLoggedIn;
+      });
+    } else {
+      _approving = false;
+    }
+  }
+
+  Future approve(bool approval) {
+    for (Change result in selected) {
+      result.approved = approval ?? result.approved;
+    }
+    return _tryDataService.saveApproval(
+        approval,
+        commentText,
+        changeGroup.comments.isEmpty
+            ? null
+            : changeGroup.comments.last.baseComment ??
+                changeGroup.comments.last.id,
+        [for (Change result in selected) result.id],
+        changeInfo.review);
+  }
+
   void tryUpdate() async {
     if (updating) {
       updatePending = true;
diff --git a/results_feed/lib/src/components/try_results_component.html b/results_feed/lib/src/components/try_results_component.html
index 8681726..9c56399 100644
--- a/results_feed/lib/src/components/try_results_component.html
+++ b/results_feed/lib/src/components/try_results_component.html
@@ -5,13 +5,63 @@
   </h2>
 
   <h2 *ngIf="changeGroup.changes.isEmpty">No changed test results</h2>
-  <results-panel
+  <results-panel *ngIf="!approving"
       [changes]="changeGroup.changes"
       [range]="range">
   </results-panel>
+  <results-selector-panel *ngIf="approving"
+      [changes]="changeGroup.changes"
+      [range]="range"
+      [selected]="selected"
+      failuresOnly>
+  </results-selector-panel>
   <div *ngFor="let c of changeGroup.comments">
-    <b>{{c.approvedText()}}</b> {{c.author}} {{formatted(c.created)}}<br>
+    <b>{{c.approvedText()}}</b>
+    <span class="nowrap">{{formattedDate(c.created)}}</span>
+    {{formattedEmail(c.author)}}<br>
     <div style="padding-left: 32px">{{c.comment}}</div>
   </div>
+  <div *ngIf="approving">
+    Comment:<br>
+    <material-input
+        multiline
+        style="width: 80%"
+        label="Comment"
+        required
+        rows="3"
+        [(ngModel)]="commentText"></material-input>
+  </div>
+  <div>
+    <material-button
+        *ngIf="approveEnabled && !approving"
+        raised
+        (click)="approving = true">
+      Approve/Comment ...
+    </material-button>
+    <material-button
+        *ngIf="approving"
+        raised
+        (click)="approving = false">
+      Cancel
+    </material-button>
+    <material-button
+        *ngIf="approving"
+        raised
+        (click)="approve(false)">
+      Revoke Selected Approvals
+    </material-button>
+    <material-button
+        *ngIf="approving"
+        raised
+        (click)="approve(null)">
+      Comment without Approving
+    </material-button>
+    <material-button
+        *ngIf="approving"
+        raised
+        (click)="approve(true)">
+      Approve
+    </material-button>
+  </div>
 
 </div>
\ No newline at end of file
diff --git a/results_feed/lib/src/services/firestore_service.dart b/results_feed/lib/src/services/firestore_service.dart
index 5daf670..1a3882d 100644
--- a/results_feed/lib/src/services/firestore_service.dart
+++ b/results_feed/lib/src/services/firestore_service.dart
@@ -135,29 +135,41 @@
     }
   }
 
-  Future saveApproval(bool approve, String comment, List<String> resultIds,
-      String baseComment, blamelistStart, blamelistEnd) async {
-    app.firestore().collection('comments').add({
+  Future saveApproval(bool approve, String comment, String baseComment,
+      {List<String> resultIds,
+      List<String> tryResultIds,
+      int blamelistStart,
+      int blamelistEnd,
+      int review}) async {
+    await app.firestore().collection('comments').add({
       'author': app.auth().currentUser.email,
       if (comment != null) 'comment': comment,
       'created': DateTime.now(),
       if (approve != null) 'approved': approve,
-      'results': resultIds,
-      'blamelist_start_index': blamelistStart,
-      'blamelist_end_index': blamelistEnd
+      if (resultIds != null) 'results': resultIds,
+      if (tryResultIds != null) 'try_results': tryResultIds,
+      if (blamelistStart != null) 'blamelist_start_index': blamelistStart,
+      if (blamelistStart != null) 'blamelist_end_index': blamelistEnd,
+      if (review != null) 'gerrit_change': review
     });
-    // Update approved field in results documents.
     if (approve == null) return;
-    // If resultIds.length > 500, break into multiple batches.
-    for (final results in iterables.partition(resultIds, 500)) {
-      final batch = app.firestore().batch();
-      final collection = app.firestore().collection('results');
-      for (final id in results) {
-        batch
-            .update(collection.doc(id), fieldsAndValues: ['approved', approve]);
+    // Update approved field in results documents.
+    Future<void> approveResults(List<String> ids, String collectionName) async {
+      if (ids == null) return;
+      // If resultIds.length > 500, break into multiple batches.
+      for (final results in iterables.partition(ids, 500)) {
+        final batch = app.firestore().batch();
+        final collection = app.firestore().collection(collectionName);
+        for (final id in results) {
+          batch.update(collection.doc(id),
+              fieldsAndValues: ['approved', approve]);
+        }
+        await batch.commit();
       }
-      await batch.commit();
     }
+
+    await approveResults(resultIds, 'results');
+    await approveResults(tryResultIds, 'try_results');
   }
 
   Future<void> getFirebaseClient() async {
@@ -177,9 +189,6 @@
 }
 
 class StagingFirestoreService extends FirestoreService {
-  // StagingFirestoreService uses the Firestore database at
-  // dart-ci-staging.
-  // It adds additional methods used by integration tests.
   Future<void> getFirebaseClient() async {
     if (app != null) return;
     // Firebase api key is public, and must be sent to client for use.
@@ -193,9 +202,34 @@
         projectId: "dart-ci-staging",
         storageBucket: "dart-ci-staging.appspot.com");
     await app.auth().setPersistence(firebase.Persistence.LOCAL);
+  }
+}
+
+class TestingFirestoreService extends StagingFirestoreService {
+  // TestingFirestoreService uses the Firestore database at
+  // dart-ci-staging.
+  // It adds additional methods used by integration tests.
+  // It includes local password-based authentication for
+  // tests that write to staging.
+  Future<void> getFirebaseClient() async {
+    await super.getFirebaseClient();
     await logIn();
   }
 
+  Future logIn() async {
+    try {
+      await app.auth().signInWithEmailAndPassword(
+          'dartresultsfeedtestuser@example.com', r'');
+      // Password must be entered locally before testing.
+      // Because this is running in a browser, cannot read password from
+      // a file or environment variable. Investigate what testing framework
+      // supports for local secrets.
+    } catch (e) {
+      print(e.toString());
+    }
+    return;
+  }
+
   Future<void> writeDocumentsFrom(Map<String, dynamic> documents,
       {bool delete = false}) async {
     for (final keys in iterables.partition(documents.keys, 500)) {
@@ -226,18 +260,4 @@
       await batch.commit();
     }
   }
-
-  Future logIn() async {
-    try {
-      await app.auth().signInWithEmailAndPassword(
-          'dartresultsfeedtestuser@example.com', r'');
-      // Password must be entered locally before testing.
-      // Because this is running in a browser, cannot read password from
-      // a file or environment variable. Investigate what testing framework
-      // supports for local secrets.
-    } catch (e) {
-      print(e.toString());
-    }
-    return;
-  }
 }
diff --git a/results_feed/lib/src/services/try_data_service.dart b/results_feed/lib/src/services/try_data_service.dart
index 30dd346..e74c1fa 100644
--- a/results_feed/lib/src/services/try_data_service.dart
+++ b/results_feed/lib/src/services/try_data_service.dart
@@ -8,6 +8,9 @@
   final FirestoreService _firestoreService;
   TryDataService(this._firestoreService);
 
+  Future logIn() => _firestoreService.logIn();
+  bool get isLoggedIn => _firestoreService.isLoggedIn;
+
   Future<List<Change>> changes(ReviewInfo changeInfo, int patch) async {
     final patchsets = changeInfo.patchsets;
     if (patchsets.length < patch) return [];
@@ -39,6 +42,11 @@
     final docs = await _firestoreService.fetchCommentsForReview(review);
     return [for (final doc in docs) Comment.fromDocument(doc)];
   }
+
+  Future saveApproval(bool approve, String comment, String baseComment,
+          Iterable<String> resultIds, int review) =>
+      _firestoreService.saveApproval(approve, comment, baseComment,
+          tryResultIds: resultIds, review: review);
 }
 
 class ReviewInfo {
diff --git a/results_feed/test/comments_test.dart b/results_feed/test/comments_test.dart
index 5b2b8e7..8970e5e 100644
--- a/results_feed/test/comments_test.dart
+++ b/results_feed/test/comments_test.dart
@@ -21,7 +21,7 @@
 // pub run build_runner test --fail-on-severe -- -p chrome comments_test.dart
 
 @GenerateInjector([
-  ClassProvider(FirestoreService, useClass: StagingFirestoreService),
+  ClassProvider(FirestoreService, useClass: TestingFirestoreService),
 ])
 final InjectorFactory rootInjector = self.rootInjector$Injector;
 
diff --git a/results_feed/web/main.dart b/results_feed/web/main.dart
index 906d70e..bceb6d7 100644
--- a/results_feed/web/main.dart
+++ b/results_feed/web/main.dart
@@ -9,7 +9,8 @@
     as ng;
 import 'main.template.dart' as self;
 
-// Local testing use @GenerateInjector(routerProvidersHash)
+// Local testing use
+/// @GenerateInjector([ClassProvider(FirestoreService, useClass: TestingFirestoreService), ...routerProvidersHash])
 // Use for deploying on staging website:
 // @GenerateInjector([ClassProvider(FirestoreService, useClass: StagingFirestoreService), ...routerProviders])