| // Copyright (c) 2019, 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 'dart:collection'; |
| import 'dart:html'; |
| |
| import 'package:angular/angular.dart'; |
| import 'package:angular_components/angular_components.dart'; |
| import 'package:angular_components/app_layout/material_temporary_drawer.dart'; |
| import 'package:angular_components/laminate/components/modal/modal.dart'; |
| import 'package:angular_components/laminate/overlay/module.dart'; |
| import 'package:angular_components/material_button/material_button.dart'; |
| import 'package:angular_components/material_dialog/material_dialog.dart'; |
| 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 'commit_component.dart'; |
| import '../model/commit.dart'; |
| import '../model/comment.dart'; |
| import '../services/filter_service.dart'; |
| import '../services/firestore_service.dart'; |
| import '../services/build_service.dart'; |
| |
| @Component( |
| selector: 'my-app', |
| pipes: [commonPipes], |
| directives: [ |
| coreDirectives, |
| AutoDismissDirective, |
| CommitComponent, |
| FilterComponent, |
| MaterialIconComponent, |
| MaterialButtonComponent, |
| MaterialDialogComponent, |
| MaterialTemporaryDrawerComponent, |
| MaterialToggleComponent, |
| ModalComponent |
| ], |
| providers: [ |
| ClassProvider(FilterService), |
| ClassProvider(BuildService), |
| overlayBindings |
| ], |
| templateUrl: 'app.html', |
| styleUrls: [ |
| 'package:angular_components/app_layout/layout.scss.css', |
| 'app_component.css' |
| ]) |
| class AppComponent implements OnInit, CanReuse { |
| String title = 'Dart Results Feed'; |
| |
| Map<IntRange, ChangeGroup> changeGroups = SplayTreeMap(reverse); |
| Map<int, Commit> commits = SplayTreeMap(reverse); |
| Map<IntRange, List<Comment>> comments = SplayTreeMap(reverse); |
| Map<String, Map<IntRange, List<Change>>> changesByName = {}; |
| Map<String, Map<IntRange, List<Change>>> liveChangesByName = {}; |
| Map<IntRange, List<Change>> changes = SplayTreeMap(reverse); |
| Map<IntRange, List<Change>> liveChanges = SplayTreeMap(reverse); |
| Set<String> modifiedNames = Set(); |
| Set<IntRange> modifiedRanges = Set(); |
| |
| int firstIndex; |
| int lastIndex; |
| Future fetching; |
| num infiniteScrollVisibleRatio = 0; |
| bool showFilter = false; |
| |
| ApplicationRef _applicationRef; |
| FirestoreService _firestoreService; |
| FilterService filterService; |
| |
| AppComponent( |
| this._firestoreService, this.filterService, this._applicationRef); |
| |
| @ViewChild("infiniteScroll") |
| Element infiniteScroll; |
| |
| @override |
| void ngOnInit() async { |
| await _firestoreService.getFirebaseClient(); |
| await fetchData(); |
| IntersectionObserver(infiniteScrollCallback).observe(infiniteScroll); |
| } |
| |
| /// We do not want to create a new AppComponent object each time the |
| /// route changes, which includes changes to the fragment. |
| /// It is always acceptable to use the same AppComponent. |
| @override |
| Future<bool> canReuse(_, __) async => true; |
| |
| void infiniteScrollCallback( |
| List entries, IntersectionObserver observer) async { |
| infiniteScrollVisibleRatio = entries[0].intersectionRatio; |
| // The event stream will write to infiniteScrollVisible, stopping the loop. |
| while (infiniteScrollVisibleRatio > 0) { |
| await fetchData(); |
| await Future.delayed(Duration(seconds: 2)); |
| } |
| } |
| |
| List<Change> getInMap(Map map, String name, IntRange range) => map |
| .putIfAbsent(name, () => SplayTreeMap<IntRange, List<Change>>(reverse)) |
| .putIfAbsent(range, () => <Change>[]); |
| |
| Future fetchData() => fetching ??= () async { |
| try { |
| final before = commits.isEmpty ? null : commits.keys.last; |
| final range = await fetchEarlierCommits(before); |
| await fetchResults(range); |
| await fetchComments(range); |
| updateLiveChanges(); |
| updateRanges(); |
| } finally { |
| // Force a reevaluation of changed views |
| filterService.filter = filterService.filter.copy(); |
| fetching = null; |
| _applicationRef.tick(); |
| } |
| }(); |
| |
| Future<IntRange> fetchEarlierCommits(int before) async { |
| final newCommits = (await _firestoreService.fetchCommits(before, 30)) |
| .map((x) => Commit.fromDocument(x)); |
| if (newCommits.isEmpty) { |
| throw Exception("Failed to fetch more commits"); |
| } |
| for (Commit commit in newCommits) { |
| commits[commit.index] = commit; |
| final range = IntRange(commit.index, commit.index); |
| changeGroups.putIfAbsent( |
| range, () => ChangeGroup(range, commits, [], [], [])); |
| } |
| final range = IntRange( |
| commits.keys.last, before == null ? commits.keys.first : before - 1); |
| // Add new commits to previously loaded blamelists that included them. |
| for (ChangeGroup changeGroup in changeGroups.values) { |
| if (changeGroup.range.contains(range.end) && |
| changeGroup.range.length > changeGroup.commits.length) { |
| changeGroup.commits = commits.values |
| .where((commit) => changeGroup.range.contains(commit.index)) |
| .toList(); |
| } |
| } |
| return range; |
| } |
| |
| Future fetchResults(IntRange commitRange) async { |
| final resultsData = await _firestoreService.fetchChanges( |
| commitRange.start, commitRange.end); |
| for (var resultData in resultsData) { |
| final change = Change.fromDocument(resultData); |
| final range = IntRange(change.pinnedIndex ?? change.blamelistStartIndex, |
| change.pinnedIndex ?? change.blamelistEndIndex); |
| |
| changes.putIfAbsent(range, () => <Change>[]).add(change); |
| getInMap(changesByName, change.name, range).add(change); |
| modifiedRanges.add(range); |
| modifiedNames.add(change.name); |
| } |
| } |
| |
| Future fetchComments(IntRange commitRange) async { |
| final commentsData = await _firestoreService.fetchCommentsForRange( |
| commitRange.start, commitRange.end); |
| final newComments = [ |
| for (final doc in commentsData) Comment.fromDocument(doc) |
| ]; |
| for (final comment in newComments) { |
| IntRange range; |
| if (comment.pinnedIndex != null) { |
| range = IntRange(comment.pinnedIndex, comment.pinnedIndex); |
| } else if (comment.blamelistStartIndex != null) { |
| range = |
| IntRange(comment.blamelistStartIndex, comment.blamelistEndIndex); |
| } |
| if (range != null) { |
| comments.putIfAbsent(range, () => []).add(comment); |
| modifiedRanges.add(range); |
| } |
| } |
| } |
| |
| /// Updates Change objects representing only the changed test results that |
| /// have not been replaced by a later changed result on the same test and |
| /// configuration. |
| /// For each test name with incoming new results, scan backwards in time, |
| /// keeping a set of the configurations that we have seen a test change on. |
| /// Only the changes to configurations not in the 'seen' set are still 'live', |
| /// and still impact the current state of the test on that configuration. |
| /// Update the existing Change object for the live changes for this |
| /// test, configuration, and results. Remove it if there are no live results. |
| /// If the existing Change object is still correct, leave it unchanged, so |
| /// Angular change detection does not need to recompute the view. |
| void updateLiveChanges() { |
| for (final name in modifiedNames) { |
| final byRange = changesByName[name]; |
| final seenConfigurations = Set<String>(); |
| for (final range in byRange.keys) { |
| // The current live Change objects for that test and range, |
| // which need to be updated. |
| final existingLive = getInMap(liveChangesByName, name, range); |
| for (final change in byRange[range]) { |
| final liveConfigurations = change.configurations.configurations |
| .where((c) => !seenConfigurations.contains(c)) |
| .toList(); |
| final previous = getInMap(liveChangesByName, name, range).firstWhere( |
| (c) => c.changesText == change.changesText, |
| orElse: () => null); |
| if (liveConfigurations.isEmpty) { |
| if (previous != null) { |
| existingLive.remove(previous); |
| modifiedRanges.add(range); |
| } |
| } else if (Configurations(liveConfigurations) != |
| previous?.configurations) { |
| // Configurations are canonicalized. |
| if (previous != null) { |
| existingLive.remove(previous); |
| } |
| existingLive |
| .add(change.copy(newConfigurations: liveConfigurations)); |
| modifiedRanges.add(range); |
| } |
| seenConfigurations.addAll(liveConfigurations); |
| } |
| } |
| } |
| modifiedNames.clear(); |
| } |
| |
| void updateRanges() { |
| for (final range in modifiedRanges) { |
| liveChanges[range] = [ |
| for (final byRange in liveChangesByName.values) ...?byRange[range] |
| ]; |
| if (changes[range].isEmpty && liveChanges[range].isEmpty) { |
| changeGroups.remove(range); |
| } else { |
| changeGroups[range] = ChangeGroup(range, commits, comments[range] ?? [], |
| changes[range], liveChanges[range]); |
| } |
| } |
| modifiedRanges.clear(); |
| } |
| |
| String get loginMessage => _firestoreService.isLoggedIn ? 'logout' : 'login'; |
| |
| void toggleLogin() { |
| if (_firestoreService.isLoggedIn) { |
| _firestoreService.logOut(); |
| } else { |
| _firestoreService.logIn(); |
| } |
| } |
| } |
| |
| // We often want changeGroups and commits ordered from latest to earliest. |
| int reverse(key1, key2) => key2.compareTo(key1); |
| |
| /// Exposes private members of AppComponent for testing purposes. |
| /// Only usable on the staging Firestore instance. |
| class AppComponentTest { |
| AppComponent appComponent; |
| |
| AppComponentTest(this.appComponent); |
| |
| TestingFirestoreService get firestoreService => |
| appComponent._firestoreService; |
| ApplicationRef get applicationRef => appComponent._applicationRef; |
| } |