• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geonetwork / geonetwork-ui / 14537820791

18 Apr 2025 03:53PM UTC coverage: 84.358% (+1.3%) from 83.067%
14537820791

Pull #1203

github

web-flow
Merge 2e5955f5b into 3e7617888
Pull Request #1203: New truncate text component

1725 of 2323 branches covered (74.26%)

Branch coverage included in aggregate %.

30 of 31 new or added lines in 4 files covered. (96.77%)

10 existing lines in 2 files now uncovered.

5572 of 6327 relevant lines covered (88.07%)

10.4 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.86
/libs/feature/search/src/lib/utils/service/fields.ts
1
import { firstValueFrom, Observable, of, switchMap } from 'rxjs'
6✔
2
import { map } from 'rxjs/operators'
6✔
3
import { Injector } from '@angular/core'
4
import { TranslateService } from '@ngx-translate/core'
6✔
5
import { marker } from '@biesbjerg/ngx-translate-extract-marker'
6✔
6
import { OrganizationsServiceInterface } from '@geonetwork-ui/common/domain/organizations.service.interface'
6✔
7
import { RecordsRepositoryInterface } from '@geonetwork-ui/common/domain/repository/records-repository.interface'
6✔
8
import { PlatformServiceInterface } from '@geonetwork-ui/common/domain/platform.service.interface'
6✔
9
import {
10
  AggregationBuckets,
11
  AggregationsParams,
12
  FieldFilter,
13
  FieldFilterByExpression,
14
  FieldFilters,
15
  TermBucket,
16
} from '@geonetwork-ui/common/domain/model/search'
17
import {
6✔
18
  DateRange,
19
  ElasticsearchService,
20
  isDateRange,
21
  METADATA_LANGUAGE,
22
} from '@geonetwork-ui/api/repository'
23
import { LangService } from '@geonetwork-ui/util/i18n'
6✔
24
import { formatUserInfo } from '@geonetwork-ui/util/shared'
6✔
25
import { PossibleResourceTypes } from '@geonetwork-ui/api/metadata-converter'
6✔
26

27
export type FieldType = 'values' | 'dateRange'
28

29
export type FieldValue = string | number
30
export interface FieldAvailableValue {
31
  value: FieldValue
32
  label: string
33
  count?: number
34
}
35

36
export abstract class AbstractSearchField {
6✔
37
  abstract getAvailableValues(): Observable<FieldAvailableValue[] | DateRange[]>
38
  abstract getFiltersForValues(
39
    values: FieldValue[] | DateRange[]
40
  ): Observable<FieldFilters>
41
  abstract getValuesForFilter(
42
    filters: FieldFilters
43
  ): Observable<FieldValue[] | FieldValue | DateRange>
44
  abstract getType(): FieldType
45
}
46

47
export class SimpleSearchField implements AbstractSearchField {
6✔
48
  protected repository = this.injector.get(RecordsRepositoryInterface)
171✔
49

50
  // FIXME: this is required to register runtime fields; abstract this as well
51
  protected esService = this.injector.get(ElasticsearchService)
171✔
52

53
  constructor(
54
    protected esFieldName: string,
171✔
55
    protected injector: Injector,
171✔
56
    protected order: 'asc' | 'desc' = 'asc',
171!
57
    protected orderType: 'key' | 'count' = 'key'
171✔
58
  ) {}
59

60
  protected getAggregations(): AggregationsParams {
61
    return {
13✔
62
      [this.esFieldName]: {
63
        type: 'terms',
64
        field: this.esFieldName,
65
        limit: 1000,
66
        sort: [this.order, this.orderType],
67
      },
68
    }
69
  }
70

71
  protected async getBucketLabel(bucket: TermBucket): Promise<string> {
72
    return bucket.term.toString()
26✔
73
  }
74

75
  getAvailableValues(): Observable<FieldAvailableValue[]> {
76
    return this.repository.aggregate(this.getAggregations()).pipe(
16✔
77
      map(
78
        (response) =>
79
          (response[this.esFieldName] as AggregationBuckets).buckets || []
16!
80
      ),
81
      switchMap((buckets: TermBucket[]) => {
82
        const bucketPromises = buckets.map(async (bucket) => ({
64✔
83
          label: `${await this.getBucketLabel(bucket)} (${bucket.count})`,
84
          value: bucket.term.toString(),
85
          count: bucket.count,
86
        }))
87
        return Promise.all(bucketPromises)
16✔
88
      })
89
    )
90
  }
91
  getFiltersForValues(
92
    values: FieldValue[] | DateRange[]
93
  ): Observable<FieldFilters> {
94
    // FieldValue[]
95
    if (this.getType() === 'values') {
8✔
96
      return of({
6✔
97
        [this.esFieldName]: (values as FieldValue[]).reduce((acc, val) => {
98
          const value = val.toString()
10✔
99
          if (value !== '') {
10✔
100
            return { ...acc, [value]: true }
8✔
101
          }
102
          return acc
2✔
103
        }, {}),
104
      })
105
    }
106
    // DateRange
107
    return of({
2✔
108
      [this.esFieldName]: values[0] !== '' ? values[0] : {},
2✔
109
    })
110
  }
111
  getValuesForFilter(
112
    filters: FieldFilters
113
  ): Observable<FieldValue[] | FieldValue | DateRange> {
114
    const filter = filters[this.esFieldName]
19✔
115
    if (!filter) return of([])
19✔
116
    // filter by expression
117
    if (typeof filter === 'string') {
5✔
118
      return of([filter])
1✔
119
    }
120
    // filter by date range
121
    if (isDateRange(filter)) {
4✔
122
      return of(filter)
2✔
123
    }
124
    // filter by values
125
    const values = Object.keys(filter).filter((v) => filter[v])
4✔
126
    return of(values)
2✔
127
  }
128

129
  getType(): FieldType {
130
    return 'values'
8✔
131
  }
132
}
133

134
export class TranslatedSearchField extends SimpleSearchField {
6✔
135
  protected platformService = this.injector.get(PlatformServiceInterface)
37✔
136

137
  constructor(
138
    protected esFieldName: string,
37✔
139
    protected injector: Injector,
37✔
140
    protected order: 'asc' | 'desc' = 'asc',
37!
141
    protected orderType: 'key' | 'count' = 'key'
37✔
142
  ) {
143
    super(esFieldName, injector, order, orderType)
37✔
144
  }
145

146
  protected async getTranslation(key: string) {
147
    return firstValueFrom(this.platformService.translateKey(key))
20✔
148
  }
149

150
  protected async getBucketLabel(bucket: TermBucket) {
151
    return (await this.getTranslation(bucket.term)) || bucket.term
20✔
152
  }
153

154
  getAvailableValues(): Observable<FieldAvailableValue[]> {
155
    if (this.orderType === 'count') return super.getAvailableValues()
5✔
156
    // sort values by alphabetical order
157
    return super
3✔
158
      .getAvailableValues()
159
      .pipe(
160
        map((values) =>
161
          values.sort((a, b) => new Intl.Collator().compare(a.label, b.label))
15✔
162
        )
163
      )
164
  }
165
}
166

167
/**
168
 * This search field will either target the `.default` field, or a specific `.langxyz` field according
169
 * to the defined METADATA_LANGUAGE token
170
 * The provided ES field name should not include any prefix such as `.langeng`
171
 */
172
export class MultilingualSearchField extends SimpleSearchField {
6✔
173
  private langService = this.injector.get(LangService)
27✔
174
  private searchLanguage = this.injector.get(METADATA_LANGUAGE, null)
27✔
175

176
  constructor(
177
    protected esFieldName: string,
27✔
178
    protected injector: Injector,
27✔
179
    protected order: 'asc' | 'desc' = 'asc',
27!
180
    protected orderType: 'key' | 'count' = 'key'
27!
181
  ) {
182
    super(esFieldName, injector, order, orderType)
27✔
183
    // note: we're excluding the metadata language "current" value because that would produce
184
    // permalinks that might not work for different users
185
    if (this.searchLanguage && this.searchLanguage !== 'current') {
27✔
186
      this.esFieldName += `.lang${this.searchLanguage}`
1✔
187
    } else {
188
      this.esFieldName += '.default'
26✔
189
    }
190
  }
191
}
192

193
export class FullTextSearchField implements AbstractSearchField {
6✔
194
  getAvailableValues(): Observable<FieldAvailableValue[]> {
195
    return of([])
1✔
196
  }
197
  getFiltersForValues(values: FieldValue[]): Observable<FieldFilters> {
198
    return of({
3✔
199
      any: values[0] as string,
200
    })
201
  }
202
  getValuesForFilter(filters: FieldFilters): Observable<FieldValue[]> {
203
    return of(filters.any ? [filters.any as FieldFilterByExpression] : [])
2✔
204
  }
205
  getType(): FieldType {
UNCOV
206
    return 'values'
×
207
  }
208
}
209

210
marker('search.filters.isSpatial.yes')
6✔
211
marker('search.filters.isSpatial.no')
6✔
212

213
export class IsSpatialSearchField extends SimpleSearchField {
6✔
214
  private translateService = this.injector.get(TranslateService)
12✔
215

216
  constructor(injector: Injector) {
217
    super('isSpatial', injector, 'asc')
12✔
218
    this.esService.registerRuntimeField(
12✔
219
      'isSpatial',
220
      `String result = 'no';
221
String formats = doc.format.join('|').toLowerCase();
222
if (formats.contains('geojson') || formats.contains('arcgis') || formats.contains('ogc') || formats.contains('shp')) result = 'yes';
223
String protocols = doc.linkProtocol.join('|').toLowerCase();
224
if (protocols.contains('esri') || protocols.contains('ogc')) result = 'yes';
225
emit(result);`
226
    )
227
  }
228

229
  protected getAggregations(): AggregationsParams {
230
    return {
1✔
231
      isSpatial: {
232
        type: 'terms',
233
        limit: 2,
234
        field: 'isSpatial',
235
        sort: ['asc', 'key'],
236
      },
237
    }
238
  }
239

240
  protected async getBucketLabel(bucket: TermBucket) {
241
    return firstValueFrom(
2✔
242
      this.translateService.get(`search.filters.isSpatial.${bucket.term}`)
243
    )
244
  }
245

246
  getFiltersForValues(values: FieldValue[]): Observable<FieldFilters> {
247
    const isSpatial = {}
1✔
248
    if (values.length > 0) isSpatial[values[values.length - 1]] = true
1✔
249
    return of({
1✔
250
      isSpatial,
251
    })
252
  }
253

254
  getValuesForFilter(filters: FieldFilters): Observable<FieldValue[]> {
255
    const filter = filters.isSpatial
2✔
256
    if (!filter) return of([])
2✔
257
    const keys = Object.keys(filter)
1✔
258
    return of(keys.length ? [keys[0]] : [])
1!
259
  }
260
}
261

262
marker('search.filters.license.pddl')
6✔
263
marker('search.filters.license.odbl')
6✔
264
marker('search.filters.license.odc-by')
6✔
265
marker('search.filters.license.cc-by-sa')
6✔
266
marker('search.filters.license.cc-by')
6✔
267
marker('search.filters.license.cc-zero')
6✔
268
marker('search.filters.license.etalab-v2')
6✔
269
marker('search.filters.license.etalab')
6✔
270
marker('search.filters.license.unknown')
6✔
271

272
// Note: values are inspired from https://doc.data.gouv.fr/moissonnage/licences/
273
export class LicenseSearchField extends SimpleSearchField {
6✔
274
  private translateService = this.injector.get(TranslateService)
11✔
275

276
  constructor(injector: Injector) {
277
    super('license', injector, 'asc')
11✔
278
    this.esService.registerRuntimeField(
11✔
279
      'license',
280
      `String raw = '';
281
if (doc.containsKey('licenseObject.default.keyword') && doc['licenseObject.default.keyword'].length > 0)
282
  raw += doc['licenseObject.default.keyword'].join('|').toLowerCase();
283
if (doc.containsKey('MD_LegalConstraintsUseLimitationObject.default.keyword') && doc['MD_LegalConstraintsUseLimitationObject.default.keyword'].length > 0)
284
  raw += doc['MD_LegalConstraintsUseLimitationObject.default.keyword'].join('|').toLowerCase();
285

286
boolean unknown = true;
287
if (raw.contains('pddl') || raw.contains('public domain dedication and licence')) {
288
  unknown = false;
289
  emit('pddl');
290
}
291
if (raw.contains('odbl') || raw.contains('open database license')) {
292
  unknown = false;
293
  emit('odbl');
294
}
295
if (raw.contains('odc-by') || raw.contains('opendatacommons.org/licenses/by/summary/"')) {
296
  unknown = false;
297
  emit('odc-by');
298
}
299

300
if (raw.contains('cc-by-sa') || raw.contains('creative commons attribution share-alike')) {
301
  unknown = false;
302
  emit('cc-by-sa');
303
} else if (raw.contains('cc-by') || raw.contains('cc by') || raw.contains('creative commons attribution')) {
304
  unknown = false;
305
  emit('cc-by');
306
} else if (raw.contains('cc0') || raw.contains('cc-0') || raw.contains('cczero') || raw.contains('cc-zero')) {
307
  unknown = false;
308
  emit('cc-zero');
309
}
310

311
if (raw.contains('etalab') && (raw.contains('v2') || raw.contains('2.0'))) {
312
  unknown = false;
313
  emit('etalab-v2');
314
} else if (raw.contains('open licence') || raw.contains('licence ouverte') || raw.contains('licence_ouverte')) {
315
  unknown = false;
316
  emit('etalab');
317
}
318

319
if(unknown) emit('unknown');`
320
    )
321
  }
322

323
  protected getAggregations(): AggregationsParams {
324
    return {
2✔
325
      license: {
326
        type: 'terms',
327
        limit: 10,
328
        field: 'license',
329
        sort: ['desc', 'count'],
330
      },
331
    }
332
  }
333

334
  protected async getBucketLabel(bucket: TermBucket) {
335
    return firstValueFrom(
14✔
336
      this.translateService.get(`search.filters.license.${bucket.term}`)
337
    )
338
  }
339
}
340

341
// This will use the OrganizationsServiceInterface
342
// Field values are the organization names
343
export class OrganizationSearchField implements AbstractSearchField {
6✔
344
  private orgsService = this.injector.get(OrganizationsServiceInterface)
11✔
345

346
  constructor(private injector: Injector) {}
11✔
347

348
  getFiltersForValues(values: FieldValue[]): Observable<FieldFilters> {
349
    return this.orgsService.organisations$.pipe(
3✔
350
      map((orgs) =>
351
        values
3✔
352
          .map((name) => orgs.find((org) => org.name === name))
11✔
353
          .filter((org) => org !== undefined)
7✔
354
      ),
355
      switchMap((selectedOrgs) =>
356
        this.orgsService.getFiltersForOrgs(selectedOrgs)
3✔
357
      )
358
    )
359
  }
360

361
  getValuesForFilter(filters: FieldFilters): Observable<FieldValue[]> {
362
    return this.orgsService
2✔
363
      .getOrgsFromFilters(filters)
364
      .pipe(map((orgs) => orgs.map((org) => org.name)))
3✔
365
  }
366

367
  getAvailableValues(): Observable<FieldAvailableValue[]> {
368
    // sort values by alphabetical order
369
    return this.orgsService.organisations$.pipe(
3✔
370
      map((organisations) =>
371
        organisations.map((org) => ({
5✔
372
          label: `${org.name} (${org.recordCount})`,
373
          value: org.name,
374
        }))
375
      ),
376
      map((values) =>
377
        values.sort((a, b) => new Intl.Collator().compare(a.label, b.label))
4✔
378
      )
379
    )
380
  }
381

382
  getType(): FieldType {
383
    return 'values'
1✔
384
  }
385
}
386
export class OwnerSearchField extends SimpleSearchField {
6✔
387
  constructor(injector: Injector) {
388
    super('owner', injector, 'asc')
8✔
389
  }
390

391
  getAvailableValues(): Observable<FieldAvailableValue[]> {
UNCOV
392
    return of([])
×
393
  }
394
}
395

396
export class UserSearchField extends SimpleSearchField {
6✔
397
  constructor(injector: Injector) {
398
    super('userinfo.keyword', injector, 'asc')
10✔
399
  }
400

401
  getAvailableValues(): Observable<FieldAvailableValue[]> {
402
    return super.getAvailableValues().pipe(
2✔
403
      map((values) =>
404
        values.map((v) => ({
6✔
405
          ...v,
406
          label: formatUserInfo(v.label, true),
407
        }))
408
      )
409
    )
410
  }
411
}
412

413
export class DateRangeSearchField extends SimpleSearchField {
6✔
414
  constructor(
415
    protected esFieldName: string,
13✔
416
    protected injector: Injector,
13✔
417
    protected order: 'asc' | 'desc' = 'asc',
13!
418
    protected orderType: 'key' | 'count' = 'key'
13✔
419
  ) {
420
    super(esFieldName, injector, order, orderType)
13✔
421
  }
422

423
  getAvailableValues(): Observable<FieldAvailableValue[]> {
424
    // TODO: return an array of dates to show which one are available in the date picker
425
    return of([])
1✔
426
  }
427

428
  getType(): FieldType {
429
    return 'dateRange'
3✔
430
  }
431
}
432

433
marker('search.filters.availableServices.view')
6✔
434
marker('search.filters.availableServices.download')
6✔
435

436
export class AvailableServicesField extends SimpleSearchField {
6✔
437
  private translateService = this.injector.get(TranslateService)
11✔
438

439
  constructor(injector: Injector) {
440
    super('availableServices', injector, 'asc')
11✔
441
  }
442

443
  linkProtocolViewFilter = '/OGC:WMT?S.*/'
11✔
444
  linkProtocolDownloadFilter = '/OGC:WFS.*/'
11✔
445

446
  protected async getBucketLabel(bucket: TermBucket) {
447
    return firstValueFrom(
2✔
448
      this.translateService.get(
449
        `search.filters.availableServices.${bucket.term}`
450
      )
451
    )
452
  }
453

454
  protected getAggregations(): AggregationsParams {
455
    return {
1✔
456
      availableServices: {
457
        type: 'filters',
458
        filters: {
459
          view: `+linkProtocol:${this.linkProtocolViewFilter}`,
460
          download: `+linkProtocol:${this.linkProtocolDownloadFilter}`,
461
        },
462
      },
463
    }
464
  }
465

466
  getFiltersForValues(values: FieldValue[]): Observable<FieldFilters> {
467
    const filters: FieldFilter = {}
1✔
468
    if (values.includes('view')) filters[this.linkProtocolViewFilter] = true
1✔
469
    if (values.includes('download'))
1✔
470
      filters[this.linkProtocolDownloadFilter] = true
1✔
471

472
    return of({
1✔
473
      linkProtocol: filters,
474
    })
475
  }
476

477
  getValuesForFilter(filters: FieldFilters): Observable<FieldValue[]> {
478
    const linkFilter = filters.linkProtocol
2✔
479
    if (!linkFilter) return of([])
2✔
480
    const values = []
1✔
481
    if (linkFilter[this.linkProtocolViewFilter]) values.push('view')
1✔
482
    if (linkFilter[this.linkProtocolDownloadFilter]) values.push('download')
1!
483
    return of(values)
1✔
484
  }
485
}
486

487
/**
488
 * This class is meant to be used with the legacy filter on `resourceType` (now deprecated, the use of `recordKind` field is recommended).
489
 * Since creating filters on the same ES field is not possible, in order to make the resource type filter still working,
490
 * we create an ES on the fly: `resourceTypeLegacy` that references the `resourceType` under the hood.
491
 * @deprecated Use `recordKind` field instead.
492
 */
493
export class ResourceTypeLegacyField extends TranslatedSearchField {
6✔
494
  constructor(injector: Injector) {
495
    super('resourceTypeLegacy', injector, 'asc')
8✔
496

497
    // Ask ES to create a field on the fly: 'resourceTypeLegacy' that is in fact, 'resourceType'
498
    this.esService.registerRuntimeField(
8✔
499
      'resourceTypeLegacy',
500
      `for (resourceType in doc.resourceType) { emit(resourceType) }`
501
    )
502
  }
503
}
504

505
export class RecordKindField extends SimpleSearchField {
6✔
506
  TYPE_MAPPING = {
11✔
507
    dataset: ['dataset', 'series', 'featureCatalog'],
508
    service: ['service'],
509
    reuse: Object.entries(PossibleResourceTypes)
510
      .filter(([_, v]) => v === 'reuse')
132✔
511
      .map(([k]) => k), // = ['application', 'map', 'staticMap', 'interactiveMap', ...]
99✔
512
  }
513

514
  constructor(injector: Injector) {
515
    super('resourceType', injector, 'asc')
11✔
516
  }
517

518
  getAvailableValues(): Observable<FieldAvailableValue[]> {
519
    return this.repository.aggregate(this.getAggregations()).pipe(
1✔
520
      map(
521
        (response) =>
522
          (response[this.esFieldName] as AggregationBuckets).buckets || []
1!
523
      ),
524
      map((buckets: TermBucket[]) => {
525
        const counts = buckets.reduce(
1✔
526
          (acc, { term, count }) => {
527
            const value = term.toString()
4✔
528
            const key = this.TYPE_MAPPING.reuse.includes(value)
4✔
529
              ? 'reuse'
530
              : this.TYPE_MAPPING.dataset.includes(value)
3✔
531
                ? 'dataset'
532
                : value
533

534
            acc[key] = (acc[key] || 0) + count
4✔
535
            return acc
4✔
536
          },
537
          {} as Record<string, number>
538
        )
539

540
        return Object.keys(this.TYPE_MAPPING).map((type) => ({
3✔
541
          label: type,
542
          value: type,
543
          count: counts[type] ?? 0,
3!
544
        }))
545
      })
546
    )
547
  }
548

549
  getFiltersForValues(values: FieldValue[]): Observable<FieldFilters> {
550
    const filters = {
1✔
551
      [this.esFieldName]: values.reduce((acc, value) => {
552
        if (value === '') return { ...acc, [value]: true }
2!
553

554
        const keysToAdd = this.TYPE_MAPPING[value] || [value]
2!
555
        keysToAdd.forEach((key: string) => (acc[key] = true))
12✔
556

557
        return acc
2✔
558
      }, {}),
559
    }
560

561
    return of(filters)
1✔
562
  }
563

564
  getValuesForFilter(filters: FieldFilters): Observable<FieldValue[]> {
565
    const filter = filters[this.esFieldName]
2✔
566
    if (!filter) return of([])
2✔
567

568
    const activeValues = Object.keys(filter).filter((v) => filter[v])
4✔
569
    const activeTypes = Object.keys(this.TYPE_MAPPING).filter((type) =>
1✔
570
      this.TYPE_MAPPING[type].every((t: string) => activeValues.includes(t))
5✔
571
    )
572

573
    // Allow unknown values eg. 'type=somethingnotexist' (for UI to select none)
574
    const allTypes = [].concat(...Object.values(this.TYPE_MAPPING))
1✔
575
    const unknownValues = activeValues.filter(
1✔
576
      (value) => !allTypes.includes(value)
3✔
577
    )
578

579
    return of([...activeTypes, ...unknownValues])
1✔
580
  }
581
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc