diff --git a/docs/features/README.md b/docs/features/README.md
index 9d58333cbe..fbe14dcb9a 100644
--- a/docs/features/README.md
+++ b/docs/features/README.md
@@ -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)
diff --git a/docs/features/context-menu-actions.md b/docs/features/context-menu-actions.md
new file mode 100644
index 0000000000..72d6554471
--- /dev/null
+++ b/docs/features/context-menu-actions.md
@@ -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
+
+
+```
+
+*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
+
+
+```
+
+*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.
diff --git a/projects/aca-content/src/lib/components/context-menu/base-context-menu.directive.spec.ts b/projects/aca-content/src/lib/components/context-menu/base-context-menu.directive.spec.ts
new file mode 100644
index 0000000000..2e96ca9f8c
--- /dev/null
+++ b/projects/aca-content/src/lib/components/context-menu/base-context-menu.directive.spec.ts
@@ -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 .
+ */
+
+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: '
',
+ standalone: true,
+ imports: [OutsideEventDirective]
+})
+class TestComponent extends BaseContextMenuDirective {}
+
+describe('BaseContextMenuComponent', () => {
+ let contextMenuOverlayRef: ContextMenuOverlayRef;
+ let extensionsService: AppExtensionService;
+ let fixture: ComponentFixture;
+ 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);
+ });
+});
diff --git a/projects/aca-content/src/lib/components/context-menu/base-context-menu.directive.ts b/projects/aca-content/src/lib/components/context-menu/base-context-menu.directive.ts
new file mode 100644
index 0000000000..c71a463c42
--- /dev/null
+++ b/projects/aca-content/src/lib/components/context-menu/base-context-menu.directive.ts
@@ -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 .
+ */
+
+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 = [];
+
+ @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;
+ }
+}
diff --git a/projects/aca-content/src/lib/components/context-menu/context-menu.component.spec.ts b/projects/aca-content/src/lib/components/context-menu/context-menu.component.spec.ts
index 83e9b1ab25..8c8ba2611c 100644
--- a/projects/aca-content/src/lib/components/context-menu/context-menu.component.spec.ts
+++ b/projects/aca-content/src/lib/components/context-menu/context-menu.component.spec.ts
@@ -35,7 +35,6 @@ import { AppExtensionService } from '@alfresco/aca-shared';
describe('ContextMenuComponent', () => {
let fixture: ComponentFixture;
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'
- });
- });
});
diff --git a/projects/aca-content/src/lib/components/context-menu/context-menu.component.ts b/projects/aca-content/src/lib/components/context-menu/context-menu.component.ts
index 5531f167b8..4eaf339089 100644
--- a/projects/aca-content/src/lib/components/context-menu/context-menu.component.ts
+++ b/projects/aca-content/src/lib/components/context-menu/context-menu.component.ts
@@ -22,11 +22,9 @@
* from Hyland Software. If not, see .
*/
-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 = new Subject();
- actions: Array = [];
+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;
- }
}
diff --git a/projects/aca-content/src/lib/components/context-menu/context-menu.service.spec.ts b/projects/aca-content/src/lib/components/context-menu/context-menu.service.spec.ts
index 1e68d9483b..4f7e58a6d5 100644
--- a/projects/aca-content/src/lib/components/context-menu/context-menu.service.spec.ts
+++ b/projects/aca-content/src/lib/components/context-menu/context-menu.service.spec.ts
@@ -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);
+ });
});
diff --git a/projects/aca-content/src/lib/components/context-menu/context-menu.service.ts b/projects/aca-content/src/lib/components/context-menu/context-menu.service.ts
index 80b9d0dab7..37d49a50c5 100644
--- a/projects/aca-content/src/lib/components/context-menu/context-menu.service.ts
+++ b/projects/aca-content/src/lib/components/context-menu/context-menu.service.ts
@@ -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 = 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 = 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;
diff --git a/projects/aca-content/src/lib/components/context-menu/custom-context-menu-actions.token.ts b/projects/aca-content/src/lib/components/context-menu/custom-context-menu-actions.token.ts
new file mode 100644
index 0000000000..9a7d27d886
--- /dev/null
+++ b/projects/aca-content/src/lib/components/context-menu/custom-context-menu-actions.token.ts
@@ -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 .
+ */
+
+import { InjectionToken } from '@angular/core';
+
+export const CONTEXT_MENU_CUSTOM_ACTIONS = new InjectionToken('CONTEXT_MENU_CUSTOM_ACTIONS');
diff --git a/projects/aca-content/src/lib/components/context-menu/custom-context-menu.component.spec.ts b/projects/aca-content/src/lib/components/context-menu/custom-context-menu.component.spec.ts
new file mode 100644
index 0000000000..264c98bf43
--- /dev/null
+++ b/projects/aca-content/src/lib/components/context-menu/custom-context-menu.component.spec.ts
@@ -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 .
+ */
+
+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;
+ 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);
+ });
+});
diff --git a/projects/aca-content/src/lib/components/context-menu/custom-context-menu.component.ts b/projects/aca-content/src/lib/components/context-menu/custom-context-menu.component.ts
new file mode 100644
index 0000000000..bfe338a4d6
--- /dev/null
+++ b/projects/aca-content/src/lib/components/context-menu/custom-context-menu.component.ts
@@ -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 .
+ */
+
+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);
+ }
+}
diff --git a/projects/aca-content/src/lib/store/app-store.module.ts b/projects/aca-content/src/lib/store/app-store.module.ts
index dfeb5d1076..dcbc291b6b 100644
--- a/projects/aca-content/src/lib/store/app-store.module.ts
+++ b/projects/aca-content/src/lib/store/app-store.module.ts
@@ -71,7 +71,6 @@ import { SearchAiEffects } from './effects/search-ai.effects';
TemplateEffects,
ContextMenuEffects,
SearchAiEffects,
- ContextMenuEffects,
SnackbarEffects,
RouterEffects
])
diff --git a/projects/aca-content/src/lib/store/effects/contextmenu.effects.spec.ts b/projects/aca-content/src/lib/store/effects/contextmenu.effects.spec.ts
index 043c8d60c8..7913eb3739 100644
--- a/projects/aca-content/src/lib/store/effects/contextmenu.effects.spec.ts
+++ b/projects/aca-content/src/lib/store/effects/contextmenu.effects.spec.ts
@@ -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;
@@ -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();
+ });
});
diff --git a/projects/aca-content/src/lib/store/effects/contextmenu.effects.ts b/projects/aca-content/src/lib/store/effects/contextmenu.effects.ts
index 0121b19431..9b01196279 100644
--- a/projects/aca-content/src/lib/store/effects/contextmenu.effects.ts
+++ b/projects/aca-content/src/lib/store/effects/contextmenu.effects.ts
@@ -22,7 +22,7 @@
* from Hyland Software. If not, see .
*/
-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(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 }
+ );
}
diff --git a/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.spec.ts b/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.spec.ts
index 13cf25a55d..c207c70131 100644
--- a/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.spec.ts
+++ b/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.spec.ts
@@ -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));
+ }));
+ });
});
diff --git a/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.ts b/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.ts
index c9fed3d5a8..b212fb1a4b 100644
--- a/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.ts
+++ b/projects/aca-shared/src/lib/directives/contextmenu/contextmenu.directive.ts
@@ -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));
+ }
});
}
diff --git a/projects/aca-shared/store/src/actions/context-menu-action-types.ts b/projects/aca-shared/store/src/actions/context-menu-action-types.ts
index bddf28fee1..321f8b6a1f 100644
--- a/projects/aca-shared/store/src/actions/context-menu-action-types.ts
+++ b/projects/aca-shared/store/src/actions/context-menu-action-types.ts
@@ -23,5 +23,6 @@
*/
export enum ContextMenuActionTypes {
- ContextMenu = 'CONTEXT_MENU'
+ ContextMenu = 'CONTEXT_MENU',
+ CustomContextMenu = 'CUSTOM_CONTEXT_MENU'
}
diff --git a/projects/aca-shared/store/src/actions/contextmenu.actions.ts b/projects/aca-shared/store/src/actions/contextmenu.actions.ts
index 6694a58a51..8b08410d85 100644
--- a/projects/aca-shared/store/src/actions/contextmenu.actions.ts
+++ b/projects/aca-shared/store/src/actions/contextmenu.actions.ts
@@ -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[] = []) {}
+}