[ACA] Context menu - support nested sub menus (#715)

* remove ContextMenuItemDirective

* remove custom menu animation

* remove custom menu styling

* menu item component

* material menu trigger

* clean up menu theme

* update context menu module

* remoe context menu from libraries document list

* clean up

* clean up

* tests
This commit is contained in:
Cilibiu Bogdan
2018-10-14 13:27:47 +03:00
committed by Denys Vuika
parent 9dcdacce40
commit 4802656d79
16 changed files with 390 additions and 267 deletions

View File

@@ -1,68 +0,0 @@
/*!
* @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 {
state,
style,
animate,
transition,
query,
group,
sequence
} from '@angular/animations';
export const contextMenuAnimation = [
state(
'void',
style({
opacity: 0,
transform: 'scale(0.01, 0.01)'
})
),
transition(
'void => *',
sequence([
query('.mat-menu-content', style({ opacity: 0 })),
animate(
'100ms linear',
style({ opacity: 1, transform: 'scale(1, 0.5)' })
),
group([
query(
'.mat-menu-content',
animate(
'400ms cubic-bezier(0.55, 0, 0.55, 0.2)',
style({ opacity: 1 })
)
),
animate(
'300ms cubic-bezier(0.25, 0.8, 0.25, 1)',
style({ transform: 'scale(1, 1)' })
)
])
])
),
transition('* => void', animate('150ms 50ms linear', style({ opacity: 0 })))
];

View File

@@ -0,0 +1,39 @@
<div class="aca-context-menu">
<ng-container [ngSwitch]="actionRef.type">
<ng-container *ngSwitchCase="'menu'">
<button
mat-menu-item
[id]="actionRef.id"
[matMenuTriggerFor]="childMenu">
<mat-icon color="primary">{{ actionRef.icon }}</mat-icon>
<span>{{ actionRef.title | translate }}</span>
</button>
<mat-menu #childMenu="matMenu">
<ng-container *ngFor="let child of actionRef.children; trackBy: trackById">
<app-context-menu-item [actionRef]="child"></app-context-menu-item>
</ng-container>
</mat-menu>
</ng-container>
<ng-container *ngSwitchCase="'separator'">
<mat-divider></mat-divider>
</ng-container>
<ng-container *ngSwitchCase="'custom'">
<adf-dynamic-component [id]="actionRef.component"></adf-dynamic-component>
</ng-container>
<ng-container *ngSwitchDefault>
<button mat-menu-item
color="primary"
[id]="actionRef.id"
(click)="runAction()">
<mat-icon color="primary">{{ actionRef.icon }}</mat-icon>
<span>{{ actionRef.title | translate }}</span>
</button>
</ng-container>
</ng-container>
</div>

View File

@@ -0,0 +1,107 @@
/*!
* @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 { TestBed, ComponentFixture } from '@angular/core/testing';
import { AppTestingModule } from '../../testing/app-testing.module';
import { AppExtensionService } from '../../extensions/extension.service';
import { ContextMenuItemComponent } from './context-menu-item.component';
import { ContextMenuModule } from './context-menu.module';
import {
TranslateModule,
TranslateLoader,
TranslateFakeLoader
} from '@ngx-translate/core';
describe('ContextMenuComponent', () => {
let fixture: ComponentFixture<ContextMenuItemComponent>;
let component: ContextMenuItemComponent;
let extensionsService;
let contextItem;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
AppTestingModule,
ContextMenuModule,
TranslateModule.forRoot({
loader: { provide: TranslateLoader, useClass: TranslateFakeLoader }
})
],
providers: [AppExtensionService]
});
fixture = TestBed.createComponent(ContextMenuItemComponent);
component = fixture.componentInstance;
extensionsService = TestBed.get(AppExtensionService);
contextItem = <any>{
type: 'button',
id: 'action-button',
title: 'Test Button',
actions: {
click: 'TEST_EVENT'
}
};
});
afterEach(() => {
fixture.destroy();
});
it('should render defined menu actions items', () => {
component.actionRef = contextItem;
fixture.detectChanges();
const buttonElement = fixture.nativeElement.querySelector('button');
expect(buttonElement.innerText.trim()).toBe(contextItem.title);
});
it('should not run action when entry has no click attribute defined', () => {
spyOn(extensionsService, 'runActionById');
contextItem.actions = {};
component.actionRef = contextItem;
fixture.detectChanges();
fixture.nativeElement
.querySelector('#action-button')
.dispatchEvent(new MouseEvent('click'));
expect(extensionsService.runActionById).not.toHaveBeenCalled();
});
it('should run action with provided action id', () => {
spyOn(extensionsService, 'runActionById');
component.actionRef = contextItem;
fixture.detectChanges();
fixture.nativeElement
.querySelector('#action-button')
.dispatchEvent(new MouseEvent('click'));
expect(extensionsService.runActionById).toHaveBeenCalledWith(
contextItem.actions.click
);
});
});

View File

@@ -23,29 +23,36 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Directive, ElementRef, OnDestroy } from '@angular/core';
import { FocusableOption, FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
import { Component, Input, ViewEncapsulation } from '@angular/core';
import { ContentActionRef } from '@alfresco/adf-extensions';
import { AppExtensionService } from '../../extensions/extension.service';
@Directive({
selector: '[acaContextMenuItem]'
@Component({
selector: 'app-context-menu-item',
templateUrl: 'context-menu-item.component.html',
encapsulation: ViewEncapsulation.None,
host: { class: 'app-context-menu-item' }
})
export class ContextMenuItemDirective implements OnDestroy, FocusableOption {
constructor(
private elementRef: ElementRef,
private focusMonitor: FocusMonitor
) {
focusMonitor.monitor(this.getHostElement(), false);
export class ContextMenuItemComponent {
@Input()
actionRef: ContentActionRef;
constructor(private extensions: AppExtensionService) {}
runAction() {
if (this.hasClickAction(this.actionRef)) {
this.extensions.runActionById(this.actionRef.actions.click);
}
}
ngOnDestroy() {
this.focusMonitor.stopMonitoring(this.getHostElement());
private hasClickAction(actionRef: ContentActionRef): boolean {
if (actionRef && actionRef.actions && actionRef.actions.click) {
return true;
}
return false;
}
focus(origin: FocusOrigin = 'keyboard'): void {
this.focusMonitor.focusVia(this.getHostElement(), origin);
}
private getHostElement(): HTMLElement {
return this.elementRef.nativeElement;
trackById(index: number, obj: { id: string }) {
return obj.id;
}
}

View File

@@ -1,32 +0,0 @@
/*!
* @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 { ContextMenuItemDirective } from './context-menu-item.directive';
describe('ContextMenuItemDirective', () => {
it('should be defined', () => {
expect(ContextMenuItemDirective).toBeDefined();
});
});

View File

@@ -1,48 +1,41 @@
<div mat-menu class="mat-menu-panel"
@panelAnimation
<button style="visibility: hidden" [matMenuTriggerFor]="rootMenu" #rootTriggerEl></button>
<mat-menu #rootMenu="matMenu"
class="aca-context-menu"
hasBackdrop="false"
acaContextMenuOutsideEvent
(clickOutside)="onClickOutsideEvent()">
<div class="mat-menu-content">
<ng-container *ngFor="let entry of actions" [ngSwitch]="entry.type">
<ng-container *ngSwitchCase="'default'">
<button mat-menu-item
[id]="entry.id"
acaContextMenuItem
(click)="runAction(entry.actions.click)">
<mat-icon color="primary">{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>
</ng-container>
<ng-container *ngSwitchCase="'button'">
<button mat-menu-item
[id]="entry.id"
acaContextMenuItem
(click)="runAction(entry.actions.click)">
<mat-icon color="primary">{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>
</ng-container>
<span *ngSwitchCase="'separator'"
class="aca-context-menu__separator"
[id]="entry.id">
</span>
<ng-container *ngSwitchCase="'menu'">
<button mat-menu-item
acaContextMenuItem
[id]="entry.id"
class="aca-context-menu__more-actions">
<mat-icon color="primary">{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>
</ng-container>
<ng-container *ngSwitchCase="'custom'">
<adf-dynamic-component [id]="entry.component"></adf-dynamic-component>
</ng-container>
<ng-container *ngFor="let entry of actions" [ngSwitch]="entry.type">
<ng-container *ngSwitchDefault>
<button mat-menu-item
[id]="entry.id"
(click)="runAction(entry.actions.click)">
<mat-icon color="primary">{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>
</ng-container>
</div>
</div>
<ng-container *ngSwitchCase="'separator'">
<mat-divider></mat-divider>
</ng-container>
<ng-container *ngSwitchCase="'menu'">
<button mat-menu-item
[id]="entry.id"
[matMenuTriggerFor]="childMenu">
<mat-icon color="primary">{{ entry.icon }}</mat-icon>
<span>{{ entry.title | translate }}</span>
</button>
<mat-menu #childMenu="matMenu">
<ng-container *ngFor="let child of entry.children; trackBy: trackById">
<app-context-menu-item [actionRef]="child"></app-context-menu-item>
</ng-container>
</mat-menu>
</ng-container>
<ng-container *ngSwitchCase="'custom'">
<adf-dynamic-component [id]="entry.component"></adf-dynamic-component>
</ng-container>
</ng-container>
</mat-menu>

View File

@@ -1,19 +0,0 @@
.aca-context-menu {
&__more-actions::after {
margin-left: 34px;
width: 0;
height: 0;
border-style: solid;
border-width: 5px 0 5px 5px;
content: '';
display: inline-block;
}
&__separator {
display: block;
margin: 0;
padding: 0;
border-top-width: 1px;
border-top-style: solid;
}
}

View File

@@ -23,10 +23,104 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import {
TestBed,
ComponentFixture,
fakeAsync,
tick
} from '@angular/core/testing';
import { AppTestingModule } from '../../testing/app-testing.module';
import { AppExtensionService } from '../../extensions/extension.service';
import { ContextMenuComponent } from './context-menu.component';
import { ContextMenuModule } from './context-menu.module';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import {
TranslateModule,
TranslateLoader,
TranslateFakeLoader
} from '@ngx-translate/core';
import { of } from 'rxjs';
import { Store } from '@ngrx/store';
describe('ContextMenuComponent', () => {
it('should be defined', () => {
expect(ContextMenuComponent).toBeDefined();
let fixture: ComponentFixture<ContextMenuComponent>;
let component: ContextMenuComponent;
let contextMenuOverlayRef;
let extensionsService;
const contextItem = {
type: 'button',
id: 'action-button',
title: 'Test Button',
actions: {
click: 'TEST_EVENT'
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
AppTestingModule,
ContextMenuModule,
TranslateModule.forRoot({
loader: { provide: TranslateLoader, useClass: TranslateFakeLoader }
})
],
providers: [
AppExtensionService,
{
provide: ContextMenuOverlayRef,
useValue: {
close: jasmine.createSpy('close')
}
},
{
provide: Store,
useValue: {
dispatch: () => {},
select: () => of({ count: 1 })
}
}
]
});
fixture = TestBed.createComponent(ContextMenuComponent);
component = fixture.componentInstance;
contextMenuOverlayRef = TestBed.get(ContextMenuOverlayRef);
extensionsService = TestBed.get(AppExtensionService);
spyOn(extensionsService, 'getAllowedContextMenuActions').and.returnValue([
contextItem
]);
fixture.detectChanges();
});
it('should close context menu on Escape event', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(contextMenuOverlayRef.close).toHaveBeenCalled();
});
it('should render defined context menu actions items', fakeAsync(() => {
component.ngAfterViewInit();
tick(500);
const contextMenuElements = document.body
.querySelector('.aca-context-menu')
.querySelectorAll('button');
expect(contextMenuElements.length).toBe(1);
expect(contextMenuElements[0].innerText).toBe(contextItem.title);
}));
it('should run action with provided action id', fakeAsync(() => {
spyOn(extensionsService, 'runActionById');
component.runAction(contextItem.actions.click);
expect(extensionsService.runActionById).toHaveBeenCalledWith(
contextItem.actions.click
);
}));
});

View File

@@ -4,14 +4,5 @@
.aca-context-menu {
@include angular-material-theme($theme);
&__separator {
border-top-color: mat-color($foreground, divider);
}
&__more-actions::after {
border-color: transparent;
border-left-color: mat-color($primary);
}
}
}

View File

@@ -29,13 +29,10 @@ import {
OnInit,
OnDestroy,
HostListener,
ViewChildren,
QueryList,
ViewChild,
AfterViewInit
} from '@angular/core';
import { trigger } from '@angular/animations';
import { FocusKeyManager } from '@angular/cdk/a11y';
import { DOWN_ARROW, UP_ARROW } from '@angular/cdk/keycodes';
import { MatMenuTrigger } from '@angular/material';
import { AppExtensionService } from '../../extensions/extension.service';
import { AppStore } from '../../store/states';
@@ -44,42 +41,23 @@ import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ContentActionRef } from '@alfresco/adf-extensions';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { contextMenuAnimation } from './animations';
import { ContextMenuItemDirective } from './context-menu-item.directive';
@Component({
selector: 'aca-context-menu',
templateUrl: './context-menu.component.html',
styleUrls: [
'./context-menu.component.scss',
'./context-menu.component.theme.scss'
],
styleUrls: ['./context-menu.component.theme.scss'],
host: {
role: 'menu',
class: 'aca-context-menu'
class: 'aca-context-menu-holder'
},
encapsulation: ViewEncapsulation.None,
animations: [trigger('panelAnimation', contextMenuAnimation)]
encapsulation: ViewEncapsulation.None
})
export class ContextMenuComponent implements OnInit, OnDestroy, AfterViewInit {
private onDestroy$: Subject<boolean> = new Subject<boolean>();
private _keyManager: FocusKeyManager<ContextMenuItemDirective>;
actions: Array<ContentActionRef> = [];
@ViewChildren(ContextMenuItemDirective)
private contextMenuItems: QueryList<ContextMenuItemDirective>;
@HostListener('contextmenu', ['$event'])
handleContextMenu(event: MouseEvent) {
if (event) {
event.preventDefault();
if (this.contextMenuOverlayRef) {
this.contextMenuOverlayRef.close();
}
}
}
@ViewChild(MatMenuTrigger)
trigger: MatMenuTrigger;
@HostListener('document:keydown.Escape', ['$event'])
handleKeydownEscape(event: KeyboardEvent) {
@@ -90,16 +68,6 @@ export class ContextMenuComponent implements OnInit, OnDestroy, AfterViewInit {
}
}
@HostListener('document:keydown', ['$event'])
handleKeydownEvent(event: KeyboardEvent) {
if (event) {
const keyCode = event.keyCode;
if (keyCode === UP_ARROW || keyCode === DOWN_ARROW) {
this._keyManager.onKeydown(event);
}
}
}
constructor(
private contextMenuOverlayRef: ContextMenuOverlayRef,
private extensions: AppExtensionService,
@@ -114,7 +82,6 @@ export class ContextMenuComponent implements OnInit, OnDestroy, AfterViewInit {
runAction(actionId: string) {
this.extensions.runActionById(actionId);
this.contextMenuOverlayRef.close();
}
ngOnDestroy() {
@@ -134,9 +101,6 @@ export class ContextMenuComponent implements OnInit, OnDestroy, AfterViewInit {
}
ngAfterViewInit() {
this._keyManager = new FocusKeyManager<ContextMenuItemDirective>(
this.contextMenuItems
);
this._keyManager.setFirstItemActive();
setTimeout(() => this.trigger.openMenu(), 0);
}
}

View File

@@ -43,14 +43,6 @@ export class ContextActionsDirective {
@Input('acaContextEnable')
enabled = true;
@HostListener('window:resize', ['$event'])
onResize(event) {
if (event && this.overlayRef) {
this.clearSelection();
this.overlayRef.close();
}
}
@HostListener('contextmenu', ['$event'])
onContextMenuEvent(event: MouseEvent) {
if (event) {
@@ -69,6 +61,7 @@ export class ContextActionsDirective {
) {}
private execute(event: MouseEvent) {
// todo: review this in ADF
const selected = this.getSelectedRow(event);
if (selected) {

View File

@@ -26,7 +26,37 @@
import { ContextActionsDirective } from './context-menu.directive';
describe('ContextActionsDirective', () => {
it('should be defined', () => {
expect(ContextActionsDirective).toBeDefined();
let directive;
const contextMenuServiceMock = <any>{
open: jasmine.createSpy('open')
};
const storeMock = <any>{};
const documentListMock = <any>{};
beforeEach(() => {
directive = new ContextActionsDirective(
documentListMock,
storeMock,
contextMenuServiceMock
);
});
it('should not render context menu when disable property is false', () => {
directive.enabled = false;
spyOn(directive, 'getSelectedRow').and.returnValue({});
directive.onContextMenuEvent(new MouseEvent('contextmenu'));
expect(contextMenuServiceMock.open).not.toHaveBeenCalled();
});
it('should render context menu when disable property is true', () => {
directive.enabled = true;
spyOn(directive, 'getSelectedRow').and.returnValue({});
spyOn(directive, 'isInSelection').and.returnValue(true);
directive.onContextMenuEvent(new MouseEvent('contextmenu'));
expect(contextMenuServiceMock.open).toHaveBeenCalled();
});
});

View File

@@ -35,9 +35,9 @@ import { CoreExtensionsModule } from '../../extensions/core.extensions.module';
import { ContextActionsDirective } from './context-menu.directive';
import { ContextMenuComponent } from './context-menu.component';
import { ContextMenuItemDirective } from './context-menu-item.directive';
import { ExtensionsModule } from '@alfresco/adf-extensions';
import { OutsideEventDirective } from './context-menu-outside-event.directive';
import { ContextMenuItemComponent } from './context-menu-item.component';
@NgModule({
imports: [
@@ -52,13 +52,14 @@ import { OutsideEventDirective } from './context-menu-outside-event.directive';
declarations: [
ContextActionsDirective,
ContextMenuComponent,
ContextMenuItemDirective,
ContextMenuItemComponent,
OutsideEventDirective
],
exports: [
OutsideEventDirective,
ContextActionsDirective,
ContextMenuComponent
ContextMenuComponent,
ContextMenuItemComponent
],
entryComponents: [ContextMenuComponent]
})

View File

@@ -23,10 +23,48 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { TestBed } from '@angular/core/testing';
import { Overlay } from '@angular/cdk/overlay';
import { Injector } from '@angular/core';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { AppConfigService } from '@alfresco/adf-core';
import { ContextMenuService } from './context-menu.service';
import { ContextMenuModule } from './context-menu.module';
describe('ContextMenuService', () => {
it('should be defined', () => {
expect(ContextMenuService).toBeDefined();
let contextMenuService;
const overlayConfig = {
hasBackdrop: false,
backdropClass: '',
panelClass: 'test-panel'
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ContextMenuModule],
providers: [
Overlay,
{ provide: Store, useValue: { select: () => of() } },
{ provide: AppConfigService, useValue: {} }
]
});
const injector = TestBed.get(Injector);
const overlay = TestBed.get(Overlay);
contextMenuService = new ContextMenuService(injector, overlay);
});
it('should create a custom overlay', () => {
contextMenuService.open(overlayConfig);
expect(document.querySelector('.test-panel')).not.toBe(null);
});
it('should render component', () => {
contextMenuService.open(overlayConfig);
expect(document.querySelector('aca-context-menu')).not.toBe(null);
});
});

View File

@@ -18,20 +18,6 @@ export class ContextMenuService {
this.attachDialogContainer(overlay, config, overlayRef);
overlay.backdropClick().subscribe(() => overlayRef.close());
// prevent native contextmenu on overlay element if config.hasBackdrop is true
if (config.hasBackdrop) {
(<any>overlay)._backdropElement.addEventListener(
'contextmenu',
() => {
event.preventDefault();
(<any>overlay)._backdropClick.next(null);
},
true
);
}
return overlayRef;
}

View File

@@ -18,7 +18,6 @@
<div class="inner-layout__panel">
<adf-document-list #documentList
acaDocumentList
acaContextActions
[display]="documentDisplayMode$ | async"
currentFolderId="-mysites-"
selectionMode="single"