[ACS-8706] split context menu to allow injecting actions (#4203)

* ACS-8706 split context menu to allow injecting actions

* ACS-8706 fix class naming, add context menu components unit tests

* ACS-8706 add context menu service, effects and directive unit tests

* ACS-8706 review remarks - redundant condition, directive unit tests

* ACS-8706 improve unit testing approach, remove unnecessary class attributes

* ACS-8706 documentation

* ACS-8706 fix sonar issues

* ACS-8706 replace takeUntil with takeUntilDestroyed

* ACS-8706 fix sonar lint issue

* ACS-8706 change incorrect import path
This commit is contained in:
Grzegorz Jaśkowski 2024-11-08 08:26:33 +01:00 committed by GitHub
parent 38e667b334
commit 71764b09e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 642 additions and 95 deletions

View File

@ -26,3 +26,4 @@ This application simplifies the complexity of Content Management and provides co
- [Search results](/features/search-results)
- [Search forms](/features/search-forms)
- [Application Hook](/extending/application-hook)
- [Context Menu actions](context-menu-actions)

View File

@ -0,0 +1,69 @@
---
Title: Context Menu Actions
---
# Context Menu Actions
Context Menu Component, appearing on right-clicking a document list item, contains Actions executable on particular file or folder. This entry describes two ways of populating Context Menu.
**Important:** Those two ways are ***mutually exclusive***.
## Default behavior
When using `acaContextActions` directive as shown below, Context Menu actions are loaded from `app.extensions.json` by default.
```html
<adf-document-list
#documentList
acaContextActions>
</adf-document-list>
```
*Note:* To learn more, see [Extensibility features](../extending/extensibility-features.md) and [Extension format](../extending/extension-format.md).
## Injecting Context Menu Actions
In order to inject custom actions into Context Menu, assign an array of rules, formatted as described in [Extension format](../extending/extension-format.md), to an attribute of a Component using [Document List Component](https://github.com/Alfresco/alfresco-ng2-components/blob/develop/docs/content-services/components/document-list.component.md).
```ts
const contextMenuAction = [
{
"id": "custom.action.id",
"title": "CUSTOM_ACTION",
"order": 1,
"icon": "adf:custom-icon",
"actions": {
"click": "CUSTOM_ACTION"
},
"rules": {
"visible": "show.custom.action"
}
},
{
"id": "another.custom.action.id"
...
}
]
...
@Component({...})
export class ComponentWithDocumentList {
customContextMenuActions = contextMenuActions;
...
}
```
Next, pass them to `customActions` input of `acaContextActions` directive inside component's template.
```html
<adf-document-list
#documentList
acaContextActions
customActions="customContextMenuActions">
</adf-document-list>
```
*Note:* Refer to [Application Actions](../extending/application-actions.md) and [Rules](../extending/rules.md) for information on creating custom *"actions"* and *"rules"* for Context Menu actions.

View File

@ -0,0 +1,108 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* 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
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { ContentActionType } from '@alfresco/adf-extensions';
import { AppExtensionService } from '@alfresco/aca-shared';
import { BaseContextMenuDirective } from './base-context-menu.directive';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { AppTestingModule } from '../../testing/app-testing.module';
import { OutsideEventDirective } from './context-menu-outside-event.directive';
import { By } from '@angular/platform-browser';
@Component({
selector: 'app-test-component',
template: '<div acaContextMenuOutsideEvent (clickOutside)="onClickOutsideEvent()"></div>',
standalone: true,
imports: [OutsideEventDirective]
})
class TestComponent extends BaseContextMenuDirective {}
describe('BaseContextMenuComponent', () => {
let contextMenuOverlayRef: ContextMenuOverlayRef;
let extensionsService: AppExtensionService;
let fixture: ComponentFixture<TestComponent>;
let component: TestComponent;
const contextItem = {
type: ContentActionType.button,
id: 'action-button',
title: 'Test Button',
actions: {
click: 'TEST_EVENT'
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, TestComponent],
providers: [
{
provide: ContextMenuOverlayRef,
useValue: {
close: jasmine.createSpy('close')
}
},
BaseContextMenuDirective,
OutsideEventDirective
]
});
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
contextMenuOverlayRef = TestBed.inject(ContextMenuOverlayRef);
extensionsService = TestBed.inject(AppExtensionService);
fixture.detectChanges();
});
it('should close context menu on Escape event', () => {
fixture.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(contextMenuOverlayRef.close).toHaveBeenCalled();
});
it('should close context menu on click outside event', () => {
fixture.debugElement.query(By.directive(OutsideEventDirective)).injector.get(OutsideEventDirective).clickOutside.emit();
fixture.detectChanges();
expect(contextMenuOverlayRef.close).toHaveBeenCalled();
});
it('should run action with provided action id and correct payload', () => {
spyOn(extensionsService, 'runActionById');
component.runAction(contextItem);
expect(extensionsService.runActionById).toHaveBeenCalledWith(contextItem.actions.click, {
focusedElementOnCloseSelector: '.adf-context-menu-source'
});
});
it('should return action id on trackByActionId', () => {
const actionId = component.trackByActionId(0, contextItem);
expect(actionId).toBe(contextItem.id);
});
});

View File

@ -0,0 +1,68 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* 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
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { HostListener, ViewChild, Inject, Directive } from '@angular/core';
import { MatMenuTrigger } from '@angular/material/menu';
import { ContentActionRef } from '@alfresco/adf-extensions';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { CONTEXT_MENU_DIRECTION } from './direction.token';
import { Direction } from '@angular/cdk/bidi';
import { AppExtensionService } from '@alfresco/aca-shared';
@Directive()
export class BaseContextMenuDirective {
actions: Array<ContentActionRef> = [];
@ViewChild(MatMenuTrigger)
trigger: MatMenuTrigger;
@HostListener('keydown.escape', ['$event'])
handleKeydownEscape(event: KeyboardEvent) {
if (event && this.contextMenuOverlayRef) {
this.contextMenuOverlayRef.close();
}
}
constructor(
private readonly contextMenuOverlayRef: ContextMenuOverlayRef,
protected extensions: AppExtensionService,
@Inject(CONTEXT_MENU_DIRECTION) public direction: Direction
) {}
onClickOutsideEvent() {
if (this.contextMenuOverlayRef) {
this.contextMenuOverlayRef.close();
}
}
runAction(contentActionRef: ContentActionRef) {
this.extensions.runActionById(contentActionRef.actions.click, {
focusedElementOnCloseSelector: '.adf-context-menu-source'
});
}
trackByActionId(_: number, obj: ContentActionRef): string {
return obj.id;
}
}

View File

@ -35,7 +35,6 @@ import { AppExtensionService } from '@alfresco/aca-shared';
describe('ContextMenuComponent', () => {
let fixture: ComponentFixture<ContextMenuComponent>;
let component: ContextMenuComponent;
let contextMenuOverlayRef: ContextMenuOverlayRef;
let extensionsService: AppExtensionService;
const contextItem = {
@ -49,7 +48,7 @@ describe('ContextMenuComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ContextMenuComponent, AppTestingModule],
imports: [AppTestingModule],
providers: [
{
provide: ContextMenuOverlayRef,
@ -70,7 +69,6 @@ describe('ContextMenuComponent', () => {
fixture = TestBed.createComponent(ContextMenuComponent);
component = fixture.componentInstance;
contextMenuOverlayRef = TestBed.inject(ContextMenuOverlayRef);
extensionsService = TestBed.inject(AppExtensionService);
spyOn(extensionsService, 'getAllowedContextMenuActions').and.returnValue(of([contextItem]));
@ -78,30 +76,17 @@ describe('ContextMenuComponent', () => {
fixture.detectChanges();
});
it('should close context menu on Escape event', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(contextMenuOverlayRef.close).toHaveBeenCalled();
it('should load context menu actions on init', () => {
expect(component.actions.length).toBe(1);
});
it('should render defined context menu actions items', async () => {
component.ngAfterViewInit();
fixture.detectChanges();
await fixture.whenStable();
const contextMenuElements = document.body.querySelector('.aca-context-menu')?.querySelectorAll('button');
const actionButtonLabel: HTMLElement = contextMenuElements?.[0].querySelector('[data-automation-id="action-button-label"]');
const actionButtonLabel: HTMLElement = contextMenuElements?.[0].querySelector(`[data-automation-id="${contextItem.id}-label"]`);
expect(contextMenuElements?.length).toBe(1);
expect(actionButtonLabel.innerText).toBe(contextItem.title);
});
it('should run action with provided action id and correct payload', () => {
spyOn(extensionsService, 'runActionById');
component.runAction(contextItem);
expect(extensionsService.runActionById).toHaveBeenCalledWith(contextItem.actions.click, {
focusedElementOnCloseSelector: '.adf-context-menu-source'
});
});
});

View File

@ -22,11 +22,9 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { Component, ViewEncapsulation, OnInit, OnDestroy, HostListener, ViewChild, AfterViewInit, Inject } from '@angular/core';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ContentActionRef, DynamicExtensionComponent } from '@alfresco/adf-extensions';
import { Component, ViewEncapsulation, OnInit, AfterViewInit, Inject, inject, DestroyRef } from '@angular/core';
import { MatMenuModule } from '@angular/material/menu';
import { DynamicExtensionComponent } from '@alfresco/adf-extensions';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { CONTEXT_MENU_DIRECTION } from './direction.token';
import { Direction } from '@angular/cdk/bidi';
@ -37,6 +35,8 @@ import { MatDividerModule } from '@angular/material/divider';
import { IconComponent } from '@alfresco/adf-core';
import { ContextMenuItemComponent } from './context-menu-item.component';
import { OutsideEventDirective } from './context-menu-outside-event.directive';
import { BaseContextMenuDirective } from './base-context-menu.directive';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
standalone: true,
@ -58,57 +58,23 @@ import { OutsideEventDirective } from './context-menu-outside-event.directive';
},
encapsulation: ViewEncapsulation.None
})
export class ContextMenuComponent implements OnInit, OnDestroy, AfterViewInit {
private onDestroy$: Subject<boolean> = new Subject<boolean>();
actions: Array<ContentActionRef> = [];
export class ContextMenuComponent extends BaseContextMenuDirective implements OnInit, AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
@ViewChild(MatMenuTrigger)
trigger: MatMenuTrigger;
@HostListener('document:keydown.Escape', ['$event'])
handleKeydownEscape(event: KeyboardEvent) {
if (event) {
if (this.contextMenuOverlayRef) {
this.contextMenuOverlayRef.close();
}
}
}
constructor(
private contextMenuOverlayRef: ContextMenuOverlayRef,
private extensions: AppExtensionService,
@Inject(CONTEXT_MENU_DIRECTION) public direction: Direction
) {}
onClickOutsideEvent() {
if (this.contextMenuOverlayRef) {
this.contextMenuOverlayRef.close();
}
}
runAction(contentActionRef: ContentActionRef) {
this.extensions.runActionById(contentActionRef.actions.click, {
focusedElementOnCloseSelector: '.adf-context-menu-source'
});
}
ngOnDestroy() {
this.onDestroy$.next(true);
this.onDestroy$.complete();
constructor(contextMenuOverlayRef: ContextMenuOverlayRef, extensions: AppExtensionService, @Inject(CONTEXT_MENU_DIRECTION) direction: Direction) {
super(contextMenuOverlayRef, extensions, direction);
}
ngOnInit() {
this.extensions
.getAllowedContextMenuActions()
.pipe(takeUntil(this.onDestroy$))
.subscribe((actions) => (this.actions = actions));
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((actions) => {
this.actions = actions;
});
}
ngAfterViewInit() {
setTimeout(() => this.trigger.openMenu(), 0);
}
trackByActionId(_: number, obj: ContentActionRef): string {
return obj.id;
}
}

View File

@ -32,6 +32,7 @@ import { ContextMenuService } from './context-menu.service';
import { TranslateModule } from '@ngx-translate/core';
import { ContextMenuComponent } from './context-menu.component';
import { ContextmenuOverlayConfig } from './interfaces';
import { ContentActionRef, ContentActionType } from '@alfresco/adf-extensions';
describe('ContextMenuService', () => {
let contextMenuService: ContextMenuService;
@ -39,6 +40,17 @@ describe('ContextMenuService', () => {
let injector: Injector;
let userPreferencesService: UserPreferencesService;
const customActionMock: ContentActionRef[] = [
{
type: ContentActionType.default,
id: 'action',
title: 'action',
actions: {
click: 'event'
}
}
];
const overlayConfig: ContextmenuOverlayConfig = {
hasBackdrop: false,
backdropClass: '',
@ -93,4 +105,20 @@ describe('ContextMenuService', () => {
expect(document.body.querySelector('div[dir="rtl"]')).not.toBe(null);
});
it('should render custom context menu component', () => {
contextMenuService = new ContextMenuService(injector, overlay, userPreferencesService);
contextMenuService.open(overlayConfig, customActionMock);
expect(document.querySelector('aca-custom-context-menu')).not.toBe(null);
});
it('should not render custom context menu when no custom actions are provided', () => {
contextMenuService = new ContextMenuService(injector, overlay, userPreferencesService);
contextMenuService.open(overlayConfig, []);
expect(document.querySelector('aca-custom-context-menu')).toBe(null);
});
});

View File

@ -31,6 +31,9 @@ import { ContextmenuOverlayConfig } from './interfaces';
import { UserPreferencesService } from '@alfresco/adf-core';
import { Directionality } from '@angular/cdk/bidi';
import { CONTEXT_MENU_DIRECTION } from './direction.token';
import { CONTEXT_MENU_CUSTOM_ACTIONS } from './custom-context-menu-actions.token';
import { ContentActionRef } from '@alfresco/adf-extensions';
import { CustomContextMenuComponent } from './custom-context-menu.component';
@Injectable({
providedIn: 'root'
@ -44,11 +47,14 @@ export class ContextMenuService {
});
}
open(config: ContextmenuOverlayConfig): ContextMenuOverlayRef {
open(config: ContextmenuOverlayConfig, customActions?: ContentActionRef[]): ContextMenuOverlayRef {
const overlay = this.createOverlay(config);
const overlayRef = new ContextMenuOverlayRef(overlay);
this.attachDialogContainer(overlay, overlayRef);
if (customActions?.length) {
this.attachCustomDialogContainer(overlay, overlayRef, customActions);
} else {
this.attachDialogContainer(overlay, overlayRef);
}
return overlayRef;
}
@ -60,7 +66,6 @@ export class ContextMenuService {
private attachDialogContainer(overlay: OverlayRef, contextmenuOverlayRef: ContextMenuOverlayRef): ContextMenuComponent {
const injector = this.createInjector(contextmenuOverlayRef);
const containerPortal = new ComponentPortal(ContextMenuComponent, null, injector);
const containerRef: ComponentRef<ContextMenuComponent> = overlay.attach(containerPortal);
@ -77,6 +82,29 @@ export class ContextMenuService {
});
}
private attachCustomDialogContainer(
overlay: OverlayRef,
contextmenuOverlayRef: ContextMenuOverlayRef,
customActions: ContentActionRef[]
): CustomContextMenuComponent {
const injector = this.createCustomInjector(contextmenuOverlayRef, customActions);
const containerPortal = new ComponentPortal(CustomContextMenuComponent, null, injector);
const containerRef: ComponentRef<CustomContextMenuComponent> = overlay.attach(containerPortal);
return containerRef.instance;
}
private createCustomInjector(contextmenuOverlayRef: ContextMenuOverlayRef, customActions: ContentActionRef[]): Injector {
return Injector.create({
parent: this.injector,
providers: [
{ provide: ContextMenuOverlayRef, useValue: contextmenuOverlayRef },
{ provide: CONTEXT_MENU_DIRECTION, useValue: this.direction },
{ provide: CONTEXT_MENU_CUSTOM_ACTIONS, useValue: customActions }
]
});
}
private getOverlayConfig(config: ContextmenuOverlayConfig): OverlayConfig {
const { x, y } = config.source;

View File

@ -0,0 +1,27 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* 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
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { InjectionToken } from '@angular/core';
export const CONTEXT_MENU_CUSTOM_ACTIONS = new InjectionToken('CONTEXT_MENU_CUSTOM_ACTIONS');

View File

@ -0,0 +1,99 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* 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
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../testing/app-testing.module';
import { CustomContextMenuComponent } from './custom-context-menu.component';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { ContentActionType } from '@alfresco/adf-extensions';
import { CONTEXT_MENU_CUSTOM_ACTIONS } from './custom-context-menu-actions.token';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { Store } from '@ngrx/store';
import { initialState } from '@alfresco/aca-shared';
describe('ContextMenuComponent', () => {
let fixture: ComponentFixture<CustomContextMenuComponent>;
let component: CustomContextMenuComponent;
const contextMenuActionsMock = [
{
type: ContentActionType.button,
id: 'action1',
title: 'action1',
actions: {
click: 'event1'
}
},
{
type: ContentActionType.button,
id: 'action2',
title: 'action2',
actions: {
click: 'event2'
}
}
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule],
providers: [
{
provide: ContextMenuOverlayRef,
useValue: {
close: jasmine.createSpy('close')
}
},
{
provide: Store,
useValue: MockStore
},
provideMockStore({ initialState }),
{
provide: CONTEXT_MENU_CUSTOM_ACTIONS,
useValue: contextMenuActionsMock
}
]
});
fixture = TestBed.createComponent(CustomContextMenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should set context menu actions from Injection Token', () => {
expect(component.actions.length).toBe(2);
});
it('should render defined context menu actions items', async () => {
await fixture.whenStable();
const contextMenuElements = document.body.querySelector('.aca-context-menu')?.querySelectorAll('button');
const actionButtonLabel: HTMLElement = contextMenuElements?.[0].querySelector(`[data-automation-id="${contextMenuActionsMock[0].id}-label"]`);
expect(contextMenuElements?.length).toBe(2);
expect(actionButtonLabel.innerText).toBe(contextMenuActionsMock[0].title);
});
});

View File

@ -0,0 +1,75 @@
/*!
* Copyright © 2005-2024 Hyland Software, Inc. and its affiliates. All rights reserved.
*
* Alfresco Example Content Application
*
* 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
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { AfterViewInit, Component, Inject, ViewEncapsulation } from '@angular/core';
import { Direction } from '@angular/cdk/bidi';
import { ContextMenuOverlayRef } from './context-menu-overlay';
import { AppExtensionService } from '@alfresco/aca-shared';
import { CONTEXT_MENU_DIRECTION } from './direction.token';
import { ContentActionRef, DynamicExtensionComponent } from '@alfresco/adf-extensions';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { MatMenuModule } from '@angular/material/menu';
import { ContextMenuItemComponent } from './context-menu-item.component';
import { OutsideEventDirective } from './context-menu-outside-event.directive';
import { MatDividerModule } from '@angular/material/divider';
import { IconComponent } from '@alfresco/adf-core';
import { CONTEXT_MENU_CUSTOM_ACTIONS } from './custom-context-menu-actions.token';
import { BaseContextMenuDirective } from './base-context-menu.directive';
@Component({
selector: 'aca-custom-context-menu',
templateUrl: './context-menu.component.html',
styleUrls: ['./context-menu.component.scss'],
imports: [
CommonModule,
TranslateModule,
MatMenuModule,
MatDividerModule,
ContextMenuItemComponent,
OutsideEventDirective,
IconComponent,
DynamicExtensionComponent
],
host: {
class: 'aca-context-menu-holder'
},
encapsulation: ViewEncapsulation.None,
standalone: true
})
export class CustomContextMenuComponent extends BaseContextMenuDirective implements AfterViewInit {
constructor(
contextMenuOverlayRef: ContextMenuOverlayRef,
extensions: AppExtensionService,
@Inject(CONTEXT_MENU_DIRECTION) direction: Direction,
@Inject(CONTEXT_MENU_CUSTOM_ACTIONS) customActions: ContentActionRef[]
) {
super(contextMenuOverlayRef, extensions, direction);
this.actions = customActions;
}
ngAfterViewInit() {
setTimeout(() => this.trigger.openMenu(), 0);
}
}

View File

@ -71,7 +71,6 @@ import { SearchAiEffects } from './effects/search-ai.effects';
TemplateEffects,
ContextMenuEffects,
SearchAiEffects,
ContextMenuEffects,
SnackbarEffects,
RouterEffects
])

View File

@ -27,10 +27,22 @@ import { AppTestingModule } from '../../testing/app-testing.module';
import { ContextMenuEffects } from './contextmenu.effects';
import { EffectsModule } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { ContextMenu } from '@alfresco/aca-shared/store';
import { ContextMenu, CustomContextMenu } from '@alfresco/aca-shared/store';
import { ContextMenuService } from '../../components/context-menu/context-menu.service';
import { OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ContextMenuOverlayRef } from '../../components/context-menu/context-menu-overlay';
import { ContentActionRef, ContentActionType } from '@alfresco/adf-extensions';
const actionPayloadMock: ContentActionRef[] = [
{
type: ContentActionType.default,
id: 'action',
title: 'action',
actions: {
click: 'event'
}
}
];
describe('ContextMenuEffects', () => {
let store: Store<any>;
@ -62,4 +74,22 @@ describe('ContextMenuEffects', () => {
store.dispatch(new ContextMenu(new MouseEvent('click')));
expect(overlayRefMock.close).toHaveBeenCalled();
});
it('should open custom context menu on customContextMenu$ action', () => {
store.dispatch(new CustomContextMenu(new MouseEvent('click'), actionPayloadMock));
expect(contextMenuService.open).toHaveBeenCalled();
});
it('should not open custom context menu on customContextMenu$ action if no action provided', () => {
store.dispatch(new CustomContextMenu(new MouseEvent('click'), []));
expect(contextMenuService.open).not.toHaveBeenCalled();
});
it('should close custom context menu if a new one is opened', () => {
store.dispatch(new CustomContextMenu(new MouseEvent('click'), actionPayloadMock));
expect(contextMenuService.open).toHaveBeenCalled();
store.dispatch(new CustomContextMenu(new MouseEvent('click'), actionPayloadMock));
expect(overlayRefMock.close).toHaveBeenCalled();
});
});

View File

@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { ContextMenu, ContextMenuActionTypes } from '@alfresco/aca-shared/store';
import { ContextMenu, ContextMenuActionTypes, CustomContextMenu } from '@alfresco/aca-shared/store';
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map } from 'rxjs/operators';
@ -55,4 +55,28 @@ export class ContextMenuEffects {
),
{ dispatch: false }
);
customContextMenu$ = createEffect(
() =>
this.actions$.pipe(
ofType<CustomContextMenu>(ContextMenuActionTypes.CustomContextMenu),
map((action) => {
if (action.payload?.length) {
if (this.overlayRef) {
this.overlayRef.close();
}
this.overlayRef = this.contextMenuService.open(
{
source: action.event,
hasBackdrop: false,
backdropClass: 'cdk-overlay-transparent-backdrop',
panelClass: 'cdk-overlay-pane'
},
action.payload
);
}
})
),
{ dispatch: false }
);
}

View File

@ -23,9 +23,21 @@
*/
import { ContextActionsDirective } from './contextmenu.directive';
import { ContextMenu } from '@alfresco/aca-shared/store';
import { ContextMenu, CustomContextMenu } from '@alfresco/aca-shared/store';
import { ContentActionRef, ContentActionType } from '@alfresco/adf-extensions';
import { fakeAsync, tick } from '@angular/core/testing';
const customActionsMock: ContentActionRef[] = [
{
type: ContentActionType.default,
id: 'action',
title: 'action',
actions: {
click: 'event'
}
}
];
describe('ContextActionsDirective', () => {
let directive: ContextActionsDirective;
@ -45,24 +57,6 @@ describe('ContextActionsDirective', () => {
expect(directive.execute).not.toHaveBeenCalled();
});
it('should call service to render context menu', fakeAsync(() => {
const el = document.createElement('div');
el.className = 'adf-datatable-cell adf-datatable-cell--text adf-datatable-row';
const fragment = document.createDocumentFragment();
fragment.appendChild(el);
const target = fragment.querySelector('div');
const mouseEventMock: any = { preventDefault: () => {}, target };
directive.ngOnInit();
directive.onContextMenuEvent(mouseEventMock);
tick(500);
expect(storeMock.dispatch).toHaveBeenCalledWith(new ContextMenu(mouseEventMock));
}));
it('should not call service to render context menu if the datatable is empty', fakeAsync(() => {
storeMock.dispatch.calls.reset();
const el = document.createElement('div');
@ -82,4 +76,34 @@ describe('ContextActionsDirective', () => {
expect(storeMock.dispatch).not.toHaveBeenCalled();
}));
describe('Context Menu rendering', () => {
let mouseEventMock: any;
beforeEach(() => {
const el = document.createElement('div');
el.className = 'adf-datatable-cell adf-datatable-cell--text adf-datatable-row';
const fragment = document.createDocumentFragment();
fragment.appendChild(el);
const target = fragment.querySelector('div');
mouseEventMock = { preventDefault: () => {}, target };
});
it('should call service to render context menu', fakeAsync(() => {
directive.ngOnInit();
directive.onContextMenuEvent(mouseEventMock);
tick(500);
expect(storeMock.dispatch).toHaveBeenCalledWith(new ContextMenu(mouseEventMock));
}));
it('should call service to render custom context menu if custom actions are provided', fakeAsync(() => {
directive.customActions = customActionsMock;
directive.ngOnInit();
directive.onContextMenuEvent(mouseEventMock);
tick(500);
expect(storeMock.dispatch).toHaveBeenCalledWith(new CustomContextMenu(mouseEventMock, customActionsMock));
}));
});
});

View File

@ -26,7 +26,8 @@ import { Directive, HostListener, Input, OnInit, OnDestroy } from '@angular/core
import { debounceTime, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';
import { Store } from '@ngrx/store';
import { AppStore, ContextMenu } from '@alfresco/aca-shared/store';
import { AppStore, ContextMenu, CustomContextMenu } from '@alfresco/aca-shared/store';
import { ContentActionRef } from '@alfresco/adf-extensions';
@Directive({
standalone: true,
@ -41,6 +42,9 @@ export class ContextActionsDirective implements OnInit, OnDestroy {
@Input('acaContextEnable')
enabled = true;
@Input()
customActions: ContentActionRef[] = [];
@HostListener('contextmenu', ['$event'])
onContextMenuEvent(event: MouseEvent) {
if (event) {
@ -59,7 +63,11 @@ export class ContextActionsDirective implements OnInit, OnDestroy {
ngOnInit() {
this.execute$.pipe(debounceTime(300), takeUntil(this.onDestroy$)).subscribe((event: MouseEvent) => {
this.store.dispatch(new ContextMenu(event));
if (this.customActions?.length) {
this.store.dispatch(new CustomContextMenu(event, this.customActions));
} else {
this.store.dispatch(new ContextMenu(event));
}
});
}

View File

@ -23,5 +23,6 @@
*/
export enum ContextMenuActionTypes {
ContextMenu = 'CONTEXT_MENU'
ContextMenu = 'CONTEXT_MENU',
CustomContextMenu = 'CUSTOM_CONTEXT_MENU'
}

View File

@ -24,9 +24,16 @@
import { Action } from '@ngrx/store';
import { ContextMenuActionTypes } from './context-menu-action-types';
import { ContentActionRef } from '@alfresco/adf-extensions';
export class ContextMenu implements Action {
readonly type = ContextMenuActionTypes.ContextMenu;
constructor(public event: MouseEvent) {}
}
export class CustomContextMenu implements Action {
readonly type = ContextMenuActionTypes.CustomContextMenu;
constructor(public event: MouseEvent, public payload: ContentActionRef[] = []) {}
}