diff --git a/docs/extending/application-features.md b/docs/extending/application-features.md index 40a0324b3..cecaac05f 100644 --- a/docs/extending/application-features.md +++ b/docs/extending/application-features.md @@ -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 diff --git a/docs/extending/components.md b/docs/extending/components.md index e94fa0011..e978a69d9 100644 --- a/docs/extending/components.md +++ b/docs/extending/components.md @@ -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. diff --git a/docs/extending/rules.md b/docs/extending/rules.md index 4fd4f27e6..0a23b56fc 100644 --- a/docs/extending/rules.md +++ b/docs/extending/rules.md @@ -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**. |

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 { diff --git a/src/app/components/search/search-input-control/search-input-control.component.spec.ts b/src/app/components/search/search-input-control/search-input-control.component.spec.ts index 3d6085803..fa24e52f1 100644 --- a/src/app/components/search/search-input-control/search-input-control.component.spec.ts +++ b/src/app/components/search/search-input-control/search-input-control.component.spec.ts @@ -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; + 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); }); }); diff --git a/src/app/components/search/search-input-control/search-input-control.component.ts b/src/app/components/search/search-input-control/search-input-control.component.ts index 68490b28b..66a2093cd 100644 --- a/src/app/components/search/search-input-control/search-input-control.component.ts +++ b/src/app/components/search/search-input-control/search-input-control.component.ts @@ -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); } } diff --git a/src/app/components/search/search-input/search-input.component.spec.ts b/src/app/components/search/search-input/search-input.component.spec.ts index 6fad90428..b0d4e290e 100644 --- a/src/app/components/search/search-input/search-input.component.spec.ts +++ b/src/app/components/search/search-input/search-input.component.spec.ts @@ -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; 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'; diff --git a/src/app/components/search/search-input/search-input.component.ts b/src/app/components/search/search-input/search-input.component.ts index a767c0e1f..deae299e6 100644 --- a/src/app/components/search/search-input/search-input.component.ts +++ b/src/app/components/search/search-input/search-input.component.ts @@ -23,7 +23,13 @@ * along with Alfresco. If not, see . */ -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 = new Subject(); hasOneChange = false; hasNewChange = false; navigationTimer: any; + has400LibraryError = false; searchedWord = null; searchOptions: Array = [ @@ -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 ) {} @@ -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; } diff --git a/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.spec.ts b/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.spec.ts index 3445b9001..a6ad36dfc 100644 --- a/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.spec.ts +++ b/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.spec.ts @@ -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); + }); }); diff --git a/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.ts b/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.ts index 86008e73d..02b697dc2 100644 --- a/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.ts +++ b/src/app/components/search/search-libraries-results/search-libraries-query-builder.service.ts @@ -36,6 +36,7 @@ export class SearchLibrariesQueryBuilderService { updated: Subject = new Subject(); executed: Subject = new Subject(); + hadError: Subject = new Subject(); paging: { maxItems?: number; skipCount?: number } = null; @@ -77,10 +78,13 @@ export class SearchLibrariesQueryBuilderService { return null; } - private findLibraries(libraryQuery: { term; opts }): Promise { + private findLibraries(libraryQuery): Promise { 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: [] } }; + }); } } diff --git a/src/app/components/search/search-libraries-results/search-libraries-results.component.ts b/src/app/components/search/search-libraries-results/search-libraries-results.component.ts index e2a21ef81..c33c96a24 100644 --- a/src/app/components/search/search-libraries-results/search-libraries-results.component.ts +++ b/src/app/components/search/search-libraries-results/search-libraries-results.component.ts @@ -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 => { diff --git a/src/app/directives/library-membership.directive.spec.ts b/src/app/directives/library-membership.directive.spec.ts new file mode 100644 index 000000000..d6c2abe8f --- /dev/null +++ b/src/app/directives/library-membership.directive.spec.ts @@ -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 . + */ + +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(); + })); + }); +}); diff --git a/src/app/directives/library-membership.directive.ts b/src/app/directives/library-membership.directive.ts index 5ea4e19de..1d2ae0957 100644 --- a/src/app/directives/library-membership.directive.ts +++ b/src/app/directives/library-membership.directive.ts @@ -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); } ); } diff --git a/src/app/services/content-management.service.ts b/src/app/services/content-management.service.ts index f6a19e1c4..4069413b2 100644 --- a/src/app/services/content-management.service.ts +++ b/src/app/services/content-management.service.ts @@ -83,6 +83,7 @@ export class ContentManagementService { libraryCreated = new Subject(); libraryUpdated = new Subject(); libraryJoined = new Subject(); + library400Error = new Subject(); joinLibraryToggle = new Subject(); linksUnshared = new Subject(); favoriteAdded = new Subject>();