[ui] general keyboard navigation: 1.3.4 release (#14138)
* Initialized keyboard service Neat but funky: dynamic subnav traversal 👻 generalized traverseSubnav method Shift as special modifier key Nice little demo panel Keyboard shortcuts keycard Some animation styles on keyboard shortcuts Handle situations where a link is deeply nested from its parent menu item Keyboard service cleanup helper-based initializer and teardown for new contextual commands Keyboard shortcuts modal component added and demo-ghost removed Removed j and k from subnav traversal Register and unregister methods for subnav plus new subnavs for volumes and volume register main nav method Generalizing the register nav method 12762 table keynav (#12975) * Experimental feature: shortcut visual hints * Long way around to a custom modifier for keyboard shortcuts * dynamic table and list iterative shortcuts * Progress with regular old tether * Delogging * Table Keynav tether fix, server and client navs, and fix to shiftless on modified arrow keys Go to Optimize keyboard link and storage key changed to g r parameterized jobs keyboard nav Dynamic numeric keynav for multiple tables (#13482) * Multiple tables init * URL-bind enumerable keyboard commands and add to more taskRow and allocationRows * Type safety and lint fixes * Consolidated push to keyCommands * Default value when removing keyCommands * Remove the URL-based removal method and perform a recompute on any add Get tests passing in Keynav: remove math helpers and a few other defensive moves (#13761) * Remove ember math helpers * Test fixes for jobparts/body * Kill an unneeded integration helper test * delog * Trying if disabling percy lets this finish * Okay so its not percy; try parallelism in circle * Percyless yet again * Trying a different angle to not have percy * Upgrade percy to 1.6.1 [ui] Keyboard nav: "u" key to go up a level (#13754) * U to go up a level * Mislabelled my conditional * Custom lint ignore rule * Custom lint ignore rule, this time with commas * Since we're getting rid of ember math helpers elsewhere, do the math ourselves here Replace ArrowLeft etc. with an ascii arrow (#13776) * Replace ArrowLeft etc. with an ascii arrow * non-mutative helper cleanup Keyboard Nav: let users rebind their shortcuts (#13781) * click-outside and shortcuts enabled/disabled toggle * Trap focus when modal open * Enabled/disabled saved to localStorage * Autofocus edit button on variable index * Modal overflow styles * Functional rebind * Saving rebinds to localStorage for all majors * Started on defaultCommandBindings * Modal header style and cancel rebind on escape * keyboardable keybindings w buttons instead of spans * recording and defaultvalues * Enter short-circuits rebind * Only some commands are rebindable, and dont show dupes * No unused get import * More visually distinct header on modal * Disallowed keys for rebind, showing buffer as you type, and moving dedupe to modal logic willDestroy hook to prevent tests from doubling/tripling up addEventListener on kb events remove unused tests Keyboard Navigation acceptance tests (#13893) * Acceptance tests for keyboard modal * a11y audit fix and localStorage clear * Bind/rebind/localStorage tests * Keyboard tests for dynamic nav and tables * Rebinder and assert expectation * Second percy snapshot showing hints no longer relevant Weird issue where linktos with query props specifically from the task-groups page would fail to route / hit undefined.shouldSuperCede errors Adds the concept of exclusivity to a keycommand, removing peers that also share its label Lintfix Changelog and PR feedback Changelog and PR feedback Fix to rebinding in firefox by blurring the now-disabled button on rebind (#14053) * Secure Variables shortcuts removed * Variable index route autofocus removed * Updated changelog entry * Updated changelog entry * Keynav docs (#14148) * Section added to the API Docs UI page * Added a note about disabling * Prev and Next order * Remove dev log and unneeded comments
This commit is contained in:
parent
b63944b5c1
commit
cbd4deedf8
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui: add general keyboard navigation to the Nomad UI
|
||||
```
|
|
@ -7,6 +7,12 @@ module.exports = {
|
|||
'no-action': 'off',
|
||||
'no-invalid-interactive': 'off',
|
||||
'no-inline-styles': 'off',
|
||||
'no-curly-component-invocation': { allow: ['format-volume-name'] },
|
||||
'no-curly-component-invocation': {
|
||||
allow: ['format-volume-name', 'keyboard-commands'],
|
||||
},
|
||||
'no-implicit-this': { allow: ['keyboard-commands'] },
|
||||
},
|
||||
ignore: [
|
||||
'app/components/breadcrumbs/*', // using {{(modifier)}} syntax
|
||||
],
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@ import classic from 'ember-classic-decorator';
|
|||
@tagName('')
|
||||
export default class AllocationSubnav extends Component {
|
||||
@service router;
|
||||
@service keyboard;
|
||||
|
||||
@equal('router.currentRouteName', 'allocations.allocation.fs')
|
||||
fsIsActive;
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import Component from '@glimmer/component';
|
||||
|
||||
export default class AppBreadcrumbsComponent extends Component {
|
||||
isOneCrumbUp(iter = 0, totalNum = 0) {
|
||||
return iter === totalNum - 2;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,17 @@
|
|||
{{! template-lint-disable no-unknown-arguments-for-builtin-components }}
|
||||
<li data-test-breadcrumb-default>
|
||||
<li data-test-breadcrumb-default
|
||||
{{(modifier
|
||||
this.maybeKeyboardShortcut
|
||||
label="Go up a level"
|
||||
pattern=(array "u")
|
||||
menuLevel=true
|
||||
action=(action this.traverseUpALevel @crumb.args)
|
||||
exclusive=true
|
||||
)}}
|
||||
>
|
||||
<LinkTo
|
||||
@params={{@crumb.args}}
|
||||
data-test-breadcrumb={{@crumb.args.firstObject}}
|
||||
>
|
||||
data-test-breadcrumb={{@crumb.args.firstObject}}>
|
||||
{{#if @crumb.title}}
|
||||
<dl>
|
||||
<dt>
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import { action } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class BreadcrumbsTemplate extends Component {
|
||||
@service router;
|
||||
|
||||
@action
|
||||
traverseUpALevel(args) {
|
||||
const [path, ...rest] = args;
|
||||
this.router.transitionTo(path, ...rest);
|
||||
}
|
||||
|
||||
get maybeKeyboardShortcut() {
|
||||
return this.args.isOneCrumbUp() ? KeyboardShortcutModifier : null;
|
||||
}
|
||||
}
|
|
@ -26,7 +26,16 @@
|
|||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li>
|
||||
<li
|
||||
{{(modifier
|
||||
this.maybeKeyboardShortcut
|
||||
label="Go up a level"
|
||||
pattern=(array "u")
|
||||
menuLevel=true
|
||||
action=(action this.traverseUpALevel (array "jobs.job" this.job.idWithNamespace))
|
||||
exclusive=true
|
||||
)}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="jobs.job.index"
|
||||
@model={{this.job}}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { assert } from '@ember/debug';
|
||||
import { action } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import BreadcrumbsTemplate from './default';
|
||||
|
||||
export default class BreadcrumbsJob extends Component {
|
||||
export default class BreadcrumbsJob extends BreadcrumbsTemplate {
|
||||
get job() {
|
||||
return this.args.crumb.job;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import Component from '@ember/component';
|
||||
import { tagName } from '@ember-decorators/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
@tagName('')
|
||||
export default class ClientSubnav extends Component {}
|
||||
export default class ClientSubnav extends Component {
|
||||
@service keyboard;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
{{#let this.currentEvalDetail as |evaluation|}}
|
||||
{{#if this.isSideBarOpen}}
|
||||
{{keyboard-commands this.keyCommands}}
|
||||
{{/if}}
|
||||
<Portal @target="eval-detail-portal">
|
||||
<div
|
||||
data-test-eval-detail
|
||||
|
|
|
@ -75,4 +75,12 @@ export default class Detail extends Component {
|
|||
closeSidebar() {
|
||||
return this.statechart.send('MODAL_CLOSE');
|
||||
}
|
||||
|
||||
keyCommands = [
|
||||
{
|
||||
label: 'Close Evaluations Sidebar',
|
||||
pattern: ['Escape'],
|
||||
action: () => this.closeSidebar(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import classic from 'ember-classic-decorator';
|
|||
export default class GutterMenu extends Component {
|
||||
@service system;
|
||||
@service router;
|
||||
@service keyboard;
|
||||
|
||||
@computed('system.namespaces.@each.name')
|
||||
get sortedNamespaces() {
|
||||
|
@ -37,6 +38,11 @@ export default class GutterMenu extends Component {
|
|||
|
||||
onHamburgerClick() {}
|
||||
|
||||
// Seemingly redundant, but serves to ensure the action is passed to the keyboard service correctly
|
||||
transitionTo(destination) {
|
||||
return this.router.transitionTo(destination);
|
||||
}
|
||||
|
||||
gotoJobsForNamespace(namespace) {
|
||||
if (!namespace || !namespace.get('id')) return;
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import Component from '@glimmer/component';
|
|||
|
||||
export default class JobSubnav extends Component {
|
||||
@service can;
|
||||
@service keyboard;
|
||||
|
||||
get shouldRenderClientsTab() {
|
||||
const { job } = this.args;
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
{{#if this.keyboard.shortcutsVisible}}
|
||||
{{keyboard-commands (array this.escapeCommand)}}
|
||||
<div class="keyboard-shortcuts"
|
||||
{{on-click-outside
|
||||
(toggle "keyboard.shortcutsVisible" this)
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<button
|
||||
{{autofocus}}
|
||||
class="button is-borderless dismiss"
|
||||
type="button"
|
||||
{{on "click" (toggle "keyboard.shortcutsVisible" this)}}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
{{x-icon "cancel"}}
|
||||
</button>
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<p>Click a key pattern to re-bind it to a shortcut of your choosing.</p>
|
||||
</header>
|
||||
<ul class="commands-list">
|
||||
{{#each this.commands as |command|}}
|
||||
<li data-test-command-label={{command.label}}>
|
||||
<strong>{{command.label}}</strong>
|
||||
<span class="keys">
|
||||
{{#if command.recording}}
|
||||
<span class="recording">Recording; ESC to cancel.</span>
|
||||
{{else}}
|
||||
{{#if command.custom}}
|
||||
<button type="button" class="reset-to-default" {{on "click" (action this.keyboard.resetCommandToDefault command)}}>reset to default</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<button data-test-rebinder disabled={{or (not command.rebindable) command.recording}} type="button" {{on "click" (action this.keyboard.rebindCommand command)}}>
|
||||
{{#each command.pattern as |key|}}
|
||||
<span>{{clean-keycommand key}}</span>
|
||||
{{/each}}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
<footer>
|
||||
<strong>Keyboard shortcuts {{#if this.keyboard.enabled}}enabled{{else}}disabled{{/if}}</strong>
|
||||
<Toggle
|
||||
data-test-enable-shortcuts-toggle
|
||||
@isActive={{this.keyboard.enabled}}
|
||||
@onToggle={{this.toggleListener}}
|
||||
title="{{if this.keyboard.enabled "enable" "disable"}} keyboard shortcuts"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and this.keyboard.enabled this.keyboard.displayHints)}}
|
||||
{{#each this.hints as |hint|}}
|
||||
<span
|
||||
{{did-insert (fn this.tetherToElement hint.element hint)}}
|
||||
{{will-destroy (fn this.untetherFromElement hint)}}
|
||||
data-test-keyboard-hint
|
||||
data-shortcut={{hint.pattern}}
|
||||
class="{{if hint.menuLevel "menu-level"}}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{#each hint.pattern as |key|}}
|
||||
<span>{{key}}</span>
|
||||
{{/each}}
|
||||
</span>
|
||||
{{/each}}
|
||||
{{/if}}
|
|
@ -0,0 +1,70 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { computed } from '@ember/object';
|
||||
import { action } from '@ember/object';
|
||||
import Tether from 'tether';
|
||||
|
||||
export default class KeyboardShortcutsModalComponent extends Component {
|
||||
@service keyboard;
|
||||
@service config;
|
||||
|
||||
escapeCommand = {
|
||||
label: 'Hide Keyboard Shortcuts',
|
||||
pattern: ['Escape'],
|
||||
action: () => {
|
||||
this.keyboard.shortcutsVisible = false;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* commands: filter keyCommands to those that have an action and a label,
|
||||
* to distinguish between those that are just visual hints of existing commands
|
||||
*/
|
||||
@computed('keyboard.keyCommands.[]')
|
||||
get commands() {
|
||||
return this.keyboard.keyCommands.reduce((memo, c) => {
|
||||
if (c.label && c.action && !memo.find((m) => m.label === c.label)) {
|
||||
memo.push(c);
|
||||
}
|
||||
return memo;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* hints: filter keyCommands to those that have an element property,
|
||||
* and then compute a position on screen to place the hint.
|
||||
*/
|
||||
@computed('keyboard.{keyCommands.length,displayHints}')
|
||||
get hints() {
|
||||
if (this.keyboard.displayHints) {
|
||||
return this.keyboard.keyCommands.filter((c) => c.element);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
tetherToElement(element, hint, self) {
|
||||
if (!this.config.isTest) {
|
||||
let binder = new Tether({
|
||||
element: self,
|
||||
target: element,
|
||||
attachment: 'top left',
|
||||
targetAttachment: 'top left',
|
||||
targetModifier: 'visible',
|
||||
});
|
||||
hint.binder = binder;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
untetherFromElement(hint) {
|
||||
if (!this.config.isTest) {
|
||||
hint.binder.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@action toggleListener() {
|
||||
this.keyboard.enabled = !this.keyboard.enabled;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class PluginSubnavComponent extends Component {
|
||||
@service keyboard;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { LinkComponent } from '@ember/legacy-built-in-components';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
// Necessary for programmatic routing away pages with <LinkTo>s that contain @query properties.
|
||||
// (There's an issue with query param calculations in the new component that uses the router service)
|
||||
// https://github.com/emberjs/ember.js/issues/20051
|
||||
|
||||
@classic
|
||||
export default class SafeLinkToComponent extends LinkComponent {}
|
|
@ -41,9 +41,13 @@ export default class ServerAgentRow extends Component {
|
|||
return currentURL.replace(/%40/g, '@') === targetURL.replace(/%40/g, '@');
|
||||
}
|
||||
|
||||
click() {
|
||||
goToAgent() {
|
||||
const transition = () =>
|
||||
this.router.transitionTo('servers.server', this.agent);
|
||||
lazyClick([transition, event]);
|
||||
}
|
||||
|
||||
click() {
|
||||
this.goToAgent();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import Component from '@ember/component';
|
||||
import { tagName } from '@ember-decorators/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
@tagName('')
|
||||
export default class ServerSubnav extends Component {}
|
||||
export default class ServerSubnav extends Component {
|
||||
@service keyboard;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import Component from '@glimmer/component';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class StorageSubnavComponent extends Component {
|
||||
@service keyboard;
|
||||
}
|
|
@ -8,6 +8,7 @@ import classic from 'ember-classic-decorator';
|
|||
@tagName('')
|
||||
export default class TaskSubnav extends Component {
|
||||
@service router;
|
||||
@service keyboard;
|
||||
|
||||
@equal('router.currentRouteName', 'allocations.allocation.task.fs')
|
||||
fsIsActive;
|
||||
|
|
|
@ -9,7 +9,8 @@ import codesForError from '../utils/codes-for-error';
|
|||
import NoLeaderError from '../utils/no-leader-error';
|
||||
import OTTExchangeError from '../utils/ott-exchange-error';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import KeyboardService from '../services/keyboard';
|
||||
@classic
|
||||
export default class ApplicationController extends Controller {
|
||||
@service config;
|
||||
|
@ -17,6 +18,17 @@ export default class ApplicationController extends Controller {
|
|||
@service token;
|
||||
@service flashMessages;
|
||||
|
||||
/**
|
||||
* @type {KeyboardService}
|
||||
*/
|
||||
@service keyboard;
|
||||
|
||||
// eslint-disable-next-line ember/classic-decorator-hooks
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.keyboard.listenForKeypress();
|
||||
}
|
||||
|
||||
queryParams = [
|
||||
{
|
||||
region: 'region',
|
||||
|
|
|
@ -22,6 +22,7 @@ export default class IndexController extends Controller.extend(
|
|||
) {
|
||||
@service system;
|
||||
@service userSettings;
|
||||
@service keyboard;
|
||||
@controller('csi/volumes') volumesController;
|
||||
|
||||
@alias('volumesController.isForbidden')
|
||||
|
|
|
@ -65,7 +65,9 @@ export default class EvaluationsController extends Controller {
|
|||
async handleEvaluationClick(evaluation, e) {
|
||||
if (
|
||||
e instanceof MouseEvent ||
|
||||
(e instanceof KeyboardEvent && (e.code === 'Enter' || e.code === 'Space'))
|
||||
(e instanceof KeyboardEvent &&
|
||||
(e.code === 'Enter' || e.code === 'Space')) ||
|
||||
!e
|
||||
) {
|
||||
this.statechart.send('LOAD_EVALUATION', { evaluation });
|
||||
}
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
// @ts-check
|
||||
import { helper } from '@ember/component/helper';
|
||||
|
||||
const KEY_ALIAS_MAP = {
|
||||
ArrowRight: '→',
|
||||
ArrowLeft: '←',
|
||||
ArrowUp: '↑',
|
||||
ArrowDown: '↓',
|
||||
'+': ' + ',
|
||||
};
|
||||
|
||||
export default helper(function cleanKeycommand([key] /*, named*/) {
|
||||
let cleaned = key;
|
||||
Object.keys(KEY_ALIAS_MAP).forEach((k) => {
|
||||
cleaned = cleaned.replace(k, KEY_ALIAS_MAP[k]);
|
||||
});
|
||||
return cleaned;
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
import Helper from '@ember/component/helper';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
/**
|
||||
`{{keyboard-commands}}` helper used to initialize and tear down contextual keynav commands
|
||||
@public
|
||||
@method keyboard-commands
|
||||
*/
|
||||
export default class keyboardCommands extends Helper {
|
||||
@service keyboard;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
compute([commands]) {
|
||||
if (commands) {
|
||||
this.commands = commands;
|
||||
this.keyboard.addCommands(commands);
|
||||
}
|
||||
}
|
||||
willDestroy() {
|
||||
super.willDestroy();
|
||||
this.keyboard.removeCommands(this.commands);
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@ import Helper from '@ember/component/helper';
|
|||
* that should be handled instead.
|
||||
*/
|
||||
export function lazyClick([onClick, event]) {
|
||||
if (!['a', 'button'].includes(event.target.tagName.toLowerCase())) {
|
||||
if (!['a', 'button'].includes(event?.target.tagName.toLowerCase())) {
|
||||
onClick(event);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { inject as service } from '@ember/service';
|
||||
import Modifier from 'ember-modifier';
|
||||
import { registerDestructor } from '@ember/destroyable';
|
||||
|
||||
export default class KeyboardShortcutModifier extends Modifier {
|
||||
@service keyboard;
|
||||
@service router;
|
||||
|
||||
modify(
|
||||
element,
|
||||
_positional,
|
||||
{
|
||||
label,
|
||||
pattern = '',
|
||||
action = () => {},
|
||||
menuLevel = false,
|
||||
enumerated = false,
|
||||
exclusive = false,
|
||||
}
|
||||
) {
|
||||
let commands = [
|
||||
{
|
||||
label,
|
||||
action,
|
||||
pattern,
|
||||
element,
|
||||
menuLevel,
|
||||
enumerated,
|
||||
exclusive,
|
||||
},
|
||||
];
|
||||
|
||||
this.keyboard.addCommands(commands);
|
||||
registerDestructor(this, () => {
|
||||
this.keyboard.removeCommands(commands);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,465 @@
|
|||
// @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 Servers': ['g', 's'],
|
||||
'Go to Clients': ['g', 'c'],
|
||||
'Go to Topology': ['g', 't'],
|
||||
'Go to Evaluations': ['g', 'e'],
|
||||
'Go to ACL Tokens': ['g', 'a'],
|
||||
'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 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 ACL Tokens',
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
// 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'
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -47,3 +47,4 @@
|
|||
@import './components/two-step-button';
|
||||
@import './components/evaluations';
|
||||
@import './components/secure-variables';
|
||||
@import './components/keyboard-shortcuts-modal';
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
.keyboard-shortcuts {
|
||||
position: fixed;
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
margin-top: 20vh;
|
||||
width: 40vw;
|
||||
left: 30vw;
|
||||
z-index: 499;
|
||||
box-shadow: 2px 2px 12px 3000px rgb(0, 0, 0, 0.8);
|
||||
animation-name: slideIn;
|
||||
animation-duration: 0.2s;
|
||||
animation-fill-mode: both;
|
||||
max-height: 60vh;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
h2 {
|
||||
font-size: $size-3;
|
||||
font-weight: $weight-semibold;
|
||||
}
|
||||
|
||||
button.dismiss {
|
||||
float: right;
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul.commands-list {
|
||||
overflow: auto;
|
||||
margin: 0 -2rem;
|
||||
padding: 0 2rem;
|
||||
li {
|
||||
list-style-type: none;
|
||||
padding: 0.5rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
strong {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.keys {
|
||||
text-align: right;
|
||||
& > span.recording {
|
||||
color: $red;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled]:hover {
|
||||
background: #eee;
|
||||
color: black;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
span {
|
||||
margin: 0.25rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.reset-to-default {
|
||||
background: white;
|
||||
color: $red;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #eee;
|
||||
padding: 1rem 2rem;
|
||||
margin: 1rem -2rem -2rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
||||
.toggle {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global keyboard hint style
|
||||
|
||||
// .display-hints {
|
||||
[data-shortcut] {
|
||||
background: lighten($nomad-green, 25%);
|
||||
border: 1px solid $nomad-green-dark;
|
||||
content: attr(data-shortcut);
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.5rem;
|
||||
text-transform: lowercase;
|
||||
color: black;
|
||||
font-weight: 300;
|
||||
z-index: $z-popover;
|
||||
&.menu-level {
|
||||
z-index: $z-tooltip;
|
||||
}
|
||||
}
|
||||
// }
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
top: 40px;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
top: 0px;
|
||||
}
|
||||
}
|
|
@ -184,6 +184,10 @@
|
|||
</t.head>
|
||||
<t.body as |row|>
|
||||
<TaskRow
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "taskClick" row.model.allocation row.model)
|
||||
}}
|
||||
@data-test-task-row={{row.model.name}}
|
||||
@task={{row.model}}
|
||||
@onClick={{action "taskClick" row.model.allocation row.model}}
|
||||
|
|
|
@ -24,6 +24,8 @@
|
|||
{{/each}}
|
||||
</section>
|
||||
|
||||
<KeyboardShortcutsModal />
|
||||
|
||||
{{#if this.error}}
|
||||
<div class="error-container">
|
||||
<div data-test-error class="error-message">
|
||||
|
|
|
@ -546,6 +546,10 @@
|
|||
</t.head>
|
||||
<t.body as |row|>
|
||||
<AllocationRow
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoAllocation" row.model)
|
||||
}}
|
||||
@allocation={{row.model}}
|
||||
@context="node"
|
||||
@onClick={{action "gotoAllocation" row.model}}
|
||||
|
|
|
@ -86,6 +86,10 @@
|
|||
data-test-client-node-row
|
||||
@node={{row.model}}
|
||||
@onClick={{action "gotoNode" row.model}}
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoNode" row.model)
|
||||
}}
|
||||
/>
|
||||
</t.body>
|
||||
</ListTable>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="tabs is-subnav">
|
||||
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li><LinkTo @route="allocations.allocation.index" @model={{this.allocation}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li><LinkTo @route="allocations.allocation.fs-root" @model={{this.allocation}} class={{if this.filesLinkActive "is-active"}}>Files</LinkTo></li>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<Breadcrumbs as |breadcrumbs|>
|
||||
{{#each breadcrumbs as |crumb|}}
|
||||
{{#each breadcrumbs as |crumb iter|}}
|
||||
{{#let crumb.args.crumb as |c|}}
|
||||
{{component (concat "breadcrumbs/" (or c.type "default")) crumb=c}}
|
||||
{{component (concat "breadcrumbs/" (or c.type "default")) crumb=c isOneCrumbUp=(action this.isOneCrumbUp iter breadcrumbs.length)}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
</Breadcrumbs>
|
|
@ -1,4 +1,4 @@
|
|||
<div class="tabs is-subnav">
|
||||
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li><LinkTo @route="clients.client.index" @model={{this.client}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li><LinkTo @route="clients.client.monitor" @model={{this.client}} @activeClass="is-active">Monitor</LinkTo></li>
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
>
|
||||
Documentation
|
||||
</a>
|
||||
<LinkTo @route="settings.tokens" class="navbar-item">
|
||||
<LinkTo @route="settings.tokens" class="navbar-item" {{keyboard-shortcut menuLevel=true pattern=(array "g" "a") }}>
|
||||
ACL Tokens
|
||||
</LinkTo>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<div
|
||||
data-test-gutter-menu
|
||||
class="page-column is-left {{if this.isOpen "is-open"}}"
|
||||
{{did-insert this.keyboard.registerNav type="main"}}
|
||||
>
|
||||
<div class="gutter {{if this.isOpen "is-open"}}">
|
||||
<header class="collapsed-menu {{if this.isOpen "is-open"}}">
|
||||
|
@ -33,7 +34,7 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "j") }}>
|
||||
<LinkTo
|
||||
@route="jobs"
|
||||
@activeClass="is-active"
|
||||
|
@ -43,7 +44,13 @@
|
|||
</LinkTo>
|
||||
</li>
|
||||
{{#if (can "accept recommendation")}}
|
||||
<li>
|
||||
<li
|
||||
{{keyboard-shortcut
|
||||
menuLevel=true
|
||||
pattern=(array "g" "o")
|
||||
action=(action this.transitionTo 'optimize')
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="optimize"
|
||||
@activeClass="is-active"
|
||||
|
@ -53,7 +60,7 @@
|
|||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
<li>
|
||||
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "r") }}>
|
||||
<LinkTo
|
||||
@route="csi"
|
||||
@activeClass="is-active"
|
||||
|
@ -78,7 +85,7 @@
|
|||
Cluster
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "c") }}>
|
||||
<LinkTo
|
||||
@route="clients"
|
||||
@activeClass="is-active"
|
||||
|
@ -87,7 +94,7 @@
|
|||
Clients
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li>
|
||||
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "s") }}>
|
||||
<LinkTo
|
||||
@route="servers"
|
||||
@activeClass="is-active"
|
||||
|
@ -96,7 +103,7 @@
|
|||
Servers
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li>
|
||||
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "t") }}>
|
||||
<LinkTo
|
||||
@route="topology"
|
||||
@activeClass="is-active"
|
||||
|
@ -110,7 +117,7 @@
|
|||
Debugging
|
||||
</p>
|
||||
<ul class="menu-list">
|
||||
<li>
|
||||
<li {{keyboard-shortcut menuLevel=true pattern=(array "g" "e") }}>
|
||||
<LinkTo
|
||||
@route="evaluations"
|
||||
@activeClass="is-active"
|
||||
|
|
|
@ -52,6 +52,10 @@
|
|||
@allocation={{row.model}}
|
||||
@context="job"
|
||||
@onClick={{action "gotoAllocation" row.model}}
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoAllocation" row.model)
|
||||
}}
|
||||
/>
|
||||
</t.body>
|
||||
</ListTable>
|
||||
|
|
|
@ -36,6 +36,10 @@
|
|||
@data-test-task-group={{row.model.name}}
|
||||
@taskGroup={{row.model}}
|
||||
@onClick={{fn this.gotoTaskGroup row.model}}
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(fn this.gotoTaskGroup row.model)
|
||||
}}
|
||||
/>
|
||||
</t.body>
|
||||
</ListTable>
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
<td data-test-job-name>
|
||||
<td data-test-job-name
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoJob" @job)
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="jobs.job.index"
|
||||
@model={{this.job.idWithNamespace}}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div data-test-subnav="job" class="tabs is-subnav">
|
||||
<div data-test-subnav="job" class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li data-test-tab="overview">
|
||||
<LinkTo
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<LinkTo
|
||||
<SafeLinkTo
|
||||
@query={{hash sortProperty=this.prop sortDescending=this.shouldSortDescending}}
|
||||
data-test-sort-by={{this.prop}}>
|
||||
{{yield}}
|
||||
</LinkTo>
|
||||
</SafeLinkTo>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<div data-test-subnav="plugins" class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li data-test-tab="overview"><LinkTo @route="csi.plugins.plugin.index" @model={{@plugin}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li data-test-tab="allocations"><LinkTo @route="csi.plugins.plugin.allocations" @model={{@plugin}} @activeClass="is-active">Allocations</LinkTo></li>
|
||||
</ul>
|
||||
</div>
|
|
@ -1,4 +1,9 @@
|
|||
<td data-test-server-name><LinkTo @route="servers.server" @model={{this.agent.id}} class="is-primary">{{this.agent.name}}</LinkTo></td>
|
||||
<td data-test-server-name
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action this.goToAgent)
|
||||
}}
|
||||
><LinkTo @route="servers.server" @model={{this.agent.id}} class="is-primary">{{this.agent.name}}</LinkTo></td>
|
||||
<td data-test-server-status>{{this.agent.status}}</td>
|
||||
<td data-test-server-is-leader>{{if this.agent.isLeader "True" "False"}}</td>
|
||||
<td data-test-server-address class="is-200px is-truncatable">{{this.agent.address}}</td>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="tabs is-subnav">
|
||||
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li><LinkTo @route="servers.server.index" @model={{this.server}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li><LinkTo @route="servers.server.monitor" @model={{this.server}} @activeClass="is-active">Monitor</LinkTo></li>
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li data-test-tab="volumes">
|
||||
<LinkTo @route="csi.volumes.index" @activeClass="is-active">
|
||||
Volumes
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li data-test-tab="plugins">
|
||||
<LinkTo @route="csi.plugins.index" @activeClass="is-active">
|
||||
Plugins
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
|
@ -1,4 +1,4 @@
|
|||
<div class="tabs is-subnav">
|
||||
<div class="tabs is-subnav" {{did-insert this.keyboard.registerNav type="subnav"}} {{will-destroy this.keyboard.unregisterSubnav}}>
|
||||
<ul>
|
||||
<li><LinkTo @route="allocations.allocation.task.index" @models={{array this.task.allocation this.task}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li><LinkTo @route="allocations.allocation.task.logs" @models={{array this.task.allocation this.task}} @activeClass="is-active">Logs</LinkTo></li>
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
{{page-title "CSI Plugins"}}
|
||||
<div class="tabs is-subnav">
|
||||
<ul>
|
||||
<li data-test-tab="volumes"><LinkTo @route="csi.volumes.index" @activeClass="is-active">Volumes</LinkTo></li>
|
||||
<li data-test-tab="plugins"><LinkTo @route="csi.plugins.index" @activeClass="is-active">Plugins</LinkTo></li>
|
||||
</ul>
|
||||
</div>
|
||||
<StorageSubnav />
|
||||
<section class="section">
|
||||
{{#if this.isForbidden}}
|
||||
<ForbiddenMessage />
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
{{page-title "CSI Plugin " this.model.plainId " allocations"}}
|
||||
<div data-test-subnav="plugins" class="tabs is-subnav">
|
||||
<ul>
|
||||
<li data-test-tab="overview"><LinkTo @route="csi.plugins.plugin.index" @model={{this.model}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li data-test-tab="allocations"><LinkTo @route="csi.plugins.plugin.allocations" @model={{this.model}} @activeClass="is-active">Allocations</LinkTo></li>
|
||||
</ul>
|
||||
</div>
|
||||
<PluginSubnav @plugin={{this.model}} />
|
||||
<section class="section">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
|
|
|
@ -1,10 +1,5 @@
|
|||
{{page-title "CSI Plugin " this.model.plainId}}
|
||||
<div data-test-subnav="plugins" class="tabs is-subnav">
|
||||
<ul>
|
||||
<li data-test-tab="overview"><LinkTo @route="csi.plugins.plugin.index" @model={{this.model}} @activeClass="is-active">Overview</LinkTo></li>
|
||||
<li data-test-tab="allocations"><LinkTo @route="csi.plugins.plugin.allocations" @model={{this.model}} @activeClass="is-active">Allocations</LinkTo></li>
|
||||
</ul>
|
||||
</div>
|
||||
<PluginSubnav @plugin={{this.model}} />
|
||||
<section class="section">
|
||||
<h1 class="title" data-test-title>{{this.model.plainId}}</h1>
|
||||
|
||||
|
|
|
@ -1,18 +1,5 @@
|
|||
{{page-title "CSI Volumes"}}
|
||||
<div class="tabs is-subnav">
|
||||
<ul>
|
||||
<li data-test-tab="volumes">
|
||||
<LinkTo @route="csi.volumes.index" @activeClass="is-active">
|
||||
Volumes
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li data-test-tab="plugins">
|
||||
<LinkTo @route="csi.plugins.index" @activeClass="is-active">
|
||||
Plugins
|
||||
</LinkTo>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<StorageSubnav />
|
||||
<section class="section">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-item">
|
||||
|
@ -51,7 +38,7 @@
|
|||
@source={{p.list}}
|
||||
@sortProperty={{this.sortProperty}}
|
||||
@sortDescending={{this.sortDescending}}
|
||||
@class="with-foot" as |t|
|
||||
@class="with-foot {{if this.keyboard.displayHints "display-hints"}}" as |t|
|
||||
>
|
||||
<t.head>
|
||||
<t.sort-by @prop="name">
|
||||
|
@ -84,7 +71,12 @@
|
|||
data-test-volume-row
|
||||
{{on "click" (action "gotoVolume" row.model)}}
|
||||
>
|
||||
<td data-test-volume-name>
|
||||
<td data-test-volume-name
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoVolume" row.model)
|
||||
}}
|
||||
>
|
||||
<LinkTo
|
||||
@route="csi.volumes.volume"
|
||||
@model={{row.model.idWithNamespace}}
|
||||
|
|
|
@ -52,6 +52,10 @@
|
|||
</t.head>
|
||||
<t.body as |row|>
|
||||
<AllocationRow
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoAllocation" row.model)
|
||||
}}
|
||||
@data-test-write-allocation={{row.model.id}}
|
||||
@allocation={{row.model}}
|
||||
@context="volume"
|
||||
|
@ -90,6 +94,10 @@
|
|||
</t.head>
|
||||
<t.body as |row|>
|
||||
<AllocationRow
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoAllocation" row.model)
|
||||
}}
|
||||
@data-test-read-allocation={{row.model.id}}
|
||||
@allocation={{row.model}}
|
||||
@context="volume"
|
||||
|
|
|
@ -78,7 +78,12 @@
|
|||
{{on "click" (fn this.handleEvaluationClick row.model)}}
|
||||
{{on "keyup" (fn this.handleEvaluationClick row.model)}}
|
||||
>
|
||||
<td data-test-id>
|
||||
<td data-test-id
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(fn this.handleEvaluationClick row.model)
|
||||
}}
|
||||
>
|
||||
{{row.model.shortId}}
|
||||
</td>
|
||||
<td data-test-id>
|
||||
|
|
|
@ -62,6 +62,10 @@
|
|||
</t.head>
|
||||
<t.body as |row|>
|
||||
<AllocationRow
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoAllocation" row.model)
|
||||
}}
|
||||
@data-test-allocation={{row.model.id}}
|
||||
@allocation={{row.model}}
|
||||
@context="job"
|
||||
|
|
|
@ -197,6 +197,10 @@
|
|||
</t.head>
|
||||
<t.body @key="model.id" as |row|>
|
||||
<AllocationRow
|
||||
{{keyboard-shortcut
|
||||
enumerated=true
|
||||
action=(action "gotoAllocation" row.model)
|
||||
}}
|
||||
@data-test-allocation={{row.model.id}}
|
||||
@allocation={{row.model}}
|
||||
@context="taskGroup"
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.4.3",
|
||||
"@ember/legacy-built-in-components": "^0.4.1",
|
||||
"@ember/optional-features": "2.0.0",
|
||||
"@ember/render-modifiers": "^2.0.4",
|
||||
"@ember/test-helpers": "^2.6.0",
|
||||
|
@ -97,7 +98,7 @@
|
|||
"ember-inline-svg": "^1.0.1",
|
||||
"ember-load-initializers": "^2.1.2",
|
||||
"ember-maybe-import-regenerator": "^1.0.0",
|
||||
"ember-modifier": "^3.1.0",
|
||||
"ember-modifier": "3.2.7",
|
||||
"ember-moment": "^9.0.1",
|
||||
"ember-named-blocks-polyfill": "^0.2.4",
|
||||
"ember-on-resize-modifier": "^1.0.0",
|
||||
|
@ -179,6 +180,7 @@
|
|||
"d3": "^7.3.0",
|
||||
"lru_map": "^0.4.1",
|
||||
"no-case": "^3.0.4",
|
||||
"tether": "^2.0.0",
|
||||
"title-case": "^3.0.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
|
@ -0,0 +1,350 @@
|
|||
/* eslint-disable qunit/require-expect */
|
||||
import { module, test } from 'qunit';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import {
|
||||
click,
|
||||
currentURL,
|
||||
visit,
|
||||
triggerEvent,
|
||||
triggerKeyEvent,
|
||||
findAll,
|
||||
} from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import Layout from 'nomad-ui/tests/pages/layout';
|
||||
import percySnapshot from '@percy/ember';
|
||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
|
||||
module('Acceptance | keyboard', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
module('modal', function () {
|
||||
test('Opening and closing shortcuts modal with key commands', async function (assert) {
|
||||
assert.expect(4);
|
||||
await visit('/');
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
assert.ok(Layout.keyboard.modalShown);
|
||||
await percySnapshot(assert);
|
||||
await a11yAudit(assert);
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Escape' });
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
});
|
||||
|
||||
test('closing shortcuts modal by clicking dismiss', async function (assert) {
|
||||
await visit('/');
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
assert.ok(Layout.keyboard.modalShown);
|
||||
assert.dom('button.dismiss').isFocused();
|
||||
await click('button.dismiss');
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
});
|
||||
|
||||
test('closing shortcuts modal by clicking outside', async function (assert) {
|
||||
await visit('/');
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
assert.ok(Layout.keyboard.modalShown);
|
||||
await click('.page-layout');
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
});
|
||||
});
|
||||
|
||||
module('Enable/Disable', function (enableDisableHooks) {
|
||||
enableDisableHooks.beforeEach(function () {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('Shortcuts work by default and stops working when disabled', async function (assert) {
|
||||
await visit('/');
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'g' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'c' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/clients`,
|
||||
'end up on the clients page after typing g c'
|
||||
);
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
assert.ok(Layout.keyboard.modalShown);
|
||||
assert.dom('[data-test-enable-shortcuts-toggle]').hasClass('is-active');
|
||||
await click('[data-test-enable-shortcuts-toggle]');
|
||||
assert
|
||||
.dom('[data-test-enable-shortcuts-toggle]')
|
||||
.doesNotHaveClass('is-active');
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Escape' });
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'g' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'j' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/clients`,
|
||||
'typing g j did not bring you back to the jobs page, since shortcuts are disabled'
|
||||
);
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
await click('[data-test-enable-shortcuts-toggle]');
|
||||
assert.dom('[data-test-enable-shortcuts-toggle]').hasClass('is-active');
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Escape' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'g' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'j' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs`,
|
||||
'typing g j brings me to the jobs page after re-enabling shortcuts'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Local storage bind/rebind', function (rebindHooks) {
|
||||
rebindHooks.beforeEach(function () {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('You can rebind shortcuts', async function (assert) {
|
||||
await visit('/');
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'g' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'c' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/clients`,
|
||||
'end up on the clients page after typing g c'
|
||||
);
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'g' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'j' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs`,
|
||||
'end up on the clients page after typing g j'
|
||||
);
|
||||
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
assert.ok(Layout.keyboard.modalShown);
|
||||
|
||||
await click(
|
||||
'[data-test-command-label="Go to Clients"] button[data-test-rebinder]'
|
||||
);
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'r' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'o' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'f' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'l' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Enter' });
|
||||
assert
|
||||
.dom(
|
||||
'[data-test-command-label="Go to Clients"] button[data-test-rebinder]'
|
||||
)
|
||||
.hasText('r o f l');
|
||||
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs`,
|
||||
'typing g c does not do anything, since I re-bound the shortcut'
|
||||
);
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'r' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'o' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'f' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'l' });
|
||||
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/clients`,
|
||||
'typing the newly bound shortcut brings me to clients'
|
||||
);
|
||||
|
||||
await click(
|
||||
'[data-test-command-label="Go to Clients"] button[data-test-rebinder]'
|
||||
);
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'n' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'o' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'p' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'e' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Escape' });
|
||||
assert
|
||||
.dom(
|
||||
'[data-test-command-label="Go to Clients"] button[data-test-rebinder]'
|
||||
)
|
||||
.hasText(
|
||||
'r o f l',
|
||||
'text unchanged when I hit escape during recording'
|
||||
);
|
||||
|
||||
await click(
|
||||
'[data-test-command-label="Go to Clients"] button.reset-to-default'
|
||||
);
|
||||
assert
|
||||
.dom(
|
||||
'[data-test-command-label="Go to Clients"] button[data-test-rebinder]'
|
||||
)
|
||||
.hasText('g c', 'Resetting to default rebinds the shortcut');
|
||||
});
|
||||
|
||||
test('Rebound shortcuts persist from localStorage', async function (assert) {
|
||||
window.localStorage.setItem(
|
||||
'keyboard.command.Go to Clients',
|
||||
JSON.stringify(['b', 'o', 'o', 'p'])
|
||||
);
|
||||
await visit('/');
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'g' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'c' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs`,
|
||||
'After a refresh with a localStorage-found binding, a default key binding doesnt do anything'
|
||||
);
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'b' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'o' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'o' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'p' });
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/clients`,
|
||||
'end up on the clients page after typing the localstorage-bound shortcut'
|
||||
);
|
||||
|
||||
assert.notOk(Layout.keyboard.modalShown);
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '?' });
|
||||
assert.ok(Layout.keyboard.modalShown);
|
||||
assert
|
||||
.dom(
|
||||
'[data-test-command-label="Go to Clients"] button[data-test-rebinder]'
|
||||
)
|
||||
.hasText('b o o p');
|
||||
});
|
||||
});
|
||||
|
||||
module('Hints', function () {
|
||||
test('Hints show up on shift', async function (assert) {
|
||||
await visit('/');
|
||||
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Shift' });
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-test-keyboard-hint]').length,
|
||||
7,
|
||||
'Shows 7 hints by default'
|
||||
);
|
||||
await triggerEvent('.page-layout', 'keyup', { key: 'Shift' });
|
||||
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-test-keyboard-hint]').length,
|
||||
0,
|
||||
'Hints disappear when you release Shift'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('Dynamic Nav', function (dynamicHooks) {
|
||||
dynamicHooks.beforeEach(async function () {
|
||||
server.create('node');
|
||||
});
|
||||
test('Dynamic Table Nav', async function (assert) {
|
||||
assert.expect(4);
|
||||
server.createList('job', 3, { createRecommendations: true });
|
||||
await visit('/jobs');
|
||||
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Shift' });
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-shortcut="Shift+01"]').length,
|
||||
1,
|
||||
'First job gets a shortcut hint'
|
||||
);
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-shortcut="Shift+02"]').length,
|
||||
1,
|
||||
'Second job gets a shortcut hint'
|
||||
);
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-shortcut="Shift+03"]').length,
|
||||
1,
|
||||
'Third job gets a shortcut hint'
|
||||
);
|
||||
|
||||
triggerEvent('.page-layout', 'keydown', { key: 'Shift' });
|
||||
triggerEvent('.page-layout', 'keydown', { key: '0' });
|
||||
await triggerEvent('.page-layout', 'keydown', { key: '1' });
|
||||
|
||||
const clickedJob = server.db.jobs.sortBy('modifyIndex').reverse()[0].id;
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs/${clickedJob}@default`,
|
||||
'Shift+01 takes you to the first job'
|
||||
);
|
||||
});
|
||||
test('Multi-Table Nav', async function (assert) {
|
||||
server.createList('job', 3, { createRecommendations: true });
|
||||
await visit(
|
||||
`/jobs/${server.db.jobs.sortBy('modifyIndex').reverse()[0].id}@default`
|
||||
);
|
||||
const numberOfGroups = findAll('.task-group-row').length;
|
||||
const numberOfAllocs = findAll('.allocation-row').length;
|
||||
|
||||
await triggerEvent('.page-layout', 'keydown', { key: 'Shift' });
|
||||
[...Array(numberOfGroups + numberOfAllocs)].forEach((_, iter) => {
|
||||
assert.equal(
|
||||
document.querySelectorAll(`[data-shortcut="Shift+0${iter + 1}"]`)
|
||||
.length,
|
||||
1,
|
||||
`Dynamic item #${iter + 1} gets a shortcut hint`
|
||||
);
|
||||
});
|
||||
await triggerEvent('.page-layout', 'keyup', { key: 'Shift' });
|
||||
});
|
||||
|
||||
test('Dynamic nav arrows and looping', async function (assert) {
|
||||
server.createList('job', 3, { createAllocations: true, type: 'system' });
|
||||
const jobID = server.db.jobs.sortBy('modifyIndex').reverse()[0].id;
|
||||
await visit(`/jobs/${jobID}@default`);
|
||||
|
||||
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
|
||||
shiftKey: true,
|
||||
});
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs/${jobID}@default/definition`,
|
||||
'Shift+ArrowRight takes you to the next tab (Definition)'
|
||||
);
|
||||
|
||||
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
|
||||
shiftKey: true,
|
||||
});
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs/${jobID}@default/versions`,
|
||||
'Shift+ArrowRight takes you to the next tab (Version)'
|
||||
);
|
||||
|
||||
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
|
||||
shiftKey: true,
|
||||
});
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs/${jobID}@default/allocations`,
|
||||
'Shift+ArrowRight takes you to the next tab (Allocations)'
|
||||
);
|
||||
|
||||
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
|
||||
shiftKey: true,
|
||||
});
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs/${jobID}@default/evaluations`,
|
||||
'Shift+ArrowRight takes you to the next tab (Evaluations)'
|
||||
);
|
||||
|
||||
await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
|
||||
shiftKey: true,
|
||||
});
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
`/jobs/${jobID}@default`,
|
||||
'Shift+ArrowRight takes you to the first tab in the loop'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -27,7 +27,6 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
|
|||
<div class="inner-content">Inner content</div>
|
||||
</JobPage::Parts::Body>
|
||||
`);
|
||||
|
||||
assert.ok(find('[data-test-subnav="job"]'), 'Job subnav is rendered');
|
||||
});
|
||||
|
||||
|
@ -36,7 +35,7 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
|
|||
|
||||
const store = this.owner.lookup('service:store');
|
||||
const job = await store.createRecord('job', {
|
||||
id: 'service-job',
|
||||
id: '["service-job","default"]',
|
||||
type: 'service',
|
||||
});
|
||||
|
||||
|
@ -51,7 +50,6 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
|
|||
const subnavLabels = findAll('[data-test-tab]').map((anchor) =>
|
||||
anchor.textContent.trim()
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
subnavLabels.some((label) => label === 'Definition'),
|
||||
'Definition link'
|
||||
|
@ -72,7 +70,7 @@ module('Integration | Component | job-page/parts/body', function (hooks) {
|
|||
test('the subnav does not include the deployments link when the job is not a service', async function (assert) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const job = await store.createRecord('job', {
|
||||
id: 'batch-job',
|
||||
id: '["batch-job","default"]',
|
||||
type: 'batch',
|
||||
});
|
||||
|
||||
|
|
|
@ -107,4 +107,8 @@ export default create({
|
|||
isDanger: hasClass('is-danger', '[data-test-inline-error]'),
|
||||
isWarning: hasClass('is-warning', '[data-test-inline-error]'),
|
||||
},
|
||||
|
||||
keyboard: {
|
||||
modalShown: isPresent('.keyboard-shortcuts'),
|
||||
},
|
||||
});
|
||||
|
|
41
ui/yarn.lock
41
ui/yarn.lock
|
@ -2480,6 +2480,16 @@
|
|||
resolved "https://registry.yarnpkg.com/@ember/edition-utils/-/edition-utils-1.2.0.tgz#a039f542dc14c8e8299c81cd5abba95e2459cfa6"
|
||||
integrity sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog==
|
||||
|
||||
"@ember/legacy-built-in-components@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@ember/legacy-built-in-components/-/legacy-built-in-components-0.4.1.tgz#5c0319be8f8d0b3b43c44912c3710da4fab7b274"
|
||||
integrity sha512-tLxiU1YR+A+002rkGfwyB4FK8bO5qqU/3c7cZ1z2j3XG+1T28Yg2iZuMxPwFJ0LsE//mhRFkWlGzO3tJUtMHbA==
|
||||
dependencies:
|
||||
"@embroider/macros" "^1.0.0"
|
||||
ember-cli-babel "^7.26.6"
|
||||
ember-cli-htmlbars "^5.7.1"
|
||||
ember-cli-typescript "^4.1.0"
|
||||
|
||||
"@ember/optional-features@2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@ember/optional-features/-/optional-features-2.0.0.tgz#c809abd5a27d5b0ef3c6de3941334ab6153313f0"
|
||||
|
@ -9778,6 +9788,22 @@ ember-cli-typescript@^4.0.0, ember-cli-typescript@^4.1.0, ember-cli-typescript@^
|
|||
stagehand "^1.0.0"
|
||||
walk-sync "^2.2.0"
|
||||
|
||||
ember-cli-typescript@^5.0.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-5.1.0.tgz#460eb848564e29d64f2b36b2a75bbe98172b72a4"
|
||||
integrity sha512-wEZfJPkjqFEZAxOYkiXsDrJ1HY75e/6FoGhQFg8oNFJeGYpIS/3W0tgyl1aRkSEEN1NRlWocDubJ4aZikT+RTA==
|
||||
dependencies:
|
||||
ansi-to-html "^0.6.15"
|
||||
broccoli-stew "^3.0.0"
|
||||
debug "^4.0.0"
|
||||
execa "^4.0.0"
|
||||
fs-extra "^9.0.1"
|
||||
resolve "^1.5.0"
|
||||
rsvp "^4.8.1"
|
||||
semver "^7.3.2"
|
||||
stagehand "^1.0.0"
|
||||
walk-sync "^2.2.0"
|
||||
|
||||
ember-cli-version-checker@^2.1.0, ember-cli-version-checker@^2.1.2:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-version-checker/-/ember-cli-version-checker-2.2.0.tgz#47771b731fe0962705e27c8199a9e3825709f3b3"
|
||||
|
@ -10180,15 +10206,15 @@ ember-modifier-manager-polyfill@^1.2.0:
|
|||
ember-cli-version-checker "^2.1.2"
|
||||
ember-compatibility-helpers "^1.2.0"
|
||||
|
||||
"ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0, ember-modifier@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.1.0.tgz#ba5b0941302accd787ed3dcfc8d20400b77ffc41"
|
||||
integrity sha512-G5Lj9jVFsD2sVJcRNQfaGKG1p81wT4LGfClBhCuB4TgwP1NGJKdqI+Q8BW2MptONxQt/71UjjUH0YK7Gm9eahg==
|
||||
ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0:
|
||||
version "3.2.7"
|
||||
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.2.7.tgz#f2d35b7c867cbfc549e1acd8d8903c5ecd02ea4b"
|
||||
integrity sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==
|
||||
dependencies:
|
||||
ember-cli-babel "^7.26.6"
|
||||
ember-cli-normalize-entity-name "^1.0.0"
|
||||
ember-cli-string-utils "^1.1.0"
|
||||
ember-cli-typescript "^4.2.1"
|
||||
ember-cli-typescript "^5.0.0"
|
||||
ember-compatibility-helpers "^1.2.5"
|
||||
|
||||
ember-moment@^9.0.1:
|
||||
|
@ -18685,6 +18711,11 @@ testem@^3.0.3, testem@^3.2.0:
|
|||
tap-parser "^7.0.0"
|
||||
tmp "0.0.33"
|
||||
|
||||
tether@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tether/-/tether-2.0.0.tgz#8ccb03992e71e2e913e6b1d4feb6be8cb49a4f79"
|
||||
integrity sha512-iAkyBhwILpLIvkwzO5w5WUBtpYwxvzLRTO+sbzF3Uy7X4zznsy73v2b4sOQHXE3CQHeSNtB/YMU2Nn9tocbeBQ==
|
||||
|
||||
text-encoder-lite@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/text-encoder-lite/-/text-encoder-lite-2.0.0.tgz#3c865dd6f3720b279c9e370f8f36c831d2cee175"
|
||||
|
|
|
@ -553,3 +553,34 @@ authenticate all future requests to allow read access to additional resources.
|
|||
| Path | Produces |
|
||||
| --------------------- | ----------- |
|
||||
| `/ui/settings/tokens` | `text/html` |
|
||||
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
The Nomad UI supports several keyboard shortcuts in order to help users navigate and operate Nomad. You can use common key commands to dig into jobs, view logs, monitor evaluations, and more.
|
||||
|
||||
Type `?` from anywhere in the UI to launch the Keyboard Shortcuts panel.
|
||||
|
||||
### Default key commands:
|
||||
|
||||
| Command | Pattern |
|
||||
| --------------------- | ----------- |
|
||||
| Go to Jobs | `g j` |
|
||||
| Go to Storage | `g r` |
|
||||
| Go to Servers | `g s` |
|
||||
| Go to Clients | `g c` |
|
||||
| Go to Topology | `g t` |
|
||||
| Go to Evaluations | `g e` |
|
||||
| Go to ACL Tokens | `g a` |
|
||||
| Next Subnav | `Shift + →` |
|
||||
| Previous Subnav | `Shift + ←` |
|
||||
| Next Main Section | `Shift + ↓` |
|
||||
| Previous Main Section | `Shift + ↑` |
|
||||
| Show Keyboard Shortcuts | `Shift + ?` |
|
||||
| Hide Keyboard Shortcuts | `Escape` |
|
||||
| Go Up a Level | `u` |
|
||||
|
||||
### Rebinding and Disabling Commands
|
||||
|
||||
From the Keyboard Shortcuts modal, you can click on any pattern to re-bind it to the shortcut of your choosing. This shortcut will persist via your browser's local storage and across refreshes. You can also toggle "Keyboard shortcuts enabled" to disable them completely.
|
||||
|
||||
|
|
Loading…
Reference in New Issue