[ACS-4592] missing right click menu on row and left click is only allow on indicator rotate (#8327)

* ACS-4592 Displaying context menu for tree list row and allow to expand row by clicking at label

* ACS-4592 Added clicking on label for load more button

* ACS-4592 Unit tests

* ACS-4592 Added documentation for context menu for tree component and fixed lint issues

* ACS-4592 Trigger stuck check
This commit is contained in:
AleksanderSklorz 2023-03-08 14:11:48 +01:00 committed by GitHub
parent 41f9974919
commit 9863149a28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 315 additions and 49 deletions

View File

@ -28,7 +28,7 @@ Shows the nodes in tree structure, each node containing children is collapsible/
### Properties
| Name | Type | Default value | Description |
| ---- | ---- | ------------- | ----------- |
| ---- |---------------| --------- | ----------- |
| emptyContentTemplate | `TemplateRef` | | Template that will be rendered when no nodes are loaded. |
| nodeActionsMenuTemplate | `TemplateRef` | | Template that will be rendered when context menu for given node is opened. |
| stickyHeader | `boolean` | false | If set to true header will be sticky. |
@ -37,13 +37,15 @@ Shows the nodes in tree structure, each node containing children is collapsible/
| loadMoreSuffix | `string` | | Suffix added to `Load more` string inside load more node. |
| expandIcon | `string` | `chevron_right` | Icon shown when node is collapsed. |
| collapseIcon | `string` | `expand_more` | Icon showed when node is expanded. |
| contextMenuOptions | `any[]` | | Array of context menu options which should be displayed for each row. |
### Events
| Name | Type | Description |
| ---- | ---- | ----------- |
| ---- |----------------------------------------------------------------------------------------|------------------------------------------------------------------|
| paginationChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<PaginationModel>` | Emitted when during loading additional nodes pagination changes. |
| contextMenuOptionSelected | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<TreeContextMenuResult<T>>` | Emitted when any context menu option is selected. |
## Details

View File

@ -33,7 +33,9 @@
</button>
</div>
<div class="adf-tree-cell">
<span class="adf-tree-cell-value">
<span
class="adf-tree-cell-value"
(click)="loadMoreSubnodes(node)">
{{ 'ADF-TREE.LOAD-MORE-BUTTON' | translate: { name: loadMoreSuffix } }}
</span>
</div>
@ -42,7 +44,10 @@
class="adf-tree-row"
[attr.data-automation-id]="'node_' + node.id"
*matTreeNodeDef="let node"
matTreeNodePadding>
matTreeNodePadding
[adf-context-menu]="contextMenuOptions"
[adf-context-menu-enabled]="!!contextMenuOptions"
(contextmenu)="contextMenuSource = node">
<div class="adf-tree-expand-collapse-container">
<button *ngIf="node.hasChildren"
class="adf-tree-expand-collapse-button"
@ -80,7 +85,10 @@
</ng-template>
</ng-container>
<div class="adf-tree-cell">
<span class="adf-tree-cell-value">
<span
class="adf-tree-cell-value"
[class.adf-tree-clickable-cell-value]="node.hasChildren"
(click)="expandCollapseNode(node)">
{{ node.nodeName }}
</span>
</div>

View File

@ -11,6 +11,11 @@ $tree-header-font-size: 12px !default;
}
}
.adf-tree-load-more-row .adf-tree-cell-value,
.adf-tree-clickable-cell-value {
cursor: pointer;
}
.adf-tree-row,
.adf-tree-load-more-row {
transition: all 0.3s ease;
@ -21,7 +26,6 @@ $tree-header-font-size: 12px !default;
transition-property: background-color;
border-bottom: 1px solid var(--theme-border-color);
min-height: $tree-row-height;
cursor: pointer;
user-select: none;
.adf-tree-expand-collapse-container {
@ -55,7 +59,7 @@ $tree-header-font-size: 12px !default;
width: 100%;
.adf-tree-cell-value {
display: block;
display: inline-block;
padding: 10px;
word-break: break-word;
}

View File

@ -17,32 +17,41 @@
import { TreeComponent } from './tree.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CoreTestingModule, UserPreferencesService } from '@alfresco/adf-core';
import { ContextMenuDirective, CoreTestingModule, UserPreferencesService } from '@alfresco/adf-core';
import { MatTreeModule } from '@angular/material/tree';
import { TreeNode, TreeNodeType } from '../models/tree-node.interface';
import { singleNode, treeNodesChildrenMockExpanded, treeNodesMock, treeNodesMockExpanded } from '../mock/tree-node.mock';
import { of } from 'rxjs';
import {
singleNode,
treeNodesChildrenMockExpanded,
treeNodesMock,
treeNodesMockExpanded,
treeNodesNoChildrenMock
} from '../mock/tree-node.mock';
import { of, Subject } from 'rxjs';
import { TreeService } from '../services/tree.service';
import { TreeServiceMock } from '../mock/tree-service.service.mock';
import { By } from '@angular/platform-browser';
import { SelectionChange } from '@angular/cdk/collections';
import { DebugElement } from '@angular/core';
describe('TreeComponent', () => {
let fixture: ComponentFixture<TreeComponent<TreeNode>>;
let component: TreeComponent<TreeNode>;
let userPreferenceService: UserPreferencesService;
const getDisplayNameValue = (nodeId: string) =>
fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"] .adf-tree-cell-value`).innerText.trim();
const composeNodeSelector = (nodeId: string) => `.mat-tree-node[data-automation-id="node_${nodeId}"]`;
const getNodePadding = (nodeId: string) => {
const element = fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"]`);
return parseInt(window.getComputedStyle(element).paddingLeft, 10);
};
const getNode = (nodeId: string) => fixture.debugElement.query(By.css(composeNodeSelector(nodeId)));
const getNodeSpinner = (nodeId: string) => fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"] .mat-progress-spinner`);
const getDisplayNameElement = (nodeId: string) => fixture.nativeElement.querySelector(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`);
const getExpandCollapseBtn = (nodeId: string) => fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"] .adf-icon`);
const getDisplayNameValue = (nodeId: string) => getDisplayNameElement(nodeId).innerText.trim();
const getNodePadding = (nodeId: string) => parseInt(getComputedStyle(getNode(nodeId).nativeElement).paddingLeft, 10);
const getNodeSpinner = (nodeId: string) => fixture.nativeElement.querySelector(`${composeNodeSelector(nodeId)} .mat-progress-spinner`);
const getExpandCollapseBtn = (nodeId: string) => fixture.nativeElement.querySelector(`${composeNodeSelector(nodeId)} .adf-icon`);
const tickCheckbox = (index: number) => {
const nodeCheckboxes = fixture.debugElement.queryAll(By.css('mat-checkbox'));
@ -179,6 +188,56 @@ describe('TreeComponent', () => {
expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
});
it('should call collapseNode on TreeService when collapsing node by clicking at node label and node has children', () => {
component.refreshTree();
fixture.detectChanges();
spyOn(component.treeService, 'collapseNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
getDisplayNameElement(component.treeService.treeNodes[0].id).dispatchEvent(new Event('click'));
expect(component.treeService.collapseNode).toHaveBeenCalledWith(component.treeService.treeNodes[0]);
});
it('should call expandNode on TreeService when expanding node by clicking at node label and node has children', () => {
component.refreshTree();
fixture.detectChanges();
spyOn(component.treeService, 'expandNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false);
getDisplayNameElement(component.treeService.treeNodes[0].id).dispatchEvent(new Event('click'));
expect(component.treeService.expandNode).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded);
});
it('should not call collapseNode on TreeService when collapsing node and node has not children', () => {
spyOn(component.treeService, 'getSubNodes').and.returnValue(of({
pagination: {
skipCount: 0,
maxItems: 25
},
entries: Array.from(treeNodesNoChildrenMock)
}));
component.refreshTree();
fixture.detectChanges();
spyOn(component.treeService, 'collapseNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true);
getDisplayNameElement(component.treeService.treeNodes[0].id).dispatchEvent(new Event('click'));
expect(component.treeService.collapseNode).not.toHaveBeenCalled();
});
it('should not call expandNode on TreeService when expanding node by clicking at node label and node has not children', () => {
spyOn(component.treeService, 'getSubNodes').and.returnValue(of({
pagination: {
skipCount: 0,
maxItems: 25
},
entries: Array.from(treeNodesNoChildrenMock)
}));
component.refreshTree();
fixture.detectChanges();
spyOn(component.treeService, 'expandNode');
spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false);
getDisplayNameElement(component.treeService.treeNodes[0].id).dispatchEvent(new Event('click'));
expect(component.treeService.expandNode).not.toHaveBeenCalled();
});
it('should load more subnodes and remove load more button when load more button is clicked', () => {
component.refreshTree();
fixture.detectChanges();
@ -193,6 +252,22 @@ describe('TreeComponent', () => {
expect(loadMoreNodes).toBeUndefined();
});
it('should load more subnodes and remove load more button when label of load more button is clicked', () => {
component.refreshTree();
fixture.detectChanges();
spyOn(component.treeService, 'getSubNodes').and.returnValue(of({
pagination: {},
entries: Array.from(singleNode)
}));
spyOn(component.treeService, 'appendNodes');
fixture.debugElement.query(By.css('.adf-tree-load-more-row .adf-tree-cell-value')).nativeElement.click();
fixture.whenStable();
fixture.detectChanges();
expect(component.treeService.appendNodes).toHaveBeenCalledWith(component.treeService.treeNodes[0], Array.from(singleNode));
expect(component.treeService.treeNodes.find((node) => node.nodeType === TreeNodeType.LoadMoreNode))
.toBeUndefined();
});
it('selection should be disabled by default, no checkboxes should be displayed', () => {
component.refreshTree();
fixture.detectChanges();
@ -257,4 +332,96 @@ describe('TreeComponent', () => {
fixture.detectChanges();
});
});
describe('Context menu', () => {
let contextMenu: ContextMenuDirective;
let contextMenuOption1: any;
let contextMenuOption2: any;
let node: DebugElement;
const optionTitle1 = 'option 1';
const optionTitle2 = 'option 2';
beforeEach(() => {
fixture.detectChanges();
node = getNode('testId1');
contextMenu = node.injector.get(ContextMenuDirective);
contextMenuOption1 = {
title: optionTitle1,
subject: new Subject()
};
contextMenuOption2 = {
title: optionTitle2,
subject: new Subject()
};
});
it('should have assigned correct value to links property of context menu for row', () => {
component.contextMenuOptions = [contextMenuOption1, contextMenuOption2];
fixture.detectChanges();
expect(contextMenu.links).toEqual(component.contextMenuOptions);
});
it('should have assigned default subject to each context menu option', () => {
contextMenuOption1.subject = undefined;
contextMenuOption2.subject = undefined;
component.contextMenuOptions = [contextMenuOption1, contextMenuOption2];
fixture.detectChanges();
expect(contextMenu.links).toEqual([{
title: optionTitle1,
subject: jasmine.any(Subject)
}, {
title: optionTitle2,
subject: jasmine.any(Subject)
}]);
});
it('should have assigned false to enabled property of context menu for row by default', () => {
expect(contextMenu.enabled).toBeFalse();
});
it('should have assigned true to enabled property of context menu for row if contextMenuOptions is set', () => {
component.contextMenuOptions = [contextMenuOption1, contextMenuOption2];
fixture.detectChanges();
expect(contextMenu.enabled).toBeTrue();
});
it('should have assigned false to enabled property of context menu for row if contextMenuOptions is not set', () => {
component.contextMenuOptions = [contextMenuOption1, contextMenuOption2];
fixture.detectChanges();
component.contextMenuOptions = null;
fixture.detectChanges();
expect(contextMenu.enabled).toBeFalse();
});
it('should emit contextMenuOptionSelected with correct parameters when any context menu option has been selected', () => {
spyOn(component.contextMenuOptionSelected, 'emit');
component.contextMenuOptions = [contextMenuOption1, contextMenuOption2];
const option = component.contextMenuOptions[0];
component.contextMenuOptions[0].subject.next(option);
expect(component.contextMenuOptionSelected.emit).toHaveBeenCalledWith({
contextMenuOption: option,
row: undefined
});
});
it('should emit contextMenuOptionSelected including row when any context menu option has been selected and contextmenu event has been triggered earlier', () => {
spyOn(component.contextMenuOptionSelected, 'emit');
component.contextMenuOptions = [contextMenuOption1, contextMenuOption2];
fixture.detectChanges();
node.nativeElement.dispatchEvent(new MouseEvent('contextmenu'));
const option = component.contextMenuOptions[0];
component.contextMenuOptions[0].subject.next(option);
expect(component.contextMenuOptionSelected.emit).toHaveBeenCalledWith({
contextMenuOption: option,
row: treeNodesMockExpanded[0]
});
});
});
});

View File

@ -15,14 +15,28 @@
* limitations under the License.
*/
import { Component, EventEmitter, HostBinding, Input, OnInit, Output, QueryList, TemplateRef, ViewChildren, ViewEncapsulation } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import {
Component,
EventEmitter,
HostBinding,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
TemplateRef,
ViewChildren,
ViewEncapsulation
} from '@angular/core';
import { BehaviorSubject, merge, Observable, Subject } from 'rxjs';
import { TreeNode, TreeNodeType } from '../models/tree-node.interface';
import { TreeService } from '../services/tree.service';
import { PaginationModel, UserPreferencesService } from '@alfresco/adf-core';
import { SelectionChange, SelectionModel } from '@angular/cdk/collections';
import { TreeResponse } from '../models/tree-response.interface';
import { MatCheckbox } from '@angular/material/checkbox';
import { TreeContextMenuResult } from '../models/tree-context-menu-result.interface';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'adf-tree',
@ -31,7 +45,7 @@ import { MatCheckbox } from '@angular/material/checkbox';
host: { class: 'adf-tree' },
encapsulation: ViewEncapsulation.None
})
export class TreeComponent<T extends TreeNode> implements OnInit {
export class TreeComponent<T extends TreeNode> implements OnInit, OnDestroy {
/** TemplateRef to provide empty template when no nodes are loaded */
@Input()
@ -70,16 +84,57 @@ export class TreeComponent<T extends TreeNode> implements OnInit {
@Output()
public paginationChanged: EventEmitter<PaginationModel> = new EventEmitter();
/** Emitted when any context menu option is selected */
@Output()
public contextMenuOptionSelected = new EventEmitter<TreeContextMenuResult<T>>();
@ViewChildren(MatCheckbox)
public nodeCheckboxes: QueryList<MatCheckbox>;
private loadingRootSource = new BehaviorSubject<boolean>(false);
private _contextMenuSource: T;
private _contextMenuOptions: any[];
private contextMenuOptionsChanged$ = new Subject<void>();
public loadingRoot$: Observable<boolean>;
public treeNodesSelection = new SelectionModel<T>(true, [], true, (node1: T, node2: T) => node1.id === node2.id);
constructor(public treeService: TreeService<T>,
private userPreferenceService: UserPreferencesService) {}
set contextMenuSource(contextMenuSource: T) {
this._contextMenuSource = contextMenuSource;
}
/** Array of context menu options which should be displayed for each row. */
@Input()
set contextMenuOptions(contextMenuOptions: any[]) {
this.contextMenuOptionsChanged$.next();
if (contextMenuOptions) {
this._contextMenuOptions = contextMenuOptions.map((option) => {
if (!option.subject) {
option = {
...option,
subject: new Subject()
};
}
return option;
});
merge(...this.contextMenuOptions.map((option) => option.subject)).pipe(takeUntil(this.contextMenuOptionsChanged$))
.subscribe((option) => {
this.contextMenuOptionSelected.emit({
row: this._contextMenuSource,
contextMenuOption: option
});
});
} else {
this._contextMenuOptions = contextMenuOptions;
}
}
get contextMenuOptions(): any[] {
return this._contextMenuOptions;
}
ngOnInit(): void {
this.loadingRoot$ = this.loadingRootSource.asObservable();
this.refreshTree(0, this.userPreferenceService.paginationSize);
@ -88,6 +143,11 @@ export class TreeComponent<T extends TreeNode> implements OnInit {
});
}
ngOnDestroy() {
this.contextMenuOptionsChanged$.next();
this.contextMenuOptionsChanged$.complete();
}
/**
* Checks if node is LoadMoreNode node
*
@ -141,6 +201,7 @@ export class TreeComponent<T extends TreeNode> implements OnInit {
* @param node node to be collapsed/expanded
*/
public expandCollapseNode(node: T): void {
if (node.hasChildren) {
if (this.treeService.treeControl.isExpanded(node)) {
this.treeService.collapseNode(node);
} else {
@ -158,6 +219,7 @@ export class TreeComponent<T extends TreeNode> implements OnInit {
});
}
}
}
/**
* Loads more subnode for a given parent node

View File

@ -0,0 +1,21 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface TreeContextMenuResult<T> {
row: T;
contextMenuOption: any;
}

View File

@ -18,5 +18,6 @@
export * from './tree.module';
export * from './models/tree-response.interface';
export * from './models/tree-node.interface';
export * from './models/tree-context-menu-result.interface';
export * from './services/tree.service';
export * from './components/tree.component';

View File

@ -15,7 +15,7 @@
* limitations under the License.
*/
import { CoreModule } from '@alfresco/adf-core';
import { ContextMenuModule, CoreModule } from '@alfresco/adf-core';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@ -27,7 +27,8 @@ import { TreeComponent } from './components/tree.component';
CommonModule,
CoreModule,
MaterialModule,
TranslateModule
TranslateModule,
ContextMenuModule
],
declarations: [
TreeComponent