From 91bb63710750cc1c664c7385d699df4947740f1d Mon Sep 17 00:00:00 2001 From: Tobias Schweizer Date: Mon, 21 Jun 2021 16:57:00 +0200 Subject: [PATCH] feat(viewer): improve editing of geonames (DSP-1212) (#307) * feat(viewer): return more detailed information for geonames * feat(viewer): add search method to geoname service * test(viewer): add specs to geoname service * feat(viewer): edit / create geoname value form a list * test(viewer): adapt spec * test(viewer): adapt spec * test(viewer): adapt spec * feat(viewer): make sure geoname id is always a string * feat(viewer): make language consistent when querying geonames * feat(viewer): show current value when editing geoname * test(viewer): adapt spec --- .../viewer/services/geoname.service.spec.ts | 348 ++++++++++++++++-- .../lib/viewer/services/geoname.service.ts | 136 +++++-- .../geoname-value.component.html | 13 +- .../geoname-value.component.scss | 4 + .../geoname-value.component.spec.ts | 175 ++++++--- .../geoname-value/geoname-value.component.ts | 67 +++- 6 files changed, 623 insertions(+), 120 deletions(-) diff --git a/projects/dsp-ui/src/lib/viewer/services/geoname.service.spec.ts b/projects/dsp-ui/src/lib/viewer/services/geoname.service.spec.ts index 4872b5d9a..baba1ce0f 100644 --- a/projects/dsp-ui/src/lib/viewer/services/geoname.service.spec.ts +++ b/projects/dsp-ui/src/lib/viewer/services/geoname.service.spec.ts @@ -1,8 +1,210 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { GeonameService } from './geoname.service'; +import { DisplayPlace, GeonameService } from './geoname.service'; import { AppInitService } from '../../core'; +const geonamesGetResponse = { + 'timezone': {'gmtOffset': 1, 'timeZoneId': 'Europe/Zurich', 'dstOffset': 2}, + 'asciiName': 'Zuerich Enge', + 'astergdem': 421, + 'countryId': '2658434', + 'fcl': 'S', + 'srtm3': 412, + 'adminId2': '6458798', + 'adminId3': '7287650', + 'countryCode': 'CH', + 'adminCodes1': {'ISO3166_2': 'ZH'}, + 'adminId1': '2657895', + 'lat': '47.3641', + 'fcode': 'RSTN', + 'continentCode': 'EU', + 'adminCode2': '112', + 'adminCode3': '261', + 'adminCode1': 'ZH', + 'lng': '8.53081', + 'geonameId': 11963110, + 'toponymName': 'Zürich Enge', + 'population': 0, + 'wikipediaURL': 'en.wikipedia.org/wiki/Z%C3%BCrich_Enge_railway_station', + 'adminName5': '', + 'adminName4': '', + 'adminName3': 'Zurich', + 'alternateNames': [{ + 'name': '8503010', + 'lang': 'uicn' + }, {'name': 'https://en.wikipedia.org/wiki/Z%C3%BCrich_Enge_railway_station', 'lang': 'link'}, { + 'name': 'ZEN', + 'lang': 'abbr' + }, {'isShortName': true, 'isPreferredName': true, 'name': 'Zürich Enge'}], + 'adminName2': 'Zürich District', + 'name': 'Zürich Enge', + 'fclName': 'spot, building, farm', + 'countryName': 'Switzerland', + 'fcodeName': 'railroad station', + 'adminName1': 'Zurich' +}; + +const geonamesSearchResponse = { + 'totalResultsCount': 203, 'geonames': [{ + 'timezone': {'gmtOffset': 1, 'timeZoneId': 'Europe/Zurich', 'dstOffset': 2}, + 'bbox': { + 'east': 7.634148441523814, + 'south': 47.523628289543254, + 'north': 47.58955415634046, + 'west': 7.554659665553558, + 'accuracyLevel': 10 + }, + 'asciiName': 'Basel', + 'astergdem': 287, + 'countryId': '2658434', + 'fcl': 'P', + 'srtm3': 279, + 'score': 23.979536056518555, + 'adminId2': '6458763', + 'adminId3': '7285161', + 'countryCode': 'CH', + 'adminCodes1': {'ISO3166_2': 'BS'}, + 'adminId1': '2661602', + 'lat': '47.55839', + 'fcode': 'PPLA', + 'continentCode': 'EU', + 'adminCode2': '1200', + 'adminCode3': '2701', + 'adminCode1': 'BS', + 'lng': '7.57327', + 'geonameId': 2661604, + 'toponymName': 'Basel', + 'population': 164488, + 'adminName5': '', + 'adminName4': '', + 'adminName3': 'Basel', + 'alternateNames': [{'name': 'Basel', 'lang': 'als'}, {'name': 'ባዝል', 'lang': 'am'}, { + 'name': 'بازل', + 'lang': 'ar' + }, {'name': 'بازل', 'lang': 'arz'}, {'name': 'بازل', 'lang': 'azb'}, { + 'name': 'Базель', + 'lang': 'be' + }, {'name': 'Базел', 'lang': 'bg'}, {'name': 'বাজেল', 'lang': 'bn'}, { + 'name': 'པ་སེལ།', + 'lang': 'bo' + }, {'name': 'Basel', 'lang': 'bs'}, {'name': 'Basilea', 'lang': 'ca'}, { + 'name': 'Базель', + 'lang': 'ce' + }, {'name': 'بازل', 'lang': 'ckb'}, {'name': 'Basilej', 'lang': 'cs'}, { + 'name': 'Базель', + 'lang': 'cv' + }, {'name': 'Basel', 'lang': 'da'}, {'name': 'Basel', 'lang': 'de'}, { + 'name': 'Βασιλεία', + 'lang': 'el' + }, {'name': 'Basel', 'lang': 'en'}, {'name': 'Bazelo', 'lang': 'eo'}, { + 'name': 'Basilea', + 'lang': 'es' + }, {'name': 'بازل', 'lang': 'fa'}, {'name': 'Basel', 'lang': 'fi'}, { + 'name': 'Bâle', + 'lang': 'fr' + }, {'name': 'Bâla', 'lang': 'frp'}, {'name': 'Bāsel', 'lang': 'frr'}, { + 'name': 'בזל', + 'lang': 'he' + }, {'name': 'Bázel', 'lang': 'hu'}, {'name': 'Բազել', 'lang': 'hy'}, { + 'isPreferredName': true, + 'name': 'BSL', + 'lang': 'iata' + }, {'name': 'Basel', 'lang': 'id'}, {'name': 'Basilea', 'lang': 'it'}, { + 'name': 'バーゼル', + 'lang': 'ja' + }, {'name': 'ბაზელი', 'lang': 'ka'}, {'name': 'Базель', 'lang': 'kk'}, { + 'name': '바젤', + 'lang': 'ko' + }, {'name': 'Robur', 'lang': 'la'}, { + 'name': 'https://en.wikipedia.org/wiki/Basel', + 'lang': 'link' + }, {'name': 'Bazelis', 'lang': 'lt'}, {'name': 'Bāzele', 'lang': 'lv'}, { + 'name': 'Базел', + 'lang': 'mk' + }, {'name': 'Базель хот', 'lang': 'mn'}, {'name': 'बासल', 'lang': 'mr'}, { + 'name': 'Bazel', + 'lang': 'nl' + }, {'name': 'Basel', 'lang': 'nn'}, {'name': 'Basel', 'lang': 'no'}, { + 'name': 'Basilèa', + 'lang': 'oc' + }, {'name': 'Базель', 'lang': 'os'}, {'name': 'Bazylea', 'lang': 'pl'}, { + 'isPreferredName': true, + 'name': '4000', + 'lang': 'post' + }, {'name': 'Basileia', 'lang': 'pt'}, {'name': 'Basilea', 'lang': 'rm'}, { + 'name': 'Basel', + 'lang': 'ro' + }, {'name': 'Базель', 'lang': 'ru'}, {'name': 'Bazilej', 'lang': 'sk'}, { + 'name': 'Basel', + 'lang': 'sl' + }, {'name': 'Bazeli', 'lang': 'sq'}, {'name': 'Базел', 'lang': 'sr'}, { + 'name': 'Basel', + 'lang': 'sv' + }, {'name': 'பேசெல்', 'lang': 'ta'}, {'name': 'บาเซิล', 'lang': 'th'}, { + 'name': 'Basel', + 'lang': 'tr' + }, {'name': 'Базель', 'lang': 'uk'}, {'name': 'CHBSL', 'lang': 'unlc'}, { + 'name': 'بازل', + 'lang': 'ur' + }, {'name': 'Baxiłea', 'lang': 'vec'}, {'name': '白才尔', 'lang': 'wuu'}, { + 'name': 'באזעל', + 'lang': 'yi' + }, {'name': '巴塞爾', 'lang': 'yue'}, {'name': '巴塞尔', 'lang': 'zh'}, {'name': '巴塞尔', 'lang': 'zh-CN'}], + 'adminName2': 'Basel-City', + 'name': 'Basel', + 'fclName': 'city, village,...', + 'countryName': 'Schweiz', + 'fcodeName': 'seat of a first-order administrative division', + 'adminName1': 'Basel-Stadt' + }, { + 'timezone': {'gmtOffset': 1, 'timeZoneId': 'Europe/Brussels', 'dstOffset': 2}, + 'bbox': { + 'east': 4.328900686384307, + 'south': 51.12543286042041, + 'north': 51.169661437124496, + 'west': 4.23076509693852, + 'accuracyLevel': 10 + }, + 'asciiName': 'Bazel', + 'astergdem': 10, + 'countryId': '2802361', + 'fcl': 'P', + 'srtm3': 14, + 'score': 21.20476722717285, + 'adminId2': '2789733', + 'adminId3': '2786577', + 'countryCode': 'BE', + 'adminId4': '2793941', + 'adminCodes2': {'ISO3166_2': 'VOV'}, + 'adminCodes1': {'ISO3166_2': 'VLG'}, + 'adminId1': '3337388', + 'lat': '51.14741', + 'fcode': 'PPL', + 'continentCode': 'EU', + 'adminCode2': 'VOV', + 'adminCode3': '46', + 'adminCode1': 'VLG', + 'lng': '4.30129', + 'geonameId': 2802529, + 'toponymName': 'Bazel', + 'adminCode4': '46013', + 'population': 5687, + 'adminName5': '', + 'adminName4': 'Kruibeke', + 'adminName3': 'Arrondissement Sint-Niklaas', + 'alternateNames': [{'name': 'https://en.wikipedia.org/wiki/Bazel', 'lang': 'link'}, { + 'name': '9150', + 'lang': 'post' + }, {'name': 'BEBAZ', 'lang': 'unlc'}], + 'adminName2': 'Provinz Ostflandern', + 'name': 'Basel', + 'fclName': 'city, village,...', + 'countryName': 'Belgien', + 'fcodeName': 'populated place', + 'adminName1': 'Flandern' + }] +}; + describe('GeonameService', () => { let service: GeonameService; let httpTestingController: HttpTestingController; @@ -34,60 +236,134 @@ describe('GeonameService', () => { expect(service).toBeTruthy(); }); - it('should resolve a given geoname id', done => { + describe('Method resolveGeonameID', () => { - service.resolveGeonameID('2661604').subscribe( - name => { - expect(name).toEqual('Basel'); - done(); - } - ); + it('should resolve a given geoname id', done => { - const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + service.resolveGeonameID('2661604').subscribe( + (displayPlace: DisplayPlace) => { + expect(displayPlace.displayName).toEqual('Zürich Enge, Zurich, Switzerland'); + done(); + } + ); - expect(httpRequest.request.method).toEqual('GET'); + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); - const expectedResponse = { name: 'Basel' }; + expect(httpRequest.request.method).toEqual('GET'); - httpRequest.flush(expectedResponse); + const expectedResponse = geonamesGetResponse; - }); + httpRequest.flush(expectedResponse); + + }); - it('should use the given geoname id as a fallback value if the requests fails', done => { + it('should return an error if the requests fails', done => { - service.resolveGeonameID('2661604').subscribe( - name => { - expect(name).toEqual('2661604'); - done(); - } - ); + service.resolveGeonameID('2661604').subscribe( + name => { + }, + err => { + done(); + } + ); - const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); - expect(httpRequest.request.method).toEqual('GET'); + expect(httpRequest.request.method).toEqual('GET'); - const mockErrorResponse = {status: 400, statusText: 'Bad Request'}; + const mockErrorResponse = {status: 400, statusText: 'Bad Request'}; - httpRequest.flush(mockErrorResponse); + httpRequest.flush(mockErrorResponse); - }); + }); + + it('should return an error if the requests response does not contain the expected information', done => { + + service.resolveGeonameID('2661604').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = {place: 'Basel'}; + + httpRequest.flush(expectedResponse); + + }); + + describe('Method searchPlace', () => { - it('should use the given geoname id as a fallback value if the requests response does not contain the expected information', done => { + it('should search for a place', done => { - service.resolveGeonameID('2661604').subscribe( - name => { - expect(name).toEqual('2661604'); - done(); - } - ); + service.searchPlace('Basel').subscribe( + places => { + expect(places.length).toEqual(2); - const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/getJSON?geonameId=2661604&username=token&style=short'); + const placeBasel = places[0]; + expect(placeBasel.displayName).toEqual('Basel, Basel-Stadt, Schweiz'); + expect(placeBasel.id).toEqual('2661604'); - expect(httpRequest.request.method).toEqual('GET'); + done(); + } + ); - const expectedResponse = { place: 'Basel' }; + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/searchJSON?userName=token&lang=en&style=full&maxRows=12&name_startsWith=Basel'); - httpRequest.flush(expectedResponse); + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = geonamesSearchResponse; + + httpRequest.flush(expectedResponse); + + }); + + it('should return an error if the requests fails', done => { + + service.searchPlace('Basel').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/searchJSON?userName=token&lang=en&style=full&maxRows=12&name_startsWith=Basel'); + + expect(httpRequest.request.method).toEqual('GET'); + + const mockErrorResponse = {status: 400, statusText: 'Bad Request'}; + + httpRequest.flush(mockErrorResponse); + + }); + + it('should return an error if the requests response does not contain the expected information', done => { + + service.searchPlace('Basel').subscribe( + name => { + }, + err => { + done(); + } + ); + + const httpRequest = httpTestingController.expectOne('https://ws.geonames.net/searchJSON?userName=token&lang=en&style=full&maxRows=12&name_startsWith=Basel'); + + expect(httpRequest.request.method).toEqual('GET'); + + const expectedResponse = {place: 'Basel'}; + + httpRequest.flush(expectedResponse); + + }); + + }); }); diff --git a/projects/dsp-ui/src/lib/viewer/services/geoname.service.ts b/projects/dsp-ui/src/lib/viewer/services/geoname.service.ts index c65a47782..776935211 100644 --- a/projects/dsp-ui/src/lib/viewer/services/geoname.service.ts +++ b/projects/dsp-ui/src/lib/viewer/services/geoname.service.ts @@ -1,38 +1,124 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; -import { AppInitService } from '../../core'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map, shareReplay } from 'rxjs/operators'; +import { AppInitService } from '../../core/app-init.service'; + +export interface GIS { + longitude: number; + latitude: number; +} + +export interface DisplayPlace { + displayName: string; + name: string; + country: string; + administrativeName?: string; + wikipediaUrl?: string; + location: GIS; +} + +export interface SearchPlace { + id: string; + displayName: string; + name: string; + administrativeName?: string; + country: string; + locationType: string; +} @Injectable({ - providedIn: 'root' + providedIn: 'root' }) export class GeonameService { - constructor( - private readonly _http: HttpClient, - private _appInitService: AppInitService - ) { - } + constructor( + private readonly _http: HttpClient, + private _appInitService: AppInitService + ) { + } + + /** + * Given a geoname id, resolves the identifier. + * + * @param id the geiname id to resolve. + */ + resolveGeonameID(id: string): Observable { + + return this._http.get('https://ws.geonames.net/getJSON?geonameId=' + id + '&username=' + this._appInitService.config['geonameToken'] + '&style=short').pipe( + map( + (geo: { name: string, countryName: string, adminName1?: string, wikipediaURL?: string, lat: number, lng: number }) => { // assertions for TS compiler + + if (!(('name' in geo) && ('countryName' in geo) && ('lat' in geo) && ('lng' in geo))) { + // at least one of the expected properties is not present + throw 'required property missing in geonames response'; + } + + return { + displayName: geo.name + (geo.adminName1 !== undefined ? ', ' + geo.adminName1 : '') + ', ' + geo.countryName, + name: geo.name, + administrativeName: geo.adminName1, + country: geo.countryName, + wikipediaUrl: geo.wikipediaURL, + location: { + longitude: geo.lng, + latitude: geo.lat + } + }; + } + ), + shareReplay({ refCount: false, bufferSize: 1 }), // several subscribers may use the same source Observable (one HTTP request to geonames) + catchError(error => { + // an error occurred + return throwError(error); + }) + ); + } + + /** + * Given a search string, searches for places matching the string. + * + * @param searchString place to search for. + */ + searchPlace(searchString: string): Observable { + + return this._http.get('https://ws.geonames.net/searchJSON?userName=' + this._appInitService.config['geonameToken'] + '&lang=en&style=full&maxRows=12&name_startsWith=' + encodeURIComponent(searchString)).pipe( + map( + (places: { + geonames: { geonameId: string, name: string, countryName: string, adminName1?: string, fclName: string }[] // assertions for TS compiler + }) => { + + if (!Array.isArray(places.geonames)) { + // there is no top level array + throw 'search did not return an array of results'; + } - resolveGeonameID(id: string): Observable { + return places.geonames.map( + geo => { - return this._http.get('https://ws.geonames.net/getJSON?geonameId=' + id + '&username=' + this._appInitService.config['geonameToken'] + '&style=short').pipe( - map( - (geo: { name: string }) => { + if (!(('geonameId' in geo) && ('name' in geo) && ('countryName' in geo) && ('fclName' in geo))) { + // at least one of the expected properties is not present + throw 'required property missing in geonames response'; + } - if (!('name' in geo)) { - throw 'no name property'; - } + return { + id: geo.geonameId.toString(), + displayName: geo.name + (geo.adminName1 !== undefined ? ', ' + geo.adminName1 : '') + ', ' + geo.countryName, + name: geo.name, + administrativeName: geo.adminName1, + country: geo.countryName, + locationType: geo.fclName + }; + } + ); - return geo.name; - } - ), - catchError(error => { - // an error occurred, just return the id - return of(id); - }) - ); + } + ), + catchError(error => { + // an error occurred + return throwError(error); + }) + ); - } + } } diff --git a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.html b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.html index 83edecd20..87055fff9 100644 --- a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.html +++ b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.html @@ -1,6 +1,6 @@ - {{ $geonameLabel | async }} + {{ ($geonameLabel | async)?.displayName }} {{ commentFormControl.value }} @@ -8,15 +8,18 @@ + Current value: {{ ($geonameLabel | async)?.displayName }} - + + + + {{place?.displayName}} + + New value must be different than the current value. - - New value must be a valid GeoName identifier. - A GeoName value is required. diff --git a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.scss b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.scss index 7bcec6130..304f4a5da 100644 --- a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.scss +++ b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.scss @@ -29,3 +29,7 @@ .read-mode-view .more-info .mat-icon{ font-size: 18px; } + +.current-value { + font-size: 12px; +} diff --git a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.spec.ts b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.spec.ts index df52e1e43..e7afd4c08 100644 --- a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.spec.ts +++ b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.spec.ts @@ -7,8 +7,12 @@ import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { CreateGeonameValue, MockResource, ReadGeonameValue, UpdateGeonameValue } from '@dasch-swiss/dsp-js'; import { GeonameValueComponent } from './geoname-value.component'; -import { GeonameService } from '../../services/geoname.service'; +import { DisplayPlace, GeonameService } from '../../services/geoname.service'; import { of } from 'rxjs'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatAutocompleteHarness } from '@angular/material/autocomplete/testing'; @@ -64,7 +68,7 @@ class TestHostCreateValueComponent implements OnInit { describe('GeonameValueComponent', () => { beforeEach(waitForAsync(() => { - const mockGeonameService = jasmine.createSpyObj('GeonameService', ['resolveGeonameID']); + const mockGeonameService = jasmine.createSpyObj('GeonameService', ['resolveGeonameID', 'searchPlace']); TestBed.configureTestingModule({ declarations: [ @@ -76,7 +80,8 @@ describe('GeonameValueComponent', () => { ReactiveFormsModule, MatInputModule, BrowserAnimationsModule, - MatIconModule + MatIconModule, + MatAutocompleteModule ], providers: [{ provide: GeonameService, @@ -90,18 +95,19 @@ describe('GeonameValueComponent', () => { let testHostComponent: TestHostDisplayValueComponent; let testHostFixture: ComponentFixture; let valueComponentDe: DebugElement; - let valueInputDebugElement: DebugElement; - let valueInputNativeElement; let valueReadModeDebugElement: DebugElement; let valueReadModeNativeElement; + let loader: HarnessLoader; + beforeEach(() => { const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; - geonameServiceMock.resolveGeonameID.and.returnValue(of('Basel')); + geonameServiceMock.resolveGeonameID.and.returnValue(of({ displayName: 'Basel'} as DisplayPlace)); testHostFixture = TestBed.createComponent(TestHostDisplayValueComponent); testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); testHostFixture.detectChanges(); expect(testHostComponent).toBeTruthy(); @@ -135,26 +141,45 @@ describe('GeonameValueComponent', () => { }); - it('should make an existing value editable', () => { + it('should make an existing value editable', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); testHostComponent.mode = 'update'; testHostFixture.detectChanges(); - valueInputDebugElement = valueComponentDe.query(By.css('input.value')); - valueInputNativeElement = valueInputDebugElement.nativeElement; + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); expect(testHostComponent.inputValueComponent.mode).toEqual('update'); expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); - expect(valueInputNativeElement.value).toEqual('2661604'); + await autocomplete.enterText('Terra Lind'); - valueInputNativeElement.value = '5401678'; + expect(geonameServiceMock.searchPlace).toHaveBeenCalledWith('Terra Lind'); - valueInputNativeElement.dispatchEvent(new Event('input')); + const options = await autocomplete.getOptions(); - testHostFixture.detectChanges(); + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); expect(testHostComponent.inputValueComponent.form.valid).toBeTruthy(); @@ -166,26 +191,26 @@ describe('GeonameValueComponent', () => { }); - it('should not return an invalid update value', () => { + it('should not return an invalid update value', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([])); testHostComponent.mode = 'update'; testHostFixture.detectChanges(); - valueInputDebugElement = valueComponentDe.query(By.css('input.value')); - valueInputNativeElement = valueInputDebugElement.nativeElement; - expect(testHostComponent.inputValueComponent.mode).toEqual('update'); expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); - expect(valueInputNativeElement.value).toEqual('2661604'); - - valueInputNativeElement.value = ''; + const autocomplete = await loader.getHarness(MatAutocompleteHarness); - valueInputNativeElement.dispatchEvent(new Event('input')); + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); - testHostFixture.detectChanges(); + await autocomplete.enterText('invalid'); expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); @@ -195,30 +220,49 @@ describe('GeonameValueComponent', () => { }); - it('should restore the initially displayed value', () => { + it('should restore the initially displayed value', async () => { + + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; + + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); testHostComponent.mode = 'update'; testHostFixture.detectChanges(); - valueInputDebugElement = valueComponentDe.query(By.css('input.value')); - valueInputNativeElement = valueInputDebugElement.nativeElement; - expect(testHostComponent.inputValueComponent.mode).toEqual('update'); expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); - expect(valueInputNativeElement.value).toEqual('2661604'); + const autocomplete = await loader.getHarness(MatAutocompleteHarness); - valueInputNativeElement.value = '5401678'; + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); - valueInputNativeElement.dispatchEvent(new Event('input')); + await autocomplete.enterText('Terra Lind'); - testHostFixture.detectChanges(); + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); + + expect(testHostComponent.inputValueComponent.valueFormControl.value.id).toEqual('5401678'); testHostComponent.inputValueComponent.resetFormControl(); - expect(valueInputNativeElement.value).toEqual('2661604'); + expect(testHostComponent.inputValueComponent.valueFormControl.value.id).toEqual('2661604'); expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); @@ -228,7 +272,7 @@ describe('GeonameValueComponent', () => { const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; - geonameServiceMock.resolveGeonameID.and.returnValue(of('Terra Linda High School')); + geonameServiceMock.resolveGeonameID.and.returnValue(of({displayName: 'Terra Linda High School'} as DisplayPlace)); const newStr = new ReadGeonameValue(); @@ -266,9 +310,12 @@ describe('GeonameValueComponent', () => { let commentInputDebugElement: DebugElement; let commentInputNativeElement; + let loader: HarnessLoader; + beforeEach(() => { testHostFixture = TestBed.createComponent(TestHostCreateValueComponent); testHostComponent = testHostFixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(testHostFixture); testHostFixture.detectChanges(); expect(testHostComponent).toBeTruthy(); @@ -289,12 +336,35 @@ describe('GeonameValueComponent', () => { expect(commentInputNativeElement.value).toEqual(''); }); - it('should create a value', () => { - valueInputNativeElement.value = '5401678'; + it('should create a value', async () => { - valueInputNativeElement.dispatchEvent(new Event('input')); + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; - testHostFixture.detectChanges(); + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); + + const autocomplete = await loader.getHarness(MatAutocompleteHarness); + + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + await autocomplete.enterText('Terra Lind'); + + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); expect(testHostComponent.inputValueComponent.mode).toEqual('create'); @@ -307,16 +377,35 @@ describe('GeonameValueComponent', () => { expect((newValue as CreateGeonameValue).geoname).toEqual('5401678'); }); - it('should reset form after cancellation', () => { - valueInputNativeElement.value = '5401678'; + it('should reset form after cancellation', async () => { - valueInputNativeElement.dispatchEvent(new Event('input')); + const geonameServiceMock = TestBed.inject(GeonameService) as jasmine.SpyObj; - commentInputNativeElement.value = 'created comment'; + geonameServiceMock.searchPlace.and.returnValue(of([{ + id: '5401678', + displayName: 'Terra Linda High School, California, United States', + name: 'Terra Linda High School', + administrativeName: 'California', + country: 'United States', + locationType: 'spot, building, farm' + }])); - commentInputNativeElement.dispatchEvent(new Event('input')); + const autocomplete = await loader.getHarness(MatAutocompleteHarness); - testHostFixture.detectChanges(); + // empty field when switching to edit mode + expect(await autocomplete.getValue()).toEqual(''); + + await autocomplete.enterText('Terra Lind'); + + const options = await autocomplete.getOptions(); + + expect(options.length).toEqual(1); + + expect(await options[0].getText()).toEqual('Terra Linda High School, California, United States'); + + await options[0].click(); + + expect(await autocomplete.getValue()).toEqual('Terra Linda High School, California, United States'); expect(testHostComponent.inputValueComponent.mode).toEqual('create'); @@ -326,7 +415,7 @@ describe('GeonameValueComponent', () => { expect(testHostComponent.inputValueComponent.form.valid).toBeFalsy(); - expect(valueInputNativeElement.value).toEqual(''); + expect(await autocomplete.getValue()).toEqual(''); expect(commentInputNativeElement.value).toEqual(''); diff --git a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.ts b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.ts index 276b14eaa..ece4d2174 100644 --- a/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.ts +++ b/projects/dsp-ui/src/lib/viewer/values/geoname-value/geoname-value.component.ts @@ -1,11 +1,17 @@ import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { AbstractControl, FormBuilder, FormControl, FormGroup } from '@angular/forms'; import { CreateGeonameValue, ReadGeonameValue, UpdateGeonameValue } from '@dasch-swiss/dsp-js'; import { Observable, Subscription } from 'rxjs'; import { BaseValueComponent } from '../base-value.component'; -import { CustomRegex } from '../custom-regex'; import { ValueErrorStateMatcher } from '../value-error-state-matcher'; -import { GeonameService } from '../../services/geoname.service'; +import { DisplayPlace, GeonameService, SearchPlace } from '../../services/geoname.service'; + +export function geonameIdValidator(control: AbstractControl) { + // null or empty checks are out of this validator's scope + // check for a valid geoname id object + const invalid = !(control.value === null || control.value === '' || (typeof control.value === 'object' && 'id' in control.value)); + return invalid ? { invalidType: { value: control.value } } : null; +} // https://stackoverflow.com/questions/45661010/dynamic-nested-reactive-form-expressionchangedafterithasbeencheckederror const resolvedPromise = Promise.resolve(null); @@ -25,23 +31,42 @@ export class GeonameValueComponent extends BaseValueComponent implements OnInit, valueChangesSubscription: Subscription; matcher = new ValueErrorStateMatcher(); - customValidators = [Validators.pattern(CustomRegex.GEONAME_REGEX)]; + customValidators = [geonameIdValidator]; + + $geonameLabel: Observable; - $geonameLabel: Observable; + places: SearchPlace[]; constructor(@Inject(FormBuilder) private _fb: FormBuilder, private _geonameService: GeonameService) { super(); } - getInitValue(): string | null { + standardValueComparisonFunc(initValue: { id: string }, curValue: { id: string } | null): boolean { + return (curValue !== null && typeof curValue === 'object' && 'id' in curValue) && initValue.id === curValue.id; + } + + getInitValue(): { id: string } | null { if (this.displayValue !== undefined) { - return this.displayValue.geoname; + return { + id: this.displayValue.geoname + }; // TODO: try to set a display name to be shown when value is updated } else { return null; } } + /** + * Used to create a value which is displayed to the user after selection from autocomplete. + * + * @param place the user selected place. + */ + displayPlaceInSearch(place: SearchPlace | null) { + if (place !== null) { + return place.displayName; + } + } + ngOnInit() { // initialize form control elements @@ -49,6 +74,26 @@ export class GeonameValueComponent extends BaseValueComponent implements OnInit, this.commentFormControl = new FormControl(null); + // react to user typing places + this.valueFormControl.valueChanges.subscribe( + (searchTerm: string) => { + + // console.log(searchTerm); + // TODO: move this to a method + if ((this.mode === 'create' || this.mode === 'update') && searchTerm !== null) { + if (typeof searchTerm === 'string' && searchTerm.length >= 3) { + // console.log('searching for ' + searchTerm); + this._geonameService.searchPlace(searchTerm).subscribe( + places => this.places = places, + err => this.places = [] + ); + } else { + this.places = []; + } + } + } + ); + this.valueChangesSubscription = this.commentFormControl.valueChanges.subscribe( data => { this.valueFormControl.updateValueAndValidity(); @@ -63,7 +108,7 @@ export class GeonameValueComponent extends BaseValueComponent implements OnInit, this.resetFormControl(); if (this.mode === 'read') { - this.$geonameLabel = this._geonameService.resolveGeonameID(this.valueFormControl.value); + this.$geonameLabel = this._geonameService.resolveGeonameID(this.valueFormControl.value.id); } resolvedPromise.then(() => { @@ -79,7 +124,7 @@ export class GeonameValueComponent extends BaseValueComponent implements OnInit, this.resetFormControl(); if (this.mode === 'read' && this.valueFormControl !== undefined) { - this.$geonameLabel = this._geonameService.resolveGeonameID(this.valueFormControl.value); + this.$geonameLabel = this._geonameService.resolveGeonameID(this.valueFormControl.value.id); } } @@ -100,7 +145,7 @@ export class GeonameValueComponent extends BaseValueComponent implements OnInit, const newGeonameValue = new CreateGeonameValue(); - newGeonameValue.geoname = this.valueFormControl.value; + newGeonameValue.geoname = this.valueFormControl.value.id; if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { newGeonameValue.valueHasComment = this.commentFormControl.value; @@ -120,7 +165,7 @@ export class GeonameValueComponent extends BaseValueComponent implements OnInit, updatedGeonameValue.id = this.displayValue.id; - updatedGeonameValue.geoname = this.valueFormControl.value; + updatedGeonameValue.geoname = this.valueFormControl.value.id; if (this.commentFormControl.value !== null && this.commentFormControl.value !== '') { updatedGeonameValue.valueHasComment = this.commentFormControl.value;