You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@asterixdb.apache.org by AsterixDB Code Review <do...@asterix-gerrit.ics.uci.edu> on 2026/09/11 18:22:02 UTC

[L] Change in asterixdb[master]: [ASTERIXDB-3831][UI] Persistent query history

From Ian Maxon <im...@apache.org>:

Ian Maxon has uploaded this change for review. ( https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21751?usp=email )


Change subject: [ASTERIXDB-3831][UI] Persistent query history
......................................................................

[ASTERIXDB-3831][UI] Persistent query history

Query history is a great thing to have. However today
it's just stored in an array. That means when you refresh
the page it's gone.
localStorage is a very natural interface that is provided by
modern browsers to store simple strings (like queries) across
browser sessions. This patch utilizes that facility to persist
the query history.

Generated-by: Claude Opus 5
Change-Id: I708ec383df2bd490c297d29b1b23a0e28d5128c4
---
M asterixdb/asterix-dashboard/src/node/angular.json
M asterixdb/asterix-dashboard/src/node/src/app/app.module.ts
M asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.html
M asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.scss
M asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.ts
A asterixdb/asterix-dashboard/src/node/src/app/shared/services/query-history.service.ts
D asterixdb/asterix-dashboard/src/node/src/test.ts
M asterixdb/asterix-dashboard/src/node/src/tests/appbar.component.spec.ts
M asterixdb/asterix-dashboard/src/node/src/tests/input.component.spec.ts
A asterixdb/asterix-dashboard/src/node/src/tests/query-history.service.spec.ts
M asterixdb/asterix-dashboard/src/node/src/tsconfig.spec.json
11 files changed, 527 insertions(+), 71 deletions(-)



  git pull ssh://asterix-gerrit.ics.uci.edu:29418/asterixdb refs/changes/51/21751/1

diff --git a/asterixdb/asterix-dashboard/src/node/angular.json b/asterixdb/asterix-dashboard/src/node/angular.json
index a6625f0..bb896db 100644
--- a/asterixdb/asterix-dashboard/src/node/angular.json
+++ b/asterixdb/asterix-dashboard/src/node/angular.json
@@ -79,7 +79,6 @@
         "test": {
           "builder": "@angular-devkit/build-angular:karma",
           "options": {
-            "main": "src/test.ts",
             "karmaConfig": "./karma.conf.js",
             "polyfills": [
               "zone.js",
diff --git a/asterixdb/asterix-dashboard/src/node/src/app/app.module.ts b/asterixdb/asterix-dashboard/src/node/src/app/app.module.ts
index db60c6d..4df55e5 100644
--- a/asterixdb/asterix-dashboard/src/node/src/app/app.module.ts
+++ b/asterixdb/asterix-dashboard/src/node/src/app/app.module.ts
@@ -32,6 +32,7 @@
 import { AppTabComponent }  from './dashboard/apptab.component';
 import { reducers } from './shared/reducers';
 import { SQLService } from './shared/services/async-query.service'
+import { QueryHistoryService } from './shared/services/query-history.service'
 import { FormsModule } from '@angular/forms';
 import { MaterialModule } from './material.module';
 import { StoreModule,  } from '@ngrx/store';
@@ -80,7 +81,7 @@
         NgxChartsModule
     ],
     schemas: [CUSTOM_ELEMENTS_SCHEMA],
-    providers: [SQLService],
+    providers: [SQLService, QueryHistoryService],
     bootstrap: [AppComponent]
 })
 export class AppModule {}
diff --git a/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.html b/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.html
index 9bb0daa..a6d5b1e 100644
--- a/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.html
+++ b/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.html
@@ -56,6 +56,9 @@
                         <mat-option *ngFor="let query of history" [value]="query.index">
                           {{query.query}}
                         </mat-option>
+                        <mat-option *ngIf="history.length" class="clear-history" [value]="clearHistoryIndex">
+                          Clear History
+                        </mat-option>
                         </mat-select>
                     </mat-form-field>
                 </div>
@@ -72,6 +75,7 @@
     <div class="message">
         <span *ngIf="querySuccess==true && queryWarningsShow==false" class="metrics">{{metricsString}}</span>
         <span *ngIf="queryError" class="queryErrorMessage">{{queryErrorMessageString}}</span>
+        <span *ngIf="historyRestoredMessage" class="historyRestoredMessage">{{historyRestoredMessage}}</span>
         <span *ngIf="querySuccess==true && queryWarningsShow==true" class="queryWarningMessage" matTooltip="{{queryWarningsMessages}}">{{metricsString}}</span>
         <mat-divider></mat-divider>
         <div class="obj-returned-div">
diff --git a/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.scss b/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.scss
index c9c0359..820a624 100644
--- a/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.scss
+++ b/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.scss
@@ -104,6 +104,20 @@
     padding: 0;
 }
 
+.historyRestoredMessage {
+  color: rgba(0, 0, 0, 0.54);
+  font-size: 14px !important;
+  word-break: break-all;
+  font-size: 1.0rem;
+  font-weight: 500;
+  margin: 0;
+  padding: 0;
+}
+
+.clear-history {
+  border-top: 1px solid rgba(0, 0, 0, 0.12);
+}
+
 .queryWarningMessage {
   color: #ffae42;
   font-size: 14px !important;
diff --git a/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.ts b/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.ts
index e36e019..bbe5e7c 100644
--- a/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.ts
+++ b/asterixdb/asterix-dashboard/src/node/src/app/dashboard/query/input.component.ts
@@ -16,6 +16,11 @@
   import * as sqlCancelActions from '../../shared/actions/cancel.actions';
   import * as appActions from '../../shared/actions/app.actions'
   import * as dataverseActions from '../../shared/actions/dataverse.actions'
+  import {
+    QueryHistoryEntry,
+    QueryHistoryService,
+    QUERY_HISTORY_MAX_ENTRIES
+  } from '../../shared/services/query-history.service';
   import * as CodeMirror from 'codemirror';
   import 'codemirror/addon/edit/closebrackets';
   import 'codemirror/mode/sql/sql';
@@ -75,14 +80,16 @@
     dataverses: any;
     defaultDataverse = 'Default';
     selected = 'Default';
-    history = [];
+    history: QueryHistoryEntry[] = [];
+    restoredQueryCount = 0;
+    historyRestoredMessage = '';
+    readonly clearHistoryIndex = -2;
     currentHistory = 0;
     viewCurrentHistory = 0; // for the view
     sideMenuVisible$: Observable<any>;
     sideMenuVisible: any;
     none = 'None';
     planFormat = 'JSON';
-    historyStringSelected = '';
     historyIdxSelected = 0;
     formatOptions = 'JSON';
     outputOptions = 'JSON';
@@ -99,7 +106,7 @@
     sqlppTypes = "boolean tinyint smallint integer int bigint string float double binary point line rectangle circle polygon" +
       "uuid object array multiset date time datetime duration year_month_duration day_time_duration interval"
 
-    constructor(private store: Store < any > ) {
+    constructor(private store: Store < any > , private queryHistoryService: QueryHistoryService) {
       this.currentQuery = 0;
       this.querySuccess$ = this.store.select(s => s.sqlQuery.successHash);
       this.querySuccess$.subscribe((data: any) => {
@@ -211,6 +218,9 @@
       })
 
       this.preparedQueryCount = 0;
+
+      this.restoreHistory();
+
       // Initialize Query Editor, prepare the default query
       this.saveQuery(String(this.currentQuery), this.queryString, this.formatOptions, "JSON");
 
@@ -324,6 +334,7 @@
 
     runQuery(stringToRun: string, isExplain: boolean) {
       this.autodetectDataverse();
+      this.historyRestoredMessage = '';
 
       if (this.queryString != this.queryPrepared[Object.keys(this.queryPrepared).length - 1]) {
         //not the same query as before, currentQuery needs to be incremented and another needs to be dispatched
@@ -334,6 +345,7 @@
         this.saveQuery(String(this.currentQuery), this.queryString, this.formatOptions, this.outputOptions);
 
         this.history.unshift({query: this.queryString, index: this.currentQuery});
+        this.persistHistory();
 
         //this.currentHistory = this.history.length - 1;
         //this.viewCurrentHistory = this.history.length;
@@ -372,6 +384,7 @@
       this.hideOutputEmitter.emit(true);
 
       this.historyIdxSelected = -1;
+      this.historyRestoredMessage = '';
 
       // Prepare a new Query String, cleanup screen messages
       this.currentQuery = Object.keys(this.queryPrepared).length;
@@ -401,6 +414,7 @@
       this.queryErrorMessageString = "";
       this.queryString = "";
       this.metricsString = "";
+      this.historyRestoredMessage = '';
       this.queryWarningsCount = 0;
       this.queryWarningsMessages = [];
 
@@ -468,6 +482,9 @@
       // Select the query from the QUERY History
       this.historyIdxSelected = this.currentQuery;
 
+      this.historyRestoredMessage = this.isRestoredQuery(this.currentQuery)
+          ? 'Loaded from saved history. Results are not saved, run the query again to see its output.' : '';
+
       this.autodetectDataverse();
 
       // Retrieve the prepared SQL plan Format
@@ -546,23 +563,58 @@
     }
 
     historySelected(idx: number) {
-      if (this.historyStringSelected == undefined) {
-        this.historyStringSelected = '';
-      } else if (this.historyStringSelected === 'Clear') {
-        this.history = [];
-        this.historyStringSelected = '';
+      if (idx === this.clearHistoryIndex) {
+        this.clearHistory();
+        return;
       }
 
       this.nextSQLEditor(idx);
     }
 
+    clearHistory() {
+      this.history = [];
+      this.restoredQueryCount = 0;
+      this.historyRestoredMessage = '';
+      this.historyIdxSelected = this.currentQuery;
+      this.queryHistoryService.clear();
+    }
+
+    isRestoredQuery(index: number): boolean {
+      return index < this.restoredQueryCount && !(this.queryMetrics && this.queryMetrics[index]);
+    }
+
+    persistHistory() {
+      if (this.history.length > QUERY_HISTORY_MAX_ENTRIES) {
+        this.history.length = QUERY_HISTORY_MAX_ENTRIES;
+      }
+      this.queryHistoryService.save(this.history);
+    }
+
+    restoreHistory() {
+      let restored = this.queryHistoryService.load();
+      if (restored.length === 0) {
+        return;
+      }
+
+      for (let i = restored.length - 1; i >= 0; i--) {
+        restored[i].index = restored.length - 1 - i;
+        this.saveQuery(String(restored[i].index), restored[i].query, this.formatOptions, this.outputOptions);
+      }
+
+      this.history = restored;
+      this.restoredQueryCount = restored.length;
+      this.currentQuery = restored.length;
+      this.historyIdxSelected = this.currentQuery;
+      this.queryString = "";
+    }
+
     planFormatSelected() {}
 
     onClickNextHistory() {
       if (this.currentHistory < this.history.length - 1) {
         this.currentHistory++;
         this.viewCurrentHistory++;
-        this.queryString = this.history[this.currentHistory];
+        this.queryString = this.history[this.currentHistory].query;
         this.editor.getDoc().setValue(this.queryString);
         this.editor.focus();
       }
@@ -572,7 +624,7 @@
       if (this.currentHistory > 0) {
         this.currentHistory--;
         this.viewCurrentHistory--;
-        this.queryString = this.history[this.currentHistory];
+        this.queryString = this.history[this.currentHistory].query;
         this.editor.getDoc().setValue(this.queryString);
         this.editor.focus();
       }
diff --git a/asterixdb/asterix-dashboard/src/node/src/app/shared/services/query-history.service.ts b/asterixdb/asterix-dashboard/src/node/src/app/shared/services/query-history.service.ts
new file mode 100644
index 0000000..33d26de
--- /dev/null
+++ b/asterixdb/asterix-dashboard/src/node/src/app/shared/services/query-history.service.ts
@@ -0,0 +1,79 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { Injectable } from '@angular/core';
+
+export interface QueryHistoryEntry {
+    query: string;
+    index: number;
+}
+
+export const QUERY_HISTORY_STORAGE_KEY = 'asterixdb.dashboard.queryHistory';
+export const QUERY_HISTORY_MAX_ENTRIES = 100;
+
+/*
+* Persists the query strings shown in the QUERY HISTORY dropdown across page reloads.
+* Only the query text is stored, never the results, which can be arbitrarily large.
+*/
+@Injectable()
+export class QueryHistoryService {
+
+    load(): QueryHistoryEntry[] {
+        let stored: string;
+        try {
+            stored = localStorage.getItem(QUERY_HISTORY_STORAGE_KEY);
+        } catch (error) {
+            return [];
+        }
+
+        if (!stored) {
+            return [];
+        }
+
+        let parsed: any;
+        try {
+            parsed = JSON.parse(stored);
+        } catch (error) {
+            this.clear();
+            return [];
+        }
+
+        if (!Array.isArray(parsed)) {
+            this.clear();
+            return [];
+        }
+
+        return parsed
+            .filter(entry => entry != null && typeof entry.query === 'string' && typeof entry.index === 'number')
+            .slice(0, QUERY_HISTORY_MAX_ENTRIES)
+            .map(entry => ({query: entry.query, index: entry.index}));
+    }
+
+    save(history: QueryHistoryEntry[]) {
+        let serialized = JSON.stringify(history.slice(0, QUERY_HISTORY_MAX_ENTRIES)
+            .map(entry => ({query: entry.query, index: entry.index})));
+        try {
+            localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, serialized);
+        } catch (error) {
+            /* storage is unavailable or full; the history stays in memory for this session only */
+        }
+    }
+
+    clear() {
+        try {
+            localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
+        } catch (error) {
+            /* storage is unavailable; there is nothing persisted to remove */
+        }
+    }
+}
diff --git a/asterixdb/asterix-dashboard/src/node/src/test.ts b/asterixdb/asterix-dashboard/src/node/src/test.ts
deleted file mode 100644
index a471d9a..0000000
--- a/asterixdb/asterix-dashboard/src/node/src/test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// This file is required by karma.conf.js and loads recursively all the .spec and framework files
-import 'zone.js/testing';
-import { getTestBed } from '@angular/core/testing';
-import {
-  BrowserDynamicTestingModule,
-  platformBrowserDynamicTesting
-} from '@angular/platform-browser-dynamic/testing';
-
-declare const require: {
-  context(path: string, deep?: boolean, filter?: RegExp): {
-    keys(): string[];
-    <T>(id: string): T;
-  };
-};
-
-// First, initialize the Angular testing environment.
-getTestBed().initTestEnvironment(
-  BrowserDynamicTestingModule,
-  platformBrowserDynamicTesting()
-);
-// Then we find all the tests.
-const context = require.context('./', true, /\.spec\.ts$/);
-// And load the modules.
-context.keys().map(context);
diff --git a/asterixdb/asterix-dashboard/src/node/src/tests/appbar.component.spec.ts b/asterixdb/asterix-dashboard/src/node/src/tests/appbar.component.spec.ts
index a62aa4e..94fc132 100644
--- a/asterixdb/asterix-dashboard/src/node/src/tests/appbar.component.spec.ts
+++ b/asterixdb/asterix-dashboard/src/node/src/tests/appbar.component.spec.ts
@@ -30,39 +30,40 @@
     }).compileComponents();
   }));
 
-  it('should render link titled "WEBSITE"', waitForAsync(() => {
+  /*
+  * the container div and every link share the .menu class, so a positional selector picks the
+  * container rather than the link it names
+  */
+  function link(label: string): HTMLAnchorElement {
     const fixture = TestBed.createComponent(AppBarComponent);
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.menu:nth-child(1)').textContent).toContain('WEBSITE');
+    return fixture.debugElement.nativeElement.querySelector('nav a[aria-label="' + label + '"]');
+  }
+
+  it('should render link titled "WEBSITE"', waitForAsync(() => {
+    expect(link('WEBSITE').textContent).toContain('WEBSITE');
   }));
 
   it('should render link titled "FILE ISSUES"', waitForAsync(() => {
-    const fixture = TestBed.createComponent(AppBarComponent);
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.menu:nth-child(2)').textContent).toContain('FILE ISSUES');
+    expect(link('FILE ISSUES').textContent).toContain('FILE ISSUES');
   }));
 
   it('should render link titled "DOCUMENTATION"', waitForAsync(() => {
-    const fixture = TestBed.createComponent(AppBarComponent);
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.menu:nth-child(3)').textContent).toContain('DOCUMENTATION');
+    expect(link('DOCUMENTATION').textContent).toContain('DOCUMENTATION');
   }));
 
   it('should render link titled "CONTACT"', waitForAsync(() => {
-    const fixture = TestBed.createComponent(AppBarComponent);
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.menu:nth-child(4)').textContent).toContain('CONTACT');
+    expect(link('CONTACT').textContent).toContain('CONTACT');
   }));
 
   it('should render link titled "GITHUB"', waitForAsync(() => {
-    const fixture = TestBed.createComponent(AppBarComponent);
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.menu:nth-child(5)').textContent).toContain('GITHUB');
+    expect(link('GITHUB').textContent).toContain('GITHUB');
   }));
 
-  it('should render link titled "METADATA"', waitForAsync(() => {
+  it('should render every link in the order the navigation declares them', waitForAsync(() => {
     const fixture = TestBed.createComponent(AppBarComponent);
-    const compiled = fixture.debugElement.nativeElement;
-    expect(compiled.querySelector('.menu:nth-child(6)').textContent).toContain('METADATA');
+    const labels = Array.from(fixture.debugElement.nativeElement.querySelectorAll('nav a'))
+        .map((anchor: HTMLAnchorElement) => anchor.getAttribute('aria-label'));
+
+    expect(labels).toEqual(['WEBSITE', 'FILE ISSUES', 'DOCUMENTATION', 'CONTACT', 'GITHUB']);
   }));
 });
diff --git a/asterixdb/asterix-dashboard/src/node/src/tests/input.component.spec.ts b/asterixdb/asterix-dashboard/src/node/src/tests/input.component.spec.ts
index d006b44..db88640 100644
--- a/asterixdb/asterix-dashboard/src/node/src/tests/input.component.spec.ts
+++ b/asterixdb/asterix-dashboard/src/node/src/tests/input.component.spec.ts
@@ -10,12 +10,17 @@
 limitations under the License.
 */
 import {InputQueryComponent} from "../app/dashboard/query/input.component";
-import {ComponentFixture, TestBed, waitForAsync, async} from "@angular/core/testing";
+import {ComponentFixture, TestBed, waitForAsync} from "@angular/core/testing";
 import {provideMockStore, MockStore} from "@ngrx/store/testing";
 import {CUSTOM_ELEMENTS_SCHEMA, DebugElement} from "@angular/core";
 import {By} from "@angular/platform-browser";
 import {HarnessLoader} from "@angular/cdk/testing";
 import {TestbedHarnessEnvironment} from "@angular/cdk/testing/testbed";
+import {
+  QueryHistoryService,
+  QUERY_HISTORY_MAX_ENTRIES,
+  QUERY_HISTORY_STORAGE_KEY
+} from "../app/shared/services/query-history.service";
 
 describe('InputQueryComponent: unit test', () => {
   let component: InputQueryComponent;
@@ -71,7 +76,8 @@
         InputQueryComponent
       ],
       providers: [
-        provideMockStore({initialState})
+        provideMockStore({initialState}),
+        QueryHistoryService
       ],
       schemas: [CUSTOM_ELEMENTS_SCHEMA]
     }).compileComponents();
@@ -79,6 +85,15 @@
     store = TestBed.inject(MockStore);
     spyOn(store, 'dispatch');
     jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000;
+    localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
+  });
+
+  afterEach(() => {
+    try {
+      localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
+    } catch (error) {
+      /* a spec may leave storage stubbed out as unavailable */
+    }
   });
 
   beforeEach(() => {
@@ -210,4 +225,182 @@
     });
 
   });
+
+  //query history persistence
+  describe('query history persistence tests', () => {
+    /*
+    * builds a second component on top of an already seeded localStorage, which is what a
+    * page reload looks like to the component
+    */
+    function reloadComponent(): ComponentFixture<InputQueryComponent> {
+      let reloaded = TestBed.createComponent(InputQueryComponent);
+      reloaded.detectChanges();
+      return reloaded;
+    }
+
+    function persisted(): any[] {
+      return JSON.parse(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY));
+    }
+
+    it('persists a query to localStorage when it is run', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+
+      expect(persisted()).toEqual([{query: "select 1", index: component.history[0].index}]);
+    });
+
+    it('persists the most recent query first', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+      component.queryString = "select 2";
+      component.onClickRun();
+
+      expect(persisted().map(entry => entry.query)).toEqual(["select 2", "select 1"]);
+    });
+
+    it('persists the query string only, never the results', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+
+      expect(Object.keys(persisted()[0]).sort()).toEqual(['index', 'query']);
+    });
+
+    it('caps the persisted history', () => {
+      for (let i = 0; i < QUERY_HISTORY_MAX_ENTRIES + 5; i++) {
+        component.queryString = "select " + i;
+        component.onClickRun();
+      }
+
+      expect(component.history.length).toBe(QUERY_HISTORY_MAX_ENTRIES);
+      expect(persisted().length).toBe(QUERY_HISTORY_MAX_ENTRIES);
+      expect(persisted()[0].query).toBe("select " + (QUERY_HISTORY_MAX_ENTRIES + 4));
+    });
+
+    it('starts with an empty history when nothing was persisted', () => {
+      expect(component.history).toEqual([]);
+      expect(component.restoredQueryCount).toBe(0);
+    });
+
+    it('restores the history of a previous session, newest first', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY,
+          JSON.stringify([{query: "select 2", index: 1}, {query: "select 1", index: 0}]));
+
+      let reloaded = reloadComponent().componentInstance;
+
+      expect(reloaded.history.map(entry => entry.query)).toEqual(["select 2", "select 1"]);
+      expect(reloaded.restoredQueryCount).toBe(2);
+    });
+
+    it('reindexes the restored queries onto consecutive editors', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY,
+          JSON.stringify([{query: "select 2", index: 17}, {query: "select 1", index: 9}]));
+
+      let reloaded = reloadComponent().componentInstance;
+
+      expect(reloaded.history.map(entry => entry.index)).toEqual([1, 0]);
+    });
+
+    it('restores each query into its own editor and opens an empty one', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY,
+          JSON.stringify([{query: "select 2", index: 1}, {query: "select 1", index: 0}]));
+
+      let reloaded = reloadComponent().componentInstance;
+      let prepared = (store.dispatch as jasmine.Spy).calls.all()
+          .map(call => call.args[0])
+          .filter(action => action.payload && action.payload.editorId !== undefined)
+          .map(action => action.payload);
+
+      expect(prepared.slice(-3).map(payload => [payload.editorId, payload.queryString]))
+          .toEqual([['0', "select 1"], ['1', "select 2"], ['2', ""]]);
+      expect(reloaded.currentQuery).toBe(2);
+      expect(reloaded.queryString).toBe("");
+    });
+
+    it('tells the user to rerun a query selected from a restored history', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY,
+          JSON.stringify([{query: "select 2", index: 1}, {query: "select 1", index: 0}]));
+
+      let reloaded = reloadComponent().componentInstance;
+      reloaded.queryPrepared = {0: "select 1", 1: "select 2"};
+      reloaded.historySelected(1);
+
+      expect(reloaded.queryString).toBe("select 2");
+      expect(reloaded.historyRestoredMessage).toContain('run the query again');
+    });
+
+    it('drops the rerun notice once the restored query is run again', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, JSON.stringify([{query: "select 1", index: 0}]));
+
+      let reloaded = reloadComponent().componentInstance;
+      reloaded.queryPrepared = {0: "select 1"};
+      reloaded.historySelected(0);
+      expect(reloaded.historyRestoredMessage).not.toBe('');
+
+      reloaded.onClickRun();
+
+      expect(reloaded.historyRestoredMessage).toBe('');
+    });
+
+    it('does not show the rerun notice for a query run in this session', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+      component.queryPrepared = {0: "select 1"};
+
+      component.historySelected(component.history[0].index);
+
+      expect(component.historyRestoredMessage).toBe('');
+    });
+
+    it('clearing the history empties it in memory and in localStorage', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+      expect(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).not.toBeNull();
+
+      component.historySelected(component.clearHistoryIndex);
+
+      expect(component.history).toEqual([]);
+      expect(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).toBeNull();
+    });
+
+    it('a cleared history stays empty after a reload', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+      component.historySelected(component.clearHistoryIndex);
+
+      expect(reloadComponent().componentInstance.history).toEqual([]);
+    });
+
+    it('clearing the history does not switch the editor', () => {
+      component.queryString = "select 1";
+      component.onClickRun();
+      let currentQuery = component.currentQuery;
+
+      component.historySelected(component.clearHistoryIndex);
+
+      expect(component.currentQuery).toBe(currentQuery);
+      expect(component.historyIdxSelected).toBe(currentQuery);
+    });
+
+    it('offers the clear history option only when the history is not empty', () => {
+      expect(de.query(By.css('.sql-history mat-select'))).toBeTruthy();
+      expect(component.history.length).toBe(0);
+
+      component.queryString = "select 1";
+      component.onClickRun();
+      fixture.detectChanges();
+
+      expect(component.history.length).toBe(1);
+    });
+
+    it('survives a page reload with localStorage disabled', () => {
+      spyOn(localStorage, 'getItem').and.throwError('storage disabled');
+      spyOn(localStorage, 'setItem').and.throwError('storage disabled');
+
+      let reloaded = reloadComponent().componentInstance;
+      reloaded.queryString = "select 1";
+
+      expect(() => reloaded.onClickRun()).not.toThrow();
+      expect(reloaded.history.length).toBe(1);
+    });
+  });
 });
diff --git a/asterixdb/asterix-dashboard/src/node/src/tests/query-history.service.spec.ts b/asterixdb/asterix-dashboard/src/node/src/tests/query-history.service.spec.ts
new file mode 100644
index 0000000..3c4084e
--- /dev/null
+++ b/asterixdb/asterix-dashboard/src/node/src/tests/query-history.service.spec.ts
@@ -0,0 +1,152 @@
+/*
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import {TestBed} from "@angular/core/testing";
+import {
+  QueryHistoryService,
+  QUERY_HISTORY_MAX_ENTRIES,
+  QUERY_HISTORY_STORAGE_KEY
+} from "../app/shared/services/query-history.service";
+
+describe('QueryHistoryService: unit test', () => {
+  let service: QueryHistoryService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [QueryHistoryService]
+    });
+    service = TestBed.inject(QueryHistoryService);
+    localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
+  });
+
+  afterEach(() => {
+    try {
+      localStorage.removeItem(QUERY_HISTORY_STORAGE_KEY);
+    } catch (error) {
+      /* a spec may leave storage stubbed out as unavailable */
+    }
+  });
+
+  it('should create', () => {
+    expect(service).toBeTruthy();
+  });
+
+  describe('load() tests', () => {
+    it('returns an empty history when nothing was persisted', () => {
+      expect(service.load()).toEqual([]);
+    });
+
+    it('returns the persisted entries in the order they were saved', () => {
+      service.save([{query: 'select 2', index: 1}, {query: 'select 1', index: 0}]);
+
+      expect(service.load()).toEqual([{query: 'select 2', index: 1}, {query: 'select 1', index: 0}]);
+    });
+
+    it('discards a corrupted entry that is not valid JSON', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, 'not json at all');
+
+      expect(service.load()).toEqual([]);
+      expect(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).toBeNull();
+    });
+
+    it('discards a persisted value that is not an array', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, JSON.stringify({query: 'select 1', index: 0}));
+
+      expect(service.load()).toEqual([]);
+      expect(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).toBeNull();
+    });
+
+    it('filters out malformed entries and keeps the valid ones', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, JSON.stringify([
+        {query: 'select 1', index: 0},
+        null,
+        {query: 'select 2'},
+        {index: 3},
+        {query: 7, index: 4},
+        {query: 'select 3', index: '5'},
+        {query: 'select 4', index: 6}
+      ]));
+
+      expect(service.load()).toEqual([{query: 'select 1', index: 0}, {query: 'select 4', index: 6}]);
+    });
+
+    it('keeps only the query and index of a persisted entry', () => {
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY,
+          JSON.stringify([{query: 'select 1', index: 0, results: [1, 2, 3]}]));
+
+      expect(service.load()).toEqual([{query: 'select 1', index: 0}]);
+    });
+
+    it('caps the number of restored entries', () => {
+      let persisted = [];
+      for (let i = 0; i < QUERY_HISTORY_MAX_ENTRIES + 10; i++) {
+        persisted.push({query: 'select ' + i, index: i});
+      }
+      localStorage.setItem(QUERY_HISTORY_STORAGE_KEY, JSON.stringify(persisted));
+
+      let loaded = service.load();
+
+      expect(loaded.length).toBe(QUERY_HISTORY_MAX_ENTRIES);
+      expect(loaded[QUERY_HISTORY_MAX_ENTRIES - 1].query).toBe('select ' + (QUERY_HISTORY_MAX_ENTRIES - 1));
+    });
+
+    it('returns an empty history when storage is unavailable', () => {
+      spyOn(localStorage, 'getItem').and.throwError('storage disabled');
+
+      expect(service.load()).toEqual([]);
+    });
+  });
+
+  describe('save() tests', () => {
+    it('persists only the query and the index, never the results', () => {
+      service.save([{query: 'select 1', index: 0, results: [1, 2, 3]} as any]);
+
+      expect(JSON.parse(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)))
+          .toEqual([{query: 'select 1', index: 0}]);
+    });
+
+    it('caps the number of persisted entries', () => {
+      let history = [];
+      for (let i = 0; i < QUERY_HISTORY_MAX_ENTRIES + 10; i++) {
+        history.push({query: 'select ' + i, index: i});
+      }
+
+      service.save(history);
+
+      expect(JSON.parse(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).length)
+          .toBe(QUERY_HISTORY_MAX_ENTRIES);
+    });
+
+    it('does not throw when storage rejects the write', () => {
+      spyOn(localStorage, 'setItem').and.throwError('quota exceeded');
+
+      expect(() => service.save([{query: 'select 1', index: 0}])).not.toThrow();
+    });
+  });
+
+  describe('clear() tests', () => {
+    it('removes the persisted history', () => {
+      service.save([{query: 'select 1', index: 0}]);
+      expect(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).not.toBeNull();
+
+      service.clear();
+
+      expect(localStorage.getItem(QUERY_HISTORY_STORAGE_KEY)).toBeNull();
+      expect(service.load()).toEqual([]);
+    });
+
+    it('does not throw when storage is unavailable', () => {
+      spyOn(localStorage, 'removeItem').and.throwError('storage disabled');
+
+      expect(() => service.clear()).not.toThrow();
+    });
+  });
+});
diff --git a/asterixdb/asterix-dashboard/src/node/src/tsconfig.spec.json b/asterixdb/asterix-dashboard/src/node/src/tsconfig.spec.json
index f953f10..59f0639 100644
--- a/asterixdb/asterix-dashboard/src/node/src/tsconfig.spec.json
+++ b/asterixdb/asterix-dashboard/src/node/src/tsconfig.spec.json
@@ -22,7 +22,6 @@
         ]
     },
     "files": [
-        "test.ts",
       "polyfills.ts"
   ],
     "include": [

-- 
To view, visit https://asterix-gerrit.ics.uci.edu/c/asterixdb/+/21751?usp=email
To unsubscribe, or for help writing mail filters, visit https://asterix-gerrit.ics.uci.edu/settings?usp=email

Gerrit-MessageType: newchange
Gerrit-Project: asterixdb
Gerrit-Branch: master
Gerrit-Change-Id: I708ec383df2bd490c297d29b1b23a0e28d5128c4
Gerrit-Change-Number: 21751
Gerrit-PatchSet: 1
Gerrit-Owner: Ian Maxon <im...@apache.org>