331 lines
9.9 KiB
TypeScript
331 lines
9.9 KiB
TypeScript
/**
|
|
* Copyright since 2025 Mifos Initiative
|
|
*
|
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
*/
|
|
|
|
import { Component, OnInit, inject, ViewChild } from '@angular/core';
|
|
import {
|
|
MatDialogRef,
|
|
MatDialogTitle,
|
|
MatDialogContent,
|
|
MatDialogActions,
|
|
MatDialog,
|
|
MatDialogClose,
|
|
MAT_DIALOG_DATA
|
|
} from '@angular/material/dialog';
|
|
import { CdkScrollable } from '@angular/cdk/scrolling';
|
|
import { MatDivider } from '@angular/material/divider';
|
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
import { KeyboardShortcutService, ShortcutItem } from '../../core/services/keyboard-shortcut.service';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { NgClass } from '@angular/common';
|
|
import { MatButton } from '@angular/material/button';
|
|
import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component';
|
|
import { ShortcutUsageDialogComponent } from '../shortcut-usage-dialog/shortcut-usage-dialog.component';
|
|
import { MatIcon } from '@angular/material/icon';
|
|
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
|
import { MatInput } from '@angular/material/input';
|
|
import { MatSelect } from '@angular/material/select';
|
|
import { MatOption } from '@angular/material/core';
|
|
|
|
@Component({
|
|
selector: 'mifosx-keyboard-shortcuts-dialog',
|
|
templateUrl: './keyboard-shortcuts-dialog.component.html',
|
|
styleUrls: ['./keyboard-shortcuts-dialog.component.scss'],
|
|
imports: [
|
|
...STANDALONE_SHARED_IMPORTS,
|
|
MatDialogTitle,
|
|
CdkScrollable,
|
|
MatDialogContent,
|
|
MatDialogActions,
|
|
MatDialogClose,
|
|
MatButton,
|
|
FormsModule,
|
|
MatIcon,
|
|
MatFormField,
|
|
MatLabel,
|
|
MatInput,
|
|
MatSelect,
|
|
MatOption
|
|
]
|
|
})
|
|
export class KeyboardShortcutsDialogComponent implements OnInit {
|
|
dialogRef = inject<MatDialogRef<KeyboardShortcutsDialogComponent>>(MatDialogRef);
|
|
private keyboardShortcutService = inject(KeyboardShortcutService);
|
|
public data = inject(MAT_DIALOG_DATA, { optional: true });
|
|
|
|
shortcuts: ShortcutItem[] = [];
|
|
validationError: string | null = null;
|
|
activeInputItem: ShortcutItem | null = null;
|
|
displayedColumns: string[] = [
|
|
'title',
|
|
'keyCombination'
|
|
];
|
|
|
|
isAddingNew = false;
|
|
newShortcut: Partial<ShortcutItem> = {};
|
|
activeNewInput = false;
|
|
|
|
editingShortcutId: string | null = null;
|
|
editingShortcutData: Partial<ShortcutItem> = {};
|
|
activeEditInput = false;
|
|
|
|
ngOnInit() {
|
|
this.dialogRef.updateSize('800px');
|
|
this.keyboardShortcutService.activeShortcuts$.subscribe((shortcuts) => {
|
|
// Deep copy to allow editing without affecting the global state immediately
|
|
this.shortcuts = JSON.parse(JSON.stringify(shortcuts));
|
|
});
|
|
|
|
if (this.data && this.data.prefill) {
|
|
this.startAddShortcut();
|
|
this.newShortcut = {
|
|
...this.newShortcut,
|
|
...this.data.prefill
|
|
};
|
|
}
|
|
}
|
|
|
|
formatShortcut(item: Partial<ShortcutItem>): string {
|
|
if (!item || (!item.key && !item.ctrlKey && !item.shiftKey && !item.altKey)) return '';
|
|
const parts = [];
|
|
if (item.ctrlKey) parts.push('Ctrl');
|
|
if (item.shiftKey) parts.push('Shift');
|
|
if (item.altKey) parts.push('Alt');
|
|
if (item.key) parts.push(item.key.toUpperCase());
|
|
return parts.join(' + ');
|
|
}
|
|
|
|
onKeyDown(event: KeyboardEvent, item: Partial<ShortcutItem>, isNewOrEdit: boolean = false) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Ignore if only modifier keys are pressed
|
|
if ([
|
|
'Control',
|
|
'Shift',
|
|
'Alt',
|
|
'Meta'
|
|
].includes(event.key)) {
|
|
return;
|
|
}
|
|
|
|
item.ctrlKey = event.ctrlKey;
|
|
item.shiftKey = event.shiftKey;
|
|
item.altKey = event.altKey;
|
|
item.key = event.key.toLowerCase();
|
|
|
|
if (!isNewOrEdit) {
|
|
this.activeInputItem = null;
|
|
this.validateShortcuts();
|
|
} else {
|
|
this.activeNewInput = false;
|
|
this.activeEditInput = false;
|
|
}
|
|
}
|
|
|
|
validateShortcuts() {
|
|
this.validationError = null;
|
|
const seen = new Set<string>();
|
|
|
|
for (const item of this.shortcuts) {
|
|
if (!item.key) {
|
|
this.validationError = `Invalid shortcut for ${item.title}: Key is required`;
|
|
return;
|
|
}
|
|
|
|
const keyStr = `${!!item.ctrlKey}-${!!item.shiftKey}-${!!item.altKey}-${item.key.toLowerCase()}`;
|
|
if (seen.has(keyStr)) {
|
|
this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(item)}`;
|
|
return;
|
|
}
|
|
seen.add(keyStr);
|
|
}
|
|
}
|
|
|
|
startAddShortcut() {
|
|
this.isAddingNew = true;
|
|
this.newShortcut = {
|
|
title: '',
|
|
route: '',
|
|
key: '',
|
|
ctrlKey: false,
|
|
shiftKey: false,
|
|
altKey: false,
|
|
action: 'navigate',
|
|
custom: true,
|
|
id: 'custom_' + Date.now()
|
|
};
|
|
this.validationError = null;
|
|
}
|
|
|
|
cancelAddShortcut() {
|
|
this.isAddingNew = false;
|
|
this.validationError = null;
|
|
}
|
|
|
|
private dialog = inject(MatDialog);
|
|
|
|
@ViewChild('shortcutDetailsTemplate') shortcutDetailsTemplate: any;
|
|
private detailsDialogRef: MatDialogRef<any> | null = null;
|
|
|
|
showShortcutDetails(item: ShortcutItem) {
|
|
this.detailsDialogRef = this.dialog.open(this.shortcutDetailsTemplate, {
|
|
data: item,
|
|
width: '400px'
|
|
});
|
|
}
|
|
|
|
openUsagePopup() {
|
|
this.dialog.open(ShortcutUsageDialogComponent, {
|
|
width: '600px'
|
|
});
|
|
}
|
|
|
|
getShortcutDescription(item: Partial<ShortcutItem>): string {
|
|
if (item.custom) {
|
|
if (item.action === 'navigate') {
|
|
return `Navigates to the ${item.title} page.`;
|
|
}
|
|
return `Executes the ${item.action} action.`;
|
|
} else {
|
|
switch (item.id) {
|
|
case 'logout':
|
|
return 'Logs you out of the application securely.';
|
|
case 'help':
|
|
return 'Opens the user manual and help documentation.';
|
|
case 'runReport':
|
|
return 'Executes the currently selected report.';
|
|
case 'cancel':
|
|
return 'Cancels the current operation or form.';
|
|
case 'submit':
|
|
return 'Submits the current form or dialog.';
|
|
default:
|
|
return `Navigates to the predefined ${item.title} feature.`;
|
|
}
|
|
}
|
|
}
|
|
|
|
saveNewShortcut() {
|
|
if (this.newShortcut.action === 'navigate' && this.newShortcut.route) {
|
|
this.newShortcut.route = this.keyboardShortcutService.normalizeRouteStr(this.newShortcut.route);
|
|
}
|
|
if (this.validateSingleShortcut(this.newShortcut, null)) {
|
|
this.shortcuts.push(this.newShortcut as ShortcutItem);
|
|
this.isAddingNew = false;
|
|
this.validateShortcuts();
|
|
if (!this.validationError) {
|
|
this.keyboardShortcutService.updateShortcuts(this.shortcuts);
|
|
}
|
|
}
|
|
}
|
|
|
|
startEditShortcut(item: ShortcutItem) {
|
|
if (this.detailsDialogRef) {
|
|
this.detailsDialogRef.close();
|
|
this.detailsDialogRef = null;
|
|
}
|
|
this.editingShortcutId = item.id || null;
|
|
this.editingShortcutData = { ...item };
|
|
this.validationError = null;
|
|
}
|
|
|
|
cancelEditShortcut() {
|
|
this.editingShortcutId = null;
|
|
this.validationError = null;
|
|
}
|
|
|
|
saveEditShortcut() {
|
|
if (this.editingShortcutData.action === 'navigate' && this.editingShortcutData.route) {
|
|
this.editingShortcutData.route = this.keyboardShortcutService.normalizeRouteStr(this.editingShortcutData.route);
|
|
}
|
|
if (this.validateSingleShortcut(this.editingShortcutData, this.editingShortcutId)) {
|
|
const index = this.shortcuts.findIndex((s) => s.id === this.editingShortcutId);
|
|
if (index > -1) {
|
|
this.shortcuts[index] = { ...this.editingShortcutData } as ShortcutItem;
|
|
}
|
|
this.editingShortcutId = null;
|
|
this.validateShortcuts();
|
|
if (!this.validationError) {
|
|
this.keyboardShortcutService.updateShortcuts(this.shortcuts);
|
|
}
|
|
}
|
|
}
|
|
|
|
deleteShortcut(item: ShortcutItem) {
|
|
if (this.detailsDialogRef) {
|
|
this.detailsDialogRef.close();
|
|
this.detailsDialogRef = null;
|
|
}
|
|
this.shortcuts = this.shortcuts.filter((s) => (s.id ? s.id !== item.id : s.title !== item.title));
|
|
this.validateShortcuts();
|
|
if (!this.validationError) {
|
|
this.keyboardShortcutService.updateShortcuts(this.shortcuts);
|
|
}
|
|
}
|
|
|
|
validateSingleShortcut(item: Partial<ShortcutItem>, excludeId: string | null): boolean {
|
|
if (!item.title || item.title.trim() === '') {
|
|
this.validationError = 'Shortcut Name is required';
|
|
return false;
|
|
}
|
|
if (!item.key) {
|
|
this.validationError = 'Key combination is required';
|
|
return false;
|
|
}
|
|
if (item.action === 'navigate' && (!item.route || item.route.trim() === '')) {
|
|
this.validationError = 'URL / Route is required';
|
|
return false;
|
|
}
|
|
|
|
const keyStr = `${!!item.ctrlKey}-${!!item.shiftKey}-${!!item.altKey}-${item.key.toLowerCase()}`;
|
|
const seen = new Set<string>();
|
|
|
|
for (const s of this.shortcuts) {
|
|
if (excludeId && s.id === excludeId) continue;
|
|
seen.add(`${!!s.ctrlKey}-${!!s.shiftKey}-${!!s.altKey}-${s.key.toLowerCase()}`);
|
|
}
|
|
|
|
if (seen.has(keyStr)) {
|
|
this.validationError = `Duplicate shortcut detected: ${this.formatShortcut(item)}`;
|
|
return false;
|
|
}
|
|
|
|
this.validationError = null;
|
|
return true;
|
|
}
|
|
|
|
save() {
|
|
this.validateShortcuts();
|
|
if (!this.validationError) {
|
|
this.keyboardShortcutService.updateShortcuts(this.shortcuts);
|
|
this.dialogRef.close(true);
|
|
}
|
|
}
|
|
|
|
resetToDefaults() {
|
|
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
|
|
data: {
|
|
heading: 'Reset Keyboard Shortcuts',
|
|
dialogContext:
|
|
'Are you sure you want to reset all keyboard shortcuts to their default configuration? This will remove all custom shortcuts and restore default keys.',
|
|
type: 'Warn'
|
|
}
|
|
});
|
|
|
|
dialogRef.afterClosed().subscribe((confirm: boolean) => {
|
|
if (confirm) {
|
|
this.keyboardShortcutService.resetToDefaults();
|
|
this.dialogRef.close(true);
|
|
}
|
|
});
|
|
}
|
|
|
|
cancel() {
|
|
this.dialogRef.close(false);
|
|
}
|
|
}
|