[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:
Phil Renaud 2022-08-17 12:59:33 -04:00 committed by GitHub
parent b63944b5c1
commit cbd4deedf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 1482 additions and 77 deletions

3
.changelog/14138.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
ui: add general keyboard navigation to the Nomad UI
```

View File

@ -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
],
};

View File

@ -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;

View File

@ -0,0 +1,7 @@
import Component from '@glimmer/component';
export default class AppBreadcrumbsComponent extends Component {
isOneCrumbUp(iter = 0, totalNum = 0) {
return iter === totalNum - 2;
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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}}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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(),
},
];
}

View File

@ -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;

View File

@ -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;

View File

@ -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}}

View File

@ -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;
}
}

View File

@ -0,0 +1,6 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class PluginSubnavComponent extends Component {
@service keyboard;
}

View File

@ -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 {}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
export default class StorageSubnavComponent extends Component {
@service keyboard;
}

View File

@ -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;

View File

@ -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',

View File

@ -22,6 +22,7 @@ export default class IndexController extends Controller.extend(
) {
@service system;
@service userSettings;
@service keyboard;
@controller('csi/volumes') volumesController;
@alias('volumesController.isForbidden')

View File

@ -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 });
}

View File

@ -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;
});

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
});
}
}

465
ui/app/services/keyboard.js Normal file
View File

@ -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);
}
}

View File

@ -47,3 +47,4 @@
@import './components/two-step-button';
@import './components/evaluations';
@import './components/secure-variables';
@import './components/keyboard-shortcuts-modal';

View File

@ -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;
}
}

View File

@ -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}}

View File

@ -24,6 +24,8 @@
{{/each}}
</section>
<KeyboardShortcutsModal />
{{#if this.error}}
<div class="error-container">
<div data-test-error class="error-message">

View File

@ -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}}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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}}

View File

@ -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

View File

@ -1,5 +1,5 @@
<LinkTo
<SafeLinkTo
@query={{hash sortProperty=this.prop sortDescending=this.shouldSortDescending}}
data-test-sort-by={{this.prop}}>
{{yield}}
</LinkTo>
</SafeLinkTo>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 />

View File

@ -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">

View File

@ -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>

View File

@ -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}}

View File

@ -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"

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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": {

View File

@ -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'
);
});
});
});

View File

@ -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',
});

View File

@ -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'),
},
});

View File

@ -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"

View File

@ -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.