ACA-3426 - Search Headers for Document List (#5800)

* [ACA-3426] Move filter-menu inside search and renamed as search-header

* [ACA-3426] adf-search-header removed from document-list and implemented in app-files

* [ACA-3426] Allow custom header filters inside document-list

* [ACA-3426] Decouple search from the document-list

* [ACA-3409] NodePaging ouputed to the DL

* [ACA-3426] - fixed injection for service

* Dev baptiste aca 3430 (#5773)

* [ACA-3430] Add style to filter and hide action buttons from facet widgets

* [ACA-3430] Update eventEmitter created in the DL and create  unit tests for the search-header

Co-authored-by: BaptisteMahe <mahe.baptiste.19@gmail.com>

* [ACA-3426] - added parent for service

* [ACA-3426] - added parent for service - fixed method

* [ACA-3426] Revert update EventEmitter inside DL

* [ACA-3436] Use of the node input instead of nodeUpdate mehtod

* [ACA-3426] Add clear behaviour to search-header

* [ACA-3426] Remove useless update exposition

* [ACA-3426] Update filter button styles and padding inside the filter menu

* [ACA-3443] Propagate filters states through DL and datatable to avoid hiding the header

* [ACA-3426] Refactor showHeader logic and use it for the filters

* [ACA-3426] - fixed pagination for filter result

* [ACA-3426] - fixed messed files after rebase

* [ACA-3426] - added simplified config version

* [ACA-3426] - enabling created by filter

* [ACA-3426] Fix search-date-range apply method

* [ACA-3426] Fix loading style and default showHeaderMode

* [ACA-3426] Changed showHedaer default to always

* [ACA-3426] - stabilised the feature and added injection token

* [ACA-3426] Add unit test for showHeader new behaviour

* [ACA-3426] Add documentation to search-header

* [ACA-3426] - added parent filtering for special folders

* [ACA-3426] - added unit test for search header

* [ACA-3426] - fixed search fitler behavour

* [ACA-3426] - fixed search result inject service

* [ACA-3426] - fixed search result inject service for search sorting

* [ACA-3426] - fixed title for matching selector

* [ACA-3426] - fixed app config with missing search widget

* Update search-header.component.md

Co-authored-by: BaptisteMahe <mahe.baptiste.19@gmail.com>
Co-authored-by: Eugenio Romano <eromano@users.noreply.github.com>
This commit is contained in:
Vito
2020-06-22 09:24:57 +01:00
committed by GitHub
parent 5a0ba6666d
commit 29d953e2d1
54 changed files with 1888 additions and 544 deletions

View File

@@ -11,7 +11,7 @@
[loading]="loading"
[display]="display"
[noPermission]="noPermission"
[showHeader]="!isEmpty() && showHeader"
[showHeader]="showHeader"
[rowMenuCacheEnabled]="false"
[stickyHeader]="stickyHeader"
[allowFiltering]="allowFiltering"
@@ -24,9 +24,13 @@
(row-unselect)="onNodeUnselect($event.detail)"
[class.adf-datatable-gallery-thumbnails]="data.thumbnails">
<ng-template let-col>
<adf-filter-menu class="adf-filter-menu" [col]="col"></adf-filter-menu>
</ng-template>
<adf-header-filter-template>
<ng-template let-col>
<ng-template [ngTemplateOutlet]="customHeaderFilterTemplate?.template"
[ngTemplateOutletContext]="{$implicit: col}">
</ng-template>
</ng-template>
</adf-header-filter-template>
<adf-no-content-template>
<ng-template>

View File

@@ -207,11 +207,4 @@
}
}
}
.adf-filter-button {
mat-icon {
height: 18px;
width: 18px;
}
}
}

View File

@@ -25,7 +25,8 @@ import {
DataColumn,
DataTableComponent,
DataTableModule,
ObjectDataTableAdapter
ObjectDataTableAdapter,
ShowHeaderMode
} from '@alfresco/adf-core';
import { Subject, of, throwError } from 'rxjs';
import {
@@ -330,7 +331,7 @@ describe('DocumentList', () => {
});
it('should hide the header if showHeader is false', () => {
documentList.showHeader = false;
documentList.showHeader = ShowHeaderMode.Data;
fixture.detectChanges();
@@ -338,7 +339,7 @@ describe('DocumentList', () => {
});
it('should show the header if showHeader is true', () => {
documentList.showHeader = true;
documentList.showHeader = ShowHeaderMode.Data;
fixture.detectChanges();

View File

@@ -30,6 +30,7 @@ import {
DataSorting,
DataTableComponent,
DisplayMode,
ShowHeaderMode,
ObjectDataColumn,
PaginatedComponent,
AppConfigService,
@@ -40,6 +41,7 @@ import {
CustomLoadingContentTemplateDirective,
CustomNoPermissionTemplateDirective,
CustomEmptyContentTemplateDirective,
CustomHeaderFilterTemplateDirective,
RequestPaginationModel,
AlfrescoApiService,
UserPreferenceValues,
@@ -91,6 +93,9 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte
@ContentChild(CustomEmptyContentTemplateDirective)
customNoContentTemplate: CustomEmptyContentTemplateDirective;
@ContentChild(CustomHeaderFilterTemplateDirective)
customHeaderFilterTemplate: CustomHeaderFilterTemplateDirective;
/** Include additional information about the node in the server request. For example: association, isLink, isLocked and others. */
@Input()
includeFields: string[];
@@ -123,7 +128,7 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte
/** Toggles the header */
@Input()
showHeader: boolean = true;
showHeader: string = ShowHeaderMode.Data;
/** User interaction for folder navigation or file preview.
* Valid values are "click" and "dblclick". Default value: "dblclick"
@@ -308,7 +313,7 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte
noPermission: boolean = false;
selection = new Array<NodeEntry>();
$folderNode: Subject<Node> = new Subject<Node>();
allowFiltering: boolean = false;
allowFiltering: boolean = true;
// @deprecated 3.0.0
folderNode: Node;
@@ -854,5 +859,4 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte
this.setLoadingState(false);
this.error.emit(err);
}
}

View File

@@ -1,16 +0,0 @@
<button mat-icon-button [matMenuTriggerFor]="filter"
(click)="onMenuButtonClick($event)"
class="adf-filter-button"
matTooltip="{{ 'ADF-DOCUMENT-LIST.FILTER_MENU.FILTER_BY' | translate:{category: col.title} }}">
<adf-icon value="adf:filter" color="primary"></adf-icon>
</button>
<mat-menu #filter="matMenu">
<div>{{col.title}}</div>
<mat-dialog-actions>
<button mat-button>{{ 'ADF-DOCUMENT-LIST.FILTER_MENU.CLEAR' | translate | uppercase }}
</button>
<button mat-button>{{ 'ADF-DOCUMENT-LIST.FILTER_MENU.APPLY' | translate | uppercase }}
</button>
</mat-dialog-actions>
</mat-menu>

View File

@@ -32,7 +32,6 @@ import { LibraryStatusColumnComponent } from './components/library-status-column
import { LibraryRoleColumnComponent } from './components/library-role-column/library-role-column.component';
import { LibraryNameColumnComponent } from './components/library-name-column/library-name-column.component';
import { NameColumnComponent } from './components/name-column/name-column.component';
import { FilterMenuComponent } from './components/filter-menu/filter-menu.component';
@NgModule({
imports: [
@@ -51,8 +50,7 @@ import { FilterMenuComponent } from './components/filter-menu/filter-menu.compon
LibraryNameColumnComponent,
NameColumnComponent,
ContentActionComponent,
ContentActionListComponent,
FilterMenuComponent
ContentActionListComponent
],
exports: [
DocumentListComponent,

View File

@@ -62,11 +62,6 @@
"VIEW": "View",
"REMOVE": "Remove",
"DOWNLOAD": "Download"
},
"FILTER_MENU" : {
"FILTER_BY": "Filter by {{ category }}",
"CLEAR": "Clear",
"APPLY": "Apply"
}
},
"ALFRESCO_DOCUMENT_LIST": {
@@ -289,6 +284,12 @@
"PROCESS_ACTION": "Start Process"
}
}
},
"SEARCH_HEADER" : {
"TITLE":"Filter",
"FILTER_BY": "Filter by {{ category }}",
"CLEAR": "Clear",
"APPLY": "Apply"
}
},
"PERMISSION": {

View File

@@ -36,7 +36,8 @@ import {
MatSlideToggleModule,
MatRadioModule,
MatSliderModule,
MatTreeModule
MatTreeModule,
MatBadgeModule
} from '@angular/material';
export function modules() {
@@ -60,7 +61,8 @@ export function modules() {
MatSlideToggleModule,
MatRadioModule,
MatSliderModule,
MatTreeModule
MatTreeModule,
MatBadgeModule
];
}

View File

@@ -15,9 +15,9 @@
* limitations under the License.
*/
import { Node } from '@alfresco/js-api';
import { Node, NodePaging } from '@alfresco/js-api';
export const fakeNodeWithCreatePermission = new Node({
export const fakeNodeWithCreatePermission = new Node({
isFile: false,
createdByUser: { id: 'admin', displayName: 'Administrator' },
modifiedAt: '2017-06-08T13:53:46.495Z',
@@ -193,3 +193,25 @@ export const fakeGetSiteMembership = {
}]
}
};
export const fakeNodePaging: NodePaging = {
list: {
pagination: {
count: 5,
hasMoreItems: false,
totalItems: 5,
skipCount: 0,
maxItems: 100
}, entries: [{
entry: fakeNodeWithNoPermission
}, {
entry: fakeNodeWithNoPermission
}, {
entry: fakeNodeWithNoPermission
}, {
entry: fakeNodeWithNoPermission
}, {
entry: fakeNodeWithNoPermission
}]
}
};

View File

@@ -0,0 +1,439 @@
/*!
* @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 { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core';
import {
QueryBody,
RequestFacetFields,
RequestFacetField,
RequestSortDefinitionInner,
ResultSetPaging,
RequestHighlight
} from '@alfresco/js-api';
import { SearchCategory } from './search-category.interface';
import { FilterQuery } from './filter-query.interface';
import { SearchRange } from './search-range.interface';
import { SearchConfiguration } from './search-configuration.interface';
import { FacetQuery } from './facet-query.interface';
import { SearchSortingDefinition } from './search-sorting-definition.interface';
import { FacetField } from './facet-field.interface';
import { FacetFieldBucket } from './facet-field-bucket.interface';
@Injectable({
providedIn: 'root'
})
export abstract class BaseQueryBuilderService {
private _userQuery = '';
updated = new Subject<QueryBody>();
executed = new Subject<ResultSetPaging>();
error = new Subject();
categories: Array<SearchCategory> = [];
queryFragments: { [id: string]: string } = {};
filterQueries: FilterQuery[] = [];
paging: { maxItems?: number; skipCount?: number } = null;
sorting: Array<SearchSortingDefinition> = [];
protected userFacetBuckets: { [key: string]: Array<FacetFieldBucket> } = {};
get userQuery(): string {
return this._userQuery;
}
set userQuery(value: string) {
value = (value || '').trim();
this._userQuery = value ? `(${value})` : '';
}
config: SearchConfiguration = {
categories: []
};
// TODO: to be supported in future iterations
ranges: { [id: string]: SearchRange } = {};
constructor(protected appConfig: AppConfigService, protected alfrescoApiService: AlfrescoApiService) {
this.setUpConfiguration();
}
public abstract loadConfiguration(): SearchConfiguration;
public abstract isFilterServiceActive(): boolean;
public setUpConfiguration() {
const currentConfig = this.loadConfiguration();
this.setUpSearchConfiguration(currentConfig);
}
private setUpSearchConfiguration(currentConfiguration: SearchConfiguration) {
if (currentConfiguration) {
this.config = JSON.parse(JSON.stringify(currentConfiguration));
this.categories = (this.config.categories || []).filter(
category => category.enabled
);
this.filterQueries = this.config.filterQueries || [];
this.userFacetBuckets = {};
if (this.config.sorting) {
this.sorting = this.config.sorting.defaults || [];
}
}
}
/**
* Adds a facet bucket to a field.
* @param field The target field
* @param bucket Bucket to add
*/
addUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (field && field.field && bucket) {
const buckets = this.userFacetBuckets[field.field] || [];
const existing = buckets.find((facetBucket) => facetBucket.label === bucket.label);
if (!existing) {
buckets.push(bucket);
}
this.userFacetBuckets[field.field] = buckets;
}
}
/**
* Gets the buckets currently added to a field
* @param field The target fields
* @returns Bucket array
*/
getUserFacetBuckets(field: string) {
return this.userFacetBuckets[field] || [];
}
/**
* Removes an existing bucket from a field.
* @param field The target field
* @param bucket Bucket to remove
*/
removeUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (field && field.field && bucket) {
const buckets = this.userFacetBuckets[field.field] || [];
this.userFacetBuckets[field.field] = buckets
.filter((facetBucket) => facetBucket.label !== bucket.label);
}
}
/**
* Adds a filter query to the current query.
* @param query Query string to add
*/
addFilterQuery(query: string): void {
if (query) {
const existing = this.filterQueries.find((filterQuery) => filterQuery.query === query);
if (!existing) {
this.filterQueries.push({ query: query });
}
}
}
/**
* Removes an existing filter query.
* @param query The query to remove
*/
removeFilterQuery(query: string): void {
if (query) {
this.filterQueries = this.filterQueries
.filter((filterQuery) => filterQuery.query !== query);
}
}
/**
* Gets a facet query by label.
* @param label Label of the query
* @returns Facet query data
*/
getFacetQuery(label: string): FacetQuery {
if (label && this.hasFacetQueries) {
const result = this.config.facetQueries.queries.find((query) => query.label === label);
if (result) {
return { ...result };
}
}
return null;
}
/**
* Gets a facet field by label.
* @param label Label of the facet field
* @returns Facet field data
*/
getFacetField(label: string): FacetField {
if (label) {
const fields = this.config.facetFields.fields || [];
const result = fields.find((field) => field.label === label);
if (result) {
result.label = this.getSupportedLabel(result.label);
return { ...result };
}
}
return null;
}
/**
* Builds the current query and triggers the `updated` event.
*/
update(): void {
const query = this.buildQuery();
this.updated.next(query);
}
/**
* Builds and executes the current query.
* @returns Nothing
*/
async execute() {
try {
const query = this.buildQuery();
if (query) {
const resultSetPaging: ResultSetPaging = await this.alfrescoApiService.searchApi.search(query);
this.executed.next(resultSetPaging);
}
} catch (error) {
this.error.next(error);
this.executed.next({
list: {
pagination: {
totalItems: 0
},
entries: []
}
});
}
}
/**
* Builds the current query.
* @returns The finished query
*/
buildQuery(): QueryBody {
const query = this.getFinalQuery();
const include = this.config.include || [];
if (include.length === 0) {
include.push('path', 'allowableOperations');
}
if (query) {
const result: QueryBody = <QueryBody> {
query: {
query: query,
language: 'afts'
},
include: include,
paging: this.paging,
fields: this.config.fields,
filterQueries: this.filterQueries,
facetQueries: this.facetQueries,
facetIntervals: this.facetIntervals,
facetFields: this.facetFields,
sort: this.sort,
highlight: this.highlight
};
result['facetFormat'] = 'V2';
return result;
}
return null;
}
/**
* Gets the primary sorting definition.
* @returns The primary sorting definition
*/
getPrimarySorting(): SearchSortingDefinition {
if (this.sorting && this.sorting.length > 0) {
return this.sorting[0];
}
return null;
}
/**
* Gets all pre-configured sorting options that users can choose from.
* @returns Pre-configured sorting options
*/
getSortingOptions(): SearchSortingDefinition[] {
if (this.config && this.config.sorting) {
return this.config.sorting.options || [];
}
return [];
}
/**
* Gets the query group.
* @param query Target query
* @returns Query group
*/
getQueryGroup(query) {
return query.group || this.config.facetQueries.label || 'Facet Queries';
}
/**
* Checks if FacetQueries has been defined
* @returns True if defined, false otherwise
*/
get hasFacetQueries(): boolean {
if (this.config
&& this.config.facetQueries
&& this.config.facetQueries.queries
&& this.config.facetQueries.queries.length > 0) {
return true;
}
return false;
}
/**
* Checks if FacetIntervals has been defined
* @returns True if defined, false otherwise
*/
get hasFacetIntervals(): boolean {
if (this.config
&& this.config.facetIntervals
&& this.config.facetIntervals.intervals
&& this.config.facetIntervals.intervals.length > 0) {
return true;
}
return false;
}
get hasFacetHighlight(): boolean {
return this.config && this.config.highlight ? true : false;
}
protected get sort(): RequestSortDefinitionInner[] {
return this.sorting.map((def) => {
return new RequestSortDefinitionInner({
type: def.type,
field: def.field,
ascending: def.ascending
});
});
}
protected get facetQueries(): FacetQuery[] {
if (this.hasFacetQueries) {
return this.config.facetQueries.queries.map((query) => {
query.group = this.getQueryGroup(query);
return <FacetQuery> { ...query };
});
}
return null;
}
protected get facetIntervals(): any {
if (this.hasFacetIntervals) {
const configIntervals = this.config.facetIntervals;
return {
intervals: configIntervals.intervals.map((interval) => <any> {
label: this.getSupportedLabel(interval.label),
field: interval.field,
sets: interval.sets.map((set) => <any> {
label: this.getSupportedLabel(set.label),
start: set.start,
end: set.end,
startInclusive: set.startInclusive,
endInclusive: set.endInclusive
})
})
};
}
return null;
}
protected get highlight(): RequestHighlight {
return this.hasFacetHighlight ? this.config.highlight : null;
}
protected getFinalQuery(): string {
let query = '';
this.categories.forEach((facet) => {
const customQuery = this.queryFragments[facet.id];
if (customQuery) {
if (query.length > 0) {
query += ' AND ';
}
query += `(${customQuery})`;
}
});
let result = [this.userQuery, query]
.filter((entry) => entry)
.join(' AND ');
if (this.userFacetBuckets) {
Object.keys(this.userFacetBuckets).forEach((key) => {
const subQuery = (this.userFacetBuckets[key] || [])
.filter((bucket) => bucket.filterQuery)
.map((bucket) => bucket.filterQuery)
.join(' OR ');
if (subQuery) {
if (result.length > 0) {
result += ' AND ';
}
result += `(${subQuery})`;
}
});
}
return result;
}
protected get facetFields(): RequestFacetFields {
const facetFields = this.config.facetFields && this.config.facetFields.fields;
if (facetFields && facetFields.length > 0) {
return {
facets: facetFields.map((facet) => <RequestFacetField> {
field: facet.field,
mincount: facet.mincount,
label: this.getSupportedLabel(facet.label),
limit: facet.limit,
offset: facet.offset,
prefix: facet.prefix
})
};
}
return null;
}
/**
* Encloses a label name with double quotes if it contains whitespace characters.
* @param configLabel Original label text
* @returns Label, possibly with quotes if it contains spaces
*/
getSupportedLabel(configLabel: string): string {
const spaceInsideLabelIndex = configLabel.search(/\s/g);
if (spaceInsideLabelIndex > -1) {
return `"${configLabel}"`;
}
return configLabel;
}
}

View File

@@ -125,6 +125,10 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy
}
}
applyCurrentForm() {
this.apply(this.form.value, this.form.valid);
}
reset() {
this.form.reset({
from: '',

View File

@@ -59,8 +59,8 @@ describe('SearchFilterComponent', () => {
});
beforeEach(() => {
queryBuilder = TestBed.get(SearchQueryBuilderService);
fixture = TestBed.createComponent(SearchFilterComponent);
queryBuilder = fixture.componentInstance.queryBuilder;
appConfigService = TestBed.get(AppConfigService);
const translationService = fixture.debugElement.injector.get(TranslationService);
spyOn(translationService, 'instant').and.callFake((key) => key ? `${key}_translated` : null);
@@ -737,7 +737,7 @@ describe('SearchFilterComponent', () => {
it('should not show the disabled widget', async(() => {
appConfigService.config.search = { categories: disabledCategories };
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
fixture.whenStable().then(() => {
@@ -748,7 +748,7 @@ describe('SearchFilterComponent', () => {
it('should show the widget in expanded mode', async(() => {
appConfigService.config.search = { categories: expandedCategories };
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
fixture.whenStable().then(() => {
@@ -769,7 +769,7 @@ describe('SearchFilterComponent', () => {
it('should show the widgets only if configured', async(() => {
appConfigService.config.search = { categories: simpleCategories };
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
fixture.whenStable().then(() => {
@@ -784,7 +784,7 @@ describe('SearchFilterComponent', () => {
it('should be update the search query when name changed', async( async () => {
spyOn(queryBuilder, 'update').and.stub();
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
await fixture.whenStable();
@@ -805,7 +805,7 @@ describe('SearchFilterComponent', () => {
it('should show the long facet options list with pagination', () => {
const panel = '[data-automation-id="expansion-panel-Size facet queries"]';
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
@@ -871,7 +871,7 @@ describe('SearchFilterComponent', () => {
delete filter.facetQueries;
appConfigService.config.search = filter;
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
@@ -884,7 +884,7 @@ describe('SearchFilterComponent', () => {
it('should search the facets options and select it', () => {
const panel = '[data-automation-id="expansion-panel-Size facet queries"]';
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
fixture.detectChanges();
@@ -930,7 +930,7 @@ describe('SearchFilterComponent', () => {
const panel1 = '[data-automation-id="expansion-panel-Size facet queries"]';
const panel2 = '[data-automation-id="expansion-panel-Type facet queries"]';
appConfigService.config.search = searchFilter;
queryBuilder.resetToDefaults();
queryBuilder.setUpConfiguration();
fixture.detectChanges();
queryBuilder.executed.next(<any> mockSearchResult);
fixture.detectChanges();

View File

@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit, OnDestroy } from '@angular/core';
import { Component, ViewEncapsulation, OnInit, OnDestroy, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material';
import { SearchService, TranslationService } from '@alfresco/adf-core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
@@ -25,6 +25,7 @@ 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;
@@ -58,7 +59,7 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
private onDestroy$ = new Subject<boolean>();
constructor(public queryBuilder: SearchQueryBuilderService,
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) public queryBuilder: SearchQueryBuilderService,
private searchService: SearchService,
private translationService: TranslationService) {
if (queryBuilder.config && queryBuilder.config.facetQueries) {

View File

@@ -0,0 +1,37 @@
<div *ngIf="isFilterServiceActive">
<div *ngIf="!!category" class="adf-filter">
<button mat-icon-button [matMenuTriggerFor]="filter"
id="filter-menu-button"
#menuTrigger="matMenuTrigger"
(click)="onMenuButtonClick($event)"
class="adf-filter-button"
matTooltip="{{ 'SEARCH.SEARCH_HEADER.FILTER_BY' | translate: { category: col.title || 'Type' } }}">
<adf-icon value="adf:filter"
[ngClass]="{ 'adf-icon-active': isActive || menuTrigger.menuOpen }"
matBadge="&#8288;"
matBadgeColor="warn"
[matBadgeHidden]="!isActive"></adf-icon>
</button>
<mat-menu #filter="matMenu" class="adf-filter-menu">
<div (click)="onMenuClick($event)" class="adf-filter-container">
<div class="adf-filter-title">{{ 'SEARCH.SEARCH_HEADER.TITLE' | translate }}</div>
<adf-search-widget-container
[id]="category?.id"
[selector]="category?.component?.selector"
[settings]="category?.component?.settings">
</adf-search-widget-container>
</div>
<mat-dialog-actions class="adf-filter-actions">
<button mat-button id="clear-filter-button"
(click)="onClearButtonClick($event)">{{ 'SEARCH.SEARCH_HEADER.CLEAR' | translate | uppercase }}
</button>
<button mat-button color="primary"
id="apply-filter-button"
class="adf-filter-apply-button" (click)="onApplyButtonClick()">
{{ 'SEARCH.SEARCH_HEADER.APPLY' | translate | uppercase }}
</button>
</mat-dialog-actions>
</mat-menu>
</div>
</div>

View File

@@ -0,0 +1,72 @@
@mixin adf-filter-menu-theme($theme) {
$primary: map-get($theme, primary);
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
.adf-filter {
&-button {
margin-left: -7px !important;
.adf-icon {
opacity: 1;
color: mat-color($foreground, icon);
&-active {
color: mat-color($primary);
}
}
.mat-icon {
height: 18px;
width: 18px;
}
.mat-badge-content {
width: 10px;
height: 10px;
top: -3px !important;
right: -6px !important;
}
}
&-container {
display: flex;
flex-direction: column;
padding: 15px 15px 10px;
.adf-facet-buttons {
display: none;
}
.adf-search-check-list {
padding: 5px 0;
}
}
&-title {
font-size: 1.1em;
padding-bottom: 5px;
}
&-actions {
background-color: mat-color($background, background);
display: flex;
justify-content: flex-end;
padding: 15px;
> button {
font-size: 0.9em;
}
}
&-apply-button {
background-color: mat-color($background, hover);
}
}
.mat-menu-panel.adf-filter-menu .mat-menu-content {
min-width: 260px;
padding: 0;
}
}

View File

@@ -0,0 +1,147 @@
/*!
* @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';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { SearchService, setupTestBed, AlfrescoApiService } from '@alfresco/adf-core';
import { SearchHeaderComponent } from './search-header.component';
import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service';
import { ContentTestingModule } from '../../../testing/content.testing.module';
import { fakeNodePaging } from '../../../mock';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { By } from '@angular/platform-browser';
import { SimpleChange } from '@angular/core';
const mockCategory: any = {
'id': 'queryName',
'name': 'Name',
'columnKey': 'name',
'enabled': true,
'expanded': true,
'component': {
'selector': 'text',
'settings': {
'pattern': "cm:name:'(.*?)'",
'field': 'cm:name',
'placeholder': 'Enter the name'
}
}
};
describe('SearchHeaderComponent', () => {
let fixture: ComponentFixture<SearchHeaderComponent>;
let component: SearchHeaderComponent;
let queryBuilder: SearchHeaderQueryBuilderService;
let alfrescoApiService: AlfrescoApiService;
const searchMock: any = {
dataLoaded: new Subject()
};
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
],
providers: [
{ provide: SearchService, useValue: searchMock },
{ provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchHeaderQueryBuilderService }
]
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchHeaderComponent);
component = fixture.componentInstance;
queryBuilder = fixture.componentInstance['searchHeaderQueryBuilder'];
alfrescoApiService = TestBed.get(AlfrescoApiService);
component.col = {key: '123', type: 'text'};
spyOn(queryBuilder, 'getCategoryForColumn').and.returnValue(mockCategory);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
});
it('should show the filter when a category is found', async () => {
expect(queryBuilder.isFilterServiceActive()).toBe(true);
const element = fixture.nativeElement.querySelector('.adf-filter');
expect(element).not.toBeNull();
expect(element).not.toBeUndefined();
});
it('should emit the node paging received from the queryBuilder after the filter gets applied', async (done) => {
spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging));
spyOn(queryBuilder, 'buildQuery').and.returnValue({});
component.update.subscribe((newNodePaging) => {
expect(newNodePaging).toBe(fakeNodePaging);
done();
});
const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button');
menuButton.click();
fixture.detectChanges();
await fixture.whenStable();
const applyButton = fixture.debugElement.query(By.css('#apply-filter-button'));
applyButton.triggerEventHandler('click', {});
fixture.detectChanges();
await fixture.whenStable();
});
it('should execute a new query when the page size is changed', async (done) => {
spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging));
spyOn(queryBuilder, 'buildQuery').and.returnValue({});
component.update.subscribe((newNodePaging) => {
expect(newNodePaging).toBe(fakeNodePaging);
done();
});
const maxItem = new SimpleChange(10, 20, false);
component.ngOnChanges({ 'maxItems': maxItem });
fixture.detectChanges();
await fixture.whenStable();
});
it('should execute a new query when a new page is requested', async (done) => {
spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging));
spyOn(queryBuilder, 'buildQuery').and.returnValue({});
component.update.subscribe((newNodePaging) => {
expect(newNodePaging).toBe(fakeNodePaging);
done();
});
const skipCount = new SimpleChange(0, 10, false);
component.ngOnChanges({ 'skipCount': skipCount });
fixture.detectChanges();
await fixture.whenStable();
});
it('should emit the clear event when no filter has been selected', async (done) => {
spyOn(queryBuilder, 'isNoFilterActive').and.returnValue(true);
spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging));
spyOn(queryBuilder, 'buildQuery').and.returnValue({});
spyOn(component.widgetContainer, 'resetInnerWidget').and.stub();
const fakeEvent = jasmine.createSpyObj('event', ['stopPropagation']);
component.clear.subscribe(() => {
done();
});
const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button');
menuButton.click();
fixture.detectChanges();
await fixture.whenStable();
const clearButton = fixture.debugElement.query(By.css('#clear-filter-button'));
clearButton.triggerEventHandler('click', fakeEvent);
fixture.detectChanges();
});
});

View File

@@ -0,0 +1,137 @@
/*!
* @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, Input, Output, OnInit, OnChanges, EventEmitter, SimpleChanges, ViewEncapsulation, ViewChild, Inject, OnDestroy } from '@angular/core';
import { DataColumn } from '@alfresco/adf-core';
import { SearchWidgetContainerComponent } from '../search-widget-container/search-widget-container.component';
import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service';
import { NodePaging } from '@alfresco/js-api';
import { SearchCategory } from '../../search-category.interface';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'adf-search-header',
templateUrl: './search-header.component.html',
styleUrls: ['./search-header.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class SearchHeaderComponent implements OnInit, OnChanges, OnDestroy {
@Input()
col: DataColumn;
@Input()
currentFolderNodeId: string;
@Input()
maxItems: number;
@Input()
skipCount: number;
@Output()
update: EventEmitter<NodePaging> = new EventEmitter();
@Output()
clear: EventEmitter<any> = new EventEmitter();
@ViewChild(SearchWidgetContainerComponent)
widgetContainer: SearchWidgetContainerComponent;
public isActive: boolean;
category: SearchCategory;
isFilterServiceActive: boolean;
private onDestroy$ = new Subject<boolean>();
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private searchHeaderQueryBuilder: SearchHeaderQueryBuilderService) {
this.isFilterServiceActive = this.searchHeaderQueryBuilder.isFilterServiceActive();
}
ngOnInit() {
this.category = this.searchHeaderQueryBuilder.getCategoryForColumn(
this.col.key
);
this.searchHeaderQueryBuilder.executed
.pipe(takeUntil(this.onDestroy$))
.subscribe((newNodePaging: NodePaging) => {
this.update.emit(newNodePaging);
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes['currentFolderNodeId'] && changes['currentFolderNodeId'].currentValue) {
const currentIdValue = changes['currentFolderNodeId'].currentValue;
const previousIdValue = changes['currentFolderNodeId'].previousValue;
this.searchHeaderQueryBuilder.setCurrentRootFolderId(
currentIdValue,
previousIdValue
);
this.isActive = false;
}
if (changes['maxItems'] || changes['skipCount']) {
let actualMaxItems = this.maxItems;
let actualSkipCount = this.skipCount;
if (changes['maxItems'] && changes['maxItems'].currentValue) {
actualMaxItems = changes['maxItems'].currentValue;
}
if (changes['skipCount'] && changes['skipCount'].currentValue) {
actualSkipCount = changes['skipCount'].currentValue;
}
this.searchHeaderQueryBuilder.setupCurrentPagination(actualMaxItems, actualSkipCount);
}
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
onMenuButtonClick(event: Event) {
event.stopPropagation();
}
onMenuClick(event: Event) {
event.stopPropagation();
}
onApplyButtonClick() {
this.isActive = true;
this.widgetContainer.applyInnerWidget();
this.searchHeaderQueryBuilder.setActiveFilter(this.category.columnKey);
this.searchHeaderQueryBuilder.execute();
}
onClearButtonClick(event: Event) {
event.stopPropagation();
this.widgetContainer.resetInnerWidget();
this.isActive = false;
this.searchHeaderQueryBuilder.removeActiveFilter(this.category.columnKey);
if (this.searchHeaderQueryBuilder.isNoFilterActive()) {
this.clear.emit();
} else {
this.searchHeaderQueryBuilder.execute();
}
}
}

View File

@@ -89,4 +89,11 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
changeHandler(event: MatRadioChange) {
this.setValue(event.value);
}
reset() {
const initialValue = this.getSelectedValue();
if (initialValue !== null) {
this.setValue(initialValue);
}
}
}

View File

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

View File

@@ -15,9 +15,10 @@
* limitations under the License.
*/
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { Component, OnInit, ViewEncapsulation, Inject } from '@angular/core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchSortingDefinition } from '../../search-sorting-definition.interface';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
@Component({
selector: 'adf-search-sorting-picker',
@@ -32,7 +33,7 @@ export class SearchSortingPickerComponent implements OnInit {
value: string;
ascending: boolean;
constructor(private queryBuilder: SearchQueryBuilderService) {}
constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private queryBuilder: SearchQueryBuilderService) {}
ngOnInit() {
this.options = this.queryBuilder.getSortingOptions();

View File

@@ -15,9 +15,10 @@
* limitations under the License.
*/
import { Component, Input, ViewChild, ViewContainerRef, OnInit, OnDestroy, ComponentRef, ComponentFactoryResolver } from '@angular/core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { Component, Input, ViewChild, ViewContainerRef, OnInit, OnDestroy, ComponentRef, ComponentFactoryResolver, Inject } from '@angular/core';
import { SearchFilterService } from '../search-filter/search-filter.service';
import { BaseQueryBuilderService } from '../../base-query-builder.service';
import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token';
@Component({
selector: 'adf-search-widget-container',
@@ -44,7 +45,7 @@ export class SearchWidgetContainerComponent implements OnInit, OnDestroy {
constructor(
private searchFilterService: SearchFilterService,
private queryBuilder: SearchQueryBuilderService,
@Inject(SEARCH_QUERY_SERVICE_TOKEN) private queryBuilder: BaseQueryBuilderService,
private componentFactoryResolver: ComponentFactoryResolver) {
}
@@ -75,4 +76,15 @@ export class SearchWidgetContainerComponent implements OnInit, OnDestroy {
}
}
applyInnerWidget() {
if (this.selector === 'date-range' && this.componentRef && this.componentRef.instance) {
this.componentRef.instance.applyCurrentForm();
}
}
resetInnerWidget() {
if (this.componentRef && this.componentRef.instance) {
this.componentRef.instance.reset();
}
}
}

View File

@@ -25,6 +25,8 @@ export * from './search-widget.interface';
export * from './search-configuration.interface';
export * from './search-query-builder.service';
export * from './search-range.interface';
export * from './search-query-service.token';
export * from './search-header-query-builder.service';
export * from './components/search.component';
export * from './components/search-control.component';

View File

@@ -20,6 +20,7 @@ import { SearchWidgetSettings } from './search-widget-settings.interface';
export interface SearchCategory {
id: string;
name: string;
columnKey?: string;
enabled: boolean;
expanded: boolean;
component: {

View File

@@ -0,0 +1,178 @@
/*!
* @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 { SearchConfiguration } from './search-configuration.interface';
import { AppConfigService } from '@alfresco/adf-core';
import { SearchHeaderQueryBuilderService } from './search-header-query-builder.service';
describe('SearchHeaderQueryBuilder', () => {
const buildConfig = (searchSettings): AppConfigService => {
const config = new AppConfigService(null);
config.config['search-headers'] = searchSettings;
return config;
};
it('should load the configuration from app config', () => {
const config: SearchConfiguration = {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
],
filterQueries: [{ query: 'query1' }, { query: 'query2' }]
};
const builder = new SearchHeaderQueryBuilderService(
buildConfig(config),
null,
null
);
builder.categories = [];
builder.filterQueries = [];
expect(builder.categories.length).toBe(0);
expect(builder.filterQueries.length).toBe(0);
builder.setUpConfiguration();
expect(builder.categories.length).toBe(2);
expect(builder.filterQueries.length).toBe(2);
});
it('should return the category assigned to a column key', () => {
const config: SearchConfiguration = {
categories: [
<any> { id: 'cat1', columnKey: 'fake-key-1', enabled: true },
<any> { id: 'cat2', columnKey: 'fake-key-2', enabled: true }
],
filterQueries: [{ query: 'query1' }, { query: 'query2' }]
};
const service = new SearchHeaderQueryBuilderService(
buildConfig(config),
null,
null
);
const category = service.getCategoryForColumn('fake-key-1');
expect(category).not.toBeNull();
expect(category).not.toBeUndefined();
expect(category.columnKey).toBe('fake-key-1');
});
it('should have empty user query by default', () => {
const builder = new SearchHeaderQueryBuilderService(
buildConfig({}),
null,
null
);
expect(builder.userQuery).toBe('');
});
it('should add the extra filter for the parent node', () => {
const config: SearchConfiguration = {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
],
filterQueries: [{ query: 'query1' }, { query: 'query2' }]
};
const expectedResult = [
{ query: 'query1' },
{ query: 'query2' },
{ query: 'PARENT:"workspace://SpacesStore/fake-node-id"' }
];
const searchHeaderService = new SearchHeaderQueryBuilderService(
buildConfig(config),
null,
null
);
searchHeaderService.setCurrentRootFolderId('fake-node-id', undefined);
expect(searchHeaderService.filterQueries).toEqual(expectedResult, 'Filters are not as expected');
});
it('should not add again the parent filter if that node is already added', () => {
const expectedResult = [
{ query: 'query1' },
{ query: 'query2' },
{ query: 'PARENT:"workspace://SpacesStore/fake-node-id' }
];
const config: SearchConfiguration = {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
],
filterQueries: expectedResult
};
const searchHeaderService = new SearchHeaderQueryBuilderService(
buildConfig(config),
null,
null
);
searchHeaderService.setCurrentRootFolderId('fake-node-id', undefined);
expect(searchHeaderService.filterQueries).toEqual(
expectedResult,
'Filters are not as expected'
);
});
it('should replace the new query filter for the old parent node with the new one', () => {
const expectedResult = [
{ query: 'query1' },
{ query: 'query2' },
{ query: 'PARENT:"workspace://SpacesStore/fake-next-node-id"' }
];
const config: SearchConfiguration = {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
],
filterQueries: [
{ query: 'query1' },
{ query: 'query2' },
{ query: 'PARENT:"workspace://SpacesStore/fake-node-id' }
]
};
const searchHeaderService = new SearchHeaderQueryBuilderService(
buildConfig(config),
null,
null
);
searchHeaderService.setCurrentRootFolderId(
'fake-next-node-id',
'fake-node-id'
);
expect(searchHeaderService.filterQueries).toEqual(
expectedResult,
'Filters are not as expected'
);
});
});

View File

@@ -0,0 +1,116 @@
/*!
* @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 { Injectable } from '@angular/core';
import { AlfrescoApiService, AppConfigService, NodesApiService } from '@alfresco/adf-core';
import { SearchConfiguration } from './search-configuration.interface';
import { BaseQueryBuilderService } from './base-query-builder.service';
import { SearchCategory } from './search-category.interface';
import { MinimalNode } from '@alfresco/js-api';
@Injectable({
providedIn: 'root'
})
export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService {
private customSources = ['-trashcan-', '-sharedlinks-', '-sites-', '-mysites-', '-favorites-', '-recent-', '-my-'];
activeFilters: string[] = [];
currentParentFolderID: string;
constructor(appConfig: AppConfigService, alfrescoApiService: AlfrescoApiService, private nodeApiService: NodesApiService) {
super(appConfig, alfrescoApiService);
}
public isFilterServiceActive(): boolean {
return true;
}
loadConfiguration(): SearchConfiguration {
return this.appConfig.get<SearchConfiguration>('search-headers');
}
setupCurrentPagination(maxItems: number, skipCount: number) {
if (!this.paging ||
(this.paging &&
this.paging.maxItems !== maxItems || this.paging.skipCount !== skipCount)) {
this.paging = { maxItems, skipCount };
this.execute();
}
}
setActiveFilter(columnActivated: string) {
this.activeFilters.push(columnActivated);
}
isNoFilterActive(): boolean {
return this.activeFilters.length === 0;
}
removeActiveFilter(columnRemoved: string) {
const removeIndex = this.activeFilters.findIndex((column) => column === columnRemoved);
this.activeFilters.splice(removeIndex, 1);
}
getCategoryForColumn(columnKey: string): SearchCategory {
let foundCategory = null;
if (this.categories !== null) {
foundCategory = this.categories.find(
category => category.columnKey === columnKey
);
}
return foundCategory;
}
setCurrentRootFolderId(currentFolderId: string, previousFolderId: string) {
if (this.customSources.includes(currentFolderId)) {
if (currentFolderId !== this.currentParentFolderID) {
this.nodeApiService.getNode(currentFolderId).subscribe((nodeEntity: MinimalNode) => {
this.updateCurrentParentFilter(nodeEntity.id, previousFolderId);
});
}
} else {
this.updateCurrentParentFilter(currentFolderId, previousFolderId);
}
}
private updateCurrentParentFilter(currentFolderId: string, previousFolderId: string) {
const alreadyAddedFilter = this.filterQueries.find(filterQueries =>
filterQueries.query.includes(currentFolderId)
);
if (!alreadyAddedFilter) {
this.removeOldFolderFiltering(previousFolderId, this.currentParentFolderID);
this.currentParentFolderID = currentFolderId;
this.filterQueries.push({
query: `PARENT:"workspace://SpacesStore/${currentFolderId}"`
});
}
}
private removeOldFolderFiltering(previousFolderId: string, actualSetFolderId: string) {
if (previousFolderId || actualSetFolderId) {
const folderIdToRetrieve = previousFolderId ? previousFolderId : actualSetFolderId;
const oldFilterIndex = this.filterQueries.findIndex(filterQueries =>
filterQueries.query.includes(folderIdToRetrieve)
);
if (oldFilterIndex) {
this.filterQueries.splice(oldFilterIndex, 1);
}
}
}
}

View File

@@ -47,7 +47,7 @@ describe('SearchQueryBuilder', () => {
expect(builder.categories.length).toBe(0);
expect(builder.filterQueries.length).toBe(0);
builder.resetToDefaults();
builder.setUpConfiguration();
expect(builder.categories.length).toBe(2);
expect(builder.filterQueries.length).toBe(2);

View File

@@ -16,417 +16,24 @@
*/
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core';
import {
QueryBody,
RequestFacetFields,
RequestFacetField,
RequestSortDefinitionInner,
ResultSetPaging,
RequestHighlight
} from '@alfresco/js-api';
import { SearchCategory } from './search-category.interface';
import { FilterQuery } from './filter-query.interface';
import { SearchRange } from './search-range.interface';
import { SearchConfiguration } from './search-configuration.interface';
import { FacetQuery } from './facet-query.interface';
import { SearchSortingDefinition } from './search-sorting-definition.interface';
import { FacetField } from './facet-field.interface';
import { FacetFieldBucket } from './facet-field-bucket.interface';
import { BaseQueryBuilderService } from './base-query-builder.service';
@Injectable({
providedIn: 'root'
})
export class SearchQueryBuilderService {
export class SearchQueryBuilderService extends BaseQueryBuilderService {
private _userQuery = '';
updated = new Subject<QueryBody>();
executed = new Subject<ResultSetPaging>();
error = new Subject();
categories: Array<SearchCategory> = [];
queryFragments: { [id: string]: string } = {};
filterQueries: FilterQuery[] = [];
paging: { maxItems?: number; skipCount?: number } = null;
sorting: Array<SearchSortingDefinition> = [];
protected userFacetBuckets: { [key: string]: Array<FacetFieldBucket> } = {};
get userQuery(): string {
return this._userQuery;
}
set userQuery(value: string) {
value = (value || '').trim();
this._userQuery = value ? `(${value})` : '';
}
config: SearchConfiguration = {
categories: []
};
// TODO: to be supported in future iterations
ranges: { [id: string]: SearchRange } = {};
constructor(private appConfig: AppConfigService, private alfrescoApiService: AlfrescoApiService) {
this.resetToDefaults();
}
/**
* Resets the query to the defaults specified in the app config.
*/
resetToDefaults() {
const template = this.appConfig.get<SearchConfiguration>('search');
if (template) {
this.config = JSON.parse(JSON.stringify(template));
this.categories = (this.config.categories || []).filter((category) => category.enabled);
this.filterQueries = this.config.filterQueries || [];
this.userFacetBuckets = {};
if (this.config.sorting) {
this.sorting = this.config.sorting.defaults || [];
}
}
}
/**
* Adds a facet bucket to a field.
* @param field The target field
* @param bucket Bucket to add
*/
addUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (field && field.field && bucket) {
const buckets = this.userFacetBuckets[field.field] || [];
const existing = buckets.find((facetBucket) => facetBucket.label === bucket.label);
if (!existing) {
buckets.push(bucket);
}
this.userFacetBuckets[field.field] = buckets;
}
}
/**
* Gets the buckets currently added to a field
* @param field The target fields
* @returns Bucket array
*/
getUserFacetBuckets(field: string) {
return this.userFacetBuckets[field] || [];
}
/**
* Removes an existing bucket from a field.
* @param field The target field
* @param bucket Bucket to remove
*/
removeUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) {
if (field && field.field && bucket) {
const buckets = this.userFacetBuckets[field.field] || [];
this.userFacetBuckets[field.field] = buckets
.filter((facetBucket) => facetBucket.label !== bucket.label);
}
}
/**
* Adds a filter query to the current query.
* @param query Query string to add
*/
addFilterQuery(query: string): void {
if (query) {
const existing = this.filterQueries.find((filterQuery) => filterQuery.query === query);
if (!existing) {
this.filterQueries.push({ query: query });
}
}
}
/**
* Removes an existing filter query.
* @param query The query to remove
*/
removeFilterQuery(query: string): void {
if (query) {
this.filterQueries = this.filterQueries
.filter((filterQuery) => filterQuery.query !== query);
}
}
/**
* Gets a facet query by label.
* @param label Label of the query
* @returns Facet query data
*/
getFacetQuery(label: string): FacetQuery {
if (label && this.hasFacetQueries) {
const result = this.config.facetQueries.queries.find((query) => query.label === label);
if (result) {
return { ...result };
}
}
return null;
}
/**
* Gets a facet field by label.
* @param label Label of the facet field
* @returns Facet field data
*/
getFacetField(label: string): FacetField {
if (label) {
const fields = this.config.facetFields.fields || [];
const result = fields.find((field) => field.label === label);
if (result) {
result.label = this.getSupportedLabel(result.label);
return { ...result };
}
}
return null;
}
/**
* Builds the current query and triggers the `updated` event.
*/
update(): void {
const query = this.buildQuery();
this.updated.next(query);
}
/**
* Builds and executes the current query.
* @returns Nothing
*/
async execute() {
try {
const query = this.buildQuery();
if (query) {
const resultSetPaging: ResultSetPaging = await this.alfrescoApiService.searchApi.search(query);
this.executed.next(resultSetPaging);
}
} catch (error) {
this.error.next(error);
this.executed.next({
list: {
pagination: {
totalItems: 0
},
entries: []
}
});
}
}
/**
* Builds the current query.
* @returns The finished query
*/
buildQuery(): QueryBody {
const query = this.getFinalQuery();
const include = this.config.include || [];
if (include.length === 0) {
include.push('path', 'allowableOperations');
}
if (query) {
const result: QueryBody = <QueryBody> {
query: {
query: query,
language: 'afts'
},
include: include,
paging: this.paging,
fields: this.config.fields,
filterQueries: this.filterQueries,
facetQueries: this.facetQueries,
facetIntervals: this.facetIntervals,
facetFields: this.facetFields,
sort: this.sort,
highlight: this.highlight
};
result['facetFormat'] = 'V2';
return result;
}
return null;
}
/**
* Gets the primary sorting definition.
* @returns The primary sorting definition
*/
getPrimarySorting(): SearchSortingDefinition {
if (this.sorting && this.sorting.length > 0) {
return this.sorting[0];
}
return null;
}
/**
* Gets all pre-configured sorting options that users can choose from.
* @returns Pre-configured sorting options
*/
getSortingOptions(): SearchSortingDefinition[] {
if (this.config && this.config.sorting) {
return this.config.sorting.options || [];
}
return [];
}
/**
* Gets the query group.
* @param query Target query
* @returns Query group
*/
getQueryGroup(query) {
return query.group || this.config.facetQueries.label || 'Facet Queries';
}
/**
* Checks if FacetQueries has been defined
* @returns True if defined, false otherwise
*/
get hasFacetQueries(): boolean {
if (this.config
&& this.config.facetQueries
&& this.config.facetQueries.queries
&& this.config.facetQueries.queries.length > 0) {
return true;
}
public isFilterServiceActive(): boolean {
return false;
}
/**
* Checks if FacetIntervals has been defined
* @returns True if defined, false otherwise
*/
get hasFacetIntervals(): boolean {
if (this.config
&& this.config.facetIntervals
&& this.config.facetIntervals.intervals
&& this.config.facetIntervals.intervals.length > 0) {
return true;
}
return false;
constructor(appConfig: AppConfigService, alfrescoApiService: AlfrescoApiService) {
super(appConfig, alfrescoApiService);
}
get hasFacetHighlight(): boolean {
return this.config && this.config.highlight ? true : false;
}
protected get sort(): RequestSortDefinitionInner[] {
return this.sorting.map((def) => {
return new RequestSortDefinitionInner({
type: def.type,
field: def.field,
ascending: def.ascending
});
});
}
protected get facetQueries(): FacetQuery[] {
if (this.hasFacetQueries) {
return this.config.facetQueries.queries.map((query) => {
query.group = this.getQueryGroup(query);
return <FacetQuery> { ...query };
});
}
return null;
}
protected get facetIntervals(): any {
if (this.hasFacetIntervals) {
const configIntervals = this.config.facetIntervals;
return {
intervals: configIntervals.intervals.map((interval) => <any> {
label: this.getSupportedLabel(interval.label),
field: interval.field,
sets: interval.sets.map((set) => <any> {
label: this.getSupportedLabel(set.label),
start: set.start,
end: set.end,
startInclusive: set.startInclusive,
endInclusive: set.endInclusive
})
})
};
}
return null;
}
protected get highlight(): RequestHighlight {
return this.hasFacetHighlight ? this.config.highlight : null;
}
protected getFinalQuery(): string {
let query = '';
this.categories.forEach((facet) => {
const customQuery = this.queryFragments[facet.id];
if (customQuery) {
if (query.length > 0) {
query += ' AND ';
}
query += `(${customQuery})`;
}
});
let result = [this.userQuery, query]
.filter((entry) => entry)
.join(' AND ');
if (this.userFacetBuckets) {
Object.keys(this.userFacetBuckets).forEach((key) => {
const subQuery = (this.userFacetBuckets[key] || [])
.filter((bucket) => bucket.filterQuery)
.map((bucket) => bucket.filterQuery)
.join(' OR ');
if (subQuery) {
if (result.length > 0) {
result += ' AND ';
}
result += `(${subQuery})`;
}
});
}
return result;
}
protected get facetFields(): RequestFacetFields {
const facetFields = this.config.facetFields && this.config.facetFields.fields;
if (facetFields && facetFields.length > 0) {
return {
facets: facetFields.map((facet) => <RequestFacetField> {
field: facet.field,
mincount: facet.mincount,
label: this.getSupportedLabel(facet.label),
limit: facet.limit,
offset: facet.offset,
prefix: facet.prefix
})
};
}
return null;
}
/**
* Encloses a label name with double quotes if it contains whitespace characters.
* @param configLabel Original label text
* @returns Label, possibly with quotes if it contains spaces
*/
getSupportedLabel(configLabel: string): string {
const spaceInsideLabelIndex = configLabel.search(/\s/g);
if (spaceInsideLabelIndex > -1) {
return `"${configLabel}"`;
}
return configLabel;
public loadConfiguration(): SearchConfiguration {
return this.appConfig.get<SearchConfiguration>('search');
}
}

View File

@@ -15,19 +15,7 @@
* limitations under the License.
*/
import { Component, Input } from '@angular/core';
import { DataColumn } from '@alfresco/adf-core';
import { InjectionToken } from '@angular/core';
import { BaseQueryBuilderService } from './base-query-builder.service';
@Component({
selector: 'adf-filter-menu',
templateUrl: './filter-menu.component.html'
})
export class FilterMenuComponent {
@Input()
col: DataColumn;
onMenuButtonClick(event: Event) {
event.stopPropagation();
}
}
export const SEARCH_QUERY_SERVICE_TOKEN = new InjectionToken<BaseQueryBuilderService>('QueryService');

View File

@@ -22,4 +22,5 @@ export interface SearchWidget {
id: string;
settings?: SearchWidgetSettings;
context?: SearchQueryBuilderService;
reset();
}

View File

@@ -35,6 +35,9 @@ import { SearchNumberRangeComponent } from './components/search-number-range/sea
import { SearchCheckListComponent } from './components/search-check-list/search-check-list.component';
import { SearchDateRangeComponent } from './components/search-date-range/search-date-range.component';
import { SearchSortingPickerComponent } from './components/search-sorting-picker/search-sorting-picker.component';
import { SearchHeaderComponent } from './components/search-header/search-header.component';
import { SEARCH_QUERY_SERVICE_TOKEN } from './search-query-service.token';
import { SearchQueryBuilderService } from './search-query-builder.service';
@NgModule({
imports: [
@@ -57,7 +60,8 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker
SearchNumberRangeComponent,
SearchCheckListComponent,
SearchDateRangeComponent,
SearchSortingPickerComponent
SearchSortingPickerComponent,
SearchHeaderComponent
],
exports: [
SearchComponent,
@@ -72,7 +76,8 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker
SearchNumberRangeComponent,
SearchCheckListComponent,
SearchDateRangeComponent,
SearchSortingPickerComponent
SearchSortingPickerComponent,
SearchHeaderComponent
],
entryComponents: [
SearchWidgetContainerComponent,
@@ -82,6 +87,9 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker
SearchNumberRangeComponent,
SearchCheckListComponent,
SearchDateRangeComponent
],
providers: [
{ provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchQueryBuilderService }
]
})
export class SearchModule {}

View File

@@ -13,6 +13,7 @@
@import '../search/components/search-sorting-picker/search-sorting-picker.component';
@import '../search/components/search-filter/search-filter.component';
@import '../search/components/search-chip-list/search-chip-list.component';
@import '../search/components/search-header/search-header.component';
@import '../dialogs/folder.dialog';
@@ -39,6 +40,7 @@
@include adf-search-control-theme($theme);
@include adf-search-autocomplete-theme($theme);
@include adf-search-sorting-picker-theme($theme);
@include adf-filter-menu-theme($theme);
@include adf-dialog-theme($theme);
@include adf-content-node-selector-dialog-theme($theme);
@include adf-content-metadata-module-theme($theme);