mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-05-12 17:04:57 +00:00
[ACS-5181] Logical search components (#8616)
* [ACS-5181] Add logical search component * [ACS-5181] Add unit tests to logical search components * [ACS-5181] Logical filter components docs * [ACS-5181] CR fixes * [ACS-5181] CR and accessibility fixes
This commit is contained in:
parent
9845b1e2a0
commit
85fd98874a
@ -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) |
|
||||
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## Basic usage
|
||||
|
||||
```html
|
||||
<adf-search-chip-input
|
||||
[label]="'Some label'"
|
||||
[onReset]="onResetObservable"
|
||||
(phrasesChanged)="handlePhraseChanged($event)">
|
||||
</adf-search-chip-input>
|
||||
```
|
||||
|
||||
### 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)`<void>` | | 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)`<string[]>` | 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)
|
@ -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.
|
||||
|
||||

|
||||
|
||||
## 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)
|
BIN
docs/docassets/images/search-chip-input.png
Normal file
BIN
docs/docassets/images/search-chip-input.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
BIN
docs/docassets/images/search-logical-filter.png
Normal file
BIN
docs/docassets/images/search-logical-filter.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 64 KiB |
@ -47,6 +47,8 @@ backend services have been tested with each released version of ADF.
|
||||
<!--v610 start-->
|
||||
|
||||
- [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)
|
||||
|
||||
<!--v610 end-->
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -0,0 +1,15 @@
|
||||
<mat-label>{{label | translate}}</mat-label>
|
||||
<mat-chip-list #chipList [attr.aria-label]="label | translate">
|
||||
<mat-chip *ngFor="let phrase of phrases; let i = index" (removed)="removePhrase(i)">
|
||||
<span>{{phrase}}</span>
|
||||
<button matChipRemove [attr.aria-label]="('SEARCH.FILTER.BUTTONS.REMOVE' | translate) + ' ' + phrase">
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</button>
|
||||
</mat-chip>
|
||||
<input placeholder="{{ 'SEARCH.LOGICAL_SEARCH.SEARCH_CHIP_INPUT.ADD_PHRASE' | translate }}"
|
||||
[attr.aria-label]="'SEARCH.LOGICAL_SEARCH.SEARCH_CHIP_INPUT.ADD_PHRASE' | translate"
|
||||
[matChipInputFor]="chipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
[matChipInputAddOnBlur]="addOnBlur"
|
||||
(matChipInputTokenEnd)="addPhrase($event)" />
|
||||
</mat-chip-list>
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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<SearchChipInputComponent>;
|
||||
const onResetSubject = new Subject<void>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
@ -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<void>;
|
||||
|
||||
@Output()
|
||||
phrasesChanged: EventEmitter<string[]> = new EventEmitter();
|
||||
|
||||
private onDestroy$ = new Subject<void>();
|
||||
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);
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
<div class="adf-search-logical-filter-container">
|
||||
<adf-search-chip-input
|
||||
[label]="'SEARCH.LOGICAL_SEARCH.MATCH_ALL_LABEL' | translate"
|
||||
[onReset]="resetObservable"
|
||||
(phrasesChanged)="onPhraseChange($event, LogicalSearchFields.MATCH_ALL)">
|
||||
</adf-search-chip-input>
|
||||
<adf-search-chip-input
|
||||
[label]="'SEARCH.LOGICAL_SEARCH.MATCH_ANY_LABEL' | translate"
|
||||
[onReset]="resetObservable"
|
||||
(phrasesChanged)="onPhraseChange($event, LogicalSearchFields.MATCH_ANY)">
|
||||
</adf-search-chip-input>
|
||||
<adf-search-chip-input
|
||||
[label]="'SEARCH.LOGICAL_SEARCH.EXCLUDE_LABEL' | translate"
|
||||
[onReset]="resetObservable"
|
||||
(phrasesChanged)="onPhraseChange($event, LogicalSearchFields.EXCLUDE)">
|
||||
</adf-search-chip-input>
|
||||
|
||||
<div class="adf-facet-buttons" *ngIf="!settings?.hideDefaultAction">
|
||||
<button mat-button color="primary" data-automation-id="logical-filter-btn-clear" (click)="reset()">
|
||||
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
|
||||
</button>
|
||||
<button mat-button color="primary" data-automation-id="logical-filter-btn-apply" (click)="submitValues()">
|
||||
{{ 'SEARCH.FILTER.ACTIONS.APPLY' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,4 @@
|
||||
.adf-search-logical-filter-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
@ -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<SearchLogicalFilterComponent>;
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
@ -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<void>();
|
||||
|
||||
id: string;
|
||||
settings?: SearchWidgetSettings;
|
||||
context?: SearchQueryBuilderService;
|
||||
startValue: string;
|
||||
displayValue$: Subject<string> = new Subject<string>();
|
||||
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('');
|
||||
}
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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: [
|
||||
|
@ -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
|
||||
};
|
||||
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user