mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-05-12 17:04:57 +00:00
[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:
parent
ef278bde79
commit
c1cffa9cfb
@ -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
|
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
|
- _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
|
- _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.
|
- **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
|
## Details
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -30,7 +30,7 @@ export class DialogAspectListService {
|
|||||||
constructor(private dialog: MatDialog, private overlayContainer: OverlayContainer) {
|
constructor(private dialog: MatDialog, private overlayContainer: OverlayContainer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
openAspectListDialog(nodeId?: string): Observable<string[]> {
|
openAspectListDialog(nodeId?: string, selectorAutoFocusedOnClose?: string): Observable<string[]> {
|
||||||
const select = new Subject<string[]>();
|
const select = new Subject<string[]>();
|
||||||
select.subscribe({
|
select.subscribe({
|
||||||
complete: this.close.bind(this)
|
complete: this.close.bind(this)
|
||||||
@ -44,18 +44,19 @@ export class DialogAspectListService {
|
|||||||
nodeId
|
nodeId
|
||||||
};
|
};
|
||||||
|
|
||||||
this.openDialog(data, 'adf-aspect-list-dialog', '750px');
|
this.openDialog(data, 'adf-aspect-list-dialog', '750px', selectorAutoFocusedOnClose);
|
||||||
return select;
|
return select;
|
||||||
}
|
}
|
||||||
|
|
||||||
private openDialog(data: AspectListDialogComponentData, panelClass: string, width: string) {
|
private openDialog(data: AspectListDialogComponentData, panelClass: string, width: string,
|
||||||
|
selectorAutoFocusedOnClose?: string) {
|
||||||
this.dialog.open(AspectListDialogComponent, {
|
this.dialog.open(AspectListDialogComponent, {
|
||||||
data,
|
data,
|
||||||
panelClass,
|
panelClass,
|
||||||
width,
|
width,
|
||||||
role: 'dialog',
|
role: 'dialog',
|
||||||
disableClose: true
|
disableClose: true
|
||||||
});
|
}).afterClosed().subscribe(() => DialogAspectListService.focusOnClose(selectorAutoFocusedOnClose));
|
||||||
this.overlayContainer.getContainerElement().setAttribute('role', 'main');
|
this.overlayContainer.getContainerElement().setAttribute('role', 'main');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,4 +64,10 @@ export class DialogAspectListService {
|
|||||||
this.dialog.closeAll();
|
this.dialog.closeAll();
|
||||||
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
|
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static focusOnClose(selectorAutoFocusedOnClose: string): void {
|
||||||
|
if (selectorAutoFocusedOnClose) {
|
||||||
|
document.querySelector<HTMLElement>(selectorAutoFocusedOnClose).focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,7 +19,7 @@ import { MinimalNode } from '@alfresco/js-api';
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { AlfrescoApiService, CardViewUpdateService, NodesApiService, setupTestBed } from '@alfresco/adf-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 { ContentTestingModule } from '../../testing/content.testing.module';
|
||||||
import { NodeAspectService } from './node-aspect.service';
|
import { NodeAspectService } from './node-aspect.service';
|
||||||
import { DialogAspectListService } from './dialog-aspect-list.service';
|
import { DialogAspectListService } from './dialog-aspect-list.service';
|
||||||
@ -47,11 +47,26 @@ describe('NodeAspectService', () => {
|
|||||||
cardViewUpdateService = TestBed.inject(CardViewUpdateService);
|
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', () => {
|
it('should open the aspect list dialog', () => {
|
||||||
spyOn(dialogAspectListService, 'openAspectListDialog').and.returnValue(of([]));
|
spyOn(dialogAspectListService, 'openAspectListDialog').and.returnValue(of([]));
|
||||||
spyOn(nodeApiService, 'updateNode').and.returnValue(of(null));
|
spyOn(nodeApiService, 'updateNode').and.returnValue(of(null));
|
||||||
nodeAspectService.updateNodeAspects('fake-node-id');
|
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', () => {
|
it('should update the node when the aspect dialog apply the changes', () => {
|
||||||
|
@ -30,8 +30,8 @@ export class NodeAspectService {
|
|||||||
private cardViewUpdateService: CardViewUpdateService) {
|
private cardViewUpdateService: CardViewUpdateService) {
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNodeAspects(nodeId: string) {
|
updateNodeAspects(nodeId: string, selectorAutoFocusedOnClose?: string) {
|
||||||
this.dialogAspectListService.openAspectListDialog(nodeId).subscribe((aspectList) => {
|
this.dialogAspectListService.openAspectListDialog(nodeId, selectorAutoFocusedOnClose).subscribe((aspectList) => {
|
||||||
this.nodesApiService.updateNode(nodeId, { aspectNames: [...aspectList] }).subscribe((updatedNode) => {
|
this.nodesApiService.updateNode(nodeId, { aspectNames: [...aspectList] }).subscribe((updatedNode) => {
|
||||||
this.alfrescoApiService.nodeUpdated.next(updatedNode);
|
this.alfrescoApiService.nodeUpdated.next(updatedNode);
|
||||||
this.cardViewUpdateService.updateNodeAspect(updatedNode);
|
this.cardViewUpdateService.updateNodeAspect(updatedNode);
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="{{'NODE_SELECTOR.SEARCH' | translate}}"
|
placeholder="{{'NODE_SELECTOR.SEARCH' | translate}}"
|
||||||
[value]="searchTerm"
|
[value]="searchTerm"
|
||||||
|
adf-auto-focus
|
||||||
data-automation-id="content-node-selector-search-input">
|
data-automation-id="content-node-selector-search-input">
|
||||||
|
|
||||||
<mat-icon *ngIf="searchTerm.length > 0"
|
<mat-icon *ngIf="searchTerm.length > 0"
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -51,9 +51,10 @@ export class NewVersionUploaderService {
|
|||||||
*
|
*
|
||||||
* @param data data to pass to MatDialog
|
* @param data data to pass to MatDialog
|
||||||
* @param config allow to override default MatDialogConfig
|
* @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
|
* @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 { file, node, showVersionsOnly } = data;
|
||||||
const showComments = true;
|
const showComments = true;
|
||||||
const allowDownload = true;
|
const allowDownload = true;
|
||||||
@ -76,6 +77,7 @@ export class NewVersionUploaderService {
|
|||||||
});
|
});
|
||||||
dialogRef.afterClosed().subscribe(() => {
|
dialogRef.afterClosed().subscribe(() => {
|
||||||
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
|
this.overlayContainer.getContainerElement().setAttribute('role', 'region');
|
||||||
|
NewVersionUploaderService.focusOnClose(selectorAutoFocusedOnClose);
|
||||||
});
|
});
|
||||||
this.overlayContainer.getContainerElement().setAttribute('role', 'main');
|
this.overlayContainer.getContainerElement().setAttribute('role', 'main');
|
||||||
});
|
});
|
||||||
@ -90,4 +92,10 @@ export class NewVersionUploaderService {
|
|||||||
const dialogCssClass = 'adf-new-version-uploader-dialog';
|
const dialogCssClass = 'adf-new-version-uploader-dialog';
|
||||||
return [dialogCssClass, `${dialogCssClass}-${showVersionsOnly ? 'list' : 'upload'}`];
|
return [dialogCssClass, `${dialogCssClass}-${showVersionsOnly ? 'list' : 'upload'}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static focusOnClose(selectorAutoFocusedOnClose: string): void {
|
||||||
|
if (selectorAutoFocusedOnClose) {
|
||||||
|
document.querySelector<HTMLElement>(selectorAutoFocusedOnClose).focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -153,7 +153,8 @@
|
|||||||
[adf-upload-data]="row"
|
[adf-upload-data]="row"
|
||||||
[ngStyle]="rowStyle"
|
[ngStyle]="rowStyle"
|
||||||
[ngClass]="getRowStyle(row)"
|
[ngClass]="getRowStyle(row)"
|
||||||
[attr.data-automation-id]="'datatable-row-' + idx">
|
[attr.data-automation-id]="'datatable-row-' + idx"
|
||||||
|
(contextmenu)="markRowAsContextMenuSource(row)">
|
||||||
<!-- Actions (left) -->
|
<!-- Actions (left) -->
|
||||||
<div *ngIf="actions && actionsPosition === 'left'" role="gridcell" class="adf-datatable-cell">
|
<div *ngIf="actions && actionsPosition === 'left'" role="gridcell" class="adf-datatable-cell">
|
||||||
<button mat-icon-button [matMenuTriggerFor]="menu" #actionsMenuTrigger="matMenuTrigger"
|
<button mat-icon-button [matMenuTriggerFor]="menu" #actionsMenuTrigger="matMenuTrigger"
|
||||||
|
@ -1783,4 +1783,25 @@ describe('Show/hide columns', () => {
|
|||||||
const headerCells = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell--text.adf-datatable-cell-header');
|
const headerCells = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell--text.adf-datatable-cell-header');
|
||||||
expect(headerCells.length).toBe(1);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -753,7 +753,13 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
|
|||||||
getRowStyle(row: DataRow): string {
|
getRowStyle(row: DataRow): string {
|
||||||
row.cssClass = row.cssClass ? row.cssClass : '';
|
row.cssClass = row.cssClass ? row.cssClass : '';
|
||||||
this.rowStyleClass = this.rowStyleClass ? this.rowStyleClass : '';
|
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 {
|
getSortingKey(): string | null {
|
||||||
|
@ -22,6 +22,7 @@ export interface DataRow {
|
|||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
cssClass?: string;
|
cssClass?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
isContextMenuSource?: boolean;
|
||||||
|
|
||||||
hasValue(key: string): boolean;
|
hasValue(key: string): boolean;
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user