diff --git a/docs/README.md b/docs/README.md index c45943bb49..3c4834bae2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -286,6 +286,7 @@ for more information about installing and using the source code. | [Permission List Component](content-services/components/permission-list.component.md) | Shows node permissions as a table. | [Source](../lib/content-services/src/lib/permission-manager/components/permission-list/permission-list.component.ts) | | [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 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) | @@ -293,6 +294,7 @@ for more information about installing and using the source code. | [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) | +| [Search Logical Filter component](content-services/components/search-logical-filter.component.md) | Displays 3 chip inputs each representing different logical condition for search query. | [Source](../lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts) | | [Search number range component](content-services/components/search-number-range.component.md) | Implements a number range widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-number-range/search-number-range.component.ts) | | [Search radio component](content-services/components/search-radio.component.md) | Implements a radio button list widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts) | | [Search slider component](content-services/components/search-slider.component.md) | Implements a numeric slider widget for the Search Filter component. | [Source](../lib/content-services/src/lib/search/components/search-slider/search-slider.component.ts) | diff --git a/docs/content-services/components/search-chip-input.component.md b/docs/content-services/components/search-chip-input.component.md new file mode 100644 index 0000000000..ea38705a60 --- /dev/null +++ b/docs/content-services/components/search-chip-input.component.md @@ -0,0 +1,49 @@ +--- +Title: Search Chip Input component +Added: v6.1.0 +Status: Active +Last reviewed: 2023-06-01 +--- + +# [Search Chip Input component](../../../lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.ts "Defined in search-chip-input.component.ts") + +Represents an input with stacked list of chips as phrases added through input. + +![Search Chip Input](../../docassets/images/search-chip-input.png) + +## Basic usage + +```html + + +``` + +### Properties + +| Name | Type | Default value | Description | +| ---- | ---- | ------------- | ----------- | +| label | `string` | | Label that will be associated with the input | +| addOnBlur | `boolean` | true | Specifies whether new phrase will be added when input blurs | +| onReset | [`Observable`](https://rxjs.dev/guide/observable)`` | | Observable that will listen to any reset event causing component to clear the chips and input | + +### Events + +| Name | Type | Description | +| ---- | ---- | ----------- | +| phrasesChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when new phrase is entered | + +## 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 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) diff --git a/docs/content-services/components/search-logical-filter.component.md b/docs/content-services/components/search-logical-filter.component.md new file mode 100644 index 0000000000..30d7bb04c7 --- /dev/null +++ b/docs/content-services/components/search-logical-filter.component.md @@ -0,0 +1,61 @@ +--- +Title: Search Logical Filter component +Added: v6.1.0 +Status: Active +Last reviewed: 2023-06-01 +--- + +# [Search Logical Filter component](../../../lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts "Defined in search-logical-filter.component.ts") + +Implements a [search widget](../../../lib/content-services/src/lib/search/models/search-widget.interface.ts) consisting of 3 chip inputs representing logical conditions to form search query from. + +![Search Logical Filter](../../docassets/images/search-logical-filter.png) + +## Basic usage + +```json +{ + "search": { + "categories": [ + { + "id": "logic", + "name": "Logic", + "enabled": true, + "component": { + "selector": "logical-filter", + "settings": { + "allowUpdateOnChange": false, + "hideDefaultAction": true, + "field": "cm:name,cm:title,TEXT" + } + } + } + ] + } +} +``` + +### Settings + +| Name | Type | Description | +| ---- | ---- | ----------- | +| field | string | Field/fields to apply the query to. Required value | +| hideDefaultAction | boolean | Show/hide the [widget](../../../lib/testing/src/lib/protractor/core/pages/form/widgets/widget.ts) actions. By default is false. | + +## Details + +This component lets the user provide logical conditions to apply to each `field` in the search query. +See the [Search chip input component](search-chip-input.component.md) for full details of how to use chip inputs. + +## 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 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) diff --git a/docs/docassets/images/search-chip-input.png b/docs/docassets/images/search-chip-input.png new file mode 100644 index 0000000000..b165168fb9 Binary files /dev/null and b/docs/docassets/images/search-chip-input.png differ diff --git a/docs/docassets/images/search-logical-filter.png b/docs/docassets/images/search-logical-filter.png new file mode 100644 index 0000000000..b9c826aaac Binary files /dev/null and b/docs/docassets/images/search-logical-filter.png differ diff --git a/docs/versionIndex.md b/docs/versionIndex.md index d6463ee34b..438c1dd121 100644 --- a/docs/versionIndex.md +++ b/docs/versionIndex.md @@ -47,6 +47,8 @@ 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) diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index 234613519b..7a764bc6d1 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -397,6 +397,17 @@ "TITLE" : "Created Date (range)" } } + }, + "LOGICAL_SEARCH": { + "SEARCH_CHIP_INPUT": { + "ADD_PHRASE": "Add phrase..." + }, + "MATCH_ALL": "ALL", + "MATCH_ALL_LABEL": "Match ALL of these phrases", + "MATCH_ANY": "ANY", + "MATCH_ANY_LABEL": "Match ANY of these phrases", + "EXCLUDE": "EXCLUDE", + "EXCLUDE_LABEL": "EXCLUDE these phrases" } }, "PERMISSION": { diff --git a/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.html b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.html new file mode 100644 index 0000000000..be72cd993b --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.html @@ -0,0 +1,15 @@ +{{label | translate}} + + + {{phrase}} + + + + diff --git a/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.scss b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.scss new file mode 100644 index 0000000000..8733000835 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.scss @@ -0,0 +1,18 @@ +.adf-search-chip-input { + padding-bottom: 15px; + + .mat-chip-list-wrapper { + border: 1px solid var(--adf-theme-mat-grey-color-a400); + border-radius: 5px; + margin-top: 5px; + + .mat-chip { + word-break: break-all; + height: unset; + } + + input { + height: 25px; + } + } +} diff --git a/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.spec.ts b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.spec.ts new file mode 100644 index 0000000000..0e3e85d279 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.spec.ts @@ -0,0 +1,161 @@ +/*! + * @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 { SearchChipInputComponent } from './search-chip-input.component'; + +describe('SearchChipInputComponent', () => { + let component: SearchChipInputComponent; + let fixture: ComponentFixture; + const onResetSubject = new Subject(); + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SearchChipInputComponent], + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ] + }); + + fixture = TestBed.createComponent(SearchChipInputComponent); + component = fixture.componentInstance; + component.onReset = onResetSubject.asObservable(); + fixture.detectChanges(); + }); + + afterEach(() => removeAllChips()); + + function getChipInput(): HTMLInputElement { + return fixture.debugElement.query(By.css('input')).nativeElement; + } + + 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; + } + + function enterNewChip(value: string) { + const input = getChipInput(); + input.value = value; + fixture.detectChanges(); + input.dispatchEvent(new KeyboardEvent('keydown', {keyCode: 13})); + fixture.detectChanges(); + } + + function removeAllChips() { + const chips = getChipList(); + if (!!chips && chips.length > 0) { + chips.forEach((chip) => chip.remove()); + } + } + + function removeChip(index: number) { + const removeBtns = fixture.debugElement.queryAll(By.directive(MatChipRemove)).map((removeBtn) => removeBtn.nativeElement); + removeBtns[index].click(); + fixture.detectChanges(); + } + + it('should display label provided as component input', () => { + const label = 'Test'; + component.label = label; + fixture.detectChanges(); + const matLabel = fixture.debugElement.query(By.css('mat-label')).nativeElement.innerText; + expect(matLabel).toBe(label); + }); + + it('should display proper placeholder for chip input', () => { + const input = getChipInput(); + expect(input.placeholder).toBe('SEARCH.LOGICAL_SEARCH.SEARCH_CHIP_INPUT.ADD_PHRASE'); + }); + + it('should not display any chips initially', () => { + const chips = getChipList(); + expect(chips).toEqual([]); + }); + + it('should add new chip when input has value and enter was hit', () => { + const phrasesChangedSpy = spyOn(component.phrasesChanged, 'emit'); + enterNewChip('test'); + expect(phrasesChangedSpy).toHaveBeenCalledOnceWith(['test']); + expect(getChipList().length).toBe(1); + expect(getChipValue(0)).toBe('test'); + }); + + it('should add input value as whole phrase even if it contains whitespaces and special signs', () => { + const phrase = 'test another world &*,.;""!@#$$%^*()[]-+='; + enterNewChip(phrase); + expect(getChipList().length).toBe(1); + expect(getChipValue(0)).toBe(phrase); + }); + + it('should add new chip when input is blurred', () => { + const input = getChipInput(); + input.value = 'test'; + fixture.detectChanges(); + input.dispatchEvent(new InputEvent('blur')); + fixture.detectChanges(); + expect(input.value).toBe(''); + expect(getChipList().length).toBe(1); + expect(getChipValue(0)).toBe('test'); + }); + + it('should not add new chip when input is blurred if addOnBlur is false', () => { + component.addOnBlur = false; + const input = getChipInput(); + input.value = 'test'; + fixture.detectChanges(); + input.dispatchEvent(new InputEvent('blur')); + fixture.detectChanges(); + expect(input.value).toBe('test'); + expect(getChipList().length).toBe(0); + }); + + it('should clear the input after new chip is added', () => { + const input = getChipInput(); + enterNewChip('test2'); + expect(input.value).toBe(''); + }); + + it('should reset all chips when onReset event is emitted', () => { + enterNewChip('test1'); + enterNewChip('test2'); + enterNewChip('test3'); + const phrasesChangedSpy = spyOn(component.phrasesChanged, 'emit'); + onResetSubject.next(); + fixture.detectChanges(); + expect(phrasesChangedSpy).toHaveBeenCalledOnceWith([]); + expect(getChipList()).toEqual([]); + }); + + it('should remove chip upon clicking remove button', () => { + enterNewChip('test1'); + enterNewChip('test2'); + const phrasesChangedSpy = spyOn(component.phrasesChanged, 'emit'); + removeChip(0); + expect(phrasesChangedSpy).toHaveBeenCalledOnceWith(['test2']); + expect(getChipList().length).toEqual(1); + }); +}); diff --git a/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.ts b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.ts new file mode 100644 index 0000000000..d85f562b45 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-chip-input/search-chip-input.component.ts @@ -0,0 +1,76 @@ +/*! + * @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 { ENTER } from '@angular/cdk/keycodes'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { MatChipInputEvent } from '@angular/material/chips'; +import { Observable, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'adf-search-chip-input', + templateUrl: './search-chip-input.component.html', + styleUrls: ['./search-chip-input.component.scss'], + encapsulation: ViewEncapsulation.None, + host: { class: 'adf-search-chip-input' } +}) +export class SearchChipInputComponent implements OnInit, OnDestroy { + @Input() + label: string; + + @Input() + addOnBlur = true; + + @Input() + onReset: Observable; + + @Output() + phrasesChanged: EventEmitter = new EventEmitter(); + + private onDestroy$ = new Subject(); + phrases: string[] = []; + readonly separatorKeysCodes = [ENTER] as const; + + ngOnInit() { + this.onReset?.pipe(takeUntil(this.onDestroy$)).subscribe(() => this.resetChips()); + } + + ngOnDestroy(): void { + this.onDestroy$.next(); + this.onDestroy$.complete(); + } + + addPhrase(event: MatChipInputEvent) { + const phrase = (event.value || '').trim(); + + if (phrase) { + this.phrases.push(phrase); + this.phrasesChanged.emit(this.phrases); + } + event.chipInput.clear(); + } + + removePhrase(index: number) { + this.phrases.splice(index, 1); + this.phrasesChanged.emit(this.phrases); + } + + private resetChips() { + this.phrases = []; + this.phrasesChanged.emit(this.phrases); + } +} diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.html b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.html new file mode 100644 index 0000000000..feb2b02cc6 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.html @@ -0,0 +1,26 @@ +
+ + + + + + + +
+ + +
+
diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.scss b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.scss new file mode 100644 index 0000000000..99943c9939 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.scss @@ -0,0 +1,4 @@ +.adf-search-logical-filter-container { + display: flex; + flex-direction: column; +} diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts new file mode 100644 index 0000000000..de39a7309d --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.spec.ts @@ -0,0 +1,178 @@ +/*! + * @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 { TranslateModule } from '@ngx-translate/core'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; +import { SearchChipInputComponent } from '../search-chip-input/search-chip-input.component'; +import { LogicalSearchCondition, LogicalSearchFields, SearchLogicalFilterComponent } from './search-logical-filter.component'; + +describe('SearchLogicalFilterComponent', () => { + let component: SearchLogicalFilterComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [SearchLogicalFilterComponent, SearchChipInputComponent], + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ] + }); + + fixture = TestBed.createComponent(SearchLogicalFilterComponent); + component = fixture.componentInstance; + component.id = 'logic'; + component.context = { + queryFragments: { + logic: '' + }, + update: () => {} + } as any; + component.settings = { field: 'field1,field2', allowUpdateOnChange: true, hideDefaultAction: false }; + fixture.detectChanges(); + }); + + function getChipInputs(): HTMLInputElement[] { + return fixture.debugElement.queryAll(By.css('adf-search-chip-input input')).map((input) => input.nativeElement); + } + + function getChipInputsLabels(): string[] { + return fixture.debugElement.queryAll(By.css('adf-search-chip-input mat-label')).map((label) => label.nativeElement.innerText); + } + + function enterNewPhrase(value: string, index: number) { + const inputs = getChipInputs(); + inputs[index].value = value; + fixture.detectChanges(); + inputs[index].dispatchEvent(new KeyboardEvent('keydown', {keyCode: 13})); + fixture.detectChanges(); + } + + function clickApplyBtn() { + const applyBtn: HTMLButtonElement = fixture.debugElement.query(By.css('[data-automation-id="logical-filter-btn-apply"]')).nativeElement; + applyBtn.click(); + fixture.detectChanges(); + } + + it('should update display value on init', () => { + spyOn(component.displayValue$, 'next'); + component.ngOnInit(); + expect(component.displayValue$.next).toHaveBeenCalledOnceWith(''); + }); + + it('should not have valid value initially', () => { + expect(component.hasValidValue()).toBeFalse(); + }); + + it('should contain 3 chip input components with correct labels', () => { + const labels = getChipInputsLabels(); + expect(labels.length).toBe(3); + expect(labels[0]).toBe('SEARCH.LOGICAL_SEARCH.MATCH_ALL_LABEL'); + expect(labels[1]).toBe('SEARCH.LOGICAL_SEARCH.MATCH_ANY_LABEL'); + expect(labels[2]).toBe('SEARCH.LOGICAL_SEARCH.EXCLUDE_LABEL'); + }); + + it('should has valid value after phrase is entered', () => { + enterNewPhrase('test', 0); + expect(component.hasValidValue()).toBeTrue(); + }); + + it('should update display value after phrases changes', () => { + spyOn(component, 'onPhraseChange').and.callThrough(); + spyOn(component.displayValue$, 'next'); + enterNewPhrase('test2', 0); + expect(component.onPhraseChange).toHaveBeenCalled(); + expect(component.displayValue$.next).toHaveBeenCalledOnceWith(` SEARCH.LOGICAL_SEARCH.${Object.keys(LogicalSearchFields)[0]}: test2`); + }); + + it('should have correct display value after each field has at least one phrase', () => { + spyOn(component, 'onPhraseChange').and.callThrough(); + spyOn(component.displayValue$, 'next'); + enterNewPhrase('test1', 0); + enterNewPhrase('test2', 1); + enterNewPhrase('test3', 2); + const displayVal1 = ` SEARCH.LOGICAL_SEARCH.${Object.keys(LogicalSearchFields)[0]}: test1`; + const displayVal2 = ` SEARCH.LOGICAL_SEARCH.${Object.keys(LogicalSearchFields)[1]}: test2`; + const displayVal3 = ` SEARCH.LOGICAL_SEARCH.${Object.keys(LogicalSearchFields)[2]}: test3`; + expect(component.onPhraseChange).toHaveBeenCalled(); + expect(component.displayValue$.next).toHaveBeenCalledWith(displayVal1 + displayVal2 + displayVal3); + }); + + it('should set correct value and update display value', () => { + spyOn(component.displayValue$, 'next'); + const searchCondition: LogicalSearchCondition = { matchAll: ['test1'], matchAny: ['test2'], exclude: ['test3'] }; + component.setValue(searchCondition); + expect(component.getCurrentValue()).toEqual(searchCondition); + expect(component.displayValue$.next).toHaveBeenCalled(); + }); + + it('should reset value and display value when reset button is clicked', () => { + const searchCondition: LogicalSearchCondition = { matchAll: ['test1'], matchAny: ['test2'], exclude: ['test3'] }; + component.setValue(searchCondition); + fixture.detectChanges(); + spyOn(component.context, 'update'); + spyOn(component.displayValue$, 'next'); + const resetBtn: HTMLButtonElement = fixture.debugElement.query(By.css('[data-automation-id="logical-filter-btn-clear"]')).nativeElement; + resetBtn.click(); + expect(component.context.queryFragments[component.id]).toBe(''); + expect(component.context.update).toHaveBeenCalled(); + expect(component.getCurrentValue()).toEqual({ matchAll: [], matchAny: [], exclude: [] }); + expect(component.displayValue$.next).toHaveBeenCalledWith(''); + }); + + it('should form correct query from match all field', () => { + spyOn(component.context, 'update'); + enterNewPhrase('test1', 0); + enterNewPhrase('test2', 0); + clickApplyBtn(); + expect(component.context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toBe('((field1:"test1" AND field1:"test2") OR (field2:"test1" AND field2:"test2"))'); + }); + + it('should form correct query from match any field', () => { + spyOn(component.context, 'update'); + enterNewPhrase('test3', 1); + enterNewPhrase('test4', 1); + clickApplyBtn(); + expect(component.context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toBe('((field1:"test3" OR field1:"test4") OR (field2:"test3" OR field2:"test4"))'); + }); + + it('should form correct query from exclude field', () => { + spyOn(component.context, 'update'); + enterNewPhrase('test5', 2); + enterNewPhrase('test6', 2); + clickApplyBtn(); + expect(component.context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toBe('((NOT field1:"test5" AND NOT field1:"test6") AND (NOT field2:"test5" AND NOT field2:"test6"))'); + }); + + it('should form correct joined query from all fields', () => { + spyOn(component.context, 'update'); + enterNewPhrase('test1', 0); + enterNewPhrase('test2', 1); + enterNewPhrase('test3', 2); + clickApplyBtn(); + const subQuery1 = '((field1:"test1") OR (field2:"test1"))'; + const subQuery2 = '((field1:"test2") OR (field2:"test2"))'; + const subQuery3 = '((NOT field1:"test3") AND (NOT field2:"test3"))'; + expect(component.context.update).toHaveBeenCalled(); + expect(component.context.queryFragments[component.id]).toBe(`${subQuery1} AND ${subQuery2} AND ${subQuery3}`); + }); +}); diff --git a/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts new file mode 100644 index 0000000000..6ae5784ff9 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-logical-filter/search-logical-filter.component.ts @@ -0,0 +1,142 @@ +/*! + * @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, OnInit, ViewEncapsulation } from '@angular/core'; +import { SearchWidget } from '../../models/search-widget.interface'; +import { SearchWidgetSettings } from '../../models/search-widget-settings.interface'; +import { SearchQueryBuilderService } from '../../services/search-query-builder.service'; +import { Subject } from 'rxjs'; +import { TranslationService } from '@alfresco/adf-core'; + +export enum LogicalSearchFields { + MATCH_ALL = 'matchAll', + MATCH_ANY = 'matchAny', + EXCLUDE = 'exclude' +} + +export type LogicalSearchConditionEnumValuedKeys = { [T in LogicalSearchFields]: string[]; }; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface LogicalSearchCondition extends LogicalSearchConditionEnumValuedKeys {} + +@Component({ + selector: 'adf-search-logical-filter', + templateUrl: './search-logical-filter.component.html', + styleUrls: ['./search-logical-filter.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SearchLogicalFilterComponent implements SearchWidget, OnInit { + private searchCondition: LogicalSearchCondition; + private reset$ = new Subject(); + + id: string; + settings?: SearchWidgetSettings; + context?: SearchQueryBuilderService; + startValue: string; + displayValue$: Subject = new Subject(); + resetObservable = this.reset$.asObservable(); + LogicalSearchFields = LogicalSearchFields; + + constructor(private translationService: TranslationService) {} + + ngOnInit(): void { + this.searchCondition = { matchAll: [], matchAny: [], exclude: [] }; + this.updateDisplayValue(); + } + + onPhraseChange(phrases: string[], field: LogicalSearchFields) { + this.searchCondition[field] = phrases; + this.updateDisplayValue(); + } + + submitValues() { + if (this.hasValidValue() && this.id && this.context && this.settings && this.settings.field) { + this.updateDisplayValue(); + const fields = this.settings.field.split(',').map((field) => field += ':'); + let query = ''; + Object.keys(this.searchCondition).forEach((key) => { + if (this.searchCondition[key].length > 0) { + let connector = ''; + let subQuery = ''; + switch(key) { + case LogicalSearchFields.MATCH_ALL: + connector = 'AND'; + break; + case LogicalSearchFields.MATCH_ANY: + connector = 'OR'; + break; + case LogicalSearchFields.EXCLUDE: + connector = 'AND NOT'; + break; + default: + break; + } + fields.forEach((field) => { + subQuery += subQuery === '' ? '' : key === LogicalSearchFields.EXCLUDE ? ' AND ' : ' OR '; + let fieldQuery = '('; + this.searchCondition[key].forEach((phrase: string) => { + const refinedPhrase = '\"' + phrase + '\"'; + fieldQuery += fieldQuery === '(' ? + `${key === LogicalSearchFields.EXCLUDE ? 'NOT ' : ''}${field}${refinedPhrase}` : + ` ${connector} ${field}${refinedPhrase}`; + }); + subQuery += `${fieldQuery})`; + }); + query += query === '' ? `(${subQuery})` : ` AND (${subQuery})`; + subQuery = ''; + } + }); + this.context.queryFragments[this.id] = query; + this.context.update(); + } + } + + hasValidValue(): boolean { + return Object.keys(this.searchCondition).some((key: string) => this.searchCondition[key].length !== 0); + } + + getCurrentValue(): LogicalSearchCondition { + return this.searchCondition; + } + + setValue(value: LogicalSearchCondition) { + this.searchCondition = value; + this.updateDisplayValue(); + } + + reset() { + if (this.id && this.context) { + this.reset$.next(); + this.context.queryFragments[this.id] = ''; + this.updateDisplayValue(); + this.context.update(); + } + } + + private updateDisplayValue(): void { + if (this.hasValidValue()) { + const displayValue = Object.keys(this.searchCondition).reduce((acc, key) => { + const fieldIndex = Object.values(LogicalSearchFields).indexOf(key as LogicalSearchFields); + const fieldKeyTranslated = this.translationService.instant(`SEARCH.LOGICAL_SEARCH.${Object.keys(LogicalSearchFields)[fieldIndex]}`); + const stackedPhrases = this.searchCondition[key].reduce((phraseAcc, phrase) => `${phraseAcc === '' ? phraseAcc : phraseAcc + ','} ${phrase}`, ''); + return stackedPhrases !== '' ? `${acc} ${fieldKeyTranslated}: ${stackedPhrases}` : acc; + }, ''); + this.displayValue$.next(displayValue); + } else { + this.displayValue$.next(''); + } + } +} diff --git a/lib/content-services/src/lib/search/public-api.ts b/lib/content-services/src/lib/search/public-api.ts index b34e6db6b2..9c799db124 100644 --- a/lib/content-services/src/lib/search/public-api.ts +++ b/lib/content-services/src/lib/search/public-api.ts @@ -60,6 +60,8 @@ export * from './components/search-form/search-form.component'; export * from './components/search-filter-chips/search-filter-chips.component'; export * from './components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component'; 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 './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 30f50391f3..0cc7f1ab0b 100644 --- a/lib/content-services/src/lib/search/search.module.ts +++ b/lib/content-services/src/lib/search/search.module.ts @@ -46,6 +46,8 @@ import { SearchFilterMenuCardComponent } from './components/search-filter-chips/ import { SearchFacetFieldComponent } from './components/search-facet-field/search-facet-field.component'; import { SearchWidgetChipComponent } from './components/search-filter-chips/search-widget-chip/search-widget-chip.component'; import { SearchFacetChipComponent } from './components/search-filter-chips/search-facet-chip/search-facet-chip.component'; +import { SearchChipInputComponent } from './components/search-chip-input/search-chip-input.component'; +import { SearchLogicalFilterComponent } from './components/search-logical-filter/search-logical-filter.component'; import { ResetSearchDirective } from './components/reset-search.directive'; @NgModule({ @@ -80,6 +82,8 @@ import { ResetSearchDirective } from './components/reset-search.directive'; SearchFacetFieldComponent, SearchWidgetChipComponent, SearchFacetChipComponent, + SearchChipInputComponent, + SearchLogicalFilterComponent, ResetSearchDirective ], exports: [ @@ -103,6 +107,8 @@ import { ResetSearchDirective } from './components/reset-search.directive'; SearchFilterChipsComponent, SearchFilterMenuCardComponent, SearchFacetFieldComponent, + SearchChipInputComponent, + SearchLogicalFilterComponent, ResetSearchDirective ], providers: [ 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 50c36731a6..70b5fa1444 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 @@ -23,6 +23,7 @@ import { SearchNumberRangeComponent } from '../components/search-number-range/se import { SearchCheckListComponent } from '../components/search-check-list/search-check-list.component'; 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'; @Injectable({ providedIn: 'root' @@ -39,7 +40,8 @@ export class SearchFilterService { 'number-range': SearchNumberRangeComponent, 'check-list': SearchCheckListComponent, 'date-range': SearchDateRangeComponent, - 'datetime-range': SearchDatetimeRangeComponent + 'datetime-range': SearchDatetimeRangeComponent, + 'logical-filter': SearchLogicalFilterComponent }; }