[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
This commit is contained in:
Mykyta Maliarchuk
2023-07-21 09:27:31 +02:00
committed by GitHub
parent 1ebac21251
commit 2a4507d529
18 changed files with 308 additions and 126 deletions

View File

@@ -15,30 +15,32 @@ Represents an input with autocomplete options.
```html
<adf-search-chip-autocomplete-input
[autocompleteOptions]="allOptions"
[autocompleteOptions]="autocompleteOptions"
[onReset$]="onResetObservable$"
[allowOnlyPredefinedValues]="allowOnlyPredefinedValues"
(inputChanged)="onInputChange($event)"
(optionsChanged)="onOptionsChange($event)">
</adf-search-chip-autocomplete-input>
```
### Properties
| Name | Type | Default value | Description |
|---------------------------|--------------------------|----|-----------------------------------------------------------------------------------------------|
| autocompleteOptions | `string[]` | [] | Options for autocomplete |
| onReset$ | [`Observable`](https://rxjs.dev/guide/observable)`<void>` | | 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)`<void>` | | 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)`<string[]>` | Emitted when the selected options are changed |
| inputChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<string>` | Emitted when the input changes |
| optionsChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<AutocompleteOption[]>` | Emitted when the selected options are changed |
## See also

View File

@@ -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 |

View File

@@ -14,7 +14,7 @@ Checks if the provided value is contained in the provided array.
<!-- {% raw %} -->
```HTML
<mat-option [disabled]="value | adfIsIncluded: arrayOfValues"</mat-option>
<mat-option [disabled]="value | adfIsIncluded: arrayOfValues : comparator"></mat-option>
```
<!-- {% endraw %} -->

View File

@@ -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",

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -4,8 +4,14 @@
class="adf-option-chips"
*ngFor="let option of selectedOptions"
(removed)="remove(option)">
<span>{{option}}</span>
<button matChipRemove class="adf-option-chips-delete-button" [attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option">
<span [matTooltip]="'SEARCH.RESULTS.WILL_CONTAIN' | translate:{searchTerm: option.fullPath}"
[matTooltipDisabled]="!option.fullPath" [matTooltipShowDelay]="tooltipShowDelay">
{{ option.value }}
</span>
<button matChipRemove class="adf-option-chips-delete-button" [matTooltipDisabled]="!option.fullPath"
[matTooltip]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' \'' + option.fullPath + '\''"
[matTooltipShowDelay]="tooltipShowDelay"
[attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + option.value">
<mat-icon class="adf-option-chips-delete-icon">close</mat-icon>
</button>
</mat-chip>
@@ -24,9 +30,15 @@
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" id="adf-search-chip-autocomplete"
(optionActivated)="activeAnyOption = true" (closed)="activeAnyOption = false">
<mat-option [disabled]="option | adfIsIncluded: selectedOptions : compareOption" *ngFor="let option of filteredOptions$ | async"
[ngClass]="(option | adfIsIncluded: selectedOptions : compareOption) && 'adf-autocomplete-added-option'">
{{option}}
</mat-option>
<ng-container *ngIf="optionInput.value.length > 0">
<mat-option
[disabled]="option | adfIsIncluded: selectedOptions : compareOption"
*ngFor="let option of filteredOptions" [value]="option" [matTooltipShowDelay]="tooltipShowDelay"
[matTooltipDisabled]="!option.fullPath" matTooltipPosition="right"
[matTooltip]="'SEARCH.RESULTS.WILL_CONTAIN' | translate:{searchTerm: option.fullPath || option.value}"
[ngClass]="(option | adfIsIncluded: selectedOptions : compareOption) && 'adf-autocomplete-added-option'">
{{ option.fullPath || option.value }}
</mat-option>
</ng-container>
</mat-autocomplete>
</mat-form-field>

View File

@@ -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);

View File

@@ -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');
});
});

View File

@@ -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<HTMLInputElement>;
@Input()
autocompleteOptions: string[] = [];
autocompleteOptions: AutocompleteOption[] = [];
@Input()
onReset$: Observable<void>;
@@ -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<string[]> = new EventEmitter();
optionsChanged = new EventEmitter<AutocompleteOption[]>();
@Output()
inputChanged = new EventEmitter<string>();
readonly separatorKeysCodes = [ENTER] as const;
formCtrl = new FormControl('');
filteredOptions$: Observable<string[]>;
selectedOptions: string[] = [];
filteredOptions: AutocompleteOption[] = [];
selectedOptions: AutocompleteOption[] = [];
tooltipShowDelay = 800;
private onDestroy$ = new Subject<void>();
private _activeAnyOption = false;
@@ -71,16 +88,25 @@ 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());
}
ngOnInit() {
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() {
@@ -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 = '';
}
}

View File

@@ -1,7 +1,9 @@
<adf-search-chip-autocomplete-input
[autocompleteOptions]="autocompleteOptions"
[autocompleteOptions]="autocompleteOptions$ | async"
[onReset$]="reset$"
[allowOnlyPredefinedValues]="settings.allowOnlyPredefinedValues"
(inputChanged)="onInputChange($event)"
[compareOption]="optionComparator"
(optionsChanged)="onOptionsChange($event)">
</adf-search-chip-autocomplete-input>

View File

@@ -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"');
});
});

View File

@@ -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<string[]>;
startValue: string[] = null;
options: SearchFilterList<AutocompleteOption[]>;
startValue: AutocompleteOption[] = [];
displayValue$ = new Subject<string>();
selectedOptions: AutocompleteOption[] = [];
enableChangeUpdate: boolean;
private resetSubject$ = new Subject<void>();
reset$: Observable<void> = this.resetSubject$.asObservable();
autocompleteOptions: string[] = [];
selectedOptions: string[] = [];
enableChangeUpdate: boolean;
private autocompleteOptionsSubject$ = new BehaviorSubject<AutocompleteOption[]>([]);
autocompleteOptions$: Observable<AutocompleteOption[]> = this.autocompleteOptionsSubject$.asObservable();
constructor( private tagService: TagService ) {
this.options = new SearchFilterList<string[]>();
constructor(private tagService: TagService, private categoryService: CategoryService) {
this.options = new SearchFilterList<AutocompleteOption[]>();
}
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};
}));
});
}
}

View File

@@ -42,7 +42,7 @@
</mat-form-field>
<p class="adf-search-properties-file-type-label">{{ 'SEARCH.SEARCH_PROPERTIES.FILE_TYPE' | translate }}</p>
<adf-search-chip-autocomplete-input
[autocompleteOptions]="settings?.fileExtensions"
[autocompleteOptions]="autocompleteOptions"
(optionsChanged)="selectedExtensions = $event"
[onReset$]="reset$"
[allowOnlyPredefinedValues]="false"

View File

@@ -140,9 +140,10 @@ describe('SearchPropertiesComponent', () => {
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', () => {

View File

@@ -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<string>();
autocompleteOptions: AutocompleteOption[] = [];
private _form = this.formBuilder.nonNullable.group<FileSizeCondition>({
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;

View File

@@ -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'
}

View File

@@ -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;
}