Add an "Import match list" tab to the scouting web page

This patch adds a new tab to the scouting web page to import the match
list. You have to point the server at a config file with `--tba_config`.
See its `--help` for more information.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I606915a6cc5776fe183da41ab8b04b063f57dafd
diff --git a/scouting/webserver/main.go b/scouting/webserver/main.go
index 94e0222..282e248 100644
--- a/scouting/webserver/main.go
+++ b/scouting/webserver/main.go
@@ -45,7 +45,7 @@
 	blueAllianceConfigPtr := flag.String("tba_config", "",
 		"The path to your The Blue Alliance JSON config. "+
 			"It needs an \"api_key\" field with your TBA API key. "+
-			"Optionally, it can have a \"url\" field with the TBA API base URL.")
+			"Optionally, it can have a \"base_url\" field with the TBA API base URL.")
 	flag.Parse()
 
 	database, err := db.NewDatabase(*dbPathPtr)
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 39246d8..8a32f89 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -22,6 +22,7 @@
     visibility = ["//visibility:public"],
     deps = [
         "//scouting/www/entry",
+        "//scouting/www/import_match_list",
         "@npm//@angular/animations",
         "@npm//@angular/common",
         "@npm//@angular/core",
@@ -78,3 +79,9 @@
     ],
     deps = [":main_bundle_compiled"],
 )
+
+filegroup(
+    name = "common_css",
+    srcs = ["common.css"],
+    visibility = ["//scouting/www:__subpackages__"],
+)
diff --git a/scouting/www/app.ng.html b/scouting/www/app.ng.html
index f763b7d..2fafceb 100644
--- a/scouting/www/app.ng.html
+++ b/scouting/www/app.ng.html
@@ -1,5 +1,13 @@
-<!--Progress Bar-->
-<!--<div class="row">
-  <h1 class="text-end">Match {{matchNumber}}, Team {{teamNumber}}</h1>
-</div>-->
-<app-entry></app-entry>
+<ul class="nav nav-tabs">
+  <li class="nav-item">
+    <a class="nav-link" [class.active]="tabIs('Entry')" (click)="switchTabTo('Entry')">Data Entry</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [class.active]="tabIs('ImportMatchList')" (click)="switchTabTo('ImportMatchList')">Import Match List</a>
+  </li>
+</ul>
+
+<ng-container [ngSwitch]="tab">
+  <app-entry *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 f6247d3..285d306 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -1,8 +1,20 @@
 import {Component} from '@angular/core';
 
+type Tab = 'Entry'|'ImportMatchList';
+
 @Component({
   selector: 'my-app',
   templateUrl: './app.ng.html',
+  styleUrls: ['./common.css']
 })
 export class App {
+  tab: Tab = 'Entry';
+
+  tabIs(tab: Tab) {
+    return this.tab == tab;
+  }
+
+  switchTabTo(tab: Tab) {
+    this.tab = tab;
+  }
 }
diff --git a/scouting/www/app_module.ts b/scouting/www/app_module.ts
index 6a6fff0..2a514f2 100644
--- a/scouting/www/app_module.ts
+++ b/scouting/www/app_module.ts
@@ -2,6 +2,7 @@
 import {BrowserModule} from '@angular/platform-browser';
 import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
 import {EntryModule} from './entry/entry.module';
+import {ImportMatchListModule} from './import_match_list/import_match_list.module';
 
 import {App} from './app';
 
@@ -11,6 +12,7 @@
     BrowserModule,
     BrowserAnimationsModule,
     EntryModule,
+    ImportMatchListModule,
   ],
   exports: [App],
   bootstrap: [App],
diff --git a/scouting/www/common.css b/scouting/www/common.css
new file mode 100644
index 0000000..35e943f
--- /dev/null
+++ b/scouting/www/common.css
@@ -0,0 +1,10 @@
+/* This CSS is shared between all scouting app tabs. */
+
+.error_message {
+  color: red;
+}
+.error_message:empty, .progress_message:empty {
+  /* TODO(phil): Figure out a way to make these take up no horizontal space.
+   * I.e. It would be nice to keep the error message and the progress message
+   * aligned when they are non-empty. */
+}
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 55b6937..2c394de 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -2,13 +2,15 @@
 
 ts_library(
     name = "entry",
-    srcs = glob([
-        "*.ts",
-    ]),
-    angular_assets = glob([
-        "*.ng.html",
-        "*.css",
-    ]),
+    srcs = [
+        "entry.component.ts",
+        "entry.module.ts",
+    ],
+    angular_assets = [
+        "entry.component.css",
+        "entry.ng.html",
+        "//scouting/www:common_css",
+    ],
     compiler = "//tools:tsc_wrapped_with_angular",
     target_compatible_with = ["@platforms//cpu:x86_64"],
     use_angular_plugin = True,
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index 7cbc6d7..76a3c29 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -19,10 +19,6 @@
   height: 150px;
 }
 
-.error_message {
-  color: red;
-}
-
 button {
   touch-action: manipulation;
 }
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index ddc31df..fb4a1b1 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -15,7 +15,7 @@
 @Component({
     selector: 'app-entry',
     templateUrl: './entry.ng.html',
-    styleUrls: ['./entry.component.css']
+    styleUrls: ['../common.css', './entry.component.css']
 })
 export class EntryComponent {
     section: Section = 'Team Selection';
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index c210ea3..b5783d2 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -209,7 +209,7 @@
             <li>Defense Played Rating: {{defensePlayedScore}}</li>
         </ul>
 
-        <div class="error_message">{{ errorMessage }}</div>
+        <span class="error_message">{{ errorMessage }}</span>
 
         <div class="buttons">
           <button class="btn btn-primary" (click)="prevSection()">Back</button>
diff --git a/scouting/www/import_match_list/BUILD b/scouting/www/import_match_list/BUILD
new file mode 100644
index 0000000..9e40794
--- /dev/null
+++ b/scouting/www/import_match_list/BUILD
@@ -0,0 +1,27 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+
+ts_library(
+    name = "import_match_list",
+    srcs = [
+        "import_match_list.component.ts",
+        "import_match_list.module.ts",
+    ],
+    angular_assets = [
+        "import_match_list.component.css",
+        "import_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:refresh_match_list_response_ts_fbs",
+        "//scouting/webserver/requests/messages:refresh_match_list_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        "@npm//@angular/common",
+        "@npm//@angular/core",
+        "@npm//@angular/forms",
+    ],
+)
diff --git a/scouting/www/import_match_list/import_match_list.component.css b/scouting/www/import_match_list/import_match_list.component.css
new file mode 100644
index 0000000..0570c72
--- /dev/null
+++ b/scouting/www/import_match_list/import_match_list.component.css
@@ -0,0 +1,3 @@
+* {
+    padding: 10px;
+}
diff --git a/scouting/www/import_match_list/import_match_list.component.ts b/scouting/www/import_match_list/import_match_list.component.ts
new file mode 100644
index 0000000..b2b15e5
--- /dev/null
+++ b/scouting/www/import_match_list/import_match_list.component.ts
@@ -0,0 +1,53 @@
+import { Component, OnInit } 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 refresh_match_list_response from 'org_frc971/scouting/webserver/requests/messages/refresh_match_list_response_generated';
+import * as refresh_match_list from 'org_frc971/scouting/webserver/requests/messages/refresh_match_list_generated';
+import RefreshMatchList = refresh_match_list.scouting.webserver.requests.RefreshMatchList;
+import RefreshMatchListResponse = refresh_match_list_response.scouting.webserver.requests.RefreshMatchListResponse;
+import ErrorResponse = error_response.scouting.webserver.requests.ErrorResponse;
+
+@Component({
+    selector: 'app-import-match-list',
+    templateUrl: './import_match_list.ng.html',
+    styleUrls: ['../common.css', './import_match_list.component.css']
+})
+export class ImportMatchListComponent {
+    year: number = new Date().getFullYear();
+    eventCode: string = '';
+    progressMessage: string = '';
+    errorMessage: string = '';
+
+    async importMatchList() {
+        this.errorMessage = '';
+
+        const builder = new flatbuffer_builder.Builder() as unknown as flatbuffers.Builder;
+        const eventCode = builder.createString(this.eventCode);
+        RefreshMatchList.startRefreshMatchList(builder);
+        RefreshMatchList.addYear(builder, this.year);
+        RefreshMatchList.addEventCode(builder, eventCode);
+        builder.finish(RefreshMatchList.endRefreshMatchList(builder));
+
+        this.progressMessage = 'Importing match list. Please be patient.';
+
+        const buffer = builder.asUint8Array();
+        const res = await fetch(
+            '/requests/refresh_match_list', {method: 'POST', body: buffer});
+
+        if (res.ok) {
+            // We successfully submitted the data.
+            this.progressMessage = 'Successfully imported 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/import_match_list/import_match_list.module.ts b/scouting/www/import_match_list/import_match_list.module.ts
new file mode 100644
index 0000000..1da8bec
--- /dev/null
+++ b/scouting/www/import_match_list/import_match_list.module.ts
@@ -0,0 +1,12 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {ImportMatchListComponent} from './import_match_list.component';
+import {FormsModule} from '@angular/forms';
+
+@NgModule({
+  declarations: [ImportMatchListComponent],
+  exports: [ImportMatchListComponent],
+  imports: [CommonModule, FormsModule],
+})
+export class ImportMatchListModule {
+}
diff --git a/scouting/www/import_match_list/import_match_list.ng.html b/scouting/www/import_match_list/import_match_list.ng.html
new file mode 100644
index 0000000..3c7ffa0
--- /dev/null
+++ b/scouting/www/import_match_list/import_match_list.ng.html
@@ -0,0 +1,20 @@
+<div class="header">
+    <h2>Import Match List</h2>
+</div>
+
+<div class="container-fluid">
+    <div class="row">
+        <label for="year">Year</label>
+        <input [(ngModel)]="year" type="number" id="year" min="1970" max="2500">
+    </div>
+    <div class="row">
+        <label for="event_code">Event Code</label>
+        <input [(ngModel)]="eventCode" type="text" id="event_code">
+    </div>
+
+    <span class="progress_message">{{ progressMessage }}</span>
+    <span class="error_message">{{ errorMessage }}</span>
+    <div class="text-right">
+        <button class="btn btn-primary" (click)="importMatchList()">Import</button>
+    </div>
+</div>