[AAE-11496] Move 'content-plugin' to projects folder as 'aca-content' (#2817)

* [AAE-11496] Move content-plugin to projects

* Fix unit test
This commit is contained in:
Bartosz Sekuła
2022-12-20 18:15:34 +01:00
committed by GitHub
parent c87662900e
commit e570ef8da0
263 changed files with 291 additions and 58 deletions

View File

@@ -0,0 +1,32 @@
<button mat-icon-button [matMenuTriggerFor]="dataSorting" id="aca-button-action-menu" aria-label="Search action menu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #dataSorting="matMenu">
<button mat-menu-item [matMenuTriggerFor]="sorting" id="aca-button-sorting-menu">{{ 'SEARCH.SORT.SORTING_OPTION' | translate }}</button>
</mat-menu>
<mat-menu #sorting="matMenu">
<ng-template matMenuContent>
<button
mat-menu-item
*ngFor="let option of options"
[id]="option.key + '-sorting-option'"
[matMenuTriggerFor]="direction"
[matMenuTriggerData]="{ option: option }"
>
{{ option.label | translate }}
</button>
</ng-template>
</mat-menu>
<mat-menu #direction="matMenu">
<ng-template matMenuContent let-option="option">
<button mat-menu-item [id]="option.key + '-sorting-option-asc'" (click)="onAscSortingClicked(option)">
{{ 'SEARCH.SORT.ASCENDING' | translate }}
</button>
<button mat-menu-item [id]="option.key + '-sorting-option-desc'" (click)="onDescSortingClicked(option)">
{{ 'SEARCH.SORT.DESCENDING' | translate }}
</button>
</ng-template>
</mat-menu>

View File

@@ -0,0 +1,137 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { SearchActionMenuComponent } from './search-action-menu.component';
const mockSortingData: SearchSortingDefinition[] = [
{
ascending: false,
field: 'fieldA',
key: 'keyA',
label: 'LabelA',
type: 'A'
},
{
ascending: true,
field: 'fieldB',
key: 'keyB',
label: 'Zorro',
type: 'Z'
}
];
describe('SearchActionMenuComponent', () => {
let fixture: ComponentFixture<SearchActionMenuComponent>;
let component: SearchActionMenuComponent;
let queryService: SearchQueryBuilderService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule],
declarations: [SearchActionMenuComponent],
providers: [SearchQueryBuilderService]
});
fixture = TestBed.createComponent(SearchActionMenuComponent);
queryService = TestBed.inject(SearchQueryBuilderService);
component = fixture.componentInstance;
});
it('should emit sortingSelected event when asc sorting option is selected', async () => {
spyOn(queryService, 'getSortingOptions').and.returnValue(mockSortingData);
const expectedOption: SearchSortingDefinition = {
ascending: true,
field: 'fieldA',
key: 'keyA',
label: 'LabelA',
type: 'A'
};
spyOn(component.sortingSelected, 'emit').and.callThrough();
fixture.detectChanges();
await fixture.whenStable();
const actionMenuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#aca-button-action-menu');
actionMenuButton.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
const sortingMenuButton: HTMLButtonElement | null = document.querySelector('#aca-button-sorting-menu');
sortingMenuButton?.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
const fieldAMenuButton: HTMLButtonElement | null = document.querySelector('#keyA-sorting-option');
fieldAMenuButton?.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
const directionButton: HTMLButtonElement | null = document.querySelector('#keyA-sorting-option-asc');
directionButton?.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
expect(component.sortingSelected.emit).toHaveBeenCalledWith(expectedOption);
});
it('should emit sortingSelected event when desc sorting option is selected', async () => {
spyOn(queryService, 'getSortingOptions').and.returnValue(mockSortingData);
const expectedOption: SearchSortingDefinition = {
ascending: false,
field: 'fieldB',
key: 'keyB',
label: 'Zorro',
type: 'Z'
};
spyOn(component.sortingSelected, 'emit').and.callThrough();
fixture.detectChanges();
await fixture.whenStable();
const actionMenuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#aca-button-action-menu');
actionMenuButton.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
const sortingMenuButton: HTMLButtonElement | null = document.querySelector('#aca-button-sorting-menu');
sortingMenuButton?.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
const fieldAMenuButton: HTMLButtonElement | null = document.querySelector('#keyB-sorting-option');
fieldAMenuButton?.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
const directionButton: HTMLButtonElement | null = document.querySelector('#keyB-sorting-option-desc');
directionButton?.dispatchEvent(new Event('click'));
await fixture.whenStable();
fixture.detectChanges();
expect(component.sortingSelected.emit).toHaveBeenCalledWith(expectedOption);
});
});

View File

@@ -0,0 +1,56 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface';
import { Component, OnInit, Output, ViewEncapsulation, EventEmitter } from '@angular/core';
@Component({
selector: 'aca-search-action-menu',
templateUrl: './search-action-menu.component.html',
encapsulation: ViewEncapsulation.None
})
export class SearchActionMenuComponent implements OnInit {
@Output()
sortingSelected: EventEmitter<SearchSortingDefinition> = new EventEmitter();
options: SearchSortingDefinition[] = [];
constructor(private queryBuilder: SearchQueryBuilderService) {}
ngOnInit(): void {
this.options = this.queryBuilder.getSortingOptions();
}
onAscSortingClicked(option: SearchSortingDefinition) {
option.ascending = true;
this.sortingSelected.emit(option);
}
onDescSortingClicked(option: SearchSortingDefinition) {
option.ascending = false;
this.sortingSelected.emit(option);
}
}

View File

@@ -0,0 +1,27 @@
<div class="app-search-container">
<button
mat-icon-button
id="app-search-button"
class="app-search-button"
(click)="searchSubmit(searchTerm)"
[title]="'SEARCH.BUTTON.TOOLTIP' | translate"
>
<mat-icon [attr.aria-label]="'SEARCH.BUTTON.ARIA-LABEL' | translate">search</mat-icon>
</button>
<mat-form-field class="app-input-form-field" [floatLabel]="'never'">
<input
matInput
#searchInput
[attr.aria-label]="'SEARCH.INPUT.ARIA-LABEL' | translate"
[type]="inputType"
id="app-control-input"
[(ngModel)]="searchTerm"
(ngModelChange)="inputChange($event)"
(keyup.enter)="searchSubmit($event)"
[placeholder]="'SEARCH.INPUT.PLACEHOLDER' | translate"
/>
<div matSuffix class="app-suffix-search-icon-wrapper">
<mat-icon *ngIf="searchTerm.length" (click)="clear()" class="app-clear-icon">clear</mat-icon>
</div>
</mat-form-field>
</div>

View File

@@ -0,0 +1,61 @@
$top-margin: 12px;
.app-search-container {
margin-top: -$top-margin;
padding-top: 2px;
font-size: 16px;
padding-left: 15px;
box-sizing: border-box;
.mat-form-field {
font-size: 16px;
}
.mat-form-field-underline {
display: none;
}
.mat-form-field-label-wrapper {
cursor: text;
}
// fixes pointer event on FF
&.searchMenuTrigger .mat-form-field-label-wrapper {
pointer-events: auto;
}
.app-input-form-field {
letter-spacing: -0.7px;
width: calc(100% - 56px);
.mat-input-element {
letter-spacing: -0.7px;
}
}
.app-search-button.mat-icon-button {
top: -2px;
left: -8px;
.mat-icon {
font-size: 24px;
padding-right: 0;
}
}
}
.app-suffix-search-icon-wrapper {
height: 6px;
margin: 14px 1px;
float: left;
.mat-icon {
font-size: 24px;
cursor: pointer;
}
.app-clear-icon {
font-size: 18px;
margin: 3px;
}
}

View File

@@ -0,0 +1,94 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { SearchInputControlComponent } from './search-input-control.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('SearchInputControlComponent', () => {
let fixture: ComponentFixture<SearchInputControlComponent>;
let component: SearchInputControlComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule],
declarations: [SearchInputControlComponent],
schemas: [NO_ERRORS_SCHEMA]
});
fixture = TestBed.createComponent(SearchInputControlComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should emit submit event on searchSubmit', async () => {
const keyboardEvent = { target: { value: 'a' } };
let eventArgs = null;
component.submit.subscribe((args) => (eventArgs = args));
await component.searchSubmit(keyboardEvent);
expect(eventArgs).toBe(keyboardEvent);
});
it('should emit searchChange event on inputChange', async () => {
const searchTerm = 'b';
let eventArgs = null;
component.searchChange.subscribe((args) => (eventArgs = args));
await component.inputChange(searchTerm);
expect(eventArgs).toBe(searchTerm);
});
it('should emit searchChange event on clear', async () => {
let eventArgs = null;
component.searchChange.subscribe((args) => (eventArgs = args));
await component.clear();
expect(eventArgs).toBe('');
});
it('should clear searchTerm', async () => {
component.searchTerm = 'c';
fixture.detectChanges();
await component.clear();
expect(component.searchTerm).toBe('');
});
it('should check if searchTerm has a length less than 2', () => {
expect(component.isTermTooShort()).toBe(false);
component.searchTerm = 'd';
fixture.detectChanges();
expect(component.isTermTooShort()).toBe(true);
component.searchTerm = 'dd';
fixture.detectChanges();
expect(component.isTermTooShort()).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, EventEmitter, Input, OnDestroy, Output, ViewEncapsulation, ViewChild, ElementRef } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'app-search-input-control',
templateUrl: './search-input-control.component.html',
styleUrls: ['./search-input-control.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'app-search-control' }
})
export class SearchInputControlComponent implements OnDestroy {
onDestroy$: Subject<boolean> = new Subject<boolean>();
/** Type of the input field to render, e.g. "search" or "text" (default). */
@Input()
inputType = 'text';
/** Emitted when the search is submitted pressing ENTER button.
* The search term is provided as value of the event.
*/
@Output()
// eslint-disable-next-line @angular-eslint/no-output-native
submit: EventEmitter<any> = new EventEmitter();
/** Emitted when the search term is changed. The search term is provided
* in the 'value' property of the returned object. If the term is less
* than three characters in length then the term is truncated to an empty
* string.
*/
@Output()
searchChange: EventEmitter<string> = new EventEmitter();
@ViewChild('searchInput', { static: true })
searchInput: ElementRef;
searchTerm = '';
ngOnDestroy(): void {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
searchSubmit(event: any) {
this.submit.emit(event);
}
inputChange(event: any) {
this.searchChange.emit(event);
}
clear() {
this.searchTerm = '';
this.searchChange.emit('');
}
isTermTooShort() {
return !!(this.searchTerm && this.searchTerm.length < 2);
}
}

View File

@@ -0,0 +1,39 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CoreModule } from '@alfresco/adf-core';
import { SearchInputComponent } from './search-input/search-input.component';
import { SearchInputControlComponent } from './search-input-control/search-input-control.component';
import { ContentModule } from '@alfresco/adf-content-services';
import { A11yModule } from '@angular/cdk/a11y';
@NgModule({
imports: [CommonModule, CoreModule.forChild(), ContentModule.forChild(), A11yModule],
declarations: [SearchInputComponent, SearchInputControlComponent],
exports: [SearchInputComponent, SearchInputControlComponent]
})
export class AppSearchInputModule {}

View File

@@ -0,0 +1,52 @@
<div
class="app-search-container searchMenuTrigger"
[matMenuTriggerFor]="searchOptionsMenu"
(menuOpened)="onMenuOpened()"
(menuClosed)="syncInputValues()"
>
<button mat-icon-button class="app-search-button" (click)="searchByOption()" [title]="'SEARCH.BUTTON.TOOLTIP' | translate">
<mat-icon [attr.aria-label]="'SEARCH.BUTTON.ARIA-LABEL' | translate">search</mat-icon>
</button>
<mat-form-field class="app-input-form-field" [floatLabel]="'never'">
<input
matInput
[attr.aria-label]="'SEARCH.INPUT.ARIA-LABEL' | translate"
[type]="'text'"
[disabled]="true"
[value]="searchedWord"
[placeholder]="'SEARCH.INPUT.PLACEHOLDER' | translate"
/>
<div matSuffix class="app-suffix-search-icon-wrapper">
<mat-icon>arrow_drop_down</mat-icon>
</div>
</mat-form-field>
</div>
<mat-menu #searchOptionsMenu="matMenu" [overlapTrigger]="true" class="app-search-options-menu">
<div (keydown.tab)="$event.stopPropagation()" (keydown.shift.tab)="$event.stopPropagation()">
<div cdkTrapFocus>
<app-search-input-control
#searchInputControl
(click)="$event.stopPropagation()"
(submit)="onSearchSubmit($event)"
(searchChange)="onSearchChange($event)"
>
</app-search-input-control>
<mat-hint *ngIf="hasLibraryConstraint()" class="app-search-hint">{{ 'SEARCH.INPUT.HINT' | translate }}</mat-hint>
<div id="search-options">
<mat-checkbox
*ngFor="let option of searchOptions"
id="{{ option.id }}"
[(ngModel)]="option.value"
[disabled]="option.shouldDisable()"
(change)="searchByOption()"
(click)="$event.stopPropagation()"
>
{{ option.key | translate }}
</mat-checkbox>
</div>
</div>
</div>
</mat-menu>

View File

@@ -0,0 +1,155 @@
$search-width: 594px;
$search-height: 40px;
$search-background: #f5f6f5;
$search-border-radius: 4px;
$top-margin: 12px;
.app-search-container {
color: var(--theme-foreground-text-color);
.app-input-form-field {
.mat-input-element {
caret-color: var(--theme-text-color);
&:disabled {
color: var(--theme-text-color);
}
}
}
.mat-focused label.mat-form-field-label {
display: none;
}
}
.app-search-options-menu {
&.mat-menu-panel {
background-color: var(--theme-dialog-background-color);
width: $search-width;
max-width: unset;
border-radius: $search-border-radius;
margin-top: $top-margin;
}
.mat-menu-content:not(:empty) {
padding-top: 0;
padding-bottom: 0;
}
}
#search-options {
color: var(--theme-text-color);
border-top: 1px solid var(--theme-divider-color);
}
mat-checkbox {
.mat-checkbox-frame {
border-color: var(--theme-text-color);
}
}
.aca-search-input {
width: 100%;
max-width: $search-width;
background-color: $search-background;
border-radius: $search-border-radius;
height: $search-height;
}
.app-search-container {
width: 100%;
max-width: $search-width;
height: $search-height + $top-margin;
}
.app-search-control {
margin-top: -$top-margin;
}
#search-options {
padding: 20px 0;
font-size: 16px;
letter-spacing: -0.7px;
mat-checkbox {
padding: 3px 24px 3px 19px;
.mat-checkbox-inner-container {
height: 18px;
width: 18px;
}
.mat-checkbox-label {
padding: 0 0 0 11px;
overflow: hidden;
text-overflow: ellipsis;
}
}
.mat-checkbox-layout {
max-width: 155px;
}
}
.app-search-hint {
position: absolute;
font-size: 12px;
padding-left: 17px;
}
@media screen and (max-width: 959px) {
$search-width-small: 400px;
.aca-search-input {
max-width: $search-width-small;
}
.app-search-container {
max-width: $search-width-small;
}
.app-search-options-menu {
&.mat-menu-panel {
width: $search-width-small;
}
}
#search-options {
padding-left: 20px;
mat-checkbox {
padding: 3px 20px 3px 0;
.mat-checkbox-label {
padding: 0;
}
}
.mat-checkbox-layout {
max-width: 105px;
}
}
}
@media screen and (max-width: 599px) {
$search-width-xsmall: 220px;
.aca-search-input {
max-width: $search-width-xsmall;
}
.app-search-container {
max-width: $search-width-xsmall;
}
.app-search-options-menu {
&.mat-menu-panel {
width: $search-width-xsmall;
margin-top: 9px;
}
}
#search-options .mat-checkbox-layout {
max-width: 180px;
}
}

View File

@@ -0,0 +1,196 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { SearchInputComponent } from './search-input.component';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { Actions, ofType } from '@ngrx/effects';
import { SearchByTermAction, SearchActionTypes, SnackbarErrorAction, SnackbarActionTypes } from '@alfresco/aca-shared/store';
import { AppHookService } from '@alfresco/aca-shared';
import { map } from 'rxjs/operators';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
describe('SearchInputComponent', () => {
let fixture: ComponentFixture<SearchInputComponent>;
let component: SearchInputComponent;
let actions$: Actions;
let appHookService: AppHookService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule],
declarations: [SearchInputComponent],
providers: [SearchQueryBuilderService],
schemas: [NO_ERRORS_SCHEMA]
});
actions$ = TestBed.inject(Actions);
fixture = TestBed.createComponent(SearchInputComponent);
appHookService = TestBed.inject(AppHookService);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should change flag on library400Error event', () => {
expect(component.has400LibraryError).toBe(false);
appHookService.library400Error.next();
expect(component.has400LibraryError).toBe(true);
});
it('should have no library constraint by default', () => {
expect(component.hasLibraryConstraint()).toBe(false);
});
it('should have library constraint on 400 error received', () => {
const libItem = component.searchOptions.find((item) => item.key.toLowerCase().indexOf('libraries') > 0);
libItem.value = true;
appHookService.library400Error.next();
expect(component.hasLibraryConstraint()).toBe(true);
});
describe('onSearchSubmit()', () => {
it('should call search action with correct search options', (done) => {
const searchedTerm = 's';
component.searchedWord = 'te';
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.searchOptions[0].key).toBe('SEARCH.INPUT.FILES');
})
)
.subscribe(() => {
done();
});
component.onSearchSubmit({ target: { value: searchedTerm } });
fixture.detectChanges();
});
it('should call search action with correct searched term', (done) => {
const searchedTerm = 's';
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.payload).toBe(searchedTerm);
})
)
.subscribe(() => {
done();
});
component.onSearchSubmit({ target: { value: searchedTerm } });
fixture.detectChanges();
});
});
describe('onSearchChange()', () => {
it('should call search action with correct search options', (done) => {
const searchedTerm = 's';
const currentSearchOptions = [{ key: 'SEARCH.INPUT.FILES' }];
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.searchOptions[0].key).toBe(currentSearchOptions[0].key);
})
)
.subscribe(() => {
done();
});
component.onSearchChange(searchedTerm);
});
it('should call search action with correct searched term', (done) => {
const searchedTerm = 's';
actions$
.pipe(
ofType<SearchByTermAction>(SearchActionTypes.SearchByTerm),
map((action) => {
expect(action.payload).toBe(searchedTerm);
})
)
.subscribe(() => {
done();
});
component.onSearchChange(searchedTerm);
});
it('should show snack for empty search', (done) => {
const searchedTerm = '';
actions$
.pipe(
ofType<SnackbarErrorAction>(SnackbarActionTypes.Error),
map((action) => {
expect(action.payload).toBe('APP.BROWSE.SEARCH.EMPTY_SEARCH');
})
)
.subscribe(() => {
done();
});
component.onSearchSubmit(searchedTerm);
});
});
describe('isLibrariesChecked()', () => {
it('should return false by default', () => {
expect(component.isLibrariesChecked()).toBe(false);
});
it('should return true when libraries checked', () => {
const libItem = component.searchOptions.find((item) => item.key.toLowerCase().indexOf('libraries') > 0);
libItem.value = true;
expect(component.isLibrariesChecked()).toBe(true);
});
});
describe('isContentChecked()', () => {
it('should return false by default', () => {
expect(component.isContentChecked()).toBe(false);
});
it('should return true when files checked', () => {
const filesItem = component.searchOptions.find((item) => item.key.toLowerCase().indexOf('files') > 0);
filesItem.value = true;
expect(component.isContentChecked()).toBe(true);
});
it('should return true when folders checked', () => {
const foldersItem = component.searchOptions.find((item) => item.key.toLowerCase().indexOf('folders') > 0);
foldersItem.value = true;
expect(component.isContentChecked()).toBe(true);
});
it('should return true when both files and folders checked', () => {
const filesItem = component.searchOptions.find((item) => item.key.toLowerCase().indexOf('files') > 0);
filesItem.value = true;
const foldersItem = component.searchOptions.find((item) => item.key.toLowerCase().indexOf('folders') > 0);
foldersItem.value = true;
expect(component.isContentChecked()).toBe(true);
});
});
});

View File

@@ -0,0 +1,276 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppHookService } from '@alfresco/aca-shared';
import { AppStore, SearchByTermAction, SearchOptionIds, SearchOptionModel, SnackbarErrorAction } from '@alfresco/aca-shared/store';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { AppConfigService } from '@alfresco/adf-core';
import { Component, OnDestroy, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { MatMenuTrigger } from '@angular/material/menu';
import { NavigationEnd, PRIMARY_OUTLET, Router, RouterEvent, UrlSegment, UrlSegmentGroup, UrlTree } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { SearchInputControlComponent } from '../search-input-control/search-input-control.component';
import { SearchLibrariesQueryBuilderService } from '../search-libraries-results/search-libraries-query-builder.service';
@Component({
selector: 'aca-search-input',
templateUrl: './search-input.component.html',
styleUrls: ['./search-input.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-search-input' }
})
export class SearchInputComponent implements OnInit, OnDestroy {
onDestroy$: Subject<boolean> = new Subject<boolean>();
hasOneChange = false;
hasNewChange = false;
navigationTimer: any;
has400LibraryError = false;
searchOnChange: boolean;
searchedWord: string = null;
searchOptions: Array<SearchOptionModel> = [
{
id: SearchOptionIds.Files,
key: 'SEARCH.INPUT.FILES',
value: false,
shouldDisable: this.isLibrariesChecked.bind(this)
},
{
id: SearchOptionIds.Folders,
key: 'SEARCH.INPUT.FOLDERS',
value: false,
shouldDisable: this.isLibrariesChecked.bind(this)
},
{
id: SearchOptionIds.Libraries,
key: 'SEARCH.INPUT.LIBRARIES',
value: false,
shouldDisable: this.isContentChecked.bind(this)
}
];
@ViewChild('searchInputControl', { static: true })
searchInputControl: SearchInputControlComponent;
@ViewChild(MatMenuTrigger, { static: true })
trigger: MatMenuTrigger;
constructor(
private queryBuilder: SearchQueryBuilderService,
private queryLibrariesBuilder: SearchLibrariesQueryBuilderService,
private config: AppConfigService,
private router: Router,
private store: Store<AppStore>,
private appHookService: AppHookService
) {
this.searchOnChange = this.config.get<boolean>('search.aca:triggeredOnChange', true);
}
ngOnInit() {
this.showInputValue();
this.router.events
.pipe(takeUntil(this.onDestroy$))
.pipe(filter((e) => e instanceof RouterEvent))
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.showInputValue();
}
});
this.appHookService.library400Error.pipe(takeUntil(this.onDestroy$)).subscribe(() => {
this.has400LibraryError = true;
});
}
showInputValue() {
this.has400LibraryError = false;
this.searchedWord = this.getUrlSearchTerm();
if (this.searchInputControl) {
this.searchInputControl.searchTerm = this.searchedWord;
}
}
ngOnDestroy(): void {
this.onDestroy$.next(true);
this.onDestroy$.complete();
this.removeContentFilters();
}
onMenuOpened() {
this.searchInputControl.searchInput.nativeElement.focus();
}
/**
* Called when the user submits the search, e.g. hits enter or clicks submit
*
* @param event Parameters relating to the search
*/
onSearchSubmit(event: any) {
const searchTerm = event.target ? (event.target as HTMLInputElement).value : event;
if (searchTerm) {
this.searchedWord = searchTerm;
this.searchByOption();
} else {
this.store.dispatch(new SnackbarErrorAction('APP.BROWSE.SEARCH.EMPTY_SEARCH'));
}
this.trigger.closeMenu();
}
onSearchChange(searchTerm: string) {
if (!this.searchOnChange) {
return;
}
this.has400LibraryError = false;
this.searchedWord = searchTerm;
if (this.hasOneChange) {
this.hasNewChange = true;
} else {
this.hasOneChange = true;
}
if (this.hasNewChange) {
clearTimeout(this.navigationTimer);
this.hasNewChange = false;
}
this.navigationTimer = setTimeout(() => {
if (searchTerm) {
this.store.dispatch(new SearchByTermAction(searchTerm, this.searchOptions));
}
this.hasOneChange = false;
}, 1000);
}
searchByOption() {
this.syncInputValues();
this.has400LibraryError = false;
if (this.isLibrariesChecked()) {
if (this.onLibrariesSearchResults && this.isSameSearchTerm()) {
this.queryLibrariesBuilder.update();
} else if (this.searchedWord) {
this.store.dispatch(new SearchByTermAction(this.searchedWord, this.searchOptions));
}
} else {
if (this.isFoldersChecked() && !this.isFilesChecked()) {
this.filterContent(SearchOptionIds.Folders);
} else if (this.isFilesChecked() && !this.isFoldersChecked()) {
this.filterContent(SearchOptionIds.Files);
} else {
this.removeContentFilters();
}
if (this.onSearchResults && this.isSameSearchTerm()) {
this.queryBuilder.update();
} else if (this.searchedWord) {
this.store.dispatch(new SearchByTermAction(this.searchedWord, this.searchOptions));
}
}
}
get onLibrariesSearchResults() {
return this.router.url.indexOf('/search-libraries') === 0;
}
get onSearchResults() {
return !this.onLibrariesSearchResults && this.router.url.indexOf('/search') === 0;
}
isFilesChecked(): boolean {
return this.isOptionChecked(SearchOptionIds.Files);
}
isFoldersChecked(): boolean {
return this.isOptionChecked(SearchOptionIds.Folders);
}
isLibrariesChecked(): boolean {
return this.isOptionChecked(SearchOptionIds.Libraries);
}
isOptionChecked(optionId: string): boolean {
const libItem = this.searchOptions.find((item) => item.id === optionId);
return !!libItem && libItem.value;
}
isContentChecked(): boolean {
return this.isFilesChecked() || this.isFoldersChecked();
}
hasLibraryConstraint(): boolean {
if (this.isLibrariesChecked()) {
return this.has400LibraryError || this.searchInputControl.isTermTooShort();
}
return false;
}
filterContent(option: SearchOptionIds.Folders | SearchOptionIds.Files) {
const oppositeOption = option === SearchOptionIds.Folders ? SearchOptionIds.Files : SearchOptionIds.Folders;
this.queryBuilder.addFilterQuery(`+TYPE:'cm:${option}'`);
this.queryBuilder.removeFilterQuery(`+TYPE:'cm:${oppositeOption}'`);
}
removeContentFilters() {
this.queryBuilder.removeFilterQuery(`+TYPE:'cm:${SearchOptionIds.Files}'`);
this.queryBuilder.removeFilterQuery(`+TYPE:'cm:${SearchOptionIds.Folders}'`);
}
syncInputValues() {
if (this.searchInputControl.searchTerm !== this.searchedWord) {
if (this.searchInputControl.searchTerm) {
this.searchedWord = this.searchInputControl.searchTerm;
} else {
this.searchInputControl.searchTerm = this.searchedWord;
}
}
}
getUrlSearchTerm(): string {
let searchTerm = '';
if (this.onSearchResults || this.onLibrariesSearchResults) {
const urlTree: UrlTree = this.router.parseUrl(this.router.url);
const urlSegmentGroup: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
if (urlSegmentGroup) {
const urlSegments: UrlSegment[] = urlSegmentGroup.segments;
searchTerm = urlSegments[0].parameters['q'] ? decodeURIComponent(urlSegments[0].parameters['q']) : '';
}
}
return searchTerm;
}
isSameSearchTerm(): boolean {
const urlSearchTerm = this.getUrlSearchTerm();
return this.searchedWord === urlSearchTerm;
}
}

View File

@@ -0,0 +1,116 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { AlfrescoApiService } from '@alfresco/adf-core';
import { SearchLibrariesQueryBuilderService, LibrarySearchQuery } from './search-libraries-query-builder.service';
describe('SearchLibrariesQueryBuilderService', () => {
let apiService: AlfrescoApiService;
let builder: SearchLibrariesQueryBuilderService;
let queriesApi;
const query = {} as LibrarySearchQuery;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule]
});
apiService = TestBed.inject(AlfrescoApiService);
apiService.reset();
builder = new SearchLibrariesQueryBuilderService(apiService);
queriesApi = builder['queriesApi'];
});
it('should have empty user query by default', () => {
expect(builder.userQuery).toBe('');
});
it('should trim user query value', () => {
builder.userQuery = ' something ';
expect(builder.userQuery).toEqual('something');
});
it('should build query and raise an event on update', async () => {
spyOn(builder, 'buildQuery').and.returnValue(query);
let eventArgs = null;
builder.updated.subscribe((args) => (eventArgs = args));
await builder.update();
expect(eventArgs).toBe(query);
});
it('should build query and raise an event on execute', async () => {
const data = {};
spyOn(queriesApi, 'findSites').and.returnValue(Promise.resolve(data));
spyOn(builder, 'buildQuery').and.returnValue(query);
let eventArgs = null;
builder.executed.subscribe((args) => (eventArgs = args));
await builder.execute();
expect(eventArgs).toBe(data);
});
it('should require a query fragment to build query', () => {
const compiled = builder.buildQuery();
expect(compiled).toBeNull();
});
it('should build query when there is a useQuery value', () => {
const searchedTerm = 'test';
builder.userQuery = searchedTerm;
const compiled = builder.buildQuery();
expect(compiled.term).toBe(searchedTerm);
});
it('should use pagination settings', () => {
const searchedTerm = 'test';
builder.paging = { maxItems: 5, skipCount: 5 };
builder.userQuery = searchedTerm;
const compiled = builder.buildQuery();
expect(compiled.opts).toEqual({ maxItems: 5, skipCount: 5 });
});
it('should raise an event on error', async () => {
const err = '{"error": {"statusCode": 400}}';
spyOn(queriesApi, 'findSites').and.returnValue(Promise.reject(err));
spyOn(builder, 'buildQuery').and.returnValue(query);
let eventArgs = null;
builder.hadError.subscribe((args) => (eventArgs = args));
await builder.execute();
expect(eventArgs).toBe(err);
});
});

View File

@@ -0,0 +1,103 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { AlfrescoApiService } from '@alfresco/adf-core';
import { Injectable } from '@angular/core';
import { QueriesApi, SitePaging } from '@alfresco/js-api';
import { Subject } from 'rxjs';
export interface LibrarySearchQuery {
term: string;
opts: {
skipCount: number;
maxItems: number;
};
}
@Injectable({
providedIn: 'root'
})
export class SearchLibrariesQueryBuilderService {
_queriesApi: QueriesApi;
get queriesApi(): QueriesApi {
this._queriesApi = this._queriesApi ?? new QueriesApi(this.alfrescoApiService.getInstance());
return this._queriesApi;
}
private _userQuery = '';
updated: Subject<any> = new Subject();
executed: Subject<any> = new Subject();
hadError: Subject<any> = new Subject();
paging: { maxItems?: number; skipCount?: number } = null;
get userQuery(): string {
return this._userQuery;
}
set userQuery(value: string) {
this._userQuery = value ? value.trim() : '';
}
constructor(private alfrescoApiService: AlfrescoApiService) {}
update(): void {
const query = this.buildQuery();
if (query) {
this.updated.next(query);
}
}
async execute() {
const query = this.buildQuery();
if (query) {
const data = await this.findLibraries(query);
this.executed.next(data);
}
}
buildQuery(): LibrarySearchQuery {
const query = this.userQuery;
if (query && query.length > 1) {
const resultQuery = {
term: query,
opts: {
skipCount: this.paging && this.paging.skipCount,
maxItems: this.paging && this.paging.maxItems
}
};
return resultQuery;
}
return null;
}
private findLibraries(libraryQuery: LibrarySearchQuery): Promise<SitePaging> {
return this.queriesApi.findSites(libraryQuery.term, libraryQuery.opts).catch((err) => {
this.hadError.next(err);
return { list: { pagination: { totalItems: 0 }, entries: [] } };
});
}
}

View File

@@ -0,0 +1,90 @@
<aca-page-layout>
<aca-page-layout-header>
<adf-breadcrumb root="APP.BROWSE.SEARCH_LIBRARIES.TITLE"> </adf-breadcrumb>
<adf-toolbar class="adf-toolbar--inline">
<ng-container *ngFor="let entry of actions; trackBy: trackByActionId">
<aca-toolbar-action [actionRef]="entry"></aca-toolbar-action>
</ng-container>
</adf-toolbar>
</aca-page-layout-header>
<aca-page-layout-content>
<div class="main-content">
<div class="adf-search-results">
<div class="adf-search-results__content">
<mat-progress-bar *ngIf="isLoading" color="primary" mode="indeterminate"> </mat-progress-bar>
<div class="adf-search-results__content-header content" *ngIf="data?.list.entries.length">
<div class="content__side--left">
<div class="adf-search-results--info-text" *ngIf="totalResults !== 1">
{{ 'APP.BROWSE.SEARCH_LIBRARIES.FOUND_RESULTS' | translate: { number: totalResults } }}
</div>
<div class="adf-search-results--info-text" *ngIf="totalResults === 1">
{{ 'APP.BROWSE.SEARCH_LIBRARIES.FOUND_ONE_RESULT' | translate: { number: totalResults } }}
</div>
</div>
</div>
<adf-document-list
#documentList
acaContextActions
acaDocumentList
[showHeader]="showHeader"
[selectionMode]="'single'"
[sorting]="['name', 'asc']"
[node]="data"
[imageResolver]="imageResolver"
(node-dblclick)="handleNodeClick($event)"
(name-click)="handleNodeClick($event)"
>
<data-columns>
<ng-container *ngFor="let column of columns; trackBy: trackByColumnId">
<ng-container *ngIf="column.template && !(column.desktopOnly && isSmallScreen)">
<data-column
[key]="column.key"
[title]="column.title"
[type]="column.type"
[format]="column.format"
[class]="column.class"
[sortable]="column.sortable"
>
<ng-template let-context>
<adf-dynamic-column [id]="column.template" [context]="context"> </adf-dynamic-column>
</ng-template>
</data-column>
</ng-container>
<ng-container *ngIf="!column.template && !(column.desktopOnly && isSmallScreen)">
<data-column
[key]="column.key"
[title]="column.title"
[type]="column.type"
[format]="column.format"
[class]="column.class"
[sortable]="column.sortable"
>
</data-column>
</ng-container>
</ng-container>
</data-columns>
<adf-custom-empty-content-template>
<ng-container *ngIf="data">
<div class="empty-search__block" aria-live="polite">
<p class="empty-search__text">
{{ 'APP.BROWSE.SEARCH.NO_RESULTS' | translate }}
</p>
</div>
</ng-container>
</adf-custom-empty-content-template>
</adf-document-list>
<adf-pagination *ngIf="!documentList.isEmpty()" acaPagination [target]="documentList" (change)="onPaginationChanged($event)">
</adf-pagination>
</div>
</div>
</div>
<div class="sidebar" *ngIf="infoDrawerOpened$ | async">
<aca-info-drawer [node]="selection.last"></aca-info-drawer>
</div>
</aca-page-layout-content>
</aca-page-layout>

View File

@@ -0,0 +1,42 @@
@import '../../../ui/mixins';
.adf-search-results {
@include flex-row;
&__content {
@include flex-column;
border-left: 1px solid #eee;
}
&__content-header {
display: flex;
padding: 0 25px 0 25px;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #eee;
}
&--info-text {
flex: 1;
font-size: 16px;
color: rgba(0, 0, 0, 0.54);
}
.text--bold {
font-weight: 600;
}
.content {
@include flex-row;
flex: unset;
height: unset;
padding-top: 8px;
padding-bottom: 8px;
flex-wrap: wrap;
&__side--left {
@include flex-column;
height: unset;
}
}
}

View File

@@ -0,0 +1,58 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppConfigModule, DataTableComponent } from '@alfresco/adf-core';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SearchLibrariesResultsComponent } from './search-libraries-results.component';
import { SearchLibrariesQueryBuilderService } from './search-libraries-query-builder.service';
import { DocumentListComponent } from '@alfresco/adf-content-services';
describe('SearchLibrariesResultsComponent', () => {
let component: SearchLibrariesResultsComponent;
let fixture: ComponentFixture<SearchLibrariesResultsComponent>;
const emptyPage = { list: { pagination: { totalItems: 0 }, entries: [] } };
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, AppConfigModule],
declarations: [DataTableComponent, DocumentListComponent, SearchLibrariesResultsComponent],
schemas: [NO_ERRORS_SCHEMA],
providers: [SearchLibrariesQueryBuilderService]
});
fixture = TestBed.createComponent(SearchLibrariesResultsComponent);
component = fixture.componentInstance;
});
it('should show empty page by default', async () => {
spyOn(component, 'onSearchResultLoaded').and.callThrough();
fixture.detectChanges();
expect(component.onSearchResultLoaded).toHaveBeenCalledWith(emptyPage);
});
});

View File

@@ -0,0 +1,161 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { NavigateLibraryAction, AppStore } from '@alfresco/aca-shared/store';
import { NodePaging, Pagination, SiteEntry } from '@alfresco/js-api';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { Store } from '@ngrx/store';
import { ContentManagementService } from '../../../services/content-management.service';
import { PageComponent } from '../../page.component';
import { SearchLibrariesQueryBuilderService } from './search-libraries-query-builder.service';
import { AppExtensionService, AppHookService } from '@alfresco/aca-shared';
import { DocumentListPresetRef } from '@alfresco/adf-extensions';
@Component({
selector: 'aca-search-results',
templateUrl: './search-libraries-results.component.html',
styleUrls: ['./search-libraries-results.component.scss']
})
export class SearchLibrariesResultsComponent extends PageComponent implements OnInit {
isSmallScreen = false;
searchedWord: string;
queryParamName = 'q';
data: NodePaging;
totalResults = 0;
isLoading = false;
columns: DocumentListPresetRef[] = [];
constructor(
private breakpointObserver: BreakpointObserver,
private librariesQueryBuilder: SearchLibrariesQueryBuilderService,
private route: ActivatedRoute,
private appHookService: AppHookService,
store: Store<AppStore>,
extensions: AppExtensionService,
content: ContentManagementService
) {
super(store, extensions, content);
librariesQueryBuilder.paging = {
skipCount: 0,
maxItems: 25
};
}
ngOnInit() {
super.ngOnInit();
this.columns = this.extensions.documentListPresets.searchLibraries || [];
this.subscriptions.push(
this.appHookService.libraryJoined.subscribe(() => this.librariesQueryBuilder.update()),
this.appHookService.libraryDeleted.subscribe(() => this.librariesQueryBuilder.update()),
this.appHookService.libraryLeft.subscribe(() => this.librariesQueryBuilder.update()),
this.librariesQueryBuilder.updated.subscribe(() => {
this.isLoading = true;
this.librariesQueryBuilder.execute();
}),
this.librariesQueryBuilder.executed.subscribe((data) => {
this.onSearchResultLoaded(data);
this.isLoading = false;
}),
this.librariesQueryBuilder.hadError.subscribe((err) => {
try {
const {
error: { statusCode }
} = JSON.parse(err.message);
if (statusCode === 400) {
this.appHookService.library400Error.next();
}
} catch (e) {}
}),
this.breakpointObserver.observe([Breakpoints.HandsetPortrait, Breakpoints.HandsetLandscape]).subscribe((result) => {
this.isSmallScreen = result.matches;
})
);
if (this.route) {
this.route.params.forEach((params: Params) => {
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
const query = this.formatSearchQuery(this.searchedWord);
if (query && query.length > 1) {
this.librariesQueryBuilder.paging.skipCount = 0;
this.librariesQueryBuilder.userQuery = query;
this.librariesQueryBuilder.update();
} else {
this.librariesQueryBuilder.userQuery = null;
this.librariesQueryBuilder.executed.next({
list: { pagination: { totalItems: 0 }, entries: [] }
});
}
});
}
}
private formatSearchQuery(userInput: string) {
if (!userInput) {
return null;
}
return userInput.trim();
}
onSearchResultLoaded(nodePaging: NodePaging) {
this.data = nodePaging;
this.totalResults = this.getNumberOfResults();
}
getNumberOfResults() {
if (this.data && this.data.list && this.data.list.pagination) {
return this.data.list.pagination.totalItems;
}
return 0;
}
onPaginationChanged(pagination: Pagination) {
this.librariesQueryBuilder.paging = {
maxItems: pagination.maxItems,
skipCount: pagination.skipCount
};
this.librariesQueryBuilder.update();
}
navigateTo(node: SiteEntry) {
if (node && node.entry && node.entry.guid) {
this.store.dispatch(new NavigateLibraryAction(node.entry.guid));
}
}
handleNodeClick(event: Event) {
this.navigateTo((event as CustomEvent).detail?.node);
}
}

View File

@@ -0,0 +1,12 @@
<div class="search-file-name">
<span tabindex="0" role="link" *ngIf="isFile" (click)="showPreview($event)" (keyup.enter)="showPreview($event)" class="link">
{{ name$ | async }}
</span>
<span tabindex="0" role="link" *ngIf="!isFile" (click)="navigate($event)" (keyup.enter)="navigate($event)" class="bold link">
{{ name$ | async }}
</span>
<span>{{ title$ | async }}</span>
</div>
<div class="result-location">
<aca-location-link [context]="context" [showLocation]="true"></aca-location-link>
</div>

View File

@@ -0,0 +1,28 @@
.aca-search-results-row {
.result-location {
height: 15px;
padding-top: 3px;
}
.link {
text-decoration: none;
color: var(--theme-text-bold-color);
}
.aca-location-link .adf-datatable-cell-value {
padding: 0;
color: var(--theme-foreground-text-color);
}
.link:hover,
.aca-location-link .adf-datatable-cell-value:hover {
color: var(--theme-primary-color) !important;
text-decoration: underline;
}
.adf-is-selected .link:not(:hover),
.adf-is-selected .adf-datatable-cell-value:not(:hover) {
color: var(--theme-primary-color) !important;
}
}

View File

@@ -0,0 +1,104 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, Input, OnInit, ViewEncapsulation, ChangeDetectionStrategy, OnDestroy } from '@angular/core';
import { MinimalNodeEntity } from '@alfresco/js-api';
import { ViewNodeAction, NavigateToFolder } from '@alfresco/aca-shared/store';
import { Store } from '@ngrx/store';
import { BehaviorSubject, Subject } from 'rxjs';
import { AlfrescoApiService } from '@alfresco/adf-core';
import { takeUntil } from 'rxjs/operators';
import { Router } from '@angular/router';
@Component({
selector: 'aca-search-results-row',
templateUrl: './search-results-row.component.html',
styleUrls: ['./search-results-row.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'aca-search-results-row' }
})
export class SearchResultsRowComponent implements OnInit, OnDestroy {
private node: MinimalNodeEntity;
private onDestroy$ = new Subject<boolean>();
@Input()
context: any;
name$ = new BehaviorSubject<string>('');
title$ = new BehaviorSubject<string>('');
constructor(private store: Store<any>, private alfrescoApiService: AlfrescoApiService, private router: Router) {}
ngOnInit() {
this.updateValues();
this.alfrescoApiService.nodeUpdated.pipe(takeUntil(this.onDestroy$)).subscribe((node) => {
const row = this.context.row;
if (row) {
const { entry } = row.node;
if (entry.id === node.id) {
entry.name = node.name;
entry.properties = Object.assign({}, node.properties);
this.updateValues();
}
}
});
}
private updateValues() {
this.node = this.context.row.node;
const { name, properties } = this.node.entry;
const title = properties ? properties['cm:title'] : '';
this.name$.next(name);
if (title !== name) {
this.title$.next(title ? `( ${title} )` : '');
}
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
get isFile(): boolean {
return this.node.entry.isFile;
}
showPreview(event: Event) {
event.stopPropagation();
this.store.dispatch(new ViewNodeAction(this.node.entry.id, { location: this.router.url }));
}
navigate(event: Event) {
event.stopPropagation();
this.store.dispatch(new NavigateToFolder(this.node));
}
}

View File

@@ -0,0 +1,63 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { NodeEntry } from '@alfresco/js-api';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CoreModule } from '@angular/flex-layout';
import { TranslateModule } from '@ngx-translate/core';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppSearchResultsModule } from '../search-results.module';
import { SearchResultsRowComponent } from './search-results-row.component';
describe('SearchResultsRowComponent', () => {
let component: SearchResultsRowComponent;
let fixture: ComponentFixture<SearchResultsRowComponent>;
const nodeEntry: NodeEntry = {
entry: {
id: 'fake-node-entry',
modifiedByUser: { displayName: 'IChangeThings' },
modifiedAt: new Date(),
isFile: true,
properties: { 'cm:title': 'BananaRama' }
}
} as NodeEntry;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), CoreModule, AppTestingModule, AppSearchResultsModule]
});
fixture = TestBed.createComponent(SearchResultsRowComponent);
component = fixture.componentInstance;
});
it('should show the current node', () => {
component.context = { row: { node: nodeEntry } };
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('div');
expect(element).not.toBeNull();
});
});

View File

@@ -0,0 +1,60 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CoreModule } from '@alfresco/adf-core';
import { ContentModule } from '@alfresco/adf-content-services';
import { LockedByModule } from '@alfresco/aca-shared';
import { SearchResultsComponent } from './search-results/search-results.component';
import { SearchResultsRowComponent } from './search-results-row/search-results-row.component';
import { SearchLibrariesResultsComponent } from './search-libraries-results/search-libraries-results.component';
import { AppInfoDrawerModule } from '../info-drawer/info.drawer.module';
import { AppToolbarModule } from '../toolbar/toolbar.module';
import { AppCommonModule } from '../common/common.module';
import { DirectivesModule } from '../../directives/directives.module';
import { AppLayoutModule } from '../layout/layout.module';
import { ContextMenuModule } from '../context-menu/context-menu.module';
import { SearchActionMenuComponent } from './search-action-menu/search-action-menu.component';
import { DocumentListCustomComponentsModule } from '../dl-custom-components/document-list-custom-components.module';
@NgModule({
imports: [
CommonModule,
CoreModule.forChild(),
ContentModule.forChild(),
AppCommonModule,
AppInfoDrawerModule,
AppToolbarModule,
DirectivesModule,
AppLayoutModule,
ContextMenuModule,
LockedByModule,
DocumentListCustomComponentsModule
],
declarations: [SearchResultsComponent, SearchLibrariesResultsComponent, SearchResultsRowComponent, SearchActionMenuComponent],
exports: [SearchResultsComponent, SearchLibrariesResultsComponent, SearchResultsRowComponent, SearchActionMenuComponent]
})
export class AppSearchResultsModule {}

View File

@@ -0,0 +1,133 @@
<aca-page-layout>
<aca-page-layout-header>
<adf-breadcrumb root="APP.BROWSE.SEARCH.TITLE"> </adf-breadcrumb>
<adf-toolbar class="adf-toolbar--inline">
<ng-container *ngFor="let entry of actions; trackBy: trackByActionId">
<aca-toolbar-action [actionRef]="entry"></aca-toolbar-action>
</ng-container>
</adf-toolbar>
</aca-page-layout-header>
<aca-page-layout-content>
<div class="main-content">
<div class="adf-search-results">
<div class="adf-search-results__content">
<mat-progress-bar *ngIf="isLoading" color="primary" mode="indeterminate" aria-live="polite"> </mat-progress-bar>
<div class="adf-search-results__content-header content">
<adf-search-form class="content__side--left"></adf-search-form>
<mat-divider [vertical]="true" class="content__divider"></mat-divider>
<adf-search-filter-chips class="content__filter"></adf-search-filter-chips>
<button
mat-button
adf-reset-search
class="content__reset-action"
title="{{'APP.BROWSE.SEARCH.RESET_ACTION' | translate }}"
[attr.aria-label]="'APP.BROWSE.SEARCH.RESET_ACTION' | translate ">
<mat-icon> refresh </mat-icon>
</button>
</div>
<adf-document-list
#documentList
acaDocumentList
acaContextActions
[selectionMode]="'multiple'"
[sortingMode]="'server'"
[sorting]="sorting"
[imageResolver]="imageResolver"
[node]="$any(data)"
(node-dblclick)="handleNodeClick($event)"
>
<data-columns>
<data-column key="$thumbnail" type="image" [sr-title]="'ADF-DOCUMENT-LIST.LAYOUT.THUMBNAIL'" [sortable]="false">
<ng-template let-context>
<aca-custom-thumbnail-column [context]="context"></aca-custom-thumbnail-column>
</ng-template>
<adf-data-column-header>
<ng-template>
<aca-search-action-menu (sortingSelected)="onSearchSortingUpdate($event)"></aca-search-action-menu>
</ng-template>
</adf-data-column-header>
</data-column>
<data-column key type="text" class="adf-ellipsis-cell adf-expand-cell-5" title="APP.DOCUMENT_LIST.COLUMNS.NAME" [sortable]="false">
<ng-template let-context>
<aca-search-results-row [context]="context"></aca-search-results-row>
</ng-template>
</data-column>
<data-column key="properties" title="Description" class="adf-expand-cell-3" [sortable]="false" *ngIf="!isSmallScreen">
<ng-template let-context>
<span class="adf-datatable-cell-value adf-ellipsis-cell">
{{context.row?.node?.entry?.properties && context.row?.node?.entry?.properties['cm:description']}}
</span>
</ng-template>
</data-column>
<data-column key="content.sizeInBytes" type="fileSize" title="APP.DOCUMENT_LIST.COLUMNS.SIZE" class="adf-no-grow-cell adf-ellipsis-cell" [sortable]="false" *ngIf="!isSmallScreen"></data-column>
<data-column key="modifiedAt" type="date" title="APP.DOCUMENT_LIST.COLUMNS.MODIFIED_ON" class="adf-no-grow-cell adf-ellipsis-cell" format="timeAgo" [sortable]="false" *ngIf="!isSmallScreen"></data-column>
<data-column key="modifiedByUser.displayName" title="APP.DOCUMENT_LIST.COLUMNS.MODIFIED_BY" class="adf-no-grow-cell adf-ellipsis-cell" [sortable]="false" *ngIf="!isSmallScreen"></data-column>
</data-columns>
<adf-custom-empty-content-template>
<ng-container *ngIf="data">
<div class="empty-search__block" aria-live="polite">
<p class="empty-search__text">
{{ 'APP.BROWSE.SEARCH.NO_RESULTS' | translate }}
</p>
</div>
</ng-container>
</adf-custom-empty-content-template>
</adf-document-list>
<adf-pagination *ngIf="!documentList.isEmpty()" acaPagination [target]="documentList" (change)="onPaginationChanged($event)">
</adf-pagination>
</div>
</div>
</div>
<div
[ngClass]="
(infoDrawerPreview$ | async) === true ? 'adf-search-results--right_panel_section-extended' : 'adf-search-results--right_panel_section'
"
*ngIf="infoDrawerOpened$ | async"
>
<adf-viewer
class="adf-search-results--embedded_viewer"
[nodeId]="selection.last.entry.id"
*ngIf="infoDrawerPreview$ | async; else infoDrawerPanel"
>
<adf-viewer-toolbar>
<div class="adf-search-results--preview-toolbar">
<div>
<button mat-icon-button title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}" (click)="onDrawerClosed()">
<mat-icon>close</mat-icon>
</button>
</div>
<div>
<button
mat-icon-button
title="{{ 'ADF_VIEWER.ACTIONS.PREVIEW' | translate }}"
color="accent"
class="adf-search-results--visibility_button"
>
<mat-icon>visibility</mat-icon>
</button>
<button mat-icon-button title="{{ 'ADF_VIEWER.ACTIONS.CLOSE' | translate }}" (click)="onPreviewClosed()">
<mat-icon>info_outline</mat-icon>
</button>
</div>
</div>
</adf-viewer-toolbar>
</adf-viewer>
<ng-template #infoDrawerPanel>
<div class="sidebar">
<aca-info-drawer [node]="selection.last"></aca-info-drawer>
</div>
</ng-template>
</div>
</aca-page-layout-content>
</aca-page-layout>

View File

@@ -0,0 +1,128 @@
@import '../../../ui/mixins';
$adf-chip-background: #efefef;
$contrast-gray: #646569;
.adf-search-results {
@include flex-row;
&__facets {
display: flex;
flex-direction: row;
margin-top: 5px;
margin-bottom: 15px;
}
&__content {
@include flex-column;
border-left: 1px solid #eee;
}
&__content-header {
display: flex;
padding: 0 25px 0 25px;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #eee;
.adf-search-filter-chip {
&.mat-chip {
background-color: $adf-chip-background;
color: $contrast-gray;
}
.adf-search-filter-placeholder {
color: $contrast-gray;
}
}
}
&--info-text {
flex: 1;
font-size: 16px;
color: rgba(0, 0, 0, 0.54);
}
&--embedded_viewer {
position: unset;
display: flex;
width: 100%;
}
&--right_panel_section {
display: flex;
justify-content: flex-start;
}
&--right_panel_section-extended {
display: flex;
justify-content: flex-start;
flex-basis: 55%;
}
&--preview-toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin: 5px 5px;
padding-right: 24px;
}
&--visibility_button {
margin-right: 8px;
}
.adf-search-filter {
min-width: 260px;
max-width: 320px;
padding: 5px;
height: 100%;
overflow: scroll;
&--hidden {
display: none;
}
}
.content {
box-sizing: border-box;
display: flex;
place-content: flex-start space-between;
align-items: flex-start;
padding: 16px 12px;
&__button {
padding: 0 12px;
}
&__divider {
height: 100%;
}
&__filter {
padding: 0 12px;
flex: 1 1 auto;
}
&__reset-action {
line-height: 33px;
}
&__sort-picker {
min-width: 220px;
}
}
.adf-datatable {
aca-search-action-menu button {
width: 0;
}
.aca-location-link a {
font-size: 12px;
max-width: 350px;
text-align: left;
direction: rtl;
}
}
}

View File

@@ -0,0 +1,283 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { SearchResultsComponent } from './search-results.component';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { AppSearchResultsModule } from '../search-results.module';
import { AppConfigService, CoreModule, TranslationService } from '@alfresco/adf-core';
import { Store } from '@ngrx/store';
import { NavigateToFolder, SnackbarErrorAction } from '@alfresco/aca-shared/store';
import { Pagination, SearchRequest } from '@alfresco/js-api';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
describe('SearchComponent', () => {
let component: SearchResultsComponent;
let fixture: ComponentFixture<SearchResultsComponent>;
let config: AppConfigService;
let store: Store<any>;
let queryBuilder: SearchQueryBuilderService;
let translate: TranslationService;
let router: Router;
const searchRequest = {} as SearchRequest;
let params: BehaviorSubject<any>;
beforeEach(() => {
params = new BehaviorSubject({ q: 'TYPE: "cm:folder" AND %28=cm: name: email OR cm: name: budget%29' });
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), CoreModule.forRoot(), AppTestingModule, AppSearchResultsModule],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {
sortingPreferenceKey: ''
}
},
params: params.asObservable()
}
}
]
});
config = TestBed.inject(AppConfigService);
store = TestBed.inject(Store);
queryBuilder = TestBed.inject(SearchQueryBuilderService);
translate = TestBed.inject(TranslationService);
router = TestBed.inject(Router);
config.config = {
search: {}
};
fixture = TestBed.createComponent(SearchResultsComponent);
component = fixture.componentInstance;
spyOn(queryBuilder, 'update').and.stub();
fixture.detectChanges();
});
afterEach(() => {
params.complete();
});
it('should raise an error if search fails', fakeAsync(() => {
spyOn(queryBuilder['searchApi'], 'search').and.returnValue(
Promise.reject({
message: `{ "error": { "statusCode": 500 } } `
})
);
spyOn(queryBuilder, 'buildQuery').and.returnValue(searchRequest);
spyOn(store, 'dispatch').and.stub();
queryBuilder.execute();
tick();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarErrorAction('APP.BROWSE.SEARCH.ERRORS.GENERIC'));
}));
it('should raise a known error if search fails', fakeAsync(() => {
spyOn(translate, 'instant').and.callFake((key: string) => {
if (key === 'APP.BROWSE.SEARCH.ERRORS.401') {
return 'Known Error';
}
return key;
});
spyOn(queryBuilder['searchApi'], 'search').and.returnValue(
Promise.reject({
message: `{ "error": { "statusCode": 401 } } `
})
);
spyOn(queryBuilder, 'buildQuery').and.returnValue(searchRequest);
spyOn(store, 'dispatch').and.stub();
queryBuilder.execute();
tick();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarErrorAction('Known Error'));
}));
it('should raise a generic error if search fails', fakeAsync(() => {
spyOn(translate, 'instant').and.callFake((key: string) => {
if (key === 'APP.BROWSE.SEARCH.ERRORS.GENERIC') {
return 'Generic Error';
}
return key;
});
spyOn(queryBuilder['searchApi'], 'search').and.returnValue(
Promise.reject({
message: `{ "error": { "statusCode": 401 } } `
})
);
spyOn(queryBuilder, 'buildQuery').and.returnValue(searchRequest);
spyOn(store, 'dispatch').and.stub();
queryBuilder.execute();
tick();
expect(store.dispatch).toHaveBeenCalledWith(new SnackbarErrorAction('Generic Error'));
}));
it('should decode encoded URI', () => {
expect(queryBuilder.userQuery).toEqual('(TYPE: "cm:folder" AND (=cm: name: email OR cm: name: budget))');
});
it('should return null if formatting invalid query', () => {
expect(component.formatSearchQuery(null)).toBeNull();
expect(component.formatSearchQuery('')).toBeNull();
});
it('should use original user input if text contains colons', () => {
const query = 'TEXT:test OR TYPE:folder';
expect(component.formatSearchQuery(query)).toBe(query);
});
it('should be able to search if search input contains https url', () => {
const query = component.formatSearchQuery('https://alfresco.com');
expect(query).toBe(`(cm:name:"https://alfresco.com*")`);
});
it('should be able to search if search input contains http url', () => {
const query = component.formatSearchQuery('http://alfresco.com');
expect(query).toBe(`(cm:name:"http://alfresco.com*")`);
});
it('should use original user input if text contains quotes', () => {
const query = `"Hello World"`;
expect(component.formatSearchQuery(query)).toBe(query);
});
it('should format user input according to the configuration fields', () => {
const query = component.formatSearchQuery('hello', ['cm:name', 'cm:title']);
expect(query).toBe(`(cm:name:"hello*" OR cm:title:"hello*")`);
});
it('should format user input as cm:name if configuration not provided', () => {
const query = component.formatSearchQuery('hello', undefined);
expect(query).toBe(`(cm:name:"hello*")`);
});
it('should use AND operator when conjunction has no operators', () => {
const query = component.formatSearchQuery('big yellow banana', ['cm:name']);
expect(query).toBe(`(cm:name:"big*") AND (cm:name:"yellow*") AND (cm:name:"banana*")`);
});
it('should support conjunctions with AND operator', () => {
const query = component.formatSearchQuery('big AND yellow AND banana', ['cm:name', 'cm:title']);
expect(query).toBe(
`(cm:name:"big*" OR cm:title:"big*") AND (cm:name:"yellow*" OR cm:title:"yellow*") AND (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support conjunctions with OR operator', () => {
const query = component.formatSearchQuery('big OR yellow OR banana', ['cm:name', 'cm:title']);
expect(query).toBe(
`(cm:name:"big*" OR cm:title:"big*") OR (cm:name:"yellow*" OR cm:title:"yellow*") OR (cm:name:"banana*" OR cm:title:"banana*")`
);
});
it('should support exact term matching with default fields', () => {
const query = component.formatSearchQuery('=orange', ['cm:name', 'cm:title']);
expect(query).toBe(`(=cm:name:"orange" OR =cm:title:"orange")`);
});
it('should support exact term matching with operators', () => {
const query = component.formatSearchQuery('=test1.pdf or =test2.pdf', ['cm:name', 'cm:title']);
expect(query).toBe(`(=cm:name:"test1.pdf" OR =cm:title:"test1.pdf") or (=cm:name:"test2.pdf" OR =cm:title:"test2.pdf")`);
});
it('should navigate to folder on double click', () => {
const node: any = {
entry: {
isFolder: true
}
};
spyOn(store, 'dispatch').and.stub();
component.onNodeDoubleClick(node);
expect(store.dispatch).toHaveBeenCalledWith(new NavigateToFolder(node));
});
it('should preview file node on double click', () => {
const node: any = {
entry: {
isFolder: false
}
};
spyOn(component, 'showPreview').and.stub();
component.onNodeDoubleClick(node);
expect(component.showPreview).toHaveBeenCalledWith(node, {
location: router.url
});
});
it('should re-run search on pagination change', () => {
const page = new Pagination({
maxItems: 10,
skipCount: 0
});
component.onPaginationChanged(page);
expect(queryBuilder.paging).toEqual({
maxItems: 10,
skipCount: 0
});
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update the user query whenever param changed', () => {
params.next({ q: '=orange' });
expect(queryBuilder.userQuery).toBe(`((=cm:name:"orange"))`);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update the user query whenever configuration changed', () => {
params.next({ q: '=orange' });
queryBuilder.configUpdated.next({ 'aca:fields': ['cm:tag'] } as any);
expect(queryBuilder.userQuery).toBe(`((=cm:tag:"orange"))`);
expect(queryBuilder.update).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,265 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, OnInit, ViewEncapsulation } from '@angular/core';
import { MinimalNodeEntity, Pagination, ResultSetPaging } from '@alfresco/js-api';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { PageComponent } from '../../page.component';
import { Store } from '@ngrx/store';
import {
AppStore,
infoDrawerPreview,
NavigateToFolder,
SetInfoDrawerPreviewStateAction,
SetInfoDrawerStateAction,
showFacetFilter,
ShowInfoDrawerPreviewAction,
SnackbarErrorAction
} from '@alfresco/aca-shared/store';
import { ContentManagementService } from '../../../services/content-management.service';
import { TranslationService } from '@alfresco/adf-core';
import { combineLatest, Observable } from 'rxjs';
import { AppExtensionService } from '@alfresco/aca-shared';
import { SearchSortingDefinition } from '@alfresco/adf-content-services/lib/search/models/search-sorting-definition.interface';
import { takeUntil } from 'rxjs/operators';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
selector: 'aca-search-results',
templateUrl: './search-results.component.html',
encapsulation: ViewEncapsulation.None,
styleUrls: ['./search-results.component.scss']
})
export class SearchResultsComponent extends PageComponent implements OnInit {
showFacetFilter$: Observable<boolean>;
infoDrawerPreview$: Observable<boolean>;
searchedWord: string;
queryParamName = 'q';
data: ResultSetPaging;
sorting = ['name', 'asc'];
isLoading = false;
isSmallScreen = false;
constructor(
private queryBuilder: SearchQueryBuilderService,
private route: ActivatedRoute,
store: Store<AppStore>,
extensions: AppExtensionService,
content: ContentManagementService,
private translationService: TranslationService,
private router: Router,
private breakpointObserver: BreakpointObserver
) {
super(store, extensions, content);
queryBuilder.paging = {
skipCount: 0,
maxItems: 25
};
this.showFacetFilter$ = store.select(showFacetFilter);
this.infoDrawerPreview$ = store.select(infoDrawerPreview);
combineLatest([this.route.params, this.queryBuilder.configUpdated])
.pipe(takeUntil(this.onDestroy$))
.subscribe(([params, searchConfig]) => {
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
const query = this.formatSearchQuery(this.searchedWord, searchConfig['aca:fields']);
if (query) {
this.queryBuilder.userQuery = decodeURIComponent(query);
}
});
this.breakpointObserver
.observe([Breakpoints.HandsetPortrait, Breakpoints.HandsetLandscape])
.pipe(takeUntil(this.onDestroy$))
.subscribe((result) => {
this.isSmallScreen = result.matches;
});
}
ngOnInit() {
super.ngOnInit();
this.queryBuilder.resetToDefaults();
this.sorting = this.getSorting();
this.subscriptions.push(
this.queryBuilder.updated.subscribe((query) => {
if (query) {
this.sorting = this.getSorting();
this.isLoading = true;
}
}),
this.queryBuilder.executed.subscribe((data) => {
this.queryBuilder.paging.skipCount = 0;
this.onSearchResultLoaded(data);
this.isLoading = false;
}),
this.queryBuilder.error.subscribe((err: any) => {
this.onSearchError(err);
})
);
if (this.route) {
this.route.params.forEach((params: Params) => {
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
if (this.searchedWord) {
this.queryBuilder.update();
} else {
this.queryBuilder.userQuery = null;
this.queryBuilder.executed.next({
list: { pagination: { totalItems: 0 }, entries: [] }
});
}
});
}
}
onSearchError(error: { message: any }) {
const { statusCode } = JSON.parse(error.message).error;
const messageKey = `APP.BROWSE.SEARCH.ERRORS.${statusCode}`;
let translated = this.translationService.instant(messageKey);
if (translated === messageKey) {
translated = this.translationService.instant(`APP.BROWSE.SEARCH.ERRORS.GENERIC`);
}
this.store.dispatch(new SnackbarErrorAction(translated));
}
private isOperator(input: string): boolean {
if (input) {
input = input.trim().toUpperCase();
const operators = ['AND', 'OR'];
return operators.includes(input);
}
return false;
}
private formatFields(fields: string[], term: string): string {
let prefix = '';
let suffix = '*';
if (term.startsWith('=')) {
prefix = '=';
suffix = '';
term = term.substring(1);
}
return '(' + fields.map((field) => `${prefix}${field}:"${term}${suffix}"`).join(' OR ') + ')';
}
formatSearchQuery(userInput: string, fields = ['cm:name']) {
if (!userInput) {
return null;
}
if (/^http[s]?:\/\//.test(userInput)) {
return this.formatFields(fields, userInput);
}
userInput = userInput.trim();
if (userInput.includes(':') || userInput.includes('"')) {
return userInput;
}
const words = userInput.split(' ');
if (words.length > 1) {
const separator = words.some(this.isOperator) ? ' ' : ' AND ';
return words
.map((term) => {
if (this.isOperator(term)) {
return term;
}
return this.formatFields(fields, term);
})
.join(separator);
}
return this.formatFields(fields, userInput);
}
onSearchResultLoaded(nodePaging: ResultSetPaging) {
this.data = nodePaging;
}
onPaginationChanged(pagination: Pagination) {
this.queryBuilder.paging = {
maxItems: pagination.maxItems,
skipCount: pagination.skipCount
};
this.queryBuilder.update();
}
private getSorting(): string[] {
const primary = this.queryBuilder.getPrimarySorting();
if (primary) {
return [primary.key, primary.ascending ? 'asc' : 'desc'];
}
return ['name', 'asc'];
}
onNodeDoubleClick(node: MinimalNodeEntity) {
if (node && node.entry) {
if (node.entry.isFolder) {
this.store.dispatch(new NavigateToFolder(node));
return;
}
this.showPreview(node, { location: this.router.url });
}
}
handleNodeClick(event: Event) {
this.onNodeDoubleClick((event as CustomEvent).detail?.node);
}
onPreviewClosed() {
this.store.dispatch(new ShowInfoDrawerPreviewAction());
}
onDrawerClosed() {
this.store.dispatch(new SetInfoDrawerPreviewStateAction(false));
this.store.dispatch(new SetInfoDrawerStateAction(false));
}
onSearchSortingUpdate(option: SearchSortingDefinition) {
this.queryBuilder.sorting = [{ ...option, ascending: option.ascending }];
this.queryBuilder.update();
}
}