[ACA-4486] support search widget chips layout (#7122)

* [ACA-4486] support search widget chips layout

* * revert to old config

* * resolved rebase conflicts

* [ci:force] force e2e

* [ci:force] docs update and remove directive added

* [ci:force] config updated

* [ci:force] add missing app config schema to prod build
This commit is contained in:
Dharan
2021-06-25 14:24:12 +05:30
committed by GitHub
parent 87be0b0b70
commit 26d180e661
97 changed files with 3622 additions and 1272 deletions

View File

@@ -50,7 +50,7 @@ import { CustomResourcesService } from '../document-list/services/custom-resourc
import { NodeEntryEvent, ShareDataRow } from '../document-list';
import { Subject } from 'rxjs';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../search/search-query-service.token';
import { SearchQueryBuilderService } from '../search/search-query-builder.service';
import { SearchQueryBuilderService } from '../search/services/search-query-builder.service';
import { ContentNodeSelectorPanelService } from './content-node-selector-panel.service';
export type ValidationFunction = (entry: Node) => boolean;

View File

@@ -29,7 +29,7 @@ import { CoreModule } from '@alfresco/adf-core';
import { DocumentListModule } from '../document-list/document-list.module';
import { NameLocationCellComponent } from './name-location-cell/name-location-cell.component';
import { UploadModule } from '../upload/upload.module';
import { SearchQueryBuilderService } from '../search/search-query-builder.service';
import { SearchQueryBuilderService } from '../search/services/search-query-builder.service';
import { ContentDirectiveModule } from '../directives/content-directive.module';
@NgModule({

View File

@@ -21,7 +21,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { SearchService, setupTestBed, DataTableComponent, DataSorting } from '@alfresco/adf-core';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { SimpleChange } from '@angular/core';
import { SearchHeaderQueryBuilderService } from './../../../search/search-header-query-builder.service';
import { SearchHeaderQueryBuilderService } from './../../../search/services/search-header-query-builder.service';
import { SEARCH_QUERY_SERVICE_TOKEN } from './../../../search/search-query-service.token';
import { DocumentListComponent } from './../document-list.component';
import { FilterHeaderComponent } from './filter-header.component';

View File

@@ -19,7 +19,7 @@ import { Component, Inject, OnInit, OnChanges, SimpleChanges, Input, Output, Eve
import { PaginationModel, DataSorting } from '@alfresco/adf-core';
import { DocumentListComponent } from '../document-list.component';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../../search/search-query-service.token';
import { SearchHeaderQueryBuilderService } from '../../../search/search-header-query-builder.service';
import { SearchHeaderQueryBuilderService } from '../../../search/services/search-header-query-builder.service';
import { FilterSearch } from './../../../search/models/filter-search.interface';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

View File

@@ -223,14 +223,17 @@
},
"FILTER": {
"ACTIONS": {
"SEARCH": "Search",
"CLEAR": "Clear",
"APPLY": "Apply",
"CLEAR-ALL": "Clear all",
"SHOW-MORE": "Show more",
"SHOW-LESS": "Show less",
"FILTER-CATEGORY": "Filter category"
"SHOW-LESS": "Show less"
},
"BUTTONS": {
"CLOSE": "Close",
"REMOVE": "Remove",
"APPLY": "Apply",
"CLEAR-ALL": {
"LABEL": "Clear all",
"TOOLTIP": "This will remove all selections"
@@ -290,8 +293,7 @@
}
}
},
"FORMS": "Search Forms",
"UNKNOWN_FORM": "Unknown Configuration",
"UNKNOWN_CONFIGURATION": "Unknown Configuration",
"SEARCH_HEADER" : {
"TITLE":"Filter",
"TYPE": "Type",

View File

@@ -15,6 +15,8 @@
* limitations under the License.
*/
import { SearchCategory } from '../search';
export const expandableCategories = [
{
id: 'cat-1',
@@ -68,7 +70,7 @@ export const expandedCategories = [
}
];
export const simpleCategories = [
export const simpleCategories: SearchCategory[] = [
{
id: 'queryName',
name: 'Name',
@@ -76,7 +78,9 @@ export const simpleCategories = [
enabled: true,
component: {
selector: 'text',
settings: {}
settings: {
field: ''
}
}
},
{
@@ -87,7 +91,7 @@ export const simpleCategories = [
component: {
selector: 'check-list',
settings: {
'field': null,
'field': 'check-list',
'pageSize': 5,
'options': [
{ 'name': 'Folder', 'value': "TYPE:'cm:folder'" },
@@ -624,6 +628,7 @@ export const mockContentSizeResponseBucket = {
};
export function getMockSearchResultWithResponseBucket() {
mockSearchResult.list.context.facets[3].buckets.push(mockContentSizeResponseBucket);
return mockSearchResult;
const cloneResult = JSON.parse(JSON.stringify( mockSearchResult));
cloneResult.list.context.facets[3].buckets.push(mockContentSizeResponseBucket);
return cloneResult;
}

View File

@@ -0,0 +1,58 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { setupTestBed } from '@alfresco/adf-core';
import { TranslateModule } from '@ngx-translate/core';
import { ContentTestingModule } from '../../testing/content.testing.module';
import { SearchFacetFiltersService } from '../services/search-facet-filters.service';
import { SearchQueryBuilderService } from '../services/search-query-builder.service';
@Component({
template: `<button adf-reset-search></button>`
})
class TestComponent {
}
describe('Directive: ResetSearchDirective', () => {
let fixture: ComponentFixture<TestComponent>;
let searchFacetFiltersService: SearchFacetFiltersService;
let queryBuilder: SearchQueryBuilderService;
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
declarations: [TestComponent]
});
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
searchFacetFiltersService = TestBed.inject(SearchFacetFiltersService);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
});
it('should reset the search on click', () => {
spyOn(queryBuilder, 'resetToDefaults');
searchFacetFiltersService.responseFacets = [ { type: 'field', label: 'f1' } ] as any;
fixture.nativeElement.querySelector('button').click();
expect(searchFacetFiltersService.responseFacets).toEqual([]);
expect(queryBuilder.resetToDefaults).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { Directive, HostListener } from '@angular/core';
import { SearchFacetFiltersService } from '../services/search-facet-filters.service';
@Directive({
selector: '[adf-reset-search]'
})
export class ResetSearchDirective {
@HostListener('click')
onClick() {
this.filterService.reset();
}
constructor(private filterService: SearchFacetFiltersService) { }
}

View File

@@ -6,8 +6,7 @@
[attr.data-automation-id]="'checkbox-' + (option.name)"
(change)="changeHandler($event, option)"
class="adf-facet-filter">
<div
matTooltip="{{ option.name | translate }}"
<div matTooltip="{{ option.name | translate }}"
matTooltipPosition="right"
class="facet-name">
{{ option.name | translate }}
@@ -15,9 +14,9 @@
</mat-checkbox>
</div>
<div class="adf-facet-buttons" *ngIf="options.fitsPage">
<button mat-button color="primary" (click)="reset()">
<div class="adf-facet-buttons" *ngIf="options.fitsPage && !settings?.hideDefaultAction">
<button mat-button color="primary" (click)="clear()">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR-ALL' | translate }}
</button>
</div>
@@ -25,7 +24,7 @@
<div class="adf-facet-buttons" *ngIf="!options.fitsPage">
<button mat-icon-button
title="{{ 'SEARCH.FILTER.ACTIONS.CLEAR-ALL' | translate }}"
(click)="reset()">
(click)="clear()">
<mat-icon>clear</mat-icon>
</button>
<button mat-icon-button

View File

@@ -15,12 +15,14 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit } from '@angular/core';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchFilterList } from '../../models/search-filter-list.model';
import { TranslationService } from '@alfresco/adf-core';
import { Subject } from 'rxjs';
export interface SearchListOption {
name: string;
@@ -46,8 +48,9 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
pageSize = 5;
isActive = false;
enableChangeUpdate = true;
displayValue$: Subject<string> = new Subject<string>();
constructor() {
constructor(private translationService: TranslationService) {
this.options = new SearchFilterList<SearchListOption>();
}
@@ -59,11 +62,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
if (this.settings.options && this.settings.options.length > 0) {
this.options = new SearchFilterList(this.settings.options, this.pageSize);
}
if (this.settings.allowUpdateOnChange !== undefined &&
this.settings.allowUpdateOnChange !== null) {
this.enableChangeUpdate = this.settings.allowUpdateOnChange;
}
this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true;
}
if (this.startValue) {
@@ -71,18 +70,42 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
}
}
reset() {
clear() {
this.isActive = false;
this.clearOptions();
if (this.id && this.context && this.enableChangeUpdate) {
this.updateDisplayValue();
this.context.update();
}
}
clearOptions() {
this.options.items.forEach((opt) => {
opt.checked = false;
});
if (this.id && this.context) {
this.context.queryFragments[this.id] = '';
}
}
reset() {
this.isActive = false;
this.clearOptions();
if (this.id && this.context) {
this.updateDisplayValue();
this.context.update();
}
}
updateDisplayValue(): void {
const displayValue = this.options.items
.filter((option) => option.checked)
.map(({name}) => this.translationService.instant(name))
.join(', ');
this.displayValue$.next(displayValue);
}
changeHandler(event: MatCheckboxChange, option: any) {
option.checked = event.checked;
const checkedValues = this.getCheckedValues();
@@ -118,6 +141,7 @@ export class SearchCheckListComponent implements SearchWidget, OnInit {
const query = checkedValues.join(` ${this.operator} `);
if (this.id && this.context) {
this.context.queryFragments[this.id] = query;
this.updateDisplayValue();
this.context.update();
}
}

View File

@@ -1,20 +1,20 @@
<mat-chip-list>
<ng-container *ngIf="searchFilter && searchFilter.selectedBuckets.length">
<mat-chip *ngIf="clearAll && searchFilter.selectedBuckets.length > 1"
<ng-container *ngIf="facetFiltersService.selectedBuckets.length">
<mat-chip *ngIf="clearAll && facetFiltersService.selectedBuckets.length > 1"
data-automation-id="reset-filter"
color="primary"
selected
matTooltip="{{ 'SEARCH.FILTER.BUTTONS.CLEAR-ALL.TOOLTIP' | translate }}"
matTooltipPosition="right"
(click)="searchFilter.resetAllSelectedBuckets()">
(click)="facetFiltersService.resetAllSelectedBuckets()">
{{ 'SEARCH.FILTER.BUTTONS.CLEAR-ALL.LABEL' | translate }}
</mat-chip>
<mat-chip
data-automation-id="chip-list-entry"
*ngFor="let selection of searchFilter.selectedBuckets"
*ngFor="let selection of facetFiltersService.selectedBuckets"
[removable]="true"
(removed)="searchFilter.unselectFacetBucket(selection.field, selection.bucket)">
(removed)="facetFiltersService.unselectFacetBucket(selection.field, selection.bucket)">
{{ (selection.bucket.display || selection.bucket.label) | translate }}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>

View File

@@ -16,10 +16,9 @@
*/
import { Component } from '@angular/core';
import { setupTestBed } from '@alfresco/adf-core';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SelectedBucket } from '../search-filter/search-filter.component';
import { SearchFacetFiltersService, SelectedBucket } from '../../services/search-facet-filters.service';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { TranslateModule } from '@ngx-translate/core';
@@ -43,20 +42,21 @@ class TestComponent {
describe('SearchChipListComponent', () => {
let fixture: ComponentFixture<TestComponent>;
let component: TestComponent;
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
declarations: [
TestComponent
]
});
let searchFacetFiltersService: SearchFacetFiltersService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
declarations: [
TestComponent
]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
searchFacetFiltersService = TestBed.inject(SearchFacetFiltersService);
});
it('should display clear button only when entries present', () => {
@@ -65,7 +65,7 @@ describe('SearchChipListComponent', () => {
let clearButton = fixture.debugElement.query(By.css(`[data-automation-id="reset-filter"]`));
expect(clearButton).toBeNull();
component.searchFilter.selectedBuckets = [{
searchFacetFiltersService.selectedBuckets = [{
bucket: {
count: 1,
label: 'test',
@@ -79,7 +79,7 @@ describe('SearchChipListComponent', () => {
});
it('should reflect changes in the search filter', () => {
const selectedBuckets = component.searchFilter.selectedBuckets;
const selectedBuckets = searchFacetFiltersService.selectedBuckets;
fixture.detectChanges();
let chips = fixture.debugElement.queryAll(By.css(`[data-automation-id="chip-list-entry"]`));
@@ -100,9 +100,9 @@ describe('SearchChipListComponent', () => {
});
it('should remove the entry upon remove button click', async () => {
spyOn(component.searchFilter, 'unselectFacetBucket').and.callThrough();
spyOn(searchFacetFiltersService, 'unselectFacetBucket').and.callThrough();
component.searchFilter.selectedBuckets = [
searchFacetFiltersService.selectedBuckets = [
{
bucket: {
count: 1,
@@ -119,15 +119,15 @@ describe('SearchChipListComponent', () => {
chips[0].nativeElement.click();
await fixture.whenStable();
expect(component.searchFilter.unselectFacetBucket).toHaveBeenCalled();
expect(searchFacetFiltersService.unselectFacetBucket).toHaveBeenCalled();
});
it('should remove items from the search filter on clear button click', () => {
spyOn(component.searchFilter, 'unselectFacetBucket').and.stub();
spyOn(searchFacetFiltersService, 'unselectFacetBucket').and.stub();
const selectedBucket1: any = { field: { id: 1 }, bucket: {label: 'bucket1'} };
const selectedBucket2: any = { field: { id: 2 }, bucket: {label: 'bucket2'} };
component.searchFilter.selectedBuckets = [selectedBucket1, selectedBucket2];
searchFacetFiltersService.selectedBuckets = [selectedBucket1, selectedBucket2];
fixture.detectChanges();
@@ -137,14 +137,14 @@ describe('SearchChipListComponent', () => {
closeButtons[0].click();
fixture.detectChanges();
expect(component.searchFilter.unselectFacetBucket).toHaveBeenCalledWith(selectedBucket1.field, selectedBucket1.bucket);
expect(searchFacetFiltersService.unselectFacetBucket).toHaveBeenCalledWith(selectedBucket1.field, selectedBucket1.bucket);
});
it('should disable clear mode via input properties', () => {
spyOn(component.searchFilter, 'unselectFacetBucket').and.callThrough();
component.allowClear = false;
component.searchFilter.selectedBuckets = [
searchFacetFiltersService.selectedBuckets = [
{
bucket: {
count: 1,

View File

@@ -17,6 +17,7 @@
import { Component, ViewEncapsulation, Input } from '@angular/core';
import { SearchFilterComponent } from '../../components/search-filter/search-filter.component';
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
@Component({
selector: 'adf-search-chip-list',
@@ -26,11 +27,13 @@ import { SearchFilterComponent } from '../../components/search-filter/search-fil
})
export class SearchChipListComponent {
/** Search filter to supply the data for the chips. */
@Input()
/** @deprecated This is not required since ADF 4.5.0 */
searchFilter: SearchFilterComponent;
/** Flag used to enable the display of a clear-all-filters button. */
@Input()
clearAll: boolean = false;
constructor(public facetFiltersService: SearchFacetFiltersService) {}
}

View File

@@ -34,8 +34,8 @@
</mat-error>
</mat-form-field>
<div class="adf-facet-buttons adf-facet-buttons--topSpace">
<button mat-button color="primary" type="button" (click)="reset()" data-automation-id="date-range-clear-btn">
<div class="adf-facet-buttons adf-facet-buttons--topSpace" *ngIf="!settings?.hideDefaultAction">
<button mat-button color="primary" type="button" (click)="clear()" data-automation-id="date-range-clear-btn">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
<button mat-button color="primary" type="submit" [disabled]="!form.valid" data-automation-id="date-range-apply-btn">

View File

@@ -15,14 +15,19 @@
* limitations under the License.
*/
import { OnInit, Component, ViewEncapsulation, OnDestroy } from '@angular/core';
import { FormControl, Validators, FormGroup } from '@angular/forms';
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core';
import { MomentDateAdapter, MOMENT_DATE_FORMATS, UserPreferencesService, UserPreferenceValues } from '@alfresco/adf-core';
import {
MOMENT_DATE_FORMATS,
MomentDateAdapter,
UserPreferencesService,
UserPreferenceValues
} from '@alfresco/adf-core';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { LiveErrorStateMatcher } from '../../forms/live-error-state-matcher';
import { Moment } from 'moment';
import { Subject } from 'rxjs';
@@ -64,6 +69,8 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy
fromMaxDate: any;
isActive = false;
startValue: any;
enableChangeUpdate: boolean;
displayValue$: Subject<string> = new Subject<string>();
private onDestroy$ = new Subject<boolean>();
@@ -126,6 +133,7 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy
});
this.setFromMaxDate();
this.enableChangeUpdate = this.settings?.allowUpdateOnChange ?? true;
}
ngOnDestroy() {
@@ -141,6 +149,8 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy
const end = moment(model.to).endOf('day').format();
this.context.queryFragments[this.id] = `${this.settings.field}:['${start}' TO '${end}']`;
this.updateDisplayValue();
this.context.update();
}
}
@@ -160,6 +170,14 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy
};
}
updateDisplayValue(): void {
if (this.form.invalid || this.form.pristine) {
this.displayValue$.next('');
} else {
this.displayValue$.next(`${this.dateAdapter.format(this.form.value.from, this.datePickerFormat)} - ${this.dateAdapter.format(this.form.value.to, this.datePickerFormat)}`);
}
}
setValue(parsedDate: string) {
const splitValue = parsedDate.split('||');
const fromValue = this.dateAdapter.parse(splitValue[0], this.datePickerFormat);
@@ -172,20 +190,34 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy
this.to.markAsTouched();
this.submitValues();
}
reset() {
clear() {
this.isActive = false;
this.form.reset({
from: '',
to: ''
});
if (this.id && this.context) {
this.context.queryFragments[this.id] = '';
this.context.update();
if (this.enableChangeUpdate) {
this.updateQuery();
}
}
this.setFromMaxDate();
}
reset() {
this.clear();
this.updateQuery();
}
private updateQuery() {
if (this.id && this.context) {
this.updateDisplayValue();
this.context.update();
}
}
onChangedHandler(event: any, formControl: FormControl) {
const inputValue = event.value;

View File

@@ -34,8 +34,8 @@
</mat-error>
</mat-form-field>
<div class="adf-facet-buttons adf-facet-buttons--topSpace">
<button mat-button color="primary" type="button" (click)="reset()" data-automation-id="datetime-range-clear-btn">
<div class="adf-facet-buttons adf-facet-buttons--topSpace" *ngIf="!settings?.hideDefaultAction">
<button mat-button color="primary" type="button" (click)="clear()" data-automation-id="datetime-range-clear-btn">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
<button mat-button color="primary" type="submit" [disabled]="!form.valid" data-automation-id="datetime-range-apply-btn">

View File

@@ -15,13 +15,13 @@
* limitations under the License.
*/
import { OnInit, Component, ViewEncapsulation, OnDestroy } from '@angular/core';
import { FormControl, Validators, FormGroup } from '@angular/forms';
import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { UserPreferencesService, UserPreferenceValues } from '@alfresco/adf-core';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { LiveErrorStateMatcher } from '../../forms/live-error-state-matcher';
import { Moment } from 'moment';
import { Subject } from 'rxjs';
@@ -64,6 +64,8 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDes
fromMaxDatetime: any;
isActive = false;
startValue: any;
enableChangeUpdate: boolean;
displayValue$: Subject<string> = new Subject<string>();
private onDestroy$ = new Subject<boolean>();
@@ -119,6 +121,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDes
});
this.setFromMaxDatetime();
this.enableChangeUpdate = this.settings?.allowUpdateOnChange ?? true;
}
ngOnDestroy() {
@@ -134,6 +137,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDes
const end = moment.utc(model.to).endOf('minute').format();
this.context.queryFragments[this.id] = `${this.settings.field}:['${start}' TO '${end}']`;
this.updateDisplayValue();
this.context.update();
}
}
@@ -149,10 +153,18 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDes
getCurrentValue(): DatetimeRangeValue {
return {
from: this.dateAdapter.format(this.form.value.from, this.datetimePickerFormat),
to: this.dateAdapter.format(this.form.value.from, this.datetimePickerFormat)
to: this.dateAdapter.format(this.form.value.to, this.datetimePickerFormat)
};
}
updateDisplayValue(): void {
if (this.form.invalid || this.form.pristine) {
this.displayValue$.next('');
} else {
this.displayValue$.next(`${this.dateAdapter.format(this.form.value.from, this.datetimePickerFormat)} - ${this.dateAdapter.format(this.form.value.to, this.datetimePickerFormat)}`);
}
}
setValue(parsedDate: string) {
const splitValue = parsedDate.split('||');
const fromValue = this.dateAdapter.parse(splitValue[0], this.datetimePickerFormat);
@@ -166,7 +178,7 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDes
this.submitValues();
}
reset() {
clear() {
this.isActive = false;
this.form.reset({
from: '',
@@ -174,11 +186,26 @@ export class SearchDatetimeRangeComponent implements SearchWidget, OnInit, OnDes
});
if (this.id && this.context) {
this.context.queryFragments[this.id] = '';
this.context.update();
}
if (this.id && this.context && this.enableChangeUpdate) {
this.updateQuery();
}
this.setFromMaxDatetime();
}
reset() {
this.clear();
this.updateQuery();
}
private updateQuery() {
if (this.id && this.context) {
this.updateDisplayValue();
this.context.update();
}
}
onChangedHandler(event: any, formControl: FormControl) {
const inputValue = event.value;

View File

@@ -0,0 +1,49 @@
<div class="adf-search-filter-facet">
<div class="adf-facet-result-filter">
<div class="adf-facet-search-container">
<button mat-icon-button class="adf-facet-search-icon" tabindex="-1">
<mat-icon>search</mat-icon>
</button>
<mat-form-field class="adf-facet-search-field" floatLabel="never">
<input matInput placeholder="{{ 'SEARCH.FILTER.ACTIONS.SEARCH' | translate }}"
[attr.data-automation-id]="'facet-result-filter-'+field.label" [(ngModel)]="field.buckets.filterText">
<button *ngIf="field.buckets.filterText" mat-button matSuffix mat-icon-button
(click)="field.buckets.filterText = ''">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</div>
</div>
<div class="adf-checklist">
<mat-checkbox *ngFor="let bucket of field.buckets" [checked]="bucket.checked"
[attr.data-automation-id]="'checkbox-'+field.label+'-'+(bucket.display || bucket.label)"
(change)="onToggleBucket($event, field, bucket)">
<div matTooltip="{{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}"
matTooltipPosition="right" class="adf-facet-label">
{{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}
</div>
</mat-checkbox>
</div>
<div class="adf-facet-buttons" *ngIf="field.buckets.fitsPage && !field.settings?.hideDefaultAction">
<button *ngIf="canResetSelectedBuckets(field)" mat-button color="primary" (click)="resetSelectedBuckets(field)">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR-ALL' | translate }}
</button>
</div>
<div class="adf-facet-buttons" *ngIf="!field.buckets.fitsPage">
<button mat-icon-button *ngIf="canResetSelectedBuckets(field)"
title="{{ 'SEARCH.FILTER.ACTIONS.CLEAR-ALL' | translate }}" (click)="resetSelectedBuckets(field)">
<mat-icon>clear</mat-icon>
</button>
<button mat-icon-button *ngIf="field.buckets.canShowLessItems" (click)="field.buckets.showLessItems()"
title="{{ 'SEARCH.FILTER.ACTIONS.SHOW-LESS' | translate }}">
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
<button mat-icon-button *ngIf="field.buckets.canShowMoreItems" (click)="field.buckets.showMoreItems()"
title="{{ 'SEARCH.FILTER.ACTIONS.SHOW-MORE' | translate }}">
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
</div>
</div>

View File

@@ -0,0 +1,89 @@
@mixin adf-search-filter-field-theme($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
.adf-search-filter-facet {
.adf-checklist {
display: flex;
flex-direction: column;
.mat-checkbox-label {
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
}
.mat-checkbox-layout {
width: 100%;
}
.adf-facet-label {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mat-checkbox {
margin: 5px;
&.mat-checkbox-checked .mat-checkbox-label {
font-weight: bold;
}
}
}
.adf-facet-result-filter {
padding-bottom: 16px;
.adf-facet-search-container {
border-radius: 6px;
background: mat-color($background, background);
display: flex;
height: 32px;
.adf-facet-search-icon {
width: 27px;
margin-top: -4px;
.mat-icon {
font-size: 15px;
}
}
.adf-facet-search-field {
padding: 2px;
flex: 1;
margin-top: -16px;
font-size: 14px;
line-height: 24px;
letter-spacing: 0.25px;
.mat-form-field-underline {
display: none;
}
.mat-form-field-suffix {
padding-right: 1px;
}
}
}
}
.adf-facet-buttons {
text-align: right;
.mat-button {
text-transform: uppercase;
}
&--topSpace {
padding-top: 15px;
}
}
.mat-checkbox-label,
.mat-radio-label {
color: mat-color($foreground, text, 0.54);
}
}
}

View File

@@ -0,0 +1,195 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { SearchFacetFieldComponent } from './search-facet-field.component';
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { FacetField } from '../../models/facet-field.interface';
import { FacetFieldBucket } from '../../models/facet-field-bucket.interface';
import { SearchFilterList } from '../../models/search-filter-list.model';
import { TranslateModule } from '@ngx-translate/core';
describe('SearchFacetFieldComponent', () => {
let component: SearchFacetFieldComponent;
let fixture: ComponentFixture<SearchFacetFieldComponent>;
let searchFacetFiltersService: SearchFacetFiltersService;
let queryBuilder: SearchQueryBuilderService;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
]
});
searchFacetFiltersService = TestBed.inject(SearchFacetFiltersService);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetFieldComponent);
component = fixture.componentInstance;
spyOn(searchFacetFiltersService, 'updateSelectedBuckets').and.stub();
});
it('should update bucket model and query builder on facet toggle', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'addUserFacetBucket').and.callThrough();
const event: any = { checked: true };
const field: FacetField = { field: 'f1', label: 'f1', buckets: new SearchFilterList() };
const bucket: FacetFieldBucket = { checked: false, filterQuery: 'q1', label: 'q1', count: 1 };
component.field = field;
fixture.detectChanges();
component.onToggleBucket(event, field, bucket);
expect(bucket.checked).toBeTruthy();
expect(queryBuilder.addUserFacetBucket).toHaveBeenCalledWith(field, bucket);
expect(queryBuilder.update).toHaveBeenCalled();
expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled();
});
it('should update bucket model and query builder on facet un-toggle', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const event: any = { checked: false };
const field: FacetField = { field: 'f1', label: 'f1', buckets: new SearchFilterList() };
const bucket: FacetFieldBucket = { checked: true, filterQuery: 'q1', label: 'q1', count: 1 };
component.field = field;
fixture.detectChanges();
component.onToggleBucket(event, field, bucket);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(field, bucket);
expect(queryBuilder.update).toHaveBeenCalled();
expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled();
});
it('should unselect facet query and update builder', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const event: any = { checked: false };
const query = { checked: true, label: 'q1', filterQuery: 'query1' };
const field = { field: 'q1', type: 'query', label: 'label1', buckets: new SearchFilterList([ query ] ) } as FacetField;
component.field = field;
fixture.detectChanges();
component.onToggleBucket(event, <any> field, <any> query);
expect(query.checked).toEqual(false);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(field, query);
expect(queryBuilder.update).toHaveBeenCalled();
expect(searchFacetFiltersService.updateSelectedBuckets).toHaveBeenCalled();
});
it('should update query builder only when has bucket to unselect', () => {
spyOn(queryBuilder, 'update').and.stub();
const field: FacetField = { field: 'f1', label: 'f1' };
component.onToggleBucket(<any> { checked: true }, field, null);
expect(queryBuilder.update).not.toHaveBeenCalled();
});
it('should allow to to reset selected buckets', () => {
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: true, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: false, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
component.field = field;
fixture.detectChanges();
expect(component.canResetSelectedBuckets(field)).toBeTruthy();
});
it('should not allow to reset selected buckets', () => {
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: false, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
component.field = field;
fixture.detectChanges();
expect(component.canResetSelectedBuckets(field)).toEqual(false);
});
it('should reset selected buckets', () => {
spyOn(queryBuilder, 'execute').and.stub();
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: true, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
component.field = field;
fixture.detectChanges();
component.resetSelectedBuckets(field);
expect(buckets[0].checked).toEqual(false);
expect(buckets[1].checked).toEqual(false);
});
it('should update query builder upon resetting buckets', () => {
spyOn(queryBuilder, 'update').and.stub();
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: true, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
component.field = field;
fixture.detectChanges();
component.resetSelectedBuckets(field);
expect(queryBuilder.update).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,129 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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, Inject, Input, ViewEncapsulation } from '@angular/core';
import { FacetField } from '../../models/facet-field.interface';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { FacetFieldBucket } from '../../models/facet-field-bucket.interface';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
import { FacetWidget } from '../../models/facet-widget.interface';
import { TranslationService } from '@alfresco/adf-core';
import { Subject } from 'rxjs';
@Component({
selector: 'adf-search-facet-field',
templateUrl: './search-facet-field.component.html',
encapsulation: ViewEncapsulation.None
})
export class SearchFacetFieldComponent implements FacetWidget {
@Input()
field!: FacetField;
displayValue$: Subject<string> = new Subject<string>();
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) public queryBuilder: SearchQueryBuilderService,
private searchFacetFiltersService: SearchFacetFiltersService,
private translationService: TranslationService) {
}
get canUpdateOnChange() {
return this.field.settings?.allowUpdateOnChange ?? true;
}
onToggleBucket(event: MatCheckboxChange, field: FacetField, bucket: FacetFieldBucket) {
if (event && bucket) {
if (event.checked) {
this.selectFacetBucket(field, bucket);
} else {
this.unselectFacetBucket(field, bucket);
}
}
}
selectFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (bucket) {
bucket.checked = true;
this.queryBuilder.addUserFacetBucket(field, bucket);
this.searchFacetFiltersService.updateSelectedBuckets();
if (this.canUpdateOnChange) {
this.updateDisplayValue();
this.queryBuilder.update();
}
}
}
unselectFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (bucket) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
this.searchFacetFiltersService.updateSelectedBuckets();
if (this.canUpdateOnChange) {
this.updateDisplayValue();
this.queryBuilder.update();
}
}
}
canResetSelectedBuckets(field: FacetField): boolean {
if (field && field.buckets) {
return field.buckets.items.some((bucket) => bucket.checked);
}
return false;
}
resetSelectedBuckets(field: FacetField) {
if (field && field.buckets) {
for (const bucket of field.buckets.items) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
}
this.searchFacetFiltersService.updateSelectedBuckets();
if (this.canUpdateOnChange) {
this.queryBuilder.update();
}
}
}
getBucketCountDisplay(bucket: FacetFieldBucket): string {
return bucket.count === null ? '' : `(${bucket.count})`;
}
updateDisplayValue(): void {
if (!this.field.buckets?.items) {
this.displayValue$.next('');
} else {
const displayValue = this.field.buckets?.items?.filter((item) => item.checked)
.map((item) => this.translationService.instant(item.display || item.label))
.join(', ');
this.displayValue$.next(displayValue);
}
}
reset(): void {
this.resetSelectedBuckets(this.field);
this.updateDisplayValue();
this.queryBuilder.update();
}
submitValues(): void {
this.updateDisplayValue();
this.queryBuilder.update();
}
}

View File

@@ -0,0 +1,42 @@
<mat-chip [attr.data-automation-id]="'search-filter-chip-' + field.label"
disableRipple
class="adf-search-filter-chip"
[class.adf-search-toggle-chip]="(facetField.displayValue$ | async) || menuTrigger.menuOpen"
[matMenuTriggerFor]="menu"
(onMenuOpen)="onMenuOpen()"
[attr.title]="facetField.displayValue$ | async"
#menuTrigger="matMenuTrigger">
<span class="adf-search-filter-placeholder">
<span class="adf-search-filter-ellipsis">{{ field.label | translate }}</span>
<ng-container *ngIf="facetField.displayValue$ | async">:</ng-container>
</span>
<span class="adf-search-filter-ellipsis" *ngIf="facetField.displayValue$ | async as displayValue">
&nbsp; {{ displayValue | translate }}
</span>
<mat-icon>keyboard_arrow_down</mat-icon>
</mat-chip>
<mat-menu #menu="matMenu" backdropClass="adf-search-filter-chip-menu" (closed)="onClosed()">
<div #menuContainer [attr.data-automation-id]="'search-field-' + field.label">
<adf-search-filter-menu-card (click)="$event.stopPropagation()"
(keydown.tab)="$event.stopPropagation();"
(close)="menuTrigger.closeMenu()">
<ng-container ngProjectAs="filter-title">
{{ field.label | translate }}
</ng-container>
<ng-container ngProjectAs="filter-content">
<adf-search-facet-field [field]="field" #facetField></adf-search-facet-field>
</ng-container>
<ng-container ngProjectAs="filter-actions">
<button mat-flat-button class="adf-search-action-button" color="accent" (click)="onRemove()" id="cancel-filter-button">
{{ 'SEARCH.FILTER.BUTTONS.REMOVE' | translate }}
</button>
<button mat-flat-button class="adf-search-action-button" color="primary" (click)="onApply()" id="apply-filter-button">
{{ 'SEARCH.FILTER.BUTTONS.APPLY' | translate }}
</button>
</ng-container>
</adf-search-filter-menu-card>
</div>
</mat-menu>

View File

@@ -0,0 +1,66 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { SearchFacetChipComponent } from './search-facet-chip.component';
import { ContentTestingModule } from '../../../../testing/content.testing.module';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { SearchQueryBuilderService } from '../../../services/search-query-builder.service';
import { setupTestBed } from '@alfresco/adf-core';
import { SearchFilterList } from '../../../models/search-filter-list.model';
describe('SearchFacetChipComponent', () => {
let component: SearchFacetChipComponent;
let fixture: ComponentFixture<SearchFacetChipComponent>;
let queryBuilder: SearchQueryBuilderService;
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
]
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetChipComponent);
component = fixture.componentInstance;
queryBuilder = TestBed.inject(SearchQueryBuilderService);
spyOn(queryBuilder, 'update').and.stub();
component.field = { type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() };
fixture.detectChanges();
});
it('should update search query on apply click', () => {
const chip = fixture.debugElement.query(By.css('mat-chip'));
chip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const applyButton = fixture.debugElement.query(By.css('#apply-filter-button'));
applyButton.triggerEventHandler('click', {});
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update search query on cancel click', () => {
const chip = fixture.debugElement.query(By.css('mat-chip'));
chip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const applyButton = fixture.debugElement.query(By.css('#cancel-filter-button'));
applyButton.triggerEventHandler('click', {});
expect(queryBuilder.update).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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, ElementRef, Input, ViewChild, ViewEncapsulation } from '@angular/core';
import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cdk/a11y';
import { FacetField } from '../../../models/facet-field.interface';
import { MatMenuTrigger } from '@angular/material/menu';
import { SearchFacetFieldComponent } from '../../search-facet-field/search-facet-field.component';
@Component({
selector: 'adf-search-facet-chip',
templateUrl: './search-facet-chip.component.html',
encapsulation: ViewEncapsulation.None
})
export class SearchFacetChipComponent {
@Input()
field: FacetField;
@ViewChild('menuContainer', { static: false })
menuContainer: ElementRef;
@ViewChild('menuTrigger', { static: false })
menuTrigger: MatMenuTrigger;
@ViewChild(SearchFacetFieldComponent, { static: false })
facetFieldComponent: SearchFacetFieldComponent;
focusTrap: ConfigurableFocusTrap;
constructor(private focusTrapFactory: ConfigurableFocusTrapFactory) {}
onMenuOpen() {
if (this.menuContainer && !this.focusTrap) {
this.focusTrap = this.focusTrapFactory.create(this.menuContainer.nativeElement);
this.focusTrap.focusInitialElement();
}
}
onClosed() {
this.focusTrap.destroy();
this.focusTrap = null;
}
onRemove() {
this.facetFieldComponent.reset();
this.menuTrigger.closeMenu();
}
onApply() {
this.facetFieldComponent.submitValues();
this.menuTrigger.closeMenu();
}
}

View File

@@ -0,0 +1,11 @@
<mat-chip-list aria-orientation="horizontal">
<ng-container *ngFor="let category of queryBuilder.categories">
<adf-search-widget-chip [category]="category"></adf-search-widget-chip>
</ng-container>
<ng-container *ngIf="facetFiltersService.responseFacets && showContextFacets">
<ng-container *ngFor="let field of facetFiltersService.responseFacets">
<adf-search-facet-chip [field]="field" [attr.data-automation-id]="'search-fact-chip-' + field.field" ></adf-search-facet-chip>
</ng-container>
</ng-container>
</mat-chip-list>

View File

@@ -0,0 +1,66 @@
@mixin adf-search-filter-chips-theme($theme) {
$accent: map-get($theme, accent);
$background: map-get($theme, background);
$foreground: map-get($theme, foreground);
$unselected-background: mat-color($background, unselected-chip);
$unselected-foreground: mat-color($foreground, text);
$selected-chip-background: mat-color($background, card);
$chip-placeholder: mat-color($foreground, disabled-text);
.adf-search-filter-chip {
&.mat-chip {
border: 2px solid transparent;
transition : border 500ms ease-in-out;
max-width: 320px;
text-overflow: ellipsis;
overflow: hidden;
background: $unselected-background;
&:focus {
color: unset;
}
&.mat-standard-chip::after {
background: $unselected-background;
color: unset;
}
&.mat-chip-list-wrapper {
margin: 4px 6px;
}
}
&.adf-search-toggle-chip {
background: $selected-chip-background;
border: 2px solid mat-color($accent);
&.mat-chip::after {
background: unset;
}
}
.adf-search-filter-placeholder {
flex: 1 1 auto;
white-space: nowrap;
color: $chip-placeholder;
}
.adf-search-filter-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-icon {
padding-top: 5px;
padding-left: 5px;
}
&-menu + * .cdk-overlay-pane .mat-menu-panel {
min-width: 320px;
border-radius: 12px;
@include mat-elevation(2);
}
}
}

View File

@@ -0,0 +1,413 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { SearchFilterChipsComponent } from './search-filter-chips.component';
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { By } from '@angular/platform-browser';
import { SearchFacetFieldComponent } from '../search-facet-field/search-facet-field.component';
import { TranslateModule } from '@ngx-translate/core';
import { SearchFilterList } from '../../models/search-filter-list.model';
import {
disabledCategories,
filteredResult,
mockSearchResult,
searchFilter,
simpleCategories,
stepOne,
stepThree,
stepTwo
} from '../../../mock';
import { getAllMenus } from '../search-filter/search-filter.component.spec';
import { AppConfigService } from '@alfresco/adf-core';
describe('SearchFilterChipsComponent', () => {
let fixture: ComponentFixture<SearchFilterChipsComponent>;
let searchFacetFiltersService: SearchFacetFiltersService;
let queryBuilder: SearchQueryBuilderService;
let appConfigService: AppConfigService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
]
});
queryBuilder = TestBed.inject(SearchQueryBuilderService);
appConfigService = TestBed.inject(AppConfigService);
searchFacetFiltersService = TestBed.inject(SearchFacetFiltersService);
fixture = TestBed.createComponent(SearchFilterChipsComponent);
});
it('should fetch facet fields from response payload and show the already checked items', () => {
spyOn(queryBuilder, 'execute').and.stub();
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
searchFacetFiltersService.responseFacets = [
{ type: 'field', label: 'f1', field: 'f1', buckets: new SearchFilterList([
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }]) },
{ type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList()}
];
searchFacetFiltersService.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[0]);
const serverResponseFields: any = [
{ type: 'field', label: 'f1', field: 'f1', buckets: [
{ label: 'b1', metrics: [{value: {count: 6}}], filterQuery: 'filter' },
{ label: 'b2', metrics: [{value: {count: 1}}], filterQuery: 'filter2' }] },
{ type: 'field', label: 'f2', field: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facets: serverResponseFields
}
}
};
fixture.detectChanges();
const facetChip = fixture.debugElement.query(By.css('[data-automation-id="search-fact-chip-f1"] mat-chip'));
facetChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.selectFacetBucket({ field: 'f1', label: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[1]);
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].checked).toEqual(true, 'should show the already checked item');
});
it('should fetch facet fields from response payload and show the newly checked items', () => {
spyOn(queryBuilder, 'execute').and.stub();
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
searchFacetFiltersService.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: new SearchFilterList([
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }]) },
{ type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList()}
];
queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[0]);
const serverResponseFields: any = [
{ type: 'field', label: 'f1', field: 'f1', buckets: [
{ label: 'b1', metrics: [{value: {count: 6}}], filterQuery: 'filter' },
{ label: 'b2', metrics: [{value: {count: 1}}], filterQuery: 'filter2' }] },
{ type: 'field', label: 'f2', field: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facets: serverResponseFields
}
}
};
fixture.detectChanges();
const facetChip = fixture.debugElement.query(By.css('[data-automation-id="search-fact-chip-f1"] mat-chip'));
facetChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.selectFacetBucket({ field: 'f1', label: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[1]);
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].checked).toEqual(true, 'should show the newly checked item');
});
it('should show buckets with 0 values when there are no facet fields on the response payload', () => {
spyOn(queryBuilder, 'execute').and.stub();
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
searchFacetFiltersService.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: new SearchFilterList( [
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }]) },
{ type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() }
];
queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[0]);
const data = {
list: {
context: {}
}
};
fixture.detectChanges();
const facetChip = fixture.debugElement.query(By.css('[data-automation-id="search-fact-chip-f1"] mat-chip'));
facetChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.selectFacetBucket({ field: 'f1', label: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[1]);
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(0);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].count).toEqual(0);
});
it('should update query builder upon resetting selected queries', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const queryResponse = {
field: 'query-response',
label: 'query response',
buckets: new SearchFilterList([
{ label: 'q1', query: 'q1', checked: true, metrics: [{value: {count: 1}}] },
{ label: 'q2', query: 'q2', checked: false, metrics: [{value: {count: 1}}] },
{ label: 'q3', query: 'q3', checked: true, metrics: [{value: {count: 1}}] }])
} as any;
searchFacetFiltersService.responseFacets = [queryResponse];
fixture.detectChanges();
const facetChip = fixture.debugElement.query(By.css(`[data-automation-id="search-fact-chip-query-response"] mat-chip`));
facetChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.resetSelectedBuckets(queryResponse);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3);
expect(queryBuilder.update).toHaveBeenCalled();
for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) {
expect(entry.checked).toEqual(false);
}
});
describe('widgets', () => {
it('should not show the disabled widget', async () => {
appConfigService.config.search = { categories: disabledCategories };
queryBuilder.resetToDefaults();
fixture.detectChanges();
await fixture.whenStable();
const chips = fixture.debugElement.queryAll(By.css('mat-chip'));
expect(chips.length).toBe(0);
});
it('should show the widgets only if configured', async () => {
appConfigService.config.search = { categories: simpleCategories };
queryBuilder.resetToDefaults();
fixture.detectChanges();
await fixture.whenStable();
const chips = fixture.debugElement.queryAll(By.css('mat-chip'));
expect(chips.length).toBe(2);
const titleElements = fixture.debugElement.queryAll(By.css('.adf-search-filter-placeholder'));
expect(titleElements.map(title => title.nativeElement.innerText.trim())).toEqual(['Name', 'Type']);
});
it('should be update the search query when name changed', async () => {
spyOn(queryBuilder, 'update').and.stub();
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
fixture.detectChanges();
await fixture.whenStable();
let chips = fixture.debugElement.queryAll(By.css('mat-chip'));
expect(chips.length).toBe(6);
fixture.detectChanges();
const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Name"]`));
searchChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const inputElement = fixture.debugElement.query(By.css('[data-automation-id="search-field-Name"] input'));
inputElement.triggerEventHandler('change', { target: { value: '*' } });
expect(queryBuilder.update).toHaveBeenCalled();
queryBuilder.executed.next(<any> mockSearchResult);
await fixture.whenStable();
fixture.detectChanges();
chips = fixture.debugElement.queryAll(By.css('mat-chip'));
expect(chips.length).toBe(8);
});
it('should show the long facet options list with pagination', () => {
const field = `[data-automation-id="search-field-Size facet queries"]`;
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
fixture.detectChanges();
fixture.detectChanges();
const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Size facet queries"]`));
searchChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
let sizes = getAllMenus(`${field} mat-checkbox`, fixture);
expect(sizes).toEqual(stepOne);
let moreButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-MORE"]`));
let lessButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-LESS"]`));
expect(lessButton).toEqual(null);
expect(moreButton).toBeDefined();
moreButton.triggerEventHandler('click', {});
fixture.detectChanges();
sizes = getAllMenus(`${field} mat-checkbox`, fixture);
expect(sizes).toEqual(stepTwo);
moreButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-MORE"]`));
lessButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-LESS"]`));
expect(lessButton).toBeDefined();
expect(moreButton).toBeDefined();
moreButton.triggerEventHandler('click', {});
fixture.detectChanges();
sizes = getAllMenus(`${field} mat-checkbox`, fixture);
expect(sizes).toEqual(stepThree);
moreButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-MORE"]`));
lessButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-LESS"]`));
expect(lessButton).toBeDefined();
expect(moreButton).toEqual(null);
lessButton.triggerEventHandler('click', {});
fixture.detectChanges();
sizes = getAllMenus(`${field} mat-checkbox`, fixture);
expect(sizes).toEqual(stepTwo);
moreButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-MORE"]`));
lessButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-LESS"]`));
expect(lessButton).toBeDefined();
expect(moreButton).toBeDefined();
lessButton.triggerEventHandler('click', {});
fixture.detectChanges();
sizes = getAllMenus(`${field} mat-checkbox`, fixture);
expect(sizes).toEqual(stepOne);
moreButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-MORE"]`));
lessButton = fixture.debugElement.query(By.css(`${field} button[title="SEARCH.FILTER.ACTIONS.SHOW-LESS"]`));
expect(lessButton).toEqual(null);
expect(moreButton).toBeDefined();
});
it('should not show facets if filter is not available', () => {
const chip = '[data-automation-id="search-filter-chip-Size facet queries"]';
const filter = { ...searchFilter };
delete filter.facetQueries;
appConfigService.config.search = filter;
queryBuilder.resetToDefaults();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
fixture.detectChanges();
const facetElement = fixture.debugElement.query(By.css(chip));
expect(facetElement).toEqual(null);
});
it('should search the facets options and select it', () => {
const field = `[data-automation-id="search-field-Size facet queries"]`;
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
fixture.detectChanges();
spyOn(queryBuilder, 'update').and.stub();
const searchChip = fixture.debugElement.query(By.css(`[data-automation-id="search-filter-chip-Size facet queries"]`));
searchChip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const inputElement = fixture.debugElement.query(By.css(`${field} input`));
inputElement.nativeElement.value = 'Extra';
inputElement.nativeElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
let filteredMenu = getAllMenus(`${field} mat-checkbox`, fixture);
expect(filteredMenu).toEqual(['Extra Small (10239)']);
inputElement.nativeElement.value = 'my';
inputElement.nativeElement.dispatchEvent(new Event('input'));
fixture.detectChanges();
filteredMenu = getAllMenus(`${field} mat-checkbox`, fixture);
expect(filteredMenu).toEqual(filteredResult);
const clearButton = fixture.debugElement.query(By.css(`${field} mat-form-field button`));
clearButton.triggerEventHandler('click', {});
fixture.detectChanges();
filteredMenu = getAllMenus(`${field} mat-checkbox`, fixture);
expect(filteredMenu).toEqual(stepOne);
const firstOption = fixture.debugElement.query(By.css(`${field} mat-checkbox`));
firstOption.triggerEventHandler('change', { checked: true });
fixture.detectChanges();
const checkedOption = fixture.debugElement.query(By.css(`${field} mat-checkbox.mat-checkbox-checked`));
expect(checkedOption.nativeElement.innerText).toEqual('Extra Small (10239)');
expect(queryBuilder.update).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,38 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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, Inject, Input, ViewEncapsulation } from '@angular/core';
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
@Component({
selector: 'adf-search-filter-chips',
templateUrl: './search-filter-chips.component.html',
encapsulation: ViewEncapsulation.None
})
export class SearchFilterChipsComponent {
/** Toggles whether to show or not the context facet filters. */
@Input()
showContextFacets: boolean = true;
constructor(
@Inject(SEARCH_QUERY_SERVICE_TOKEN)
public queryBuilder: SearchQueryBuilderService,
public facetFiltersService: SearchFacetFiltersService) {}
}

View File

@@ -0,0 +1,22 @@
<div class="adf-search-filter-menu-card">
<div class="adf-search-filter-title">
<ng-content select="filter-title"></ng-content>
<mat-icon class="adf-search-filter-title-action"
(click)="onClose()"
[matTooltip]="'SEARCH.FILTER.BUTTONS.CLOSE' | translate">
close
</mat-icon>
</div>
<mat-divider></mat-divider>
<div class="adf-search-filter-content">
<ng-content select="filter-content"></ng-content>
</div>
<mat-divider></mat-divider>
<div class="adf-search-filter-actions">
<ng-content select="filter-actions"></ng-content>
</div>
</div>

View File

@@ -0,0 +1,39 @@
@mixin adf-search-filter-menu-card($theme) {
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
.adf-search-filter-menu-card {
color: mat-color($foreground, text);
background: mat-color($background, card);
.adf-search-filter-title {
padding: 16px 12px;
height: 32px;
flex: 1 1 auto;
font-size: 14px;
letter-spacing: 0.15px;
line-height: 24px;
font-weight: bold;
font-style: inherit;
&-action {
float: right;
}
}
.adf-search-filter-content {
padding: 16px 12px;
overflow: auto;
}
.adf-search-filter-actions {
padding: 16px 12px;
display: flex;
justify-content: space-between;
.adf-search-action-button {
border-radius: 6px;
}
}
}
}

View File

@@ -0,0 +1,46 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { SearchFilterMenuCardComponent } from './search-filter-menu-card.component';
import { TranslateModule } from '@ngx-translate/core';
import { ContentTestingModule } from '../../../../testing/content.testing.module';
import { setupTestBed } from '@alfresco/adf-core';
describe('SearchFilterMenuComponent', () => {
let component: SearchFilterMenuCardComponent;
let fixture: ComponentFixture<SearchFilterMenuCardComponent>;
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
]
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchFilterMenuCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should emit on close click', (done) => {
component.close.subscribe(() => done());
const closButton = fixture.debugElement.nativeElement.querySelector('.adf-search-filter-title-action');
closButton.click();
});
});

View File

@@ -0,0 +1,31 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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, EventEmitter, Output } from '@angular/core';
@Component({
selector: 'adf-search-filter-menu-card',
templateUrl: './search-filter-menu-card.component.html'
})
export class SearchFilterMenuCardComponent {
@Output()
close = new EventEmitter();
onClose() {
this.close.emit();
}
}

View File

@@ -0,0 +1,47 @@
<mat-chip [attr.data-automation-id]="'search-filter-chip-' + category.name"
disableRipple
class="adf-search-filter-chip"
[class.adf-search-toggle-chip]="(widget.getDisplayValue() | async) || menuTrigger.menuOpen"
[matMenuTriggerFor]="menu"
(onMenuOpen)="onMenuOpen()"
[attr.title]="widget.getDisplayValue() | async"
#menuTrigger="matMenuTrigger">
<span class="adf-search-filter-placeholder">
<span class="adf-search-filter-ellipsis">{{ category.name | translate }}</span>
<ng-container *ngIf="widget.getDisplayValue() | async">:</ng-container>
</span>
<span class="adf-search-filter-ellipsis" *ngIf="widget.getDisplayValue() | async as displayValue">
&nbsp;{{ displayValue | translate }}
</span>
<mat-icon>keyboard_arrow_down</mat-icon>
</mat-chip>
<mat-menu #menu="matMenu" backdropClass="adf-search-filter-chip-menu" (closed)="onClosed()">
<div #menuContainer [attr.data-automation-id]="'search-field-' + category.name">
<adf-search-filter-menu-card (click)="$event.stopPropagation()"
(keydown.tab)="$event.stopPropagation();"
(close)="menuTrigger.closeMenu()">
<ng-container ngProjectAs="filter-title">
{{ category.name | translate }} <ng-container *ngIf="category.component.settings.unit">({{category.component.settings.unit}})</ng-container>
</ng-container>
<ng-container ngProjectAs="filter-content">
<adf-search-widget-container #widget
[id]="category.id"
[selector]="category.component.selector"
[settings]="category.component.settings">
</adf-search-widget-container>
</ng-container>
<ng-container ngProjectAs="filter-actions">
<button mat-flat-button class="adf-search-action-button" color="accent" (click)="onRemove()" id="cancel-filter-button">
{{ 'SEARCH.FILTER.BUTTONS.REMOVE' | translate }}
</button>
<button mat-flat-button class="adf-search-action-button" color="primary" (click)="onApply()" id="apply-filter-button">
{{ 'SEARCH.FILTER.BUTTONS.APPLY' | translate }}
</button>
</ng-container>
</adf-search-filter-menu-card>
</div>
</mat-menu>

View File

@@ -0,0 +1,68 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { SearchWidgetChipComponent } from './search-widget-chip.component';
import { simpleCategories } from '../../../../mock';
import { setupTestBed } from '@alfresco/adf-core';
import { TranslateModule } from '@ngx-translate/core';
import { ContentTestingModule } from '../../../../testing/content.testing.module';
import { MatMenuModule } from '@angular/material/menu';
import { By } from '@angular/platform-browser';
import { SearchQueryBuilderService } from '../../../services/search-query-builder.service';
describe('SearchWidgetChipComponent', () => {
let component: SearchWidgetChipComponent;
let fixture: ComponentFixture<SearchWidgetChipComponent>;
let queryBuilder: SearchQueryBuilderService;
setupTestBed( {
imports: [
MatMenuModule,
TranslateModule.forRoot(),
ContentTestingModule
]
});
beforeEach(() => {
queryBuilder = TestBed.inject(SearchQueryBuilderService);
fixture = TestBed.createComponent(SearchWidgetChipComponent);
component = fixture.componentInstance;
spyOn(queryBuilder, 'update').and.stub();
component.category = simpleCategories[1];
fixture.detectChanges();
});
it('should update search query on apply click', () => {
const chip = fixture.debugElement.query(By.css('mat-chip'));
chip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const applyButton = fixture.debugElement.query(By.css('#apply-filter-button'));
applyButton.triggerEventHandler('click', {});
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update search query on cancel click', () => {
const chip = fixture.debugElement.query(By.css('mat-chip'));
chip.triggerEventHandler('click', { stopPropagation: () => null });
fixture.detectChanges();
const applyButton = fixture.debugElement.query(By.css('#cancel-filter-button'));
applyButton.triggerEventHandler('click', {});
expect(queryBuilder.update).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,68 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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, ElementRef, Input, ViewChild, ViewEncapsulation } from '@angular/core';
import { SearchCategory } from '../../../models/search-category.interface';
import { ConfigurableFocusTrap, ConfigurableFocusTrapFactory } from '@angular/cdk/a11y';
import { MatMenuTrigger } from '@angular/material/menu';
import { SearchWidgetContainerComponent } from '../../search-widget-container/search-widget-container.component';
@Component({
selector: 'adf-search-widget-chip',
templateUrl: './search-widget-chip.component.html',
encapsulation: ViewEncapsulation.None
})
export class SearchWidgetChipComponent {
@Input()
category: SearchCategory;
@ViewChild('menuContainer', { static: false })
menuContainer: ElementRef;
@ViewChild('menuTrigger', { static: false })
menuTrigger: MatMenuTrigger;
@ViewChild(SearchWidgetContainerComponent, { static: false })
widgetContainerComponent: SearchWidgetContainerComponent;
focusTrap: ConfigurableFocusTrap;
constructor(private focusTrapFactory: ConfigurableFocusTrapFactory) {}
onMenuOpen() {
if (this.menuContainer && !this.focusTrap) {
this.focusTrap = this.focusTrapFactory.create(this.menuContainer.nativeElement);
this.focusTrap.focusInitialElement();
}
}
onClosed() {
this.focusTrap.destroy();
this.focusTrap = null;
}
onRemove() {
this.widgetContainerComponent.resetInnerWidget();
this.menuTrigger.closeMenu();
}
onApply() {
this.widgetContainerComponent.applyInnerWidget();
this.menuTrigger.closeMenu();
}
}

View File

@@ -18,7 +18,7 @@ import { Subject } from 'rxjs';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { SearchService, setupTestBed, AlfrescoApiService } from '@alfresco/adf-core';
import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service';
import { SearchHeaderQueryBuilderService } from '../../services/search-header-query-builder.service';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { fakeNodePaging } from './../../../mock/document-list.component.mock';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';

View File

@@ -30,7 +30,7 @@ import {
import { ConfigurableFocusTrapFactory, ConfigurableFocusTrap } from '@angular/cdk/a11y';
import { DataColumn, TranslationService } from '@alfresco/adf-core';
import { SearchWidgetContainerComponent } from '../search-widget-container/search-widget-container.component';
import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service';
import { SearchHeaderQueryBuilderService } from '../../services/search-header-query-builder.service';
import { SearchCategory } from '../../models/search-category.interface';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { Subject } from 'rxjs';

View File

@@ -1,11 +1,11 @@
<mat-accordion multi="true" displayMode="flat">
<button *ngIf="displayResetButton && responseFacets"
<button *ngIf="displayResetButton && facetFiltersService.responseFacets"
mat-button
color="primary"
matTooltip="{{ 'SEARCH.FILTER.BUTTONS.RESET-ALL.TOOLTIP' | translate }}"
matTooltipPosition="right"
(click)="resetAll()">
adf-reset-search>
{{ 'SEARCH.FILTER.BUTTONS.RESET-ALL.LABEL' | translate }}
</button>
<mat-expansion-panel
@@ -24,72 +24,15 @@
</adf-search-widget-container>
</mat-expansion-panel>
<ng-container *ngIf="responseFacets && showContextFacets">
<mat-expansion-panel [attr.data-automation-id]="'expansion-panel-'+field.label" *ngFor="let field of responseFacets"
<ng-container *ngIf="facetFiltersService.responseFacets && showContextFacets">
<mat-expansion-panel [attr.data-automation-id]="'expansion-panel-'+field.label" *ngFor="let field of facetFiltersService.responseFacets"
[expanded]="shouldExpand(field)">
<mat-expansion-panel-header>
<mat-panel-title>{{ field.label | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<div class="adf-facet-result-filter">
<mat-form-field>
<input
matInput
placeholder="{{ 'SEARCH.FILTER.ACTIONS.FILTER-CATEGORY' | translate }}"
[attr.data-automation-id]="'facet-result-filter-'+field.label"
[(ngModel)]="field.buckets.filterText">
<button *ngIf="field.buckets.filterText"
mat-button matSuffix mat-icon-button
(click)="field.buckets.filterText = ''">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
</div>
<adf-search-facet-field [field]="field"></adf-search-facet-field>
<div class="adf-checklist">
<mat-checkbox
*ngFor="let bucket of field.buckets"
[checked]="bucket.checked"
[attr.data-automation-id]="'checkbox-'+field.label+'-'+(bucket.display || bucket.label)"
(change)="onToggleBucket($event, field, bucket)">
<div
matTooltip="{{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}"
matTooltipPosition="right"
class="adf-facet-label">
{{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}
</div>
</mat-checkbox>
</div>
<div class="adf-facet-buttons" *ngIf="field.buckets.fitsPage">
<button *ngIf="canResetSelectedBuckets(field)"
mat-button
color="primary"
(click)="resetSelectedBuckets(field)">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR-ALL' | translate }}
</button>
</div>
<div class="adf-facet-buttons" *ngIf="!field.buckets.fitsPage">
<button mat-icon-button
*ngIf="canResetSelectedBuckets(field)"
title="{{ 'SEARCH.FILTER.ACTIONS.CLEAR-ALL' | translate }}"
(click)="resetSelectedBuckets(field)">
<mat-icon>clear</mat-icon>
</button>
<button mat-icon-button
*ngIf="field.buckets.canShowLessItems"
(click)="field.buckets.showLessItems()"
title="{{ 'SEARCH.FILTER.ACTIONS.SHOW-LESS' | translate }}">
<mat-icon>keyboard_arrow_up</mat-icon>
</button>
<button mat-icon-button
*ngIf="field.buckets.canShowMoreItems"
(click)="field.buckets.showMoreItems()"
title="{{ 'SEARCH.FILTER.ACTIONS.SHOW-MORE' | translate }}">
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
</div>
</mat-expansion-panel>
</ng-container>
</mat-accordion>

View File

@@ -2,57 +2,6 @@
$foreground: map-get($theme, foreground);
.adf-search-filter {
.adf-checklist {
display: flex;
flex-direction: column;
.mat-checkbox-label {
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
}
.mat-checkbox-layout {
width: 100%;
}
.adf-facet-label {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mat-checkbox {
margin: 5px;
&.mat-checkbox-checked .mat-checkbox-label {
font-weight: bold;
}
}
}
.adf-facet-result-filter {
display: flex;
flex-direction: column;
& > * {
width: 100%;
}
}
.adf-facet-buttons {
text-align: right;
.mat-button {
text-transform: uppercase;
}
&--topSpace {
padding-top: 15px;
}
}
.mat-expansion-panel-header-title {
font-size: 14px;
color: mat-color($foreground, text, 0.87);

View File

@@ -16,11 +16,9 @@
*/
import { SearchFilterComponent } from './search-filter.component';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { AppConfigService, SearchService, setupTestBed, TranslationService } from '@alfresco/adf-core';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { AppConfigService, SearchService, TranslationService } from '@alfresco/adf-core';
import { Subject } from 'rxjs';
import { FacetFieldBucket } from '../../models/facet-field-bucket.interface';
import { FacetField } from '../../models/facet-field.interface';
import { SearchFilterList } from '../../models/search-filter-list.model';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
@@ -39,6 +37,8 @@ import {
stepTwo
} from '../../../mock';
import { TranslateModule } from '@ngx-translate/core';
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
import { SearchFacetFieldComponent } from '../search-facet-field/search-facet-field.component';
describe('SearchFilterComponent', () => {
let fixture: ComponentFixture<SearchFilterComponent>;
@@ -48,18 +48,19 @@ describe('SearchFilterComponent', () => {
const searchMock: any = {
dataLoaded: new Subject()
};
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
providers: [
{ provide: SearchService, useValue: searchMock }
]
});
let searchFacetFiltersService: SearchFacetFiltersService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
providers: [
{ provide: SearchService, useValue: searchMock }
]
});
searchFacetFiltersService = TestBed.inject(SearchFacetFiltersService);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
fixture = TestBed.createComponent(SearchFilterComponent);
appConfigService = TestBed.inject(AppConfigService);
@@ -73,346 +74,6 @@ describe('SearchFilterComponent', () => {
describe('component', () => {
beforeEach(() => fixture.detectChanges());
it('should subscribe to query builder executed event', () => {
spyOn(component, 'onDataLoaded').and.stub();
const data = { list: {} };
queryBuilder.executed.next(data);
expect(component.onDataLoaded).toHaveBeenCalledWith(data);
});
it('should update bucket model and query builder on facet toggle', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'addUserFacetBucket').and.callThrough();
const event: any = { checked: true };
const field: FacetField = { field: 'f1', label: 'f1' };
const bucket: FacetFieldBucket = { checked: false, filterQuery: 'q1', label: 'q1', count: 1 };
component.onToggleBucket(event, field, bucket);
expect(bucket.checked).toBeTruthy();
expect(queryBuilder.addUserFacetBucket).toHaveBeenCalledWith(field, bucket);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update bucket model and query builder on facet un-toggle', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const event: any = { checked: false };
const field: FacetField = { field: 'f1', label: 'f1' };
const bucket: FacetFieldBucket = { checked: true, filterQuery: 'q1', label: 'q1', count: 1 };
component.onToggleBucket(event, field, bucket);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(field, bucket);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should unselect facet query and update builder', () => {
spyOn(queryBuilder, 'update').and.stub();
spyOn(queryBuilder, 'removeUserFacetBucket').and.callThrough();
const event: any = { checked: false };
const query = { checked: true, label: 'q1', filterQuery: 'query1' };
const field = { type: 'query', label: 'label1', buckets: [ query ] };
component.onToggleBucket(event, <any> field, <any> query);
expect(query.checked).toEqual(false);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledWith(field, query);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should fetch facet queries from response payload', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetQueries: {
label: 'label1',
queries: [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
]
}
};
const queries = [
{ label: 'q1', filterQuery: 'query1', metrics: [{value: {count: 1}}] },
{ label: 'q2', filterQuery: 'query2', metrics: [{value: {count: 1}}] }
];
const data = {
list: {
context: {
facets: [{
type: 'query',
label: 'label1',
buckets: queries
}]
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toBe(1);
expect(component.responseFacets[0].buckets.length).toEqual(2);
});
it('should preserve order after response processing', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetQueries: {
label: 'label1',
queries: [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' },
{ label: 'q3', query: 'query3' }
]
}
};
const queries = [
{ label: 'q2', filterQuery: 'query2', metrics: [{value: {count: 1}}] },
{ label: 'q1', filterQuery: 'query1', metrics: [{value: {count: 1}}] },
{ label: 'q3', filterQuery: 'query3', metrics: [{value: {count: 1}}] }
];
const data = {
list: {
context: {
facets: [{
type: 'query',
label: 'label1',
buckets: queries
}]
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toBe(1);
expect(component.responseFacets[0].buckets.length).toBe(3);
expect(component.responseFacets[0].buckets.items[0].label).toBe('q1');
expect(component.responseFacets[0].buckets.items[1].label).toBe('q2');
expect(component.responseFacets[0].buckets.items[2].label).toBe('q3');
});
it('should not fetch facet queries from response payload', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetQueries: {
queries: []
}
};
const data = {
list: {
context: {
facets: null
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets).toBeNull();
});
it('should fetch facet fields from response payload', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1', mincount: 0 },
{ label: 'f2', field: 'f2', mincount: 0 }
]},
facetQueries: {
queries: []
}
};
const fields: any = [
{ type: 'field', label: 'f1', buckets: [{ label: 'a1' }, { label: 'a2' }] },
{ type: 'field', label: 'f2', buckets: [{ label: 'b1' }, { label: 'b2' }] }
];
const data = {
list: {
context: {
facets: fields
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toEqual(2);
expect(component.responseFacets[0].buckets.length).toEqual(2);
expect(component.responseFacets[1].buckets.length).toEqual(2);
});
it('should filter response facet fields based on search filter config method', () => {
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' }
]},
facetQueries: {
queries: []
},
filterWithContains: false
};
const initialFields: any = [
{ type: 'field', label: 'f1', buckets: [
{ label: 'firstLabel', display: 'firstLabel', metrics: [{value: {count: 5}}] },
{ label: 'secondLabel', display: 'secondLabel', metrics: [{value: {count: 5}}] },
{ label: 'thirdLabel', display: 'thirdLabel', metrics: [{value: {count: 5}}] }
]
}
];
const data = {
list: {
context: {
facets: initialFields
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toBe(1);
expect(component.responseFacets[0].buckets.visibleItems.length).toBe(3);
component.responseFacets[0].buckets.filterText = 'f';
expect(component.responseFacets[0].buckets.visibleItems.length).toBe(1);
expect(component.responseFacets[0].buckets.visibleItems[0].label).toEqual('firstLabel');
component.responseFacets[0].buckets.filterText = 'label';
expect(component.responseFacets[0].buckets.visibleItems.length).toBe(0);
// Set filter method to use contains and test again
queryBuilder.config.filterWithContains = true;
component.responseFacets[0].buckets.filterText = 'f';
expect(component.responseFacets[0].buckets.visibleItems.length).toBe(1);
component.responseFacets[0].buckets.filterText = 'label';
expect(component.responseFacets[0].buckets.visibleItems.length).toBe(3);
});
it('should fetch facet fields from response payload and show the bucket values', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
const serverResponseFields: any = [
{
type: 'field',
label: 'f1',
buckets: [
{ label: 'b1', metrics: [{value: {count: 10}}] },
{ label: 'b2', metrics: [{value: {count: 1}}] }
]
},
{ type: 'field', label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facets: serverResponseFields
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toEqual(1);
expect(component.responseFacets[0].buckets.items[0].count).toEqual(10);
expect(component.responseFacets[0].buckets.items[1].count).toEqual(1);
});
it('should fetch facet fields from response payload and update the existing bucket values', () => {
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
const initialFields: any = [
{ type: 'field', label: 'f1', buckets: { items: [{ label: 'b1', count: 10, filterQuery: 'filter' }, { label: 'b2', count: 1 }]} },
{ type: 'field', label: 'f2', buckets: [] }
];
component.responseFacets = initialFields;
expect(component.responseFacets[0].buckets.items[0].count).toEqual(10);
expect(component.responseFacets[0].buckets.items[1].count).toEqual(1);
const serverResponseFields: any = [
{ type: 'field', label: 'f1', buckets:
[{ label: 'b1', metrics: [{value: {count: 6}}], filterQuery: 'filter' },
{ label: 'b2', metrics: [{value: {count: 0}}] }] },
{ type: 'field', label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facets: serverResponseFields
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets[0].buckets.items[0].count).toEqual(6);
expect(component.responseFacets[0].buckets.items[1].count).toEqual(0);
});
it('should update correctly the existing facetFields bucket values', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetFields: { fields: [{ label: 'f1', field: 'f1' }] },
facetQueries: { queries: [] }
};
const firstCallFields: any = [{
type: 'field',
label: 'f1',
buckets: [{ label: 'b1', metrics: [{value: {count: 10}}] }]
}];
const firstCallData = { list: { context: { facets: firstCallFields }}};
component.onDataLoaded(firstCallData);
expect(component.responseFacets[0].buckets.items[0].count).toEqual(10);
const secondCallFields: any = [{
type: 'field',
label: 'f1',
buckets: [{ label: 'b1', metrics: [{value: {count: 6}}] }]
}];
const secondCallData = { list: { context: { facets: secondCallFields}}};
component.onDataLoaded(secondCallData);
expect(component.responseFacets[0].buckets.items[0].count).toEqual(6);
});
it('should fetch facet fields from response payload and show the already checked items', () => {
spyOn(queryBuilder, 'execute').and.stub();
queryBuilder.config = {
@@ -426,13 +87,13 @@ describe('SearchFilterComponent', () => {
}
};
component.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: {items: [
searchFacetFiltersService.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: new SearchFilterList([
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }] }},
{ type: 'field', label: 'f2', field: 'f2', buckets: {items: [] }}
{ label: 'b2', count: 1, filterQuery: 'filter2' }]) },
{ type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList([]) }
];
component.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[0]);
queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[0]);
const serverResponseFields: any = [
{ type: 'field', label: 'f1', field: 'f1', buckets: [
@@ -447,10 +108,14 @@ describe('SearchFilterComponent', () => {
}
}
};
component.selectFacetBucket({ field: 'f1', label: 'f1' }, component.responseFacets[0].buckets.items[1]);
component.onDataLoaded(data);
expect(component.responseFacets.length).toEqual(2);
expect(component.responseFacets[0].buckets.items[0].checked).toEqual(true, 'should show the already checked item');
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.selectFacetBucket({ field: 'f1', label: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[1]);
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].checked).toEqual(true, 'should show the already checked item');
});
it('should fetch facet fields from response payload and show the newly checked items', () => {
@@ -466,13 +131,13 @@ describe('SearchFilterComponent', () => {
}
};
component.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: {items: [
searchFacetFiltersService.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: new SearchFilterList([
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }] }},
{ type: 'field', label: 'f2', field: 'f2', buckets: {items: [] }}
{ label: 'b2', count: 1, filterQuery: 'filter2' }]) },
{ type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList([]) }
];
component.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[0]);
searchFacetFiltersService.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[0]);
const serverResponseFields: any = [
{ type: 'field', label: 'f1', field: 'f1', buckets: [
@@ -487,10 +152,14 @@ describe('SearchFilterComponent', () => {
}
}
};
component.selectFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[1]);
component.onDataLoaded(data);
expect(component.responseFacets.length).toEqual(2);
expect(component.responseFacets[0].buckets.items[1].checked).toEqual(true, 'should show the newly checked item');
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.selectFacetBucket({ field: 'f1', label: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[1]);
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].checked).toEqual(true, 'should show the newly checked item');
});
it('should show buckets with 0 values when there are no facet fields on the response payload', () => {
@@ -506,99 +175,26 @@ describe('SearchFilterComponent', () => {
}
};
component.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: {items: [
searchFacetFiltersService.responseFacets = <any> [
{ type: 'field', label: 'f1', field: 'f1', buckets: new SearchFilterList([
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }] }},
{ type: 'field', label: 'f2', field: 'f2', buckets: {items: [] }}
{ label: 'b2', count: 1, filterQuery: 'filter2' }]) },
{ type: 'field', label: 'f2', field: 'f2', buckets: new SearchFilterList() }
];
component.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[0]);
searchFacetFiltersService.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[0]);
const data = {
list: {
context: {}
}
};
component.selectFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[1]);
component.onDataLoaded(data);
expect(component.responseFacets[0].buckets.items[0].count).toEqual(0);
expect(component.responseFacets[0].buckets.items[1].count).toEqual(0);
});
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.selectFacetBucket({ label: 'f1', field: 'f1' }, searchFacetFiltersService.responseFacets[0].buckets.items[1]);
searchFacetFiltersService.onDataLoaded(data);
it('should update query builder only when has bucket to unselect', () => {
spyOn(queryBuilder, 'update').and.stub();
const field: FacetField = { field: 'f1', label: 'f1' };
component.onToggleBucket(<any> { checked: true }, field, null);
expect(queryBuilder.update).not.toHaveBeenCalled();
});
it('should allow to to reset selected buckets', () => {
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: true, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: false, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
expect(component.canResetSelectedBuckets(field)).toBeTruthy();
});
it('should not allow to reset selected buckets', () => {
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: false, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
expect(component.canResetSelectedBuckets(field)).toEqual(false);
});
it('should reset selected buckets', () => {
spyOn(queryBuilder, 'execute').and.stub();
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: true, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
component.resetSelectedBuckets(field);
expect(buckets[0].checked).toEqual(false);
expect(buckets[1].checked).toEqual(false);
});
it('should update query builder upon resetting buckets', () => {
spyOn(queryBuilder, 'update').and.stub();
const buckets: FacetFieldBucket[] = [
{ label: 'bucket1', checked: false, count: 1, filterQuery: 'q1' },
{ label: 'bucket2', checked: true, count: 1, filterQuery: 'q2' }
];
const field: FacetField = {
field: 'f1',
label: 'field1',
buckets: new SearchFilterList<FacetFieldBucket>(buckets)
};
component.resetSelectedBuckets(field);
expect(queryBuilder.update).toHaveBeenCalled();
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(0);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].count).toEqual(0);
});
it('should update query builder upon resetting selected queries', () => {
@@ -607,110 +203,25 @@ describe('SearchFilterComponent', () => {
const queryResponse = <any> {
label: 'query response',
buckets: <any> {
items: [
buckets: new SearchFilterList([
{ label: 'q1', query: 'q1', checked: true, metrics: [{value: {count: 1}}] },
{ label: 'q2', query: 'q2', checked: false, metrics: [{value: {count: 1}}] },
{ label: 'q3', query: 'q3', checked: true, metrics: [{value: {count: 1}}] }]
}};
component.responseFacets = [queryResponse];
component.resetSelectedBuckets(queryResponse);
{ label: 'q3', query: 'q3', checked: true, metrics: [{value: {count: 1}}] }])
};
searchFacetFiltersService.responseFacets = [queryResponse];
fixture.detectChanges();
const facetField: SearchFacetFieldComponent = fixture.debugElement.query(By.css('adf-search-facet-field')).componentInstance;
facetField.resetSelectedBuckets(queryResponse);
expect(queryBuilder.removeUserFacetBucket).toHaveBeenCalledTimes(3);
expect(queryBuilder.update).toHaveBeenCalled();
for (const entry of component.responseFacets[0].buckets.items) {
for (const entry of searchFacetFiltersService.responseFacets[0].buckets.items) {
expect(entry.checked).toEqual(false);
}
});
it('should fetch facet intervals from response payload', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetIntervals: {
intervals: [
{ label: 'test_intervals1', field: 'f1', sets: [
{ label: 'interval1', start: 's1', end: 'e1'},
{ label: 'interval2', start: 's2', end: 'e2'}
]},
{ label: 'test_intervals2', field: 'f2', sets: [
{ label: 'interval3', start: 's3', end: 'e3'},
{ label: 'interval4', start: 's4', end: 'e4'}
]}
]
}
};
const response1 = [
{ label: 'interval1', filterQuery: 'query1', metrics: [{ value: { count: 1 }}]},
{ label: 'interval2', filterQuery: 'query2', metrics: [{ value: { count: 2 }}]}
];
const response2 = [
{ label: 'interval3', filterQuery: 'query3', metrics: [{ value: { count: 3 }}]},
{ label: 'interval4', filterQuery: 'query4', metrics: [{ value: { count: 4 }}]}
];
const data = {
list: {
context: {
facets: [
{ type: 'interval', label: 'test_intervals1', buckets: response1 },
{ type: 'interval', label: 'test_intervals2', buckets: response2 }
]
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toBe(2);
expect(component.responseFacets[0].buckets.length).toEqual(2);
expect(component.responseFacets[1].buckets.length).toEqual(2);
});
it('should filter out the fetched facet intervals that have bucket values less than their set mincount', () => {
component.responseFacets = null;
queryBuilder.config = {
categories: [],
facetIntervals: {
intervals: [
{ label: 'test_intervals1', field: 'f1', mincount: 2, sets: [
{ label: 'interval1', start: 's1', end: 'e1'},
{ label: 'interval2', start: 's2', end: 'e2'}
]},
{ label: 'test_intervals2', field: 'f2', mincount: 5, sets: [
{ label: 'interval3', start: 's3', end: 'e3'},
{ label: 'interval4', start: 's4', end: 'e4'}
]}
]
}
};
const response1 = [
{ label: 'interval1', filterQuery: 'query1', metrics: [{ value: { count: 1 }}]},
{ label: 'interval2', filterQuery: 'query2', metrics: [{ value: { count: 2 }}]}
];
const response2 = [
{ label: 'interval3', filterQuery: 'query3', metrics: [{ value: { count: 3 }}]},
{ label: 'interval4', filterQuery: 'query4', metrics: [{ value: { count: 4 }}]}
];
const data = {
list: {
context: {
facets: [
{ type: 'interval', label: 'test_intervals1', buckets: response1 },
{ type: 'interval', label: 'test_intervals2', buckets: response2 }
]
}
}
};
component.onDataLoaded(data);
expect(component.responseFacets.length).toBe(1);
expect(component.responseFacets[0].buckets.length).toEqual(1);
});
});
});
describe('widgets', () => {
@@ -919,8 +430,6 @@ describe('SearchFilterComponent', () => {
fixture.detectChanges();
spyOn(queryBuilder, 'update').and.stub();
spyOn(component, 'selectFacetBucket').and.callThrough();
spyOn(component, 'onToggleBucket').and.callThrough();
const inputElement = fixture.debugElement.query(By.css(`${panel} input`));
inputElement.nativeElement.value = 'Extra';
@@ -937,7 +446,7 @@ describe('SearchFilterComponent', () => {
filteredMenu = getAllMenus(`${panel} mat-checkbox`, fixture);
expect(filteredMenu).toEqual(filteredResult);
const clearButton = fixture.debugElement.query(By.css(`${panel} button`));
const clearButton = fixture.debugElement.query(By.css(`${panel} mat-form-field button`));
clearButton.triggerEventHandler('click', {});
fixture.detectChanges();
@@ -951,8 +460,7 @@ describe('SearchFilterComponent', () => {
const checkedOption = fixture.debugElement.query(By.css(`${panel} mat-checkbox.mat-checkbox-checked`));
expect(checkedOption.nativeElement.innerText).toEqual('Extra Small (10239)');
expect(component.onToggleBucket).toHaveBeenCalledTimes(1);
expect(component.selectFacetBucket).toHaveBeenCalledTimes(1);
expect(queryBuilder.update).toHaveBeenCalledTimes(1);
});
it('should preserve the filter state if other fields edited', () => {
@@ -964,8 +472,6 @@ describe('SearchFilterComponent', () => {
queryBuilder.executed.next(<any> mockSearchResult);
fixture.detectChanges();
spyOn(queryBuilder, 'update').and.stub();
spyOn(component, 'selectFacetBucket').and.callThrough();
spyOn(component, 'onToggleBucket').and.callThrough();
const inputElement = fixture.debugElement.query(By.css(`${panel1} input`));
inputElement.nativeElement.value = 'my';
@@ -995,15 +501,20 @@ describe('SearchFilterComponent', () => {
panel1CheckedOption = fixture.debugElement.query(By.css(`${panel1} mat-checkbox.mat-checkbox-checked`));
expect(panel1CheckedOption.nativeElement.innerText).toEqual('my1 (806)');
expect(component.onToggleBucket).toHaveBeenCalledTimes(2);
expect(component.selectFacetBucket).toHaveBeenCalledTimes(2);
expect(queryBuilder.update).toHaveBeenCalledTimes(2);
});
it('should reset the query fragments when reset All is clicked', () => {
component.queryBuilder.queryFragments = { 'fragment1' : 'value1'};
component.responseFacets = [];
spyOn(queryBuilder, 'resetToDefaults').and.stub();
component.resetAll();
appConfigService.config.search = searchFilter;
searchFacetFiltersService.responseFacets = [];
component.displayResetButton = true;
fixture.detectChanges();
spyOn(queryBuilder, 'resetToDefaults').and.callThrough();
const resetButton = fixture.debugElement.query(By.css('button'));
resetButton.nativeElement.click();
expect(component.queryBuilder.queryFragments).toEqual({});
expect(queryBuilder.resetToDefaults).toHaveBeenCalled();
});

View File

@@ -15,22 +15,12 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit, OnDestroy, Inject, Input } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { TranslationService, SearchService } from '@alfresco/adf-core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { Component, Inject, Input, ViewEncapsulation } from '@angular/core';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { FacetFieldBucket } from '../../models/facet-field-bucket.interface';
import { FacetField } from '../../models/facet-field.interface';
import { SearchFilterList } from '../../models/search-filter-list.model';
import { takeUntil } from 'rxjs/operators';
import { GenericBucket, GenericFacetResponse, ResultSetContext, ResultSetPaging } from '@alfresco/js-api';
import { Subject } from 'rxjs';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
export interface SelectedBucket {
field: FacetField;
bucket: FacetFieldBucket;
}
import { SearchFacetFiltersService } from '../../services/search-facet-filters.service';
@Component({
selector: 'adf-search-filter',
@@ -38,36 +28,22 @@ export interface SelectedBucket {
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-filter' }
})
export class SearchFilterComponent implements OnInit, OnDestroy {
export class SearchFilterComponent {
/** Toggles whether to show or not the context facet filters. */
@Input()
showContextFacets: boolean = true;
private DEFAULT_PAGE_SIZE = 5;
/** All facet field items to be displayed in the component. These are updated according to the response.
* When a new search is performed, the already existing items are updated with the new bucket count values and
* the newly received items are added to the responseFacets.
*/
responseFacets: FacetField[] = null;
private facetQueriesPageSize = this.DEFAULT_PAGE_SIZE;
facetQueriesLabel: string = 'Facet Queries';
facetExpanded = {
'default': false
};
displayResetButton: boolean;
selectedBuckets: SelectedBucket[] = [];
private onDestroy$ = new Subject<boolean>();
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) public queryBuilder: SearchQueryBuilderService,
private searchService: SearchService,
private translationService: TranslationService) {
public facetFiltersService: SearchFacetFiltersService) {
if (queryBuilder.config && queryBuilder.config.facetQueries) {
this.facetQueriesLabel = queryBuilder.config.facetQueries.label || 'Facet Queries';
this.facetQueriesPageSize = queryBuilder.config.facetQueries.pageSize || this.DEFAULT_PAGE_SIZE;
this.facetExpanded['query'] = queryBuilder.config.facetQueries.expanded;
}
if (queryBuilder.config && queryBuilder.config.facetFields) {
@@ -77,350 +53,13 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
this.facetExpanded['interval'] = queryBuilder.config.facetIntervals.expanded;
}
this.displayResetButton = this.queryBuilder.config && !!this.queryBuilder.config.resetButton;
this.queryBuilder.updated
.pipe(takeUntil(this.onDestroy$))
.subscribe((query) => this.queryBuilder.execute(query));
}
ngOnInit() {
if (this.queryBuilder) {
this.queryBuilder.executed
.pipe(takeUntil(this.onDestroy$))
.subscribe((resultSetPaging: ResultSetPaging) => {
this.onDataLoaded(resultSetPaging);
this.searchService.dataLoaded.next(resultSetPaging);
});
}
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
private updateSelectedBuckets() {
if (this.responseFacets) {
this.selectedBuckets = [];
for (const field of this.responseFacets) {
if (field.buckets) {
this.selectedBuckets.push(
...this.queryBuilder.getUserFacetBuckets(field.field)
.filter((bucket) => bucket.checked)
.map((bucket) => {
return { field, bucket };
})
);
}
}
} else {
this.selectedBuckets = [];
}
}
onToggleBucket(event: MatCheckboxChange, field: FacetField, bucket: FacetFieldBucket) {
if (event && bucket) {
if (event.checked) {
this.selectFacetBucket(field, bucket);
} else {
this.unselectFacetBucket(field, bucket);
}
}
}
selectFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (bucket) {
bucket.checked = true;
this.queryBuilder.addUserFacetBucket(field, bucket);
this.updateSelectedBuckets();
this.queryBuilder.update();
}
}
unselectFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (bucket) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
this.updateSelectedBuckets();
this.queryBuilder.update();
}
}
canResetSelectedBuckets(field: FacetField): boolean {
if (field && field.buckets) {
return field.buckets.items.some((bucket) => bucket.checked);
}
return false;
}
resetSelectedBuckets(field: FacetField) {
if (field && field.buckets) {
for (const bucket of field.buckets.items) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
}
this.updateSelectedBuckets();
this.queryBuilder.update();
}
}
resetAllSelectedBuckets() {
this.responseFacets.forEach((field) => {
if (field && field.buckets) {
for (const bucket of field.buckets.items) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
}
this.updateSelectedBuckets();
}
});
this.queryBuilder.update();
}
resetQueryFragments() {
this.queryBuilder.queryFragments = {};
this.queryBuilder.resetToDefaults();
}
resetAll() {
this.resetAllSelectedBuckets();
this.resetQueryFragments();
this.responseFacets = null;
}
shouldExpand(field: FacetField): boolean {
return this.facetExpanded[field.type] || this.facetExpanded['default'];
}
onDataLoaded(data: any) {
const context = data.list.context;
if (context) {
this.parseFacets(context);
} else {
this.responseFacets = null;
}
}
private parseFacets(context: ResultSetContext) {
this.parseFacetFields(context);
this.parseFacetIntervals(context);
this.parseFacetQueries(context);
}
private parseFacetItems(context: ResultSetContext, configFacetFields: FacetField[], itemType: string) {
configFacetFields.forEach((field) => {
const responseField = this.findFacet(context, itemType, field.label);
const responseBuckets = this.getResponseBuckets(responseField, field)
.filter(this.getFilterByMinCount(field.mincount));
const alreadyExistingField = this.findResponseFacet(itemType, field.label);
if (alreadyExistingField) {
const alreadyExistingBuckets = alreadyExistingField.buckets && alreadyExistingField.buckets.items || [];
this.updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets);
} else if (responseField && this.showContextFacets) {
if (responseBuckets.length > 0) {
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, field.pageSize);
bucketList.filter = this.getBucketFilterFunction(bucketList);
if (!this.responseFacets) {
this.responseFacets = [];
}
this.responseFacets.push(<FacetField> {
...field,
type: responseField.type || itemType,
label: field.label,
pageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
currentPageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
buckets: bucketList
});
}
}
});
}
private parseFacetFields(context: ResultSetContext) {
const configFacetFields = this.queryBuilder.config.facetFields && this.queryBuilder.config.facetFields.fields || [];
this.parseFacetItems(context, configFacetFields, 'field');
}
private parseFacetIntervals(context: ResultSetContext) {
const configFacetIntervals = this.queryBuilder.config.facetIntervals && this.queryBuilder.config.facetIntervals.intervals || [];
this.parseFacetItems(context, configFacetIntervals, 'interval');
}
private parseFacetQueries(context: ResultSetContext) {
const configFacetQueries = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.queries || [];
const configGroups = configFacetQueries.reduce((acc, query) => {
const group = this.queryBuilder.getQueryGroup(query);
if (acc[group]) {
acc[group].push(query);
} else {
acc[group] = [query];
}
return acc;
}, []);
const mincount = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.mincount;
const mincountFilter = this.getFilterByMinCount(mincount);
Object.keys(configGroups).forEach((group) => {
const responseField = this.findFacet(context, 'query', group);
const responseBuckets = this.getResponseQueryBuckets(responseField, configGroups[group])
.filter(mincountFilter);
const alreadyExistingField = this.findResponseFacet('query', group);
if (alreadyExistingField) {
const alreadyExistingBuckets = alreadyExistingField.buckets && alreadyExistingField.buckets.items || [];
this.updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets);
} else if (responseField && this.showContextFacets) {
if (responseBuckets.length > 0) {
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, this.facetQueriesPageSize);
bucketList.filter = this.getBucketFilterFunction(bucketList);
if (!this.responseFacets) {
this.responseFacets = [];
}
this.responseFacets.push(<FacetField> {
field: group,
type: responseField.type || 'query',
label: group,
pageSize: this.DEFAULT_PAGE_SIZE,
currentPageSize: this.DEFAULT_PAGE_SIZE,
buckets: bucketList
});
}
}
});
}
private getResponseBuckets(responseField: GenericFacetResponse, configField: FacetField): FacetFieldBucket[] {
return ((responseField && responseField.buckets) || []).map((respBucket) => {
respBucket['count'] = this.getCountValue(respBucket);
respBucket.filterQuery = respBucket.filterQuery || this.getCorrespondingFilterQuery(configField, respBucket.label);
return <FacetFieldBucket> {
...respBucket,
checked: false,
display: respBucket.display,
label: respBucket.label
};
});
}
private getResponseQueryBuckets(responseField: GenericFacetResponse, configGroup: any): FacetFieldBucket[] {
return (configGroup || []).map((query) => {
const respBucket = ((responseField && responseField.buckets) || [])
.find((bucket) => bucket.label === query.label) || {};
respBucket['count'] = this.getCountValue(respBucket);
return <FacetFieldBucket> {
...respBucket,
checked: false,
display: respBucket.display,
label: respBucket.label
};
});
}
private getCountValue(bucket: GenericBucket): number {
return (!!bucket && !!bucket.metrics && bucket.metrics[0]?.value?.count) || 0;
}
getBucketCountDisplay(bucket: FacetFieldBucket): string {
return bucket.count === null ? '' : `(${bucket.count})`;
}
private getFilterByMinCount(mincountInput: number) {
return (bucket) => {
let mincount = mincountInput;
if (mincount === undefined) {
mincount = 1;
}
return bucket.count >= mincount;
};
}
private getCorrespondingFilterQuery(configFacetItem: FacetField, bucketLabel: string): string {
let filterQuery = null;
if (configFacetItem.field && bucketLabel) {
if (configFacetItem.sets) {
const configSet = configFacetItem.sets.find((set) => bucketLabel === set.label);
if (configSet) {
filterQuery = this.buildIntervalQuery(configFacetItem.field, configSet);
}
} else {
filterQuery = `${configFacetItem.field}:"${bucketLabel}"`;
}
}
return filterQuery;
}
private buildIntervalQuery(fieldName: string, interval: any): string {
const start = interval.start;
const end = interval.end;
const startLimit = (interval.startInclusive === undefined || interval.startInclusive === true) ? '[' : '<';
const endLimit = (interval.endInclusive === undefined || interval.endInclusive === true) ? ']' : '>';
return `${fieldName}:${startLimit}"${start}" TO "${end}"${endLimit}`;
}
private findFacet(context: ResultSetContext, itemType: string, fieldLabel: string): GenericFacetResponse {
return (context.facets || []).find((response) => response.type === itemType && response.label === fieldLabel) || {};
}
private findResponseFacet(itemType: string, fieldLabel: string): FacetField {
return (this.responseFacets || []).find((response) => response.type === itemType && response.label === fieldLabel);
}
private updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets) {
const bucketsToDelete = [];
alreadyExistingBuckets
.map((bucket) => {
const responseBucket = ((responseField && responseField.buckets) || []).find((respBucket) => respBucket.label === bucket.label);
if (!responseBucket) {
bucketsToDelete.push(bucket);
}
bucket.count = this.getCountValue(responseBucket);
return bucket;
});
const hasSelection = this.selectedBuckets
.find((selBuckets) => alreadyExistingField.label === selBuckets.field.label && alreadyExistingField.type === selBuckets.field.type);
if (!hasSelection && bucketsToDelete.length) {
bucketsToDelete.forEach((bucket) => {
alreadyExistingField.buckets.deleteItem(bucket);
});
}
responseBuckets.forEach((respBucket) => {
const existingBucket = alreadyExistingBuckets.find((oldBucket) => oldBucket.label === respBucket.label);
if (!existingBucket) {
alreadyExistingField.buckets.addItem(respBucket);
}
});
}
private getBucketFilterFunction(bucketList) {
return (bucket: FacetFieldBucket): boolean => {
if (bucket && bucketList.filterText) {
const pattern = (bucketList.filterText || '').toLowerCase();
const label = (this.translationService.instant(bucket.display) || this.translationService.instant(bucket.label)).toLowerCase();
return this.queryBuilder.config.filterWithContains ? label.indexOf(pattern) !== -1 : label.startsWith(pattern);
}
return true;
};
}
}

View File

@@ -1,8 +1,33 @@
<mat-form-field floatLabel="always" *ngIf="searchForms.length">
<mat-label>{{ 'SEARCH.FORMS' | translate }}</mat-label>
<mat-select [(value)]="selected" (selectionChange)="onSelectionChange($event.value)">
<mat-option *ngFor="let form of searchForms" [value]="form.index">
{{form.name | translate}}
</mat-option>
</mat-select>
</mat-form-field>
<ng-container *ngIf="queryBuilder.searchForms | async as forms">
<ng-container *ngIf="forms.length === 1">
<button class="adf-search-form" disableRipple mat-button [title]="getSelected(forms) | translate">
<span class="adf-search-form-title">
{{ getSelected(forms) | translate }}
</span>
</button>
</ng-container>
<ng-container *ngIf="forms.length > 1">
<button class="adf-search-form"
[matMenuTriggerFor]="menu"
#menuTrigger="matMenuTrigger"
disableRipple
mat-button
[title]="getSelected(forms) | translate"
[matMenuTriggerRestoreFocus]="true">
<span class="adf-search-form-title" >
{{ getSelected(forms) | translate }}
</span>
<mat-icon [class.adf-search-form-icon-selected]="menuTrigger.menuOpen" class="adf-search-form-icon">expand_more</mat-icon>
</button>
<mat-menu #menu="matMenu" backdropClass="adf-search-form-menu">
<button mat-menu-item *ngFor="let form of forms" (click)="onSelectionChange(form)">
{{form.name | translate}}
</button>
</mat-menu>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,49 @@
@mixin adf-search-forms-theme($theme) {
$accent: map-get($theme, accent);
.adf-search-form {
&.mat-button {
height: 35px;
max-width: 190px;
min-width: 190px;
align-content: center;
overflow: hidden;
.mat-button-wrapper {
display: flex;
align-items: center;
}
}
&-title {
max-width: 120px;
min-width: 120px;
font-weight: bold;
font-size: 14px;
line-height: 24px;
padding-right: 12px;
text-overflow: ellipsis;
overflow: hidden;
text-align: left;
}
&-icon {
border: 2px solid transparent;
border-radius: 6px;
transition: border 500ms ease-out;
}
&-icon-selected {
border-color: mat-color($accent);
}
&-menu + * .mat-menu-panel {
@include mat-elevation(2);
border-radius: 6px;
.mat-menu-content {
padding: 0;
}
}
}
}

View File

@@ -16,20 +16,19 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchFormComponent } from './search-form.component';
import { setupTestBed } from '@alfresco/adf-core';
import { TranslateModule } from '@ngx-translate/core';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchForm } from '../../models/search-form.interface';
import { By } from '@angular/platform-browser';
describe('SearchFormComponent', () => {
let fixture: ComponentFixture<SearchFormComponent>;
let component: SearchFormComponent;
let queryBuilder: SearchHeaderQueryBuilderService;
let queryBuilder: SearchQueryBuilderService;
const mockSearchForms: SearchForm[] = [
{ default: false, index: 0, name: 'All', selected: false },
{ default: true, index: 1, name: 'First', selected: true },
@@ -42,50 +41,59 @@ describe('SearchFormComponent', () => {
ContentTestingModule
],
providers: [
{ provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchHeaderQueryBuilderService }
{ provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchQueryBuilderService }
]
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchFormComponent);
component = fixture.componentInstance;
queryBuilder = TestBed.inject<SearchHeaderQueryBuilderService>(SEARCH_QUERY_SERVICE_TOKEN);
spyOn(queryBuilder, 'getSearchConfigurationDetails').and.returnValue(mockSearchForms);
queryBuilder = TestBed.inject<SearchQueryBuilderService>(SEARCH_QUERY_SERVICE_TOKEN);
queryBuilder.searchForms.next(mockSearchForms);
fixture.detectChanges();
});
it('should show search forms', async () => {
await fixture.whenStable();
fixture.detectChanges();
expect(component.selected).toBe(1);
const label = fixture.debugElement.query(By.css('.mat-form-field mat-label'));
expect(label.nativeElement.innerText).toContain('SEARCH.FORMS');
const selectValue = fixture.debugElement.query(By.css('.mat-select-value'));
expect(selectValue.nativeElement.innerText).toContain('First');
it('should show search forms', () => {
const title = fixture.debugElement.query(By.css('.adf-search-form-title'));
expect(title.nativeElement.innerText).toContain(mockSearchForms[1].name);
});
it('should emit on form change', async (done) => {
it('should emit on form change', (done) => {
spyOn(queryBuilder, 'updateSelectedConfiguration').and.stub();
component.formChange.subscribe((form) => {
expect(form).toEqual(mockSearchForms[2]);
expect(queryBuilder.updateSelectedConfiguration).toHaveBeenCalled();
done();
});
await fixture.whenStable();
const button = fixture.debugElement.query(By.css('.adf-search-form')).nativeElement;
button.click();
fixture.detectChanges();
const matSelect = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
matSelect.click();
fixture.detectChanges();
const matOption = fixture.debugElement.queryAll(By.css('.mat-option'))[2].nativeElement;
const matOption = fixture.debugElement.queryAll(By.css('.mat-menu-item'))[2].nativeElement;
matOption.click();
});
it('should not display search form if no form configured', async () => {
component.searchForms = [];
await fixture.whenStable();
it('should not show menu if only one config found', () => {
queryBuilder.searchForms.next([{ name: 'one', selected: true, default: true, index: 0 }]);
fixture.detectChanges();
const field = fixture.debugElement.query(By.css('.mat-form-field'));
const button = fixture.debugElement.query(By.css('.adf-search-form')).nativeElement;
button.click();
const title = fixture.debugElement.query(By.css('.adf-search-form-title'));
expect(title.nativeElement.innerText).toContain('one');
fixture.detectChanges();
const matOption = fixture.debugElement.query(By.css('.mat-menu-item'));
expect(matOption).toBe(null, 'should not show mat menu');
});
it('should not display search form if no form configured', () => {
queryBuilder.searchForms.next([]);
fixture.detectChanges();
const field = fixture.debugElement.query(By.css('.adf-search-form-title'));
expect(field).toEqual(null, 'search form displayed for empty configuration');
});
});

View File

@@ -15,30 +15,30 @@
* limitations under the License.
*/
import { Component, EventEmitter, Inject, OnInit, Output } from '@angular/core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { Component, EventEmitter, Inject, Output, ViewEncapsulation } from '@angular/core';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchForm } from '../../models/search-form.interface';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
@Component({
selector: 'adf-search-form',
templateUrl: './search-form.component.html'
templateUrl: './search-form.component.html',
styleUrls: ['./search-form.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SearchFormComponent implements OnInit {
export class SearchFormComponent {
@Output()
formChange: EventEmitter<SearchForm> = new EventEmitter<SearchForm>();
selected: number;
searchForms: SearchForm[] = [];
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private queryBuilder: SearchQueryBuilderService) {}
ngOnInit(): void {
this.searchForms = this.queryBuilder.getSearchConfigurationDetails();
this.selected = this.searchForms.find(form => form.selected)?.index;
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) public queryBuilder: SearchQueryBuilderService) {
}
onSelectionChange(index: number) {
this.formChange.emit(this.searchForms[index]);
onSelectionChange(form: SearchForm) {
this.queryBuilder.updateSelectedConfiguration(form.index);
this.formChange.emit(form);
}
getSelected(forms: SearchForm[]): string {
return forms.find((form) => form.selected)?.name;
}
}

View File

@@ -29,8 +29,8 @@
</mat-form-field>
<div class="adf-facet-buttons">
<button mat-button color="primary" type="button" (click)="reset()" data-automation-id="number-range-btn-clear">
<div class="adf-facet-buttons" *ngIf="!settings?.hideDefaultAction">
<button mat-button color="primary" type="button" (click)="clear()" data-automation-id="number-range-btn-clear">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
<button mat-button color="primary" type="submit" [disabled]="!form.valid" data-automation-id="number-range-btn-apply">

View File

@@ -15,12 +15,13 @@
* limitations under the License.
*/
import { OnInit, Component, ViewEncapsulation } from '@angular/core';
import { FormControl, Validators, FormGroup } from '@angular/forms';
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { LiveErrorStateMatcher } from '../../forms/live-error-state-matcher';
import { Subject } from 'rxjs';
@Component({
selector: 'adf-search-number-range',
@@ -48,6 +49,8 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
startValue: any;
validators: Validators;
enableChangeUpdate: boolean;
displayValue$: Subject<string> = new Subject<string>();
ngOnInit(): void {
@@ -74,6 +77,9 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
from: this.from,
to: this.to
}, this.formValidator);
this.enableChangeUpdate = this.settings?.allowUpdateOnChange ?? true;
this.updateDisplayValue();
}
formValidator(formGroup: FormGroup) {
@@ -82,6 +88,7 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
apply(model: { from: string, to: string }, isValid: boolean) {
if (isValid && this.id && this.context && this.field) {
this.updateDisplayValue();
this.isActive = true;
const map = new Map<string, string>();
@@ -118,12 +125,21 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
return this.form.value;
}
updateDisplayValue(): void {
if (this.form.invalid || this.form.pristine) {
this.displayValue$.next('');
} else {
this.displayValue$.next(`${this.form.value.from} - ${this.form.value.to} ${this.settings.unit ?? ''}`);
}
}
setValue(value: any) {
this.form['from'].setValue(value);
this.form['to'].setValue(value);
this.updateDisplayValue();
}
reset() {
clear() {
this.isActive = false;
this.form.reset({
@@ -133,6 +149,16 @@ export class SearchNumberRangeComponent implements SearchWidget, OnInit {
if (this.id && this.context) {
this.context.queryFragments[this.id] = '';
this.updateDisplayValue();
if (this.enableChangeUpdate) {
this.context.update();
}
}
}
reset() {
this.clear();
if (this.id && this.context) {
this.context.update();
}
}

View File

@@ -17,7 +17,7 @@
import { Component, Inject, OnInit, ViewEncapsulation } from '@angular/core';
import { ContentNodeSelectorPanelService } from '../../../content-node-selector/content-node-selector-panel.service';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
@Component({

View File

@@ -15,13 +15,14 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { MatRadioChange } from '@angular/material/radio';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchFilterList } from '../../models/search-filter-list.model';
import { Subject } from 'rxjs';
export interface SearchRadioOption {
name: string;
@@ -48,6 +49,8 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
pageSize = 5;
isActive = false;
startValue: any;
enableChangeUpdate: boolean;
displayValue$: Subject<string> = new Subject<string>();
constructor() {
this.options = new SearchFilterList<SearchRadioOption>();
@@ -73,6 +76,8 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
this.value = initialValue;
this.context.queryFragments[this.id] = initialValue;
}
this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true;
this.updateDisplayValue();
}
private getSelectedValue(): string {
@@ -90,8 +95,9 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
}
submitValues() {
const currentValue = this.getSelectedValue();
this.setValue(currentValue);
this.setValue(this.value);
this.updateDisplayValue();
this.context.update();
}
hasValidValue() {
@@ -102,23 +108,43 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
setValue(newValue: string) {
this.value = newValue;
this.context.queryFragments[this.id] = newValue;
this.context.update();
if (this.enableChangeUpdate) {
this.updateDisplayValue();
this.context.update();
}
}
getCurrentValue() {
return this.getSelectedValue();
}
updateDisplayValue(): void {
const selectOptions = this.options.items.find(({ value}) => value === this.value);
if (selectOptions) {
this.displayValue$.next(selectOptions.name);
} else {
this.displayValue$.next('');
}
}
changeHandler(event: MatRadioChange) {
this.setValue(event.value);
}
reset() {
clear() {
this.isActive = false;
const initialValue = this.getSelectedValue();
if (initialValue !== null) {
this.setValue(initialValue);
}
}
reset() {
const initialValue = this.getSelectedValue();
if (initialValue !== null) {
this.setValue(initialValue);
this.updateDisplayValue();
this.context.update();
}
}
}

View File

@@ -8,8 +8,8 @@
data-automation-id="slider-range">
</mat-slider>
<div class="adf-facet-buttons">
<button mat-button color="primary" (click)="reset()" data-automation-id="slider-btn-clear">
<div class="adf-facet-buttons" *ngIf="!settings?.hideDefaultAction">
<button mat-button color="primary" (click)="clear()" data-automation-id="slider-btn-clear">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
</div>

View File

@@ -74,14 +74,13 @@ describe('SearchSliderComponent', () => {
component.context = context;
component.id = 'contentSize';
component.settings = { field: 'cm:content.size' };
fixture.detectChanges();
component.onChangedHandler(<MatSliderChange> { value: 10 });
fixture.detectChanges();
expect(context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 10]');
expect(context.update).toHaveBeenCalled();
component.onChangedHandler(<MatSliderChange> { value: 20 });
fixture.detectChanges();
expect(context.queryFragments[component.id]).toEqual('cm:content.size:[0 TO 20]');
});

View File

@@ -15,11 +15,12 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { MatSliderChange } from '@angular/material/slider';
import { Subject } from 'rxjs';
@Component({
selector: 'adf-search-slider',
@@ -39,6 +40,8 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
min: number;
max: number;
thumbLabel = false;
enableChangeUpdate: boolean;
displayValue$: Subject<string> = new Subject<string>();
/** The numeric value represented by the slider. */
@Input()
@@ -59,6 +62,7 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
}
this.thumbLabel = this.settings['thumbLabel'] ? true : false;
this.enableChangeUpdate = this.settings.allowUpdateOnChange ?? true;
}
if (this.startValue) {
@@ -66,6 +70,13 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
}
}
clear() {
this.value = this.min || 0;
if (this.enableChangeUpdate) {
this.updateQuery(null);
}
}
reset() {
this.value = this.min || 0;
this.updateQuery(null);
@@ -73,7 +84,9 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
onChangedHandler(event: MatSliderChange) {
this.value = event.value;
this.updateQuery(this.value);
if (this.enableChangeUpdate) {
this.updateQuery(this.value);
}
}
submitValues() {
@@ -94,6 +107,7 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
}
private updateQuery(value: number | null) {
this.displayValue$.next( this.value ? `${this.value} ${this.settings.unit ?? ''}` : '' );
if (this.id && this.context && this.settings && this.settings.field) {
if (value === null) {
this.context.queryFragments[this.id] = '';

View File

@@ -16,7 +16,7 @@
*/
import { SearchSortingPickerComponent } from './search-sorting-picker.component';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { AppConfigService } from '@alfresco/adf-core';
import { SearchConfiguration } from '../../models/search-configuration.interface';
import { TestBed } from '@angular/core/testing';

View File

@@ -16,7 +16,7 @@
*/
import { Component, OnInit, ViewEncapsulation, Inject } from '@angular/core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { SearchSortingDefinition } from '../../models/search-sorting-definition.interface';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';

View File

@@ -4,7 +4,7 @@
placeholder="{{ settings?.placeholder | translate }}"
[(ngModel)]="value"
(change)="onChangedHandler($event)">
<button mat-button *ngIf="value" matSuffix mat-icon-button (click)="reset()">
<button mat-button *ngIf="value" matSuffix mat-icon-button (click)="clear()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>

View File

@@ -15,10 +15,11 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core';
import { SearchWidget } from '../../models/search-widget.interface';
import { SearchWidgetSettings } from '../../models/search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchQueryBuilderService } from '../../services/search-query-builder.service';
import { Subject } from 'rxjs';
@Component({
selector: 'adf-search-text',
@@ -39,6 +40,7 @@ export class SearchTextComponent implements SearchWidget, OnInit {
startValue: string;
isActive = false;
enableChangeUpdate = true;
displayValue$: Subject<string> = new Subject<string>();
ngOnInit() {
if (this.context && this.settings && this.settings.pattern) {
@@ -59,9 +61,15 @@ export class SearchTextComponent implements SearchWidget, OnInit {
}
}
reset() {
clear() {
this.isActive = false;
this.value = '';
if (this.enableChangeUpdate) {
this.updateQuery(null);
}
}
reset() {
this.value = '';
this.updateQuery(null);
}
@@ -75,11 +83,11 @@ export class SearchTextComponent implements SearchWidget, OnInit {
}
private updateQuery(value: string) {
this.displayValue$.next(value);
if (this.context && this.settings && this.settings.field) {
this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${this.getSearchPrefix()}${value}${this.getSearchSuffix()}'` : '';
this.context.update();
}
}
submitValues() {
@@ -96,6 +104,7 @@ export class SearchTextComponent implements SearchWidget, OnInit {
setValue(value: string) {
this.value = value;
this.displayValue$.next(this.value);
this.submitValues();
}

View File

@@ -15,10 +15,23 @@
* limitations under the License.
*/
import { Component, Input, ViewChild, ViewContainerRef, OnInit, OnDestroy, ComponentRef, ComponentFactoryResolver, Inject, SimpleChanges, OnChanges } from '@angular/core';
import { SearchFilterService } from '../search-filter/search-filter.service';
import { BaseQueryBuilderService } from '../../base-query-builder.service';
import {
Component,
Input,
ViewChild,
ViewContainerRef,
OnInit,
OnDestroy,
ComponentRef,
ComponentFactoryResolver,
Inject,
SimpleChanges,
OnChanges
} from '@angular/core';
import { SearchFilterService } from '../../services/search-filter.service';
import { BaseQueryBuilderService } from '../../services/base-query-builder.service';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { Observable } from 'rxjs';
@Component({
selector: 'adf-search-widget-container',
@@ -74,7 +87,7 @@ export class SearchWidgetContainerComponent implements OnInit, OnDestroy, OnChan
private setupWidget(ref: ComponentRef<any>) {
if (ref && ref.instance) {
ref.instance.id = this.id;
ref.instance.settings = { ...this.settings };
ref.instance.settings = {...this.settings};
ref.instance.context = this.queryBuilder;
if (this.value) {
ref.instance.isActive = true;
@@ -107,6 +120,13 @@ export class SearchWidgetContainerComponent implements OnInit, OnDestroy, OnChan
return this.componentRef.instance.getCurrentValue();
}
getDisplayValue(): Observable<string> | null {
if (!this.componentRef?.instance) {
return null;
}
return this.componentRef.instance.displayValue$;
}
resetInnerWidget() {
if (this.componentRef && this.componentRef.instance) {
this.componentRef.instance.reset();

View File

@@ -31,5 +31,13 @@ export interface FacetField {
currentPageSize?: number;
checked?: boolean;
type?: string;
settings?: FacetFieldSettings;
[propName: string]: any;
}
export interface FacetFieldSettings {
/* allow the user to update search in every change */
allowUpdateOnChange?: boolean;
/* allow the user show/hide default search actions */
hideDefaultAction?: boolean;
}

View File

@@ -0,0 +1,27 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { Subject } from 'rxjs';
export interface FacetWidget {
/* provide the formatted selected value for chip */
displayValue$: Subject<string>;
/* reset the value and update the search */
reset(): void;
/* update the search with field value */
submitValues(): void;
}

View File

@@ -17,7 +17,7 @@
import { FilterQuery } from './filter-query.interface';
import { FacetQuery } from './facet-query.interface';
import { FacetField } from './facet-field.interface';
import { FacetField, FacetFieldSettings } from './facet-field.interface';
import { SearchCategory } from './search-category.interface';
import { SearchSortingDefinition } from './search-sorting-definition.interface';
import { RequestHighlight } from '@alfresco/js-api';
@@ -35,6 +35,7 @@ export interface SearchConfiguration {
expanded?: boolean;
mincount?: number;
queries: FacetQuery[];
settings?: FacetFieldSettings;
};
facetFields?: {
expanded?: boolean;

View File

@@ -17,5 +17,14 @@
export interface SearchWidgetSettings {
field: string;
/* allow the user to update search in every change */
allowUpdateOnChange?: boolean;
/* allow the user hide default search actions. So widget can have custom actions */
hideDefaultAction?: boolean;
/* describes the unit of the value i.e byte for better display message */
unit?: string;
/* describes query format */
format?: string;
[indexer: string]: any;
}

View File

@@ -16,17 +16,23 @@
*/
import { SearchWidgetSettings } from './search-widget-settings.interface';
import { SearchQueryBuilderService } from '../search-query-builder.service';
import { SearchQueryBuilderService } from '../services/search-query-builder.service';
import { Subject } from 'rxjs';
export interface SearchWidget {
id: string;
/* optional field control options */
settings?: SearchWidgetSettings;
context?: SearchQueryBuilderService;
isActive?: boolean;
startValue: any;
reset();
submitValues();
hasValidValue();
getCurrentValue();
/* stream emit value on changes */
displayValue$: Subject<string>;
/* reset the value and update the search */
reset(): void;
/* update the search with field value */
submitValues(): void;
hasValidValue(): boolean;
getCurrentValue(): any;
setValue(value: any);
}

View File

@@ -24,12 +24,12 @@ export * from './models/search-category.interface';
export * from './models/search-widget-settings.interface';
export * from './models/search-widget.interface';
export * from './models/search-configuration.interface';
export * from './search-query-builder.service';
export * from './services/search-query-builder.service';
export * from './models/search-range.interface';
export * from './models/search-form.interface';
export * from './search-query-service.token';
export * from './search-header-query-builder.service';
export * from './services/search-header-query-builder.service';
export * from './components/search.component';
export * from './components/search-control.component';
@@ -41,7 +41,7 @@ export * from './components/search-check-list/search-check-list.component';
export * from './components/search-chip-list/search-chip-list.component';
export * from './components/search-date-range/search-date-range.component';
export * from './components/search-filter/search-filter.component';
export * from './components/search-filter/search-filter.service';
export * from './services/search-filter.service';
export * from './components/search-filter-container/search-filter-container.component';
export * from './components/search-number-range/search-number-range.component';
export * from './components/search-radio/search-radio.component';
@@ -52,5 +52,10 @@ export * from './components/search-text/search-text.component';
export * from './components/search-widget-container/search-widget-container.component';
export * from './components/search-datetime-range/search-datetime-range.component';
export * from './components/search-form/search-form.component';
export * from './services/search-facet-filters.service';
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/reset-search.directive';
export * from './search.module';

View File

@@ -16,6 +16,6 @@
*/
import { InjectionToken } from '@angular/core';
import { BaseQueryBuilderService } from './base-query-builder.service';
import { BaseQueryBuilderService } from './services/base-query-builder.service';
export const SEARCH_QUERY_SERVICE_TOKEN = new InjectionToken<BaseQueryBuilderService>('QueryService');

View File

@@ -37,10 +37,16 @@ import { SearchCheckListComponent } from './components/search-check-list/search-
import { SearchDateRangeComponent } from './components/search-date-range/search-date-range.component';
import { SearchSortingPickerComponent } from './components/search-sorting-picker/search-sorting-picker.component';
import { SEARCH_QUERY_SERVICE_TOKEN } from './search-query-service.token';
import { SearchQueryBuilderService } from './search-query-builder.service';
import { SearchQueryBuilderService } from './services/search-query-builder.service';
import { SearchFilterContainerComponent } from './components/search-filter-container/search-filter-container.component';
import { SearchDatetimeRangeComponent } from './components/search-datetime-range/search-datetime-range.component';
import { SearchFormComponent } from './components/search-form/search-form.component';
import { SearchFilterChipsComponent } from './components/search-filter-chips/search-filter-chips.component';
import { SearchFilterMenuCardComponent } from './components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component';
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 { ResetSearchDirective } from './components/reset-search.directive';
@NgModule({
imports: [
@@ -67,7 +73,13 @@ import { SearchFormComponent } from './components/search-form/search-form.compon
SearchDatetimeRangeComponent,
SearchSortingPickerComponent,
SearchFilterContainerComponent,
SearchFormComponent
SearchFormComponent,
SearchFilterChipsComponent,
SearchFilterMenuCardComponent,
SearchFacetFieldComponent,
SearchWidgetChipComponent,
SearchFacetChipComponent,
ResetSearchDirective
],
exports: [
SearchComponent,
@@ -86,11 +98,14 @@ import { SearchFormComponent } from './components/search-form/search-form.compon
SearchDatetimeRangeComponent,
SearchSortingPickerComponent,
SearchFilterContainerComponent,
SearchFormComponent
SearchFormComponent,
SearchFilterChipsComponent,
SearchFilterMenuCardComponent,
SearchFacetFieldComponent,
ResetSearchDirective
],
providers: [
{ provide: SEARCH_QUERY_SERVICE_TOKEN, useExisting: SearchQueryBuilderService },
SearchSortingPickerComponent
{ provide: SEARCH_QUERY_SERVICE_TOKEN, useExisting: SearchQueryBuilderService }
]
})
export class SearchModule {}

View File

@@ -16,7 +16,7 @@
*/
import { Injectable } from '@angular/core';
import { Subject, Observable, from } from 'rxjs';
import { Subject, Observable, from, ReplaySubject } from 'rxjs';
import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core';
import {
QueryBody,
@@ -27,15 +27,15 @@ import {
RequestHighlight,
RequestScope
} from '@alfresco/js-api';
import { SearchCategory } from './models/search-category.interface';
import { FilterQuery } from './models/filter-query.interface';
import { SearchRange } from './models/search-range.interface';
import { SearchConfiguration } from './models/search-configuration.interface';
import { FacetQuery } from './models/facet-query.interface';
import { SearchSortingDefinition } from './models/search-sorting-definition.interface';
import { FacetField } from './models/facet-field.interface';
import { FacetFieldBucket } from './models/facet-field-bucket.interface';
import { SearchForm } from './models/search-form.interface';
import { SearchCategory } from '../models/search-category.interface';
import { FilterQuery } from '../models/filter-query.interface';
import { SearchRange } from '../models/search-range.interface';
import { SearchConfiguration } from '../models/search-configuration.interface';
import { FacetQuery } from '../models/facet-query.interface';
import { SearchSortingDefinition } from '../models/search-sorting-definition.interface';
import { FacetField } from '../models/facet-field.interface';
import { FacetFieldBucket } from '../models/facet-field-bucket.interface';
import { SearchForm } from '../models/search-form.interface';
@Injectable({
providedIn: 'root'
@@ -54,6 +54,9 @@ export abstract class BaseQueryBuilderService {
/* Stream that emits the error whenever user search */
error = new Subject();
/* Stream that emits search forms */
searchForms = new ReplaySubject<SearchForm[]>(1);
categories: SearchCategory[] = [];
queryFragments: { [id: string]: string } = {};
filterQueries: FilterQuery[] = [];
@@ -92,14 +95,16 @@ export abstract class BaseQueryBuilderService {
public resetToDefaults() {
const currentConfig = this.getDefaultConfiguration();
this.resetSearchOptions();
this.configUpdated.next(currentConfig);
this.searchForms.next(this.getSearchFormDetails());
this.setUpSearchConfiguration(currentConfig);
}
public getDefaultConfiguration(): SearchConfiguration | undefined {
const configurations = this.loadConfiguration();
if (this.selectedConfiguration >= 0) {
if (this.selectedConfiguration !== undefined) {
return configurations[this.selectedConfiguration];
}
@@ -112,8 +117,9 @@ export abstract class BaseQueryBuilderService {
public updateSelectedConfiguration(index: number): void {
const currentConfig = this.loadConfiguration();
if (Array.isArray(currentConfig) && currentConfig[index] !== undefined) {
this.configUpdated.next(currentConfig[index]);
this.selectedConfiguration = index;
this.configUpdated.next(currentConfig[index]);
this.searchForms.next(this.getSearchFormDetails());
this.resetSearchOptions();
this.setUpSearchConfiguration(currentConfig[index]);
this.update();
@@ -126,18 +132,21 @@ export abstract class BaseQueryBuilderService {
this.filterQueries = [];
this.sorting = [];
this.sortingOptions = [];
this.userFacetBuckets = {};
this.scope = null;
}
public getSearchConfigurationDetails(): SearchForm[] {
public getSearchFormDetails(): SearchForm[] {
const configurations = this.loadConfiguration();
if (Array.isArray(configurations)) {
return configurations.map((configuration, index) => ({
index,
name: configuration.name || 'SEARCH.UNKNOWN_FORM',
name: configuration.name || 'SEARCH.UNKNOWN_CONFIGURATION',
default: configuration.default || false,
selected: this.selectedConfiguration !== undefined ? index === this.selectedConfiguration : configuration.default
}));
} else if (!!configurations) {
return [{ index: 0, name: configurations.name || 'SEARCH.UNKNOWN_CONFIGURATION', default: true, selected: true }];
}
return [];
}

View File

@@ -0,0 +1,419 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { TestBed } from '@angular/core/testing';
import { SearchFacetFiltersService } from './search-facet-filters.service';
import { ContentTestingModule } from '../../testing/content.testing.module';
import { SearchQueryBuilderService } from './search-query-builder.service';
describe('SearchFacetFiltersService', () => {
let searchFacetFiltersService: SearchFacetFiltersService;
let queryBuilder: SearchQueryBuilderService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ContentTestingModule]
});
searchFacetFiltersService = TestBed.inject(SearchFacetFiltersService);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
});
it('should subscribe to query builder executed event', () => {
spyOn(searchFacetFiltersService, 'onDataLoaded').and.stub();
const data = { list: {} };
queryBuilder.executed.next(data);
expect(searchFacetFiltersService.onDataLoaded).toHaveBeenCalledWith(data);
});
it('should fetch facet queries from response payload', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetQueries: {
label: 'label1',
queries: [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
]
}
};
const queries = [
{ label: 'q1', filterQuery: 'query1', metrics: [{value: {count: 1}}] },
{ label: 'q2', filterQuery: 'query2', metrics: [{value: {count: 1}}] }
];
const data = {
list: {
context: {
facets: [{
type: 'query',
label: 'label1',
buckets: queries
}]
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toBe(1);
expect(searchFacetFiltersService.responseFacets[0].buckets.length).toEqual(2);
});
it('should preserve order after response processing', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetQueries: {
label: 'label1',
queries: [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' },
{ label: 'q3', query: 'query3' }
]
}
};
const queries = [
{ label: 'q2', filterQuery: 'query2', metrics: [{value: {count: 1}}] },
{ label: 'q1', filterQuery: 'query1', metrics: [{value: {count: 1}}] },
{ label: 'q3', filterQuery: 'query3', metrics: [{value: {count: 1}}] }
];
const data = {
list: {
context: {
facets: [{
type: 'query',
label: 'label1',
buckets: queries
}]
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toBe(1);
expect(searchFacetFiltersService.responseFacets[0].buckets.length).toBe(3);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].label).toBe('q1');
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].label).toBe('q2');
expect(searchFacetFiltersService.responseFacets[0].buckets.items[2].label).toBe('q3');
});
it('should not fetch facet queries from response payload', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetQueries: {
queries: []
}
};
const data = {
list: {
context: {
facets: null
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets).toBeNull();
});
it('should fetch facet fields from response payload', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1', mincount: 0 },
{ label: 'f2', field: 'f2', mincount: 0 }
]},
facetQueries: {
queries: []
}
};
const fields: any = [
{ type: 'field', label: 'f1', buckets: [{ label: 'a1' }, { label: 'a2' }] },
{ type: 'field', label: 'f2', buckets: [{ label: 'b1' }, { label: 'b2' }] }
];
const data = {
list: {
context: {
facets: fields
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[0].buckets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[1].buckets.length).toEqual(2);
});
it('should filter response facet fields based on search filter config method', () => {
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' }
]},
facetQueries: {
queries: []
},
filterWithContains: false
};
const initialFields: any = [
{ type: 'field', label: 'f1', buckets: [
{ label: 'firstLabel', display: 'firstLabel', metrics: [{value: {count: 5}}] },
{ label: 'secondLabel', display: 'secondLabel', metrics: [{value: {count: 5}}] },
{ label: 'thirdLabel', display: 'thirdLabel', metrics: [{value: {count: 5}}] }
]
}
];
const data = {
list: {
context: {
facets: initialFields
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toBe(1);
expect(searchFacetFiltersService.responseFacets[0].buckets.visibleItems.length).toBe(3);
searchFacetFiltersService.responseFacets[0].buckets.filterText = 'f';
expect(searchFacetFiltersService.responseFacets[0].buckets.visibleItems.length).toBe(1);
expect(searchFacetFiltersService.responseFacets[0].buckets.visibleItems[0].label).toEqual('firstLabel');
searchFacetFiltersService.responseFacets[0].buckets.filterText = 'label';
expect(searchFacetFiltersService.responseFacets[0].buckets.visibleItems.length).toBe(0);
// Set filter method to use contains and test again
queryBuilder.config.filterWithContains = true;
searchFacetFiltersService.responseFacets[0].buckets.filterText = 'f';
expect(searchFacetFiltersService.responseFacets[0].buckets.visibleItems.length).toBe(1);
searchFacetFiltersService.responseFacets[0].buckets.filterText = 'label';
expect(searchFacetFiltersService.responseFacets[0].buckets.visibleItems.length).toBe(3);
});
it('should fetch facet fields from response payload and show the bucket values', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
const serverResponseFields: any = [
{
type: 'field',
label: 'f1',
buckets: [
{ label: 'b1', metrics: [{value: {count: 10}}] },
{ label: 'b2', metrics: [{value: {count: 1}}] }
]
},
{ type: 'field', label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facets: serverResponseFields
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toEqual(1);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(10);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].count).toEqual(1);
});
it('should fetch facet fields from response payload and update the existing bucket values', () => {
queryBuilder.config = {
categories: [],
facetFields: { fields: [
{ label: 'f1', field: 'f1' },
{ label: 'f2', field: 'f2' }
]},
facetQueries: {
queries: []
}
};
const initialFields: any = [
{ type: 'field', label: 'f1', buckets: { items: [{ label: 'b1', count: 10, filterQuery: 'filter' }, { label: 'b2', count: 1 }]} },
{ type: 'field', label: 'f2', buckets: [] }
];
searchFacetFiltersService.responseFacets = initialFields;
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(10);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].count).toEqual(1);
const serverResponseFields: any = [
{ type: 'field', label: 'f1', buckets:
[{ label: 'b1', metrics: [{value: {count: 6}}], filterQuery: 'filter' },
{ label: 'b2', metrics: [{value: {count: 0}}] }] },
{ type: 'field', label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facets: serverResponseFields
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(6);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[1].count).toEqual(0);
});
it('should update correctly the existing facetFields bucket values', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetFields: { fields: [{ label: 'f1', field: 'f1' }] },
facetQueries: { queries: [] }
};
const firstCallFields: any = [{
type: 'field',
label: 'f1',
buckets: [{ label: 'b1', metrics: [{value: {count: 10}}] }]
}];
const firstCallData = { list: { context: { facets: firstCallFields }}};
searchFacetFiltersService.onDataLoaded(firstCallData);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(10);
const secondCallFields: any = [{
type: 'field',
label: 'f1',
buckets: [{ label: 'b1', metrics: [{value: {count: 6}}] }]
}];
const secondCallData = { list: { context: { facets: secondCallFields}}};
searchFacetFiltersService.onDataLoaded(secondCallData);
expect(searchFacetFiltersService.responseFacets[0].buckets.items[0].count).toEqual(6);
});
it('should fetch facet intervals from response payload', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetIntervals: {
intervals: [
{ label: 'test_intervals1', field: 'f1', sets: [
{ label: 'interval1', start: 's1', end: 'e1'},
{ label: 'interval2', start: 's2', end: 'e2'}
]},
{ label: 'test_intervals2', field: 'f2', sets: [
{ label: 'interval3', start: 's3', end: 'e3'},
{ label: 'interval4', start: 's4', end: 'e4'}
]}
]
}
};
const response1 = [
{ label: 'interval1', filterQuery: 'query1', metrics: [{ value: { count: 1 }}]},
{ label: 'interval2', filterQuery: 'query2', metrics: [{ value: { count: 2 }}]}
];
const response2 = [
{ label: 'interval3', filterQuery: 'query3', metrics: [{ value: { count: 3 }}]},
{ label: 'interval4', filterQuery: 'query4', metrics: [{ value: { count: 4 }}]}
];
const data = {
list: {
context: {
facets: [
{ type: 'interval', label: 'test_intervals1', buckets: response1 },
{ type: 'interval', label: 'test_intervals2', buckets: response2 }
]
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toBe(2);
expect(searchFacetFiltersService.responseFacets[0].buckets.length).toEqual(2);
expect(searchFacetFiltersService.responseFacets[1].buckets.length).toEqual(2);
});
it('should filter out the fetched facet intervals that have bucket values less than their set mincount', () => {
searchFacetFiltersService.responseFacets = null;
queryBuilder.config = {
categories: [],
facetIntervals: {
intervals: [
{ label: 'test_intervals1', field: 'f1', mincount: 2, sets: [
{ label: 'interval1', start: 's1', end: 'e1'},
{ label: 'interval2', start: 's2', end: 'e2'}
]},
{ label: 'test_intervals2', field: 'f2', mincount: 5, sets: [
{ label: 'interval3', start: 's3', end: 'e3'},
{ label: 'interval4', start: 's4', end: 'e4'}
]}
]
}
};
const response1 = [
{ label: 'interval1', filterQuery: 'query1', metrics: [{ value: { count: 1 }}]},
{ label: 'interval2', filterQuery: 'query2', metrics: [{ value: { count: 2 }}]}
];
const response2 = [
{ label: 'interval3', filterQuery: 'query3', metrics: [{ value: { count: 3 }}]},
{ label: 'interval4', filterQuery: 'query4', metrics: [{ value: { count: 4 }}]}
];
const data = {
list: {
context: {
facets: [
{ type: 'interval', label: 'test_intervals1', buckets: response1 },
{ type: 'interval', label: 'test_intervals2', buckets: response2 }
]
}
}
};
searchFacetFiltersService.onDataLoaded(data);
expect(searchFacetFiltersService.responseFacets.length).toBe(1);
expect(searchFacetFiltersService.responseFacets[0].buckets.length).toEqual(1);
});
});

View File

@@ -0,0 +1,370 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* 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 { Inject, Injectable, OnDestroy } from '@angular/core';
import { FacetField } from '../models/facet-field.interface';
import { Subject } from 'rxjs';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../search-query-service.token';
import { SearchQueryBuilderService } from './search-query-builder.service';
import { SearchService, TranslationService } from '@alfresco/adf-core';
import { takeUntil } from 'rxjs/operators';
import { GenericBucket, GenericFacetResponse, ResultSetContext, ResultSetPaging } from '@alfresco/js-api';
import { SearchFilterList } from '../models/search-filter-list.model';
import { FacetFieldBucket } from '../models/facet-field-bucket.interface';
export interface SelectedBucket {
field: FacetField;
bucket: FacetFieldBucket;
}
@Injectable({
providedIn: 'root'
})
export class SearchFacetFiltersService implements OnDestroy {
/** All facet field items to be displayed in the component. These are updated according to the response.
* When a new search is performed, the already existing items are updated with the new bucket count values and
* the newly received items are added to the responseFacets.
*/
responseFacets: FacetField[] = null;
/** shows the facet chips */
selectedBuckets: SelectedBucket[] = [];
private DEFAULT_PAGE_SIZE = 5;
private readonly facetQueriesPageSize = this.DEFAULT_PAGE_SIZE;
private readonly onDestroy$ = new Subject<boolean>();
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) public queryBuilder: SearchQueryBuilderService,
private searchService: SearchService,
private translationService: TranslationService) {
if (queryBuilder.config && queryBuilder.config.facetQueries) {
this.facetQueriesPageSize = queryBuilder.config.facetQueries.pageSize || this.DEFAULT_PAGE_SIZE;
}
this.queryBuilder.configUpdated
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => {
this.selectedBuckets = [];
this.responseFacets = null;
});
this.queryBuilder.updated
.pipe(takeUntil(this.onDestroy$))
.subscribe((query) => this.queryBuilder.execute(query));
this.queryBuilder.executed
.pipe(takeUntil(this.onDestroy$))
.subscribe((resultSetPaging: ResultSetPaging) => {
this.onDataLoaded(resultSetPaging);
this.searchService.dataLoaded.next(resultSetPaging);
});
}
onDataLoaded(data: any) {
const context = data.list.context;
if (context) {
this.parseFacets(context);
} else {
this.responseFacets = null;
}
}
private parseFacets(context: ResultSetContext) {
this.parseFacetFields(context);
this.parseFacetIntervals(context);
this.parseFacetQueries(context);
}
private parseFacetItems(context: ResultSetContext, configFacetFields: FacetField[], itemType: string) {
configFacetFields.forEach((field) => {
const responseField = this.findFacet(context, itemType, field.label);
const responseBuckets = this.getResponseBuckets(responseField, field)
.filter(this.getFilterByMinCount(field.mincount));
const alreadyExistingField = this.findResponseFacet(itemType, field.label);
if (alreadyExistingField) {
const alreadyExistingBuckets = alreadyExistingField.buckets && alreadyExistingField.buckets.items || [];
this.updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets);
} else if (responseField) {
if (responseBuckets.length > 0) {
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, field.pageSize);
bucketList.filter = this.getBucketFilterFunction(bucketList);
if (!this.responseFacets) {
this.responseFacets = [];
}
this.responseFacets.push(<FacetField> {
...field,
type: responseField.type || itemType,
label: field.label,
pageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
currentPageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
buckets: bucketList
});
}
}
});
}
private parseFacetFields(context: ResultSetContext) {
const configFacetFields = this.queryBuilder.config.facetFields && this.queryBuilder.config.facetFields.fields || [];
this.parseFacetItems(context, configFacetFields, 'field');
}
private parseFacetIntervals(context: ResultSetContext) {
const configFacetIntervals = this.queryBuilder.config.facetIntervals && this.queryBuilder.config.facetIntervals.intervals || [];
this.parseFacetItems(context, configFacetIntervals, 'interval');
}
private parseFacetQueries(context: ResultSetContext) {
const facetQuerySetting = this.queryBuilder.config.facetQueries?.settings || {};
const configFacetQueries = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.queries || [];
const configGroups = configFacetQueries.reduce((acc, query) => {
const group = this.queryBuilder.getQueryGroup(query);
if (acc[group]) {
acc[group].push(query);
} else {
acc[group] = [query];
}
return acc;
}, []);
const mincount = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.mincount;
const mincountFilter = this.getFilterByMinCount(mincount);
Object.keys(configGroups).forEach((group) => {
const responseField = this.findFacet(context, 'query', group);
const responseBuckets = this.getResponseQueryBuckets(responseField, configGroups[group])
.filter(mincountFilter);
const alreadyExistingField = this.findResponseFacet('query', group);
if (alreadyExistingField) {
const alreadyExistingBuckets = alreadyExistingField.buckets && alreadyExistingField.buckets.items || [];
this.updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets);
} else if (responseField) {
if (responseBuckets.length > 0) {
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, this.facetQueriesPageSize);
bucketList.filter = this.getBucketFilterFunction(bucketList);
if (!this.responseFacets) {
this.responseFacets = [];
}
this.responseFacets.push(<FacetField> {
field: group,
type: responseField.type || 'query',
label: group,
pageSize: this.DEFAULT_PAGE_SIZE,
currentPageSize: this.DEFAULT_PAGE_SIZE,
buckets: bucketList,
settings: facetQuerySetting
});
}
}
});
}
private getResponseBuckets(responseField: GenericFacetResponse, configField: FacetField): FacetFieldBucket[] {
return ((responseField && responseField.buckets) || []).map((respBucket) => {
respBucket['count'] = this.getCountValue(respBucket);
respBucket.filterQuery = respBucket.filterQuery || this.getCorrespondingFilterQuery(configField, respBucket.label);
return <FacetFieldBucket> {
...respBucket,
checked: false,
display: respBucket.display,
label: respBucket.label
};
});
}
private getResponseQueryBuckets(responseField: GenericFacetResponse, configGroup: any): FacetFieldBucket[] {
return (configGroup || []).map((query) => {
const respBucket = ((responseField && responseField.buckets) || [])
.find((bucket) => bucket.label === query.label) || {};
respBucket['count'] = this.getCountValue(respBucket);
return <FacetFieldBucket> {
...respBucket,
checked: false,
display: respBucket.display,
label: respBucket.label
};
});
}
private getCountValue(bucket: GenericBucket): number {
return (!!bucket && !!bucket.metrics && bucket.metrics[0]?.value?.count) || 0;
}
getBucketCountDisplay(bucket: FacetFieldBucket): string {
return bucket.count === null ? '' : `(${bucket.count})`;
}
private getFilterByMinCount(mincountInput: number) {
return (bucket) => {
let mincount = mincountInput;
if (mincount === undefined) {
mincount = 1;
}
return bucket.count >= mincount;
};
}
private getCorrespondingFilterQuery(configFacetItem: FacetField, bucketLabel: string): string {
let filterQuery = null;
if (configFacetItem.field && bucketLabel) {
if (configFacetItem.sets) {
const configSet = configFacetItem.sets.find((set) => bucketLabel === set.label);
if (configSet) {
filterQuery = this.buildIntervalQuery(configFacetItem.field, configSet);
}
} else {
filterQuery = `${configFacetItem.field}:"${bucketLabel}"`;
}
}
return filterQuery;
}
private buildIntervalQuery(fieldName: string, interval: any): string {
const start = interval.start;
const end = interval.end;
const startLimit = (interval.startInclusive === undefined || interval.startInclusive === true) ? '[' : '<';
const endLimit = (interval.endInclusive === undefined || interval.endInclusive === true) ? ']' : '>';
return `${fieldName}:${startLimit}"${start}" TO "${end}"${endLimit}`;
}
private findFacet(context: ResultSetContext, itemType: string, fieldLabel: string): GenericFacetResponse {
return (context.facets || []).find((response) => response.type === itemType && response.label === fieldLabel) || {};
}
private findResponseFacet(itemType: string, fieldLabel: string): FacetField {
return (this.responseFacets || []).find((response) => response.type === itemType && response.label === fieldLabel);
}
private updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets) {
const bucketsToDelete = [];
alreadyExistingBuckets
.map((bucket) => {
const responseBucket = ((responseField && responseField.buckets) || []).find((respBucket) => respBucket.label === bucket.label);
if (!responseBucket) {
bucketsToDelete.push(bucket);
}
bucket.count = this.getCountValue(responseBucket);
return bucket;
});
const hasSelection = this.selectedBuckets
.find((selBuckets) => alreadyExistingField.label === selBuckets.field.label && alreadyExistingField.type === selBuckets.field.type);
if (!hasSelection && bucketsToDelete.length) {
bucketsToDelete.forEach((bucket) => {
alreadyExistingField.buckets.deleteItem(bucket);
});
}
responseBuckets.forEach((respBucket) => {
const existingBucket = alreadyExistingBuckets.find((oldBucket) => oldBucket.label === respBucket.label);
if (!existingBucket) {
alreadyExistingField.buckets.addItem(respBucket);
}
});
}
private getBucketFilterFunction(bucketList) {
return (bucket: FacetFieldBucket): boolean => {
if (bucket && bucketList.filterText) {
const pattern = (bucketList.filterText || '').toLowerCase();
const label = (this.translationService.instant(bucket.display) || this.translationService.instant(bucket.label)).toLowerCase();
return this.queryBuilder.config.filterWithContains ? label.indexOf(pattern) !== -1 : label.startsWith(pattern);
}
return true;
};
}
unselectFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (bucket) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
this.updateSelectedBuckets();
this.queryBuilder.update();
}
}
/* update adf-search-chip-list component view */
updateSelectedBuckets() {
if (this.responseFacets) {
this.selectedBuckets = [];
for (const field of this.responseFacets) {
if (field.buckets) {
this.selectedBuckets.push(
...this.queryBuilder.getUserFacetBuckets(field.field)
.filter((bucket) => bucket.checked)
.map((bucket) => {
return {field, bucket};
})
);
}
}
} else {
this.selectedBuckets = [];
}
}
ngOnDestroy(): void {
this.onDestroy$.next();
this.onDestroy$.complete();
}
resetAllSelectedBuckets() {
this.responseFacets.forEach((field) => {
if (field && field.buckets) {
for (const bucket of field.buckets.items) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
}
this.updateSelectedBuckets();
}
});
this.queryBuilder.update();
}
resetQueryFragments() {
this.queryBuilder.queryFragments = {};
this.queryBuilder.resetToDefaults();
}
reset() {
this.responseFacets = [];
this.selectedBuckets = [];
this.queryBuilder.resetToDefaults();
this.queryBuilder.update();
}
}

View File

@@ -16,13 +16,13 @@
*/
import { Injectable, Type } from '@angular/core';
import { SearchTextComponent } from '../search-text/search-text.component';
import { SearchRadioComponent } from '../search-radio/search-radio.component';
import { SearchSliderComponent } from '../search-slider/search-slider.component';
import { SearchNumberRangeComponent } from '../search-number-range/search-number-range.component';
import { SearchCheckListComponent } from '../search-check-list/search-check-list.component';
import { SearchDateRangeComponent } from '../search-date-range/search-date-range.component';
import { SearchDatetimeRangeComponent } from '../search-datetime-range/search-datetime-range.component';
import { SearchTextComponent } from '../components/search-text/search-text.component';
import { SearchRadioComponent } from '../components/search-radio/search-radio.component';
import { SearchSliderComponent } from '../components/search-slider/search-slider.component';
import { SearchNumberRangeComponent } from '../components/search-number-range/search-number-range.component';
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';
@Injectable({
providedIn: 'root'

View File

@@ -15,11 +15,11 @@
* limitations under the License.
*/
import { SearchConfiguration } from './models/search-configuration.interface';
import { SearchConfiguration } from '../models/search-configuration.interface';
import { AppConfigService } from '@alfresco/adf-core';
import { SearchHeaderQueryBuilderService } from './search-header-query-builder.service';
import { TestBed } from '@angular/core/testing';
import { ContentTestingModule } from '../testing/content.testing.module';
import { ContentTestingModule } from '../../testing/content.testing.module';
describe('SearchHeaderQueryBuilderService', () => {

View File

@@ -17,14 +17,14 @@
import { Injectable } from '@angular/core';
import { AlfrescoApiService, AppConfigService, NodesApiService, DataSorting } from '@alfresco/adf-core';
import { SearchConfiguration } from './models/search-configuration.interface';
import { SearchConfiguration } from '../models/search-configuration.interface';
import { BaseQueryBuilderService } from './base-query-builder.service';
import { SearchCategory } from './models/search-category.interface';
import { SearchCategory } from '../models/search-category.interface';
import { MinimalNode, QueryBody } from '@alfresco/js-api';
import { filter } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { SearchSortingDefinition } from './models/search-sorting-definition.interface';
import { FilterSearch } from './models/filter-search.interface';
import { SearchSortingDefinition } from '../models/search-sorting-definition.interface';
import { FilterSearch } from '../models/filter-search.interface';
@Injectable({
providedIn: 'root'

View File

@@ -16,11 +16,11 @@
*/
import { SearchQueryBuilderService } from './search-query-builder.service';
import { SearchConfiguration } from './models/search-configuration.interface';
import { SearchConfiguration } from '../models/search-configuration.interface';
import { AppConfigService } from '@alfresco/adf-core';
import { FacetField } from './models/facet-field.interface';
import { FacetField } from '../models/facet-field.interface';
import { TestBed } from '@angular/core/testing';
import { ContentTestingModule } from '../testing/content.testing.module';
import { ContentTestingModule } from '../../testing/content.testing.module';
describe('SearchQueryBuilder', () => {
@@ -670,10 +670,12 @@ describe('SearchQueryBuilder', () => {
expect(queryBody.scope).toEqual(mockScope);
});
it('should return empty if array of search config not found', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
const forms = builder.getSearchConfigurationDetails();
expect(forms).toEqual([]);
it('should return empty if array of search config not found', (done) => {
const builder = new SearchQueryBuilderService(buildConfig(null), null);
builder.searchForms.subscribe((forms) => {
expect(forms).toEqual([]);
done();
});
});
describe('Multiple search configuration', () => {
@@ -728,14 +730,15 @@ describe('SearchQueryBuilder', () => {
expect(builder.filterQueries.length).toBe(2);
});
it('should list available search form names', () => {
const forms = builder.getSearchConfigurationDetails();
expect(forms).toEqual([
{ index: 0, name: 'config1', default: true, selected: true },
{ index: 1, name: 'config2', default: false, selected: false },
{ index: 2, name: 'SEARCH.UNKNOWN_FORM', default: false, selected: false }
]);
it('should list available search form names', (done) => {
builder.searchForms.subscribe((forms) => {
expect(forms).toEqual([
{ index: 0, name: 'config1', default: true, selected: true },
{ index: 1, name: 'config2', default: false, selected: false },
{ index: 2, name: 'SEARCH.UNKNOWN_CONFIGURATION', default: false, selected: false }
]);
done();
});
});
it('should allow the user switch the form', () => {
@@ -745,15 +748,16 @@ describe('SearchQueryBuilder', () => {
expect(builder.filterQueries.length).toBe(2);
});
it('should keep the selected configuration value', () => {
it('should keep the selected configuration value', (done) => {
builder.updateSelectedConfiguration(1);
const forms = builder.getSearchConfigurationDetails();
expect(forms).toEqual([
{ index: 0, name: 'config1', default: true, selected: false },
{ index: 1, name: 'config2', default: false, selected: true },
{ index: 2, name: 'SEARCH.UNKNOWN_FORM', default: false, selected: false }
]);
builder.searchForms.subscribe((forms) => {
expect(forms).toEqual([
{ index: 0, name: 'config1', default: true, selected: false },
{ index: 1, name: 'config2', default: false, selected: true },
{ index: 2, name: 'SEARCH.UNKNOWN_CONFIGURATION', default: false, selected: false }
]);
done();
});
});
});
});

View File

@@ -17,7 +17,7 @@
import { Injectable } from '@angular/core';
import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core';
import { SearchConfiguration } from './models/search-configuration.interface';
import { SearchConfiguration } from '../models/search-configuration.interface';
import { BaseQueryBuilderService } from './base-query-builder.service';
@Injectable()

View File

@@ -28,6 +28,10 @@
@import '../aspect-list/aspect-list.component';
@import '../permission-manager/components/user-icon-column/user-icon-column.component';
@import '../permission-manager/components/user-name-column/user-name-column.component';
@import '../search/components/search-filter-chips/search-filter-chips.component';
@import '../search/components/search-facet-field/search-facet-field.component';
@import '../search/components/search-form/search-form.component';
@import '../search/components/search-filter-chips/search-filter-menu-card/search-filter-menu-card.component';
@mixin adf-content-services-theme($theme) {
@include adf-breadcrumb-theme($theme);
@@ -56,4 +60,8 @@
@include adf-version-comparison-theme($theme);
@include adf-content-type-dialog-theme($theme);
@include adf-aspect-list-theme($theme);
@include adf-search-filter-chips-theme($theme);
@include adf-search-filter-field-theme($theme);
@include adf-search-forms-theme($theme);
@include adf-search-filter-menu-card($theme);
}