LP#626157 Ang2 experiments
authorBill Erickson <berickxx@gmail.com>
Sun, 17 Dec 2017 22:31:10 +0000 (17:31 -0500)
committerBill Erickson <berickxx@gmail.com>
Sun, 17 Dec 2017 22:31:10 +0000 (17:31 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
13 files changed:
Open-ILS/eg2-src/src/app/core/store.ts
Open-ILS/eg2-src/src/app/staff/admin/workstation/workstations/app.component.html
Open-ILS/eg2-src/src/app/staff/admin/workstation/workstations/app.component.ts
Open-ILS/eg2-src/src/app/staff/app.component.ts
Open-ILS/eg2-src/src/app/staff/app.module.ts
Open-ILS/eg2-src/src/app/staff/catalog/search-form.component.css
Open-ILS/eg2-src/src/app/staff/catalog/search-form.component.html
Open-ILS/eg2-src/src/app/staff/catalog/search-form.component.ts
Open-ILS/eg2-src/src/app/staff/login.component.html
Open-ILS/eg2-src/src/app/staff/login.component.ts
Open-ILS/eg2-src/src/app/staff/nav.component.html
Open-ILS/eg2-src/src/app/staff/resolver.service.ts
Open-ILS/eg2-src/src/styles.css

index e1a879b..fcb83c6 100644 (file)
@@ -39,7 +39,7 @@ export class EgStoreService {
         this.loginSessionKeys.push(key);
     }
 
-    setItem(key: string, val: any, isJson?: Boolean): Promise<any> {
+    setItem(key: string, val: any, isJson?: Boolean): Promise<void> {
         // TODO: route keys appropriately
         this.setLocalItem(key, val, false);
         return Promise.resolve();
@@ -50,7 +50,7 @@ export class EgStoreService {
         window.localStorage.setItem(key, val);
     }
 
-    setServerItem(key: string, val: any): Promise<any> {
+    setServerItem(key: string, val: any): Promise<void> {
         return Promise.resolve();
     }
 
@@ -94,7 +94,7 @@ export class EgStoreService {
         window.localStorage.removeItem(key);
     }
 
-    removeServerItem(key: string): Promise<any> {
+    removeServerItem(key: string): Promise<void> {
         return Promise.resolve();
     }
 
index 1bc1cf0..859c70c 100644 (file)
@@ -8,9 +8,9 @@
 </eg-confirm-dialog>
 
 <div class="row">
-  <div class="col-8 offset-1">
-    <div class="alert alert-warning" *ngIf="removingWs" i18n>
-      Workstation {{removingWs}} is no longer valid.  Removing registration.
+  <div class="col-8 offset-1 mt-3">
+    <div class="alert alert-warning" *ngIf="removeWorkstation" i18n>
+      Workstation {{removeWorkstation}} is no longer valid.  Removing registration.
     </div>
     <div class="alert alert-danger" *ngIf="workstations.length == 0">
       <span i18n>Please register a workstation.</span>
@@ -22,7 +22,7 @@
     <div class="row mt-2">
       <div class="col-2">
         <eg-org-select 
-          (onChange)="orgOnChange"
+          (onChange)="orgOnChange($event)"
           [hideOrgs]="hideOrgs"
           [disableOrgs]="disableOrgs"
           [initialOrg]="initialOrg"
@@ -39,7 +39,9 @@
             placeholder="Workstation Name"
             [(ngModel)]='newName'/>
           <div class="input-group-btn">
-            <button class="btn btn-light" (click)="registerWorkstation()">
+            <button class="btn btn-outline-dark" 
+              [disabled]="!newName || !newOwner"
+              (click)="registerWorkstation()">
               <span i18n>Register</span>
             </button>
           </div>
       </div>
     </div>
     <div class="row">
-      <div class="col-6">
-        <select
-          class="form-control"
-          [(ngModel)]="selectedId">
-          <option *ngFor="let ws of workstations" value="{{ws.id}}">
-            {{ws.name}}
+      <div class="col-8">
+        <select class="form-control" [(ngModel)]="selectedName">
+          <option *ngFor="let ws of workstations" value="{{ws.name}}">
+            <span *ngIf="ws.name == defaultName" i18n>
+              {{ws.name}} (Default)
+            </span>
+            <span *ngIf="ws.name != defaultName">
+              {{ws.name}}
+            </span>
           </option>
         </select>
       </div>
@@ -68,7 +73,7 @@
           (click)="useNow()" [disabled]="!selected">
           Use Now
         </button>
-        <button i18n class="btn btn-light
+        <button i18n class="btn btn-outline-dark
           (click)="setDefault()" [disabled]="!selected">
           Mark As Default
         </button>
index 43ca664..96d0784 100644 (file)
@@ -1,10 +1,11 @@
 import {Component, OnInit, ViewChild} from '@angular/core';
-import {ActivatedRoute} from '@angular/router';
+import {Router, ActivatedRoute} from '@angular/router';
 import {EgStoreService} from '@eg/core/store';
 import {EgIdlObject} from '@eg/core/idl';
 import {EgNetService} from '@eg/core/net';
 import {EgAuthService} from '@eg/core/auth';
 import {EgOrgService} from '@eg/core/org';
+import {EgEventService} from '@eg/core/event';
 import {EgConfirmDialogComponent} from '@eg/share/confirm-dialog.component';
 
 // Slim version of the WS that's stored in the cache.
@@ -19,11 +20,12 @@ interface Workstation {
 })
 export class WorkstationsComponent implements OnInit {
 
-    selectedId: Number;
+    selectedName: string;
     workstations: Workstation[] = [];
     removeWorkstation: string;
     newOwner: EgIdlObject;
-    newName: string = 'NewTestName';
+    newName: string;
+    defaultName: string;
 
     @ViewChild('workstationExistsDialog')
     private wsExistsDialog: EgConfirmDialogComponent;
@@ -36,7 +38,9 @@ export class WorkstationsComponent implements OnInit {
     }
 
     constructor(
+        private router: Router,
         private route: ActivatedRoute,
+        private evt: EgEventService,
         private net: EgNetService,
         private store: EgStoreService,
         private auth: EgAuthService,
@@ -45,38 +49,48 @@ export class WorkstationsComponent implements OnInit {
 
     ngOnInit() {
         this.store.getItem('eg.workstation.all')
-        .then(res => this.workstations = res);
+        .then(list => this.workstations = list || [])
+        .then(noop => this.store.getItem('eg.workstation.default'))
+        .then(defWs => {
+            this.defaultName = defWs;
+            this.selectedName = this.auth.workstation() || defWs 
+        })
+        .then(noop => {
+            let rm = this.route.snapshot.paramMap.get('remove');
+            if (rm) this.removeSelected(this.removeWorkstation = rm)
+        })
 
         // TODO: perm limits required here too
         this.disableOrgs = this.org.filterList({canHaveUsers : true}, true);
-
-        this.removeWorkstation = this.route.snapshot.paramMap.get('remove');
-        if (this.removeWorkstation) {
-            console.log('Removing workstation ' + this.removeWorkstation);
-            // TODO remove
-        }
     }
 
     selected(): Workstation {
         return this.workstations.filter(
-          ws => {return ws.id == this.selectedId})[0];
+          ws => {return ws.name == this.selectedName})[0];
     }
 
     useNow(): void {
-        //console.debug('using ' + this.selected().name);
-
-        this.wsExistsDialog.open().then(
-            confirmed => console.log('dialog confirmed'),
-            dismissed => console.log('dialog dismissed')
-        );
+        if (!this.selected()) return;
+        this.router.navigate(['/staff/login'], 
+          {queryParams: {workstation: this.selected().name}});
     }
 
     setDefault(): void {
-      console.debug('defaulting ' + this.selected().name);
+      if (!this.selected()) return;
+      this.defaultName = this.selected().name;
+      this.store.setItem('eg.workstation.default', this.defaultName);
     }
 
-    removeSelected(): void {
-      console.debug('removing ' + this.selected().name);
+    removeSelected(name?: string): void {
+        if (!name) name = this.selected().name;
+
+        this.workstations = this.workstations.filter(w => w.name != name);
+        this.store.setItem('eg.workstation.all', this.workstations);
+
+        if (this.defaultName == name) {
+            this.defaultName = null;
+            this.store.removeItem('eg.workstation.default');
+        }
     }
     
     canDeleteSelected(): boolean {
@@ -86,6 +100,67 @@ export class WorkstationsComponent implements OnInit {
     registerWorkstation(): void {
         console.log(`Registering new workstation ` +
             `"${this.newName}" at ${this.newOwner.shortname()}`);
+
+        this.newName = this.newOwner.shortname() + '-' + this.newName;
+
+        this.registerWorkstationApi().then(
+            wsId => this.registerWorkstationLocal(wsId));
+
+    }
+
+    private handleCollision(): Promise<number> {
+        return new Promise((resolve, reject) => {
+            this.wsExistsDialog.open()
+            .then(
+                confirmed => {
+                    this.registerWorkstationApi(true).then(
+                        wsId => resolve(wsId),
+                        notOk => reject()
+                    )
+                },
+                dismissed => reject()
+            )
+        });
+    }
+
+
+    private registerWorkstationApi(override?: boolean): Promise<number> {
+        let method = 'open-ils.actor.workstation.register';
+        if (override) method += '.override';
+
+        return new Promise((resolve, reject) => {
+            this.net.request(
+                'open-ils.actor', method,
+                this.auth.token(), this.newName, this.newOwner.id()
+            ).subscribe(wsId => {
+                let evt = this.evt.parse(wsId);  
+                if (evt) {
+                    if (evt.textcode == 'WORKSTATION_NAME_EXISTS') {
+                        this.handleCollision().then(
+                            id => resolve(id),
+                            notOk => reject(notOk)
+                        )
+                    } else {
+                        console.error(`Registration failed ${evt}`);
+                        reject();
+                    }
+                } else {
+                   resolve(wsId); 
+                }
+            });
+        });
+    }
+
+    private registerWorkstationLocal(wsId: number) {
+        let ws: Workstation = {
+            id: wsId,
+            name: this.newName,
+            owning_lib: this.newOwner.id()
+        };
+
+        this.workstations.push(ws);
+        this.store.setItem('eg.workstation.all', this.workstations)
+        .then(ok => this.newName = '');
     }
 }
 
index 3c90ab0..6f1dbda 100644 (file)
@@ -11,7 +11,7 @@ import { EgNetService } from '@eg/core/net';
 export class EgStaffComponent implements OnInit {
 
     readonly loginPath = '/staff/login';
-    readonly wsAdminPath = '/staff/admin/workstation/workstations/manage';
+    readonly wsAdminBasePath = '/staff/admin/workstation/workstations/';
 
     constructor(
         private router: Router,
@@ -46,12 +46,13 @@ export class EgStaffComponent implements OnInit {
     }
 
     /**
-     * Verifying auth token on every route is overkill, since an expired
-     * token will make itself known with the first API call, but we do
+     * Verifying the auth token on every route is overkill, since
+     * an expired token will make itself known with the first API
+     * call or when the auth session poll discovers it, but we do
      * want to prevent navigation from the login or workstation admin
      * page, since these can be accessed without a valid authtoken or
-     * workstation, respectively, once the initial route resolvers
-     * have done their jobs.
+     * workstation, respectively, once the initial route resolvers have
+     * done their jobs.
      */
     basicAuthChecks(routeEvent: NavigationEnd): void {
 
@@ -63,10 +64,10 @@ export class EgStaffComponent implements OnInit {
 
         // Access to workstation admin page is granted regardless
         // of workstation validity.
-        if (routeEvent.url == this.wsAdminPath) return;
+        if (routeEvent.url.indexOf(this.wsAdminBasePath) >= 0) return;
 
         if (this.auth.workstationState != EgAuthWsState.VALID)
-            this.router.navigate([this.wsAdminPath]);
+            this.router.navigate([this.wsAdminBasePath]);
     }
 }
 
index 3b9418f..6cefff5 100644 (file)
@@ -23,6 +23,7 @@ import {EgConfirmDialogComponent} from '@eg/share/confirm-dialog.component';
     EgConfirmDialogComponent
   ],
   imports: [
+    CommonModule,
     EgStaffRoutingModule,
     FormsModule,
     NgbModule
index f67d8fa..6201dff 100644 (file)
@@ -4,6 +4,13 @@
   margin-bottom: .1rem;
 }
 
+/* BS default height is 2.25rem + 2px which is quite chunky.
+ * This better matches the text input heights */
+select.form-control:not([size]):not([multiple]) {
+  padding: .355rem .55rem;
+  height: 2.2rem;
+}
+
 #staffcat-search-form {
   border-bottom: 2px dashed rgba(0,0,0,.225);
 }
index 3ee4d21..e02c3e6 100644 (file)
@@ -4,7 +4,7 @@ TODO focus search input
 <div id='staffcat-search-form' class='pb-2 mb-3'>
   <div class="row"
     *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
-    <div class="col-9 d-flex flex-row">
+    <div class="col-9 d-flex">
       <div class="flex-1">
         <div *ngIf="idx == 0">
           <select class="form-control" [(ngModel)]="searchContext.format">
@@ -44,11 +44,19 @@ TODO focus search input
       </div>
       <div class="flex-2 pl-1">
         <div class="form-group">
-          <input type="text" class="form-control"
-            TODOfocus-me="searchContext.focus_query[idx]"
-            [(ngModel)]="searchContext.query[idx]"
-            (keyup)="checkEnter($event)"
-            placeholder="Query..."/>
+          <div *ngIf="idx == 0">
+            <input type="text" class="form-control"
+              id='first-query-input'
+              [(ngModel)]="searchContext.query[idx]"
+              (keyup)="checkEnter($event)"
+              placeholder="Query..."/>
+          </div>
+          <div *ngIf="idx > 0">
+            <input type="text" class="form-control"
+              [(ngModel)]="searchContext.query[idx]"
+              (keyup)="checkEnter($event)"
+              placeholder="Query..."/>
+          </div>
         </div>
       </div>
       <div class="flex-1 pl-1">
@@ -91,7 +99,7 @@ TODO focus search input
   </div><!-- row -->
 
   <div class="row">
-    <div class="col-9 d-flex flex-row">
+    <div class="col-9 d-flex">
       <div class="flex-1">
         <eg-org-select 
           (onChange)="orgOnChange($event)"
index 94ef0bf..9e5b807 100644 (file)
@@ -1,4 +1,4 @@
-import {Component, OnInit} from '@angular/core';
+import {Component, OnInit, AfterViewInit, Renderer} from '@angular/core';
 import {EgIdlObject} from '@eg/core/idl';
 import {EgOrgService} from '@eg/core/org';
 import {EgCatalogService,} from '@eg/share/catalog/catalog.service';
@@ -11,7 +11,7 @@ import {StaffCatalogService} from './app.service';
   styleUrls: ['search-form.component.css'],
   templateUrl: 'search-form.component.html'
 })
-export class SearchFormComponent implements OnInit {
+export class SearchFormComponent implements OnInit, AfterViewInit {
 
     searchContext: CatalogSearchContext;
     ccvmMap: {[ccvm:string] : EgIdlObject[]} = {};
@@ -19,6 +19,7 @@ export class SearchFormComponent implements OnInit {
     showAdvancedSearch: boolean = false;
 
     constructor(
+        private renderer: Renderer,
         private org: EgOrgService,
         private cat: EgCatalogService,
         private staffCat: StaffCatalogService
@@ -32,6 +33,14 @@ export class SearchFormComponent implements OnInit {
         // Start with advanced search options open 
         // if any filters are active.
         this.showAdvancedSearch = this.hasAdvancedOptions();
+
+    }
+
+    ngAfterViewInit() {
+        // Query inputs are generated from search context data,
+        // so they are not available until after the first render.
+        // Search context data is extracted synchronously from the URL.
+        this.renderer.selectRootElement('#first-query-input').focus();
     }
 
     /**
index 869fe87..bafe288 100644 (file)
@@ -1,36 +1,58 @@
-<div class="col-md-4 offset-md-4">
-  <fieldset>
-    <legend i18n>Sign In</legend>
-    <hr/>
-    <form (ngSubmit)="handleSubmit()" #loginForm="ngForm">
+<div class="container">
+  <div class="col-md-6 offset-md-3">
+    <fieldset>
+      <legend class="mb-0" i18n>Sign In</legend>
+      <hr class="mt-1"/>
+      <form (ngSubmit)="handleSubmit()" #loginForm="ngForm">
 
-      <div class="form-group">
-        <label for="username" i18n>Username</label>
-        <input 
-          type="text" 
-          class="form-control"
-          id="username" 
-          name="username"
-          required
-          i18n-placeholder
-          placeholder="Username" 
-          [(ngModel)]="args.username"/>
-      </div>
+        <div class="form-group row">
+          <label class="col-md-4 text-right font-weight-bold" for="username" i18n>Username</label>
+          <input 
+            type="text" 
+            class="form-control col-md-8"
+            id="username" 
+            name="username"
+            required
+            autocomplete="username"
+            i18n-placeholder
+            placeholder="Username" 
+            [(ngModel)]="args.username"/>
+        </div>
 
-      <div class="form-group">
-        <label for="password" i18n>Password</label>
-        <input 
-          type="password" 
-          class="form-control"
-          id="password" 
-          name="password"
-          required
-          i18n-placeholder
-          placeholder="Password" 
-          [(ngModel)]="args.password"/>
-      </div>
+        <div class="form-group row">
+          <label class="col-md-4 text-right font-weight-bold" for="password" i18n>Password</label>
+          <input 
+            type="password" 
+            class="form-control col-md-8"
+            id="password" 
+            name="password"
+            required
+            autocomplete="current-password"
+            i18n-placeholder
+            placeholder="Password" 
+            [(ngModel)]="args.password"/>
+        </div>
 
-      <button type="submit" class="btn btn-light" i18n>Sign in</button>
-    </form>
-  </fieldset>
+        <div class="form-group row" *ngIf="workstations && workstations.length">
+          <label class="col-md-4 text-right font-weight-bold" for="workstation" i18n>Workstation</label>
+          <select 
+            class="form-control col-md-8" 
+            id="workstation" 
+            name="workstation"
+            required
+            [(ngModel)]="args.workstation">
+            <option *ngFor="let ws of workstations" value="{{ws.name}}">
+              {{ws.name}}
+            </option>
+          </select>
+        </div>
+
+        <div class="row">
+          <div class="col-md-8 offset-md-4 pl-0">
+            <button type="submit" class="btn btn-outline-dark" i18n>Sign in</button>
+          </div>
+        </div>
+      </form>
+    </fieldset>
+  </div>
 </div>
index 64ae6c5..fadcfc2 100644 (file)
@@ -1,8 +1,8 @@
-import { Component, OnInit, Renderer } from '@angular/core';
-import { Location } from '@angular/common';
-import { Router } from '@angular/router';
-import { EgAuthService, EgAuthWsState } from '@eg/core/auth';
-import { EgStoreService } from '@eg/core/store'; // TODO: testing
+import {Component, OnInit, Renderer} from '@angular/core';
+import {Location} from '@angular/common';
+import {Router, ActivatedRoute} from '@angular/router';
+import {EgAuthService, EgAuthWsState} from '@eg/core/auth';
+import {EgStoreService} from '@eg/core/store';
 
 @Component({
   templateUrl : './login.component.html'
@@ -10,18 +10,18 @@ import { EgStoreService } from '@eg/core/store'; // TODO: testing
 
 export class EgStaffLoginComponent implements OnInit {
 
+    workstations: any[];
+
     args = {
       username : '',
       password : '',
-      type : 'staff',
-      //workstation : ''
-      workstation :  'BR1-skiddoo' // testing
+      workstation : '',
+      type : 'staff'
     };
 
-    workstations = [];
-
     constructor(
       private router: Router,
+      private route: ActivatedRoute,
       private ngLocation: Location,
       private renderer: Renderer,
       private auth: EgAuthService,
@@ -36,13 +36,23 @@ export class EgStaffLoginComponent implements OnInit {
         // Focus username
         this.renderer.selectRootElement('#username').focus();
 
-        // load browser-local workstation data
+        this.store.getItem('eg.workstation.all')
+        .then(list => this.workstations = list || [])
+        .then(list => this.store.getItem('eg.workstation.default'))
+        .then(defWs => this.args.workstation = defWs)
+        .then(noOp => this.applyWorkstation())
+    }
+
+    applyWorkstation() {
+        let wanted = this.route.snapshot.queryParamMap.get('workstation');
+        if (!wanted) return; // use the default
 
-        // TODO: insert for testing.
-        this.store.setItem(
-          'eg.workstation.all', 
-          [{name:'BR1-skiddoo',id:1,owning_lib:4}]
-        ); 
+        let exists = this.workstations.filter(w => w.name == wanted)[0];
+        if (exists) {
+            this.args.workstation = wanted;
+        } else {
+            console.error(`Unknown workstation requested: ${wanted}`);
+        }
     }
 
     handleSubmit() {
@@ -59,7 +69,7 @@ export class EgStaffLoginComponent implements OnInit {
                     // User attempted to login with a workstation that is
                     // unknown to the server. Redirect to the WS admin page.
                     this.router.navigate(
-                        ['/staff/admin/workstation/workstations/remove/${workstation}']);
+                        [`/staff/admin/workstation/workstations/remove/${workstation}`]);
                 } else {
                     // Force reload of the app after a successful login.
                     // This allows the route resolver to re-run with a 
index 859ec7f..f325a74 100644 (file)
         </a>
         <div class="dropdown-menu" ngbDropdownMenu>
           <a class="dropdown-item"
-              routerLink="/staff/catalog/search">
-            <span class="material-icons">search</span>
-            <span i18n>TODO</span>
+              routerLink="/staff/admin/workstation/workstations/manage">
+            <span class="material-icons">computer</span>
+            <span i18n>Registered Workstations</span>
           </a>
         </div>
       </div>
           i18n-title
           title="Log out and more..."
           class="nav-link dropdown-toggle no-caret with-material-icon">
-          <i class="material-icons">list</i>
+          <i class="material-icons">more_vert</i>
         </a>
         <div class="dropdown-menu" ngbDropdownMenu>
           <a i18n class="dropdown-item" routerLink="/staff/login">
             <span class="material-icons">lock_outline</span>
             <span i18n>Logout</span>
           </a>
+          <a i18n class="dropdown-item" href="/eg/staff/about">
+            <span class="material-icons">info_outline</span>
+            <span i18n>About</span>
+          </a>
         </div>
       </div>
     </div>
index 8c23030..28e228f 100644 (file)
@@ -2,6 +2,7 @@ import {Injectable} from '@angular/core';
 import {Location} from '@angular/common';
 import {Observable, Observer} from 'rxjs/Rx';
 import {Router, Resolve, RouterStateSnapshot,
+        ActivatedRoute,
         ActivatedRouteSnapshot} from '@angular/router';
 import {EgStoreService} from '@eg/core/store';
 import {EgNetService} from '@eg/core/net';
@@ -16,10 +17,11 @@ import {EgAuthService} from '@eg/core/auth';
 export class EgStaffResolver implements Resolve<Observable<any>> {
 
     readonly loginPath = '/staff/login';
-    readonly wsAdminPath = '/staff/admin/workstation/workstations/manage';
+    readonly wsRemPath = '/staff/admin/workstation/workstations/remove/';
 
     constructor(
         private router: Router, 
+        private route: ActivatedRoute, 
         private ngLocation: Location,
         private store: EgStoreService,
         private net: EgNetService,
@@ -38,8 +40,9 @@ export class EgStaffResolver implements Resolve<Observable<any>> {
         this.store.loginSessionBasePath = '/';
             //this.ngLocation.prepareExternalUrl('/staff');
 
-        // Login resets everything.  No need to load data.
-        if (state.url == '/staff/login') return Observable.of(true);
+        // Not sure how to get the path without params... using this for now.
+        let path = state.url.split('?')[0]
+        if (path == '/staff/login') return Observable.of(true);
 
         return Observable.create(observer => {
             this.auth.testAuthToken().then(
@@ -52,8 +55,10 @@ export class EgStaffResolver implements Resolve<Observable<any>> {
                             );
                         },
                         wsNotOk => {
-                            if (state.url != this.wsAdminPath) {
-                                this.router.navigate([this.wsAdminPath]);
+                            if (path.indexOf(this.wsRemPath) < 0) {
+                                this.router.navigate([
+                                    this.wsRemPath + this.auth.workstation()
+                                ]);
                             }
                             observer.complete();
                         }
index c580fb0..9f7b83a 100644 (file)
@@ -30,7 +30,9 @@ h5 {font-size: .95rem}
 }
 
 
-/** BS has flex utility classes, but none for specifying flex widths */
+/** BS has flex utility classes, but none for specifying flex widths.
+ *  BS class="col" is roughly equivelent to flex-1, but col-2 is not
+ *  equivalent to flex-2, since col-2 really means 2/12 width. */
 .flex-1 {flex: 1}
 .flex-2 {flex: 2}
 .flex-3 {flex: 3}