geonetwork-ui 2.11.0-dev.ca7fb6b05 → 2.11.0-dev.cf22e64cf

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/fesm2022/geonetwork-ui.mjs +1736 -1379
  2. package/fesm2022/geonetwork-ui.mjs.map +1 -1
  3. package/index.d.ts +221 -105
  4. package/index.d.ts.map +1 -1
  5. package/package.json +2 -1
  6. package/src/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts +120 -34
  7. package/src/libs/common/domain/src/lib/model/search/filter.model.ts +24 -2
  8. package/src/libs/feature/map/src/index.ts +0 -2
  9. package/src/libs/feature/map/src/lib/add-layer-from-file/add-layer-from-file.component.html +1 -0
  10. package/src/libs/feature/map/src/lib/feature-map.module.ts +1 -8
  11. package/src/libs/feature/notifications/src/lib/notifications.service.ts +6 -4
  12. package/src/libs/feature/notify-reuse/src/lib/notify-reuse-form/notify-reuse-form.component.ts +8 -3
  13. package/src/libs/feature/router/src/lib/default/state/query-params.utils.ts +1 -1
  14. package/src/libs/feature/search/src/index.ts +2 -0
  15. package/src/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.html +12 -4
  16. package/src/libs/feature/search/src/lib/filter-dropdown/filter-dropdown.component.ts +63 -19
  17. package/src/libs/feature/{map/src/lib → search/src/lib/geocoding}/geocoding.service.ts +16 -7
  18. package/src/libs/feature/search/src/lib/location-search/location-search.component.html +8 -0
  19. package/src/libs/feature/search/src/lib/location-search/location-search.component.ts +40 -0
  20. package/src/libs/feature/search/src/lib/search-filters-summary-item/search-filters-summary-item.component.ts +19 -8
  21. package/src/libs/feature/search/src/lib/state/reducer.ts +1 -4
  22. package/src/libs/feature/search/src/lib/utils/service/fields.service.ts +15 -1
  23. package/src/libs/feature/search/src/lib/utils/service/fields.ts +61 -2
  24. package/src/libs/ui/elements/src/lib/service-capabilities/service-capabilities.component.ts +2 -1
  25. package/src/libs/ui/inputs/src/index.ts +2 -0
  26. package/src/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.html +10 -1
  27. package/src/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts +8 -0
  28. package/src/libs/ui/inputs/src/lib/date-adapter.providers.ts +30 -0
  29. package/src/libs/ui/inputs/src/lib/date-picker/date-picker.component.ts +3 -24
  30. package/src/libs/ui/inputs/src/lib/date-range-dropdown/date-range-dropdown.component.css +37 -3
  31. package/src/libs/ui/inputs/src/lib/date-range-dropdown/date-range-dropdown.component.html +135 -16
  32. package/src/libs/ui/inputs/src/lib/date-range-dropdown/date-range-dropdown.component.ts +166 -29
  33. package/src/libs/ui/inputs/src/lib/date-range-picker/date-range-picker.component.ts +3 -7
  34. package/src/libs/ui/inputs/src/lib/drag-and-drop-file-input/drag-and-drop-file-input.component.css +1 -0
  35. package/src/libs/ui/inputs/src/lib/drag-and-drop-file-input/drag-and-drop-file-input.component.html +20 -7
  36. package/src/libs/ui/inputs/src/lib/drag-and-drop-file-input/drag-and-drop-file-input.component.ts +46 -5
  37. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html +7 -4
  38. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.ts +12 -9
  39. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.model.ts +2 -2
  40. package/src/libs/ui/inputs/src/lib/spatial-extent-dropdown/spatial-extent-dropdown.component.html +111 -0
  41. package/src/libs/ui/inputs/src/lib/spatial-extent-dropdown/spatial-extent-dropdown.component.ts +221 -0
  42. package/src/libs/ui/layout/src/lib/block-list/block-list.component.css +1 -0
  43. package/src/libs/ui/map/src/lib/components/map-container/map-container.component.ts +2 -0
  44. package/src/libs/util/app-config/src/lib/app-config.ts +23 -0
  45. package/src/libs/util/app-config/src/lib/fixtures.ts +16 -1
  46. package/src/libs/util/app-config/src/lib/model.ts +15 -1
  47. package/src/libs/util/shared/src/index.ts +1 -0
  48. package/src/libs/util/shared/src/lib/autofocus.directive.ts +26 -0
  49. package/src/libs/util/shared/src/lib/links/link-utils.ts +19 -10
  50. package/src/libs/util/shared/src/lib/services/date.service.ts +3 -3
  51. package/src/libs/util/shared/src/lib/utils/file.ts +15 -0
  52. package/src/libs/util/shared/src/lib/utils/geojson.ts +24 -0
  53. package/src/libs/util/shared/src/lib/utils/index.ts +1 -0
  54. package/tailwind.base.css +5 -0
  55. package/translations/de.json +19 -3
  56. package/translations/en.json +18 -2
  57. package/translations/es.json +17 -1
  58. package/translations/fr.json +23 -7
  59. package/translations/it.json +17 -1
  60. package/translations/nl.json +17 -1
  61. package/translations/pt.json +17 -1
  62. package/translations/sk.json +17 -1
  63. package/src/libs/feature/map/src/lib/geocoding/geocoding.component.html +0 -41
  64. package/src/libs/feature/map/src/lib/geocoding/geocoding.component.ts +0 -112
  65. package/src/libs/feature/map/src/lib/style/index.ts +0 -2
  66. package/src/libs/feature/map/src/lib/style/map-style.fixtures.ts +0 -47
  67. package/src/libs/feature/map/src/lib/style/map-style.service.ts +0 -108
  68. /package/src/libs/{feature/map/src/lib/geocoding/geocoding.component.css → ui/inputs/src/lib/spatial-extent-dropdown/spatial-extent-dropdown.component.css} +0 -0
@@ -34,7 +34,12 @@ import {
34
34
  LanguageCode,
35
35
  } from '../../../../../../../libs/common/domain/src/lib/model/record'
36
36
  import { TranslateService } from '@ngx-translate/core'
37
- import { getGeometryBoundingBox } from '../../../../../../../libs/util/shared/src'
37
+ import {
38
+ bboxToPolygon,
39
+ BoundingBox,
40
+ getGeometryBoundingBox,
41
+ isBoundingBox,
42
+ } from '../../../../../../../libs/util/shared/src'
38
43
  import { getLength as getGeodesicLength } from 'ol/sphere.js'
39
44
  import { LineString } from 'ol/geom.js'
40
45
 
@@ -49,7 +54,10 @@ export class ElasticsearchService {
49
54
 
50
55
  // runtime fields are computed using a Painless script
51
56
  // see: https://www.elastic.co/guide/en/elasticsearch/reference/current/runtime-mapping-fields.html
52
- private runtimeFields: Record<string, string> = {}
57
+ private runtimeFields: Record<
58
+ string,
59
+ { script: string; type: 'keyword' | 'date' }
60
+ > = {}
53
61
 
54
62
  // we're using getters in case the defined languages change over time
55
63
  private get metadataLang(): LanguageCode {
@@ -91,8 +99,8 @@ export class ElasticsearchService {
91
99
  const addMapping = (fieldName: string) => {
92
100
  if (!payload.runtime_mappings) payload.runtime_mappings = {}
93
101
  payload.runtime_mappings[fieldName] = {
94
- type: 'keyword',
95
- script: this.runtimeFields[fieldName],
102
+ type: this.runtimeFields[fieldName].type,
103
+ script: this.runtimeFields[fieldName].script,
96
104
  }
97
105
  }
98
106
  const lookForField = (node: unknown) => {
@@ -113,6 +121,14 @@ export class ElasticsearchService {
113
121
  ) {
114
122
  addMapping(runtimeField)
115
123
  }
124
+ if (
125
+ runtimeField in node &&
126
+ typeof node[runtimeField] === 'object' &&
127
+ node[runtimeField] !== null &&
128
+ ('gte' in node[runtimeField] || 'lte' in node[runtimeField])
129
+ ) {
130
+ addMapping(runtimeField)
131
+ }
116
132
  if (
117
133
  'query' in node &&
118
134
  typeof node.query === 'string' &&
@@ -131,8 +147,12 @@ export class ElasticsearchService {
131
147
  return payload
132
148
  }
133
149
 
134
- registerRuntimeField(fieldName: string, expression: string) {
135
- this.runtimeFields[fieldName] = expression
150
+ registerRuntimeField(
151
+ fieldName: string,
152
+ expression: string,
153
+ type: 'keyword' | 'date' = 'keyword'
154
+ ) {
155
+ this.runtimeFields[fieldName] = { script: expression, type }
136
156
  }
137
157
 
138
158
  getMetadataByIdsPayload(uuids: string[]): EsSearchParams {
@@ -264,8 +284,68 @@ export class ElasticsearchService {
264
284
  return this.metadataLang === 'current'
265
285
  }
266
286
 
267
- private filtersToQuery(
287
+ private findSpatialFilterExtent(
268
288
  filters: FieldFilters | FiltersAggregationParams | string
289
+ ): BoundingBox | undefined {
290
+ if (typeof filters === 'string') {
291
+ return undefined
292
+ }
293
+ return Object.values(filters).find(isBoundingBox)
294
+ }
295
+
296
+ // builds a query matching records whose dates intersect the filter range
297
+ private buildDateRangeQuery(searchField: string, dateRange: DateRange) {
298
+ // fields ending with 'DateRange' maps as ES range type
299
+ if (searchField.endsWith('DateRange')) {
300
+ return {
301
+ range: {
302
+ [searchField]: {
303
+ ...(dateRange.start && { gte: formatDate(dateRange.start) }),
304
+ ...(dateRange.end && { lte: formatDate(dateRange.end) }),
305
+ format: 'yyyy-MM-dd',
306
+ relation: 'intersects',
307
+ },
308
+ },
309
+ }
310
+ }
311
+ const minField = `${searchField}Min`
312
+ const maxField = `${searchField}Max`
313
+ if (minField in this.runtimeFields && maxField in this.runtimeFields) {
314
+ // min/max runtime fields form an interval per record; match if it intersects the filter range
315
+ const filter = [
316
+ dateRange.start && {
317
+ range: {
318
+ [maxField]: {
319
+ gte: formatDate(dateRange.start),
320
+ format: 'yyyy-MM-dd',
321
+ },
322
+ },
323
+ },
324
+ dateRange.end && {
325
+ range: {
326
+ [minField]: {
327
+ lte: formatDate(dateRange.end),
328
+ format: 'yyyy-MM-dd',
329
+ },
330
+ },
331
+ },
332
+ ].filter(Boolean)
333
+ return { bool: { filter } }
334
+ }
335
+ return {
336
+ range: {
337
+ [searchField]: {
338
+ ...(dateRange.start && { gte: formatDate(dateRange.start) }),
339
+ ...(dateRange.end && { lte: formatDate(dateRange.end) }),
340
+ format: 'yyyy-MM-dd',
341
+ },
342
+ },
343
+ }
344
+ }
345
+
346
+ private filtersToQuery(
347
+ filters: FieldFilters | FiltersAggregationParams | string,
348
+ spatialFilterExtent = this.findSpatialFilterExtent(filters)
269
349
  ): FilterQuery {
270
350
  const addQuote = (key: string) => (/^\/.+\/$/.test(key) ? key : `"${key}"`)
271
351
  const makeQuery = (filter: FieldFilter): string => {
@@ -286,7 +366,9 @@ export class ElasticsearchService {
286
366
  ? filters
287
367
  : Object.keys(filters)
288
368
  .filter((fieldname) => fieldname !== 'gn-ui-crossFieldFilter')
369
+ .filter((fieldname) => !isBoundingBox(filters[fieldname]))
289
370
  .filter((fieldname) => !isDateRange(filters[fieldname]))
371
+ .filter((fieldname) => !Array.isArray(filters[fieldname]))
290
372
  .filter(
291
373
  (fieldname) =>
292
374
  filters[fieldname] &&
@@ -299,37 +381,33 @@ export class ElasticsearchService {
299
381
  if (filters['gn-ui-crossFieldFilter']) {
300
382
  queryString = `${queryString} AND (${filters['gn-ui-crossFieldFilter']})`
301
383
  }
302
- const queryRange = Object.entries(filters)
303
- .filter(([, value]) => isDateRange(value))
304
- .map(([searchField, dateRange]) => {
305
- return {
306
- searchField,
307
- dateRange,
308
- } as {
309
- searchField: string
310
- dateRange: DateRange
311
- }
312
- })[0]
384
+ const queryRanges = Object.entries(filters).filter(([, value]) =>
385
+ isDateRange(value)
386
+ ) as [string, DateRange][]
313
387
  const queryParts = [
314
388
  queryString && {
315
389
  query_string: {
316
390
  query: queryString,
317
391
  },
318
392
  },
319
- queryRange &&
320
- queryRange.dateRange && {
321
- range: {
322
- [queryRange.searchField]: {
323
- ...(queryRange.dateRange.start && {
324
- gte: formatDate(queryRange.dateRange.start),
325
- }),
326
- ...(queryRange.dateRange.end && {
327
- lte: formatDate(queryRange.dateRange.end),
328
- }),
329
- format: 'yyyy-MM-dd',
393
+ ...queryRanges.map(([searchField, dateRange]) =>
394
+ this.buildDateRangeQuery(searchField, dateRange)
395
+ ),
396
+ spatialFilterExtent && {
397
+ geo_shape: {
398
+ geom: {
399
+ shape: {
400
+ type: 'envelope',
401
+ // spatialFilterExtent is [minX, minY, maxX, maxY]; envelope coordinates are [top-left, bottom-right]
402
+ coordinates: [
403
+ [spatialFilterExtent[0], spatialFilterExtent[3]],
404
+ [spatialFilterExtent[2], spatialFilterExtent[1]],
405
+ ],
330
406
  },
407
+ relation: 'intersects',
331
408
  },
332
409
  },
410
+ },
333
411
  ].filter(Boolean)
334
412
  return queryParts.length > 0 ? (queryParts as FilterQuery) : undefined
335
413
  }
@@ -368,7 +446,12 @@ export class ElasticsearchService {
368
446
  },
369
447
  })
370
448
  }
371
- const queryFilters = this.filtersToQuery(fieldSearchFilters)
449
+ // a spatial extent filter takes precedence over the preference geometry for boosting
450
+ const spatialFilterExtent = this.findSpatialFilterExtent(fieldSearchFilters)
451
+ const queryFilters = this.filtersToQuery(
452
+ fieldSearchFilters,
453
+ spatialFilterExtent
454
+ )
372
455
  if (queryFilters) {
373
456
  filter.push(...queryFilters)
374
457
  }
@@ -379,7 +462,10 @@ export class ElasticsearchService {
379
462
  },
380
463
  })
381
464
  }
382
- if (geometry) {
465
+ const boostGeometry = spatialFilterExtent
466
+ ? bboxToPolygon(spatialFilterExtent)
467
+ : geometry
468
+ if (boostGeometry) {
383
469
  // boosts applied using the filter geometry:
384
470
  // * records completely within the geometry receive a boost of 5
385
471
  // * records intersecting the geometry receive a boost of 2
@@ -389,7 +475,7 @@ export class ElasticsearchService {
389
475
  {
390
476
  geo_shape: {
391
477
  geom: {
392
- shape: geometry,
478
+ shape: boostGeometry,
393
479
  relation: 'within',
394
480
  },
395
481
  boost: 5.0,
@@ -398,7 +484,7 @@ export class ElasticsearchService {
398
484
  {
399
485
  geo_shape: {
400
486
  geom: {
401
- shape: geometry,
487
+ shape: boostGeometry,
402
488
  relation: 'intersects',
403
489
  },
404
490
  boost: 2.0,
@@ -409,7 +495,7 @@ export class ElasticsearchService {
409
495
  // this will boost the results variably depending on their distance from the given geometry
410
496
  // note: this takes into account the `location` field of a record; this is generally the center of all spatial extents
411
497
  // combined, and thus the actual size/coverage of the record spatial extent isn't relevant here
412
- const bbox = getGeometryBoundingBox(geometry)
498
+ const bbox = getGeometryBoundingBox(boostGeometry)
413
499
  const center = [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2]
414
500
  const northToCenter = new LineString([
415
501
  [center[0], bbox[3]],
@@ -6,17 +6,39 @@ export type FieldFilterByRange = {
6
6
  start?: Date
7
7
  end?: Date
8
8
  }
9
+ // matches util/shared's BoundingBox; duplicated here to avoid a circular
10
+ // dependency (util/shared already depends on common/domain)
11
+ export type FieldFilterByBoundingBox = [number, number, number, number]
9
12
 
10
13
  export type FieldFilter =
11
14
  | FieldFilterByExpression
12
15
  | FieldFilterByValues
13
16
  | FieldFilterByRange
17
+ | FieldFilterByBoundingBox
14
18
  export type FieldFilters = Record<FieldName, FieldFilter>
15
19
 
16
20
  export type QueryString = {
17
21
  query_string: string
18
22
  }
19
23
  export type QueryRange = {
20
- range: Record<string, { format: string; gte: string; lte: string }>
24
+ range: Record<
25
+ string,
26
+ { format: string; gte?: string; lte?: string; relation?: string }
27
+ >
21
28
  }
22
- export type FilterQuery = Array<QueryString | QueryRange>
29
+ export type QueryGeoShape = {
30
+ geo_shape: {
31
+ geom: {
32
+ shape: { type: string; coordinates: number[][] }
33
+ relation: string
34
+ }
35
+ }
36
+ }
37
+ export type QueryBool = {
38
+ bool: {
39
+ filter: QueryRange[]
40
+ }
41
+ }
42
+ export type FilterQuery = Array<
43
+ QueryString | QueryRange | QueryGeoShape | QueryBool
44
+ >
@@ -6,8 +6,6 @@ export * from './lib/feature-map.module'
6
6
  export * from './lib/map-state-container/map-state-container.component'
7
7
  export * from './lib/constant'
8
8
  export * from './lib/utils'
9
- export * from './lib/style'
10
9
  export * from './lib/layers-panel/layers-panel.component'
11
10
  export * from './lib/add-layer-from-catalog/add-layer-from-catalog.component'
12
11
  export * from './lib/add-layer-from-catalog/add-layer-record-preview/add-layer-record-preview.component'
13
- export * from './lib/geocoding/geocoding.component'
@@ -5,6 +5,7 @@
5
5
  (fileChange)="handleFileChange($event)"
6
6
  [accept]="acceptedMimeType.join(',')"
7
7
  [placeholder]="'map.addFromFile.placeholder' | translate"
8
+ textClass="text-gray-900 pl-2 py-2"
8
9
  class="placeholder-grey"
9
10
  ></gn-ui-drag-and-drop-file-input>
10
11
  </div>
@@ -2,18 +2,11 @@ import { NgModule } from '@angular/core'
2
2
  import { StoreModule } from '@ngrx/store'
3
3
  import * as fromMap from './+state/map.reducer'
4
4
  import { MapFacade } from './+state/map.facade'
5
- import { GEOCODING_PROVIDER, GeocodingProvider } from './geocoding.service'
6
5
 
7
6
  @NgModule({
8
7
  imports: [
9
8
  StoreModule.forFeature(fromMap.MAP_FEATURE_KEY, fromMap.mapReducer),
10
9
  ],
11
- providers: [
12
- MapFacade,
13
- {
14
- provide: GEOCODING_PROVIDER,
15
- useValue: ['geonames', { maxRows: 5 }] as GeocodingProvider,
16
- },
17
- ],
10
+ providers: [MapFacade],
18
11
  })
19
12
  export class FeatureMapModule {}
@@ -18,10 +18,12 @@ export class NotificationsService {
18
18
  error && console.error(error)
19
19
  const id = Math.floor(Math.random() * 1000000)
20
20
  this.notifications$.next([...this.notifications$.value, { ...content, id }])
21
- if (typeof timeoutMs === 'undefined') return
22
- setTimeout(() => {
23
- this.removeNotificationById(id)
24
- }, timeoutMs)
21
+ if (typeof timeoutMs !== 'undefined') {
22
+ setTimeout(() => {
23
+ this.removeNotificationById(id)
24
+ }, timeoutMs)
25
+ }
26
+ return id
25
27
  }
26
28
 
27
29
  removeNotificationById(id: number) {
@@ -33,7 +33,6 @@ import { RecordsRepositoryInterface } from '../../../../../../libs/common/domain
33
33
  import { NOT_KNOWN_CONSTRAINT } from '../../../../../../libs/feature/editor/src'
34
34
  import { NotificationsService } from '../../../../../../libs/feature/notifications/src'
35
35
  import { ButtonComponent, TextInputComponent } from '../../../../../../libs/ui/inputs/src'
36
- import { getOptionalEditorConfig } from '../../../../../../libs/util/app-config/src'
37
36
  import { NgIcon, provideIcons, provideNgIconsConfig } from '@ng-icons/core'
38
37
  import { iconoirAppWindow, iconoirPlusCircle } from '@ng-icons/iconoir'
39
38
  import { matCloseOutline } from '@ng-icons/material-icons/outline'
@@ -48,6 +47,10 @@ marker('notify.reuse.form.error.title')
48
47
  marker('notify.reuse.form.error.body')
49
48
 
50
49
  export const REUSE_FORM_URL = new InjectionToken<string>('reuseFormUrl')
50
+ // language assigned to the created reuse record; falls back to the current UI language
51
+ export const NEW_RECORD_DEFAULT_LANGUAGE = new InjectionToken<string>(
52
+ 'newRecordDefaultLanguage'
53
+ )
51
54
 
52
55
  @Component({
53
56
  selector: 'gn-ui-notify-reuse-form',
@@ -86,6 +89,9 @@ export class NotifyReuseFormComponent implements OnDestroy {
86
89
  private readonly notificationsService = inject(NotificationsService)
87
90
  private readonly platformServiceInterface = inject(PlatformServiceInterface)
88
91
  reuseFormUrl = inject(REUSE_FORM_URL, { optional: true })
92
+ private newRecordDefaultLanguage = inject(NEW_RECORD_DEFAULT_LANGUAGE, {
93
+ optional: true,
94
+ })
89
95
  private locationStrategy = inject(LocationStrategy)
90
96
 
91
97
  me = toSignal(this.platformServiceInterface.getMe())
@@ -158,8 +164,7 @@ export class NotifyReuseFormComponent implements OnDestroy {
158
164
  submit() {
159
165
  if (!this.isFormValid()) return
160
166
  const defaultLang =
161
- getOptionalEditorConfig()?.NEW_RECORD_DEFAULT_LANGUAGE ??
162
- this.translate.currentLang
167
+ this.newRecordDefaultLanguage ?? this.translate.currentLang
163
168
  const onlineResource: OnlineLinkResource = {
164
169
  type: 'link',
165
170
  url: new URL(this.url()),
@@ -22,7 +22,7 @@ export function flattenQueryParams(
22
22
  const start = (flattened[key] as DateRange).start
23
23
  const end = (flattened[key] as DateRange).end
24
24
  flattened[key] = [
25
- `${start ? formatDate(start) : ''}..${formatDate(end) || ''}`,
25
+ `${start ? formatDate(start) : ''}..${end ? formatDate(end) : ''}`,
26
26
  ]
27
27
  }
28
28
  }
@@ -1,4 +1,6 @@
1
1
  export * from './lib/feature-search.module'
2
+ export * from './lib/geocoding/geocoding.service'
3
+ export * from './lib/location-search/location-search.component'
2
4
  export * from './lib/filter-geometry.token'
3
5
  export * from './lib/record-url.token'
4
6
  export * from './lib/state/actions'
@@ -1,11 +1,19 @@
1
1
  @if (fieldType === 'dateRange') {
2
2
  <gn-ui-date-range-dropdown
3
3
  [title]="title"
4
- [startDate]="(selectedDateRange$ | async)?.start"
5
- [endDate]="(selectedDateRange$ | async)?.end"
6
- (startDateChange)="onStartDateChange($event)"
7
- (endDateChange)="onEndDateChange($event)"
4
+ [dateRange]="(selectedDateRange$ | async) ?? {}"
5
+ (dateRangeChange)="onDateRangeChange($event)"
6
+ [attr.data-cy-field]="fieldName"
8
7
  ></gn-ui-date-range-dropdown>
8
+ } @else if (fieldType === 'spatialExtent') {
9
+ <gn-ui-spatial-extent-dropdown
10
+ [title]="title"
11
+ [initialBbox]="(selectedBoundingBox$ | async) ?? null"
12
+ [maxFileSizeMb]="spatialExtentMaxFileSize"
13
+ (bboxChange)="onBboxChange($event)"
14
+ (errorChange)="onSpatialExtentError($event)"
15
+ [attr.data-cy-field]="fieldName"
16
+ ></gn-ui-spatial-extent-dropdown>
9
17
  } @else {
10
18
  <gn-ui-dropdown-multiselect
11
19
  class="w-full"
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ChangeDetectionStrategy,
3
3
  Component,
4
+ InjectionToken,
4
5
  Input,
5
6
  OnInit,
6
7
  inject,
@@ -9,6 +10,8 @@ import {
9
10
  Choice,
10
11
  DateRangeDropdownComponent,
11
12
  DropdownMultiselectComponent,
13
+ SpatialExtentDropdownComponent,
14
+ SpatialExtentDropdownError,
12
15
  } from '../../../../../../libs/ui/inputs/src'
13
16
  import { Observable, of, switchMap } from 'rxjs'
14
17
  import { catchError, filter, map, startWith } from 'rxjs/operators'
@@ -22,6 +25,15 @@ import {
22
25
  } from '../utils/service/fields'
23
26
  import { DateRange } from '../../../../../../libs/api/repository/src'
24
27
  import { CommonModule } from '@angular/common'
28
+ import { BoundingBox } from '../../../../../../libs/util/shared/src'
29
+ import { NotificationsService } from '../../../../../../libs/feature/notifications/src'
30
+ import { TranslateService } from '@ngx-translate/core'
31
+
32
+ // in MB, null means no limit
33
+ export const SPATIAL_EXTENT_MAX_FILE_SIZE = new InjectionToken<number | null>(
34
+ 'spatialExtentMaxFileSize',
35
+ { factory: () => null }
36
+ )
25
37
 
26
38
  @Component({
27
39
  selector: 'gn-ui-filter-dropdown',
@@ -33,18 +45,22 @@ import { CommonModule } from '@angular/common'
33
45
  CommonModule,
34
46
  DateRangeDropdownComponent,
35
47
  DropdownMultiselectComponent,
48
+ SpatialExtentDropdownComponent,
36
49
  ],
37
50
  })
38
51
  export class FilterDropdownComponent implements OnInit {
39
52
  private searchFacade = inject(SearchFacade)
40
53
  private searchService = inject(SearchService)
41
54
  private fieldsService = inject(FieldsService)
55
+ private notificationsService = inject(NotificationsService)
56
+ private translateService = inject(TranslateService)
42
57
 
43
58
  @Input() fieldName: string
44
59
  @Input() title: string
45
60
 
61
+ spatialExtentMaxFileSize = inject(SPATIAL_EXTENT_MAX_FILE_SIZE)
62
+
46
63
  fieldType: FieldType
47
- dateRange: DateRange
48
64
  choices$: Observable<Choice[]>
49
65
  selected$ = this.searchFacade.searchFilters$.pipe(
50
66
  switchMap((filters) =>
@@ -57,15 +73,54 @@ export class FilterDropdownComponent implements OnInit {
57
73
  ) as Observable<FieldValue[]>
58
74
 
59
75
  selectedDateRange$ = this.selected$.pipe(
60
- map((selectedDateRange) => selectedDateRange as DateRange)
76
+ map((selected) => (Array.isArray(selected) ? {} : (selected as DateRange)))
61
77
  ) as Observable<DateRange>
62
78
 
79
+ selectedBoundingBox$ = this.selected$.pipe(
80
+ map((selected) =>
81
+ Array.isArray(selected) && selected.length > 0
82
+ ? (selected as BoundingBox)
83
+ : null
84
+ )
85
+ ) as Observable<BoundingBox | null>
86
+
63
87
  onSelectedValues(values: unknown[]) {
64
88
  this.fieldsService
65
89
  .buildFiltersFromFieldValues({ [this.fieldName]: values as FieldValue[] })
66
90
  .subscribe((filters) => this.searchService.updateFilters(filters))
67
91
  }
68
92
 
93
+ private spatialExtentErrorNotificationId: number | null = null
94
+
95
+ onBboxChange(bbox: BoundingBox | null) {
96
+ this.fieldsService
97
+ .buildFiltersFromFieldValues({
98
+ [this.fieldName]: bbox,
99
+ })
100
+ .subscribe((filters) => this.searchService.updateFilters(filters))
101
+ this.clearSpatialExtentErrorNotification()
102
+ }
103
+
104
+ onSpatialExtentError(error: SpatialExtentDropdownError) {
105
+ this.clearSpatialExtentErrorNotification()
106
+ this.spatialExtentErrorNotificationId =
107
+ this.notificationsService.showNotification({
108
+ type: 'error',
109
+ title: this.translateService.instant(
110
+ 'search.filters.spatialExtent.error.title'
111
+ ),
112
+ text: this.translateService.instant(error.key, error.params),
113
+ })
114
+ }
115
+
116
+ private clearSpatialExtentErrorNotification() {
117
+ if (this.spatialExtentErrorNotificationId === null) return
118
+ this.notificationsService.removeNotificationById(
119
+ this.spatialExtentErrorNotificationId
120
+ )
121
+ this.spatialExtentErrorNotificationId = null
122
+ }
123
+
69
124
  ngOnInit() {
70
125
  this.fieldType = this.fieldsService.getFieldType(this.fieldName)
71
126
  this.choices$ = this.fieldsService.getAvailableValues(this.fieldName).pipe(
@@ -80,22 +135,11 @@ export class FilterDropdownComponent implements OnInit {
80
135
  )
81
136
  }
82
137
 
83
- onStartDateChange(start: Date) {
84
- if (!start) return
85
- this.dateRange = { ...this.dateRange, start }
86
- }
87
-
88
- onEndDateChange(end: Date) {
89
- if (!end) return
90
- this.dateRange = { ...this.dateRange, end }
91
- if (this.dateRange.start && this.dateRange.end) {
92
- this.fieldsService
93
- .buildFiltersFromFieldValues({
94
- [this.fieldName]: this.dateRange,
95
- })
96
- .subscribe((filters) => {
97
- return this.searchService.updateFilters(filters)
98
- })
99
- }
138
+ onDateRangeChange(dateRange: DateRange) {
139
+ this.fieldsService
140
+ .buildFiltersFromFieldValues({
141
+ [this.fieldName]: dateRange,
142
+ })
143
+ .subscribe((filters) => this.searchService.updateFilters(filters))
100
144
  }
101
145
  }
@@ -5,22 +5,31 @@ import {
5
5
  GeocodingResult,
6
6
  queryGeonames,
7
7
  GeonamesOptions,
8
- DataGouvFrOptions,
9
- queryDataGouvFr,
8
+ GeoplateformeOptions,
9
+ queryGeoplateforme,
10
10
  } from '@geospatial-sdk/geocoding'
11
11
  import { from, Observable, throwError } from 'rxjs'
12
12
  import { catchError } from 'rxjs/operators'
13
13
 
14
14
  type GeoadminGeocodingProvider = ['geoadmin', GeoadminOptions]
15
15
  type GeonamesGeocodingProvider = ['geonames', GeonamesOptions]
16
- type DataGouvFrGeocodingProvider = ['data-gouv-fr', DataGouvFrOptions]
16
+ type GeoplateformeGeocodingProvider = ['geoplateforme', GeoplateformeOptions]
17
17
  export type GeocodingProvider =
18
18
  | GeoadminGeocodingProvider
19
19
  | GeonamesGeocodingProvider
20
- | DataGouvFrGeocodingProvider
20
+ | GeoplateformeGeocodingProvider
21
+
22
+ export const DEFAULT_GEOCODING_PROVIDER = [
23
+ 'geonames',
24
+ { maxRows: 5 },
25
+ ] as GeocodingProvider
21
26
 
22
27
  export const GEOCODING_PROVIDER = new InjectionToken<GeocodingProvider>(
23
- 'geocoding-provider'
28
+ 'geocoding-provider',
29
+ {
30
+ providedIn: 'root',
31
+ factory: (): GeocodingProvider => DEFAULT_GEOCODING_PROVIDER,
32
+ }
24
33
  )
25
34
 
26
35
  @Injectable({
@@ -42,9 +51,9 @@ export class GeocodingService {
42
51
  queryGeonames(text, this.provider[1] as GeonamesOptions)
43
52
  )
44
53
  break
45
- case 'data-gouv-fr':
54
+ case 'geoplateforme':
46
55
  queryObservable = from(
47
- queryDataGouvFr(text, this.provider[1] as DataGouvFrOptions)
56
+ queryGeoplateforme(text, this.provider[1] as GeoplateformeOptions)
48
57
  )
49
58
  break
50
59
  default:
@@ -0,0 +1,8 @@
1
+ <gn-ui-autocomplete
2
+ [action]="searchAction"
3
+ [displayWithFn]="displayWithFn"
4
+ [placeholder]="placeholder"
5
+ [displayWithTemplate]="displayWithTemplate"
6
+ (itemSelected)="handleItemSelected($event)"
7
+ >
8
+ </gn-ui-autocomplete>
@@ -0,0 +1,40 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ EventEmitter,
5
+ inject,
6
+ Input,
7
+ Output,
8
+ TemplateRef,
9
+ } from '@angular/core'
10
+ import { Observable } from 'rxjs'
11
+ import { GeocodingResult } from '@geospatial-sdk/geocoding'
12
+ import {
13
+ AutocompleteComponent,
14
+ AutocompleteItem,
15
+ } from '../../../../../../libs/ui/inputs/src'
16
+ import { GeocodingService } from '../geocoding/geocoding.service'
17
+
18
+ @Component({
19
+ selector: 'gn-ui-location-search',
20
+ templateUrl: './location-search.component.html',
21
+ changeDetection: ChangeDetectionStrategy.OnPush,
22
+ standalone: true,
23
+ imports: [AutocompleteComponent],
24
+ })
25
+ export class LocationSearchComponent {
26
+ private geocodingService = inject(GeocodingService)
27
+
28
+ @Input() placeholder = ''
29
+ @Input() displayWithTemplate?: TemplateRef<{ $implicit: GeocodingResult }>
30
+ @Output() resultSelected = new EventEmitter<GeocodingResult>()
31
+
32
+ displayWithFn = (item: AutocompleteItem) => (item as GeocodingResult).label
33
+
34
+ searchAction = (text: string): Observable<AutocompleteItem[]> =>
35
+ this.geocodingService.query(text)
36
+
37
+ handleItemSelected(item: AutocompleteItem) {
38
+ this.resultSelected.emit(item as GeocodingResult)
39
+ }
40
+ }