diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index 5228554d15..d1456d7c6f 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -627,6 +627,10 @@ "LOAD-MORE-BUTTON": "Load more {{ name }}", "ACTIONS": { "TOOLTIP": "Open actions menu" + }, + "ARIA": { + "SELECTED": "{{ name }} selected", + "DESELECTED": "{{ name }} deselected" } }, "LIBRARY": { diff --git a/lib/content-services/src/lib/tree/components/tree.component.html b/lib/content-services/src/lib/tree/components/tree.component.html index ae2f903301..0f665a35fd 100644 --- a/lib/content-services/src/lib/tree/components/tree.component.html +++ b/lib/content-services/src/lib/tree/components/tree.component.html @@ -42,18 +42,23 @@
diff --git a/lib/content-services/src/lib/tree/components/tree.component.scss b/lib/content-services/src/lib/tree/components/tree.component.scss index 7477d82c30..c0e9c0e1c1 100644 --- a/lib/content-services/src/lib/tree/components/tree.component.scss +++ b/lib/content-services/src/lib/tree/components/tree.component.scss @@ -43,7 +43,6 @@ $tree-header-font-size: 12px !default; &:focus { background-color: var(--mat-sys-surface-container); outline-offset: -1px; - outline: 1px solid var(--mat-sys-secondary); } .adf-tree-expand-collapse-button, diff --git a/lib/content-services/src/lib/tree/components/tree.component.spec.ts b/lib/content-services/src/lib/tree/components/tree.component.spec.ts index cc1ca5b0cc..a054cfedd4 100644 --- a/lib/content-services/src/lib/tree/components/tree.component.spec.ts +++ b/lib/content-services/src/lib/tree/components/tree.component.spec.ts @@ -19,6 +19,7 @@ import { TreeComponent } from './tree.component'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ContextMenuDirective, UnitTestingUtils, UserPreferencesService } from '@alfresco/adf-core'; import { TreeNode, TreeNodeType } from '../models/tree-node.interface'; +import { TreeResponse } from '../models/tree-response.interface'; import { singleNode, treeNodesChildrenMockExpanded, treeNodesMock, treeNodesMockExpanded, treeNodesNoChildrenMock } from '../mock/tree-node.mock'; import { of, Subject } from 'rxjs'; import { TreeService } from '../services/tree.service'; @@ -29,6 +30,7 @@ import { DebugElement } from '@angular/core'; import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { MatCheckboxHarness } from '@angular/material/checkbox/testing'; +import { TranslateService } from '@ngx-translate/core'; describe('TreeComponent', () => { let fixture: ComponentFixture>; @@ -40,7 +42,8 @@ describe('TreeComponent', () => { const composeNodeSelector = (nodeId: string) => `[data-automation-id="node_${nodeId}"]`; const getNode = (nodeId: string) => testingUtils.getByCSS(composeNodeSelector(nodeId)); - const clickDisplayNameElement = (nodeId: string) => testingUtils.clickByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`); + const clickDisplayNameElement = (nodeId: string) => + testingUtils.getByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`).nativeElement.click(); const getDisplayNameValue = (nodeId: string) => testingUtils.getInnerTextByCSS(`${composeNodeSelector(nodeId)} .adf-tree-cell-value`); @@ -181,6 +184,7 @@ describe('TreeComponent', () => { component.refreshTree(); component.treeService.treeNodes[0].isLoading = false; fixture.detectChanges(); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); const collapseSpy = spyOn(component.treeService, 'collapseNode'); spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true); clickExpandCollapseBtn(component.treeService.treeNodes[0].id); @@ -201,15 +205,18 @@ describe('TreeComponent', () => { component.refreshTree(); component.treeService.treeNodes[0].isLoading = false; fixture.detectChanges(); - const collapseSpy = spyOn(component.treeService, 'expandNode'); - spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false); + component.treeService.treeNodes = Array.from(treeNodesMock); + component.treeService.treeNodes[0].isLoading = false; + fixture.detectChanges(); + const expandSpy = spyOn(component.treeService, 'expandNode'); clickExpandCollapseBtn(component.treeService.treeNodes[0].id); - expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded); + expect(expandSpy).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(); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); spyOn(component.treeService, 'collapseNode'); spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true); clickDisplayNameElement(component.treeService.treeNodes[0].id); @@ -219,8 +226,10 @@ describe('TreeComponent', () => { it('should call expandNode on TreeService when expanding node by clicking at node label and node has children', () => { component.refreshTree(); fixture.detectChanges(); + component.treeService.treeNodes = Array.from(treeNodesMock); + component.treeService.treeNodes[0].isLoading = false; + fixture.detectChanges(); spyOn(component.treeService, 'expandNode'); - spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false); clickDisplayNameElement(component.treeService.treeNodes[0].id); expect(component.treeService.expandNode).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded); }); @@ -356,6 +365,86 @@ describe('TreeComponent', () => { component.loadMoreSubnodes(component.treeService.treeNodes.find((node: TreeNode) => node.nodeType === TreeNodeType.LoadMoreNode)); fixture.detectChanges(); }); + + describe('announcement', () => { + let translateSpy: jasmine.Spy; + + beforeEach(() => { + fixture.detectChanges(); + translateSpy = spyOn(TestBed.inject(TranslateService), 'instant'); + }); + + it('should use selected key when node is selected', () => { + component.onNodeSelected(component.treeService.treeNodes[0]); + expect(translateSpy).toHaveBeenCalledWith('ADF-TREE.ARIA.SELECTED', { name: 'testName1' }); + }); + + it('should use deselected key when node is deselected', () => { + component.treeNodesSelection.select(component.treeService.treeNodes[0]); + component.onNodeSelected(component.treeService.treeNodes[0]); + expect(translateSpy).toHaveBeenCalledWith('ADF-TREE.ARIA.DESELECTED', { name: 'testName1' }); + }); + }); + }); + + describe('expansionModel.changed subscription', () => { + it('should load and expand node when expansionModel fires added event', () => { + fixture.detectChanges(); + component.treeService.treeNodes = Array.from(treeNodesMock); + component.treeService.treeNodes[0].isLoading = false; + const expandSpy = spyOn(component.treeService, 'expandNode'); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); + expect(expandSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded); + }); + + it('should collapse node when expansionModel fires removed event', () => { + fixture.detectChanges(); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); + const collapseSpy = spyOn(component.treeService, 'collapseNode'); + component.treeService.treeControl.collapse(component.treeService.treeNodes[0]); + expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0]); + }); + + it('should not expand node when expansionModel fires added event and node is already loading', () => { + fixture.detectChanges(); + component.treeService.treeNodes[0].isLoading = true; + const expandSpy = spyOn(component.treeService, 'expandNode'); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); + expect(expandSpy).not.toHaveBeenCalled(); + }); + + it('should not collapse node when expansionModel fires removed event and node is loading', () => { + fixture.detectChanges(); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); + component.treeService.treeNodes[0].isLoading = true; + const collapseSpy = spyOn(component.treeService, 'collapseNode'); + component.treeService.treeControl.collapse(component.treeService.treeNodes[0]); + expect(collapseSpy).not.toHaveBeenCalled(); + }); + + it('should not load children when node is expanded but children are already loaded', () => { + fixture.detectChanges(); + const expandSpy = spyOn(component.treeService, 'expandNode'); + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); + expect(expandSpy).not.toHaveBeenCalled(); + }); + + it('should reset isLoading and collapse node when getSubNodes fails during expansion', () => { + fixture.detectChanges(); + component.treeService.treeNodes = Array.from(treeNodesMock); + component.treeService.treeNodes[0].isLoading = false; + + const subject = new Subject>(); + spyOn(component.treeService, 'getSubNodes').and.returnValue(subject.asObservable()); + + component.treeService.treeControl.expand(component.treeService.treeNodes[0]); + expect(component.treeService.treeNodes[0].isLoading).toBeTrue(); + + subject.error(new Error('error')); + + expect(component.treeService.treeNodes[0].isLoading).toBeFalse(); + expect(component.treeService.treeControl.isExpanded(component.treeService.treeNodes[0])).toBeFalse(); + }); }); describe('Context menu', () => { diff --git a/lib/content-services/src/lib/tree/components/tree.component.ts b/lib/content-services/src/lib/tree/components/tree.component.ts index 5063fcde53..ad68808921 100644 --- a/lib/content-services/src/lib/tree/components/tree.component.ts +++ b/lib/content-services/src/lib/tree/components/tree.component.ts @@ -17,6 +17,7 @@ import { Component, + DestroyRef, EventEmitter, HostBinding, Input, @@ -29,7 +30,7 @@ import { ViewEncapsulation, inject } from '@angular/core'; -import { BehaviorSubject, merge, Observable, Subject } from 'rxjs'; +import { BehaviorSubject, merge, Observable, Subject, EMPTY } from 'rxjs'; import { TreeNode, TreeNodeType } from '../models/tree-node.interface'; import { TreeService } from '../services/tree.service'; import { ContextMenuDirective, IconModule, PaginationModel, UserPreferencesService } from '@alfresco/adf-core'; @@ -37,13 +38,15 @@ import { SelectionChange, SelectionModel } from '@angular/cdk/collections'; import { TreeResponse } from '../models/tree-response.interface'; import { MatCheckbox, MatCheckboxModule } from '@angular/material/checkbox'; import { TreeContextMenuResult } from '../models/tree-context-menu-result.interface'; -import { takeUntil } from 'rxjs/operators'; +import { takeUntil, catchError } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; -import { TranslatePipe } from '@ngx-translate/core'; -import { MatTreeModule } from '@angular/material/tree'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { LiveAnnouncer } from '@angular/cdk/a11y'; +import { MatTreeModule, MatTreeNode } from '@angular/material/tree'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'adf-tree', @@ -66,6 +69,9 @@ import { MatMenuModule } from '@angular/material/menu'; export class TreeComponent implements OnInit, OnDestroy { treeService = inject>(TreeService); private readonly userPreferenceService = inject(UserPreferencesService); + private readonly destroyRef = inject(DestroyRef); + private readonly translateService = inject(TranslateService); + private readonly liveAnnouncer = inject(LiveAnnouncer); /** TemplateRef to provide empty template when no nodes are loaded */ @Input() @@ -107,6 +113,9 @@ export class TreeComponent implements OnInit, OnDestroy { @ViewChildren(MatCheckbox) public nodeCheckboxes: QueryList; + @ViewChildren(MatTreeNode) + private readonly matTreeNodes: QueryList>; + private readonly loadingRootSource = new BehaviorSubject(false); private _contextMenuSource: T; private _contextMenuOptions: any[]; @@ -152,9 +161,13 @@ export class TreeComponent implements OnInit, OnDestroy { ngOnInit(): void { this.loadingRoot$ = this.loadingRootSource.asObservable(); this.refreshTree(0, this.userPreferenceService.paginationSize); - this.treeNodesSelection.changed.subscribe((selectionChange: SelectionChange) => { + this.treeNodesSelection.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((selectionChange: SelectionChange) => { this.onTreeSelectionChange(selectionChange); }); + this.treeService.treeControl.expansionModel.changed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((change: SelectionChange) => { + change.added.forEach((node: T) => this.handleNodeExpanded(node)); + change.removed.forEach((node: T) => this.handleNodeCollapsed(node)); + }); } ngOnDestroy() { @@ -207,6 +220,7 @@ export class TreeComponent implements OnInit, OnDestroy { this.treeNodesSelection.deselect(...response.entries); this.paginationChanged.emit(response.pagination); this.loadingRootSource.next(false); + setTimeout(() => this.matTreeNodes?.first?.makeFocusable()); }); } @@ -217,21 +231,7 @@ export class TreeComponent implements OnInit, OnDestroy { */ public expandCollapseNode(node: T): void { if (node.hasChildren && !node.isLoading) { - if (this.treeService.treeControl.isExpanded(node)) { - this.treeService.collapseNode(node); - } else { - node.isLoading = true; - this.treeService.getSubNodes(node.id, 0, this.userPreferenceService.paginationSize).subscribe((response: TreeResponse) => { - this.treeService.expandNode(node, response.entries); - node.isLoading = false; - if (this.treeNodesSelection.isSelected(node)) { - //timeout used to update nodeCheckboxes query list after new nodes are added so they can be selected - setTimeout(() => { - this.treeNodesSelection.select(...response.entries); - }); - } - }); - } + this.treeService.treeControl.toggle(node); } } @@ -265,6 +265,9 @@ export class TreeComponent implements OnInit, OnDestroy { * @param node selected node */ public onNodeSelected(node: T): void { + if (!this.selectableNodes) { + return; + } this.treeNodesSelection.toggle(node); const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode); if (descendants.length > 0) { @@ -273,6 +276,13 @@ export class TreeComponent implements OnInit, OnDestroy { : this.treeNodesSelection.deselect(...descendants); } this.checkParentsSelection(node); + + this.liveAnnouncer.announce( + this.translateService.instant(this.treeNodesSelection.isSelected(node) ? 'ADF-TREE.ARIA.SELECTED' : 'ADF-TREE.ARIA.DESELECTED', { + name: node.nodeName + }), + 'assertive' + ); } /** @@ -301,6 +311,42 @@ export class TreeComponent implements OnInit, OnDestroy { ); } + private handleNodeExpanded(node: T): void { + if (!node.hasChildren || node.isLoading || this.treeService.getChildren(node).length > 0) { + return; + } + node.isLoading = true; + this.treeService + .getSubNodes(node.id, 0, this.userPreferenceService.paginationSize) + .pipe( + catchError(() => { + node.isLoading = false; + this.treeService.treeControl.collapse(node); + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((response: TreeResponse) => { + if (!this.treeService.treeControl.isExpanded(node)) { + node.isLoading = false; + return; + } + this.treeService.expandNode(node, response.entries); + if (this.treeNodesSelection.isSelected(node)) { + // timeout used to update nodeCheckboxes query list after new nodes are added so they can be selected + setTimeout(() => { + this.treeNodesSelection.select(...response.entries); + }); + } + }); + } + + private handleNodeCollapsed(node: T): void { + if (!node.isLoading) { + this.treeService.collapseNode(node); + } + } + private checkParentsSelection(node: T): void { let parent: T = this.treeService.getParentNode(node.parentId); while (parent) { diff --git a/lib/content-services/src/lib/tree/services/tree.service.spec.ts b/lib/content-services/src/lib/tree/services/tree.service.spec.ts index d1e53241d1..4eff4a286a 100644 --- a/lib/content-services/src/lib/tree/services/tree.service.spec.ts +++ b/lib/content-services/src/lib/tree/services/tree.service.spec.ts @@ -58,6 +58,7 @@ describe('TreeService', () => { it('should collapse node containing children', () => { const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded); service.treeNodes = treeNodesMockExpandedCopy; + service.treeControl.expand(treeNodesMockExpandedCopy[0]); const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); const treeControlCollapseSpy = spyOn(service.treeControl, 'collapse'); service.collapseNode(treeNodesMockExpandedCopy[0]); @@ -66,6 +67,15 @@ describe('TreeService', () => { expect(service.treeNodes.length).toEqual(treeNodesMock.length); }); + it('should not call treeControl.collapse when node is not in expansionModel (re-entrant safety)', () => { + const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded); + service.treeNodes = treeNodesMockExpandedCopy; + const treeControlCollapseSpy = spyOn(service.treeControl, 'collapse'); + service.collapseNode(treeNodesMockExpandedCopy[0]); + expect(treeControlCollapseSpy).not.toHaveBeenCalled(); + expect(service.treeNodes.length).toEqual(treeNodesMock.length); + }); + it('should collapse node with more levels', () => { service.treeNodes = Array.from(treeNodesChildrenMockExpanded); const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); diff --git a/lib/content-services/src/lib/tree/services/tree.service.ts b/lib/content-services/src/lib/tree/services/tree.service.ts index 272a914720..3f4e61ab69 100644 --- a/lib/content-services/src/lib/tree/services/tree.service.ts +++ b/lib/content-services/src/lib/tree/services/tree.service.ts @@ -70,7 +70,9 @@ export abstract class TreeService extends DataSource { */ public collapseNode(nodeToCollapse: T): void { if (nodeToCollapse?.hasChildren) { - this.treeControl.collapse(nodeToCollapse); + if (this.treeControl.isExpanded(nodeToCollapse)) { + this.treeControl.collapse(nodeToCollapse); + } const children: T[] = this.treeNodes.filter((node: T) => nodeToCollapse.id === node.parentId); children.forEach((child: T) => { this.collapseInnerNode(child);