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>