diff --git a/package-lock.json b/package-lock.json index b5aa237..baab67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@mapbox/mapbox-gl-draw": "^1.3.0", "@metamask/detect-provider": "^1.2.0", "@types/mapbox__mapbox-gl-draw": "^1.2.5", - "@vaimee/desmold-sdk": "^1.0.0-alpha.16", + "@vaimee/desmold-sdk": "^1.0.0-alpha.17", "ethers": "^5.6.8", "mapbox-gl": "^2.9.2", "ngx-mapbox-gl": "^8.0.1", @@ -4968,9 +4968,9 @@ } }, "node_modules/@vaimee/desmold-sdk": { - "version": "1.0.0-alpha.16", - "resolved": "https://registry.npmjs.org/@vaimee/desmold-sdk/-/desmold-sdk-1.0.0-alpha.16.tgz", - "integrity": "sha512-FeG822xwg/10S6LF6DQSHOHu0HCAmL9iMKOemTNxaHsXDzjeBpsUnhfA5zkhprOmOEEA4ph0DL8zwCRP7KoBFw==", + "version": "1.0.0-alpha.17", + "resolved": "https://registry.npmjs.org/@vaimee/desmold-sdk/-/desmold-sdk-1.0.0-alpha.17.tgz", + "integrity": "sha512-Vv0tHNtEs/w4DGAT9JCgPyMzH65UjvHjrnADjl0PYRRapS32kiaLxStgRLJqwih8ux84QtVXx8VLKpad8+7L8w==", "dependencies": { "@vaimee/desmo-contracts": "^1.0.0-alpha.11", "ethers": "^5.6.9", @@ -20794,9 +20794,9 @@ } }, "@vaimee/desmold-sdk": { - "version": "1.0.0-alpha.16", - "resolved": "https://registry.npmjs.org/@vaimee/desmold-sdk/-/desmold-sdk-1.0.0-alpha.16.tgz", - "integrity": "sha512-FeG822xwg/10S6LF6DQSHOHu0HCAmL9iMKOemTNxaHsXDzjeBpsUnhfA5zkhprOmOEEA4ph0DL8zwCRP7KoBFw==", + "version": "1.0.0-alpha.17", + "resolved": "https://registry.npmjs.org/@vaimee/desmold-sdk/-/desmold-sdk-1.0.0-alpha.17.tgz", + "integrity": "sha512-Vv0tHNtEs/w4DGAT9JCgPyMzH65UjvHjrnADjl0PYRRapS32kiaLxStgRLJqwih8ux84QtVXx8VLKpad8+7L8w==", "requires": { "@vaimee/desmo-contracts": "^1.0.0-alpha.11", "ethers": "^5.6.9", diff --git a/package.json b/package.json index b800ef5..cf05b15 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@mapbox/mapbox-gl-draw": "^1.3.0", "@metamask/detect-provider": "^1.2.0", "@types/mapbox__mapbox-gl-draw": "^1.2.5", - "@vaimee/desmold-sdk": "^1.0.0-alpha.16", + "@vaimee/desmold-sdk": "^1.0.0-alpha.17", "ethers": "^5.6.8", "mapbox-gl": "^2.9.2", "ngx-mapbox-gl": "^8.0.1", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4708c30..c5f8bcc 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -42,6 +42,8 @@ import { QueryEventsTableComponent } from './components/query-events-table/query import { QueryPipe } from './pipes/query/query.pipe'; import { TddEventsTableComponent } from './components/tdd-events-table/tdd-events-table.component'; import { NotConnectedPageComponent } from './pages/not-connected-page/not-connected-page.component'; +import { QueryFormComponent } from './components/query-form/query-form.component'; +import { QueryResumeDialogComponent } from './components/query-resume-dialog/query-resume-dialog.component'; const mapboxToken = 'pk.eyJ1IjoiaW9zb25vcGVyc2lhIiwiYSI6ImNsNjBzYjVldjAwNWszaW1rNWZtdTRuNjkifQ.2lGOSvqt5lahEfZYLa3eRg'; @@ -61,6 +63,8 @@ const mapboxToken = QueryPipe, TddEventsTableComponent, NotConnectedPageComponent, + QueryFormComponent, + QueryResumeDialogComponent, ], imports: [ BrowserModule, diff --git a/src/app/components/query-form/query-form.component.css b/src/app/components/query-form/query-form.component.css new file mode 100644 index 0000000..9a27138 --- /dev/null +++ b/src/app/components/query-form/query-form.component.css @@ -0,0 +1,15 @@ +.grid-container { + margin: 20px; +} + +.margin-right { + margin-right: 12px; +} + +mgl-map { + width: 40vh; + height: 25vh; + min-width: 250px; + min-height: 155px; + margin-top: 10px; +} diff --git a/src/app/components/query-form/query-form.component.html b/src/app/components/query-form/query-form.component.html new file mode 100644 index 0000000..589185d --- /dev/null +++ b/src/app/components/query-form/query-form.component.html @@ -0,0 +1,333 @@ +
+

Query execution

+ + + Build your query + Hint: remember that you can use prefixes to shorten the IRIs! + + + + +
+ Property +
+ + Insert property name: + + + +
+
+ + Insert unit IRI: + + + +
+
+ Result datatype: + + + Integer + Decimal + Boolean + String + +
+ +
+ +
+
+
+ + + +
+ Query filters +
+ + Insert JsonPath filter expression: + + + +
+
+ + Insert dynamic filter expression: + + + +
+ +
+ + +
+
+
+ + + +
+ GEO filter +
+ Altitude range: +
+ Limits (in meters above sea level): + Lower bound + Upper bound +
+
+ + + + + + +
+ The lower bound must be strictly less than the upper bound. +
+
+
+ +
+ Region: +
+ Type of region filter: + + None + Polygon + Circle + +
+ + +
+ +
+ + +
+
+
+ + + +
+ TIME filter + Work in progress... +
+ + +
+
+
+ + + + Prefixes +

+ You didn't make use of any prefix. Please, skip to the next step! +

+
+

Please, provide the full version of the prefixes you used:

+
+
+ + {{ prefixNames[i] }}: < + + + > + +
+
+
+
+ + +
+
+ + + + Done +

You are now done!

+ + + + +
+
+
+
+
diff --git a/src/app/components/query-form/query-form.component.spec.ts b/src/app/components/query-form/query-form.component.spec.ts new file mode 100644 index 0000000..05dd55a --- /dev/null +++ b/src/app/components/query-form/query-form.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QueryFormComponent } from './query-form.component'; + +describe('QueryFormComponent', () => { + let component: QueryFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [QueryFormComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QueryFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/query-form/query-form.component.ts b/src/app/components/query-form/query-form.component.ts new file mode 100644 index 0000000..f6c8aa7 --- /dev/null +++ b/src/app/components/query-form/query-form.component.ts @@ -0,0 +1,320 @@ +import { StepperSelectionEvent } from '@angular/cdk/stepper'; +import { Component } from '@angular/core'; +import { + AbstractControl, + FormArray, + FormBuilder, + FormControl, + FormGroup, + ValidationErrors, + ValidatorFn, + Validators, +} from '@angular/forms'; +import { MatRadioChange } from '@angular/material/radio'; +import { MatStepper } from '@angular/material/stepper'; +import { Map as MapboxMap } from 'mapbox-gl'; +import * as MapboxDraw from '@mapbox/mapbox-gl-draw'; +import IQuery, { + RequestedDataType, + defaultIQuery, +} from 'src/app/interface/IQuery'; +import { EventEmitter, Output } from '@angular/core'; + +const drawPolygon: MapboxDraw = new MapboxDraw({ + displayControlsDefault: false, + // Select which mapbox-gl-draw control buttons to add to the map. + controls: { + polygon: true, + trash: true, + }, + // Set mapbox-gl-draw to draw by default. + // The user does not have to click the polygon control button first. + defaultMode: 'draw_polygon', +}); + +@Component({ + selector: 'app-query-form', + templateUrl: './query-form.component.html', + styleUrls: ['./query-form.component.css'], +}) +export class QueryFormComponent { + propertyFormGroup: FormGroup = this._fb.group({ + // propertyIRI: ['', [Validators.required, this.validIRIValidator()]], + propertyIRI: ['', [Validators.required]], + unitIRI: ['', [Validators.required, this.validIRIValidator()]], + datatype: [ + RequestedDataType.Integer, + [Validators.required, Validators.min(0), Validators.max(3)], + ], + }); + filtersFormGroup: FormGroup = this._fb.group( + { + jsonPathExpression: '', + dynamicFilterExpression: '', + geoAltitudeLimits: this._fb.group({ hasMin: false, hasMax: false }), + geoAltitudeRange: this._fb.group({ + min: [0, [Validators.min(0), Validators.max(10000)]], + max: [0, [Validators.min(0), Validators.max(10000)]], + }), + geoType: 'none', + }, + { validators: this.maxGreaterThanMin } + ); + prefixesFormArray: FormArray = this._fb.array([]); + prefixNames: string[] = []; + + @Output() querySubmitted: EventEmitter = new EventEmitter(); + + constructor(private _fb: FormBuilder) {} + + onFilterMapLoad(map: MapboxMap): void { + map.addControl(drawPolygon); + map.on('draw.create', (event: any) => { + const data = drawPolygon.getAll(); + if (event.type === 'draw.create') { + if (data.features.length > 1) { + alert('ERROR: you cannot create more than one polygon!'); + for (let i = 1; i < data.features.length; ++i) { + if (data.features[i].id !== undefined) { + const identifier = data.features[i].id; + if (identifier !== undefined) { + drawPolygon.delete(identifier.toString()); + } + } + } + } + } + }); + } + + clearPropertyControl(controlName: string) { + this.propertyFormGroup.controls[controlName].reset(); + } + + clearFilterControl(controlName: string) { + this.filtersFormGroup.controls[controlName].reset(); + } + + clearPrefixControl(controlIndex = -1) { + if (controlIndex >= 0 && controlIndex < this.prefixesFormArray.length) { + this.prefixesFormArray.controls[controlIndex].reset(); + } + throw new Error('Index out-of-bounds for the prefixesFormArray!'); + } + + getPrefixControl(index = -1): FormControl { + if (index >= 0 && index < this.prefixesFormArray.length) { + return this.prefixesFormArray.controls[index] as FormControl; + } + throw new Error('Index out-of-bounds for the prefixesFormArray!'); + } + + isSelectedGeoTypeNone(): boolean { + return this.filtersFormGroup.value.geoType === 'none'; + } + + async submitQuery() { + const query = defaultIQuery(); + + // Prefix list + for (let i = 0; i < this.prefixesFormArray.length; ++i) { + query.prefixList?.push({ + abbreviation: this.prefixNames[i], + completeURI: this.prefixesFormArray.controls[i].value, + }); + } + + /* + If only a single prefix is used, no response is given. + workaround to fix the following issue: https://github.com/vaimee/desmo-dapp/issues/15 + */ + query.prefixList?.push({ + abbreviation: 'xsd', + completeURI: 'http://www.w3.org/2001/XMLSchema/', + }); + + // Property + query.property.identifier = this.propertyFormGroup.value.propertyIRI; + query.property.unit = this.propertyFormGroup.value.unitIRI; + query.property.datatype = this.propertyFormGroup.value.datatype; + + // Query filters + query.staticFilter = this.filtersFormGroup.value.jsonPathExpression; + + // To uncomment when these features will be implemented + /* + query.dynamicFilter = + this.filtersFormGroup.value.dynamicFilterExpression; + + // GEO filter + // Altitude range + const {hasMin, hasMax} = this.filtersFormGroup.value.geoAltitudeLimits; + const altitudeBounds = this.filtersFormGroup.value.geoAltitudeRange; + if (hasMin || hasMax) { + query.geoFilter!.altitudeRange = {} as IGeoAltitudeRange; + if (hasMin) { + query.geoFilter!.altitudeRange!.min = altitudeBounds.min; + } + if (hasMax) { + query.geoFilter!.altitudeRange!.max = altitudeBounds.max; + } + query.geoFilter!.altitudeRange!.unit = "https://qudt.org/vocab/unit/M"; + } + // Region + const geoRegionType: string = this.filtersFormGroup.value.geoType; + if (geoRegionType === 'polygon') { + const vertices = (drawPolygon.getAll().features[0].geometry as any) + .coordinates[0]; + // For a polygon to be well-defined, it must contain + // at least 3 vertices + 1 (the first one repeated at the end): + if (vertices.length >= 4) { + query.geoFilter!.region = { vertices: [] }; + for (const vertex of vertices) { + const curVertex: IGeoPosition = { + longitude: vertex[0], + latitude: vertex[1], + }; + query.geoFilter!.region.vertices.push(curVertex); + } + } + } else if (geoRegionType === 'circle') { + // TODO + } + + // TODO: other parts of the query... + */ + this.querySubmitted.emit(query); + } + + resetQueryBuilder(stepperObject: MatStepper) { + stepperObject.reset(); // All the controls are set to null + + // The datatype must be either 0, 1, 2 or 3 + this.propertyFormGroup.controls['datatype'].setValue( + RequestedDataType.Integer + ); + + // The geoType must be either 'none', 'polygon' or 'circle' + this.filtersFormGroup.controls['geoType'].setValue('none'); + + this.filtersFormGroup.get('geoAltitudeRange.min')?.setValue(0); + this.filtersFormGroup.get('geoAltitudeRange.max')?.setValue(0); + } + + handlePrefixes(event: StepperSelectionEvent) { + if (event.selectedIndex === 4) { + // Collect prefixes used by the user: + const prefixes = new Set(); + const IRIs = [ + this.propertyFormGroup.value.propertyIRI, + this.propertyFormGroup.value.unitIRI, + ]; + for (const iri of IRIs) { + const prefix = this.getPrefix(iri); + if (prefix !== null) prefixes.add(prefix); + } + + // Prefixes to be removed: + const prefixesToBeRemoved = new Set( + this.prefixNames.filter((x) => !prefixes.has(x)) + ); + for (const prefix of prefixesToBeRemoved) { + const prefixIndex = this.prefixNames.indexOf(prefix); + this.prefixesFormArray.removeAt(prefixIndex); + this.prefixNames.splice(prefixIndex, 1); + } + + // Prefixes to be added: + const prefixesToBeAdded = new Set( + [...prefixes].filter((x) => !this.prefixNames.includes(x)) + ); + for (const prefix of prefixesToBeAdded) { + this.prefixesFormArray.push( + this._fb.control('', [Validators.required, this.httpUrlValidator()]) + ); + this.prefixNames.push(prefix); + } + } + } + + public handleGeoTypeChanged(event: MatRadioChange) { + if (event.value === 'none') { + } else if (event.value === 'polygon') { + } else if (event.value === 'circle') { + alert('ERROR: unimplemented feature!'); + this.filtersFormGroup.controls['geoType'].setValue('none'); + } + } + private getPrefix(data: string): string | null { + data = data.trim(); + // if it's an URL, it's valid + //check if it is a valid URI + if (this.isValidHttpUrl(data)) { + return null; + } + const slices: string[] = data.split(':') as string[]; + if (slices.length === 2) { + return slices[0]; + } + return null; + } + + private isValidHttpUrl(string: string) { + let url; + + try { + url = new URL(string); + } catch (_) { + return false; + } + + return url.protocol === 'http:' || url.protocol === 'https:'; + } + + private validIRIValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (control.value !== null) { + const IRIString = control.value as string; + if (this.isValidHttpUrl(IRIString)) { + return null; + } + + const semicolonIndex = IRIString.indexOf(':'); + if (semicolonIndex >= 0) { + return null; + } + } + return { invalidIRI: { value: control.value } }; + }; + } + + private httpUrlValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + if (control.value !== null) { + const IRIString = control.value as string; + + if ( + this.isValidHttpUrl(IRIString) && + (IRIString.endsWith('#') || IRIString.endsWith('/')) + ) { + return null; + } + } + return { invalidHttpUrl: { value: control.value } }; + }; + } + + private maxGreaterThanMin(control: AbstractControl): ValidationErrors | null { + const hasMin = control.get('geoAltitudeLimits.hasMin')?.value; + const hasMax = control.get('geoAltitudeLimits.hasMax')?.value; + const min = control.get('geoAltitudeRange.min')?.value; + const max = control.get('geoAltitudeRange.max')?.value; + if (hasMin && hasMax && min !== null && max !== null) { + if (min >= max) { + return { maxGreaterThanMin: true }; + } + } + + return null; + } +} diff --git a/src/app/components/query-resume-dialog/query-resume-dialog.component.css b/src/app/components/query-resume-dialog/query-resume-dialog.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/query-resume-dialog/query-resume-dialog.component.html b/src/app/components/query-resume-dialog/query-resume-dialog.component.html new file mode 100644 index 0000000..3e25596 --- /dev/null +++ b/src/app/components/query-resume-dialog/query-resume-dialog.component.html @@ -0,0 +1,19 @@ +

Warning!

+ + +

An unfinished query execution was detected.

+
+ + + + + diff --git a/src/app/components/query-resume-dialog/query-resume-dialog.component.spec.ts b/src/app/components/query-resume-dialog/query-resume-dialog.component.spec.ts new file mode 100644 index 0000000..c1aa7d4 --- /dev/null +++ b/src/app/components/query-resume-dialog/query-resume-dialog.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { QueryResumeDialogComponent } from './query-resume-dialog.component'; + +describe('QueryResumeDialogComponent', () => { + let component: QueryResumeDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [QueryResumeDialogComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(QueryResumeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/query-resume-dialog/query-resume-dialog.component.ts b/src/app/components/query-resume-dialog/query-resume-dialog.component.ts new file mode 100644 index 0000000..04750f4 --- /dev/null +++ b/src/app/components/query-resume-dialog/query-resume-dialog.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-query-resume-dialog', + templateUrl: './query-resume-dialog.component.html', + styleUrls: ['./query-resume-dialog.component.css'], +}) +export class QueryResumeDialogComponent {} diff --git a/src/app/interface/IResult.ts b/src/app/interface/IResult.ts index c19ec82..848ca43 100644 --- a/src/app/interface/IResult.ts +++ b/src/app/interface/IResult.ts @@ -1,3 +1,4 @@ +import { Bytes } from 'ethers'; import IQuery, { defaultIQuery } from './IQuery'; export enum QueryResultTypes { @@ -19,6 +20,7 @@ export interface IResult { data: IResultData; elapsedTime: number; query: IQuery; + requestId: Bytes; } interface IResultData { @@ -38,6 +40,7 @@ export function defaultIResult(): IResult { }, elapsedTime: 0, query: defaultIQuery(), + requestId: [0], }; } diff --git a/src/app/pages/query-page/query-page.component.css b/src/app/pages/query-page/query-page.component.css index d083a09..0167f9b 100644 --- a/src/app/pages/query-page/query-page.component.css +++ b/src/app/pages/query-page/query-page.component.css @@ -2,42 +2,12 @@ margin: 20px; } -.dashboard-card { - position: absolute; - top: 15px; - left: 15px; - right: 15px; - bottom: 15px; -} - -.add-button { - margin: 20px; -} - -.more-button { - position: absolute; - top: 5px; - right: 10px; -} - -.dashboard-card-content { - text-align: center; -} - -mgl-map { - width: 40vh; - height: 25vh; - min-width: 250px; - min-height: 155px; - margin-top: 10px; -} - -.margin-right { - margin-right: 12px; -} - table.mat-table { top: 10px; display: table; width: 100%; } + +::ng-deep .mat-horizontal-stepper-header{ + pointer-events: none !important; +} diff --git a/src/app/pages/query-page/query-page.component.html b/src/app/pages/query-page/query-page.component.html index 4bcb9f3..fffa2f9 100644 --- a/src/app/pages/query-page/query-page.component.html +++ b/src/app/pages/query-page/query-page.component.html @@ -1,335 +1,9 @@ -
-

Query execution

- - - Build your query - Hint: remember that you can use prefixes to shorten the IRIs! - - - - -
- Property -
- - Insert property name: - - - -
-
- - Insert unit IRI: - - - -
-
- Result datatype: - - - Integer - Decimal - Boolean - String - -
- -
- -
-
-
- - - -
- Query filters -
- - Insert JsonPath filter expression: - - - -
-
- - Insert dynamic filter expression: - - - -
- -
- - -
-
-
- - - -
- GEO filter -
- Altitude range: -
- Limits (in meters above sea level): - Lower bound - Upper bound -
-
- - - - - - -
- The lower bound must be strictly less than the upper bound. -
-
-
- -
- Region: -
- Type of region filter: - - None - Polygon - Circle - -
- - -
- -
- - -
-
-
+ - - -
- TIME filter - Work in progress... -
- - -
-
-
- - - - Prefixes -

- You didn't make use of any prefix. Please, skip to the next step! -

-
-

Please, provide the full version of the prefixes you used:

-
-
- - {{ prefixNames[i] }}: < - - - > - -
-
-
-
- - -
-
- - - - Done -

You are now done!

- - - - -
-
-
-
+
You are now done! *ngIf="this.result.loading" > + + + + + done + + Requesting ID + + + + done + + Buying query + + + + done + + Executing query + + + + done + + Retrieving result + + + done + + + Done + + +

Query Result

diff --git a/src/app/pages/query-page/query-page.component.ts b/src/app/pages/query-page/query-page.component.ts index 9e0d00d..e5a8994 100644 --- a/src/app/pages/query-page/query-page.component.ts +++ b/src/app/pages/query-page/query-page.component.ts @@ -1,26 +1,7 @@ import { DesmoldSDKService } from 'src/app/services/desmold-sdk/desmold-sdk.service'; -import { Component, OnInit } from '@angular/core'; -import IQuery, { - RequestedDataType, - defaultIQuery, -} from 'src/app/interface/IQuery'; - -import { - AbstractControl, - FormArray, - FormBuilder, - FormControl, - FormGroup, - ValidationErrors, - ValidatorFn, - Validators, -} from '@angular/forms'; -import { StepperSelectionEvent } from '@angular/cdk/stepper'; -import { MatStepper } from '@angular/material/stepper'; -import { Map as MapboxMap } from 'mapbox-gl'; -import * as MapboxDraw from '@mapbox/mapbox-gl-draw'; -import { MatRadioChange } from '@angular/material/radio'; -import { firstValueFrom } from 'rxjs'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import IQuery from 'src/app/interface/IQuery'; +import { Subscription, firstValueFrom } from 'rxjs'; import { environment } from 'src/environments/environment'; import { IResult, @@ -30,345 +11,269 @@ import { defaultIResultTable, } from 'src/app/interface/IResult'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { Bytes } from 'ethers'; +import { MatDialog } from '@angular/material/dialog'; +import { QueryResumeDialogComponent } from 'src/app/components/query-resume-dialog/query-resume-dialog.component'; + +interface IQueryState { + executing: boolean; + checkPoint: number; -let filterMap: MapboxMap; -const drawPolygon: MapboxDraw = new MapboxDraw({ - displayControlsDefault: false, - // Select which mapbox-gl-draw control buttons to add to the map. - controls: { - polygon: true, - trash: true, - }, - // Set mapbox-gl-draw to draw by default. - // The user does not have to click the polygon control button first. - defaultMode: 'draw_polygon', -}); + query?: IQuery; + requestID?: Bytes; +} @Component({ selector: 'app-query-page', templateUrl: './query-page.component.html', styleUrls: ['./query-page.component.css'], }) -export class QueryPageComponent implements OnInit { - query: IQuery = defaultIQuery(); - result: IResult = defaultIResult(); - completed = false; +export class QueryPageComponent implements OnInit, OnDestroy { + result: IResult; + displayedColumns: string[] = ['property', 'value', 'unit', 'time']; - resultTable: IResultTable[] = defaultIResultTable(); - - propertyFormGroup: FormGroup = this._fb.group({ - // propertyIRI: ['', [Validators.required, this.validIRIValidator()]], - propertyIRI: ['', [Validators.required]], - unitIRI: ['', [Validators.required, this.validIRIValidator()]], - datatype: [ - RequestedDataType.Integer, - [Validators.required, Validators.min(0), Validators.max(3)], - ], - }); - filtersFormGroup: FormGroup = this._fb.group( - { - jsonPathExpression: '', - dynamicFilterExpression: '', - geoAltitudeLimits: this._fb.group({ hasMin: false, hasMax: false }), - geoAltitudeRange: this._fb.group({ - min: [0, [Validators.min(0), Validators.max(10000)]], - max: [0, [Validators.min(0), Validators.max(10000)]], - }), - geoType: 'none', - }, - { validators: this.maxGreaterThanMin } - ); - prefixesFormArray: FormArray = this._fb.array([]); - prefixNames: string[] = []; + resultTable: IResultTable[]; + start = 0; + + stepperIndex = 0; + + private subscriptions: Subscription; + private queryStateDict: Record; + private currentUserAddress = ''; + private readonly CACHE_KEY = 'queryState'; constructor( - private _fb: FormBuilder, private desmold: DesmoldSDKService, - private snackBar: MatSnackBar - ) {} + private snackBar: MatSnackBar, + public dialog: MatDialog, + private cd: ChangeDetectorRef + ) { + this.result = defaultIResult(); + this.resultTable = defaultIResultTable(); + this.subscriptions = new Subscription(); + + // Check the cache for pre-existing data or initialise with an empty object: + const cachedValueStr: string = localStorage.getItem(this.CACHE_KEY) ?? '{}'; + this.queryStateDict = JSON.parse(cachedValueStr) as Record< + string, + IQueryState + >; + } - async ngOnInit(): Promise { + public async ngOnInit(): Promise { await this.desmold.isReady; if (!this.desmold.desmoHub.isListening) { await this.desmold.desmoHub.startListeners(); } - } - onFilterMapLoad(map: MapboxMap): void { - filterMap = map; - map.addControl(drawPolygon); - map.on('draw.create', (event: any) => { - const data = drawPolygon.getAll(); - if (event.type === 'draw.create') { - if (data.features.length > 1) { - alert('ERROR: you cannot create more than one polygon!'); - for (let i = 1; i < data.features.length; ++i) { - const identifier = data.features[i].id; - if (identifier !== undefined) { - drawPolygon.delete(identifier.toString()); - } - } + this.currentUserAddress = this.desmold.userAddress; + + this.subscriptions.add( + this.desmold.accountsChanged.subscribe(async ({ newValue }) => { + if (this.result.loading === true) { + // User changed wallet during the execution of a query: + // the best thing to do is to force the page to reload + // so that every piece of code that is still running gets + // immediately stopped. + window.location.reload(); + } else { + this.currentUserAddress = newValue; + await this.checkForUnfinishedQueryExecution(); } - } - }); - } + }) + ); - clearPropertyControl(controlName: string) { - this.propertyFormGroup.controls[controlName].reset(); - } + this.subscriptions.add( + this.desmold.desmo.queryState.subscribe((state: any) => { + if (state.state === 'TASK_UPDATED') { + this.notifySentTransaction('The task is processing...'); + } + if (state.state === 'TASK_COMPLETED') { + this.stepperIndex = 3; + this.notifySentTransaction('The task is completed'); + } + if (state.state === 'TASK_FAILED') { + this.stepperIndex = 0; + this.notifySentTransaction('The task has failed'); + } + if (state.state === 'TASK_TIMEDOUT') { + this.stepperIndex = 0; + this.notifySentTransaction('The task took too long to complete'); + } + }) + ); - clearFilterControl(controlName: string) { - this.filtersFormGroup.controls[controlName].reset(); + // Before (potentially) starting a query execution process, + // we need to set all needed subscriptions. This is why + // the following line is the last one inside this function: + await this.checkForUnfinishedQueryExecution(); } - clearPrefixControl(controlIndex = -1) { - if (controlIndex >= 0 && controlIndex < this.prefixesFormArray.length) { - this.prefixesFormArray.controls[controlIndex].reset(); + private async checkForUnfinishedQueryExecution(): Promise { + if (this.queryStateDict[this.currentUserAddress] === undefined) { + // Initialize the query state data structure + // for the currently-selected user: + this.resetQueryState(); } - throw new Error('Index out-of-bounds for the prefixesFormArray!'); - } - getPrefixControl(index = -1): FormControl { - if (index >= 0 && index < this.prefixesFormArray.length) { - return this.prefixesFormArray.controls[index] as FormControl; + if (this.queryStateDict[this.currentUserAddress].checkPoint >= 0) { + const dialogRef = this.dialog.open(QueryResumeDialogComponent, { + disableClose: true, + autoFocus: true, + }); + + dialogRef.afterClosed().subscribe(async (result) => { + if (result) { + await this.continueQueryExecution(); + } else { + this.resetQueryState(); + } + }); } - throw new Error('Index out-of-bounds for the prefixesFormArray!'); } - isSelectedGeoTypeNone(): boolean { - return this.filtersFormGroup.value.geoType === 'none'; - } + public async executeQuery(query: IQuery): Promise { + if (this.queryStateDict[this.currentUserAddress].checkPoint >= 0) { + throw new Error( + "Cannot execute query until the current one isn't finished." + ); + } - async submitQuery() { - const start: number = this.now(); this.result.loading = true; this.resultTable = defaultIResultTable(); - this.query = defaultIQuery(); - // Prefix list + // Reset the query state for the currently-selected user + // and save checkPoint "zero": + this.resetQueryState(); + this.saveCheckPointZero(query); - for (let i = 0; i < this.prefixesFormArray.length; ++i) { - this.query.prefixList?.push({ - abbreviation: this.prefixNames[i], - completeURI: this.prefixesFormArray.controls[i].value, - }); - } + // Execute the first phase of the query execution process + // and save checkPoint "one": + const requestID: Bytes = await this.executeFirstPhase(); + this.saveCheckPointOne(requestID); - /* - If only a single prefix is used, no response is given. - workaround to fix the following issue: https://github.com/vaimee/desmo-dapp/issues/15 - */ - this.query.prefixList?.push({ - abbreviation: 'xsd', - completeURI: 'http://www.w3.org/2001/XMLSchema/', - }); + // Execute the second phase of the query execution process + // and terminate: + await this.executeSecondPhase(query, requestID); - // Property - this.query.property.identifier = this.propertyFormGroup.value.propertyIRI; - this.query.property.unit = this.propertyFormGroup.value.unitIRI; - this.query.property.datatype = this.propertyFormGroup.value.datatype; - - // Query filters - this.query.staticFilter = this.filtersFormGroup.value.jsonPathExpression; - - // To uncomment when these features will be implemented - /* - this.query.dynamicFilter = - this.filtersFormGroup.value.dynamicFilterExpression; - - // GEO filter - // Altitude range - const {hasMin, hasMax} = this.filtersFormGroup.value.geoAltitudeLimits; - const altitudeBounds = this.filtersFormGroup.value.geoAltitudeRange; - if (hasMin || hasMax) { - this.query.geoFilter!.altitudeRange = {} as IGeoAltitudeRange; - if (hasMin) { - this.query.geoFilter!.altitudeRange!.min = altitudeBounds.min; - } - if (hasMax) { - this.query.geoFilter!.altitudeRange!.max = altitudeBounds.max; - } - this.query.geoFilter!.altitudeRange!.unit = "https://qudt.org/vocab/unit/M"; - } - // Region - const geoRegionType: string = this.filtersFormGroup.value.geoType; - if (geoRegionType === 'polygon') { - const vertices = (drawPolygon.getAll().features[0].geometry as any) - .coordinates[0]; - // For a polygon to be well-defined, it must contain - // at least 3 vertices + 1 (the first one repeated at the end): - if (vertices.length >= 4) { - this.query.geoFilter!.region = { vertices: [] }; - for (const vertex of vertices) { - const curVertex: IGeoPosition = { - longitude: vertex[0], - latitude: vertex[1], - }; - this.query.geoFilter!.region.vertices.push(curVertex); - } - } - } else if (geoRegionType === 'circle') { - // TODO - } + this.result.loading = false; + } - // TODO: other parts of the query... - */ - console.log(this.query); + private saveCheckPointZero(query: IQuery) { + this.queryStateDict[this.currentUserAddress].executing = false; + this.queryStateDict[this.currentUserAddress].checkPoint = 0; + this.queryStateDict[this.currentUserAddress].query = query; - const eventPromise = firstValueFrom(this.desmold.desmoHub.requestID$); - await this.desmold.desmoHub.getNewRequestID(); - const event = await eventPromise; - this.notifySentTransaction('new request ID received'); + // Persist the current state: + localStorage.setItem(this.CACHE_KEY, JSON.stringify(this.queryStateDict)); + } - const queryToSend: string = this.queryToSend(this.query); - await this.desmold.desmo.buyQuery( - event.requestID, - queryToSend, - environment.iExecDAppAddress - ); - this.notifySentTransaction('Query successfully sent'); - const { result, type } = await this.desmold.desmo.getQueryResult(); - this.notifySentTransaction('Query result received'); - const elapsedTime = this.elapsed(start); - this.queryCompleted(result, type, elapsedTime); + private saveCheckPointOne(requestID: Bytes) { + this.queryStateDict[this.currentUserAddress].executing = true; + this.queryStateDict[this.currentUserAddress].checkPoint = 1; + this.queryStateDict[this.currentUserAddress].requestID = requestID; + + // Persist the current state: + localStorage.setItem(this.CACHE_KEY, JSON.stringify(this.queryStateDict)); } - resetQueryBuilder(stepperObject: MatStepper) { - stepperObject.reset(); // All the controls are set to null - this.resultReset(); + private async continueQueryExecution() { + const cachedCheckPoint = + this.queryStateDict[this.currentUserAddress].checkPoint; - // The datatype must be either 0, 1, 2 or 3 - this.propertyFormGroup.controls['datatype'].setValue( - RequestedDataType.Integer - ); + // We must ensure that the checkPoint is >= 0: + if (cachedCheckPoint < 0) { + throw new Error( + "Cannot continue executing a query that didn't even start." + ); + } - // The geoType must be either 'none', 'polygon' or 'circle' - this.filtersFormGroup.controls['geoType'].setValue('none'); + // Update the view to show the query execution UI: + this.result.loading = true; + this.resultTable = defaultIResultTable(); + this.stepperIndex = 0; + this.cd.detectChanges(); // This is needed to make sure the previous lines have a graphical effect - this.filtersFormGroup.get('geoAltitudeRange.min')?.setValue(0); - this.filtersFormGroup.get('geoAltitudeRange.max')?.setValue(0); - } + // If the checkPoint is >= 0, than we're sure that a query was cached: + const query = this.queryStateDict[this.currentUserAddress].query; - handlePrefixes(event: StepperSelectionEvent) { - this.resultReset(); // The result is no longer valid - if (event.selectedIndex === 4) { - // Collect prefixes used by the user: - const prefixes = new Set(); - const IRIs = [ - this.propertyFormGroup.value.propertyIRI, - this.propertyFormGroup.value.unitIRI, - ]; - for (const iri of IRIs) { - const prefix = this.getPrefix(iri); - if (prefix !== null) prefixes.add(prefix); - } - - // Prefixes to be removed: - const prefixesToBeRemoved = new Set( - this.prefixNames.filter((x) => !prefixes.has(x)) - ); - for (const prefix of prefixesToBeRemoved) { - const prefixIndex = this.prefixNames.indexOf(prefix); - this.prefixesFormArray.removeAt(prefixIndex); - this.prefixNames.splice(prefixIndex, 1); - } - - // Prefixes to be added: - const prefixesToBeAdded = new Set( - [...prefixes].filter((x) => !this.prefixNames.includes(x)) + // The query must be defined at this stage: + if (query === undefined) { + throw new Error( + 'Invalid query state: missing query at checkpoint "zero".' ); - for (const prefix of prefixesToBeAdded) { - this.prefixesFormArray.push( - this._fb.control('', [Validators.required, this.httpUrlValidator()]) - ); - this.prefixNames.push(prefix); - } } - } - public handleGeoTypeChanged(event: MatRadioChange) { - if (event.value === 'none') { - } else if (event.value === 'polygon') { - } else if (event.value === 'circle') { - alert('ERROR: unimplemented feature!'); - this.filtersFormGroup.controls['geoType'].setValue('none'); - } - } - private getPrefix(data: string): string | null { - data = data.trim(); - // if it's an URL, it's valid - //check if it is a valid URI - if (this.isValidHttpUrl(data)) { - return null; + // Now we need the requestID: + let requestID: Bytes | undefined; + if (cachedCheckPoint === 0) { + // Execute the first phase and save checkPoint "one": + requestID = await this.executeFirstPhase(); + this.saveCheckPointOne(requestID); + } else if (cachedCheckPoint === 1) { + // If the checkPoint is === 1, than we're sure that a requestID was cached: + requestID = this.queryStateDict[this.currentUserAddress].requestID; } - const slices: string[] = data.split(':') as string[]; - if (slices.length === 2) { - return slices[0]; - } - return null; - } - - private isValidHttpUrl(string: string) { - let url; - try { - url = new URL(string); - } catch (_) { - return false; + // The requestID must be defined at this stage: + if (requestID === undefined) { + throw new Error( + 'Invalid query state: missing requestID at checkpoint "one".' + ); } - return url.protocol === 'http:' || url.protocol === 'https:'; - } + // Execute the second phase: + await this.executeSecondPhase(query, requestID); - private validIRIValidator(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value !== null) { - const IRIString = control.value as string; - if (this.isValidHttpUrl(IRIString)) { - return null; - } - - const semicolonIndex = IRIString.indexOf(':'); - if (semicolonIndex >= 0) { - return null; - } - } - return { invalidIRI: { value: control.value } }; - }; + // Update the view: + this.result.loading = false; } - private httpUrlValidator(): ValidatorFn { - return (control: AbstractControl): ValidationErrors | null => { - if (control.value !== null) { - const IRIString = control.value as string; + private async executeFirstPhase(): Promise { + this.stepperIndex = 0; + const eventPromise = firstValueFrom(this.desmold.desmoHub.requestID$); + await this.desmold.desmoHub.getNewRequestID(); + const event = await eventPromise; + this.result.requestId = event.requestID; + this.notifySentTransaction('New request ID obtained.'); - if ( - this.isValidHttpUrl(IRIString) && - (IRIString.endsWith('#') || IRIString.endsWith('/')) - ) { - return null; - } - } - return { invalidHttpUrl: { value: control.value } }; - }; + return event.requestID; } - private maxGreaterThanMin(control: AbstractControl): ValidationErrors | null { - const hasMin = control.get('geoAltitudeLimits.hasMin')?.value; - const hasMax = control.get('geoAltitudeLimits.hasMax')?.value; - const min = control.get('geoAltitudeRange.min')?.value; - const max = control.get('geoAltitudeRange.max')?.value; - if (hasMin && hasMax && min !== null && max !== null) { - if (min >= max) { - return { maxGreaterThanMin: true }; - } - } + private async executeSecondPhase( + query: IQuery, + requestID: Bytes + ): Promise { + this.stepperIndex = 1; + const queryToSend: string = this.encodeQuery(query); + await this.desmold.desmo.buyQuery( + requestID, + queryToSend, + environment.iExecDAppAddress + ); + this.notifySentTransaction('Query successfully sent.'); - return null; + this.stepperIndex = 2; + try { + const { result, type } = await this.desmold.desmo.getQueryResult(); + + this.notifySentTransaction('Query result received.'); + const elapsedTime = this.elapsed(this.start); + this.queryCompleted(query, result, type, elapsedTime); + this.stepperIndex = 4; + } catch { + this.notifySentTransaction('Query execution failed.'); + this.resultReset(); + } finally { + // Query execution terminated (successfully or not). + // Let's reset the cached query state! + this.resetQueryState(); + } } private queryCompleted( + query: IQuery, value: number | string, type: QueryResultTypes, elapsedTime: number @@ -378,22 +283,33 @@ export class QueryPageComponent implements OnInit { this.result.data.value = value; this.result.data.type = type; this.result.elapsedTime = elapsedTime; - this.result.query = this.query; + this.result.query = query; const resultTable: IResultTable = { - property: this.query.property.identifier, + property: query.property.identifier, value: value, - unit: this.query.property.unit, + unit: query.property.unit, time: this.result.elapsedTime, }; this.resultTable.push(resultTable); } + private resetQueryState() { + const initialQueryState: IQueryState = { + executing: false, + checkPoint: -1, + }; + this.queryStateDict[this.currentUserAddress] = initialQueryState; + + // Persist the current state: + localStorage.setItem(this.CACHE_KEY, JSON.stringify(this.queryStateDict)); + } + private resultReset(): void { this.result = defaultIResult(); this.resultTable = defaultIResultTable(); } - private queryToSend(query: IQuery): string { + private encodeQuery(query: IQuery): string { const queryString: string = JSON.stringify(query); const transformedQuery: string = queryString .trim() @@ -402,15 +318,22 @@ export class QueryPageComponent implements OnInit { console.log(transformedQuery); return transformedQuery; } + private now(): number { return new Date().getTime(); } + private elapsed(start: number): number { return this.now() - start; } + private notifySentTransaction(message: string) { this.snackBar.open(message, 'Dismiss', { duration: 1000, }); } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } }