From 2a4507d529cc0beb7d0fa6729b99edd41eb1854b Mon Sep 17 00:00:00 2001 From: Mykyta Maliarchuk <84377976+nikita-web-ua@users.noreply.github.com> Date: Fri, 21 Jul 2023 09:27:31 +0200 Subject: [PATCH] [ACS-5266] Advanced Search - New component for Category facet (#8764) * [ACS-5266] new component for category facet * [ACS-5266] fixed tests & docs * [ACS-5266] some fixes * [ACS-5266] linting * [ACS-5266] some improvements * [ACS-5266] reduced observable from child component * [ACS-5266] fixed docs * [ACS-5266] rebase & improvements * [ACS-5266] typo --- ...earch-chip-autocomplete-input.component.md | 24 ++--- ...rch-filter-autocomplete-chips.component.md | 4 +- .../pipes/is-included.pipe.md | 2 +- lib/content-services/src/lib/i18n/en.json | 1 + .../src/lib/material.module.ts | 3 + .../src/lib/pipes/is-included.pipe.spec.ts | 7 ++ ...rch-chip-autocomplete-input.component.html | 24 +++-- ...rch-chip-autocomplete-input.component.scss | 4 + ...-chip-autocomplete-input.component.spec.ts | 65 +++++++++---- ...earch-chip-autocomplete-input.component.ts | 95 ++++++++++++------- ...h-filter-autocomplete-chips.component.html | 4 +- ...ilter-autocomplete-chips.component.spec.ts | 36 ++++--- ...rch-filter-autocomplete-chips.component.ts | 76 +++++++++++---- .../search-properties.component.html | 2 +- .../search-properties.component.spec.ts | 31 +++--- .../search-properties.component.ts | 25 +++-- .../models/autocomplete-option.interface.ts | 27 ++++++ .../search-widget-settings.interface.ts | 4 + 18 files changed, 308 insertions(+), 126 deletions(-) create mode 100644 lib/content-services/src/lib/search/models/autocomplete-option.interface.ts diff --git a/docs/content-services/components/search-chip-autocomplete-input.component.md b/docs/content-services/components/search-chip-autocomplete-input.component.md index e66560273e8..7c08e82acaf 100644 --- a/docs/content-services/components/search-chip-autocomplete-input.component.md +++ b/docs/content-services/components/search-chip-autocomplete-input.component.md @@ -15,30 +15,32 @@ Represents an input with autocomplete options. ```html ``` ### Properties -| Name | Type | Default value | Description | -|---------------------------|--------------------------|----|-----------------------------------------------------------------------------------------------| -| autocompleteOptions | `string[]` | [] | Options for autocomplete | -| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`` | | Observable that will listen to any reset event causing component to clear the chips and input | -| allowOnlyPredefinedValues | boolean | true | A flag that indicates whether it is possible to add a value not from the predefined ones | -| placeholder | string | 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | Placeholder which should be displayed in input. | -| compareOption | (option1: string, option2: string) => boolean | | Function which is used to selected options with all options so it allows to detect which options are already selected. | -| formatChipValue | (option: string) => string | | Function which is used to format custom typed options. | -| filter | (options: string[], value: string) => string[] | | Function which is used to filter out possibile options from hint. By default it checks if option includes typed value and is case insensitive. | +| Name | Type | Default value | Description | +|---------------------------|--------------------------|----|-----------------------------------------------------------------------------------------------------------------------------------------------| +| autocompleteOptions | `AutocompleteOption[]` | [] | Options for autocomplete | +| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`` | | Observable that will listen to any reset event causing component to clear the chips and input | +| allowOnlyPredefinedValues | boolean | true | A flag that indicates whether it is possible to add a value not from the predefined ones | +| placeholder | string | 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | Placeholder which should be displayed in input. | +| compareOption | (option1: AutocompleteOption, option2: AutocompleteOption) => boolean | | Function which is used to selected options with all options so it allows to detect which options are already selected. | +| formatChipValue | (option: string) => string | | Function which is used to format custom typed options. | +| filter | (options: AutocompleteOption[], value: string) => AutocompleteOption[] | | Function which is used to filter out possible options from hint. By default it checks if option includes typed value and is case insensitive. | ### Events | Name | Type | Description | | ---- | ---- |-----------------------------------------------| -| optionsChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the selected options are changed | +| inputChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the input changes | +| optionsChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the selected options are changed | ## See also diff --git a/docs/content-services/components/search-filter-autocomplete-chips.component.md b/docs/content-services/components/search-filter-autocomplete-chips.component.md index 9f6351b8ac8..ea41f1ade39 100644 --- a/docs/content-services/components/search-filter-autocomplete-chips.component.md +++ b/docs/content-services/components/search-filter-autocomplete-chips.component.md @@ -28,7 +28,7 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models "hideDefaultAction": true, "allowOnlyPredefinedValues": false, "field": "SITE", - "options": [ "Option 1", "Option 2" ] + "autocompleteOptions": [ {"value": "Option 1"}, {"value": "Option 2"} ] } } } @@ -42,7 +42,7 @@ Implements a [search widget](../../../lib/content-services/src/lib/search/models | Name | Type | Description | | ---- |----------|--------------------------------------------------------------------------------------------------------------------| | field | `string` | Field to apply the query to. Required value | -| options | `string[]` | Predefined options for autocomplete | +| autocompleteOptions | `AutocompleteOption[]` | Predefined options for autocomplete | | allowOnlyPredefinedValues | `boolean` | Specifies whether the input values should only be from predefined | | allowUpdateOnChange | `boolean` | Enable/Disable the update fire event when text has been changed. By default is true | | hideDefaultAction | `boolean` | Show/hide the widget actions. By default is false | diff --git a/docs/content-services/pipes/is-included.pipe.md b/docs/content-services/pipes/is-included.pipe.md index 405ab61c9c9..8e673de431f 100644 --- a/docs/content-services/pipes/is-included.pipe.md +++ b/docs/content-services/pipes/is-included.pipe.md @@ -14,7 +14,7 @@ Checks if the provided value is contained in the provided array. ```HTML - + ``` diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index 30eb3e166d8..c6534fb37c1 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -284,6 +284,7 @@ "SUMMARY": "{{numResults}} result found for {{searchTerm}}", "NONE": "No results found for {{searchTerm}}", "ERROR": "We hit a problem during the search - try again.", + "WILL_CONTAIN": "Results will contain '{{searchTerm}}'", "COLUMNS": { "NAME": "Display name", "MODIFIED_BY": "Modified by", diff --git a/lib/content-services/src/lib/material.module.ts b/lib/content-services/src/lib/material.module.ts index 30c54bf8e08..e7145cef5aa 100644 --- a/lib/content-services/src/lib/material.module.ts +++ b/lib/content-services/src/lib/material.module.ts @@ -19,6 +19,7 @@ import { NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatChipsModule } from '@angular/material/chips'; import { MatOptionModule, MatRippleModule } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; @@ -40,6 +41,7 @@ import { MatBadgeModule } from '@angular/material/badge'; @NgModule({ imports: [ MatButtonModule, + MatAutocompleteModule, MatChipsModule, MatDialogModule, MatIconModule, @@ -63,6 +65,7 @@ import { MatBadgeModule } from '@angular/material/badge'; ], exports: [ MatButtonModule, + MatAutocompleteModule, MatChipsModule, MatDialogModule, MatIconModule, diff --git a/lib/content-services/src/lib/pipes/is-included.pipe.spec.ts b/lib/content-services/src/lib/pipes/is-included.pipe.spec.ts index 6a616571466..73cd2ca87f6 100644 --- a/lib/content-services/src/lib/pipes/is-included.pipe.spec.ts +++ b/lib/content-services/src/lib/pipes/is-included.pipe.spec.ts @@ -41,4 +41,11 @@ describe('IsIncludedPipe', () => { it('should return false if the number is not contained in an array', () => { expect(pipe.transform(50, array)).toBeFalsy(); }); + + it('should use provided comparator to check if value contains in the provided array', () => { + const arrayOfObjects = [{id: 'id-1', value: 'value-1'}, {id: 'id-2', value: 'value-2'}]; + const filterFunction = (extension1, extension2) => extension1.value === extension2.value; + expect(pipe.transform({id: 'id-1', value: 'value-1'}, arrayOfObjects, filterFunction)).toBeTruthy(); + expect(pipe.transform({id: 'id-1', value: 'value-3'}, arrayOfObjects, filterFunction)).toBeFalsy(); + }); }); diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.html b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.html index d0de2f038de..06270f040ec 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.html +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.html @@ -4,8 +4,14 @@ class="adf-option-chips" *ngFor="let option of selectedOptions" (removed)="remove(option)"> - {{option}} - @@ -24,9 +30,15 @@ - - {{option}} - + + + {{ option.fullPath || option.value }} + + diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.scss b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.scss index 73b647a9904..ced2a9d4a16 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.scss +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.scss @@ -40,6 +40,10 @@ adf-search-chip-autocomplete-input { } } +.mat-tooltip-hide { + display: none; +} + .mat-option.adf-autocomplete-added-option { background: var(--adf-theme-mat-grey-color-a200); color: var(--adf-theme-primary-300); diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts index 5b50e70d0e1..ca7da5adf22 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts @@ -41,8 +41,8 @@ describe('SearchChipAutocompleteInputComponent', () => { fixture = TestBed.createComponent(SearchChipAutocompleteInputComponent); component = fixture.componentInstance; component.onReset$ = onResetSubject.asObservable(); + component.autocompleteOptions = [{value: 'option1'}, {value: 'option2'}]; fixture.detectChanges(); - component.autocompleteOptions = ['option1', 'option2']; }); function getInput(): HTMLInputElement { @@ -110,6 +110,7 @@ describe('SearchChipAutocompleteInputComponent', () => { const optionsChangedSpy = spyOn(component.optionsChanged, 'emit'); enterNewInputValue('op'); await fixture.whenStable(); + fixture.detectChanges(); const matOptions = getOptionElements(); expect(matOptions.length).toBe(2); @@ -117,8 +118,8 @@ describe('SearchChipAutocompleteInputComponent', () => { const optionToClick = matOptions[0].nativeElement as HTMLElement; optionToClick.click(); - expect(optionsChangedSpy).toHaveBeenCalledOnceWith(['option1']); - expect(component.selectedOptions).toEqual(['option1']); + expect(optionsChangedSpy).toHaveBeenCalledOnceWith([{value: 'option1'}]); + expect(component.selectedOptions).toEqual([{value: 'option1'}]); expect(getChipList().length).toBe(1); }); @@ -126,31 +127,35 @@ describe('SearchChipAutocompleteInputComponent', () => { addNewOption('option1'); enterNewInputValue('op'); - const addedOptions = getAddedOptionElements(); - await fixture.whenStable(); + fixture.detectChanges(); + const addedOptions = getAddedOptionElements(); expect(addedOptions[0]).toBeTruthy(); expect(addedOptions.length).toBe(1); }); it('should apply class to already selected options based on custom compareOption function', async () => { component.allowOnlyPredefinedValues = false; - component.autocompleteOptions = ['.test1', 'test3', '.test2', 'test1.']; - component.compareOption = (option1, option2) => option1.split('.')[1] === option2; + component.autocompleteOptions = [{value: '.test1'}, {value: 'test3'}, {value: '.test2.'}, {value: 'test1'}]; + component.compareOption = (option1, option2) => option1.value.split('.')[1] === option2.value; - fixture.detectChanges(); addNewOption('test1'); enterNewInputValue('t'); - const addedOptions = getAddedOptionElements(); await fixture.whenStable(); - expect(addedOptions.length).toBe(1); + fixture.detectChanges(); + + expect(getAddedOptionElements().length).toBe(1); }); - it('should limit autocomplete list to 15 values max', () => { - component.autocompleteOptions = ['a1','a2','a3','a4','a5','a6','a7','a8','a9','a10','a11','a12','a13','a14','a15','a16']; + it('should limit autocomplete list to 15 values max', async () => { + component.autocompleteOptions = Array.from({length: 16}, (_, i) => ({value: `a${i}`})); enterNewInputValue('a'); + + await fixture.whenStable(); + fixture.detectChanges(); + expect(getOptionElements().length).toBe(15); }); @@ -160,27 +165,33 @@ describe('SearchChipAutocompleteInputComponent', () => { expect(getChipList().length).toBe(1); }); - it('should show autocomplete list if similar predefined values exists', () => { + it('should show autocomplete list if similar predefined values exists', async () => { enterNewInputValue('op'); + await fixture.whenStable(); + fixture.detectChanges(); expect(getOptionElements().length).toBe(2); }); - it('should show autocomplete list based on custom filtering', () => { - component.autocompleteOptions = ['.test1', 'test1', 'test1.', '.test2', '.test12']; - component.filter = (options, value) => options.filter((option) => option.split('.')[1] === value); + it('should show autocomplete list based on custom filtering', async () => { + component.autocompleteOptions = [{value: '.test1'}, {value: 'test1'}, {value: 'test1.'}, {value: '.test2'}, {value: '.test12'}]; + component.filter = (options, value) => options.filter((option) => option.value.split('.')[1] === value); enterNewInputValue('test1'); + await fixture.whenStable(); + fixture.detectChanges(); expect(getOptionElements().length).toBe(1); }); - it('should not show autocomplete list if there are no similar predefined values', () => { + it('should not show autocomplete list if there are no similar predefined values', async () => { enterNewInputValue('test'); + await fixture.whenStable(); + fixture.detectChanges(); expect(getOptionElements().length).toBe(0); }); it('should emit new value when selected options changed', () => { const optionsChangedSpy = spyOn(component.optionsChanged, 'emit'); addNewOption('option1'); - expect(optionsChangedSpy).toHaveBeenCalledOnceWith(['option1']); + expect(optionsChangedSpy).toHaveBeenCalledOnceWith([{value: 'option1'}]); expect(getChipList().length).toBe(1); expect(getChipValue(0)).toBe('option1'); }); @@ -221,7 +232,23 @@ describe('SearchChipAutocompleteInputComponent', () => { fixture.debugElement.query(By.directive(MatChipRemove)).nativeElement.click(); fixture.detectChanges(); - expect(optionsChangedSpy).toHaveBeenCalledOnceWith(['option2']); + expect(optionsChangedSpy).toHaveBeenCalledOnceWith([{value: 'option2'}]); expect(getChipList().length).toEqual(1); }); + + it('should show full category path when fullPath provided', () => { + component.filteredOptions = [{id: 'test-id', value: 'test-value', fullPath: 'test-full-path'}]; + enterNewInputValue('test-value'); + const matOption = fixture.debugElement.query(By.css('.mat-option span')).nativeElement; + fixture.detectChanges(); + + expect(matOption.innerHTML).toEqual(' test-full-path '); + }); + + it('should emit input value when input changed', async () => { + const inputChangedSpy = spyOn(component.inputChanged, 'emit'); + enterNewInputValue('test-value'); + await fixture.whenStable(); + expect(inputChangedSpy).toHaveBeenCalledOnceWith('test-value'); + }); }); diff --git a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts index ac69518f695..45666f40773 100644 --- a/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts @@ -15,13 +15,26 @@ * limitations under the License. */ -import { Component, ViewEncapsulation, ElementRef, ViewChild, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angular/core'; +import { + Component, + ViewEncapsulation, + ElementRef, + ViewChild, + OnInit, + OnDestroy, + Input, + Output, + EventEmitter, + SimpleChanges, + OnChanges +} from '@angular/core'; import { ENTER } from '@angular/cdk/keycodes'; import { FormControl } from '@angular/forms'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatChipInputEvent } from '@angular/material/chips'; -import { Observable, Subject } from 'rxjs'; -import { map, startWith, takeUntil, tap } from 'rxjs/operators'; +import { EMPTY, Observable, Subject, timer } from 'rxjs'; +import { debounce, startWith, takeUntil, tap } from 'rxjs/operators'; +import { AutocompleteOption } from '../../models/autocomplete-option.interface'; @Component({ selector: 'adf-search-chip-autocomplete-input', @@ -29,12 +42,12 @@ import { map, startWith, takeUntil, tap } from 'rxjs/operators'; styleUrls: ['./search-chip-autocomplete-input.component.scss'], encapsulation: ViewEncapsulation.None }) -export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy { +export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy, OnChanges { @ViewChild('optionInput') optionInput: ElementRef; @Input() - autocompleteOptions: string[] = []; + autocompleteOptions: AutocompleteOption[] = []; @Input() onReset$: Observable; @@ -46,24 +59,28 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy { placeholder = 'SEARCH.FILTER.ACTIONS.ADD_OPTION'; @Input() - compareOption?: (option1: string, option2: string) => boolean; + compareOption?: (option1: AutocompleteOption, option2: AutocompleteOption) => boolean; @Input() formatChipValue?: (option: string) => string; @Input() - filter = (options: string[], value: string): string[] => { + filter = (options: AutocompleteOption[], value: string): AutocompleteOption[] => { const filterValue = value.toLowerCase(); - return options.filter(option => option.toLowerCase().includes(filterValue)); + return options.filter(option => option.value.toLowerCase().includes(filterValue)).slice(0, 15); }; @Output() - optionsChanged: EventEmitter = new EventEmitter(); + optionsChanged = new EventEmitter(); + + @Output() + inputChanged = new EventEmitter(); readonly separatorKeysCodes = [ENTER] as const; formCtrl = new FormControl(''); - filteredOptions$: Observable; - selectedOptions: string[] = []; + filteredOptions: AutocompleteOption[] = []; + selectedOptions: AutocompleteOption[] = []; + tooltipShowDelay = 800; private onDestroy$ = new Subject(); private _activeAnyOption = false; @@ -71,18 +88,27 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy { this._activeAnyOption = active; } - constructor() { - this.filteredOptions$ = this.formCtrl.valueChanges.pipe( - startWith(null), - tap(() => this.activeAnyOption = false), - map((value: string | null) => (value ? this.filter(this.autocompleteOptions, value).slice(0, 15) : [])) - ); - } - ngOnInit() { + this.formCtrl.valueChanges + .pipe( + startWith(''), + tap(() => this.activeAnyOption = false), + debounce((value: string) => (value ? timer(300) : EMPTY)), + takeUntil(this.onDestroy$) + ) + .subscribe((value: string) => { + this.filteredOptions = value ? this.filter(this.autocompleteOptions, value) : []; + this.inputChanged.emit(value); + }); this.onReset$?.pipe(takeUntil(this.onDestroy$)).subscribe(() => this.reset()); } + ngOnChanges(changes: SimpleChanges) { + if (changes.autocompleteOptions) { + this.filteredOptions = changes.autocompleteOptions.currentValue.length > 0 ? this.filter(changes.autocompleteOptions.currentValue, this.formCtrl.value) : []; + } + } + ngOnDestroy() { this.onDestroy$.next(); this.onDestroy$.complete(); @@ -96,15 +122,20 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy { } if (value && this.isExists(value) && !this.isAdded(value)) { - this.selectedOptions.push(value); + if (this.allowOnlyPredefinedValues) { + const index = this.autocompleteOptions.findIndex(option => option.value.toLowerCase() === value.toLowerCase()); + this.selectedOptions.push(this.autocompleteOptions[index]); + } else { + this.selectedOptions.push({value}); + } this.optionsChanged.emit(this.selectedOptions); event.chipInput.clear(); - this.formCtrl.setValue(null); + this.formCtrl.setValue(''); } } } - remove(value: string) { + remove(value: AutocompleteOption) { const index = this.selectedOptions.indexOf(value); if (index >= 0) { @@ -114,28 +145,28 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy { } selected(event: MatAutocompleteSelectedEvent) { - if (!this.isAdded(event.option.viewValue)) { - this.selectedOptions.push(event.option.viewValue); - this.optionInput.nativeElement.value = ''; - this.formCtrl.setValue(null); - this.optionsChanged.emit(this.selectedOptions); - } + this.selectedOptions.push(event.option.value); + this.optionInput.nativeElement.value = ''; + this.formCtrl.setValue(''); + this.optionsChanged.emit(this.selectedOptions); } - private isAdded(value: string): boolean { - return this.selectedOptions.includes(value); + private isAdded(value: string): boolean { + const valueLowerCase = value.toLowerCase(); + return this.selectedOptions.some(option => option.value.toLowerCase() === valueLowerCase); } private isExists(value: string): boolean { + const valueLowerCase = value.toLowerCase(); return this.allowOnlyPredefinedValues - ? this.autocompleteOptions.map(option => option.toLowerCase()).includes(value.toLowerCase()) + ? this.autocompleteOptions.some(option => option.value.toLowerCase() === valueLowerCase) : true; } private reset() { this.selectedOptions = []; this.optionsChanged.emit(this.selectedOptions); - this.formCtrl.setValue(null); + this.formCtrl.setValue(''); this.optionInput.nativeElement.value = ''; } } diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html index 7e95495bd0e..411d33208e9 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html @@ -1,7 +1,9 @@ diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts index 8d3d17b342e..3e52d91f67c 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts @@ -22,6 +22,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { SearchFilterAutocompleteChipsComponent } from './search-filter-autocomplete-chips.component'; import { TagService } from '@alfresco/adf-content-services'; import { EMPTY, of } from 'rxjs'; +import { AutocompleteField } from '../../models/autocomplete-option.interface'; describe('SearchFilterAutocompleteChipsComponent', () => { let component: SearchFilterAutocompleteChipsComponent; @@ -51,7 +52,7 @@ describe('SearchFilterAutocompleteChipsComponent', () => { } as any; component.settings = { field: 'test', allowUpdateOnChange: true, hideDefaultAction: false, allowOnlyPredefinedValues: false, - options: ['option1', 'option2'] + autocompleteOptions: [{value: 'option1'}, {value: 'option2'}] }; fixture.detectChanges(); }); @@ -63,13 +64,16 @@ describe('SearchFilterAutocompleteChipsComponent', () => { fixture.detectChanges(); } - it('should set autocomplete options on init', () => { - component.settings.options = ['test 1', 'test 2']; + it('should set autocomplete options on init', (done) => { + component.settings.autocompleteOptions = [{value: 'test 1'}, {value: 'test 2'}]; component.ngOnInit(); - expect(component.autocompleteOptions).toEqual(['test 1', 'test 2']); + component.autocompleteOptions$.subscribe(result => { + expect(result).toEqual([{value: 'test 1'}, {value: 'test 2'}]); + done(); + }); }); - it('should load tags if field = TAG', () => { + it('should load tags if field = TAG', (done) => { const tagPagingMock = { list: { pagination: {}, @@ -77,10 +81,13 @@ describe('SearchFilterAutocompleteChipsComponent', () => { } }; - component.settings.field = 'TAG'; + component.settings.field = AutocompleteField.TAG; spyOn(tagService, 'getAllTheTags').and.returnValue(of(tagPagingMock)); component.ngOnInit(); - expect(component.autocompleteOptions).toEqual(['tag1', 'tag2']); + component.autocompleteOptions$.subscribe(result => { + expect(result).toEqual([{value: 'tag1'},{value: 'tag2'}]); + done(); + }); }); it('should update display value when options changes', () => { @@ -94,9 +101,9 @@ describe('SearchFilterAutocompleteChipsComponent', () => { }); it('should reset value and display value when reset button is clicked', () => { - component.setValue(['option1', 'option2']); + component.setValue([{value: 'option1'}, {value: 'option2'}]); fixture.detectChanges(); - expect(component.selectedOptions).toEqual(['option1', 'option2']); + expect(component.selectedOptions).toEqual([{value: 'option1'}, {value: 'option2'}]); spyOn(component.context, 'update'); spyOn(component.displayValue$, 'next'); const clearBtn: HTMLButtonElement = fixture.debugElement.query(By.css('[data-automation-id="adf-search-chip-autocomplete-btn-clear"]')).nativeElement; @@ -110,13 +117,18 @@ describe('SearchFilterAutocompleteChipsComponent', () => { it('should correctly compose the search query', () => { spyOn(component.context, 'update'); - addNewOption('option2'); - addNewOption('option1'); + component.selectedOptions = [{value: 'option2'}, {value: 'option1'}]; const applyBtn: HTMLButtonElement = fixture.debugElement.query(By.css('[data-automation-id="adf-search-chip-autocomplete-btn-apply"]')).nativeElement; applyBtn.click(); fixture.detectChanges(); expect(component.context.update).toHaveBeenCalled(); - expect(component.context.queryFragments[component.id]).toBe('test: "option2" OR test: "option1"'); + expect(component.context.queryFragments[component.id]).toBe('test:"option2" OR test:"option1"'); + + component.settings.field = AutocompleteField.CATEGORIES; + component.selectedOptions = [{id: 'test-id', value: 'test'}]; + applyBtn.click(); + fixture.detectChanges(); + expect(component.context.queryFragments[component.id]).toBe('cm:categories:"workspace://SpacesStore/test-id"'); }); }); diff --git a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts index 09eb4994746..a13747f2540 100644 --- a/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts @@ -16,12 +16,14 @@ */ import { Component, ViewEncapsulation, OnInit } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { SearchWidget } from '../../models/search-widget.interface'; import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; import { SearchFilterList } from '../../models/search-filter-list.model'; import { TagService } from '../../../tag/services/tag.service'; +import { CategoryService } from '../../../category/services/category.service'; +import { AutocompleteField, AutocompleteOption } from '../../models/autocomplete-option.interface'; @Component({ selector: 'adf-search-filter-autocomplete-chips', @@ -32,18 +34,19 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; - options: SearchFilterList; - startValue: string[] = null; + options: SearchFilterList; + startValue: AutocompleteOption[] = []; displayValue$ = new Subject(); + selectedOptions: AutocompleteOption[] = []; + enableChangeUpdate: boolean; private resetSubject$ = new Subject(); reset$: Observable = this.resetSubject$.asObservable(); - autocompleteOptions: string[] = []; - selectedOptions: string[] = []; - enableChangeUpdate: boolean; + private autocompleteOptionsSubject$ = new BehaviorSubject([]); + autocompleteOptions$: Observable = this.autocompleteOptionsSubject$.asObservable(); - constructor( private tagService: TagService ) { - this.options = new SearchFilterList(); + constructor(private tagService: TagService, private categoryService: CategoryService) { + this.options = new SearchFilterList(); } ngOnInit() { @@ -70,11 +73,11 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI return !!this.selectedOptions; } - getCurrentValue(): string[]{ + getCurrentValue(): AutocompleteOption[] { return this.selectedOptions; } - onOptionsChange(selectedOptions: string[]) { + onOptionsChange(selectedOptions: AutocompleteOption[]) { this.selectedOptions = selectedOptions; if (this.enableChangeUpdate) { this.updateQuery(); @@ -82,27 +85,62 @@ export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnI } } - setValue(value: string[]) { + setValue(value: AutocompleteOption[]) { this.selectedOptions = value; this.displayValue$.next(this.selectedOptions.join(', ')); this.submitValues(); } + onInputChange(value: string) { + if (this.settings.field === AutocompleteField.CATEGORIES && value) { + this.searchForExistingCategories(value); + } + } + + optionComparator(option1: AutocompleteOption, option2: AutocompleteOption): boolean { + return option1.id + ? option1.id.toUpperCase() === option2.id.toUpperCase() + : option1.value.toUpperCase() === option2.value.toUpperCase(); + } + private updateQuery() { - this.displayValue$.next(this.selectedOptions.join(', ')); + this.displayValue$.next(this.selectedOptions.map(option => option.value).join(', ')); if (this.context && this.settings && this.settings.field) { - this.context.queryFragments[this.id] = this.selectedOptions.map(val => `${this.settings.field}: "${val}"`).join(' OR '); + let queryFragments; + if (this.settings.field === AutocompleteField.CATEGORIES) { + queryFragments = this.selectedOptions.map(val => `${this.settings.field}:"workspace://SpacesStore/${val.id}"`); + } else { + queryFragments = this.selectedOptions.map(val => `${this.settings.field}:"${val.value}"`); + } + this.context.queryFragments[this.id] = queryFragments.join(' OR '); this.context.update(); } } private setOptions() { - if (this.settings.field === 'TAG') { - this.tagService.getAllTheTags().subscribe(res => { - this.autocompleteOptions = res.list.entries.map(tag => tag.entry.tag); - }); - } else { - this.autocompleteOptions = this.settings.options; + switch (this.settings.field) { + case AutocompleteField.TAG: + this.tagService.getAllTheTags().subscribe(tagPaging => { + this.autocompleteOptionsSubject$.next(tagPaging.list.entries.map(tag => ({ + value: tag.entry.tag + }))); + }); + break; + case AutocompleteField.CATEGORIES: + this.autocompleteOptionsSubject$.next([]); + break; + default: + this.autocompleteOptionsSubject$.next(this.settings.autocompleteOptions); } } + + private searchForExistingCategories(searchTerm: string) { + this.categoryService.searchCategories(searchTerm, 0, 15).subscribe((existingCategoriesResult) => { + this.autocompleteOptionsSubject$.next(existingCategoriesResult.list.entries.map((rowEntry) => { + const path = rowEntry.entry.path.name.split('/').splice(3).join('/'); + const fullPath = path ? `${path}/${rowEntry.entry.name}` : rowEntry.entry.name; + return {id: rowEntry.entry.id, value: rowEntry.entry.name, fullPath}; + })); + }); + } } diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html index 148fbdf48f8..d8a60486a07 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.html @@ -42,7 +42,7 @@

{{ 'SEARCH.SEARCH_PROPERTIES.FILE_TYPE' | translate }}

{ field: 'field', fileExtensions: ['pdf', 'doc', 'txt'] }; + component.ngOnInit(); fixture.detectChanges(); - expect(searchChipAutocompleteInputComponent.autocompleteOptions).toBe(component.settings.fileExtensions); + expect(searchChipAutocompleteInputComponent.autocompleteOptions).toEqual([{value: 'pdf'}, {value: 'doc'}, {value: 'txt'}]); }); it('should set onReset$ for SearchChipAutocompleteInputComponent to correct value', () => { @@ -154,10 +155,10 @@ describe('SearchPropertiesComponent', () => { }); it('should compare file extensions case insensitive after calling compareOption on SearchChipAutocompleteInputComponent', () => { - const option1 = 'pdf'; - const option2 = 'PdF'; + const option1 = {value: 'pdf'}; + const option2 = {value: 'PdF'}; expect(searchChipAutocompleteInputComponent.compareOption(option1, option2)).toBeTrue(); - expect(searchChipAutocompleteInputComponent.compareOption(option1, `${option2}1`)).toBeFalse(); + expect(searchChipAutocompleteInputComponent.compareOption(option1, {value: `${option2.value}1`})).toBeFalse(); }); it('should remove preceding dot after calling formatChipValue on SearchChipAutocompleteInputComponent', () => { @@ -167,11 +168,11 @@ describe('SearchPropertiesComponent', () => { }); it('should filter file extensions case insensitive without dots after calling filter on SearchChipAutocompleteInputComponent', () => { - const extensions = ['pdf', 'jpg', 'txt', 'png']; + const extensions = [{value: 'pdf'}, {value: 'jpg'}, {value: 'txt'}, {value: 'png'}]; const searchValue = 'p'; - expect(searchChipAutocompleteInputComponent.filter(extensions, searchValue)).toEqual(['pdf', 'jpg', 'png']); - expect(searchChipAutocompleteInputComponent.filter(extensions, `.${searchValue}`)).toEqual(['pdf', 'png']); + expect(searchChipAutocompleteInputComponent.filter(extensions, searchValue)).toEqual([{value:'pdf'}, {value:'jpg'}, {value:'png'}]); + expect(searchChipAutocompleteInputComponent.filter(extensions, `.${searchValue}`)).toEqual([{value:'pdf'}, {value:'png'}]); }); it('should set placeholder for SearchChipAutocompleteInputComponent to correct value', () => { @@ -259,17 +260,17 @@ describe('SearchPropertiesComponent', () => { }); it('should search by single file type', () => { - const extension = 'pdf'; + const extension = {value: 'pdf'}; getSearchChipAutocompleteInputComponent().optionsChanged.emit([extension]); component.submitValues(); - expect(component.displayValue$.next).toHaveBeenCalledWith(extension); - expect(component.context.queryFragments[component.id]).toBe(`${nameField}:("*.${extension}")`); + expect(component.displayValue$.next).toHaveBeenCalledWith('pdf'); + expect(component.context.queryFragments[component.id]).toBe(`${nameField}:("*.${extension.value}")`); expect(component.context.update).toHaveBeenCalled(); }); it('should search by multiple file types', () => { - getSearchChipAutocompleteInputComponent().optionsChanged.emit(['pdf', 'txt']); + getSearchChipAutocompleteInputComponent().optionsChanged.emit([{value:'pdf'}, {value:'txt'}]); component.submitValues(); expect(component.displayValue$.next).toHaveBeenCalledWith('pdf, txt'); @@ -279,7 +280,7 @@ describe('SearchPropertiesComponent', () => { it('should search by file size and type', () => { typeInFileSizeInput(); - getSearchChipAutocompleteInputComponent().optionsChanged.emit(['pdf', 'txt']); + getSearchChipAutocompleteInputComponent().optionsChanged.emit([{value:'pdf'}, {value:'txt'}]); component.submitValues(); expect(component.displayValue$.next).toHaveBeenCalledWith('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB, pdf, txt'); @@ -315,7 +316,7 @@ describe('SearchPropertiesComponent', () => { clickFileSizeUnitsSelect(); getSelectOptions()[1].nativeElement.click(); fixture.detectChanges(); - const extensions = ['pdf', 'txt']; + const extensions = [{value: 'pdf'}, {value: 'txt'}]; getSearchChipAutocompleteInputComponent().optionsChanged.emit(extensions); expect(component.getCurrentValue()).toEqual({ @@ -324,7 +325,7 @@ describe('SearchPropertiesComponent', () => { fileSizeUnit: FileSizeUnit.MB, fileSizeOperator: FileSizeOperator.AT_MOST }, - fileExtensions: extensions + fileExtensions: ['pdf', 'txt'] }); }); }); @@ -342,7 +343,7 @@ describe('SearchPropertiesComponent', () => { getSelectOptions()[1].nativeElement.click(); fixture.detectChanges(); searchChipAutocompleteInputComponent = getSearchChipAutocompleteInputComponent(); - searchChipAutocompleteInputComponent.optionsChanged.emit(['pdf', 'txt']); + searchChipAutocompleteInputComponent.optionsChanged.emit([{value: 'pdf'}, {value: 'txt'}]); }); it('should reset form', () => { diff --git a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts index 16770e53419..34e44b3d58c 100644 --- a/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts +++ b/lib/content-services/src/lib/search/components/search-properties/search-properties.component.ts @@ -26,6 +26,7 @@ import { SearchQueryBuilderService } from '../../services/search-query-builder.s import { SearchProperties } from './search-properties'; import { TranslateService } from '@ngx-translate/core'; import { SearchWidget } from '../../models/search-widget.interface'; +import { AutocompleteOption } from '../../models/autocomplete-option.interface'; @Component({ selector: 'adf-search-properties', @@ -39,6 +40,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear context?: SearchQueryBuilderService; startValue: SearchProperties; displayValue$ = new Subject(); + autocompleteOptions: AutocompleteOption[] = []; private _form = this.formBuilder.nonNullable.group({ fileSizeOperator: FileSizeOperator.AT_LEAST, @@ -77,8 +79,8 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear return this._reset$; } - set selectedExtensions(extensions: string[]) { - this._selectedExtensions = extensions; + set selectedExtensions(extensions: AutocompleteOption[]) { + this._selectedExtensions = this.parseFromAutocompleteOptions(extensions); } constructor(private formBuilder: FormBuilder, private translateService: TranslateService) {} @@ -88,6 +90,7 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear if (!this.settings.fileExtensions) { this.settings.fileExtensions = []; } + this.autocompleteOptions = this.parseToAutocompleteOptions(this.settings.fileExtensions); [this.sizeField, this.nameField] = this.settings.field.split(','); } if (this.startValue) { @@ -127,8 +130,8 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear return event.key !== '-' && event.key !== 'e' && event.key !== '+'; } - compareFileExtensions(extension1: string, extension2: string): boolean { - return extension1.toUpperCase() === extension2.toUpperCase(); + compareFileExtensions(extension1: AutocompleteOption, extension2: AutocompleteOption): boolean { + return extension1.value.toUpperCase() === extension2.value.toUpperCase(); } getExtensionWithoutDot(extension: string): string { @@ -136,11 +139,11 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear return extensionSplitByDot[extensionSplitByDot.length - 1]; } - filterExtensions = (extensions: string[], filterValue: string): string[] => { + filterExtensions = (extensions: AutocompleteOption[], filterValue: string): AutocompleteOption[] => { const filterValueLowerCase = this.getExtensionWithoutDot(filterValue).toLowerCase(); const extensionWithDot = filterValue.startsWith('.'); return extensions.filter((option) => { - const optionLowerCase = option.toLowerCase(); + const optionLowerCase = option.value.toLowerCase(); return extensionWithDot && filterValueLowerCase ? optionLowerCase.startsWith(filterValueLowerCase) : optionLowerCase.includes(filterValue); }); }; @@ -196,10 +199,18 @@ export class SearchPropertiesComponent implements OnInit, AfterViewChecked, Sear setValue(searchProperties: SearchProperties) { this.form.patchValue(searchProperties.fileSizeCondition); - this.selectedExtensions = searchProperties.fileExtensions; + this.selectedExtensions = this.parseToAutocompleteOptions(searchProperties.fileExtensions); this.submitValues(); } + private parseToAutocompleteOptions(array: string[]): AutocompleteOption[] { + return array.map(value => ({value})); + } + + private parseFromAutocompleteOptions(array: AutocompleteOption[]): string[] { + return array.flatMap(option => option.value); + } + private getOperatorNameWidth(operator: string, font: string): number { const context = this.canvas.getContext('2d'); context.font = font; diff --git a/lib/content-services/src/lib/search/models/autocomplete-option.interface.ts b/lib/content-services/src/lib/search/models/autocomplete-option.interface.ts new file mode 100644 index 00000000000..a4fc71b5d8e --- /dev/null +++ b/lib/content-services/src/lib/search/models/autocomplete-option.interface.ts @@ -0,0 +1,27 @@ +/*! + * @license + * Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface AutocompleteOption { + value: string; + id?: string; + fullPath?: string; +} + +export enum AutocompleteField { + TAG = 'TAG', + CATEGORIES = 'cm:categories' +} diff --git a/lib/content-services/src/lib/search/models/search-widget-settings.interface.ts b/lib/content-services/src/lib/search/models/search-widget-settings.interface.ts index 4885fd2bbf7..2d80d61e172 100644 --- a/lib/content-services/src/lib/search/models/search-widget-settings.interface.ts +++ b/lib/content-services/src/lib/search/models/search-widget-settings.interface.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import { AutocompleteOption } from './autocomplete-option.interface'; + export interface SearchWidgetSettings { field: string; /* allow the user to update search in every change */ @@ -27,6 +29,8 @@ export interface SearchWidgetSettings { format?: string; /* allow the user to search only within predefined options */ allowOnlyPredefinedValues?: boolean; + /* allow the user to predefine autocomplete options */ + autocompleteOptions?: AutocompleteOption[]; [indexer: string]: any; }