diff --git a/apps/datahub/src/app/record/record-metadata/record-metadata.component.spec.ts b/apps/datahub/src/app/record/record-metadata/record-metadata.component.spec.ts index 5f6585bc4d..b0fd122885 100644 --- a/apps/datahub/src/app/record/record-metadata/record-metadata.component.spec.ts +++ b/apps/datahub/src/app/record/record-metadata/record-metadata.component.spec.ts @@ -39,6 +39,7 @@ class MdViewFacadeMock { mapApiLinks$ = new BehaviorSubject([]) dataLinks$ = new BehaviorSubject([]) geoDataLinks$ = new BehaviorSubject([]) + geoDataLinksWithGeometry$ = new BehaviorSubject([]) downloadLinks$ = new BehaviorSubject([]) apiLinks$ = new BehaviorSubject([]) otherLinks$ = new BehaviorSubject([]) @@ -377,6 +378,7 @@ describe('RecordMetadataComponent', () => { }) describe('when a GEODATA link present', () => { beforeEach(() => { + facade.geoDataLinksWithGeometry$.next(['link']) facade.geoDataLinks$.next(['link']) fixture.detectChanges() mapTab = fixture.debugElement.queryAll(By.css('mat-tab'))[0] @@ -399,7 +401,7 @@ describe('RecordMetadataComponent', () => { beforeEach(() => { facade.mapApiLinks$.next(['link']) facade.dataLinks$.next(null) - facade.geoDataLinks$.next(null) + facade.geoDataLinksWithGeometry$.next(null) fixture.detectChanges() tableTab = fixture.debugElement.queryAll(By.css('mat-tab'))[1] chartTab = fixture.debugElement.queryAll(By.css('mat-tab'))[2] diff --git a/apps/datahub/src/app/record/record-metadata/record-metadata.component.ts b/apps/datahub/src/app/record/record-metadata/record-metadata.component.ts index d04313355a..602cd1b312 100644 --- a/apps/datahub/src/app/record/record-metadata/record-metadata.component.ts +++ b/apps/datahub/src/app/record/record-metadata/record-metadata.component.ts @@ -3,7 +3,7 @@ import { SourcesService } from '@geonetwork-ui/feature/catalog' import { SearchService } from '@geonetwork-ui/feature/search' import { ErrorType } from '@geonetwork-ui/ui/elements' import { BehaviorSubject, combineLatest } from 'rxjs' -import { filter, map, mergeMap } from 'rxjs/operators' +import { filter, map, mergeMap, startWith } from 'rxjs/operators' import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface' import { Keyword, @@ -22,12 +22,12 @@ export class RecordMetadataComponent { displayMap$ = combineLatest([ this.metadataViewFacade.mapApiLinks$, - this.metadataViewFacade.geoDataLinks$, + this.metadataViewFacade.geoDataLinksWithGeometry$, ]).pipe( - map( - ([mapLinks, geoDataLinks]) => - mapLinks?.length > 0 || geoDataLinks?.length > 0 - ) + map(([mapApiLinks, geoDataLinksWithGeometry]) => { + return mapApiLinks?.length > 0 || geoDataLinksWithGeometry?.length > 0 + }), + startWith(false) ) displayData$ = combineLatest([ diff --git a/libs/common/fixtures/src/lib/records.fixtures.ts b/libs/common/fixtures/src/lib/records.fixtures.ts index e1893abb07..c193335ee6 100644 --- a/libs/common/fixtures/src/lib/records.fixtures.ts +++ b/libs/common/fixtures/src/lib/records.fixtures.ts @@ -117,6 +117,14 @@ Cette section contient des *caractères internationaux* (ainsi que des "caractè description: 'This WFS service offers direct download capability', identifierInService: 'my:featuretype', }, + { + type: 'service', + url: new URL('https://my-org.net/ogc'), + accessServiceProtocol: 'ogcFeatures', + name: 'my:featuretype', + description: 'This OGC service offers direct download capability', + identifierInService: 'my:featuretype', + }, ], lineage: `This record was edited manually to test the conversion processes diff --git a/libs/feature/dataviz/src/lib/service/data.service.spec.ts b/libs/feature/dataviz/src/lib/service/data.service.spec.ts index c54e86d8d7..a26e84a814 100644 --- a/libs/feature/dataviz/src/lib/service/data.service.spec.ts +++ b/libs/feature/dataviz/src/lib/service/data.service.spec.ts @@ -93,6 +93,13 @@ jest.mock('@camptocamp/ogc-client', () => ({ }) } allCollections = Promise.resolve([{ name: 'collection1' }]) + featureCollections = + this.url.indexOf('error.http') > -1 + ? Promise.reject(new Error()) + : Promise.resolve(['collection1', 'collection2']) + getCollectionItem(collection, id) { + return Promise.resolve('item1') + } }, })) @@ -700,5 +707,25 @@ describe('DataService', () => { ) }) }) + describe('#getItemsFromOgcApi', () => { + describe('calling getItemsFromOgcApi() with a valid URL', () => { + it('returns the first collection item when collections array is not empty', async () => { + const item = await service.getItemsFromOgcApi( + 'https://my.ogc.api/features' + ) + expect(item).toBe('item1') + }) + }) + + describe('calling getItemsFromOgcApi() with an erroneous URL', () => { + it('throws an error', async () => { + try { + await service.getItemsFromOgcApi('http://error.http/ogcapi') + } catch (e) { + expect(e.message).toBe('ogc.unreachable.unknown') + } + }) + }) + }) }) }) diff --git a/libs/feature/dataviz/src/lib/service/data.service.ts b/libs/feature/dataviz/src/lib/service/data.service.ts index 993f479c63..8380d0503e 100644 --- a/libs/feature/dataviz/src/lib/service/data.service.ts +++ b/libs/feature/dataviz/src/lib/service/data.service.ts @@ -3,6 +3,7 @@ import { marker } from '@biesbjerg/ngx-translate-extract-marker' import { OgcApiCollectionInfo, OgcApiEndpoint, + OgcApiRecord, WfsEndpoint, WfsVersion, } from '@camptocamp/ogc-client' @@ -183,6 +184,19 @@ export class DataService { }) } + async getItemsFromOgcApi(url: string): Promise { + const endpoint = new OgcApiEndpoint(this.proxy.getProxiedUrl(url)) + return await endpoint.featureCollections + .then((collections) => { + return collections.length + ? endpoint.getCollectionItem(collections[0], '1') + : null + }) + .catch((error) => { + throw new Error(`ogc.unreachable.unknown`) + }) + } + getDownloadLinksFromEsriRest( esriRestLink: DatasetServiceDistribution ): DatasetDistribution[] { diff --git a/libs/feature/map/src/lib/utils/map-utils.service.spec.ts b/libs/feature/map/src/lib/utils/map-utils.service.spec.ts index ceb87a5d20..c73f4a053d 100644 --- a/libs/feature/map/src/lib/utils/map-utils.service.spec.ts +++ b/libs/feature/map/src/lib/utils/map-utils.service.spec.ts @@ -327,6 +327,16 @@ describe('MapUtilsService', () => { }) }) + describe('getRecordExtent', () => { + it('should return null if spatialExtents is not present or is an empty array', () => { + const record1: Partial = {} + const record2: Partial = { spatialExtents: [] } + + expect(service.getRecordExtent(record1)).toBeNull() + expect(service.getRecordExtent(record2)).toBeNull() + }) + }) + describe('#prioritizePageScroll', () => { const interactions = defaults() let dragRotate diff --git a/libs/feature/map/src/lib/utils/map-utils.service.ts b/libs/feature/map/src/lib/utils/map-utils.service.ts index 2a73022bb8..a99f1ee611 100644 --- a/libs/feature/map/src/lib/utils/map-utils.service.ts +++ b/libs/feature/map/src/lib/utils/map-utils.service.ts @@ -217,7 +217,7 @@ export class MapUtilsService { } getRecordExtent(record: Partial): Extent { - if (!('spatialExtents' in record)) { + if (!('spatialExtents' in record) || record.spatialExtents.length === 0) { return null } // transform an array of geojson geometries into a bbox diff --git a/libs/feature/record/src/lib/map-view/map-view.component.spec.ts b/libs/feature/record/src/lib/map-view/map-view.component.spec.ts index 6753dff0bf..587b0d73e9 100644 --- a/libs/feature/record/src/lib/map-view/map-view.component.spec.ts +++ b/libs/feature/record/src/lib/map-view/map-view.component.spec.ts @@ -67,7 +67,7 @@ jest.mock('@geonetwork-ui/util/app-config', () => ({ class MdViewFacadeMock { mapApiLinks$ = new Subject() - geoDataLinks$ = new Subject() + geoDataLinksWithGeometry$ = new Subject() metadata$ = of({ title: 'abcd' }) } @@ -272,7 +272,7 @@ describe('MapViewComponent', () => { describe('with no link compatible with MAP_API or GEODATA usage', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([]) tick() fixture.detectChanges() })) @@ -317,7 +317,7 @@ describe('MapViewComponent', () => { accessServiceProtocol: 'wms', }, ]) - mdViewFacade.geoDataLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([]) tick() fixture.detectChanges() })) @@ -365,7 +365,7 @@ describe('MapViewComponent', () => { accessServiceProtocol: 'wms', }, ]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { url: new URL('http://abcd.com/wfs'), name: 'featuretype', @@ -419,7 +419,7 @@ describe('MapViewComponent', () => { describe('with a link using WFS protocol', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { url: new URL('http://abcd.com/wfs'), name: 'featuretype', @@ -453,7 +453,7 @@ describe('MapViewComponent', () => { accessServiceProtocol: 'wmts', }, ]) - mdViewFacade.geoDataLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([]) tick(200) fixture.detectChanges() })) @@ -474,7 +474,7 @@ describe('MapViewComponent', () => { describe('with a link using ESRI:REST protocol', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { name: 'mes_hdf', url: new URL( @@ -503,7 +503,7 @@ describe('MapViewComponent', () => { describe('with a link using OGC API protocol', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { name: 'ogc layer', url: new URL('http://abcd.com/data/ogcapi'), @@ -530,7 +530,7 @@ describe('MapViewComponent', () => { describe('with a link using WFS which returns an error', () => { beforeEach(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { url: new URL('http://abcd.com/wfs/error'), name: 'featuretype', @@ -548,7 +548,7 @@ describe('MapViewComponent', () => { describe('during download', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { url: new URL('http://abcd.com/data.geojson'), name: 'data.geojson', @@ -571,7 +571,7 @@ describe('MapViewComponent', () => { describe('after download', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { url: new URL('http://abcd.com/data.geojson'), name: 'data.geojson', @@ -605,7 +605,7 @@ describe('MapViewComponent', () => { describe('when receiving several metadata records', () => { beforeEach(fakeAsync(() => { mdViewFacade.mapApiLinks$.next([]) - mdViewFacade.geoDataLinks$.next([ + mdViewFacade.geoDataLinksWithGeometry$.next([ { url: new URL('http://abcd.com/data.geojson'), name: 'data.geojson', @@ -620,7 +620,7 @@ describe('MapViewComponent', () => { accessServiceProtocol: 'wms', }, ]) - mdViewFacade.geoDataLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([]) tick() fixture.detectChanges() })) @@ -671,7 +671,7 @@ describe('MapViewComponent', () => { accessServiceProtocol: 'wms', }, ]) - mdViewFacade.geoDataLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([]) dropdownComponent.selectValue.emit(1) tick() fixture.detectChanges() @@ -856,7 +856,7 @@ describe('MapViewComponent', () => { describe('changing the map context', () => { beforeEach(() => { jest.spyOn(component, 'resetSelection') - mdViewFacade.geoDataLinks$.next([]) + mdViewFacade.geoDataLinksWithGeometry$.next([]) mdViewFacade.mapApiLinks$.next([]) }) it('resets selection', () => { diff --git a/libs/feature/record/src/lib/map-view/map-view.component.ts b/libs/feature/record/src/lib/map-view/map-view.component.ts index 24870f16b6..565d59eaca 100644 --- a/libs/feature/record/src/lib/map-view/map-view.component.ts +++ b/libs/feature/record/src/lib/map-view/map-view.component.ts @@ -55,9 +55,11 @@ export class MapViewComponent implements OnInit, OnDestroy { compatibleMapLinks$ = combineLatest([ this.mdViewFacade.mapApiLinks$, - this.mdViewFacade.geoDataLinks$, + this.mdViewFacade.geoDataLinksWithGeometry$, ]).pipe( - map(([mapApiLinks, geoDataLinks]) => [...mapApiLinks, ...geoDataLinks]) + map(([mapApiLinks, geoDataLinksWithGeometry]) => { + return [...mapApiLinks, ...geoDataLinksWithGeometry] + }) ) dropdownChoices$ = this.compatibleMapLinks$.pipe( @@ -102,8 +104,8 @@ export class MapViewComponent implements OnInit, OnDestroy { mapContext$ = this.currentLayers$.pipe( switchMap((layers) => from(this.mapUtils.getLayerExtent(layers[0])).pipe( - catchError((error) => { - console.warn(error) // FIXME: report this to the user somehow + catchError(() => { + this.error = 'The layer has no extent' return of(undefined) }), map( diff --git a/libs/feature/record/src/lib/state/mdview.facade.spec.ts b/libs/feature/record/src/lib/state/mdview.facade.spec.ts index e83b9dbdd4..24957eea0c 100644 --- a/libs/feature/record/src/lib/state/mdview.facade.spec.ts +++ b/libs/feature/record/src/lib/state/mdview.facade.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing' +import { TestBed, fakeAsync, tick } from '@angular/core/testing' import { MockStore, provideMockStore } from '@ngrx/store/testing' import { initialMetadataViewState, @@ -13,6 +13,26 @@ import { } from '@geonetwork-ui/common/fixtures' import { DatavizConfigurationModel } from '@geonetwork-ui/common/domain/model/dataviz/dataviz-configuration.model' import { AvatarServiceInterface } from '@geonetwork-ui/api/repository' +import { TestScheduler } from 'rxjs/testing' + +const newEndpointCall = jest.fn() +let testScheduler: TestScheduler + +jest.mock('@camptocamp/ogc-client', () => ({ + _newEndpointCall: jest.fn(), + OgcApiEndpoint: class { + constructor(private url) { + newEndpointCall(url) // to track endpoint creation + } + featureCollections = + this.url.indexOf('error.http') > -1 + ? Promise.reject(new Error()) + : Promise.resolve(['collection1', 'collection2']) + getCollectionItem(collection, id) { + return Promise.resolve('item1') + } + }, +})) describe('MdViewFacade', () => { let store: MockStore @@ -217,4 +237,96 @@ describe('MdViewFacade', () => { expect(store.scannedActions$).toBeObservable(expected) }) }) + + describe('geoDataLinksWithGeometry$', () => { + beforeEach(() => { + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + store.setState({ + [METADATA_VIEW_FEATURE_STATE_KEY]: { + ...initialMetadataViewState, + metadata: DATASET_RECORDS[0], + }, + }) + }) + it('should return OGC links that have geometry', fakeAsync(() => { + const values = { + a: [ + { + type: 'download', + url: new URL('http://my-org.net/download/2.geojson'), + mimeType: 'application/geo+json', + name: 'Direct download', + }, + { + type: 'service', + url: new URL('https://my-org.net/wfs'), + accessServiceProtocol: 'wfs', + name: 'my:featuretype', // FIXME: same as identifier otherwise it will be lost in iso... + description: 'This WFS service offers direct download capability', + identifierInService: 'my:featuretype', + }, + { + type: 'service', + url: new URL('https://my-org.net/ogc'), + accessServiceProtocol: 'ogcFeatures', + name: 'my:featuretype', + description: 'This OGC service offers direct download capability', + identifierInService: 'my:featuretype', + }, + ], + } + jest.spyOn(facade.dataService, 'getItemsFromOgcApi').mockResolvedValue({ + id: '123', + type: 'Feature', + time: null, + properties: { + type: '', + title: '', + }, + links: [], + geometry: { type: 'MultiPolygon', coordinates: [] }, + }) + let result + facade.geoDataLinksWithGeometry$.subscribe((v) => (result = v)) + tick() + expect(result).toEqual(values.a) + })) + it('should not return OGC links that do not have geometry', fakeAsync(() => { + const values = { + a: [ + { + type: 'download', + url: new URL('http://my-org.net/download/2.geojson'), + mimeType: 'application/geo+json', + name: 'Direct download', + }, + { + type: 'service', + url: new URL('https://my-org.net/wfs'), + accessServiceProtocol: 'wfs', + name: 'my:featuretype', // FIXME: same as identifier otherwise it will be lost in iso... + description: 'This WFS service offers direct download capability', + identifierInService: 'my:featuretype', + }, + ], + } + jest.spyOn(facade.dataService, 'getItemsFromOgcApi').mockResolvedValue({ + id: '123', + type: 'Feature', + time: null, + properties: { + type: '', + title: '', + }, + links: [], + geometry: null, + }) + let result + facade.geoDataLinksWithGeometry$.subscribe((v) => (result = v)) + tick() + expect(result).toEqual(values.a) + })) + }) }) diff --git a/libs/feature/record/src/lib/state/mdview.facade.ts b/libs/feature/record/src/lib/state/mdview.facade.ts index 37364de54d..814b6a7dcd 100644 --- a/libs/feature/record/src/lib/state/mdview.facade.ts +++ b/libs/feature/record/src/lib/state/mdview.facade.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core' import { select, Store } from '@ngrx/store' -import { filter, map } from 'rxjs/operators' +import { defaultIfEmpty, filter, map, mergeMap, scan } from 'rxjs/operators' import * as MdViewActions from './mdview.actions' import * as MdViewSelectors from './mdview.selectors' import { LinkClassifierService, LinkUsage } from '@geonetwork-ui/util/shared' @@ -10,6 +10,9 @@ import { UserFeedback, } from '@geonetwork-ui/common/domain/model/record' import { AvatarServiceInterface } from '@geonetwork-ui/api/repository' +import { OgcApiRecord } from '@camptocamp/ogc-client' +import { from, of } from 'rxjs' +import { DataService } from '@geonetwork-ui/feature/dataviz' @Injectable() /** @@ -21,8 +24,9 @@ import { AvatarServiceInterface } from '@geonetwork-ui/api/repository' export class MdViewFacade { constructor( private store: Store, - private linkClassifier: LinkClassifierService, - private avatarService: AvatarServiceInterface + public linkClassifier: LinkClassifierService, + private avatarService: AvatarServiceInterface, + public dataService: DataService ) {} isPresent$ = this.store.pipe( @@ -90,6 +94,39 @@ export class MdViewFacade { ) ) + geoDataLinksWithGeometry$ = this.allLinks$.pipe( + mergeMap((links) => { + return from(links) + }), + mergeMap((link) => { + if (this.linkClassifier.hasUsage(link, LinkUsage.GEODATA)) { + if ( + link.type === 'service' && + link.accessServiceProtocol === 'ogcFeatures' + ) { + return from(this.dataService.getItemsFromOgcApi(link.url.href)).pipe( + map((collectionRecords: OgcApiRecord) => { + return collectionRecords && collectionRecords.geometry + ? link + : null + }), + defaultIfEmpty(null) + ) + } else { + return of(link) + } + } else { + return of(null) + } + }), + scan((acc, val) => { + if (val !== null && !acc.includes(val)) { + acc.push(val) + } + return acc + }, []) + ) + landingPageLinks$ = this.metadata$.pipe( map((record) => ('landingPage' in record ? [record.landingPage] : [])) )