[ACA-1379] Search Results Actions and Bulk Actions (#406)

* download actions and effects

* download multiple search results

* remove inline toolbar

* base page and toolbar for the Search results

* toolbar actions

* update search settings

* toggle favorites from search results

* manage versions dialog

* folder navigation

* sidebar integration

* remove obsolete style
This commit is contained in:
Denys Vuika 2018-06-13 14:21:09 +01:00 committed by GitHub
parent 6620600550
commit a849a215bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 365 additions and 77 deletions

View File

@ -85,6 +85,8 @@ import { NodeEffects } from './store/effects/node.effects';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { RouterEffects } from './store/effects/router.effects'; import { RouterEffects } from './store/effects/router.effects';
import { CreateFolderDirective } from './directives/create-folder.directive'; import { CreateFolderDirective } from './directives/create-folder.directive';
import { DownloadEffects } from './store/effects/download.effects';
import { DownloadNodesDirective } from './directives/download-nodes.directive';
@NgModule({ @NgModule({
@ -108,7 +110,7 @@ import { CreateFolderDirective } from './directives/create-folder.directive';
StoreModule.forRoot({ app: appReducer }, { initialState: INITIAL_STATE }), StoreModule.forRoot({ app: appReducer }, { initialState: INITIAL_STATE }),
StoreRouterConnectingModule.forRoot({ stateKey: 'router' }), StoreRouterConnectingModule.forRoot({ stateKey: 'router' }),
EffectsModule.forRoot([SnackbarEffects, NodeEffects, RouterEffects]), EffectsModule.forRoot([SnackbarEffects, NodeEffects, RouterEffects, DownloadEffects]),
!environment.production ? StoreDevtoolsModule.instrument({ maxAge: 25 }) : [] !environment.production ? StoreDevtoolsModule.instrument({ maxAge: 25 }) : []
], ],
declarations: [ declarations: [
@ -143,7 +145,8 @@ import { CreateFolderDirective } from './directives/create-folder.directive';
SortingPreferenceKeyDirective, SortingPreferenceKeyDirective,
InfoDrawerComponent, InfoDrawerComponent,
EditFolderDirective, EditFolderDirective,
CreateFolderDirective CreateFolderDirective,
DownloadNodesDirective
], ],
providers: [ providers: [
{ provide: PageTitleService, useClass: AcaPageTitleService }, { provide: PageTitleService, useClass: AcaPageTitleService },

View File

@ -139,7 +139,7 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
}); });
} }
onNodeDoubleClick(event) { onNodeDoubleClick(event: CustomEvent) {
if (!!event.detail && !!event.detail.node) { if (!!event.detail && !!event.detail.node) {
const node: MinimalNodeEntryEntity = event.detail.node.entry; const node: MinimalNodeEntryEntity = event.detail.node.entry;

View File

@ -2,6 +2,65 @@
<div class="inner-layout__header"> <div class="inner-layout__header">
<adf-breadcrumb root="APP.BROWSE.SEARCH.TITLE"> <adf-breadcrumb root="APP.BROWSE.SEARCH.TITLE">
</adf-breadcrumb> </adf-breadcrumb>
<adf-toolbar class="inline" *ngIf="hasSelection">
<button
color="primary"
mat-icon-button
*ngIf="selectedFile"
title="{{ 'APP.ACTIONS.VIEW' | translate }}"
(click)="showPreview(selectedFile)">
<mat-icon>open_in_browser</mat-icon>
</button>
<button
color="primary"
mat-icon-button
title="{{ 'APP.ACTIONS.DOWNLOAD' | translate }}"
[acaDownloadNodes]="selectedNodes">
<mat-icon>get_app</mat-icon>
</button>
<button mat-icon-button
[color]="infoDrawerOpened ? 'accent' : 'primary'"
title="{{ 'APP.ACTIONS.DETAILS' | translate }}"
(click)="toggleSidebar()">
<mat-icon>info_outline</mat-icon>
</button>
<button
color="primary"
mat-icon-button
title="{{ 'APP.ACTIONS.MORE' | translate }}"
[matMenuTriggerFor]="actionsMenu">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #actionsMenu="matMenu" [overlapTrigger]="false">
<button
mat-menu-item
#selection="adfFavorite"
[adf-node-favorite]="selectedNodes">
<mat-icon color="primary" *ngIf="selection.hasFavorites()">star</mat-icon>
<mat-icon *ngIf="!selection.hasFavorites()">star_border</mat-icon>
<span>{{ 'APP.ACTIONS.FAVORITE' | translate }}</span>
</button>
<button
mat-menu-item
[acaCopyNode]="selectedNodes">
<mat-icon>content_copy</mat-icon>
<span>{{ 'APP.ACTIONS.COPY' | translate }}</span>
</button>
<button
mat-menu-item
*ngIf="selectedFile"
[acaNodeVersions]="selectedFile">
<mat-icon>history</mat-icon>
<span>{{ 'APP.ACTIONS.VERSIONS' | translate }}</span>
</button>
</mat-menu>
</adf-toolbar>
</div> </div>
<div class="adf-search-results__facets"> <div class="adf-search-results__facets">
@ -11,77 +70,87 @@
<div class="inner-layout__content"> <div class="inner-layout__content">
<adf-search <adf-search
#search #search
[searchTerm]="searchedWord"
[maxResults]="maxItems" [maxResults]="maxItems"
[skipResults]="skipCount" [skipResults]="skipCount"
(resultLoaded)="onSearchResultLoaded($event)"> (resultLoaded)="onSearchResultLoaded($event)">
</adf-search> </adf-search>
<div class="adf-search-results"> <div class="inner-layout__panel">
<adf-search-filter #searchFilter></adf-search-filter> <div class="adf-search-results">
<adf-search-filter #searchFilter></adf-search-filter>
<div class="adf-search-results__content"> <div class="adf-search-results__content">
<div class="adf-search-results__content-header" *ngIf="data?.list.entries.length"> <div class="adf-search-results__content-header" *ngIf="data?.list.entries.length">
<div class="adf-search-results--info-text">{{ 'APP.BROWSE.SEARCH.FOUND_RESULTS' | translate: { number: totalResults } }}</div> <div class="adf-search-results--info-text">{{ 'APP.BROWSE.SEARCH.FOUND_RESULTS' | translate: { number: totalResults } }}</div>
<adf-search-sorting-picker></adf-search-sorting-picker> <adf-search-sorting-picker></adf-search-sorting-picker>
</div>
<adf-document-list
#documentList
[showHeader]="false"
[selectionMode]="'multiple'"
[sortingMode]="'server'"
[sorting]="sorting"
[node]="data"
(node-dblclick)="onNodeDoubleClick($event.detail?.node?.entry)"
(ready)="onDocumentListReady($event, documentList)"
(node-select)="onNodeSelect($event, documentList)"
(node-unselect)="onNodeUnselect($event, documentList)">
<data-columns>
<data-column
[key]="'$thumbnail'"
[type]="'image'"
[sr-title]="'ADF-DOCUMENT-LIST.LAYOUT.THUMBNAIL'"
[sortable]="false">
</data-column>
<data-column
[key]="'name'"
[type]="'text'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.NAME'"
[class]="'full-width ellipsis-cell'"
[sortable]="false">
</data-column>
<data-column
[key]="'content.sizeInBytes'"
[type]="'fileSize'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.SIZE'"
[sortable]="false">
</data-column>
<data-column
[key]="'modifiedAt'"
[type]="'date'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.MODIFIED_ON'"
[format]="'timeAgo'"
[sortable]="false">
</data-column>
<data-column
[key]="'modifiedByUser.displayName'"
[type]="'text'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.MODIFIED_BY'"
[sortable]="false">
</data-column>
</data-columns>
<empty-folder-content>
<ng-template>
<div class="empty-search__block">
<p class="empty-search__text">Your search returned 0 results</p>
</div>
</ng-template>
</empty-folder-content>
</adf-document-list>
<adf-pagination *ngIf="!documentList.isEmpty()"
[target]="documentList"
(change)="onPaginationChanged($event)">
</adf-pagination>
</div> </div>
<adf-document-list
#documentList
[showHeader]="false"
[sortingMode]="'server'"
[sorting]="sorting"
[node]="data"
(node-dblclick)="onNodeDoubleClick($event.detail?.node?.entry)">
<data-columns>
<data-column
[key]="'$thumbnail'"
[type]="'image'"
[sr-title]="'ADF-DOCUMENT-LIST.LAYOUT.THUMBNAIL'"
[sortable]="false">
</data-column>
<data-column
[key]="'name'"
[type]="'text'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.NAME'"
[class]="'full-width ellipsis-cell'"
[sortable]="false">
</data-column>
<data-column
[key]="'content.sizeInBytes'"
[type]="'fileSize'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.SIZE'"
[sortable]="false">
</data-column>
<data-column
[key]="'modifiedAt'"
[type]="'date'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.MODIFIED_ON'"
[format]="'timeAgo'"
[sortable]="false">
</data-column>
<data-column
[key]="'modifiedByUser.displayName'"
[type]="'text'"
[title]="'ADF-DOCUMENT-LIST.LAYOUT.MODIFIED_BY'"
[sortable]="false">
</data-column>
</data-columns>
<empty-folder-content>
<ng-template>
<div class="empty-search__block">
<p class="empty-search__text">Your search returned 0 results</p>
</div>
</ng-template>
</empty-folder-content>
</adf-document-list>
<adf-pagination *ngIf="!documentList.isEmpty()"
[target]="documentList"
(change)="onPaginationChanged($event)">
</adf-pagination>
</div> </div>
</div> </div>
<div class="inner-layout__side-panel" *ngIf="infoDrawerOpened">
<aca-info-drawer [node]="lastSelectedNode"></aca-info-drawer>
</div>
</div> </div>
</div> </div>

View File

@ -23,23 +23,28 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>. * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Component, OnInit, Optional, ViewChild } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { MinimalNodeEntryEntity, NodePaging, Pagination } from 'alfresco-js-api'; import { MinimalNodeEntryEntity, NodePaging, Pagination } from 'alfresco-js-api';
import { Router, ActivatedRoute, Params } from '@angular/router'; import { Router, ActivatedRoute, Params } from '@angular/router';
import { SearchQueryBuilderService, SearchComponent as AdfSearchComponent } from '@alfresco/adf-content-services'; import { SearchQueryBuilderService, SearchComponent as AdfSearchComponent, NodePermissionService } from '@alfresco/adf-content-services';
import { SearchConfigurationService } from '@alfresco/adf-core'; import { SearchConfigurationService, UserPreferencesService, SearchService } from '@alfresco/adf-core';
import { PageComponent } from '../page.component'; import { PageComponent } from '../page.component';
import { Store } from '@ngrx/store';
import { AppStore } from '../../store/states/app.state';
import { NavigateToLocationAction } from '../../store/actions';
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
templateUrl: './search.component.html', templateUrl: './search.component.html',
styleUrls: ['./search.component.scss'] styleUrls: ['./search.component.scss'],
providers: [SearchService]
}) })
export class SearchComponent implements OnInit { export class SearchComponent extends PageComponent implements OnInit {
@ViewChild('search') @ViewChild('search')
search: AdfSearchComponent; search: AdfSearchComponent;
searchedWord: string;
queryParamName = 'q'; queryParamName = 'q';
data: NodePaging; data: NodePaging;
totalResults = 0; totalResults = 0;
@ -48,10 +53,15 @@ export class SearchComponent implements OnInit {
sorting = ['name', 'asc']; sorting = ['name', 'asc'];
constructor( constructor(
public router: Router, public permission: NodePermissionService,
private queryBuilder: SearchQueryBuilderService, private queryBuilder: SearchQueryBuilderService,
private searchConfiguration: SearchConfigurationService, private searchConfiguration: SearchConfigurationService,
@Optional() private route: ActivatedRoute) { store: Store<AppStore>,
router: Router,
preferences: UserPreferencesService,
route: ActivatedRoute) {
super(preferences, router, route, store);
queryBuilder.paging = { queryBuilder.paging = {
skipCount: 0, skipCount: 0,
maxItems: 25 maxItems: 25
@ -59,6 +69,8 @@ export class SearchComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
super.ngOnInit();
this.sorting = this.getSorting(); this.sorting = this.getSorting();
this.queryBuilder.updated.subscribe(() => { this.queryBuilder.updated.subscribe(() => {
@ -67,9 +79,9 @@ export class SearchComponent implements OnInit {
if (this.route) { if (this.route) {
this.route.params.forEach((params: Params) => { this.route.params.forEach((params: Params) => {
const searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null; this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
if (searchedWord) { if (this.searchedWord) {
const queryBody = this.searchConfiguration.generateQueryBody(searchedWord, 0, 100); const queryBody = this.searchConfiguration.generateQueryBody(this.searchedWord, 0, 100);
this.queryBuilder.userQuery = queryBody.query.query; this.queryBuilder.userQuery = queryBody.query.query;
this.queryBuilder.update(); this.queryBuilder.update();
@ -112,6 +124,10 @@ export class SearchComponent implements OnInit {
} }
onNodeDoubleClick(node: MinimalNodeEntryEntity) { onNodeDoubleClick(node: MinimalNodeEntryEntity) {
if (node && node.isFolder) {
this.store.dispatch(new NavigateToLocationAction(node));
}
if (node && PageComponent.isLockedNode(node)) { if (node && PageComponent.isLockedNode(node)) {
event.preventDefault(); event.preventDefault();

View File

@ -0,0 +1,58 @@
/*!
* @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 { Directive, HostListener, Input } from '@angular/core';
import { Store } from '@ngrx/store';
import { AppStore } from '../store/states/app.state';
import { MinimalNodeEntity } from '@alfresco/adf-core/node_modules/alfresco-js-api';
import { DownloadNodesAction } from '../store/actions';
@Directive({
selector: '[acaDownloadNodes]'
})
export class DownloadNodesDirective {
// tslint:disable-next-line:no-input-rename
@Input('acaDownloadNodes')
nodes: Array<MinimalNodeEntity> | MinimalNodeEntity;
constructor(private store: Store<AppStore>) {}
@HostListener('click')
onClick() {
const targets = Array.isArray(this.nodes) ? this.nodes : [this.nodes];
const toDownload = targets.map(node => {
const { id, nodeId, name, isFile, isFolder } = node.entry;
return {
id: nodeId || id,
name,
isFile,
isFolder
};
});
this.store.dispatch(new DownloadNodesAction(toDownload));
}
}

View File

@ -5,10 +5,13 @@ export const DELETE_NODES = 'DELETE_NODES';
export const UNDO_DELETE_NODES = 'UNDO_DELETE_NODES'; export const UNDO_DELETE_NODES = 'UNDO_DELETE_NODES';
export const RESTORE_DELETED_NODES = 'RESTORE_DELETED_NODES'; export const RESTORE_DELETED_NODES = 'RESTORE_DELETED_NODES';
export const PURGE_DELETED_NODES = 'PURGE_DELETED_NODES'; export const PURGE_DELETED_NODES = 'PURGE_DELETED_NODES';
export const DOWNLOAD_NODES = 'DOWNLOAD_NODES';
export interface NodeInfo { export interface NodeInfo {
id: string; id: string;
name: string; name: string;
isFile?: boolean;
isFolder?: boolean;
} }
export class SetSelectedNodesAction implements Action { export class SetSelectedNodesAction implements Action {
@ -35,3 +38,8 @@ export class PurgeDeletedNodesAction implements Action {
readonly type = PURGE_DELETED_NODES; readonly type = PURGE_DELETED_NODES;
constructor(public payload: NodeInfo[] = []) {} constructor(public payload: NodeInfo[] = []) {}
} }
export class DownloadNodesAction implements Action {
readonly type = DOWNLOAD_NODES;
constructor(public payload: NodeInfo[] = []) {}
}

View File

@ -1,8 +1,14 @@
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
export const NAVIGATE_ROUTE = 'NAVIGATE_ROUTE'; export const NAVIGATE_ROUTE = 'NAVIGATE_ROUTE';
export const NAVIGATE_LOCATION = 'NAVIGATE_LOCATION';
export class NavigateRouteAction implements Action { export class NavigateRouteAction implements Action {
readonly type = NAVIGATE_ROUTE; readonly type = NAVIGATE_ROUTE;
constructor(public payload: any[]) {} constructor(public payload: any[]) {}
} }
export class NavigateToLocationAction implements Action {
readonly type = NAVIGATE_LOCATION;
constructor(public payload: any) {}
}

View File

@ -0,0 +1,86 @@
import { Effect, Actions, ofType } from '@ngrx/effects';
import { Injectable } from '@angular/core';
import { map } from 'rxjs/operators';
import { DownloadNodesAction, DOWNLOAD_NODES } from '../actions';
import { AlfrescoApiService } from '@alfresco/adf-core';
import { MatDialog } from '@angular/material';
import { NodeInfo } from '../actions/node.action';
import { DownloadZipDialogComponent } from '@alfresco/adf-content-services';
@Injectable()
export class DownloadEffects {
constructor(
private actions$: Actions,
private apiService: AlfrescoApiService,
private dialog: MatDialog
) {}
@Effect({ dispatch: false })
downloadNode$ = this.actions$.pipe(
ofType<DownloadNodesAction>(DOWNLOAD_NODES),
map(action => {
if (action.payload && action.payload.length > 0) {
this.downloadNodes(action.payload);
}
})
);
private downloadNodes(nodes: Array<NodeInfo>) {
if (!nodes || nodes.length === 0) {
return;
}
if (nodes.length === 1) {
this.downloadNode(nodes[0]);
} else {
this.downloadZip(nodes);
}
}
private downloadNode(node: NodeInfo) {
if (node) {
if (node.isFolder) {
this.downloadZip([node]);
} else {
this.downloadFile(node);
}
}
}
private downloadFile(node: NodeInfo) {
if (node) {
this.download(
this.apiService.contentApi.getContentUrl(node.id, true),
node.name
);
}
}
private downloadZip(nodes: Array<NodeInfo>) {
if (nodes && nodes.length > 0) {
const nodeIds = nodes.map(node => node.id);
this.dialog.open(DownloadZipDialogComponent, {
width: '600px',
disableClose: true,
data: {
nodeIds
}
});
}
}
private download(url: string, fileName: string) {
if (url && fileName) {
const link = document.createElement('a');
link.style.display = 'none';
link.download = fileName;
link.href = url;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
}

View File

@ -1,6 +1,9 @@
import { Effect, Actions, ofType } from '@ngrx/effects'; import { Effect, Actions, ofType } from '@ngrx/effects';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { NavigateRouteAction, NAVIGATE_ROUTE } from '../actions/router.action'; import { PathInfoEntity, MinimalNodeEntryEntity } from 'alfresco-js-api';
import {
NavigateRouteAction, NAVIGATE_ROUTE, NavigateToLocationAction, NAVIGATE_LOCATION
} from '../actions/router.action';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -15,4 +18,43 @@ export class RouterEffects {
this.router.navigate(action.payload); this.router.navigate(action.payload);
}) })
); );
@Effect({ dispatch: false })
navigateLocation$ = this.actions$.pipe(
ofType<NavigateToLocationAction>(NAVIGATE_LOCATION),
map(action => {
if (action.payload) {
this.navigateToLocation(action.payload);
}
})
);
private navigateToLocation(node: MinimalNodeEntryEntity) {
let link = null;
const { path } = node;
if (path && path.name && path.elements) {
const isLibraryPath = this.isLibraryContent(<PathInfoEntity>path);
const parent = path.elements[path.elements.length - 1];
const area = isLibraryPath ? '/libraries' : '/personal-files';
if (!isLibraryPath) {
link = [ area, parent.id ];
} else {
// parent.id could be 'Site' folder or child as 'documentLibrary'
link = [ area, (parent.name === 'Sites' ? {} : parent.id) ];
}
}
this.router.navigate(link);
}
private isLibraryContent(path: PathInfoEntity): boolean {
if (path && path.elements.length >= 2 && path.elements[1].name === 'Sites') {
return true;
}
return false;
}
} }