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[] = []) {} +}