[ACA-19] show search libraries hint (#802)

* [ACA-19] show hint on 400 error

* [ACA-19] unit test

* [ACA-19] small change

* [ACA-19] unit test

* [ACA-19] unit tests

* [ACA-19] avoid memory leaks with takeUntil

* [ACA-19] remove comment & formatting

* [ACA-19] update documentation
This commit is contained in:
Suzana Dirla 2018-11-16 14:35:41 +02:00 committed by Denys Vuika
parent ff0891009e
commit dcacbc1210
13 changed files with 343 additions and 24 deletions

View File

@ -307,6 +307,7 @@ The content actions are applied to the toolbars for the following Views:
- Favorites
- Trash
- Search Results
- Libraries Search Results
## Context Menu

View File

@ -17,6 +17,7 @@ The components are used to create custom:
| app.toolbar.toggleInfoDrawer | ToggleInfoDrawerComponent | The toolbar button component that toggles Info Drawer for the selection. |
| app.toolbar.toggleFavorite | ToggleFavoriteComponent | The toolbar button component that toggles Favorite state for the selection. |
| app.toolbar.toggleFavoriteLibrary | ToggleFavoriteLibraryComponent | The toolbar button component that toggles Favorite library state for the selection. |
| app.toolbar.toggleJoinLibrary | ToggleJoinLibraryComponent | The toolbar button component that toggles Join/Cancel Join request for the selected library. |
See [Registration](/extending/registration) section for more details
on how to register your own entries to be re-used at runtime.

View File

@ -143,6 +143,9 @@ The button will be visible only when the linked rule evaluates to `true`.
| app.selection.file | A single File node is selected. |
| app.selection.file.canShare | User is able to share the selected file. |
| app.selection.library | A single Library node is selected. |
| app.selection.isPrivateLibrary | A private Library node is selected. |
| app.selection.hasLibraryRole | The selected Library node has a role property. |
| app.selection.hasNoLibraryRole | The selected Library node has no role property. |
| app.selection.folder | A single Folder node is selected. |
| app.selection.folder.canUpdate | User has permissions to update the selected folder. |
| repository.isQuickShareEnabled | Whether the quick share repository option is enabled or not. |
@ -163,18 +166,18 @@ You can also negate any rule by utilizing a `!` prefix:
| --------------------------------- | ------------------------------------------------------- |
| app.navigation.folder.canCreate | User can create content in the currently opened folder. |
| app.navigation.folder.canUpload | User can upload content to the currently opened folder. |
| app.navigation.isTrashcan | User is using the **Trashcan** page. |
| app.navigation.isTrashcan | User is using the **Trashcan** page. |
| app.navigation.isNotTrashcan | Current page is not a **Trashcan**. |
| app.navigation.isLibraries | User is using the **Libraries** page. |
| app.navigation.isNotLibraries | Current page is not **Libraries**. |
| app.navigation.isSharedFiles | User is using the **Shared Files** page. |
| app.navigation.isLibraries | User is using a **Libraries** page. |
| app.navigation.isNotLibraries | Current page is not a **Libraries** page. |
| app.navigation.isSharedFiles | User is using the **Shared Files** page. |
| app.navigation.isNotSharedFiles | Current page is not **Shared Files**. |
| app.navigation.isFavorites | User is using the **Favorites** page. |
| app.navigation.isFavorites | User is using the **Favorites** page. |
| app.navigation.isNotFavorites | Current page is not **Favorites** |
| app.navigation.isRecentFiles | User is using the **Recent Files** page. |
| app.navigation.isRecentFiles | User is using the **Recent Files** page. |
| app.navigation.isNotRecentFiles | Current page is not **Recent Files**. |
| app.navigation.isSearchResults | User is using the **Search Results** page. |
| app.navigation.isNotSearchResults | Current page is not the **Search Results**. |
| app.navigation.isSearchResults | User is using the **Search Results** page. |
| app.navigation.isNotSearchResults | Current page is not the **Search Results**. |
<p class="tip">
See [Registration](/extending/registration) section for more details
@ -187,7 +190,7 @@ The rule in the example below evaluates to `true` if all the conditions are met:
- user has selected node(s)
- user is not using the **Trashcan** page
- user is not using the **Libraries** page
- user is not using a **Libraries** page (**My Libraries**, **Favorite Libraries** or **Libraries Search Results** pages)
```json
{

View File

@ -24,9 +24,73 @@
*/
import { SearchInputControlComponent } from './search-input-control.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../../testing/app-testing.module';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('SearchInputControlComponent', () => {
it('should be defined', () => {
expect(SearchInputControlComponent).toBeDefined();
let fixture: ComponentFixture<SearchInputControlComponent>;
let component: SearchInputControlComponent;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule],
declarations: [SearchInputControlComponent],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents()
.then(() => {
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

@ -87,8 +87,6 @@ export class SearchInputControlComponent implements OnDestroy {
}
isTermTooShort() {
const alphanumericTerm = this.searchTerm.replace(/[^0-9a-z]/gi, '');
return this.searchTerm.length && alphanumericTerm.length < 2;
return !!(this.searchTerm && this.searchTerm.length < 2);
}
}

View File

@ -39,11 +39,13 @@ import { SEARCH_BY_TERM, SearchByTermAction } from '../../../store/actions';
import { map } from 'rxjs/operators';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { SearchLibrariesQueryBuilderService } from '../search-libraries-results/search-libraries-query-builder.service';
import { ContentManagementService } from '../../../services/content-management.service';
describe('SearchInputComponent', () => {
let fixture: ComponentFixture<SearchInputComponent>;
let component: SearchInputComponent;
let actions$: Actions;
let content: ContentManagementService;
beforeEach(async(() => {
TestBed.configureTestingModule({
@ -56,11 +58,33 @@ describe('SearchInputComponent', () => {
.then(() => {
actions$ = TestBed.get(Actions);
fixture = TestBed.createComponent(SearchInputComponent);
content = TestBed.get(ContentManagementService);
component = fixture.componentInstance;
fixture.detectChanges();
});
}));
it('should change flag on library400Error event', () => {
expect(component.has400LibraryError).toBe(false);
content.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;
content.library400Error.next();
expect(component.hasLibraryConstraint()).toBe(true);
});
describe('onSearchSubmit()', () => {
it('should call search action with correct search options', fakeAsync(done => {
const searchedTerm = 's';

View File

@ -23,7 +23,13 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import {
Component,
OnDestroy,
OnInit,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {
NavigationEnd,
PRIMARY_OUTLET,
@ -37,9 +43,11 @@ import { SearchInputControlComponent } from '../search-input-control/search-inpu
import { Store } from '@ngrx/store';
import { AppStore } from '../../../store/states/app.state';
import { SearchByTermAction } from '../../../store/actions';
import { filter } from 'rxjs/operators';
import { filter, takeUntil } from 'rxjs/operators';
import { SearchLibrariesQueryBuilderService } from '../search-libraries-results/search-libraries-query-builder.service';
import { SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { ContentManagementService } from '../../../services/content-management.service';
import { Subject } from 'rxjs';
export enum SearchOptionIds {
Files = 'files',
@ -53,10 +61,12 @@ export enum SearchOptionIds {
encapsulation: ViewEncapsulation.None,
host: { class: 'aca-search-input' }
})
export class SearchInputComponent implements OnInit {
export class SearchInputComponent implements OnInit, OnDestroy {
onDestroy$: Subject<boolean> = new Subject<boolean>();
hasOneChange = false;
hasNewChange = false;
navigationTimer: any;
has400LibraryError = false;
searchedWord = null;
searchOptions: Array<any> = [
@ -86,6 +96,7 @@ export class SearchInputComponent implements OnInit {
constructor(
private librariesQueryBuilder: SearchLibrariesQueryBuilderService,
private queryBuilder: SearchQueryBuilderService,
private content: ContentManagementService,
private router: Router,
private store: Store<AppStore>
) {}
@ -94,15 +105,23 @@ export class SearchInputComponent implements OnInit {
this.showInputValue();
this.router.events
.pipe(takeUntil(this.onDestroy$))
.pipe(filter(e => e instanceof RouterEvent))
.subscribe(event => {
if (event instanceof NavigationEnd) {
this.showInputValue();
}
});
this.content.library400Error
.pipe(takeUntil(this.onDestroy$))
.subscribe(() => {
this.has400LibraryError = true;
});
}
showInputValue() {
this.has400LibraryError = false;
this.searchedWord = '';
if (this.onSearchResults || this.onLibrariesSearchResults) {
@ -121,12 +140,18 @@ export class SearchInputComponent implements OnInit {
}
}
ngOnDestroy(): void {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
/**
* Called when the user submits the search, e.g. hits enter or clicks submit
*
* @param event Parameters relating to the search
*/
onSearchSubmit(event: KeyboardEvent) {
this.has400LibraryError = false;
const searchTerm = (event.target as HTMLInputElement).value;
if (searchTerm) {
this.store.dispatch(
@ -136,6 +161,7 @@ export class SearchInputComponent implements OnInit {
}
onSearchChange(searchTerm: string) {
this.has400LibraryError = false;
if (this.hasOneChange) {
this.hasNewChange = true;
} else {
@ -158,6 +184,7 @@ export class SearchInputComponent implements OnInit {
}
onOptionChange() {
this.has400LibraryError = false;
if (this.searchedWord) {
if (this.isLibrariesChecked()) {
if (this.onLibrariesSearchResults) {
@ -213,7 +240,9 @@ export class SearchInputComponent implements OnInit {
hasLibraryConstraint(): boolean {
if (this.isLibrariesChecked()) {
return this.searchInputControl.isTermTooShort();
return (
this.has400LibraryError || this.searchInputControl.isTermTooShort()
);
}
return false;
}

View File

@ -93,4 +93,18 @@ describe('SearchLibrariesQueryBuilderService', () => {
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));
const query = {};
spyOn(builder, 'buildQuery').and.returnValue(query);
let eventArgs = null;
builder.hadError.subscribe(args => (eventArgs = args));
await builder.execute();
expect(eventArgs).toBe(err);
});
});

View File

@ -36,6 +36,7 @@ export class SearchLibrariesQueryBuilderService {
updated: Subject<any> = new Subject();
executed: Subject<any> = new Subject();
hadError: Subject<any> = new Subject();
paging: { maxItems?: number; skipCount?: number } = null;
@ -77,10 +78,13 @@ export class SearchLibrariesQueryBuilderService {
return null;
}
private findLibraries(libraryQuery: { term; opts }): Promise<SitePaging> {
private findLibraries(libraryQuery): Promise<SitePaging> {
return this.alfrescoApiService
.getInstance()
.core.queriesApi.findSites(libraryQuery.term, libraryQuery.opts)
.catch(() => ({ list: { pagination: { totalItems: 0 }, entries: [] } }));
.catch(err => {
this.hadError.next(err);
return { list: { pagination: { totalItems: 0 }, entries: [] } };
});
}
}

View File

@ -93,6 +93,17 @@ export class SearchLibrariesResultsComponent extends PageComponent
this.isLoading = false;
}),
this.librariesQueryBuilder.hadError.subscribe(err => {
try {
const {
error: { statusCode }
} = JSON.parse(err.message);
if (statusCode === 400) {
this.content.library400Error.next();
}
} catch (e) {}
}),
this.breakpointObserver
.observe([Breakpoints.HandsetPortrait, Breakpoints.HandsetLandscape])
.subscribe(result => {

View File

@ -0,0 +1,169 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 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 { fakeAsync, TestBed, tick } from '@angular/core/testing';
import {
AlfrescoApiService,
AlfrescoApiServiceMock,
AppConfigService,
CoreModule,
StorageService
} from '@alfresco/adf-core';
import { AppTestingModule } from '../testing/app-testing.module';
import { DirectivesModule } from './directives.module';
import { LibraryMembershipDirective } from './library-membership.directive';
import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core';
describe('LibraryMembershipDirective', () => {
let alfrescoApiService: AlfrescoApiService;
let directive: LibraryMembershipDirective;
let peopleApi;
let addMembershipSpy;
let getMembershipSpy;
let deleteMembershipSpy;
const testSiteEntry = {
id: 'id-1',
guid: 'site-1',
title: 'aa t m',
visibility: 'MODERATED'
};
const requestedMembershipResponse = {
id: testSiteEntry.id,
createdAt: '2018-11-14',
site: testSiteEntry
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, DirectivesModule, CoreModule.forRoot()],
schemas: [NO_ERRORS_SCHEMA]
});
alfrescoApiService = new AlfrescoApiServiceMock(
new AppConfigService(null),
new StorageService()
);
peopleApi = alfrescoApiService.getInstance().core.peopleApi;
directive = new LibraryMembershipDirective(alfrescoApiService);
});
describe('markMembershipRequest', () => {
beforeEach(() => {
getMembershipSpy = spyOn(
peopleApi,
'getSiteMembershipRequest'
).and.returnValue(
Promise.resolve({ entry: requestedMembershipResponse })
);
});
it('should not check membership requests if no entry is selected', fakeAsync(() => {
const selection = {};
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
expect(getMembershipSpy).not.toHaveBeenCalled();
}));
it('should check if a membership request exists for the selected library', fakeAsync(() => {
const selection = { entry: {} };
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
expect(getMembershipSpy.calls.count()).toBe(1);
}));
it('should remember when a membership request exists for selected library', fakeAsync(() => {
const selection = { entry: testSiteEntry };
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
expect(directive.targetSite.joinRequested).toBe(true);
}));
it('should remember when a membership request is not found for selected library', fakeAsync(() => {
getMembershipSpy.and.returnValue(Promise.reject());
const selection = { entry: testSiteEntry };
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
expect(directive.targetSite.joinRequested).toBe(false);
}));
});
describe('toggleMembershipRequest', () => {
beforeEach(() => {
getMembershipSpy = spyOn(
peopleApi,
'getSiteMembershipRequest'
).and.returnValue(
Promise.resolve({ entry: requestedMembershipResponse })
);
addMembershipSpy = spyOn(
peopleApi,
'addSiteMembershipRequest'
).and.returnValue(
Promise.resolve({ entry: requestedMembershipResponse })
);
deleteMembershipSpy = spyOn(
peopleApi,
'removeSiteMembershipRequest'
).and.returnValue(Promise.resolve({}));
});
it('should do nothing if there is no selected library ', fakeAsync(() => {
const selection = {};
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
directive.toggleMembershipRequest();
tick();
expect(addMembershipSpy).not.toHaveBeenCalled();
expect(deleteMembershipSpy).not.toHaveBeenCalled();
}));
it('should delete membership request if there is one', fakeAsync(() => {
const selection = { entry: testSiteEntry };
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
directive.toggleMembershipRequest();
tick();
expect(deleteMembershipSpy).toHaveBeenCalled();
}));
it('should call API to make a membership request if there is none', fakeAsync(() => {
const selection = { entry: { id: 'no-membership-requested' } };
const change = new SimpleChange(null, selection, true);
directive.ngOnChanges({ selection: change });
tick();
directive.toggleMembershipRequest();
tick();
expect(addMembershipSpy).toHaveBeenCalled();
expect(deleteMembershipSpy).not.toHaveBeenCalled();
}));
});
});

View File

@ -90,11 +90,11 @@ export class LibraryMembershipDirective implements OnChanges {
this.toggle.emit(info);
},
error => {
const errWitMessage = {
const errWithMessage = {
error,
i18nKey: 'APP.MESSAGES.ERRORS.JOIN_CANCEL_FAILED'
};
this.error.emit(errWitMessage);
this.error.emit(errWithMessage);
}
);
}
@ -124,11 +124,11 @@ export class LibraryMembershipDirective implements OnChanges {
}
},
error => {
const errWitMessage = {
const errWithMessage = {
error,
i18nKey: 'APP.MESSAGES.ERRORS.JOIN_REQUEST_FAILED'
};
this.error.emit(errWitMessage);
this.error.emit(errWithMessage);
}
);
}

View File

@ -83,6 +83,7 @@ export class ContentManagementService {
libraryCreated = new Subject<SiteEntry>();
libraryUpdated = new Subject<SiteEntry>();
libraryJoined = new Subject<string>();
library400Error = new Subject<any>();
joinLibraryToggle = new Subject<string>();
linksUnshared = new Subject<any>();
favoriteAdded = new Subject<Array<MinimalNodeEntity>>();