diff --git a/definitions/missing-defs.d.ts b/definitions/missing-defs.d.ts index c4294dc5df..2193fe8d9e 100644 --- a/definitions/missing-defs.d.ts +++ b/definitions/missing-defs.d.ts @@ -10,11 +10,3 @@ declare module "element-resize-detector" { } export = ElementResizeDetectorMaker; } - -// https://craig.is/killing/mice -declare module "mousetrap" { - function bind(key: string, callback: Function); - function unbind(key: string, callback: Function); - function trigger(key: string); - function reset(); -} diff --git a/docs/keybindings.md b/docs/keybindings.md new file mode 100644 index 0000000000..ca84139f05 --- /dev/null +++ b/docs/keybindings.md @@ -0,0 +1,70 @@ +# Add key bindings in the application + +## Core keyboard bindings +Events that are core to the component function. Arrow navigation for a table or list for example should not be implemented this way. Those bindings are probably required by accessibility and the component should listen for keyboard event and deal with it internaly + + +## Key bindings that can be defined as a command +For all key bindings that are not core to the component functionality you can use the following. + +Keybindings works as follow: +- A command is defined + - Command has a default keyboard binding + - Command can have rules on when it can get executed + - If that's the case the component should update the context accordingly + - Command has a handler that will be executed whtn the shortcut is pressed and the condition are matched + + +### Defining a new command/key binding + +In `src/app/commands` update or create a new file `[name].appcmd.ts` and update the `index.ts` with `import [name].appcmd.ts`. + +Define your command with its default key binding there: +* `id` unique identifier for the command that can be used for user keybindings overrides(when supported) +* `binding` Default key binding +* `when` Condition on when the command can be executed. The command context will be passed. You can use the `ContextService` to update the context from the coresponding component +* `execute` Action to perform when the key binding is peformed and the condition are matched. THe injector and context are passed so you can retrieve other service instance and get information from the context. + +```ts +import { Injector } from "@angular/core"; +import { CommandContext, CommandRegistry } from "@batch-flask/core"; +import { AbstractListBase } from "@batch-flask/ui"; + +CommandRegistry.register({ + id: "list.selectAll", + binding: "ctrl+a", + when: (context: CommandContext) => { + return context.has("list.focused"); + }, + execute: (_: Injector, context: CommandContext) => { + const list = context.get("list.focused"); + if (!(list instanceof AbstractListBase)) { + log.error("Cannot delete item command context is not of enitty command type"); + return; + } + list.selectAll(); + }, +}); +``` + +## Update the context +If your command is context dependent you can in your component or service update the context + + +```ts +class MyComponent { + + constructor(private contextService: ContextService) { + + } + + @HostListener("focus") + public onFocus() { + this.contextService.setContext("my.focused", true); + } + @HostListener("blur") + public onBlur() { + this.contextService.removeContext("my.focused"); + } +} +``` diff --git a/package-lock.json b/package-lock.json index 84a543ff10..dfe21606a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9751,11 +9751,6 @@ "@types/webpack": "^4.4.19" } }, - "mousetrap": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.3.tgz", - "integrity": "sha512-bd+nzwhhs9ifsUrC2tWaSgm24/oo2c83zaRyZQF06hYA6sANfsXHtnZ19AbbbDXCDzeH5nZBSQ4NvCjgD62tJA==" - }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/package.json b/package.json index 401bb47bf4..f1b8c76440 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "sideEffects": [ "src/@batch-flask/extensions/*", "src/app/environment.ts", + "src/app/commands/**/*", "*.scss" ], "productName": "Batch Explorer", @@ -186,7 +187,6 @@ "luxon": "^1.11.4", "make-dir": "^2.1.0", "monaco-editor": "^0.16.0", - "mousetrap": "^1.6.3", "node-abi": "^2.7.0", "node-forge": "^0.8.1", "patternomaly": "^1.3.2", diff --git a/src/@batch-flask/core/commands/command-registry/command-registry.spec.ts b/src/@batch-flask/core/commands/command-registry/command-registry.spec.ts new file mode 100644 index 0000000000..6f57e3491b --- /dev/null +++ b/src/@batch-flask/core/commands/command-registry/command-registry.spec.ts @@ -0,0 +1,52 @@ +import { CommandRegistry } from "./command-registry"; + +const cmd1 = { + id: "foo", + binding: "ctrl+f", + execute: () => null, +}; + +describe("CommandRegistry", () => { + beforeEach(() => { + CommandRegistry.register(cmd1); + }); + + afterEach(() => { + (CommandRegistry as any)._commands.clear(); + }); + + it("registered the first command", () => { + expect(CommandRegistry.getCommands()).toEqual([cmd1]); + expect(CommandRegistry.getCommand("foo")).toEqual(cmd1); + }); + + it("registered another command", () => { + const cmd2 = { + id: "bar", + binding: "ctrl+b", + when: (context) => context.has("isFocused"), + execute: () => null, + }; + CommandRegistry.register(cmd2); + + expect(CommandRegistry.getCommands()).toEqual([cmd1, cmd2]); + expect(CommandRegistry.getCommand("foo")).toEqual(cmd1); + expect(CommandRegistry.getCommand("bar")).toEqual(cmd2); + }); + + it("raise error when registering another command with the same id", () => { + + const cmd2 = { + id: "foo", + binding: "ctrl+b", + when: (context) => context.has("isFocused"), + execute: () => null, + }; + expect(() => { + CommandRegistry.register(cmd2); + }).toThrowError("Command with id 'foo' was already defined. " + + "Make sure to have a unique id (Shortcut: ctrl+b, Existing: ctrl+f)"); + + }); + +}); diff --git a/src/@batch-flask/core/commands/command-registry/command-registry.ts b/src/@batch-flask/core/commands/command-registry/command-registry.ts new file mode 100644 index 0000000000..7623e7255b --- /dev/null +++ b/src/@batch-flask/core/commands/command-registry/command-registry.ts @@ -0,0 +1,32 @@ +import { Injector } from "@angular/core"; +import { SanitizedError } from "@batch-flask/utils"; +import { CommandContext } from "../context"; + +export interface Command { + id: string; + binding: string; + when?: (context: CommandContext) => boolean; + execute: (injector: Injector, context: CommandContext) => Promise | void; +} + +export class CommandRegistry { + public static register(command: Command) { + if (this._commands.has(command.id)) { + const existingCommand = this._commands.get(command.id); + throw new SanitizedError(`Command with id '${command.id}' was already defined. ` + + `Make sure to have a unique id (Shortcut: ${command.binding}, ` + + `Existing: ${existingCommand && existingCommand.binding})`); + } + this._commands.set(command.id, command); + } + + public static getCommand(id: string): Command | null { + return this._commands.get(id) || null; + } + + public static getCommands(): Command[] { + return [...this._commands.values()]; + } + + private static readonly _commands = new Map(); +} diff --git a/src/@batch-flask/core/commands/command-registry/index.ts b/src/@batch-flask/core/commands/command-registry/index.ts new file mode 100644 index 0000000000..d7af8e73f4 --- /dev/null +++ b/src/@batch-flask/core/commands/command-registry/index.ts @@ -0,0 +1 @@ +export * from "./command-registry"; diff --git a/src/@batch-flask/core/commands/context/context.service.ts b/src/@batch-flask/core/commands/context/context.service.ts new file mode 100644 index 0000000000..1269114865 --- /dev/null +++ b/src/@batch-flask/core/commands/context/context.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from "@angular/core"; + +export type CommandContext = Map; + +@Injectable({ providedIn: "root" }) +export class ContextService { + private _current: CommandContext = new Map(); + + public setContext(key: string, value: any) { + this._current.set(key, value); + } + + public removeContext(key: string) { + this._current.delete(key); + } + + public get context() { + return this._current; + } +} diff --git a/src/@batch-flask/core/commands/context/index.ts b/src/@batch-flask/core/commands/context/index.ts new file mode 100644 index 0000000000..830c0bdf4e --- /dev/null +++ b/src/@batch-flask/core/commands/context/index.ts @@ -0,0 +1 @@ +export * from "./context.service"; diff --git a/src/@batch-flask/core/commands/index.ts b/src/@batch-flask/core/commands/index.ts new file mode 100644 index 0000000000..6cfa93c2e6 --- /dev/null +++ b/src/@batch-flask/core/commands/index.ts @@ -0,0 +1,3 @@ +export * from "./command-registry"; +export * from "./context"; +export * from "./keybindings"; diff --git a/src/@batch-flask/core/commands/keybindings/index.ts b/src/@batch-flask/core/commands/keybindings/index.ts new file mode 100644 index 0000000000..684a4ecf63 --- /dev/null +++ b/src/@batch-flask/core/commands/keybindings/index.ts @@ -0,0 +1 @@ +export * from "./keybindings.service"; diff --git a/src/@batch-flask/core/commands/keybindings/keybindings.service.spec.ts b/src/@batch-flask/core/commands/keybindings/keybindings.service.spec.ts new file mode 100644 index 0000000000..2959ea1c26 --- /dev/null +++ b/src/@batch-flask/core/commands/keybindings/keybindings.service.spec.ts @@ -0,0 +1,91 @@ +import { Subscription } from "rxjs"; +import { keydown } from "test/utils/helpers"; +import { CommandRegistry } from "../command-registry"; +import { ContextService } from "../context"; +import { KeyBindingsService } from "./keybindings.service"; + +describe("Keybinding service", () => { + let injectorSpy; + let contextService: ContextService; + let service: KeyBindingsService; + + let cmd1Spy: jasmine.Spy; + let cmd2Spy: jasmine.Spy; + let cmd3Spy: jasmine.Spy; + let sub: Subscription; + + beforeEach(() => { + cmd1Spy = jasmine.createSpy("cmd1"); + cmd2Spy = jasmine.createSpy("cmd2"); + cmd3Spy = jasmine.createSpy("cmd3"); + + CommandRegistry.register({ + id: "foo", + binding: "ctrl+f", + execute: cmd1Spy, + }); + CommandRegistry.register({ + id: "bar", + binding: "ctrl+b", + when: (context) => context.has("barAllowed"), + execute: cmd2Spy, + }); + CommandRegistry.register({ + id: "barAlt", + binding: "ctrl+b", + when: (context) => !context.has("barAllowed"), + execute: cmd3Spy, + }); + + injectorSpy = { + get: () => "foo", + }; + + contextService = new ContextService(); + service = new KeyBindingsService(contextService, injectorSpy); + sub = service.listen(); + }); + + afterEach(() => { + (CommandRegistry as any)._commands.clear(); + sub.unsubscribe(); + }); + + it("runs no shortcut if it doesn't match ", () => { + keydown(document, "ctrl"); + keydown(document, "o"); + + expect(cmd1Spy).not.toHaveBeenCalled(); + expect(cmd2Spy).not.toHaveBeenCalled(); + expect(cmd3Spy).not.toHaveBeenCalled(); + }); + + it("runs a global command without condition", () => { + keydown(document, "ctrl"); + keydown(document, "f"); + + expect(cmd1Spy).toHaveBeenCalledOnce(); + expect(cmd2Spy).not.toHaveBeenCalled(); + expect(cmd3Spy).not.toHaveBeenCalled(); + }); + + it("runs a command when condition is a certain way", () => { + keydown(document, "ctrl"); + keydown(document, "b"); + + expect(cmd1Spy).not.toHaveBeenCalled(); + expect(cmd2Spy).not.toHaveBeenCalled(); + expect(cmd3Spy).toHaveBeenCalledOnce(); + }); + + it("runs another command when condition change", () => { + contextService.setContext("barAllowed", true); + + keydown(document, "ctrl"); + keydown(document, "b"); + + expect(cmd1Spy).not.toHaveBeenCalled(); + expect(cmd2Spy).toHaveBeenCalledOnce(); + expect(cmd3Spy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/@batch-flask/core/commands/keybindings/keybindings.service.ts b/src/@batch-flask/core/commands/keybindings/keybindings.service.ts new file mode 100644 index 0000000000..6dad0d51e3 --- /dev/null +++ b/src/@batch-flask/core/commands/keybindings/keybindings.service.ts @@ -0,0 +1,117 @@ +import { Injectable, Injector } from "@angular/core"; +import { KeyModifier } from "@batch-flask/core/keys"; +import { log } from "@batch-flask/utils"; +import { Subscription, fromEvent, merge } from "rxjs"; +import { map, tap } from "rxjs/operators"; +import { Command, CommandRegistry } from "../command-registry"; +import { CommandContext, ContextService } from "../context"; + +@Injectable({ providedIn: "root" }) +export class KeyBindingsService { + private _keyBindings = new Map(); + constructor(private contextService: ContextService, private injector: Injector) { } + + public listen(): Subscription { + this._loadCommands(); + const keys = new Set(); + + return merge( + fromEvent(document, "keydown").pipe( + tap((event: KeyboardEvent) => keys.add(event.key.toLowerCase())), + map((event: KeyboardEvent) => { + return { binding: new KeyBinding([...keys]), event }; + }), + tap(({ binding, event }) => { + if (this.dispatch(binding, this.contextService.context)) { + event.preventDefault(); + } + }), + ), + fromEvent(document, "keyup").pipe( + tap((event: KeyboardEvent) => { + keys.delete(event.key.toLowerCase()); + }), + ), + ).subscribe(); + } + + public dispatch(binding: KeyBinding, context: CommandContext): boolean { + if (this._keyBindings.has(binding.hash)) { + const commands = this._keyBindings.get(binding.hash)!; + const matchingCommands = commands.filter(x => x.when == null || x.when(context)); + if (matchingCommands.length === 0) { + return false; + } + + if (matchingCommands.length > 1) { + log.warn("Multiple commands founds matching the same shortcut. Picking the first one.", commands); + } + matchingCommands[0].execute(this.injector, context); + return true; + } + return false; + } + + private _loadCommands() { + const commands = CommandRegistry.getCommands(); + for (const command of commands) { + const binding = parseKeyBinding(command.binding); + if (this._keyBindings.has(binding.hash)) { + this._keyBindings.get(binding.hash)!.push(command); + } else { + this._keyBindings.set(binding.hash, [command]); + } + } + } +} + +export function parseKeyBinding(value: string): KeyBinding { + const keys = value.replace(/ /g, "").toLowerCase().split("+"); + return new KeyBinding(keys); +} + +export class KeyBinding { + public readonly mods: KeyModifier[] = []; + public readonly keys: string[] = []; + public readonly hash: string; + + constructor(keys: string[]) { + const result = this._extractModifiers(keys); + this.keys = result.keys; + this.mods = result.mods; + + this.hash = this._createHash(); + } + + private _createHash() { + return (this.mods as string[]).concat(this.keys).map(x => x.toLowerCase()).join("+"); + } + + private _extractModifiers(keys: string[]) { + const otherKeys: string[] = []; + const mods: KeyModifier[] = []; + for (const key of keys) { + switch (key) { + case "shift": + mods.push(KeyModifier.Shift); + break; + case "control": + case "ctrl": + mods.push(KeyModifier.Ctrl); + break; + case "alt": + mods.push(KeyModifier.Alt); + break; + case "cmd": + mods.push(KeyModifier.Cmd); + break; + case "cmdorctrl": + mods.push(KeyModifier.Ctrl); // Todo change depending on OS + break; + default: + otherKeys.push(key); + } + } + return { keys: otherKeys, mods }; + } +} diff --git a/src/@batch-flask/core/index.ts b/src/@batch-flask/core/index.ts index 8e32d71909..ab7cec1f75 100644 --- a/src/@batch-flask/core/index.ts +++ b/src/@batch-flask/core/index.ts @@ -1,4 +1,5 @@ export * from "./constants"; +export * from "./commands"; export * from "./data-store"; export * from "./data"; export * from "./loading-status"; diff --git a/src/@batch-flask/core/keys.ts b/src/@batch-flask/core/keys.ts index e696c1291d..83c65a797f 100644 --- a/src/@batch-flask/core/keys.ts +++ b/src/@batch-flask/core/keys.ts @@ -23,3 +23,11 @@ export enum KeyCode { Enter = "Enter", Tab = "Tab", } + +export enum KeyModifier { + Shift = "shift", + Alt = "alt", + Ctrl = "ctrl", + Cmd = "cmd", + CtrlOrCmd = "ctrlorcmd", +} diff --git a/src/@batch-flask/ui/abstract-list/abstract-list-base.ts b/src/@batch-flask/ui/abstract-list/abstract-list-base.ts index 3b2e18590d..db41668dd0 100644 --- a/src/@batch-flask/ui/abstract-list/abstract-list-base.ts +++ b/src/@batch-flask/ui/abstract-list/abstract-list-base.ts @@ -10,7 +10,7 @@ import { ViewChild, } from "@angular/core"; import { Router } from "@angular/router"; -import { ListKeyNavigator, ListView } from "@batch-flask/core"; +import { ContextService, ListKeyNavigator, ListView } from "@batch-flask/core"; import { KeyCode } from "@batch-flask/core/keys"; import { ListSelection, SelectableList } from "@batch-flask/core/list"; import { ListDataPresenter, SortingInfo } from "@batch-flask/ui/abstract-list/list-data-presenter"; @@ -171,6 +171,7 @@ export class AbstractListBase extends SelectableList implements OnDestroy { private router: Router, private breadcrumbService: BreadcrumbService, private elementRef: ElementRef, + private contextService: ContextService, changeDetection: ChangeDetectorRef) { super(changeDetection); this._initKeyNavigator(); @@ -322,6 +323,7 @@ export class AbstractListBase extends SelectableList implements OnDestroy { if (!this._clicking) { this._pickFocusedItem(); } + this.contextService.setContext("list.focused", this); this.changeDetector.markForCheck(); } @@ -335,6 +337,7 @@ export class AbstractListBase extends SelectableList implements OnDestroy { @HostListener("blur", ["$event"]) public handleBlur(_: FocusEvent) { this.listFocused = false; + this.contextService.removeContext("list.focused"); this._keyNavigator.focusColumn(-1); this.changeDetector.markForCheck(); } @@ -354,8 +357,6 @@ export class AbstractListBase extends SelectableList implements OnDestroy { if (event.shiftKey) { const focusedItem = this._keyNavigator.focusedItem; previousFocussedId = focusedItem && focusedItem.id; - } else if (event.code === KeyCode.ArrowDown || event.code === KeyCode.ArrowUp) { - this.clearSelection(); } // Handle the navigation this._keyNavigator.onKeydown(event); @@ -370,8 +371,11 @@ export class AbstractListBase extends SelectableList implements OnDestroy { } else { this.selection.add(focussedId); } + } else if (event.code === KeyCode.ArrowDown || event.code === KeyCode.ArrowUp) { + this.selection = new ListSelection({ keys: [focussedId] }); } } + this.changeDetector.markForCheck(); } public handleClick(event: MouseEvent, item, activate = true) { @@ -411,6 +415,7 @@ export class AbstractListBase extends SelectableList implements OnDestroy { public activateItem(item: AbstractListItem | null) { this.activeItem = item && item.id; + if (!item) { return; } const link = item.routerLink; if (this.config.navigable && link) { @@ -428,7 +433,6 @@ export class AbstractListBase extends SelectableList implements OnDestroy { public openContextMenu(target?: any) { if (!this.commands && !this.config.sorting) { return; } - let selection = this.selection; // If we right clicked on an non selected item it will just make this the context menu selection @@ -456,6 +460,10 @@ export class AbstractListBase extends SelectableList implements OnDestroy { }); } + public selectAll() { + this.selection = new ListSelection({ keys: this.items.map(x => x.id) }); + } + private _pickFocusedItem() { if (!this._keyNavigator.focusedItem) { if (this.activeItem) { diff --git a/src/@batch-flask/ui/entity-commands/entity-commands.ts b/src/@batch-flask/ui/entity-commands/entity-commands.ts index 7674d8540f..bc96a34f21 100644 --- a/src/@batch-flask/ui/entity-commands/entity-commands.ts +++ b/src/@batch-flask/ui/entity-commands/entity-commands.ts @@ -29,7 +29,18 @@ export abstract class EntityCommands>; + public set commands(commands: Array>) { + this._commands = commands; + const map = {}; + for (const command of commands) { + map[command.name] = command; + } + this._commandMap = map; + } + public get commands() { return this._commands; } + + private _commands: Array>; + private _commandMap: StringMap>; constructor(private injector: Injector, public typeName: string, public config: EntityCommandsConfig = {}) { this.notificationService = injector.get(NotificationService); @@ -99,6 +110,10 @@ export abstract class EntityCommands | null { + return this._commandMap[id] || null; + } + protected command>(type: Type): T { const command = new type(this.injector); command.definition = this; diff --git a/src/@batch-flask/ui/quick-list/quick-list.component.ts b/src/@batch-flask/ui/quick-list/quick-list.component.ts index 792b16ddcc..e1b4aa5f4b 100644 --- a/src/@batch-flask/ui/quick-list/quick-list.component.ts +++ b/src/@batch-flask/ui/quick-list/quick-list.component.ts @@ -10,6 +10,7 @@ import { forwardRef, } from "@angular/core"; import { Router } from "@angular/router"; +import { ContextService } from "@batch-flask/core"; import { BreadcrumbService } from "@batch-flask/ui/breadcrumbs"; import { ContextMenuService } from "@batch-flask/ui/context-menu"; import { AbstractListBase } from "../abstract-list"; @@ -47,7 +48,8 @@ export class QuickListComponent extends AbstractListBase { router: Router, elementRef: ElementRef, breadcrumbService: BreadcrumbService, + contextService: ContextService, changeDetector: ChangeDetectorRef) { - super(contextMenuService, router, breadcrumbService, elementRef, changeDetector); + super(contextMenuService, router, breadcrumbService, elementRef, contextService, changeDetector); } } diff --git a/src/@batch-flask/ui/sidebar/sidebar-bookmarks/sidebar-bookmarks.component.ts b/src/@batch-flask/ui/sidebar/sidebar-bookmarks/sidebar-bookmarks.component.ts index 12fa4a757b..8e0ec87f42 100644 --- a/src/@batch-flask/ui/sidebar/sidebar-bookmarks/sidebar-bookmarks.component.ts +++ b/src/@batch-flask/ui/sidebar/sidebar-bookmarks/sidebar-bookmarks.component.ts @@ -56,7 +56,7 @@ export class SidebarBookmarksComponent implements OnDestroy { } public referenceTitle(reference: SidebarRef) { - const title = reference.component.title; + const title = reference.component && reference.component.title; if (title) { return title; } else { diff --git a/src/@batch-flask/ui/table/table.component.ts b/src/@batch-flask/ui/table/table.component.ts index 7014f61ef1..39bf8b199d 100644 --- a/src/@batch-flask/ui/table/table.component.ts +++ b/src/@batch-flask/ui/table/table.component.ts @@ -14,6 +14,7 @@ import { ViewChild, } from "@angular/core"; import { Router } from "@angular/router"; +import { ContextService } from "@batch-flask/core"; import { BreadcrumbService } from "@batch-flask/ui/breadcrumbs"; import { ContextMenuService } from "@batch-flask/ui/context-menu"; import { DragUtils } from "@batch-flask/utils"; @@ -113,8 +114,9 @@ export class TableComponent extends AbstractListBase implements AfterContentInit router: Router, elementRef: ElementRef, liveAnnouncer: LiveAnnouncer, + contextService: ContextService, breadcrumbService: BreadcrumbService) { - super(contextmenuService, router, breadcrumbService, elementRef, changeDetection); + super(contextmenuService, router, breadcrumbService, elementRef, contextService, changeDetection); this.columnManager = new TableColumnManager(this.dataPresenter, liveAnnouncer); } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 3df9183458..0ed359b188 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -2,7 +2,7 @@ import { Component, HostBinding, OnDestroy, OnInit } from "@angular/core"; import { MatIconRegistry } from "@angular/material"; import { DomSanitizer } from "@angular/platform-browser"; import { ActivatedRoute } from "@angular/router"; -import { TelemetryService, UserConfigurationService } from "@batch-flask/core"; +import { KeyBindingsService, TelemetryService, UserConfigurationService } from "@batch-flask/core"; import { ElectronRemote, IpcService } from "@batch-flask/electron"; import { Workspace, WorkspaceService } from "@batch-flask/ui"; import { PermissionService } from "@batch-flask/ui/permission"; @@ -10,7 +10,6 @@ import { registerIcons } from "app/config"; import { AuthorizationHttpService, BatchAccountService, - CommandService, NavigatorService, NcjTemplateService, PredefinedFormulaService, @@ -39,7 +38,6 @@ export class AppComponent implements OnInit, OnDestroy { constructor( matIconRegistry: MatIconRegistry, sanitizer: DomSanitizer, - private commandService: CommandService, private accountService: BatchAccountService, private navigatorService: NavigatorService, private subscriptionService: SubscriptionService, @@ -51,6 +49,7 @@ export class AppComponent implements OnInit, OnDestroy { permissionService: PermissionService, authHttpService: AuthorizationHttpService, ipc: IpcService, + keybindingService: KeyBindingsService, private telemetryService: TelemetryService, private pricingService: PricingService, private ncjTemplateService: NcjTemplateService, @@ -59,8 +58,8 @@ export class AppComponent implements OnInit, OnDestroy { ) { this.telemetryService.init(remote.getCurrentWindow().TELEMETRY_ENABLED); this._initWorkspaces(); - this.commandService.init(); this.pricingService.init(); + keybindingService.listen(); this.navigatorService.init(); this.accountService.loadInitialData(); this.ncjTemplateService.init(); diff --git a/src/app/app.ts b/src/app/app.ts index 053e5d1e66..92c30a592c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -17,6 +17,8 @@ import "font-awesome/css/font-awesome.min.css"; import "./environment"; import "./styles/main.scss"; +import "app/commands"; + interface LoadingTimeResults { startup: number; loadTranslations: number; diff --git a/src/app/commands/account.appcmd.ts b/src/app/commands/account.appcmd.ts new file mode 100644 index 0000000000..f53c05a78c --- /dev/null +++ b/src/app/commands/account.appcmd.ts @@ -0,0 +1,11 @@ +import { Injector } from "@angular/core"; +import { Router } from "@angular/router"; +import { CommandRegistry } from "@batch-flask/core"; + +CommandRegistry.register({ + id: "account.gotoHome", + binding: "ctrl+alt+h", + execute: (injector: Injector) => { + injector.get(Router).navigate(["/accounts"]); + }, +}); diff --git a/src/app/commands/appcmd-definitions.spec.ts b/src/app/commands/appcmd-definitions.spec.ts new file mode 100644 index 0000000000..d6da6af3a9 --- /dev/null +++ b/src/app/commands/appcmd-definitions.spec.ts @@ -0,0 +1,13 @@ +import { CommandRegistry } from "@batch-flask/core"; + +describe("Batch Explorer Command definitions", () => { + afterEach(() => { + (CommandRegistry as any)._commands.clear(); + }); + + it("define the commands without errors", () => { + require("."); + + expect(CommandRegistry.getCommands().length).toEqual(6); + }); +}); diff --git a/src/app/commands/core/command-base.ts b/src/app/commands/core/command-base.ts deleted file mode 100644 index 62946e15b1..0000000000 --- a/src/app/commands/core/command-base.ts +++ /dev/null @@ -1,5 +0,0 @@ -export abstract class CommandBase { - public static id: string; - - public abstract execute(); -} diff --git a/src/app/commands/core/index.ts b/src/app/commands/core/index.ts deleted file mode 100644 index e6c3e892e0..0000000000 --- a/src/app/commands/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./command-base"; diff --git a/src/app/commands/data.appcmd.ts b/src/app/commands/data.appcmd.ts new file mode 100644 index 0000000000..4215caa835 --- /dev/null +++ b/src/app/commands/data.appcmd.ts @@ -0,0 +1,11 @@ +import { Injector } from "@angular/core"; +import { Router } from "@angular/router"; +import { CommandRegistry } from "@batch-flask/core"; + +CommandRegistry.register({ + id: "data.gotoHome", + binding: "ctrl+alt+d", + execute: (injector: Injector) => { + injector.get(Router).navigate(["/data"]); + }, +}); diff --git a/src/app/commands/index.ts b/src/app/commands/index.ts index e6d37bc8aa..64b754895e 100644 --- a/src/app/commands/index.ts +++ b/src/app/commands/index.ts @@ -1,2 +1,5 @@ -export * from "./core"; -export * from "./open-add-pool.command"; +import "./account.appcmd"; +import "./data.appcmd"; +import "./job.appcmd"; +import "./list.appcmd"; +import "./pool.appcmd"; diff --git a/src/app/commands/job.appcmd.ts b/src/app/commands/job.appcmd.ts new file mode 100644 index 0000000000..fb3d8bf485 --- /dev/null +++ b/src/app/commands/job.appcmd.ts @@ -0,0 +1,11 @@ +import { Injector } from "@angular/core"; +import { Router } from "@angular/router"; +import { CommandRegistry } from "@batch-flask/core"; + +CommandRegistry.register({ + id: "job.gotoHome", + binding: "ctrl+alt+j", + execute: (injector: Injector) => { + injector.get(Router).navigate(["/jobs"]); + }, +}); diff --git a/src/app/commands/list.appcmd.ts b/src/app/commands/list.appcmd.ts new file mode 100644 index 0000000000..fc225373f6 --- /dev/null +++ b/src/app/commands/list.appcmd.ts @@ -0,0 +1,41 @@ +import { Injector } from "@angular/core"; +import { CommandContext, CommandRegistry } from "@batch-flask/core"; +import { AbstractListBase } from "@batch-flask/ui"; +import { log } from "@batch-flask/utils"; + +CommandRegistry.register({ + id: "list.deleteItem", + binding: "delete", + when: (context: CommandContext) => { + return context.has("list.focused"); + }, + execute: (_: Injector, context: CommandContext) => { + const list = context.get("list.focused"); + if (!(list instanceof AbstractListBase)) { + log.error("Cannot delete item command context is not of enitty command type"); + return; + } + if (list.selection.isEmpty()) { return; } + + const deleteCommand = list.commands.getCommandById("delete"); + if (deleteCommand) { + deleteCommand.executeFromSelection(list.selection).subscribe(); + } + }, +}); + +CommandRegistry.register({ + id: "list.selectAll", + binding: "ctrl+a", + when: (context: CommandContext) => { + return context.has("list.focused"); + }, + execute: (_: Injector, context: CommandContext) => { + const list = context.get("list.focused"); + if (!(list instanceof AbstractListBase)) { + log.error("Cannot delete item command context is not of enitty command type"); + return; + } + list.selectAll(); + }, +}); diff --git a/src/app/commands/open-add-pool.command.ts b/src/app/commands/open-add-pool.command.ts deleted file mode 100644 index 26bd5cc93c..0000000000 --- a/src/app/commands/open-add-pool.command.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Injectable } from "@angular/core"; -import { SidebarManager } from "@batch-flask/ui/sidebar"; -import { PoolCreateBasicDialogComponent } from "app/components/pool/action"; -import { CommandBase } from "./core"; - -@Injectable() -export class OpenAddPoolCommand extends CommandBase { - public static id = "open-add-pool"; - - constructor(private sidebarManager: SidebarManager) { - super(); - } - - public execute() { - this.sidebarManager.open("add-pool", PoolCreateBasicDialogComponent); - } -} diff --git a/src/app/commands/pool.appcmd.ts b/src/app/commands/pool.appcmd.ts new file mode 100644 index 0000000000..144fd951a5 --- /dev/null +++ b/src/app/commands/pool.appcmd.ts @@ -0,0 +1,11 @@ +import { Injector } from "@angular/core"; +import { Router } from "@angular/router"; +import { CommandRegistry } from "@batch-flask/core"; + +CommandRegistry.register({ + id: "pool.gotoHome", + binding: "ctrl+alt+p", + execute: (injector: Injector) => { + injector.get(Router).navigate(["/pools"]); + }, +}); diff --git a/src/app/services/command-service.ts b/src/app/services/command-service.ts deleted file mode 100644 index 8ec4cf1177..0000000000 --- a/src/app/services/command-service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable, NgZone } from "@angular/core"; -import { CommandBase } from "app/commands/core"; -import { KeyBindings } from "app/models"; -import * as MouseTrap from "mousetrap"; - -// export const commands: any[] = ObjectUtils.values(CommandMap as any).filter((x: any) => x.id !== undefined); - -@Injectable({providedIn: "root"}) -export class CommandService { - private _commandMap: { [key: string]: CommandBase } = {}; - private _setOnce = false; - - constructor(private zone: NgZone) { - - } - - public init() { - // for(const key of Object.keys(CommandMap)) { - // const command = CommandMap[key]; - // if (command.id) { - // this._commandMap[command.id] = this.injector.get(command); - // } - // } - } - - public perform(action: string) { - const command = this._commandMap[action]; - if (!command) { - return; - } - - command.execute(); - } - - public registerShortcuts(keybindings: KeyBindings[]) { - if (this._setOnce) { - MouseTrap.reset(); - } - this._setOnce = true; - for (const shortcut of keybindings) { - if (!shortcut.key) { continue; } - MouseTrap.bind(shortcut.key, () => { - this.zone.run(() => { - this.perform(shortcut.command); - }); - }); - } - } -} diff --git a/src/app/services/index.ts b/src/app/services/index.ts index b690fa1c6c..4f36eb754a 100644 --- a/src/app/services/index.ts +++ b/src/app/services/index.ts @@ -38,6 +38,3 @@ export * from "./tenant-details.service"; export * from "./network"; export * from "./user-configuration"; export * from "./version"; - -// This needs to be last(as it does dynamic inject which problably have dependencies on above services) -export * from "./command-service"; diff --git a/src/test/utils/helpers/template-interaction.ts b/src/test/utils/helpers/template-interaction.ts index 359ea511da..40e3980c2c 100644 --- a/src/test/utils/helpers/template-interaction.ts +++ b/src/test/utils/helpers/template-interaction.ts @@ -111,7 +111,7 @@ export function mouseleave(el: DebugElement | HTMLElement) { sendEvent(el, event); } -export function keydown(el: DebugElement | HTMLElement, key: string, code?: KeyCode, keyCode?: number) { +export function keydown(el: DebugElement | HTMLElement | Node, key: string, code?: KeyCode, keyCode?: number) { const event = new KeyboardEvent("keydown", { key, code,