[ACS-5183] properties facet file size and file type (#8766)

* ACS-5183 Created component which displays form to search nodes by file type and size

* ACS-5183 Validate proper value in number input and allow to use custom file types

* ACS-5183 Corrected problem with styles, case insensitive compare for extensions

* ACS-5183 Added translations, styles for selecting type, proper comparator for types

* ACS-5183 Prevent adding custom file type when selecting existing one

* ACS-5183 Corrected bytes for each file size unit and clear number input when value is incorrect

* ACS-5183 Added documentation for search properties component, updated documentation for search chip autocomplete input and taking values from settings

* ACS-5183 Unit tests

* ACS-5183 Added automation ids

* ACS-5183 Added missing license header

* ACS-5183 Fixed lint issues

* ACS-5183 Fixed build issue
This commit is contained in:
AleksanderSklorz
2023-07-18 15:45:00 +02:00
committed by GitHub
parent cffbdd51e4
commit d70f689e06
21 changed files with 1093 additions and 39 deletions

View File

@@ -413,6 +413,22 @@
"EXCLUDE": "EXCLUDE",
"EXCLUDE_LABEL": "EXCLUDE these words",
"EXCLUDE_HINT": "Results will exclude matches with these words"
},
"SEARCH_PROPERTIES": {
"FILE_SIZE": "File Size",
"FILE_SIZE_PLACEHOLDER": "Enter file size",
"FILE_SIZE_OPERATOR": {
"AT_LEAST": "At Least",
"AT_MOST": "At Most",
"EXACTLY": "Exactly"
},
"FILE_SIZE_UNIT_ABBREVIATION": {
"KB": "KB",
"MB": "MB",
"GB": "GB"
},
"FILE_TYPE": "File Type",
"FILE_TYPE_PLACEHOLDER": "Add file type"
}
},
"PERMISSION": {

View File

@@ -21,7 +21,7 @@ import { Pipe, PipeTransform } from '@angular/core';
name: 'adfIsIncluded'
})
export class IsIncludedPipe<T> implements PipeTransform {
transform(value: T, array: T[]): boolean {
return array.includes(value);
transform(value: T, array: T[], compare?: (value1: T, value2: T) => boolean): boolean {
return compare ? array.some((arrayValue) => compare(value, arrayValue)) : array.includes(value);
}
}

View File

@@ -10,19 +10,22 @@
</button>
</mat-chip>
<input
placeholder="{{ 'SEARCH.FILTER.ACTIONS.ADD_OPTION' | translate }}"
placeholder="{{ placeholder | translate }}"
aria-controls="adf-search-chip-autocomplete"
#optionInput
[formControl]="formCtrl"
[matAutocomplete]="auto"
[matChipInputFor]="chipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[attr.aria-label]="'SEARCH.FILTER.ACTIONS.ADD_OPTION' | translate"
(matChipInputTokenEnd)="add($event)">
[attr.aria-label]="placeholder | translate"
(matChipInputTokenEnd)="add($event)"
(blur)="activeAnyOption = false"
data-automation-id="adf-search-chip-autocomplete-input">
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" id="adf-search-chip-autocomplete">
<mat-option [disabled]="option | adfIsIncluded: selectedOptions" *ngFor="let option of filteredOptions$ | async"
[ngClass]="(option | adfIsIncluded: selectedOptions) && 'adf-autocomplete-added-option'">
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)" id="adf-search-chip-autocomplete"
(optionActivated)="activeAnyOption = true" (closed)="activeAnyOption = false">
<mat-option [disabled]="option | adfIsIncluded: selectedOptions : compareOption" *ngFor="let option of filteredOptions$ | async"
[ngClass]="(option | adfIsIncluded: selectedOptions : compareOption) && 'adf-autocomplete-added-option'">
{{option}}
</mat-option>
</mat-autocomplete>

View File

@@ -22,6 +22,7 @@ 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';
import { DebugElement } from '@angular/core';
describe('SearchChipAutocompleteInputComponent', () => {
let component: SearchChipAutocompleteInputComponent;
@@ -44,16 +45,20 @@ describe('SearchChipAutocompleteInputComponent', () => {
component.autocompleteOptions = ['option1', 'option2'];
});
function getInput(): HTMLInputElement {
return fixture.debugElement.query(By.css('input')).nativeElement;
}
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'));
const inputElement = getInput();
inputElement.dispatchEvent(new Event('focusin'));
inputElement.value = value;
inputElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
}
function addNewOption(value: string) {
const inputElement = fixture.debugElement.query(By.css('input')).nativeElement;
const inputElement = getInput();
inputElement.value = value;
fixture.detectChanges();
inputElement.dispatchEvent(new KeyboardEvent('keydown', {keyCode: 13}));
@@ -68,6 +73,14 @@ describe('SearchChipAutocompleteInputComponent', () => {
return fixture.debugElement.queryAll(By.css('mat-chip span')).map((chip) => chip.nativeElement)[index].innerText;
}
function getOptionElements(): DebugElement[] {
return fixture.debugElement.queryAll(By.css('mat-option'));
}
function getAddedOptionElements(): DebugElement[] {
return fixture.debugElement.queryAll(By.css('.adf-autocomplete-added-option'));
}
it('should add new option only if value is predefined when allowOnlyPredefinedValues = true', () => {
addNewOption('test');
addNewOption('option1');
@@ -83,15 +96,25 @@ describe('SearchChipAutocompleteInputComponent', () => {
expect(getChipValue(0)).toBe('test');
});
it('should add new formatted option based on formatChipValue', () => {
component.allowOnlyPredefinedValues = false;
const option = 'abc';
component.formatChipValue = (value) => value.replace('.', '');
addNewOption(`.${option}`);
expect(getChipList().length).toBe(1);
expect(getChipValue(0)).toBe(option);
});
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');
const matOptions = getOptionElements();
expect(matOptions.length).toBe(2);
const optionToClick = matOptions[0] as HTMLElement;
const optionToClick = matOptions[0].nativeElement as HTMLElement;
optionToClick.click();
expect(optionsChangedSpy).toHaveBeenCalledOnceWith(['option1']);
@@ -103,7 +126,7 @@ describe('SearchChipAutocompleteInputComponent', () => {
addNewOption('option1');
enterNewInputValue('op');
const addedOptions = fixture.debugElement.queryAll(By.css('.adf-autocomplete-added-option'));
const addedOptions = getAddedOptionElements();
await fixture.whenStable();
@@ -111,12 +134,24 @@ describe('SearchChipAutocompleteInputComponent', () => {
expect(addedOptions.length).toBe(1);
});
it('should apply class to already selected options based on custom compareOption function', async () => {
component.allowOnlyPredefinedValues = false;
component.autocompleteOptions = ['.test1', 'test3', '.test2', 'test1.'];
component.compareOption = (option1, option2) => option1.split('.')[1] === option2;
fixture.detectChanges();
addNewOption('test1');
enterNewInputValue('t');
const addedOptions = getAddedOptionElements();
await fixture.whenStable();
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);
expect(getOptionElements().length).toBe(15);
});
it('should not add a value if same value has already been added', () => {
@@ -127,14 +162,19 @@ describe('SearchChipAutocompleteInputComponent', () => {
it('should show autocomplete list if similar predefined values exists', () => {
enterNewInputValue('op');
const matOptions = document.querySelectorAll('mat-option');
expect(matOptions.length).toBe(2);
expect(getOptionElements().length).toBe(2);
});
it('should show autocomplete list based on custom filtering', () => {
component.autocompleteOptions = ['.test1', 'test1', 'test1.', '.test2', '.test12'];
component.filter = (options, value) => options.filter((option) => option.split('.')[1] === value);
enterNewInputValue('test1');
expect(getOptionElements().length).toBe(1);
});
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);
expect(getOptionElements().length).toBe(0);
});
it('should emit new value when selected options changed', () => {
@@ -146,11 +186,21 @@ describe('SearchChipAutocompleteInputComponent', () => {
});
it('should clear the input after a new value is added', () => {
const input = fixture.debugElement.query(By.css('input')).nativeElement;
const input = getInput();
addNewOption('option1');
expect(input.value).toBe('');
});
it('should use correct default placeholder for input', () => {
expect(getInput().placeholder).toBe('SEARCH.FILTER.ACTIONS.ADD_OPTION');
});
it('should use placeholder for input passed as component input', () => {
component.placeholder = 'Some placeholder';
fixture.detectChanges();
expect(getInput().placeholder).toBe(component.placeholder);
});
it('should reset all options when onReset$ event is emitted', () => {
addNewOption('option1');
addNewOption('option2');

View File

@@ -21,7 +21,7 @@ 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';
import { map, startWith, takeUntil, tap } from 'rxjs/operators';
@Component({
selector: 'adf-search-chip-autocomplete-input',
@@ -42,6 +42,21 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy {
@Input()
allowOnlyPredefinedValues = true;
@Input()
placeholder = 'SEARCH.FILTER.ACTIONS.ADD_OPTION';
@Input()
compareOption?: (option1: string, option2: string) => boolean;
@Input()
formatChipValue?: (option: string) => string;
@Input()
filter = (options: string[], value: string): string[] => {
const filterValue = value.toLowerCase();
return options.filter(option => option.toLowerCase().includes(filterValue));
};
@Output()
optionsChanged: EventEmitter<string[]> = new EventEmitter();
@@ -50,11 +65,17 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy {
filteredOptions$: Observable<string[]>;
selectedOptions: string[] = [];
private onDestroy$ = new Subject<void>();
private _activeAnyOption = false;
set activeAnyOption(active: boolean) {
this._activeAnyOption = active;
}
constructor() {
this.filteredOptions$ = this.formCtrl.valueChanges.pipe(
startWith(null),
map((value: string | null) => (value ? this.filter(value) : []))
tap(() => this.activeAnyOption = false),
map((value: string | null) => (value ? this.filter(this.autocompleteOptions, value).slice(0, 15) : []))
);
}
@@ -68,13 +89,18 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy {
}
add(event: MatChipInputEvent) {
const value = (event.value || '').trim();
if (!this._activeAnyOption) {
let value = (event.value || '').trim();
if (this.formatChipValue) {
value = this.formatChipValue(value);
}
if (value && this.isExists(value) && !this.isAdded(value)) {
this.selectedOptions.push(value);
this.optionsChanged.emit(this.selectedOptions);
event.chipInput.clear();
this.formCtrl.setValue(null);
if (value && this.isExists(value) && !this.isAdded(value)) {
this.selectedOptions.push(value);
this.optionsChanged.emit(this.selectedOptions);
event.chipInput.clear();
this.formCtrl.setValue(null);
}
}
}
@@ -96,11 +122,6 @@ export class SearchChipAutocompleteInputComponent implements OnInit, OnDestroy {
}
}
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);
}

View File

@@ -19,7 +19,7 @@
<mat-icon>{{ chipIcon }}</mat-icon>
</mat-chip>
<mat-menu #menu="matMenu" backdropClass="adf-search-filter-chip-menu" (closed)="onClosed()">
<mat-menu #menu="matMenu" backdropClass="adf-search-filter-chip-menu" [class]="'adf-search-filter-chip-menu-panel-' + category.id" (closed)="onClosed()">
<div #menuContainer [attr.data-automation-id]="'search-field-' + category.name">
<adf-search-filter-menu-card (click)="$event.stopPropagation()"
(keydown.tab)="$event.stopPropagation();"

View File

@@ -0,0 +1,25 @@
/*!
* @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 { FileSizeOperator } from './file-size-operator.enum';
import { FileSizeUnit } from './file-size-unit.enum';
export interface FileSizeCondition {
fileSizeOperator: FileSizeOperator;
fileSize?: number;
fileSizeUnit: FileSizeUnit;
}

View File

@@ -0,0 +1,22 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export enum FileSizeOperator {
AT_LEAST = 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST',
AT_MOST = 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST',
EXACTLY = 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.EXACTLY'
}

View File

@@ -0,0 +1,24 @@
/*!
* @license
* Copyright © 2005-2023 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export class FileSizeUnit {
static readonly KB = new FileSizeUnit('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB', 1024);
static readonly MB = new FileSizeUnit('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB', 1048576);
static readonly GB = new FileSizeUnit('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.GB', 1073741824);
private constructor(readonly abbreviation: string, readonly bytes: number) {}
}

View File

@@ -0,0 +1,54 @@
<form [formGroup]="form">
<label
for="adf-search-properties-file-size"
class="adf-search-properties-file-size-label">
{{ 'SEARCH.SEARCH_PROPERTIES.FILE_SIZE' | translate }}
</label>
<mat-form-field
[style.width.px]="fileSizeOperatorsMaxWidth"
class="adf-search-properties-file-size-operator">
<mat-select
data-automation-id="adf-search-properties-file-size-operator-select"
[formControl]="form.controls.fileSizeOperator"
#fileSizeOperatorSelect>
<mat-option
*ngFor="let fileSizeOperator of fileSizeOperators"
[value]="fileSizeOperator">
{{ fileSizeOperator | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<input
[formControl]="form.controls.fileSize"
type="number"
min="0"
step="any"
(input)="narrowDownAllowedCharacters($event)"
(keydown)="preventIncorrectNumberCharacters($event)"
id="adf-search-properties-file-size"
[placeholder]="'SEARCH.SEARCH_PROPERTIES.FILE_SIZE_PLACEHOLDER' | translate"
(blur)="clearNumberFieldWhenInvalid($event)"
data-automation-id="adf-search-properties-file-size-input" />
<mat-form-field class="adf-search-properties-file-size-unit">
<mat-select
[formControl]="form.controls.fileSizeUnit"
data-automation-id="adf-search-properties-file-size-unit-select">
<mat-option
*ngFor="let fileSizeUnit of fileSizeUnits"
[value]="fileSizeUnit">
{{ fileSizeUnit.abbreviation | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<p class="adf-search-properties-file-type-label">{{ 'SEARCH.SEARCH_PROPERTIES.FILE_TYPE' | translate }}</p>
<adf-search-chip-autocomplete-input
[autocompleteOptions]="settings?.fileExtensions"
(optionsChanged)="selectedExtensions = $event"
[onReset$]="reset$"
[allowOnlyPredefinedValues]="false"
[compareOption]="compareFileExtensions"
[formatChipValue]="getExtensionWithoutDot"
[filter]="filterExtensions"
placeholder="SEARCH.SEARCH_PROPERTIES.FILE_TYPE">
</adf-search-chip-autocomplete-input>
</form>

View File

@@ -0,0 +1,89 @@
adf-search-properties {
.adf-search-properties-file-size-label {
display: block;
margin-top: 4px;
}
input {
height: 25px;
border: 1px solid var(--adf-theme-mat-grey-color-a400);
border-radius: 5px;
margin-top: 5px;
padding: 5px;
font-size: 14px;
color: var(--adf-theme-foreground-text-color);
margin-left: 8px;
margin-right: 8px;
}
.adf-search-properties-file-size-operator,
.adf-search-properties-file-size-unit {
.mat-form-field-infix {
border: 1px solid var(--adf-theme-mat-grey-color-a400);
border-radius: 5px;
padding: 9px;
}
&.mat-focused {
.mat-form-field-infix {
outline: 2px auto -webkit-focus-ring-color;
}
}
}
.mat-form-field-underline {
display: none;
}
.adf-search-properties-file-size-unit {
width: 78px;
}
adf-search-chip-autocomplete-input {
display: block;
.mat-form-field-outline {
.mat-form-field-outline {
&-start,
&-end {
border-top: 1px solid var(--adf-theme-mat-grey-color-a400);
border-bottom: 1px solid var(--adf-theme-mat-grey-color-a400);
}
&-start {
border-left: 1px solid var(--adf-theme-mat-grey-color-a400);
}
&-end {
border-right: 1px solid var(--adf-theme-mat-grey-color-a400);
}
}
}
.mat-focused {
.mat-form-field-outline {
outline: 2px auto -webkit-focus-ring-color;
}
}
.mat-form-field-appearance-outline {
.mat-form-field-outline-thick {
.mat-form-field-outline {
&-start,
&-end {
border-width: 1px;
}
}
}
}
}
.adf-search-properties-file-type-label {
margin-top: 10px;
margin-bottom: 6px;
}
}
.adf-search-filter-chip-menu-panel-properties {
max-width: unset;
}

View File

@@ -0,0 +1,432 @@
/*!
* @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 { SearchPropertiesComponent } from './search-properties.component';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { MatOption } from '@angular/material/core';
import { SearchChipAutocompleteInputComponent, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { FileSizeUnit } from './file-size-unit.enum';
import { FileSizeOperator } from './file-size-operator.enum';
import { SearchProperties } from './search-properties';
describe('SearchPropertiesComponent', () => {
let component: SearchPropertiesComponent;
let fixture: ComponentFixture<SearchPropertiesComponent>;
const clickFileSizeOperatorsSelect = () => {
fixture.debugElement.query(By.css('[data-automation-id=adf-search-properties-file-size-operator-select]')).nativeElement.click();
fixture.detectChanges();
};
const clickFileSizeUnitsSelect = () => {
fixture.debugElement.query(By.css('[data-automation-id=adf-search-properties-file-size-unit-select]')).nativeElement.click();
fixture.detectChanges();
};
const getSelectOptions = () => fixture.debugElement.queryAll(By.directive(MatOption));
const getFileSizeInput = () => fixture.debugElement.query(By.css('#adf-search-properties-file-size'));
const typeInFileSizeInput = (value = '321', data = '1'): HTMLInputElement => {
const fileSizeElement = getFileSizeInput();
fileSizeElement.nativeElement.value = value;
const event = new InputEvent('input', {
data
});
spyOnProperty(event, 'target').and.returnValue(fileSizeElement.nativeElement);
fileSizeElement.triggerEventHandler('input', event);
fixture.detectChanges();
return fileSizeElement.nativeElement;
};
const getSearchChipAutocompleteInputComponent = (): SearchChipAutocompleteInputComponent => fixture.debugElement.query(
By.directive(SearchChipAutocompleteInputComponent)
).componentInstance;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ SearchPropertiesComponent ],
imports: [ ContentTestingModule, TranslateModule.forRoot() ]
}).compileComponents();
fixture = TestBed.createComponent(SearchPropertiesComponent);
component = fixture.componentInstance;
});
describe('File size', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display correct operators for file size after opening select', () => {
clickFileSizeOperatorsSelect();
const options = getSelectOptions();
expect(options.length).toBe(3);
expect(options[0].nativeElement.innerText).toBe('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST');
expect(options[1].nativeElement.innerText).toBe('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST');
expect(options[2].nativeElement.innerText).toBe('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.EXACTLY');
});
it('should display correct units for file size after opening select', () => {
clickFileSizeUnitsSelect();
const options = getSelectOptions();
expect(options.length).toBe(3);
expect(options[0].nativeElement.innerText).toBe('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB');
expect(options[1].nativeElement.innerText).toBe('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB');
expect(options[2].nativeElement.innerText).toBe('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.GB');
});
it('should allow type digit value in file size input', () => {
const fileSizeElement = typeInFileSizeInput();
expect(fileSizeElement.value).toBe('321');
});
it('should allow type decimal value in file size input', () => {
const value = '3.21';
const fileSizeElement = typeInFileSizeInput(value);
expect(fileSizeElement.value).toBe(value);
});
it('should allow clear value in file size input', () => {
const value = '';
const fileSizeElement = typeInFileSizeInput(value);
expect(fileSizeElement.value).toBe(value);
});
it('should not allow type non digit value in file size input', () => {
const fileSizeElement = typeInFileSizeInput('e');
expect(fileSizeElement.value).toBe('');
});
it('should call preventIncorrectNumberCharacters on keydown event for file size input', () => {
spyOn(component, 'preventIncorrectNumberCharacters');
const event = new KeyboardEvent('keydown');
const fileSizeElement = getFileSizeInput();
fileSizeElement.triggerEventHandler('keydown', event);
expect(component.preventIncorrectNumberCharacters).toHaveBeenCalledWith(event);
});
});
describe('File type', () => {
let searchChipAutocompleteInputComponent: SearchChipAutocompleteInputComponent;
beforeEach(() => {
fixture.detectChanges();
searchChipAutocompleteInputComponent = getSearchChipAutocompleteInputComponent();
});
it('should set autocompleteOptions for SearchChipAutocompleteInputComponent from settings', () => {
component.settings = {
field: 'field',
fileExtensions: ['pdf', 'doc', 'txt']
};
fixture.detectChanges();
expect(searchChipAutocompleteInputComponent.autocompleteOptions).toBe(component.settings.fileExtensions);
});
it('should set onReset$ for SearchChipAutocompleteInputComponent to correct value', () => {
expect(searchChipAutocompleteInputComponent.onReset$).toBe(component.reset$);
});
it('should set allowOnlyPredefinedValues for SearchChipAutocompleteInputComponent to false', () => {
expect(searchChipAutocompleteInputComponent.allowOnlyPredefinedValues).toBeFalse();
});
it('should compare file extensions case insensitive after calling compareOption on SearchChipAutocompleteInputComponent', () => {
const option1 = 'pdf';
const option2 = 'PdF';
expect(searchChipAutocompleteInputComponent.compareOption(option1, option2)).toBeTrue();
expect(searchChipAutocompleteInputComponent.compareOption(option1, `${option2}1`)).toBeFalse();
});
it('should remove preceding dot after calling formatChipValue on SearchChipAutocompleteInputComponent', () => {
const extension = 'pdf';
expect(searchChipAutocompleteInputComponent.formatChipValue(`.${extension}`)).toBe(extension);
expect(searchChipAutocompleteInputComponent.formatChipValue(extension)).toBe(extension);
});
it('should filter file extensions case insensitive without dots after calling filter on SearchChipAutocompleteInputComponent', () => {
const extensions = ['pdf', 'jpg', 'txt', 'png'];
const searchValue = 'p';
expect(searchChipAutocompleteInputComponent.filter(extensions, searchValue)).toEqual(['pdf', 'jpg', 'png']);
expect(searchChipAutocompleteInputComponent.filter(extensions, `.${searchValue}`)).toEqual(['pdf', 'png']);
});
it('should set placeholder for SearchChipAutocompleteInputComponent to correct value', () => {
expect(searchChipAutocompleteInputComponent.placeholder).toBe('SEARCH.SEARCH_PROPERTIES.FILE_TYPE');
});
});
describe('submitValues', () => {
const sizeField = 'content.size';
const nameField = 'cm:name';
beforeEach(() => {
component.id = 'properties';
component.settings = {
field: `${sizeField},${nameField}`
};
component.context = TestBed.inject(SearchQueryBuilderService);
fixture.detectChanges();
spyOn(component.displayValue$, 'next');
spyOn(component.context, 'update');
});
it('should not search when settings is not set', () => {
component.settings = undefined;
typeInFileSizeInput();
component.submitValues();
expect(component.displayValue$.next).not.toHaveBeenCalled();
expect(component.context.queryFragments[component.id]).toBeUndefined();
expect(component.context.update).not.toHaveBeenCalled();
});
it('should not search when context is not set', () => {
component.context = undefined;
typeInFileSizeInput();
component.submitValues();
expect(component.displayValue$.next).not.toHaveBeenCalled();
});
it('should set empty search when nothing is set', () => {
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith('');
expect(component.context.queryFragments[component.id]).toBe('');
expect(component.context.update).toHaveBeenCalled();
});
it('should search by at least KB by default when any size is typed', () => {
typeInFileSizeInput();
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB');
expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[328704 TO MAX]`);
expect(component.context.update).toHaveBeenCalled();
});
it('should search by at most MB after selecting proper options', () => {
typeInFileSizeInput();
clickFileSizeOperatorsSelect();
getSelectOptions()[1].nativeElement.click();
fixture.detectChanges();
clickFileSizeUnitsSelect();
getSelectOptions()[1].nativeElement.click();
fixture.detectChanges();
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB');
expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[0 TO 336592896]`);
expect(component.context.update).toHaveBeenCalled();
});
it('should search by exactly GB after selecting proper options', () => {
typeInFileSizeInput();
clickFileSizeOperatorsSelect();
getSelectOptions()[2].nativeElement.click();
fixture.detectChanges();
clickFileSizeUnitsSelect();
getSelectOptions()[2].nativeElement.click();
fixture.detectChanges();
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.EXACTLY 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.GB');
expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[344671125504 TO 344671125504]`);
expect(component.context.update).toHaveBeenCalled();
});
it('should search by single file type', () => {
const extension = 'pdf';
getSearchChipAutocompleteInputComponent().optionsChanged.emit([extension]);
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith(extension);
expect(component.context.queryFragments[component.id]).toBe(`${nameField}:("*.${extension}")`);
expect(component.context.update).toHaveBeenCalled();
});
it('should search by multiple file types', () => {
getSearchChipAutocompleteInputComponent().optionsChanged.emit(['pdf', 'txt']);
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith('pdf, txt');
expect(component.context.queryFragments[component.id]).toBe(`${nameField}:("*.pdf" OR "*.txt")`);
expect(component.context.update).toHaveBeenCalled();
});
it('should search by file size and type', () => {
typeInFileSizeInput();
getSearchChipAutocompleteInputComponent().optionsChanged.emit(['pdf', 'txt']);
component.submitValues();
expect(component.displayValue$.next).toHaveBeenCalledWith('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_LEAST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.KB, pdf, txt');
expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[328704 TO MAX] AND ${nameField}:("*.pdf" OR "*.txt")`);
expect(component.context.update).toHaveBeenCalled();
});
});
describe('hasValidValue', () => {
it('should return true', () => {
expect(component.hasValidValue()).toBeTrue();
});
});
describe('getCurrentValue', () => {
it('should return correct value when nothing changed', () => {
expect(component.getCurrentValue()).toEqual({
fileSizeCondition: {
fileSize: null,
fileSizeUnit: FileSizeUnit.KB,
fileSizeOperator: FileSizeOperator.AT_LEAST
},
fileExtensions: undefined
});
});
it('should return correct value when inputs changed', () => {
fixture.detectChanges();
typeInFileSizeInput();
clickFileSizeOperatorsSelect();
getSelectOptions()[1].nativeElement.click();
fixture.detectChanges();
clickFileSizeUnitsSelect();
getSelectOptions()[1].nativeElement.click();
fixture.detectChanges();
const extensions = ['pdf', 'txt'];
getSearchChipAutocompleteInputComponent().optionsChanged.emit(extensions);
expect(component.getCurrentValue()).toEqual({
fileSizeCondition: {
fileSize: 321,
fileSizeUnit: FileSizeUnit.MB,
fileSizeOperator: FileSizeOperator.AT_MOST
},
fileExtensions: extensions
});
});
});
describe('reset', () => {
let searchChipAutocompleteInputComponent: SearchChipAutocompleteInputComponent;
beforeEach(() => {
fixture.detectChanges();
typeInFileSizeInput();
clickFileSizeOperatorsSelect();
getSelectOptions()[1].nativeElement.click();
fixture.detectChanges();
clickFileSizeUnitsSelect();
getSelectOptions()[1].nativeElement.click();
fixture.detectChanges();
searchChipAutocompleteInputComponent = getSearchChipAutocompleteInputComponent();
searchChipAutocompleteInputComponent.optionsChanged.emit(['pdf', 'txt']);
});
it('should reset form', () => {
spyOn(searchChipAutocompleteInputComponent.optionsChanged, 'emit');
component.reset();
fixture.detectChanges();
expect(component.form.value).toEqual({
fileSize: null,
fileSizeUnit: FileSizeUnit.KB,
fileSizeOperator: FileSizeOperator.AT_LEAST
});
expect(searchChipAutocompleteInputComponent.optionsChanged.emit).toHaveBeenCalledWith([]);
});
it('should display empty value', () => {
spyOn(component.displayValue$, 'next');
component.reset();
expect(component.displayValue$.next).toHaveBeenCalledWith('');
});
});
describe('setValue', () => {
let searchProperties: SearchProperties;
beforeEach(() => {
searchProperties = {
fileSizeCondition: {
fileSize: 321,
fileSizeUnit: FileSizeUnit.MB,
fileSizeOperator: FileSizeOperator.AT_MOST
},
fileExtensions: ['pdf', 'txt']
};
});
it('should fill form', () => {
component.setValue(searchProperties);
expect(component.form.value).toEqual(searchProperties.fileSizeCondition);
});
it('should search based on passed value', () => {
const sizeField = 'content.size';
const nameField = 'cm:name';
component.id = 'properties';
component.settings = {
field: `${sizeField},${nameField}`
};
component.context = TestBed.inject(SearchQueryBuilderService);
component.ngOnInit();
spyOn(component.displayValue$, 'next');
spyOn(component.context, 'update');
component.setValue(searchProperties);
expect(component.displayValue$.next).toHaveBeenCalledWith('SEARCH.SEARCH_PROPERTIES.FILE_SIZE_OPERATOR.AT_MOST 321 SEARCH.SEARCH_PROPERTIES.FILE_SIZE_UNIT_ABBREVIATION.MB, pdf, txt');
expect(component.context.queryFragments[component.id]).toBe(`${sizeField}:[0 TO 336592896] AND ${nameField}:("*.pdf" OR "*.txt")`);
expect(component.context.update).toHaveBeenCalled();
});
});
describe('preventIncorrectNumberCharacters', () => {
it('should prevent typing - character', () => {
expect(component.preventIncorrectNumberCharacters(new KeyboardEvent('keydown', {
key: '-'
}))).toBeFalse();
});
it('should prevent typing e character', () => {
expect(component.preventIncorrectNumberCharacters(new KeyboardEvent('keydown', {
key: 'e'
}))).toBeFalse();
});
it('should prevent typing + character', () => {
expect(component.preventIncorrectNumberCharacters(new KeyboardEvent('keydown', {
key: '+'
}))).toBeFalse();
});
it('should allow typing digit', () => {
expect(component.preventIncorrectNumberCharacters(new KeyboardEvent('keydown', {
key: '1'
}))).toBeTrue();
});
});
});

View File

@@ -0,0 +1,216 @@
/*!
* @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 { AfterViewChecked, Component, ElementRef, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { FileSizeCondition } from './file-size-condition';
import { FileSizeOperator } from './file-size-operator.enum';
import { FileSizeUnit } from './file-size-unit.enum';
import { Subject } from 'rxjs';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchProperties } from './search-properties';
import { TranslateService } from '@ngx-translate/core';
import { SearchWidget } from '../../models/search-widget.interface';
@Component({
selector: 'adf-search-properties',
templateUrl: './search-properties.component.html',
styleUrls: ['./search-properties.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SearchPropertiesComponent implements OnInit, AfterViewChecked, SearchWidget {
id: string;
settings?: SearchWidgetSettings;
context?: SearchQueryBuilderService;
startValue: SearchProperties;
displayValue$ = new Subject<string>();
private _form = this.formBuilder.nonNullable.group<FileSizeCondition>({
fileSizeOperator: FileSizeOperator.AT_LEAST,
fileSize: undefined,
fileSizeUnit: FileSizeUnit.KB
});
private _fileSizeOperators = Object.keys(FileSizeOperator).map<string>(key => FileSizeOperator[key]);
private _fileSizeUnits = [FileSizeUnit.KB, FileSizeUnit.MB, FileSizeUnit.GB];
private canvas = document.createElement('canvas');
private _fileSizeOperatorsMaxWidth: number;
private _selectedExtensions: string[];
private _reset$ = new Subject<void>();
private sizeField: string;
private nameField: string;
@ViewChild('fileSizeOperatorSelect', {read: ElementRef})
fileSizeOperatorSelectElement: ElementRef;
get form(): SearchPropertiesComponent['_form'] {
return this._form;
}
get fileSizeOperators(): string[] {
return this._fileSizeOperators;
}
get fileSizeUnits(): FileSizeUnit[] {
return this._fileSizeUnits;
}
get fileSizeOperatorsMaxWidth(): number {
return this._fileSizeOperatorsMaxWidth;
}
get reset$(): Subject<void> {
return this._reset$;
}
set selectedExtensions(extensions: string[]) {
this._selectedExtensions = extensions;
}
constructor(private formBuilder: FormBuilder, private translateService: TranslateService) {}
ngOnInit() {
if (this.settings) {
if (!this.settings.fileExtensions) {
this.settings.fileExtensions = [];
}
[this.sizeField, this.nameField] = this.settings.field.split(',');
}
if (this.startValue) {
this.setValue(this.startValue);
}
}
ngAfterViewChecked() {
if (this.fileSizeOperatorSelectElement?.nativeElement.clientWidth && !this._fileSizeOperatorsMaxWidth) {
setTimeout(() => {
const extraFreeSpace = 20;
this._fileSizeOperatorsMaxWidth = Math.max(...this._fileSizeOperators.map((operator) =>
this.getOperatorNameWidth(operator, this.getCanvasFont(this.fileSizeOperatorSelectElement.nativeElement)))) +
this.fileSizeOperatorSelectElement.nativeElement.querySelector('.mat-select-arrow-wrapper').clientWidth +
extraFreeSpace;
});
}
}
narrowDownAllowedCharacters(event: Event) {
const value = (event.target as HTMLInputElement).value;
if (!(event.target as HTMLInputElement).value) {
return;
}
if ((event as InputEvent).data !== ',' && (event as InputEvent).data !== '.') {
(event.target as HTMLInputElement).value = value.replace(/[^0-9.,]/g, '');
}
}
clearNumberFieldWhenInvalid(event: FocusEvent) {
if (!(event.target as HTMLInputElement).validity.valid) {
this.form.controls.fileSize.setValue(undefined);
}
}
preventIncorrectNumberCharacters(event: KeyboardEvent): boolean {
return event.key !== '-' && event.key !== 'e' && event.key !== '+';
}
compareFileExtensions(extension1: string, extension2: string): boolean {
return extension1.toUpperCase() === extension2.toUpperCase();
}
getExtensionWithoutDot(extension: string): string {
const extensionSplitByDot = extension.split('.');
return extensionSplitByDot[extensionSplitByDot.length - 1];
}
filterExtensions = (extensions: string[], filterValue: string): string[] => {
const filterValueLowerCase = this.getExtensionWithoutDot(filterValue).toLowerCase();
const extensionWithDot = filterValue.startsWith('.');
return extensions.filter((option) => {
const optionLowerCase = option.toLowerCase();
return extensionWithDot && filterValueLowerCase ? optionLowerCase.startsWith(filterValueLowerCase) : optionLowerCase.includes(filterValue);
});
};
reset() {
this.form.reset();
this.reset$.next();
this.displayValue$.next('');
}
submitValues() {
if (this.settings && this.context) {
let query = '';
let displayedValue = '';
if (this.form.value.fileSize !== undefined && this.form.value.fileSize !== null) {
displayedValue = `${this.translateService.instant(this.form.value.fileSizeOperator)} ${this.form.value.fileSize} ${this.translateService.instant(this.form.value.fileSizeUnit.abbreviation)}`;
const size = this.form.value.fileSize * this.form.value.fileSizeUnit.bytes;
switch (this.form.value.fileSizeOperator) {
case FileSizeOperator.AT_MOST:
query = `${this.sizeField}:[0 TO ${size}]`;
break;
case FileSizeOperator.AT_LEAST:
query = `${this.sizeField}:[${size} TO MAX]`;
break;
default:
query = `${this.sizeField}:[${size} TO ${size}]`;
}
}
if (this._selectedExtensions?.length) {
if (query) {
query += ' AND ';
displayedValue += ', ';
}
query += `${this.nameField}:("*.${this._selectedExtensions.join('" OR "*.')}")`;
displayedValue += this._selectedExtensions.join(', ');
}
this.displayValue$.next(displayedValue);
this.context.queryFragments[this.id] = query;
this.context.update();
}
}
hasValidValue(): boolean {
return true;
}
getCurrentValue(): SearchProperties {
return {
fileSizeCondition: this.form.getRawValue(),
fileExtensions: this._selectedExtensions
};
}
setValue(searchProperties: SearchProperties) {
this.form.patchValue(searchProperties.fileSizeCondition);
this.selectedExtensions = searchProperties.fileExtensions;
this.submitValues();
}
private getOperatorNameWidth(operator: string, font: string): number {
const context = this.canvas.getContext('2d');
context.font = font;
return context.measureText(this.translateService.instant(operator)).width;
}
private getCssStyle(element: HTMLElement, property: string): string {
return window.getComputedStyle(element, null).getPropertyValue(property);
}
private getCanvasFont(el: HTMLElement): string {
return `${this.getCssStyle(el, 'font-weight')} ${this.getCssStyle(el, 'font-size')} ${this.getCssStyle(el, 'font-family')}`;
}
}

View File

@@ -0,0 +1,23 @@
/*!
* @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 { FileSizeCondition } from './file-size-condition';
export interface SearchProperties {
fileSizeCondition: FileSizeCondition;
fileExtensions: string[];
}

View File

@@ -51,6 +51,7 @@ import { SearchWidgetChipComponent } from './components/search-filter-chips/sear
import { SearchFacetChipComponent } from './components/search-filter-chips/search-facet-chip/search-facet-chip.component';
import { SearchLogicalFilterComponent } from './components/search-logical-filter/search-logical-filter.component';
import { ResetSearchDirective } from './components/reset-search.directive';
import { SearchPropertiesComponent } from './components/search-properties/search-properties.component';
@NgModule({
imports: [
@@ -88,7 +89,8 @@ import { ResetSearchDirective } from './components/reset-search.directive';
SearchWidgetChipComponent,
SearchFacetChipComponent,
SearchLogicalFilterComponent,
ResetSearchDirective
ResetSearchDirective,
SearchPropertiesComponent
],
exports: [
SearchComponent,

View File

@@ -25,6 +25,7 @@ import { SearchDateRangeComponent } from '../components/search-date-range/search
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';
import { SearchPropertiesComponent } from '../components/search-properties/search-properties.component';
@Injectable({
providedIn: 'root'
@@ -38,6 +39,7 @@ export class SearchFilterService {
text: SearchTextComponent,
radio: SearchRadioComponent,
slider: SearchSliderComponent,
properties: SearchPropertiesComponent,
'number-range': SearchNumberRangeComponent,
'check-list': SearchCheckListComponent,
'date-range': SearchDateRangeComponent,