From 75b1d605f4f2ec7dfb935ed2fc9ef11a16af643b Mon Sep 17 00:00:00 2001 From: Muskan Khedia <36980003+muskankhedia@users.noreply.github.com> Date: Mon, 10 Aug 2020 20:04:33 +0530 Subject: [PATCH] feat: add keyboard shortcuts (#1191) - implement keyboard shortcuts - designed user interface to view them Fixes: #257 --- .../list-standing-instructions.component.ts | 1 - .../core/shell/sidenav/sidenav.component.html | 6 + .../core/shell/sidenav/sidenav.component.ts | 4 + .../core/shell/toolbar/toolbar.component.html | 6 +- .../core/shell/toolbar/toolbar.component.ts | 11 +- src/app/keyboards-shortcut-config.ts | 203 ++++++++++++++++++ .../run-report/run-report.component.html | 2 +- .../xbrl-report/xbrl-report.component.html | 2 +- .../keyboard-shortcuts-dialog.component.html | 18 ++ .../keyboard-shortcuts-dialog.component.scss | 11 + ...eyboard-shortcuts-dialog.component.spec.ts | 25 +++ .../keyboard-shortcuts-dialog.component.ts | 28 +++ src/app/shared/shared.module.ts | 7 +- src/app/web-app.component.ts | 59 ++++- src/assets/images/keyboard_placeholder.png | Bin 0 -> 4130 bytes 15 files changed, 373 insertions(+), 10 deletions(-) create mode 100644 src/app/keyboards-shortcut-config.ts create mode 100644 src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.html create mode 100644 src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss create mode 100644 src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.spec.ts create mode 100644 src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts create mode 100644 src/assets/images/keyboard_placeholder.png diff --git a/src/app/account-transfers/list-standing-instructions/list-standing-instructions.component.ts b/src/app/account-transfers/list-standing-instructions/list-standing-instructions.component.ts index b0e4f0aff..ae476ad0b 100644 --- a/src/app/account-transfers/list-standing-instructions/list-standing-instructions.component.ts +++ b/src/app/account-transfers/list-standing-instructions/list-standing-instructions.component.ts @@ -68,7 +68,6 @@ export class ListStandingInstructionsComponent implements OnInit { this.standingIntructionsTemplateData = data.standingIntructionsTemplate; if (data.standingIntructionsTemplate.fromClient) { this.clientName = this.standingIntructionsTemplateData.fromClient.displayName; - console.log('here'); this.getStandingInstructions(); } this.setParams(); diff --git a/src/app/core/shell/sidenav/sidenav.component.html b/src/app/core/shell/sidenav/sidenav.component.html index 098736766..4464ae862 100644 --- a/src/app/core/shell/sidenav/sidenav.component.html +++ b/src/app/core/shell/sidenav/sidenav.component.html @@ -97,6 +97,12 @@ Chart of Accounts + + + + + Help + diff --git a/src/app/core/shell/sidenav/sidenav.component.ts b/src/app/core/shell/sidenav/sidenav.component.ts index 917ac279f..3f55f0ae0 100644 --- a/src/app/core/shell/sidenav/sidenav.component.ts +++ b/src/app/core/shell/sidenav/sidenav.component.ts @@ -43,4 +43,8 @@ export class SidenavComponent implements OnInit { .subscribe(() => this.router.navigate(['/login'], { replaceUrl: true })); } + help() { + window.open('https://mifosforge.jira.com/wiki/spaces/docs/pages/52035622/User+Manual', '_blank'); + } + } diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html index 39e584f33..f947bf1ad 100644 --- a/src/app/core/shell/toolbar/toolbar.component.html +++ b/src/app/core/shell/toolbar/toolbar.component.html @@ -49,6 +49,10 @@ + + + + @@ -100,7 +104,7 @@ Settings - + diff --git a/src/app/core/shell/toolbar/toolbar.component.ts b/src/app/core/shell/toolbar/toolbar.component.ts index 5f5266716..3d164d5c4 100644 --- a/src/app/core/shell/toolbar/toolbar.component.ts +++ b/src/app/core/shell/toolbar/toolbar.component.ts @@ -1,6 +1,6 @@ /** Angular Imports */ import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core'; -import { MatSidenav } from '@angular/material'; +import { MatSidenav, MatDialog } from '@angular/material'; import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; import { Router } from '@angular/router'; @@ -10,6 +10,7 @@ import { map } from 'rxjs/operators'; /** Custom Services */ import { AuthenticationService } from '../../authentication/authentication.service'; +import { KeyboardShortcutsDialogComponent } from 'app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component'; /** * Toolbar component. @@ -42,7 +43,8 @@ export class ToolbarComponent implements OnInit { */ constructor(private breakpointObserver: BreakpointObserver, private router: Router, - private authenticationService: AuthenticationService) { } + private authenticationService: AuthenticationService, + private dialog: MatDialog) { } /** * Subscribes to breakpoint for handset. @@ -78,4 +80,9 @@ export class ToolbarComponent implements OnInit { .subscribe(() => this.router.navigate(['/login'], { replaceUrl: true })); } + showKeyboardShortcuts() { + const dialogRef = this.dialog.open(KeyboardShortcutsDialogComponent); + dialogRef.afterClosed().subscribe((response: any) => {}); + } + } diff --git a/src/app/keyboards-shortcut-config.ts b/src/app/keyboards-shortcut-config.ts new file mode 100644 index 000000000..98355d9f4 --- /dev/null +++ b/src/app/keyboards-shortcut-config.ts @@ -0,0 +1,203 @@ +/** Keyboard Shortcuts Button Configuration */ +export class KeyboardShortcutsConfiguration { + + buttonsArray: { + title: string, + ctrlKey: boolean, + shiftKey: boolean, + altKey: boolean, + key: string, + route: string, + id?: string + }[]; + + constructor() { + this.makeCombination(); + } + + makeCombination() { + this.buttonsArray = [ + // Navigation Page - alt + n + { + title: 'Navigation Page', + ctrlKey: false, + shiftKey: false, + altKey: true, + key: 'n', + route: 'navigation' + }, + // Run Report - alt + r + { + title: 'Run Report', + ctrlKey: false, + shiftKey: false, + altKey: true, + key: 'r', + route: '', + id: 'runReport' + }, + // Checker Inbox & Pending Tasks - ctrl + alt + i + // { + // title: 'Checker Inbox & Pending Tasks', + // ctrlKey: true, + // shiftKey: false, + // altKey: true, + // key: 'i, + // route: '' + // }, + // Collection Sheet- ctrl + alt + o + // { + // title: 'Collection Sheet', + // ctrlKey: true, + // shiftKey: false, + // altKey: true, + // key: 'o, + // route: '' + // }, + // Create Client - ctrl + alt + c + { + title: 'Create Client', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'c', + route: 'clients/create' + }, + // Create Group - ctrl + alt + g + { + title: 'Create Group', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'g', + route: 'groups/create' + }, + // Create Center - ctrl + alt + q + { + title: 'Create Center', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'q', + route: 'centers/create' + }, + // Frequent Posting - ctrl + alt + f + { + title: 'Frequent Posting', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'f', + route: 'accounting/journal-entries/frequent-postings' + }, + // Closure Entries - ctrl + alt + e + { + title: 'Closure Entries', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'e', + route: 'accounting/closing-entries' + }, + // Journal Entry - ctrl + alt + j + { + title: 'Journal Entry', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'j', + route: 'accounting/journal-entries/create' + }, + // Reports - ctrl + alt + r + { + title: 'Reports', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'r', + route: 'reports' + }, + // Accounting - ctrl + alt + a + { + title: 'Accounting', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'a', + route: 'accounting' + }, + // Save/Submit Forms - ctrl + alt + s + { + title: 'Save/Submit Forms', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 's', + route: '', + id: 'submit' + }, + // Cancel - ctrl + alt + x + { + title: 'Cancel', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'x', + route: '', + id: 'cancel' + }, + // Search - alt + x + // { + // title: 'Search' + // ctrlKey: true, + // shiftKey: false, + // altKey: true, + // key: 88, + // route: '' + // }, + // Help - ctrl + alt + h + { + title: 'Help', + ctrlKey: true, + shiftKey: false, + altKey: true, + key: 'h', + route: '', + id: 'help' + }, + // Pagination: Next - ctrl + n + // { + // title: 'Pagination: Next' + // ctrlKey: true, + // shiftKey: false, + // altKey: true, + // key: 'n, + // route: '' + // }, + // Pagination: Previous - ctrl + p + // { + // title: 'Pagination: Previous' + // ctrlKey: true, + // shiftKey: false, + // altKey: true, + // key: 'p, + // route: '' + // }, + // Logout - ctrl + shift + l + { + title: 'Logout', + ctrlKey: true, + shiftKey: true, + altKey: false, + key: 'l', + route: '', + id: 'logout' + } + ]; + } + + get buttonCombinations() { + return this.buttonsArray; + } + +} diff --git a/src/app/reports/run-report/run-report.component.html b/src/app/reports/run-report/run-report.component.html index 27fa3ed18..c8d8f433a 100644 --- a/src/app/reports/run-report/run-report.component.html +++ b/src/app/reports/run-report/run-report.component.html @@ -80,7 +80,7 @@ Cancel - + Run Report diff --git a/src/app/reports/xbrl-report/xbrl-report.component.html b/src/app/reports/xbrl-report/xbrl-report.component.html index e59125996..e58b3a1e6 100644 --- a/src/app/reports/xbrl-report/xbrl-report.component.html +++ b/src/app/reports/xbrl-report/xbrl-report.component.html @@ -24,7 +24,7 @@ Cancel - Run Report + Run Report diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.html b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.html new file mode 100644 index 000000000..238a57038 --- /dev/null +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.html @@ -0,0 +1,18 @@ +Keyboard Shortcuts + + + + + + + Ctrl + + Shift + + Alt + + {{ item.key }} + + {{ item.title }} + + + + + diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss new file mode 100644 index 000000000..a45d113be --- /dev/null +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.scss @@ -0,0 +1,11 @@ +.modifierKey { + border: 1px solid #0000004d; + padding: 0.3rem; + background: #f8f8f8; + font-weight: bold; + border-radius: 0.3rem; +} + +.mat-divider.mat-divider-inset { + margin-left: 0px; +} diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.spec.ts b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.spec.ts new file mode 100644 index 000000000..d5fbea511 --- /dev/null +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog.component'; + +describe('KeyboardShortcutsDialogComponent', () => { + let component: KeyboardShortcutsDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ KeyboardShortcutsDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KeyboardShortcutsDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts new file mode 100644 index 000000000..f3bd2a90b --- /dev/null +++ b/src/app/shared/keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component.ts @@ -0,0 +1,28 @@ +/** Angular Imports */ +import { Component, OnInit, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material'; +import { KeyboardShortcutsConfiguration } from '../../keyboards-shortcut-config'; +/** + * Delete dialog component. + */ +@Component({ + selector: 'mifosx-keyboard-shortcuts-dialog', + templateUrl: './keyboard-shortcuts-dialog.component.html', + styleUrls: ['./keyboard-shortcuts-dialog.component.scss'] +}) +export class KeyboardShortcutsDialogComponent implements OnInit { + + buttonConfig: KeyboardShortcutsConfiguration; + + /** + * @param {MatDialogRef} dialogRef Component reference to dialog. + * @param {any} data Provides a deleteContext. + */ + constructor(public dialogRef: MatDialogRef) { } + + ngOnInit() { + this.dialogRef.updateSize(`800px`); + this.buttonConfig = new KeyboardShortcutsConfiguration(); + } + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 9ad6b1d6f..2e23e0cbb 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -15,6 +15,7 @@ import { ChangePasswordDialogComponent } from './change-password-dialog/change-p import { EnableDialogComponent } from './enable-dialog/enable-dialog.component'; import { DisableDialogComponent } from './disable-dialog/disable-dialog.component'; import { ConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component'; +import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component'; /** Custom Modules */ import { IconsModule } from './icons.module'; @@ -43,7 +44,8 @@ import { MaterialModule } from './material.module'; ChangePasswordDialogComponent, EnableDialogComponent, DisableDialogComponent, - ConfirmationDialogComponent + ConfirmationDialogComponent, + KeyboardShortcutsDialogComponent ], exports: [ FileUploadComponent, @@ -62,7 +64,8 @@ import { MaterialModule } from './material.module'; ChangePasswordDialogComponent, EnableDialogComponent, DisableDialogComponent, - ConfirmationDialogComponent + ConfirmationDialogComponent, + KeyboardShortcutsDialogComponent ] }) export class SharedModule { } diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index 9c98cdbf6..c86333ce7 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -1,5 +1,5 @@ /** Angular Imports */ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, HostListener } from '@angular/core'; import { Router, NavigationEnd, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material'; @@ -19,9 +19,11 @@ import { Logger } from './core/logger/logger.service'; import { I18nService } from './core/i18n/i18n.service'; import { ThemeStorageService } from './shared/theme-picker/theme-storage.service'; import { AlertService } from './core/alert/alert.service'; +import { KeyboardShortcutsConfiguration } from './keyboards-shortcut-config'; /** Custom Models */ import { Alert } from './core/alert/alert.model'; +import { AuthenticationService } from './core/authentication/authentication.service'; /** Initialize Logger */ const log = new Logger('MifosX'); @@ -36,6 +38,8 @@ const log = new Logger('MifosX'); }) export class WebAppComponent implements OnInit { + buttonConfig: KeyboardShortcutsConfiguration; + /** * @param {Router} router Router for navigation. * @param {ActivatedRoute} activatedRoute Activated Route. @@ -45,6 +49,7 @@ export class WebAppComponent implements OnInit { * @param {ThemeStorageService} themeStorageService Theme Storage Service. * @param {MatSnackBar} snackBar Material Snackbar for notifications. * @param {AlertService} alertService Alert Service. + * @param {AuthenticationService} authenticationService Authentication service. */ constructor(private router: Router, private activatedRoute: ActivatedRoute, @@ -53,7 +58,8 @@ export class WebAppComponent implements OnInit { private i18nService: I18nService, private themeStorageService: ThemeStorageService, public snackBar: MatSnackBar, - private alertService: AlertService) { } + private alertService: AlertService, + private authenticationService: AuthenticationService) { } /** * Initial Setup: @@ -126,6 +132,55 @@ export class WebAppComponent implements OnInit { verticalPosition: 'top' }); }); + this.buttonConfig = new KeyboardShortcutsConfiguration(); + } + + logout() { + this.authenticationService.logout() + .subscribe(() => this.router.navigate(['/login'], { replaceUrl: true })); + } + + help() { + window.open('https://mifosforge.jira.com/wiki/spaces/docs/pages/52035622/User+Manual', '_blank'); + } + + // Monitor all keyboard events and excute keyboard shortcuts + @HostListener('window:keydown', ['$event']) + onKeydownHandler(event: KeyboardEvent) { + const routeD = this.buttonConfig.buttonCombinations.find(x => (x.ctrlKey === event.ctrlKey && x.shiftKey === event.shiftKey && x.altKey === event.altKey && x.key === event.key)); + if (!(routeD === undefined)) { + switch (routeD.id) { + case 'logout': + this.logout(); + break; + case 'help': + this.help(); + break; + case 'runReport': + document.getElementById('runReport').click(); + break; + case 'cancel': + const cancelButtons = document.querySelectorAll('button'); + const filteredcancelButtons = Array.prototype.filter.call(cancelButtons, function (el: any) { + return el.textContent.trim() === 'Cancel'; + }); + if (filteredcancelButtons.length > 0) { + filteredcancelButtons[0].click(); + } + break; + case 'submit': + const submitButton = document.querySelectorAll('button'); + const filteredSubmitButton = Array.prototype.filter.call(submitButton, function (el: any) { + return el.textContent.trim() === 'Submit'; + }); + if (filteredSubmitButton.length > 0) { + filteredSubmitButton[0].click(); + } + break; + default: + this.router.navigate([routeD.route], { relativeTo: this.activatedRoute }); + } + } } } diff --git a/src/assets/images/keyboard_placeholder.png b/src/assets/images/keyboard_placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..8570ba12f2cbd885edeafcce7823ec53f85b45f7 GIT binary patch literal 4130 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 c>N(W-JAWX$!|449Y)*JvS$a
!Eca#ts&M$v#hzx}u c>N(W-JAWX$!|449Y)*JvS$a