[ACA-1544] extensions: toolbar separators and menus (#504)

* toolbar separators

* remove the need for "target" for separators

* simplify code

* menu stub, reducing separators

* toolbar action component

* render menu items

* menu items
This commit is contained in:
Denys Vuika
2018-07-10 20:39:07 +01:00
committed by GitHub
parent 64b0790a0d
commit ad9ce9e88f
14 changed files with 343 additions and 129 deletions

View File

@@ -200,8 +200,13 @@
"content": {
"actions": [
{
"disabled": false,
"id": "aca:toolbar/separator-1",
"order": 5,
"type": "separator"
},
{
"id": "aca:toolbar/create-folder",
"type": "button",
"order": 10,
"title": "APP.NEW_MENU.TOOLTIPS.CREATE_FOLDER",
"icon": "create_new_folder",
@@ -212,8 +217,8 @@
}
},
{
"disabled": false,
"id": "aca:toolbar/preview",
"type": "button",
"order": 15,
"title": "APP.ACTIONS.VIEW",
"icon": "open_in_browser",
@@ -224,8 +229,8 @@
}
},
{
"disabled": false,
"id": "aca:toolbar/download",
"type": "button",
"order": 20,
"title": "APP.ACTIONS.DOWNLOAD",
"icon": "get_app",
@@ -237,8 +242,8 @@
}
},
{
"disabled": false,
"id": "aca:toolbar/edit-folder",
"type": "button",
"order": 30,
"title": "APP.ACTIONS.EDIT",
"icon": "create",
@@ -249,30 +254,44 @@
}
},
{
"disabled": false,
"id": "aca:action3",
"order": 101,
"title": "Settings",
"icon": "settings_applications",
"target": {
"types": [],
"permissions": [],
"action": "aca:actions/settings"
}
"id": "aca:toolbar/separator-2",
"order": 200,
"type": "separator"
},
{
"disabled": false,
"id": "aca:action4",
"order": 101,
"title": "Error",
"icon": "report_problem",
"target": {
"types": ["file"],
"permissions": ["update", "delete"],
"action": "aca:actions/error"
}
"id": "aca:toolbar/menu-1",
"type": "menu",
"icon": "storage",
"order": 300,
"children": [
{
"id": "aca:action3",
"type": "button",
"title": "Settings",
"icon": "settings_applications",
"target": {
"types": [],
"permissions": [],
"action": "aca:actions/settings"
}
},
{
"id": "aca:action4",
"type": "button",
"title": "Error",
"icon": "report_problem",
"target": {
"types": ["file"],
"permissions": ["update", "delete"],
"action": "aca:actions/error"
}
}
]
},
{
"id": "aca:toolbar/separator-3",
"type": "separator"
}
]
}

View File

@@ -13,15 +13,9 @@
</button>
<ng-container *ifExperimental="'extensions'">
<adf-toolbar-divider></adf-toolbar-divider>
<button *ngFor="let entry of actions"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider></adf-toolbar-divider>
<ng-container *ngFor="let entry of actions">
<aca-toolbar-action [entry]="entry"></aca-toolbar-action>
</ng-container>
</ng-container>
<ng-container *ngIf="!selection.isEmpty">

View File

@@ -16,15 +16,9 @@
</button>
<ng-container *ifExperimental="'extensions'">
<adf-toolbar-divider></adf-toolbar-divider>
<button *ngFor="let entry of actions"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider></adf-toolbar-divider>
<ng-container *ngFor="let entry of actions">
<aca-toolbar-action [entry]="entry"></aca-toolbar-action>
</ng-container>
</ng-container>
<ng-container *ngIf="!selection.isEmpty">

View File

@@ -83,7 +83,8 @@ export abstract class PageComponent implements OnInit, OnDestroy {
if (selection.isEmpty) {
this.infoDrawerOpened = false;
}
this.actions = this.extensions.getSelectedContentActions(selection, this.node);
const selectedNodes = selection ? selection.nodes : null;
this.actions = this.extensions.getAllowedContentActions(selectedNodes, this.node);
this.canUpdateFile = this.selection.file && this.content.canUpdateNode(selection.file);
this.canUpdateNode = this.selection.count === 1 && this.content.canUpdateNode(selection.first);
this.canDelete = !this.selection.isEmpty && this.content.canDeleteNodes(selection.nodes);

View File

@@ -13,15 +13,9 @@
</button>
<ng-container *ifExperimental="'extensions'">
<adf-toolbar-divider></adf-toolbar-divider>
<button *ngFor="let entry of actions"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider></adf-toolbar-divider>
<ng-container *ngFor="let entry of actions">
<aca-toolbar-action [entry]="entry"></aca-toolbar-action>
</ng-container>
</ng-container>
<ng-container *ngIf="!selection.isEmpty">

View File

@@ -4,15 +4,9 @@
</adf-breadcrumb>
<adf-toolbar class="inline">
<ng-container *ifExperimental="'extensions'">
<adf-toolbar-divider></adf-toolbar-divider>
<button *ngFor="let entry of actions"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider></adf-toolbar-divider>
<ng-container *ngFor="let entry of actions">
<aca-toolbar-action [entry]="entry"></aca-toolbar-action>
</ng-container>
</ng-container>
<ng-container *ngIf="!selection.isEmpty">

View File

@@ -13,15 +13,9 @@
</button>
<ng-container *ifExperimental="'extensions'">
<adf-toolbar-divider></adf-toolbar-divider>
<button *ngFor="let entry of actions"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider></adf-toolbar-divider>
<ng-container *ngFor="let entry of actions">
<aca-toolbar-action [entry]="entry"></aca-toolbar-action>
</ng-container>
</ng-container>
<ng-container *ngIf="!selection.isEmpty">

View File

@@ -13,15 +13,9 @@
</button>
<ng-container *ifExperimental="'extensions'">
<adf-toolbar-divider></adf-toolbar-divider>
<button *ngFor="let entry of actions"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider></adf-toolbar-divider>
<ng-container *ngFor="let entry of actions">
<aca-toolbar-action [entry]="entry"></aca-toolbar-action>
</ng-container>
</ng-container>
<ng-container *ngIf="!selection.isEmpty">

View File

@@ -0,0 +1,30 @@
<ng-container [ngSwitch]="entry.type">
<button *ngSwitchCase="'button'"
mat-icon-button
color="primary"
title="{{ entry.title | translate }}"
(click)="runAction(entry.target.action)">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<adf-toolbar-divider *ngSwitchCase="'separator'"></adf-toolbar-divider>
<ng-container *ngSwitchCase="'menu'">
<button
color="primary"
mat-icon-button
title="{{ entry.title | translate }}"
[matMenuTriggerFor]="menu">
<mat-icon>{{ entry.icon }}</mat-icon>
</button>
<mat-menu #menu="matMenu"
[overlapTrigger]="false">
<ng-container *ngFor="let child of entry.children">
<button mat-menu-item
(click)="runAction(child.target.action)">
<mat-icon>{{ child.icon }}</mat-icon>
<span>{{ child.title | translate }}</span>
</button>
</ng-container>
</mat-menu>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,81 @@
/*!
* @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 {
Component,
ViewEncapsulation,
ChangeDetectionStrategy,
Input,
OnInit,
OnDestroy
} from '@angular/core';
import { ContentActionExtension } from '../../content-action.extension';
import { AppStore, SelectionState } from '../../../store/states';
import { Store } from '@ngrx/store';
import { ExtensionService } from '../../extension.service';
import { appSelection } from '../../../store/selectors/app.selectors';
import { Subject } from 'rxjs/Rx';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'aca-toolbar-action',
templateUrl: './toolbar-action.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'aca-toolbar-action' }
})
export class ToolbarActionComponent implements OnInit, OnDestroy {
@Input() entry: ContentActionExtension;
selection: SelectionState;
onDestroy$: Subject<boolean> = new Subject<boolean>();
constructor(
protected store: Store<AppStore>,
protected extensions: ExtensionService
) {}
ngOnInit() {
this.store
.select(appSelection)
.pipe(takeUntil(this.onDestroy$))
.subscribe(selection => {
this.selection = selection;
});
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
}
runAction(actionId: string) {
const context = {
selection: this.selection
};
this.extensions.runActionById(actionId, context);
}
}

View File

@@ -23,12 +23,21 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
export enum ContentActionType {
default = 'button',
button = 'button',
separator = 'separator',
menu = 'menu'
}
export interface ContentActionExtension {
id: string;
type: ContentActionType;
order?: number;
title: string;
icon?: string;
disabled?: boolean;
children?: Array<ContentActionExtension>;
target: {
types: Array<string>;
permissions: Array<string>,

View File

@@ -24,14 +24,20 @@
*/
import { NgModule } from '@angular/core';
import { AuthGuardEcm } from '@alfresco/adf-core';
import { AuthGuardEcm, CoreModule } from '@alfresco/adf-core';
import { ExtensionService } from './extension.service';
import { AboutComponent } from '../components/about/about.component';
import { LayoutComponent } from '../components/layout/layout.component';
import { ToolbarActionComponent } from './components/toolbar-action/toolbar-action.component';
import { CommonModule } from '@angular/common';
@NgModule({
imports: [],
declarations: [],
imports: [
CommonModule,
CoreModule.forChild()
],
declarations: [ToolbarActionComponent],
exports: [ToolbarActionComponent],
entryComponents: [AboutComponent]
})
export class CoreExtensionsModule {

View File

@@ -27,13 +27,14 @@ import { Injectable, Type } from '@angular/core';
import { RouteExtension } from './route.extension';
import { ActionExtension } from './action.extension';
import { AppConfigService } from '@alfresco/adf-core';
import { ContentActionExtension } from './content-action.extension';
import { ContentActionExtension, ContentActionType } from './content-action.extension';
import { OpenWithExtension } from './open-with.extension';
import { AppStore, SelectionState } from '../store/states';
import { AppStore } from '../store/states';
import { Store } from '@ngrx/store';
import { NavigationExtension } from './navigation.extension';
import { Route } from '@angular/router';
import { Node } from 'alfresco-js-api';
import { Node, MinimalNodeEntity } from 'alfresco-js-api';
import { reduceSeparators, sortByOrder, filterEnabled, copyAction, reduceEmptyMenus } from './utils';
@Injectable()
export class ExtensionService {
@@ -70,7 +71,7 @@ export class ExtensionService {
'extensions.core.features.content.actions',
[]
)
.sort(this.sortByOrder);
.sort(sortByOrder);
this.openWithActions = this.config
.get<Array<OpenWithExtension>>(
@@ -78,14 +79,14 @@ export class ExtensionService {
[]
)
.filter(entry => !entry.disabled)
.sort(this.sortByOrder);
.sort(sortByOrder);
this.createActions = this.config
.get<Array<ContentActionExtension>>(
'extensions.core.features.create',
[]
)
.sort(this.sortByOrder);
.sort(sortByOrder);
}
getRouteById(id: string): RouteExtension {
@@ -178,7 +179,7 @@ export class ExtensionService {
// evaluates create actions for the folder node
getFolderCreateActions(folder: Node): Array<ContentActionExtension> {
return this.createActions.filter(this.filterOutDisabled).map(action => {
return this.createActions.filter(filterEnabled).map(action => {
if (
action.target &&
action.target.permissions &&
@@ -200,64 +201,72 @@ export class ExtensionService {
}
// evaluates content actions for the selection and parent folder node
getSelectedContentActions(
selection: SelectionState,
getAllowedContentActions(
nodes: MinimalNodeEntity[],
parentNode: Node
): Array<ContentActionExtension> {
return this.contentActions
.filter(this.filterOutDisabled)
.filter(action => action.target)
.filter(action => this.filterByTarget(selection, action))
.filter(action =>
this.filterByPermission(selection, action, parentNode)
);
.filter(filterEnabled)
.filter(action => this.filterByTarget(nodes, action))
.filter(action => this.filterByPermission(nodes, action, parentNode))
.reduce(reduceSeparators, [])
.map(action => {
if (action.type === ContentActionType.menu) {
const copy = copyAction(action);
if (copy.children && copy.children.length > 0) {
copy.children = copy.children
.filter(childAction => this.filterByTarget(nodes, childAction))
.filter(childAction => this.filterByPermission(nodes, childAction, parentNode))
.reduce(reduceSeparators, []);
}
return copy;
}
return action;
})
.reduce(reduceEmptyMenus, []);
}
private sortByOrder(
a: { order?: number | undefined },
b: { order?: number | undefined }
) {
const left = a.order === undefined ? Number.MAX_SAFE_INTEGER : a.order;
const right = b.order === undefined ? Number.MAX_SAFE_INTEGER : b.order;
return left - right;
}
private filterOutDisabled(entry: { disabled?: boolean }): boolean {
return !entry.disabled;
}
// todo: support multiple selected nodes
private filterByTarget(
selection: SelectionState,
nodes: MinimalNodeEntity[],
action: ContentActionExtension
): boolean {
if (!action) {
return false;
}
if (!action.target) {
return action.type === ContentActionType.separator
|| action.type === ContentActionType.menu;
}
const types = action.target.types;
if (!types || types.length === 0) {
return true;
}
if (selection && !selection.isEmpty) {
if (nodes && nodes.length > 0) {
if (selection.nodes.length === 1) {
if (selection.folder && types.includes('folder')) {
return true;
if (nodes.length === 1) {
if (types.includes('folder')) {
return nodes.every(node => node.entry.isFolder);
}
if (selection.file && types.includes('file')) {
return true;
if (types.includes('file')) {
return nodes.every(node => node.entry.isFile);
}
return false;
} else {
if (types.length === 1) {
if (types.includes('folder')) {
if (action.target.multiple) {
return selection.nodes.every(node => node.entry.isFolder);
return nodes.every(node => node.entry.isFolder);
}
return false;
}
if (types.includes('file')) {
if (action.target.multiple) {
return selection.nodes.every(node => node.entry.isFile);
return nodes.every(node => node.entry.isFile);
}
return false;
}
@@ -265,13 +274,13 @@ export class ExtensionService {
return types.some(type => {
if (type === 'folder') {
return action.target.multiple
? selection.nodes.some(node => node.entry.isFolder)
: selection.nodes.every(node => node.entry.isFolder);
? nodes.some(node => node.entry.isFolder)
: nodes.every(node => node.entry.isFolder);
}
if (type === 'file') {
return action.target.multiple
? selection.nodes.some(node => node.entry.isFile)
: selection.nodes.every(node => node.entry.isFile);
? nodes.some(node => node.entry.isFile)
: nodes.every(node => node.entry.isFile);
}
return false;
});
@@ -284,10 +293,19 @@ export class ExtensionService {
// todo: support multiple selected nodes
private filterByPermission(
selection: SelectionState,
nodes: MinimalNodeEntity[],
action: ContentActionExtension,
parentNode: Node
): boolean {
if (!action) {
return false;
}
if (!action.target) {
return action.type === ContentActionType.separator
|| action.type === ContentActionType.menu;
}
const permissions = action.target.permissions;
if (!permissions || permissions.length === 0) {
@@ -298,15 +316,14 @@ export class ExtensionService {
if (permission.startsWith('parent.')) {
if (parentNode) {
const parentQuery = permission.split('.')[1];
// console.log(parentNode.allowableOperations, parentQuery);
return this.nodeHasPermissions(parentNode, [parentQuery]);
}
return false;
}
if (selection && selection.first) {
if (nodes && nodes.length > 0) {
return this.nodeHasPermissions(
selection.first.entry,
nodes[0].entry,
permissions
);
}

View File

@@ -0,0 +1,87 @@
/*!
* @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 {
ContentActionExtension,
ContentActionType
} from './content-action.extension';
export function reduceSeparators(
acc: ContentActionExtension[],
el: ContentActionExtension,
i: number,
arr: ContentActionExtension[]
): ContentActionExtension[] {
// remove duplicate separators
if (i > 0) {
const prev = arr[i - 1];
if (
prev.type === ContentActionType.separator &&
el.type === ContentActionType.separator
) {
return acc;
}
}
// remove trailing separator
if (i === arr.length - 1) {
if (el.type === ContentActionType.separator) {
return acc;
}
}
return acc.concat(el);
}
export function reduceEmptyMenus(
acc: ContentActionExtension[],
el: ContentActionExtension
): ContentActionExtension[] {
if (el.type === ContentActionType.menu) {
if ((el.children || []).length === 0) {
return acc;
}
}
return acc.concat(el);
}
export function sortByOrder(
a: { order?: number | undefined },
b: { order?: number | undefined }
) {
const left = a.order === undefined ? Number.MAX_SAFE_INTEGER : a.order;
const right = b.order === undefined ? Number.MAX_SAFE_INTEGER : b.order;
return left - right;
}
export function filterEnabled(entry: { disabled?: boolean }): boolean {
return !entry.disabled;
}
export function copyAction(action: ContentActionExtension): ContentActionExtension {
return {
...action,
children: (action.children || []).map(copyAction)
};
}