Add match list tab

So the user can easily see what teams are playing in what matches

Signed-off-by: Ravago Jones <ravagojones@gmail.com>
Change-Id: Ie5efefbc890d02e8aec6b991dbc5b10249f41370
diff --git a/scouting/scouting_test.ts b/scouting/scouting_test.ts
index fda3834..84824ff 100644
--- a/scouting/scouting_test.ts
+++ b/scouting/scouting_test.ts
@@ -15,8 +15,9 @@
 // Protractor since they're not angular elements. We achieve this by checking
 // an invisible checkbox that's off-screen.
 async function disableAlerts() {
-  await browser.executeAsyncScript(function (callback) {
-    let block_alerts = document.getElementById('block_alerts') as HTMLInputElement;
+  await browser.executeAsyncScript(function(callback) {
+    let block_alerts =
+        document.getElementById('block_alerts') as HTMLInputElement;
     block_alerts.checked = true;
     callback();
   });
@@ -47,8 +48,11 @@
 
 // Asserts that the n'th instance of a field on the "Submit and Review"
 // screen has a specific value.
-async function expectNthReviewFieldToBe(fieldName: string, n: number, expectedValue: string) {
-  expect(await element.all(by.cssContainingText('li', `${fieldName}:`)).get(n).getText())
+async function expectNthReviewFieldToBe(
+    fieldName: string, n: number, expectedValue: string) {
+  expect(await element.all(by.cssContainingText('li', `${fieldName}:`))
+             .get(n)
+             .getText())
       .toEqual(`${fieldName}: ${expectedValue}`);
 }
 
@@ -58,8 +62,7 @@
   // overwrite the text that is there. If we didn't hit CTRL-A to select all
   // the text, we'd be appending to whatever is there already.
   return element(by.id(id)).sendKeys(
-        protractor.Key.CONTROL, 'a', protractor.Key.NULL,
-        value);
+      protractor.Key.CONTROL, 'a', protractor.Key.NULL, value);
 }
 
 describe('The scouting web page', () => {
@@ -71,19 +74,23 @@
     // Import the match list before running any tests. Ideally this should be
     // run in beforeEach(), but it's not worth doing that at this time. Our
     // tests are basic enough not to require this.
-    await element(by.cssContainingText('.nav-link', 'Import Match List')).click();
+    await element(by.cssContainingText('.nav-link', 'Import Match List'))
+        .click();
     expect(await getHeadingText()).toEqual('Import Match List');
     await setTextboxByIdTo('year', '2016');
     await setTextboxByIdTo('event_code', 'nytr');
     await element(by.buttonText('Import')).click();
 
     await browser.wait(EC.textToBePresentInElement(
-        element(by.css('.progress_message')), 'Successfully imported match list.'));
+        element(by.css('.progress_message')),
+        'Successfully imported match list.'));
   });
 
   it('should: error on unknown match.', async () => {
     await loadPage();
 
+    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
+
     // Pick a match that doesn't exist in the 2016nytr match list.
     await setTextboxByIdTo('match_number', '3');
     await setTextboxByIdTo('team_number', '971');
@@ -96,14 +103,16 @@
 
     // Attempt to submit and validate the error.
     await element(by.buttonText('Submit')).click();
-    expect(await getErrorMessage()).toContain(
-        'Failed to find team 971 in match 3 in the schedule.');
+    expect(await getErrorMessage())
+        .toContain('Failed to find team 971 in match 3 in the schedule.');
   });
 
 
   it('should: review and submit correct data.', async () => {
     await loadPage();
 
+    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
+
     // Submit scouting data for a random team that attended 2016nytr.
     expect(await getHeadingText()).toEqual('Team Selection');
     await setTextboxByIdTo('match_number', '2');
@@ -156,8 +165,8 @@
     await expectReviewFieldToBe('Broke (mechanically)', 'true');
 
     await element(by.buttonText('Submit')).click();
-    await browser.wait(EC.textToBePresentInElement(
-        element(by.css('.header')), 'Success'));
+    await browser.wait(
+        EC.textToBePresentInElement(element(by.css('.header')), 'Success'));
 
     // TODO(phil): Make sure the data made its way to the database correctly.
   });
@@ -165,23 +174,27 @@
   it('should: load all images successfully.', async () => {
     await loadPage();
 
+    await element(by.cssContainingText('.nav-link', 'Data Entry')).click();
+
     // Get to the Auto display with the field pictures.
     expect(await getHeadingText()).toEqual('Team Selection');
     await element(by.buttonText('Next')).click();
     expect(await getHeadingText()).toEqual('Auto');
 
     // We expect 2 fully loaded images.
-    browser.executeAsyncScript(function (callback) {
-      let images = document.getElementsByTagName('img');
-      let numLoaded = 0;
-      for (let i = 0; i < images.length; i += 1) {
-        if (images[i].naturalWidth > 0) {
-          numLoaded += 1;
-        }
-      }
-      callback(numLoaded);
-    }).then(function (numLoaded) {
-      expect(numLoaded).toBe(2);
-    });
+    browser
+        .executeAsyncScript(function(callback) {
+          let images = document.getElementsByTagName('img');
+          let numLoaded = 0;
+          for (let i = 0; i < images.length; i += 1) {
+            if (images[i].naturalWidth > 0) {
+              numLoaded += 1;
+            }
+          }
+          callback(numLoaded);
+        })
+        .then(function(numLoaded) {
+          expect(numLoaded).toBe(2);
+        });
   });
 });
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index f0b91f6..f386f06 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -19,6 +19,7 @@
     deps = [
         "//scouting/www/entry",
         "//scouting/www/import_match_list",
+        "//scouting/www/match_list",
         "@npm//@angular/animations",
         "@npm//@angular/common",
         "@npm//@angular/core",
diff --git a/scouting/www/app.ng.html b/scouting/www/app.ng.html
index d4ee5f4..fc21164 100644
--- a/scouting/www/app.ng.html
+++ b/scouting/www/app.ng.html
@@ -5,14 +5,18 @@
 
 <ul class="nav nav-tabs">
   <li class="nav-item">
-    <a class="nav-link" [class.active]="tabIs('Entry')" (click)="switchTabTo('Entry')">Data Entry</a>
+    <a class="nav-link" [class.active]="tabIs('MatchList')" (click)="switchTabToGuarded('MatchList')">Match List</a>
   </li>
   <li class="nav-item">
-    <a class="nav-link" [class.active]="tabIs('ImportMatchList')" (click)="switchTabTo('ImportMatchList')">Import Match List</a>
+    <a class="nav-link" [class.active]="tabIs('Entry')" (click)="switchTabToGuarded('Entry')">Data Entry</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [class.active]="tabIs('ImportMatchList')" (click)="switchTabToGuarded('ImportMatchList')">Import Match List</a>
   </li>
 </ul>
 
 <ng-container [ngSwitch]="tab">
-  <app-entry *ngSwitchCase="'Entry'"></app-entry>
+  <app-match-list (selectedTeamEvent)="selectTeamInMatch($event)" *ngSwitchCase="'MatchList'"></app-match-list>
+  <app-entry (switchTabsEvent)="switchTabTo($event)" [teamNumber]="selectedTeamInMatch.teamNumber" [matchNumber]="selectedTeamInMatch.matchNumber" *ngSwitchCase="'Entry'"></app-entry>
   <app-import-match-list *ngSwitchCase="'ImportMatchList'"></app-import-match-list>
 </ng-container>
diff --git a/scouting/www/app.ts b/scouting/www/app.ts
index e34283a..fbde74f 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -1,6 +1,11 @@
 import {Component, ElementRef, ViewChild} from '@angular/core';
 
-type Tab = 'Entry'|'ImportMatchList';
+type Tab = 'MatchList'|'Entry'|'ImportMatchList';
+type TeamInMatch = {
+  teamNumber: number,
+  matchNumber: number,
+  compLevel: string
+};
 
 @Component({
   selector: 'my-app',
@@ -8,14 +13,17 @@
   styleUrls: ['./common.css']
 })
 export class App {
-  tab: Tab = 'Entry';
+  selectedTeamInMatch:
+      TeamInMatch = {teamNumber: 1, matchNumber: 1, compLevel: 'qm'};
+  tab: Tab = 'MatchList';
 
-  @ViewChild("block_alerts") block_alerts: ElementRef;
+  @ViewChild('block_alerts') block_alerts: ElementRef;
 
   constructor() {
     window.addEventListener('beforeunload', (e) => {
       if (!this.block_alerts.nativeElement.checked) {
-        // Based on https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
+        // Based on
+        // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
         // This combination ensures a dialog will be shown on most browsers.
         e.preventDefault();
         e.returnValue = '';
@@ -27,7 +35,12 @@
     return this.tab == tab;
   }
 
-  switchTabTo(tab: Tab) {
+  selectTeamInMatch(teamInMatch: TeamInMatch) {
+    this.selectedTeamInMatch = teamInMatch;
+    this.switchTabTo('Entry');
+  }
+
+  switchTabToGuarded(tab: Tab) {
     let shouldSwitch = true;
     if (this.tab !== tab) {
       if (!this.block_alerts.nativeElement.checked) {
@@ -35,7 +48,11 @@
       }
     }
     if (shouldSwitch) {
-      this.tab = tab;
+      this.switchTabTo(tab);
     }
   }
+
+  private switchTabTo(tab: Tab) {
+    this.tab = tab;
+  }
 }
diff --git a/scouting/www/app_module.ts b/scouting/www/app_module.ts
index 2a514f2..e560bfc 100644
--- a/scouting/www/app_module.ts
+++ b/scouting/www/app_module.ts
@@ -3,6 +3,7 @@
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
 import {EntryModule} from './entry/entry.module';
 import {ImportMatchListModule} from './import_match_list/import_match_list.module';
+import {MatchListModule} from './match_list/match_list.module';
 
 import {App} from './app';
 
@@ -13,6 +14,7 @@
     BrowserAnimationsModule,
     EntryModule,
     ImportMatchListModule,
+    MatchListModule,
   ],
   exports: [App],
   bootstrap: [App],
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 7461aad..d850949 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -1,134 +1,141 @@
-import { Component, ElementRef, OnInit, ViewChild } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-
+import {Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core';
+import {FormsModule} from '@angular/forms';
 import * as flatbuffer_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
 import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
 import * as error_response from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
-import * as submit_data_scouting_response from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_response_generated';
 import * as submit_data_scouting from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_generated';
+import * as submit_data_scouting_response from 'org_frc971/scouting/webserver/requests/messages/submit_data_scouting_response_generated';
+
 import SubmitDataScouting = submit_data_scouting.scouting.webserver.requests.SubmitDataScouting;
 import SubmitDataScoutingResponse = submit_data_scouting_response.scouting.webserver.requests.SubmitDataScoutingResponse;
 import ErrorResponse = error_response.scouting.webserver.requests.ErrorResponse;
 
-type Section = 'Team Selection'|'Auto'|'TeleOp'|'Climb'|'Other'|'Review and Submit'|'Success'
-type Level = 'NoAttempt'|'Failed'|'FailedWithPlentyOfTime'|'Low'|'Medium'|'High'|'Transversal'
+type Section = 'Team Selection'|'Auto'|'TeleOp'|'Climb'|'Other'|
+    'Review and Submit'|'Success'
+type Level = 'NoAttempt'|'Failed'|'FailedWithPlentyOfTime'|'Low'|'Medium'|
+    'High'|'Transversal'
 
-@Component({
-    selector: 'app-entry',
-    templateUrl: './entry.ng.html',
-    styleUrls: ['../common.css', './entry.component.css']
-})
-export class EntryComponent {
-    section: Section = 'Team Selection';
-    matchNumber: number = 1
-    teamNumber: number = 1
-    autoUpperShotsMade: number = 0;
-    autoLowerShotsMade: number = 0;
-    autoShotsMissed: number = 0;
-    teleUpperShotsMade: number = 0;
-    teleLowerShotsMade: number = 0;
-    teleShotsMissed: number = 0;
-    defensePlayedOnScore: number = 0;
-    defensePlayedScore: number = 0;
-    level: Level = 'NoAttempt';
-    ball1: boolean = false;
-    ball2: boolean = false;
-    ball3: boolean = false;
-    ball4: boolean = false;
-    ball5: boolean = false;
-    quadrant: number = 1;
-    errorMessage: string = '';
-    noShow: boolean = false;
-    neverMoved: boolean = false;
-    batteryDied: boolean = false;
-    mechanicallyBroke: boolean = false;
-    lostComs: boolean = false;
+    @Component({
+      selector: 'app-entry',
+      templateUrl: './entry.ng.html',
+      styleUrls: ['../common.css', './entry.component.css']
+    }) export class EntryComponent {
+  section: Section = 'Team Selection';
+  @Output() switchTabsEvent = new EventEmitter<string>();
+  @Input() matchNumber: number = 1;
+  @Input() teamNumber: number = 1;
+  autoUpperShotsMade: number = 0;
+  autoLowerShotsMade: number = 0;
+  autoShotsMissed: number = 0;
+  teleUpperShotsMade: number = 0;
+  teleLowerShotsMade: number = 0;
+  teleShotsMissed: number = 0;
+  defensePlayedOnScore: number = 0;
+  defensePlayedScore: number = 0;
+  level: Level = 'NoAttempt';
+  ball1: boolean = false;
+  ball2: boolean = false;
+  ball3: boolean = false;
+  ball4: boolean = false;
+  ball5: boolean = false;
+  quadrant: number = 1;
+  errorMessage: string = '';
+  noShow: boolean = false;
+  neverMoved: boolean = false;
+  batteryDied: boolean = false;
+  mechanicallyBroke: boolean = false;
+  lostComs: boolean = false;
 
-    @ViewChild("header") header: ElementRef;
+  @ViewChild('header') header: ElementRef;
 
-    nextSection() {
-        if (this.section === 'Team Selection') {
-            this.section = 'Auto';
-        } else if (this.section === 'Auto') {
-            this.section = 'TeleOp';
-        } else if (this.section === 'TeleOp') {
-            this.section = 'Climb';
-        } else if (this.section === 'Climb') {
-            this.section = 'Other';
-        } else if (this.section === 'Other') {
-            this.section = 'Review and Submit';
-        } else if (this.section === 'Review and Submit') {
-            this.submitDataScouting();
-            return;
-        }
-        // Scroll back to the top so that we can be sure the user sees the
-        // entire next screen. Otherwise it's easy to overlook input fields.
-        this.scrollToTop();
+  nextSection() {
+    if (this.section === 'Team Selection') {
+      this.section = 'Auto';
+    } else if (this.section === 'Auto') {
+      this.section = 'TeleOp';
+    } else if (this.section === 'TeleOp') {
+      this.section = 'Climb';
+    } else if (this.section === 'Climb') {
+      this.section = 'Other';
+    } else if (this.section === 'Other') {
+      this.section = 'Review and Submit';
+    } else if (this.section === 'Review and Submit') {
+      this.submitDataScouting();
+      return;
+    } else if (this.section === 'Success') {
+     this.switchTabsEvent.emit('MatchList');
+     return;
     }
+    // Scroll back to the top so that we can be sure the user sees the
+    // entire next screen. Otherwise it's easy to overlook input fields.
+    this.scrollToTop();
+  }
 
-    prevSection() {
-      if (this.section === 'Auto') {
-        this.section = 'Team Selection';
-      } else if (this.section === 'TeleOp') {
-        this.section = 'Auto';
-      } else if (this.section === 'Climb') {
-        this.section = 'TeleOp';
-      } else if (this.section === 'Other') {
-        this.section = 'Climb';
-      } else if (this.section === 'Review and Submit') {
-        this.section = 'Other';
-      }
-      // Scroll back to the top so that we can be sure the user sees the
-      // entire previous screen. Otherwise it's easy to overlook input
-      // fields.
-      this.scrollToTop();
+  prevSection() {
+    if (this.section === 'Auto') {
+      this.section = 'Team Selection';
+    } else if (this.section === 'TeleOp') {
+      this.section = 'Auto';
+    } else if (this.section === 'Climb') {
+      this.section = 'TeleOp';
+    } else if (this.section === 'Other') {
+      this.section = 'Climb';
+    } else if (this.section === 'Review and Submit') {
+      this.section = 'Other';
     }
+    // Scroll back to the top so that we can be sure the user sees the
+    // entire previous screen. Otherwise it's easy to overlook input
+    // fields.
+    this.scrollToTop();
+  }
 
-    private scrollToTop() {
-        this.header.nativeElement.scrollIntoView();
+  private scrollToTop() {
+    this.header.nativeElement.scrollIntoView();
+  }
+
+  async submitDataScouting() {
+    this.errorMessage = '';
+
+    const builder =
+        new flatbuffer_builder.Builder() as unknown as flatbuffers.Builder;
+    SubmitDataScouting.startSubmitDataScouting(builder);
+    SubmitDataScouting.addTeam(builder, this.teamNumber);
+    SubmitDataScouting.addMatch(builder, this.matchNumber);
+    SubmitDataScouting.addMissedShotsAuto(builder, this.autoShotsMissed);
+    SubmitDataScouting.addUpperGoalAuto(builder, this.autoUpperShotsMade);
+    SubmitDataScouting.addLowerGoalAuto(builder, this.autoLowerShotsMade);
+    SubmitDataScouting.addMissedShotsTele(builder, this.teleShotsMissed);
+    SubmitDataScouting.addUpperGoalTele(builder, this.teleUpperShotsMade);
+    SubmitDataScouting.addLowerGoalTele(builder, this.teleLowerShotsMade);
+    SubmitDataScouting.addDefenseRating(builder, this.defensePlayedScore);
+    SubmitDataScouting.addAutoBall1(builder, this.ball1);
+    SubmitDataScouting.addAutoBall2(builder, this.ball2);
+    SubmitDataScouting.addAutoBall3(builder, this.ball3);
+    SubmitDataScouting.addAutoBall4(builder, this.ball4);
+    SubmitDataScouting.addAutoBall5(builder, this.ball5);
+    SubmitDataScouting.addStartingQuadrant(builder, this.quadrant);
+
+    // TODO(phil): Add support for defensePlayedOnScore.
+    // TODO(phil): Fix the Climbing score.
+    SubmitDataScouting.addClimbing(builder, 1);
+    builder.finish(SubmitDataScouting.endSubmitDataScouting(builder));
+
+    const buffer = builder.asUint8Array();
+    const res = await fetch(
+        '/requests/submit/data_scouting', {method: 'POST', body: buffer});
+
+    if (res.ok) {
+      // We successfully submitted the data. Report success.
+      this.section = 'Success';
+    } else {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse = ErrorResponse.getRootAsErrorResponse(
+          fbBuffer as unknown as flatbuffers.ByteBuffer);
+
+      const errorMessage = parsedResponse.errorMessage();
+      this.errorMessage =
+          `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
     }
-
-    async submitDataScouting() {
-        this.errorMessage = '';
-
-        const builder = new flatbuffer_builder.Builder() as unknown as flatbuffers.Builder;
-        SubmitDataScouting.startSubmitDataScouting(builder);
-        SubmitDataScouting.addTeam(builder, this.teamNumber);
-        SubmitDataScouting.addMatch(builder, this.matchNumber);
-        SubmitDataScouting.addMissedShotsAuto(builder, this.autoShotsMissed);
-        SubmitDataScouting.addUpperGoalAuto(builder, this.autoUpperShotsMade);
-        SubmitDataScouting.addLowerGoalAuto(builder, this.autoLowerShotsMade);
-        SubmitDataScouting.addMissedShotsTele(builder, this.teleShotsMissed);
-        SubmitDataScouting.addUpperGoalTele(builder, this.teleUpperShotsMade);
-        SubmitDataScouting.addLowerGoalTele(builder, this.teleLowerShotsMade);
-        SubmitDataScouting.addDefenseRating(builder, this.defensePlayedScore);
-        SubmitDataScouting.addAutoBall1(builder, this.ball1);
-        SubmitDataScouting.addAutoBall2(builder, this.ball2);
-        SubmitDataScouting.addAutoBall3(builder, this.ball3);
-        SubmitDataScouting.addAutoBall4(builder, this.ball4);
-        SubmitDataScouting.addAutoBall5(builder, this.ball5);
-        SubmitDataScouting.addStartingQuadrant(builder, this.quadrant);
-
-        // TODO(phil): Add support for defensePlayedOnScore.
-        // TODO(phil): Fix the Climbing score.
-        SubmitDataScouting.addClimbing(builder, 1);
-        builder.finish(SubmitDataScouting.endSubmitDataScouting(builder));
-
-        const buffer = builder.asUint8Array();
-        const res = await fetch(
-            '/requests/submit/data_scouting', {method: 'POST', body: buffer});
-
-        if (res.ok) {
-            // We successfully submitted the data. Report success.
-            this.section = 'Success';
-        } else {
-            const resBuffer = await res.arrayBuffer();
-            const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-            const parsedResponse = ErrorResponse.getRootAsErrorResponse(
-                fbBuffer as unknown as flatbuffers.ByteBuffer);
-
-            const errorMessage = parsedResponse.errorMessage();
-            this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
-        }
-    }
+  }
 }
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index a39db40..5849f3b 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -193,14 +193,16 @@
 
         <span class="error_message">{{ errorMessage }}</span>
 
-        <div class="buttons">
-          <button class="btn btn-primary" (click)="prevSection()">Back</button>
+        <div class="buttons justify-content-end">
           <button class="btn btn-primary" (click)="nextSection()">Submit</button>
         </div>
     </div>
 
     <div *ngSwitchCase="'Success'" id="success" class="container-fluid">
-        <h4>Success</h4>
-        <div>Please reload the page to submit more data.</div>
+        <span>Successfully submitted scouting data.</span>
+        <div class="buttons justify-content-end">
+          <button class="btn btn-primary" (click)="nextSection()">Continue</button>
+        </div>
+
     </div>
 </ng-container>
diff --git a/scouting/www/match_list/BUILD b/scouting/www/match_list/BUILD
new file mode 100644
index 0000000..a33caf8
--- /dev/null
+++ b/scouting/www/match_list/BUILD
@@ -0,0 +1,27 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+
+ts_library(
+    name = "match_list",
+    srcs = [
+        "match_list.component.ts",
+        "match_list.module.ts",
+    ],
+    angular_assets = [
+        "match_list.component.css",
+        "match_list.ng.html",
+        "//scouting/www:common_css",
+    ],
+    compiler = "//tools:tsc_wrapped_with_angular",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    use_angular_plugin = True,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//scouting/webserver/requests/messages:error_response_ts_fbs",
+        "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
+        "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        "@npm//@angular/common",
+        "@npm//@angular/core",
+        "@npm//@angular/forms",
+    ],
+)
diff --git a/scouting/www/match_list/match_list.component.css b/scouting/www/match_list/match_list.component.css
new file mode 100644
index 0000000..a2c1676
--- /dev/null
+++ b/scouting/www/match_list/match_list.component.css
@@ -0,0 +1,11 @@
+* {
+    padding: 10px;
+}
+
+.red {
+  background-color: #dc3545;
+}
+
+.blue {
+  background-color: #0d6efd;
+}
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
new file mode 100644
index 0000000..0085b7b
--- /dev/null
+++ b/scouting/www/match_list/match_list.component.ts
@@ -0,0 +1,122 @@
+import {Component, EventEmitter, OnInit, Output} from '@angular/core';
+import * as flatbuffer_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
+import {ByteBuffer} from 'org_frc971/external/com_github_google_flatbuffers/ts/byte-buffer';
+import * as error_response from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import * as request_all_matches from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
+import * as request_all_matches_response from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
+
+import RequestAllMatches = request_all_matches.scouting.webserver.requests.RequestAllMatches;
+import RequestAllMatchesResponse = request_all_matches_response.scouting.webserver.requests.RequestAllMatchesResponse;
+import Match = request_all_matches_response.scouting.webserver.requests.Match;
+import ErrorResponse = error_response.scouting.webserver.requests.ErrorResponse;
+
+type TeamInMatch = {
+  teamNumber: number,
+  matchNumber: number,
+  compLevel: string
+};
+
+@Component({
+  selector: 'app-match-list',
+  templateUrl: './match_list.ng.html',
+  styleUrls: ['../common.css', './match_list.component.css']
+})
+export class MatchListComponent implements OnInit {
+  @Output() selectedTeamEvent = new EventEmitter<TeamInMatch>();
+  teamInMatch: TeamInMatch = {teamNumber: 1, matchNumber: 1, compLevel: 'qm'};
+  progressMessage: string = '';
+  errorMessage: string = '';
+  matchList: Match[] = [];
+
+  setTeamInMatch(teamInMatch: TeamInMatch) {
+    this.teamInMatch = teamInMatch;
+    this.selectedTeamEvent.emit(teamInMatch);
+  }
+
+  teamsInMatch(match: Match): {number: number, color: 'red'|'blue'}[] {
+    return [
+      {number: match.r1(), color: 'red'},
+      {number: match.r2(), color: 'red'},
+      {number: match.r3(), color: 'red'},
+      {number: match.b1(), color: 'blue'},
+      {number: match.b2(), color: 'blue'},
+      {number: match.b3(), color: 'blue'},
+    ];
+  }
+
+  matchType(match: Match): string|null {
+    switch (match.compLevel()) {
+      case 'qm':
+        return 'Quals';
+      case 'ef':
+        return 'Eighth Final';
+      case 'qf':
+        return 'Quarter Final';
+      case 'sf':
+        return 'Semi Final';
+      case 'f':
+        return 'Final';
+      default:
+        return null;
+    }
+  }
+
+  displayMatchNumber(match: Match): string {
+    return `${this.matchType(match)} ${match.matchNumber()}`;
+  }
+
+  ngOnInit() {
+    this.importMatchList();
+  }
+
+  async importMatchList() {
+    this.errorMessage = '';
+
+    const builder =
+        new flatbuffer_builder.Builder() as unknown as flatbuffers.Builder;
+    RequestAllMatches.startRequestAllMatches(builder);
+    builder.finish(RequestAllMatches.endRequestAllMatches(builder));
+
+    this.progressMessage = 'Fetching match list. Please be patient.';
+
+    const buffer = builder.asUint8Array();
+    const res = await fetch(
+        '/requests/request/all_matches', {method: 'POST', body: buffer});
+
+    if (res.ok) {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse =
+          RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(
+              fbBuffer as unknown as flatbuffers.ByteBuffer);
+
+      this.matchList = [];
+      for (let i = 0; i < parsedResponse.matchListLength(); i++) {
+        this.matchList.push(parsedResponse.matchList(i));
+      }
+      this.matchList.sort((a, b) => {
+        let aString = this.displayMatchNumber(a);
+        let bString = this.displayMatchNumber(b);
+        if (aString < bString) {
+          return -1;
+        }
+        if (aString > bString) {
+          return 1;
+        }
+        return 0;
+      });
+
+      this.progressMessage = 'Successfully fetched match list.';
+    } else {
+      this.progressMessage = '';
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse = ErrorResponse.getRootAsErrorResponse(
+          fbBuffer as unknown as flatbuffers.ByteBuffer);
+
+      const errorMessage = parsedResponse.errorMessage();
+      this.errorMessage =
+          `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
+    }
+  }
+}
diff --git a/scouting/www/match_list/match_list.module.ts b/scouting/www/match_list/match_list.module.ts
new file mode 100644
index 0000000..dbec410
--- /dev/null
+++ b/scouting/www/match_list/match_list.module.ts
@@ -0,0 +1,13 @@
+import {CommonModule} from '@angular/common';
+import {NgModule} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+
+import {MatchListComponent} from './match_list.component';
+
+@NgModule({
+  declarations: [MatchListComponent],
+  exports: [MatchListComponent],
+  imports: [CommonModule, FormsModule],
+})
+export class MatchListModule {
+}
diff --git a/scouting/www/match_list/match_list.ng.html b/scouting/www/match_list/match_list.ng.html
new file mode 100644
index 0000000..39d4578
--- /dev/null
+++ b/scouting/www/match_list/match_list.ng.html
@@ -0,0 +1,30 @@
+<div class="header">
+    <h2>Matches</h2>
+</div>
+
+<div class="container-fluid">
+
+  <div class="row">
+
+    <div *ngFor="let match of matchList; index as i">
+      <span class="badge bg-secondary rounded-left">{{ displayMatchNumber(match) }}</span>
+      <div class="list-group list-group-horizontal-sm">
+        <button
+          *ngFor="let team of teamsInMatch(match);"
+          (click)="setTeamInMatch({
+            teamNumber: team.number,
+            matchNumber: match.matchNumber(),
+            compLevel: match.compLevel()
+          })"
+          class="text-center text-white fw-bold
+            list-group-item list-group-item-action"
+          [ngClass]="team.color">
+          {{ team.number }}
+        </button>
+      </div>
+    </div>
+  </div>
+
+  <span class="progress_message" role="alert">{{ progressMessage }}</span>
+  <span class="error_message" role="alert">{{ errorMessage }}</span>
+</div>