open-nomad/ui/app/services/keyboard.js
Phil Renaud ccce4b68f2
[ui] Perform common job tasks with keyboard shortcuts (#16378)
* Throw your mouse into traffic

* Add node metadata with a shortcut

* Re-labelled

* Adds a toast notification to job start/stop on keyboard shortcut

* Typo fix
2023-03-20 09:24:39 -04:00

472 lines
14 KiB
JavaScript

// @ts-check
import Service from '@ember/service';
import { inject as service } from '@ember/service';
import { timeout, restartableTask } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
import { compare } from '@ember/utils';
import { A } from '@ember/array';
// eslint-disable-next-line no-unused-vars
import EmberRouter from '@ember/routing/router';
import { schedule } from '@ember/runloop';
import { action, set } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { assert } from '@ember/debug';
// eslint-disable-next-line no-unused-vars
import MutableArray from '@ember/array/mutable';
import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
/**
* @typedef {Object} KeyCommand
* @property {string} label
* @property {string[]} pattern
* @property {any} action
* @property {boolean} [requireModifier]
* @property {boolean} [enumerated]
* @property {boolean} [recording]
* @property {boolean} [custom]
* @property {boolean} [exclusive]
*/
const DEBOUNCE_MS = 750;
// This modifies event.key to a symbol; get the digit equivalent to perform commands
const DIGIT_MAP = {
'!': 1,
'@': 2,
'#': 3,
$: 4,
'%': 5,
'^': 6,
'&': 7,
'*': 8,
'(': 9,
')': 0,
};
const DISALLOWED_KEYS = [
'Shift',
'Backspace',
'Delete',
'Meta',
'Alt',
'Control',
'Tab',
'CapsLock',
'Clear',
'ScrollLock',
];
export default class KeyboardService extends Service {
/**
* @type {EmberRouter}
*/
@service router;
@service config;
@tracked shortcutsVisible = false;
@tracked buffer = A([]);
@tracked displayHints = false;
@localStorageProperty('keyboardNavEnabled', true) enabled;
defaultPatterns = {
'Go to Jobs': ['g', 'j'],
'Go to Storage': ['g', 'r'],
'Go to Variables': ['g', 'v'],
'Go to Servers': ['g', 's'],
'Go to Clients': ['g', 'c'],
'Go to Topology': ['g', 't'],
'Go to Evaluations': ['g', 'e'],
'Go to Profile': ['g', 'p'],
'Next Subnav': ['Shift+ArrowRight'],
'Previous Subnav': ['Shift+ArrowLeft'],
'Previous Main Section': ['Shift+ArrowUp'],
'Next Main Section': ['Shift+ArrowDown'],
'Show Keyboard Shortcuts': ['Shift+?'],
};
/**
* @type {MutableArray<KeyCommand>}
*/
@tracked
keyCommands = A(
[
{
label: 'Go to Jobs',
action: () => this.router.transitionTo('jobs'),
rebindable: true,
},
{
label: 'Go to Storage',
action: () => this.router.transitionTo('csi.volumes'),
rebindable: true,
},
{
label: 'Go to Variables',
action: () => this.router.transitionTo('variables'),
},
{
label: 'Go to Servers',
action: () => this.router.transitionTo('servers'),
rebindable: true,
},
{
label: 'Go to Clients',
action: () => this.router.transitionTo('clients'),
rebindable: true,
},
{
label: 'Go to Topology',
action: () => this.router.transitionTo('topology'),
rebindable: true,
},
{
label: 'Go to Evaluations',
action: () => this.router.transitionTo('evaluations'),
rebindable: true,
},
{
label: 'Go to Profile',
action: () => this.router.transitionTo('settings.tokens'),
rebindable: true,
},
{
label: 'Next Subnav',
action: () => {
this.traverseLinkList(this.subnavLinks, 1);
},
requireModifier: true,
rebindable: true,
},
{
label: 'Previous Subnav',
action: () => {
this.traverseLinkList(this.subnavLinks, -1);
},
requireModifier: true,
rebindable: true,
},
{
label: 'Previous Main Section',
action: () => {
this.traverseLinkList(this.navLinks, -1);
},
requireModifier: true,
rebindable: true,
},
{
label: 'Next Main Section',
action: () => {
this.traverseLinkList(this.navLinks, 1);
},
requireModifier: true,
rebindable: true,
},
{
label: 'Show Keyboard Shortcuts',
action: () => {
this.shortcutsVisible = true;
},
},
].map((command) => {
const persistedValue = window.localStorage.getItem(
`keyboard.command.${command.label}`
);
if (persistedValue) {
set(command, 'pattern', JSON.parse(persistedValue));
set(command, 'custom', true);
} else {
set(command, 'pattern', this.defaultPatterns[command.label]);
}
return command;
})
);
/**
* For Dynamic/iterative keyboard shortcuts, we want to do a couple things to make them more human-friendly:
* 1. Make them 1-based, instead of 0-based
* 2. Prefix numbers 1-9 with "0" to make it so "Shift+10" doesn't trigger "Shift+1" then "0", etc.
* ^--- stops being a good solution with 100+ row lists/tables, but a better UX than waiting for shift key-up otherwise
*
* @param {number} iter
* @returns {string[]}
*/
cleanPattern(iter) {
iter = iter + 1; // first item should be Shift+1, not Shift+0
assert('Dynamic keyboard shortcuts only work up to 99 digits', iter < 100);
return [`Shift+${('0' + iter).slice(-2)}`]; // Shift+01, not Shift+1
}
recomputeEnumeratedCommands() {
this.keyCommands.filterBy('enumerated').forEach((command, iter) => {
command.pattern = this.cleanPattern(iter);
});
}
addCommands(commands) {
schedule('afterRender', () => {
commands.forEach((command) => {
if (command.exclusive) {
this.removeCommands(
this.keyCommands.filterBy('label', command.label)
);
}
this.keyCommands.pushObject(command);
if (command.enumerated) {
// Recompute enumerated numbers to handle things like sort
this.recomputeEnumeratedCommands();
}
});
});
}
removeCommands(commands = A([])) {
this.keyCommands.removeObjects(commands);
}
//#region Nav Traversal
subnavLinks = [];
navLinks = [];
/**
* Map over a passed element's links and determine if they're routable
* If so, return them in a transitionTo-able format
*
* @param {HTMLElement} element did-insertable menu container element
* @param {Object} args
* @param {('main' | 'subnav')} args.type determine which traversable list the routes belong to
*/
@action
registerNav(element, _, args) {
const { type } = args;
const links = Array.from(element.querySelectorAll('a:not(.loading)'))
.map((link) => {
if (link.getAttribute('href')) {
return {
route: this.router.recognize(link.getAttribute('href'))?.name,
parent: guidFor(element),
};
}
})
.compact();
if (type === 'main') {
this.navLinks = links;
} else if (type === 'subnav') {
this.subnavLinks = links;
}
}
/**
* Removes links associated with a specific nav.
* guidFor is necessary because willDestroy runs async;
* it can happen after the next page's did-insert, so we .reject() instead of resetting to [].
*
* @param {HTMLElement} element
*/
@action
unregisterSubnav(element) {
this.subnavLinks = this.subnavLinks.reject(
(link) => link.parent === guidFor(element)
);
}
/**
*
* @param {Array<string>} links - array of root.branch.twig strings
* @param {number} traverseBy - positive or negative number to move along links
*/
traverseLinkList(links, traverseBy) {
// afterRender because LinkTos evaluate their href value at render time
schedule('afterRender', () => {
if (links.length) {
let activeLink = links.find((link) => this.router.isActive(link.route));
// If no activeLink, means we're nested within a primary section.
// Luckily, Ember's RouteInfo.find() gives us access to parents and connected leaves of a route.
// So, if we're on /csi/volumes but the nav link is to /csi, we'll .find() it.
// Similarly, /job/:job/taskgroupid/index will find /job.
if (!activeLink) {
activeLink = links.find((link) => {
return this.router.currentRoute.find((r) => {
return r.name === link.route || `${r.name}.index` === link.route;
});
});
}
if (activeLink) {
const activeLinkPosition = links.indexOf(activeLink);
const nextPosition = activeLinkPosition + traverseBy;
// Modulo (%) logic: if the next position is longer than the array, wrap to 0.
// If it's before the beginning, wrap to the end.
const nextLink =
links[((nextPosition % links.length) + links.length) % links.length]
.route;
this.router.transitionTo(nextLink);
}
}
});
}
//#endregion Nav Traversal
/**
*
* @param {("press" | "release")} type
* @param {KeyboardEvent} event
*/
recordKeypress(type, event) {
const inputElements = ['input', 'textarea', 'code'];
const disallowedClassNames = [
'ember-basic-dropdown-trigger',
'dropdown-option',
];
const targetElementName = event.target.nodeName.toLowerCase();
const inputDisallowed =
inputElements.includes(targetElementName) ||
disallowedClassNames.any((className) =>
event.target.classList.contains(className)
);
// Don't fire keypress events from within an input field
if (!inputDisallowed) {
// Treat Shift like a special modifier key.
// If it's depressed, display shortcuts
const { key } = event;
const shifted = event.getModifierState('Shift');
if (type === 'press') {
if (key === 'Shift') {
this.displayHints = true;
} else {
if (!DISALLOWED_KEYS.includes(key)) {
this.addKeyToBuffer.perform(key, shifted, event);
}
}
} else if (type === 'release') {
if (key === 'Shift') {
this.displayHints = false;
}
}
}
}
rebindCommand = (cmd, ele) => {
ele.target.blur(); // keynav ignores on inputs
this.clearBuffer();
set(cmd, 'recording', true);
set(cmd, 'previousPattern', cmd.pattern);
set(cmd, 'pattern', null);
};
endRebind = (cmd) => {
set(cmd, 'custom', true);
set(cmd, 'recording', false);
set(cmd, 'previousPattern', null);
window.localStorage.setItem(
`keyboard.command.${cmd.label}`,
JSON.stringify([...this.buffer])
);
};
resetCommandToDefault = (cmd) => {
window.localStorage.removeItem(`keyboard.command.${cmd.label}`);
set(cmd, 'pattern', this.defaultPatterns[cmd.label]);
set(cmd, 'custom', false);
};
/**
*
* @param {string} key
* @param {boolean} shifted
*/
@restartableTask *addKeyToBuffer(key, shifted, event) {
// Replace key with its unshifted equivalent if it's a number key
if (shifted && key in DIGIT_MAP) {
key = DIGIT_MAP[key];
}
this.buffer.pushObject(shifted ? `Shift+${key}` : key);
let recorder = this.keyCommands.find((c) => c.recording);
if (recorder) {
if (key === 'Escape' || key === '/') {
// Escape cancels recording; slash is reserved for global search
set(recorder, 'recording', false);
set(recorder, 'pattern', recorder.previousPattern);
recorder = null;
} else if (key === 'Enter') {
// Enter finishes recording and removes itself from the buffer
this.buffer = this.buffer.slice(0, -1);
this.endRebind(recorder);
recorder = null;
} else {
set(recorder, 'pattern', [...this.buffer]);
}
} else {
if (this.matchedCommands.length) {
this.matchedCommands.forEach((command) => {
if (
this.enabled ||
command.label === 'Show Keyboard Shortcuts' ||
command.label === 'Hide Keyboard Shortcuts'
) {
event.preventDefault();
command.action();
}
});
this.clearBuffer();
}
}
yield timeout(DEBOUNCE_MS);
if (recorder) {
this.endRebind(recorder);
}
this.clearBuffer();
}
get matchedCommands() {
// Shiftless Buffer: handle the case where use is holding shift (to see shortcut hints) and typing a key command
const shiftlessBuffer = this.buffer.map((key) =>
key.replace('Shift+', '').toLowerCase()
);
// Shift Friendly Buffer: If you hold Shift and type 0 and 1, it'll output as ['Shift+0', 'Shift+1'].
// Instead, translate that to ['Shift+01'] for clearer UX
const shiftFriendlyBuffer = [
`Shift+${this.buffer.map((key) => key.replace('Shift+', '')).join('')}`,
];
// Ember Compare: returns 0 if there's no diff between arrays.
const matches = this.keyCommands.filter((command) => {
return (
command.action &&
(!compare(command.pattern, this.buffer) ||
(command.requireModifier
? false
: !compare(command.pattern, shiftlessBuffer)) ||
(command.requireModifier
? false
: !compare(command.pattern, shiftFriendlyBuffer)))
);
});
return matches;
}
clearBuffer() {
this.buffer.clear();
}
listenForKeypress() {
set(this, '_keyDownHandler', this.recordKeypress.bind(this, 'press'));
document.addEventListener('keydown', this._keyDownHandler);
set(this, '_keyUpHandler', this.recordKeypress.bind(this, 'release'));
document.addEventListener('keyup', this._keyUpHandler);
}
willDestroy() {
document.removeEventListener('keydown', this._keyDownHandler);
document.removeEventListener('keyup', this._keyUpHandler);
}
}