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

geonetwork / geonetwork-ui / 15845940628

24 Jun 2025 08:55AM UTC coverage: 81.561% (-2.3%) from 83.836%
15845940628

Pull #1273

github

web-flow
Merge 2bca69bcf into cfd9cd166
Pull Request #1273: [Datahub]: Close autocomplete suggestions on search

2026 of 2879 branches covered (70.37%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 1 file covered. (100.0%)

1 existing line in 1 file now uncovered.

6639 of 7745 relevant lines covered (85.72%)

11.49 hits per line

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

79.21
/libs/ui/inputs/src/lib/autocomplete/autocomplete.component.ts
1
import {
1✔
2
  AfterViewInit,
3
  ChangeDetectionStrategy,
4
  ChangeDetectorRef,
5
  Component,
6
  ElementRef,
7
  EventEmitter,
8
  Input,
9
  OnChanges,
10
  OnDestroy,
11
  OnInit,
12
  Output,
13
  SimpleChanges,
14
  ViewChild,
15
} from '@angular/core'
16
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'
1✔
17
import {
1✔
18
  MatAutocomplete,
19
  MatAutocompleteModule,
20
  MatAutocompleteSelectedEvent,
21
  MatAutocompleteTrigger,
22
} from '@angular/material/autocomplete'
23
import { first, merge, Observable, of, ReplaySubject, Subscription } from 'rxjs'
1✔
24
import {
1✔
25
  catchError,
26
  debounceTime,
27
  distinctUntilChanged,
28
  filter,
29
  finalize,
30
  map,
31
  switchMap,
32
  take,
33
  tap,
34
} from 'rxjs/operators'
35
import { PopupAlertComponent } from '@geonetwork-ui/ui/widgets'
1✔
36
import { CommonModule } from '@angular/common'
1✔
37
import { TranslateDirective } from '@ngx-translate/core'
1✔
38
import { ButtonComponent } from '../button/button.component'
1✔
39
import {
1✔
40
  NgIconComponent,
41
  provideIcons,
42
  provideNgIconsConfig,
43
} from '@ng-icons/core'
44
import { iconoirLongArrowDownLeft, iconoirSearch } from '@ng-icons/iconoir'
1✔
45
import { matClose } from '@ng-icons/material-icons/baseline'
1✔
46

47
export type AutocompleteItem = unknown
48

49
@Component({
50
  selector: 'gn-ui-autocomplete',
51
  templateUrl: './autocomplete.component.html',
52
  styleUrls: ['./autocomplete.component.css'],
53
  changeDetection: ChangeDetectionStrategy.OnPush,
54
  standalone: true,
55
  imports: [
56
    PopupAlertComponent,
57
    MatAutocompleteModule,
58
    CommonModule,
59
    TranslateDirective,
60
    ReactiveFormsModule,
61
    ButtonComponent,
62
    NgIconComponent,
63
  ],
64
  providers: [
65
    provideIcons({
66
      iconoirSearch,
67
      matClose,
68
      iconoirLongArrowDownLeft,
69
    }),
70
    provideNgIconsConfig({
71
      size: '1.75rem',
72
    }),
73
  ],
74
})
75
export class AutocompleteComponent
1✔
76
  implements OnInit, AfterViewInit, OnDestroy, OnChanges
77
{
78
  @Input() placeholder: string
79
  @Input() enterButton = false
33✔
80
  @Input() action: (value: string) => Observable<AutocompleteItem[]>
81
  @Input() value?: AutocompleteItem
82
  @Input() clearOnSelection = false
33✔
83
  @Input() preventCompleteOnSelection = false
33✔
84
  @Input() autoFocus = false
33✔
85
  @Input() minCharacterCount? = 3
33✔
86
  // this will show a submit button next to the input; if false, a search icon will appear on the left
87
  @Input() allowSubmit = false
33✔
88
  @Input() forceTrackPosition = false
33✔
89
  @Output() itemSelected = new EventEmitter<AutocompleteItem>()
33✔
90
  @Output() inputSubmitted = new EventEmitter<string>()
33✔
91
  @Output() inputCleared = new EventEmitter<void>()
33✔
92
  @Output() isSearchActive = new EventEmitter<boolean>()
33✔
93
  @ViewChild(MatAutocompleteTrigger) triggerRef: MatAutocompleteTrigger
94
  @ViewChild(MatAutocomplete) autocomplete: MatAutocomplete
95
  @ViewChild('searchInput') inputRef: ElementRef<HTMLInputElement>
96

97
  searching: boolean
98
  control = new UntypedFormControl()
33✔
99
  cancelEnter = true
33✔
100
  selectionSubject = new ReplaySubject<MatAutocompleteSelectedEvent>(1)
33✔
101
  lastInputValue$ = new ReplaySubject<string>(1)
33✔
102
  error: string | null = null
33✔
103
  suggestions$: Observable<AutocompleteItem[]>
104
  subscription = new Subscription()
33✔
105
  private lastPosition: DOMRect | null = null
33✔
106
  private intervalIdPosition: number | undefined
107
  enterBtnPosition = 0
33✔
108
  searchActive = false
33✔
109

110
  @Input() displayWithFn: (item: AutocompleteItem) => string = (item) =>
33✔
111
    item.toString()
17✔
112

113
  get displayEnterBtn() {
114
    return this.enterButton && this.allowSubmit && !this.searchActive
12!
115
  }
116

117
  displayWithFnInternal = (item?: AutocompleteItem) => {
33✔
118
    if (item === null || item === undefined) return null
55✔
119
    return this.displayWithFn(item)
24✔
120
  }
121

122
  getExtraClass(): string {
123
    if (this.allowSubmit) {
12!
124
      if (this.enterButton) {
×
125
        return 'border rounded-lg absolute w-8 h-8 right-[calc(var(--icon-width)+var(--icon-padding))] inset-y-[--icon-padding]'
×
126
      } else {
127
        return 'border rounded-lg absolute w-8 h-8 right-[calc(var(--icon-width)+0.25*var(--icon-width))] inset-y-[calc(0.25*var(--icon-width))]'
×
128
      }
129
    } else {
130
      if (!this.enterButton) {
12✔
131
        return 'border rounded-lg absolute w-8 h-8 right-2 inset-y-2'
12✔
132
      }
133
    }
134
    return 'border rounded-lg absolute w-8 h-8'
×
135
  }
136

137
  constructor(private cdRef: ChangeDetectorRef) {}
33!
138
  ngOnChanges(changes: SimpleChanges): void {
139
    const { value } = changes
5✔
140
    if (value) {
5✔
141
      const previousTextValue = this.displayWithFnInternal(value.previousValue)
5✔
142
      const currentTextValue = this.displayWithFnInternal(value.currentValue)
5✔
143
      if (previousTextValue !== currentTextValue) {
5✔
144
        if (currentTextValue) {
2!
145
          this.searchActive = true
2✔
146
          this.isSearchActive.emit(true)
2✔
147
        } else {
148
          this.searchActive = false
×
149
          this.isSearchActive.emit(false)
×
150
        }
151
        this.updateInputValue(value.currentValue)
2✔
152
      }
153
    }
154
  }
155

156
  ngOnInit(): void {
157
    const newValue$ = merge(
28✔
158
      of(''),
159
      this.inputCleared.pipe(map(() => '')),
24✔
160
      this.control.valueChanges.pipe(
161
        filter((value) => typeof value === 'string'),
88✔
162
        distinctUntilChanged(),
163
        debounceTime(400)
164
      )
165
    )
166

167
    const externalValueChange$ = this.control.valueChanges.pipe(
28✔
168
      filter((value) => typeof value === 'object' && value.title),
44✔
169
      map((item) => item.title)
5✔
170
    )
171

172
    // this observable emits arrays of suggestions loaded using the given action
173
    const suggestionsFromAction = merge(
28✔
174
      newValue$.pipe(
175
        filter((value: string) => value.length >= this.minCharacterCount)
96✔
176
      ),
177
      externalValueChange$
178
    ).pipe(
179
      tap(() => {
180
        this.searching = true
23✔
181
        this.error = null
23✔
182
      }),
183
      switchMap((value) => this.action(value)),
23✔
184
      tap((suggestions) => {
185
        // forcing the panel to open if there are suggestions
186
        if (suggestions.length > 0 && !this.isSearchActive) {
17!
UNCOV
187
          this.triggerRef?.openPanel()
×
188
        }
189
      }),
190
      catchError((error: Error) => {
191
        this.error = error.message
6✔
192
        return of([])
6✔
193
      }),
194
      finalize(() => (this.searching = false))
62✔
195
    )
196

197
    this.suggestions$ = merge(
28✔
198
      suggestionsFromAction,
199
      // if a new value is under the min char count, clear suggestions
200
      newValue$.pipe(
201
        filter((value: string) => value.length < this.minCharacterCount),
96✔
202
        map(() => [])
78✔
203
      )
204
    )
205

206
    // close the panel whenever suggestions are cleared
207
    this.subscription.add(
28✔
208
      this.suggestions$
209
        .pipe(filter((suggestions) => suggestions.length === 0))
42✔
210
        .subscribe(() => {
211
          this.triggerRef?.closePanel()
36✔
212
        })
213
    )
214

215
    this.subscription.add(
28✔
216
      this.control.valueChanges.subscribe((any) => {
217
        if (any !== '') {
18✔
218
          this.cancelEnter = false
17✔
219
        }
220
      })
221
    )
222

223
    this.control.valueChanges
28✔
224
      .pipe(filter((value) => typeof value === 'string'))
18✔
225
      .subscribe(this.lastInputValue$)
226
  }
227

228
  ngAfterViewInit(): void {
229
    this.autocomplete.optionSelected.subscribe(this.selectionSubject)
28✔
230
    if (this.autoFocus) {
28!
231
      this.inputRef.nativeElement.focus()
×
232
      this.cdRef.detectChanges()
×
233
    }
234

235
    this.startTrackingPosition()
28✔
236
  }
237

238
  /**
239
   * !!! This function is used only for web component mode,
240
   * the autocomplete dropdown may not update its position
241
   * if the page or container is disabling wind scroll.
242
   */
243
  private trackPosition = () => {
33✔
244
    const dropdownOpened = this.triggerRef && this.triggerRef.panelOpen
×
245
    const rect = this.inputRef.nativeElement.getBoundingClientRect()
×
246

247
    if (
×
248
      dropdownOpened &&
×
249
      (!this.lastPosition ||
250
        rect.top !== this.lastPosition.top ||
251
        rect.left !== this.lastPosition.left)
252
    ) {
253
      this.triggerRef.updatePosition()
×
254
    }
255

256
    this.lastPosition = rect
×
257
    requestAnimationFrame(this.trackPosition)
×
258
  }
259

260
  /**
261
   * !!! This function is used only for web component mode,
262
   * the autocomplete dropdown may not update its position
263
   * if the page or container is disabling wind scroll.
264
   */
265
  startTrackingPosition() {
266
    if (this.forceTrackPosition) {
30✔
267
      requestAnimationFrame(this.trackPosition)
1✔
268
    }
269
  }
270

271
  ngOnDestroy(): void {
272
    this.subscription?.unsubscribe()
32✔
273

274
    if (this.intervalIdPosition) {
32!
275
      clearInterval(this.intervalIdPosition)
×
276
    }
277
  }
278

279
  updateInputValue(value: AutocompleteItem) {
280
    if (value) {
3✔
281
      this.control.setValue(value)
3✔
282
    }
283
    if (this.inputRef) {
3✔
284
      this.inputRef.nativeElement.value = (value as any)?.title || ''
1!
285
    }
286
  }
287

288
  clear(): void {
289
    this.inputRef.nativeElement.value = ''
6✔
290
    this.searchActive = false
6✔
291
    this.isSearchActive.emit(false)
6✔
292
    this.inputCleared.emit()
6✔
293
    this.selectionSubject
6✔
294
      .pipe(take(1))
295
      .subscribe((selection) => selection && selection.option.deselect())
×
296
    this.inputRef.nativeElement.focus()
6✔
297
  }
298

299
  handleSearch(any?: string) {
300
    if (!this.cancelEnter && this.allowSubmit) {
4✔
301
      this.isSearchActive.emit(true)
2✔
302
      this.searchActive = true
2✔
303
      this.inputSubmitted.emit(any ?? this.inputRef.nativeElement.value)
2✔
304
    }
305
    this.triggerRef?.closePanel()
4✔
306
  }
307

308
  /**
309
   * This function is triggered when an item is selected in the list of displayed items.
310
   * If preventCompleteOnSelection is true then the input will be left as entered by the user.
311
   * If preventCompleteOnSelection is false (by default) then the input will be completed with the item selected by the user.
312
   * If clearOnSelection is true then the input will be cleared upon selection.
313
   * @param event
314
   */
315
  handleSelection(event: MatAutocompleteSelectedEvent) {
316
    this.cancelEnter = true
7✔
317
    this.itemSelected.emit(event.option.value)
7✔
318
    if (this.preventCompleteOnSelection) {
7✔
319
      this.lastInputValue$.pipe(first()).subscribe((lastInputValue) => {
2✔
320
        this.inputRef.nativeElement.value = lastInputValue
2✔
321
      })
322
      return
2✔
323
    }
324
    if (this.clearOnSelection) {
5✔
325
      this.inputRef.nativeElement.value = ''
1✔
326
      this.control.setValue('')
1✔
327
    }
328
  }
329

330
  handleInput(event: InputEvent) {
331
    this.searchActive = false
14✔
332
    this.isSearchActive.emit(false)
14✔
333
    this.enterBtnPosition = event.target['value'].length * 8 + 80
14✔
334
  }
335
}
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

© 2026 Coveralls, Inc