diff --git a/docs/README.md b/docs/README.md index 3c4834bae2..cc9081ec5e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -287,10 +287,12 @@ for more information about installing and using the source code. | [Rating component](content-services/components/rating.component.md) | Allows a user to add and remove rating to an item. | [Source](../lib/content-services/src/lib/social/rating.component.ts) | | [Search check list component](content-services/components/search-check-list.component.md) | Implements a checklist widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-check-list/search-check-list.component.ts) | | [Search Chip Input Component](content-services/components/search-chip-input.component.md) | Displays input for providing phrases display as "chips". | [Source](../lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.ts) | +| [Search Chip Autocomplete Input component](content-services/components/search-chip-autocomplete-input.component.md) | Displays an input with autocomplete options. | [Source](../lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts) | | [Search Chip List Component](content-services/components/search-chip-list.component.md) | Displays search criteria as a set of "chips". | [Source](../lib/content-services/src/lib/search/components/search-chip-list/search-chip-list.component.ts) | | [Search control component](content-services/components/search-control.component.md) | Displays a input text that shows find-as-you-type suggestions. | [Source](../lib/content-services/src/lib/search/components/search-control.component.ts) | | [Search date range component](content-services/components/search-date-range.component.md) | Implements a search widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts) | | [Search datetime range component](content-services/components/search-datetime-range.component.md) | Implements a search widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-datetime-range/search-datetime-range.component.ts) | +| [Search Filter Autocomplete Chips component](content-services/components/search-filter-autocomplete-chips.component.md) | Implements a search widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts) | | [Search Filter Chips component](content-services/components/search-filter-chips.component.md) | Represents a chip based container component for custom search and faceted search settings. | [Source](../lib/content-services/src/lib/search/components/search-filter-chips/search-filter-chips.component.ts) | | [Search Filter component](content-services/components/search-filter.component.md) | Represents a main container component for custom search and faceted search settings. | [Source](../lib/content-services/src/lib/search/components/search-filter/search-filter.component.ts) | | [Search Form component](content-services/components/search-form.component.md) | Search Form screenshot | [Source](../lib/content-services/src/lib/search/components/search-form/search-form.component.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 new file mode 100644 index 0000000000..c39dbb6ea2 --- /dev/null +++ b/docs/content-services/components/search-chip-autocomplete-input.component.md @@ -0,0 +1,52 @@ +--- +Title: Search Chip Autocomplete Input component +Added: v6.1.0 +Status: Active +Last reviewed: 2023-06-13 +--- + +# [Search Chip Autocomplete Input component](../../../lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts "Defined in search-chip-autocomplete-input.component.ts") + +Represents an input with autocomplete options. + +![Search Chip Autocomplete Input](../../docassets/images/search-chip-autocomplete-input.png) + +## Basic usage + +```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 | + +### Events + +| Name | Type | Description | +| ---- | ---- |-----------------------------------------------| +| optionsChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the selected options are changed | + +## See also + +- [Search Configuration Guide](../../user-guide/search-configuration-guide.md) +- [Search Query Builder service](../services/search-query-builder.service.md) +- [Search Widget Interface](../interfaces/search-widget.interface.md) +- [Search Filter Autocomplete Chips component](search-filter-autocomplete-chips.component.md) +- [Search Logical Filter component](search-logical-filter.component.md) +- [Search check list component](search-check-list.component.md) +- [Search date range component](search-date-range.component.md) +- [Search number range component](search-number-range.component.md) +- [Search radio component](search-radio.component.md) +- [Search slider component](search-slider.component.md) +- [Search text component](search-text.component.md) +- [Search Chip Input component](search-chip-input.component.md) diff --git a/docs/content-services/components/search-filter-autocomplete-chips.component.md b/docs/content-services/components/search-filter-autocomplete-chips.component.md new file mode 100644 index 0000000000..9f6351b8ac --- /dev/null +++ b/docs/content-services/components/search-filter-autocomplete-chips.component.md @@ -0,0 +1,67 @@ +--- +Title: Search Filter Autocomplete Chips component +Added: v6.1.0 +Status: Active +Last reviewed: 2023-06-13 +--- + +# [Search Filter Autocomplete Chips component](../../../lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts "Defined in search-filter-autocomplete-chips.component.ts") + +Implements a [search widget](../../../lib/content-services/src/lib/search/models/search-widget.interface.ts) consists of 1 input with autocomplete options representing conditions to form search query. + +![Search Filter Autocomplete Chips](../../docassets/images/search-filter-autocomplete-chips.png) + +## Basic usage + +```json +{ + "search": { + "categories": [ + { + "id": "location", + "name": "Location", + "enabled": true, + "component": { + "selector": "autocomplete-chips", + "settings": { + "allowUpdateOnChange": false, + "hideDefaultAction": true, + "allowOnlyPredefinedValues": false, + "field": "SITE", + "options": [ "Option 1", "Option 2" ] + } + } + } + ] + } +} +``` + +### Settings + +| Name | Type | Description | +| ---- |----------|--------------------------------------------------------------------------------------------------------------------| +| field | `string` | Field to apply the query to. Required value | +| options | `string[]` | 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 | +## Details + +This component allows the user to choose filter options for the search query. +See the [Search Chip Autocomplete Input component](search-chip-autocomplete-input.component.md) for more details. + +## See also + +- [Search Configuration Guide](../../user-guide/search-configuration-guide.md) +- [Search Query Builder service](../services/search-query-builder.service.md) +- [Search Widget Interface](../interfaces/search-widget.interface.md) +- [Search Chip Autocomplete Input component](search-chip-autocomplete-input.component.md) +- [Search Chip Input component](search-chip-input.component.md) +- [Search check list component](search-check-list.component.md) +- [Search date range component](search-date-range.component.md) +- [Search number range component](search-number-range.component.md) +- [Search radio component](search-radio.component.md) +- [Search slider component](search-slider.component.md) +- [Search text component](search-text.component.md) +- [Search Logical Filter component](search-logical-filter.component.md) diff --git a/docs/content-services/pipes/is-included.pipe.md b/docs/content-services/pipes/is-included.pipe.md new file mode 100644 index 0000000000..405ab61c9c --- /dev/null +++ b/docs/content-services/pipes/is-included.pipe.md @@ -0,0 +1,28 @@ +--- +Title: Is Included pipe +Added: v6.1.0 +Status: Active +Last reviewed: 2023-06-13 +--- + +# [Is Included pipe](../../../lib/content-services/src/lib/pipes/is-included.pipe.ts "Defined in is-included.pipe.ts") + +Checks if the provided value is contained in the provided array. + +## Basic Usage + + + +```HTML + +``` + + + +## Details + +The pipe takes the provided value and checks if that value is included in the provided array and returns the appropriate boolean value. + +## See also + +- [File upload error pipe](./file-upload-error.pipe.md) diff --git a/docs/docassets/images/search-chip-autocomplete-input.png b/docs/docassets/images/search-chip-autocomplete-input.png new file mode 100644 index 0000000000..ab726b1a0b Binary files /dev/null and b/docs/docassets/images/search-chip-autocomplete-input.png differ diff --git a/docs/docassets/images/search-filter-autocomplete-chips.png b/docs/docassets/images/search-filter-autocomplete-chips.png new file mode 100644 index 0000000000..cba4249eb8 Binary files /dev/null and b/docs/docassets/images/search-filter-autocomplete-chips.png differ diff --git a/docs/versionIndex.md b/docs/versionIndex.md index 438c1dd121..616d443a37 100644 --- a/docs/versionIndex.md +++ b/docs/versionIndex.md @@ -49,6 +49,9 @@ backend services have been tested with each released version of ADF. - [Use none component view encapsulation](eslint-angular/rules/use-none-component-view-encapsulation.md) - [Search Chip Input component](content-services/components/search-chip-input.component.md) - [Search Logical Filter component](content-services/components/search-logical-filter.component.md) +- [Search Chip Autocomplete Input component](content-services/components/search-chip-autocomplete-input.component.md) +- [Search Filter Autocomplete Chips component](content-services/components/search-filter-autocomplete-chips.component.md) +- [Is Included pipe](content-services/pipes/is-included.pipe.md) diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index b5cd5d8a5d..59caf12cf0 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -297,7 +297,8 @@ "APPLY": "Apply", "CLEAR-ALL": "Clear all", "SHOW-MORE": "Show more", - "SHOW-LESS": "Show less" + "SHOW-LESS": "Show less", + "ADD_OPTION": "Add Option..." }, "BUTTONS": { "CLOSE": "Close", @@ -331,7 +332,8 @@ "BEYOND-MAX-DATETIME": "The datetime is beyond the maximum datetime." }, "ARIA-LABEL": { - "SEARCH_FILTER": "Search Filter List" + "SEARCH_FILTER": "Search Filter List", + "OPTIONS-SELECTION": "Options Selection" }, "ANY": "Any" }, diff --git a/lib/content-services/src/lib/pipes/content-pipe.module.ts b/lib/content-services/src/lib/pipes/content-pipe.module.ts index 994dc2d36f..37eae541b2 100644 --- a/lib/content-services/src/lib/pipes/content-pipe.module.ts +++ b/lib/content-services/src/lib/pipes/content-pipe.module.ts @@ -19,6 +19,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { NodeNameTooltipPipe } from './node-name-tooltip.pipe'; import { TranslateModule } from '@ngx-translate/core'; +import { IsIncludedPipe } from './is-included.pipe'; @NgModule({ imports: [ @@ -26,13 +27,16 @@ import { TranslateModule } from '@ngx-translate/core'; TranslateModule ], declarations: [ - NodeNameTooltipPipe + NodeNameTooltipPipe, + IsIncludedPipe ], providers: [ - NodeNameTooltipPipe + NodeNameTooltipPipe, + IsIncludedPipe ], exports: [ - NodeNameTooltipPipe + NodeNameTooltipPipe, + IsIncludedPipe ] }) export class ContentPipeModule { 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 new file mode 100644 index 0000000000..6a61657146 --- /dev/null +++ b/lib/content-services/src/lib/pipes/is-included.pipe.spec.ts @@ -0,0 +1,44 @@ +/*! + * @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. + */ + +import { IsIncludedPipe } from './is-included.pipe'; + +describe('IsIncludedPipe', () => { + + let pipe: IsIncludedPipe; + const array = [1, 2, 'test', [null], {}]; + + beforeEach(() => { + pipe = new IsIncludedPipe(); + }); + + it('should return true if the string is contained in an array', () => { + expect(pipe.transform('test', array)).toBeTruthy(); + }); + + it('should return false if the string is not contained in an array', () => { + expect(pipe.transform('test 1', array)).toBeFalsy(); + }); + + it('should return true if the number is in the array', () => { + expect(pipe.transform(2, array)).toBeTruthy(); + }); + + it('should return false if the number is not contained in an array', () => { + expect(pipe.transform(50, array)).toBeFalsy(); + }); +}); diff --git a/lib/content-services/src/lib/pipes/is-included.pipe.ts b/lib/content-services/src/lib/pipes/is-included.pipe.ts new file mode 100644 index 0000000000..114b4592d3 --- /dev/null +++ b/lib/content-services/src/lib/pipes/is-included.pipe.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. + */ + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'adfIsIncluded' +}) +export class IsIncludedPipe implements PipeTransform { + transform(value: T, array: T[]): boolean { + return array.includes(value); + } +} diff --git a/lib/content-services/src/lib/pipes/public-api.ts b/lib/content-services/src/lib/pipes/public-api.ts index 966c6a0232..2ab199eee7 100644 --- a/lib/content-services/src/lib/pipes/public-api.ts +++ b/lib/content-services/src/lib/pipes/public-api.ts @@ -16,4 +16,5 @@ */ export * from './node-name-tooltip.pipe'; +export * from './is-included.pipe'; export * from './content-pipe.module'; 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 new file mode 100644 index 0000000000..967aefa7b1 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.html @@ -0,0 +1,28 @@ + + + + {{option}} + + + + + + + {{option}} + + + 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 new file mode 100644 index 0000000000..73b647a990 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.scss @@ -0,0 +1,46 @@ +adf-search-chip-autocomplete-input { + .adf-chip-list { + width: 100%; + } + + .mat-form-field-appearance-outline .mat-form-field-outline { + top: 0; + } + + .mat-form-field-infix { + border: none; + } + + .mat-chip.adf-option-chips { + border: 1px solid var(--theme-text-color); + border-radius: 10px; + background-color: var(--theme-primary-color-default-contrast); + height: auto; + word-break: break-word; + } + + .mat-chip-remove.adf-option-chips-delete-button { + font-size: 13px; + height: 13px; + width: 13px; + + .adf-option-chips-delete-icon.mat-icon { + font-size: 13px; + height: 13px; + width: 13px; + } + } + + .mat-chip-list-wrapper { + min-height: 40px; + } + + .mat-form-field-wrapper { + padding: 0; + } +} + +.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 new file mode 100644 index 0000000000..d8be21e38d --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.spec.ts @@ -0,0 +1,177 @@ +/*! + * @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. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatChip, MatChipRemove } from '@angular/material/chips'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; +import { SearchChipAutocompleteInputComponent } from './search-chip-autocomplete-input.component'; + +describe('SearchChipAutocompleteInputComponent', () => { + let component: SearchChipAutocompleteInputComponent; + let fixture: ComponentFixture; + const onResetSubject = new Subject(); + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SearchChipAutocompleteInputComponent], + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ] + }); + + fixture = TestBed.createComponent(SearchChipAutocompleteInputComponent); + component = fixture.componentInstance; + component.onReset$ = onResetSubject.asObservable(); + fixture.detectChanges(); + component.autocompleteOptions = ['option1', 'option2']; + }); + + function enterNewInputValue(value: string) { + const inputElement = fixture.debugElement.query(By.css('input')); + inputElement.nativeElement.dispatchEvent(new Event('focusin')); + inputElement.nativeElement.value = value; + inputElement.nativeElement.dispatchEvent(new Event('input')); + fixture.detectChanges(); + } + + function addNewOption(value: string) { + const inputElement = fixture.debugElement.query(By.css('input')).nativeElement; + inputElement.value = value; + fixture.detectChanges(); + inputElement.dispatchEvent(new KeyboardEvent('keydown', {keyCode: 13})); + fixture.detectChanges(); + } + + function getChipList(): MatChip[] { + return fixture.debugElement.queryAll(By.css('mat-chip')).map((chip) => chip.nativeElement); + } + + function getChipValue(index: number): string { + return fixture.debugElement.queryAll(By.css('mat-chip span')).map((chip) => chip.nativeElement)[index].innerText; + } + + it('should add new option only if value is predefined when allowOnlyPredefinedValues = true', () => { + addNewOption('test'); + addNewOption('option1'); + expect(getChipList().length).toBe(1); + expect(getChipValue(0)).toBe('option1'); + }); + + it('should add new option even if value is not predefined when allowOnlyPredefinedValues = false', () => { + component.allowOnlyPredefinedValues = false; + addNewOption('test'); + addNewOption('option1'); + expect(getChipList().length).toBe(2); + expect(getChipValue(0)).toBe('test'); + }); + + it('should add new option upon clicking on option from autocomplete', async () => { + const optionsChangedSpy = spyOn(component.optionsChanged, 'emit'); + enterNewInputValue('op'); + await fixture.whenStable(); + + const matOptions = document.querySelectorAll('mat-option'); + expect(matOptions.length).toBe(2); + + const optionToClick = matOptions[0] as HTMLElement; + optionToClick.click(); + + expect(optionsChangedSpy).toHaveBeenCalledOnceWith(['option1']); + expect(component.selectedOptions).toEqual(['option1']); + expect(getChipList().length).toBe(1); + }); + + it('should apply class to already selected options', async () => { + addNewOption('option1'); + enterNewInputValue('op'); + + const addedOptions = fixture.debugElement.queryAll(By.css('.adf-autocomplete-added-option')); + + await fixture.whenStable(); + + expect(addedOptions[0]).toBeTruthy(); + expect(addedOptions.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']; + enterNewInputValue('a'); + + const matOptions = document.querySelectorAll('mat-option'); + expect(matOptions.length).toBe(15); + }); + + it('should not add a value if same value has already been added', () => { + addNewOption('option1'); + addNewOption('option1'); + expect(getChipList().length).toBe(1); + }); + + it('should show autocomplete list if similar predefined values exists', () => { + enterNewInputValue('op'); + const matOptions = document.querySelectorAll('mat-option'); + expect(matOptions.length).toBe(2); + }); + + it('should not show autocomplete list if there are no similar predefined values', () => { + enterNewInputValue('test'); + const matOptions = document.querySelectorAll('mat-option'); + expect(matOptions.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(getChipList().length).toBe(1); + expect(getChipValue(0)).toBe('option1'); + }); + + it('should clear the input after a new value is added', () => { + const input = fixture.debugElement.query(By.css('input')).nativeElement; + addNewOption('option1'); + expect(input.value).toBe(''); + }); + + it('should reset all options when onReset$ event is emitted', () => { + addNewOption('option1'); + addNewOption('option2'); + const optionsChangedSpy = spyOn(component.optionsChanged, 'emit'); + onResetSubject.next(); + fixture.detectChanges(); + + expect(optionsChangedSpy).toHaveBeenCalledOnceWith([]); + expect(getChipList()).toEqual([]); + expect(component.selectedOptions).toEqual([]); + }); + + it('should remove option upon clicking remove button', () => { + addNewOption('option1'); + addNewOption('option2'); + const optionsChangedSpy = spyOn(component.optionsChanged, 'emit'); + + fixture.debugElement.query(By.directive(MatChipRemove)).nativeElement.click(); + fixture.detectChanges(); + + expect(optionsChangedSpy).toHaveBeenCalledOnceWith(['option2']); + expect(getChipList().length).toEqual(1); + }); +}); 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 new file mode 100644 index 0000000000..d03a653a7c --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-autocomplete-input/search-chip-autocomplete-input.component.ts @@ -0,0 +1,120 @@ +/*! + * @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. + */ + +import { Component, ViewEncapsulation, ElementRef, ViewChild, OnInit, OnDestroy, Input, Output, EventEmitter } 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 } from 'rxjs/operators'; + +@Component({ + selector: 'adf-search-chip-autocomplete-input', + templateUrl: './search-chip-autocomplete-input.component.html', + styleUrls: ['./search-chip-autocomplete-input.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy { + @ViewChild('optionInput') + optionInput: ElementRef; + + @Input() + autocompleteOptions: string[] = []; + + @Input() + onReset$: Observable; + + @Input() + allowOnlyPredefinedValues = true; + + @Output() + optionsChanged: EventEmitter = new EventEmitter(); + + readonly separatorKeysCodes = [ENTER] as const; + formCtrl = new FormControl(''); + filteredOptions$: Observable; + selectedOptions: string[] = []; + private onDestroy$ = new Subject(); + + constructor() { + this.filteredOptions$ = this.formCtrl.valueChanges.pipe( + startWith(null), + map((value: string | null) => (value ? this.filter(value) : [])) + ); + } + + ngOnInit() { + this.onReset$?.pipe(takeUntil(this.onDestroy$)).subscribe(() => this.reset()); + } + + ngOnDestroy() { + this.onDestroy$.next(); + this.onDestroy$.complete(); + } + + add(event: MatChipInputEvent) { + const value = (event.value || '').trim(); + + if (value && this.isExists(value) && !this.isAdded(value)) { + this.selectedOptions.push(value); + this.optionsChanged.emit(this.selectedOptions); + event.chipInput.clear(); + this.formCtrl.setValue(null); + } + } + + remove(value: string) { + const index = this.selectedOptions.indexOf(value); + + if (index >= 0) { + this.selectedOptions.splice(index, 1); + this.optionsChanged.emit(this.selectedOptions); + } + } + + 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); + } + } + + private filter(value: string): string[] { + const filterValue = value.toLowerCase(); + return this.autocompleteOptions.filter(option => option.toLowerCase().includes(filterValue)).slice(0, 15); + } + + private isAdded(value: string): boolean { + return this.selectedOptions.includes(value); + } + + private isExists(value: string): boolean { + return this.allowOnlyPredefinedValues + ? this.autocompleteOptions.map(option => option.toLowerCase()).includes(value.toLowerCase()) + : true; + } + + private reset() { + this.selectedOptions = []; + this.optionsChanged.emit(this.selectedOptions); + this.formCtrl.setValue(null); + 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 new file mode 100644 index 0000000000..7e95495bd0 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.html @@ -0,0 +1,15 @@ + + + +
+ + +
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 new file mode 100644 index 0000000000..8d3d17b342 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.spec.ts @@ -0,0 +1,122 @@ +/*! + * @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. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; +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'; + +describe('SearchFilterAutocompleteChipsComponent', () => { + let component: SearchFilterAutocompleteChipsComponent; + let fixture: ComponentFixture; + let tagService: TagService; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SearchFilterAutocompleteChipsComponent], + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ], + providers: [{ + provide: TagService, + useValue: { getAllTheTags: () => EMPTY } + }] + }); + + fixture = TestBed.createComponent(SearchFilterAutocompleteChipsComponent); + component = fixture.componentInstance; + tagService = TestBed.inject(TagService); + component.id = 'test-id'; + component.context = { + queryFragments: {}, + update: () => EMPTY + } as any; + component.settings = { + field: 'test', allowUpdateOnChange: true, hideDefaultAction: false, allowOnlyPredefinedValues: false, + options: ['option1', 'option2'] + }; + fixture.detectChanges(); + }); + + function addNewOption(value: string) { + const inputElement = fixture.debugElement.query(By.css('adf-search-chip-autocomplete-input input')).nativeElement; + inputElement.value = value; + inputElement.dispatchEvent(new KeyboardEvent('keydown', {keyCode: 13})); + fixture.detectChanges(); + } + + it('should set autocomplete options on init', () => { + component.settings.options = ['test 1', 'test 2']; + component.ngOnInit(); + expect(component.autocompleteOptions).toEqual(['test 1', 'test 2']); + }); + + it('should load tags if field = TAG', () => { + const tagPagingMock = { + list: { + pagination: {}, + entries: [{entry: {tag: 'tag1', id: 'id1'}}, {entry: {tag: 'tag2', id: 'id2'}}] + } + }; + + component.settings.field = 'TAG'; + spyOn(tagService, 'getAllTheTags').and.returnValue(of(tagPagingMock)); + component.ngOnInit(); + expect(component.autocompleteOptions).toEqual(['tag1', 'tag2']); + }); + + it('should update display value when options changes', () => { + const newOption = 'option1'; + spyOn(component, 'onOptionsChange').and.callThrough(); + spyOn(component.displayValue$, 'next'); + addNewOption(newOption); + + expect(component.onOptionsChange).toHaveBeenCalled(); + expect(component.displayValue$.next).toHaveBeenCalledOnceWith(newOption); + }); + + it('should reset value and display value when reset button is clicked', () => { + component.setValue(['option1', 'option2']); + fixture.detectChanges(); + expect(component.selectedOptions).toEqual(['option1', '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; + clearBtn.click(); + + expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.update).toHaveBeenCalled(); + expect(component.selectedOptions).toEqual( [] ); + expect(component.displayValue$.next).toHaveBeenCalledWith(''); + }); + + it('should correctly compose the search query', () => { + spyOn(component.context, 'update'); + addNewOption('option2'); + addNewOption('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"'); + }); +}); 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 new file mode 100644 index 0000000000..09eb499474 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component.ts @@ -0,0 +1,108 @@ +/*! + * @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. + */ + +import { Component, ViewEncapsulation, OnInit } from '@angular/core'; +import { 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'; + +@Component({ + selector: 'adf-search-filter-autocomplete-chips', + templateUrl: './search-filter-autocomplete-chips.component.html', + encapsulation: ViewEncapsulation.None +}) +export class SearchFilterAutocompleteChipsComponent implements SearchWidget, OnInit { + id: string; + settings?: SearchWidgetSettings; + context?: SearchQueryBuilderService; + options: SearchFilterList; + startValue: string[] = null; + displayValue$ = new Subject(); + + private resetSubject$ = new Subject(); + reset$: Observable = this.resetSubject$.asObservable(); + autocompleteOptions: string[] = []; + selectedOptions: string[] = []; + enableChangeUpdate: boolean; + + constructor( private tagService: TagService ) { + this.options = new SearchFilterList(); + } + + ngOnInit() { + if (this.settings) { + this.setOptions(); + if (this.startValue) { + this.setValue(this.startValue); + } + this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true; + } + } + + reset() { + this.selectedOptions = []; + this.resetSubject$.next(); + this.updateQuery(); + } + + submitValues() { + this.updateQuery(); + } + + hasValidValue(): boolean { + return !!this.selectedOptions; + } + + getCurrentValue(): string[]{ + return this.selectedOptions; + } + + onOptionsChange(selectedOptions: string[]) { + this.selectedOptions = selectedOptions; + if (this.enableChangeUpdate) { + this.updateQuery(); + this.context.update(); + } + } + + setValue(value: string[]) { + this.selectedOptions = value; + this.displayValue$.next(this.selectedOptions.join(', ')); + this.submitValues(); + } + + private updateQuery() { + this.displayValue$.next(this.selectedOptions.join(', ')); + if (this.context && this.settings && this.settings.field) { + this.context.queryFragments[this.id] = this.selectedOptions.map(val => `${this.settings.field}: "${val}"`).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; + } + } +} 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 5893754ed4..4885fd2bbf 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 @@ -25,6 +25,8 @@ export interface SearchWidgetSettings { unit?: string; /* describes query format */ format?: string; + /* allow the user to search only within predefined options */ + allowOnlyPredefinedValues?: boolean; [indexer: string]: any; } diff --git a/lib/content-services/src/lib/search/public-api.ts b/lib/content-services/src/lib/search/public-api.ts index 9c799db124..9fa4f9e752 100644 --- a/lib/content-services/src/lib/search/public-api.ts +++ b/lib/content-services/src/lib/search/public-api.ts @@ -63,5 +63,7 @@ export * from './components/search-facet-field/search-facet-field.component'; export * from './components/search-chip-input/search-chip-input.component'; export * from './components/search-logical-filter/search-logical-filter.component'; export * from './components/reset-search.directive'; +export * from './components/search-chip-autocomplete-input/search-chip-autocomplete-input.component'; +export * from './components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component'; export * from './search.module'; diff --git a/lib/content-services/src/lib/search/search.module.ts b/lib/content-services/src/lib/search/search.module.ts index 0cc7f1ab0b..b28271cb9d 100644 --- a/lib/content-services/src/lib/search/search.module.ts +++ b/lib/content-services/src/lib/search/search.module.ts @@ -19,6 +19,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MaterialModule } from '../material.module'; +import { ContentPipeModule } from '../pipes/content-pipe.module'; import { CoreModule, SearchTextModule } from '@alfresco/adf-core'; @@ -29,6 +30,8 @@ import { SearchWidgetContainerComponent } from './components/search-widget-conta import { SearchFilterComponent } from './components/search-filter/search-filter.component'; import { SearchChipListComponent } from './components/search-chip-list/search-chip-list.component'; import { SearchTextComponent } from './components/search-text/search-text.component'; +import { SearchChipAutocompleteInputComponent } from './components/search-chip-autocomplete-input/search-chip-autocomplete-input.component'; +import { SearchFilterAutocompleteChipsComponent } from './components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component'; import { SearchRadioComponent } from './components/search-radio/search-radio.component'; import { SearchSliderComponent } from './components/search-slider/search-slider.component'; import { SearchNumberRangeComponent } from './components/search-number-range/search-number-range.component'; @@ -53,6 +56,7 @@ import { ResetSearchDirective } from './components/reset-search.directive'; @NgModule({ imports: [ CommonModule, + ContentPipeModule, FormsModule, ReactiveFormsModule, MaterialModule, @@ -67,6 +71,8 @@ import { ResetSearchDirective } from './components/reset-search.directive'; SearchChipListComponent, SearchWidgetContainerComponent, SearchTextComponent, + SearchChipAutocompleteInputComponent, + SearchFilterAutocompleteChipsComponent, SearchRadioComponent, SearchSliderComponent, SearchNumberRangeComponent, @@ -94,6 +100,8 @@ import { ResetSearchDirective } from './components/reset-search.directive'; SearchChipListComponent, SearchWidgetContainerComponent, SearchTextComponent, + SearchChipAutocompleteInputComponent, + SearchFilterAutocompleteChipsComponent, SearchRadioComponent, SearchSliderComponent, SearchNumberRangeComponent, diff --git a/lib/content-services/src/lib/search/services/search-filter.service.ts b/lib/content-services/src/lib/search/services/search-filter.service.ts index 70b5fa1444..9ffe6cc671 100644 --- a/lib/content-services/src/lib/search/services/search-filter.service.ts +++ b/lib/content-services/src/lib/search/services/search-filter.service.ts @@ -24,6 +24,7 @@ import { SearchCheckListComponent } from '../components/search-check-list/search import { SearchDateRangeComponent } from '../components/search-date-range/search-date-range.component'; import { SearchDatetimeRangeComponent } from '../components/search-datetime-range/search-datetime-range.component'; import { SearchLogicalFilterComponent } from '../components/search-logical-filter/search-logical-filter.component'; +import { SearchFilterAutocompleteChipsComponent } from '../components/search-filter-autocomplete-chips/search-filter-autocomplete-chips.component'; @Injectable({ providedIn: 'root' @@ -41,7 +42,8 @@ export class SearchFilterService { 'check-list': SearchCheckListComponent, 'date-range': SearchDateRangeComponent, 'datetime-range': SearchDatetimeRangeComponent, - 'logical-filter': SearchLogicalFilterComponent + 'logical-filter': SearchLogicalFilterComponent, + 'autocomplete-chips': SearchFilterAutocompleteChipsComponent }; }