[ACS-3757] returning focus to element from which they were opened (#8034)

* ACS-3757 Focus first focusable element in modals and allow to autofocus specific element after modal closing

* ACS-3757 Added possibility for autofocus after closing some modals, marking datatable row as source of context menu, fixing tests

* ACS-3757 Run lint

* ACS-3757 Updated documentation

* ACS-3757 Added unit tests

* ACS-3757 Replaced toHaveBeenCalledWith with toHaveBeenCalled and removed testing all falsy
This commit is contained in:
AleksanderSklorz 2022-12-13 11:55:46 +01:00 committed by GitHub
parent ef278bde79
commit c1cffa9cfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 164 additions and 11 deletions

View File

@ -17,6 +17,7 @@ Display a dialog that allows to upload new file version or to manage the current
Opens a dialog to upload new file version or to manage current node versions
- _data:_ [`NewVersionUploaderDialogData`](../../../lib/content-services/src/lib/new-version-uploader/models/new-version-uploader.model.ts) - The data to pass to the dialog
- _config:_ `MatDialogConfig` - A configuration object that allows to override default dialog configuration
- _selectorAutoFocusedOnClose:_ `string` - Element's selector which should be autofocused after closing modal
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html) - [`Observable`](http://reactivex.io/documentation/observable.html) which you can subscribe in order to get information about the dialog actions or error notification in case of error condition.
## Details

View File

@ -0,0 +1,77 @@
/*!
* @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.
*/
import { setupTestBed } from '@alfresco/adf-core';
import { TranslateModule } from '@ngx-translate/core';
import { ContentTestingModule } from '../../testing/content.testing.module';
import { DialogAspectListService } from '@alfresco/adf-content-services';
import { TestBed } from '@angular/core/testing';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
describe('DialogAspectListService', () => {
let dialogAspectListService: DialogAspectListService;
let dialog: MatDialog;
setupTestBed({
imports: [
TranslateModule.forRoot(),
ContentTestingModule
]
});
beforeEach(() => {
dialogAspectListService = TestBed.inject(DialogAspectListService);
dialog = TestBed.inject(MatDialog);
});
describe('openAspectListDialog', () => {
const elementToFocusSelector = 'button';
it('should focus element indicated by passed selector after closing modal', () => {
const afterClosed$ = new Subject<void>();
spyOn(dialog, 'open').and.returnValue({
afterClosed: () => afterClosed$.asObservable()
} as MatDialogRef<any>);
const elementToFocus = document.createElement(elementToFocusSelector);
spyOn(elementToFocus, 'focus');
spyOn(document, 'querySelector').withArgs(elementToFocusSelector).and.returnValue(elementToFocus);
dialogAspectListService.openAspectListDialog('some-node-id', elementToFocusSelector);
afterClosed$.next();
expect(elementToFocus.focus).toHaveBeenCalled();
});
it('should not focus element indicated by passed selector if modal is not closed', () => {
const elementToFocus = document.createElement(elementToFocusSelector);
spyOn(elementToFocus, 'focus');
spyOn(document, 'querySelector').withArgs(elementToFocusSelector).and.returnValue(elementToFocus);
dialogAspectListService.openAspectListDialog('some-node-id', elementToFocusSelector);
expect(elementToFocus.focus).not.toHaveBeenCalled();
});
it('should not looking for element to focus if passed selector is empty string', () => {
const afterClosed$ = new Subject<void>();
spyOn(dialog, 'open').and.returnValue({
afterClosed: () => afterClosed$.asObservable()
} as MatDialogRef<any>);
spyOn(document, 'querySelector');
dialogAspectListService.openAspectListDialog('some-node-id', '');
afterClosed$.next();
expect(document.querySelector).not.toHaveBeenCalled();
});
});
});

View File

@ -30,7 +30,7 @@ export class DialogAspectListService {
constructor(private dialog: MatDialog, private overlayContainer: OverlayContainer) {
}
openAspectListDialog(nodeId?: string): Observable<string[]> {
openAspectListDialog(nodeId?: string, selectorAutoFocusedOnClose?: string): Observable<string[]> {
const select = new Subject<string[]>();
select.subscribe({
complete: this.close.bind(this)
@ -44,18 +44,19 @@ export class DialogAspectListService {
nodeId
};
this.openDialog(data, 'adf-aspect-list-dialog', '750px');
this.openDialog(data, 'adf-aspect-list-dialog', '750px', selectorAutoFocusedOnClose);
return select;
}
private openDialog(data: AspectListDialogComponentData, panelClass: string, width: string) {
private openDialog(data: AspectListDialogComponentData, panelClass: string, width: string,
selectorAutoFocusedOnClose?: string) {
this.dialog.open(AspectListDialogComponent, {
data,
panelClass,
width,
role: 'dialog',
disableClose: true
});
}).afterClosed().subscribe(() => DialogAspectListService.focusOnClose(selectorAutoFocusedOnClose));
this.overlayContainer.getContainerElement().setAttribute('role', 'main');
}
@ -63,4 +64,10 @@ export class DialogAspectListService {
this.dialog.closeAll();
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
}
private static focusOnClose(selectorAutoFocusedOnClose: string): void {
if (selectorAutoFocusedOnClose) {
document.querySelector<HTMLElement>(selectorAutoFocusedOnClose).focus();
}
}
}

View File

@ -19,7 +19,7 @@ import { MinimalNode } from '@alfresco/js-api';
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { AlfrescoApiService, CardViewUpdateService, NodesApiService, setupTestBed } from '@alfresco/adf-core';
import { of } from 'rxjs';
import { EMPTY, of } from 'rxjs';
import { ContentTestingModule } from '../../testing/content.testing.module';
import { NodeAspectService } from './node-aspect.service';
import { DialogAspectListService } from './dialog-aspect-list.service';
@ -47,11 +47,26 @@ describe('NodeAspectService', () => {
cardViewUpdateService = TestBed.inject(CardViewUpdateService);
});
it('should call openAspectListDialog with correct parameters when selectorAutoFocusedOnClose is passed', () => {
spyOn(dialogAspectListService, 'openAspectListDialog').and.returnValue(EMPTY);
const nodeId = 'some node id';
const selector = 'some-selector';
nodeAspectService.updateNodeAspects(nodeId, selector);
expect(dialogAspectListService.openAspectListDialog).toHaveBeenCalledWith(nodeId, selector);
});
it('should call openAspectListDialog with correct parameters when selectorAutoFocusedOnClose is not passed', () => {
spyOn(dialogAspectListService, 'openAspectListDialog').and.returnValue(EMPTY);
const nodeId = 'some node id';
nodeAspectService.updateNodeAspects(nodeId);
expect(dialogAspectListService.openAspectListDialog).toHaveBeenCalledWith(nodeId, undefined);
});
it('should open the aspect list dialog', () => {
spyOn(dialogAspectListService, 'openAspectListDialog').and.returnValue(of([]));
spyOn(nodeApiService, 'updateNode').and.returnValue(of(null));
nodeAspectService.updateNodeAspects('fake-node-id');
expect(dialogAspectListService.openAspectListDialog).toHaveBeenCalledWith('fake-node-id');
expect(dialogAspectListService.openAspectListDialog).toHaveBeenCalledWith('fake-node-id', undefined);
});
it('should update the node when the aspect dialog apply the changes', () => {

View File

@ -30,8 +30,8 @@ export class NodeAspectService {
private cardViewUpdateService: CardViewUpdateService) {
}
updateNodeAspects(nodeId: string) {
this.dialogAspectListService.openAspectListDialog(nodeId).subscribe((aspectList) => {
updateNodeAspects(nodeId: string, selectorAutoFocusedOnClose?: string) {
this.dialogAspectListService.openAspectListDialog(nodeId, selectorAutoFocusedOnClose).subscribe((aspectList) => {
this.nodesApiService.updateNode(nodeId, { aspectNames: [...aspectList] }).subscribe((updatedNode) => {
this.alfrescoApiService.nodeUpdated.next(updatedNode);
this.cardViewUpdateService.updateNodeAspect(updatedNode);

View File

@ -6,6 +6,7 @@
type="text"
placeholder="{{'NODE_SELECTOR.SEARCH' | translate}}"
[value]="searchTerm"
adf-auto-focus
data-automation-id="content-node-selector-search-input">
<mat-icon *ngIf="searchTerm.length > 0"

View File

@ -284,6 +284,21 @@ describe('NewVersionUploaderService', () => {
});
});
it('should focus element indicated by passed selector after closing modal', (done) => {
dialogRefSpyObj.componentInstance.dialogAction = new BehaviorSubject<VersionManagerUploadData>(mockNewVersionUploaderData);
const afterClosed$ = new BehaviorSubject<void>(undefined);
dialogRefSpyObj.afterClosed = () => afterClosed$;
const elementToFocusSelector = 'button';
const elementToFocus = document.createElement(elementToFocusSelector);
spyOn(elementToFocus, 'focus').and.callFake(() => {
expect(elementToFocus.focus).toHaveBeenCalled();
done();
});
spyOn(document, 'querySelector').and.returnValue(elementToFocus);
service.openUploadNewVersionDialog(mockNewVersionUploaderDialogData, undefined, elementToFocusSelector)
.subscribe();
});
});
});

View File

@ -51,9 +51,10 @@ export class NewVersionUploaderService {
*
* @param data data to pass to MatDialog
* @param config allow to override default MatDialogConfig
* @param selectorAutoFocusedOnClose element's selector which should be autofocused after closing modal
* @returns an Observable represents the triggered dialog action or an error in case of an error condition
*/
openUploadNewVersionDialog(data: NewVersionUploaderDialogData, config?: MatDialogConfig) {
openUploadNewVersionDialog(data: NewVersionUploaderDialogData, config?: MatDialogConfig, selectorAutoFocusedOnClose?: string) {
const { file, node, showVersionsOnly } = data;
const showComments = true;
const allowDownload = true;
@ -76,6 +77,7 @@ export class NewVersionUploaderService {
});
dialogRef.afterClosed().subscribe(() => {
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
NewVersionUploaderService.focusOnClose(selectorAutoFocusedOnClose);
});
this.overlayContainer.getContainerElement().setAttribute('role', 'main');
});
@ -90,4 +92,10 @@ export class NewVersionUploaderService {
const dialogCssClass = 'adf-new-version-uploader-dialog';
return [dialogCssClass, `${dialogCssClass}-${showVersionsOnly ? 'list' : 'upload'}`];
}
private static focusOnClose(selectorAutoFocusedOnClose: string): void {
if (selectorAutoFocusedOnClose) {
document.querySelector<HTMLElement>(selectorAutoFocusedOnClose).focus();
}
}
}

View File

@ -153,7 +153,8 @@
[adf-upload-data]="row"
[ngStyle]="rowStyle"
[ngClass]="getRowStyle(row)"
[attr.data-automation-id]="'datatable-row-' + idx">
[attr.data-automation-id]="'datatable-row-' + idx"
(contextmenu)="markRowAsContextMenuSource(row)">
<!-- Actions (left) -->
<div *ngIf="actions && actionsPosition === 'left'" role="gridcell" class="adf-datatable-cell">
<button mat-icon-button [matMenuTriggerFor]="menu" #actionsMenuTrigger="matMenuTrigger"

View File

@ -1783,4 +1783,25 @@ describe('Show/hide columns', () => {
const headerCells = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell--text.adf-datatable-cell-header');
expect(headerCells.length).toBe(1);
});
describe('markRowAsContextMenuSource', () => {
it('should set isContextMenuSource to false for all rows returned by getRows function', () => {
const rows = [{
isContextMenuSource: true
}, {
isContextMenuSource: true
}] as DataRow[];
spyOn(dataTable.data, 'getRows').and.returnValue(rows);
dataTable.markRowAsContextMenuSource({} as DataRow);
rows.forEach((row) => expect(row.isContextMenuSource).toBeFalse());
});
it('should set isContextMenuSource to true for passed row', () => {
const row = {
isContextMenuSource: false
} as DataRow;
dataTable.markRowAsContextMenuSource(row);
expect(row.isContextMenuSource).toBeTrue();
});
});
});

View File

@ -753,7 +753,13 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
getRowStyle(row: DataRow): string {
row.cssClass = row.cssClass ? row.cssClass : '';
this.rowStyleClass = this.rowStyleClass ? this.rowStyleClass : '';
return `${row.cssClass} ${this.rowStyleClass}`;
const contextMenuSourceClass = row.isContextMenuSource ? 'adf-context-menu-source' : '';
return `${row.cssClass} ${this.rowStyleClass} ${contextMenuSourceClass}`;
}
markRowAsContextMenuSource(selectedRow: DataRow): void {
this.data.getRows().forEach((row) => row.isContextMenuSource = false);
selectedRow.isContextMenuSource = true;
}
getSortingKey(): string | null {

View File

@ -22,6 +22,7 @@ export interface DataRow {
isDropTarget?: boolean;
cssClass?: string;
id?: string;
isContextMenuSource?: boolean;
hasValue(key: string): boolean;