Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions tests/ui/job-view/KeyboardShortcuts_test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { render, cleanup, fireEvent } from '@testing-library/react';

import KeyboardShortcuts from '../../../ui/job-view/KeyboardShortcuts';

// Fire a keydown the way react-hot-keys / hotkeys-js expects (needs keyCode).
const pressKeyDown = (key, keyCode) =>
fireEvent.keyDown(document.body, { key, keyCode, which: keyCode });

const renderShortcuts = (filterModel) =>
render(
<KeyboardShortcuts
filterModel={filterModel}
showOnScreenShortcuts={jest.fn()}
>
<div key="child">child</div>
</KeyboardShortcuts>,
);

describe('KeyboardShortcuts', () => {
afterEach(cleanup);

// The 'i' shortcut maps to filterModel.toggleInProgress(), which makes it a
// convenient probe for whether the handler actually fired.
const makeFilterModel = () => ({
toggleInProgress: jest.fn(),
removeFilter: jest.fn(),
toggleClassifiedFailures: jest.fn(),
toggleUnscheduledResultStatus: jest.fn(),
toggleUnclassifiedFailures: jest.fn(),
});

it('fires a shortcut handler on keydown', () => {
const filterModel = makeFilterModel();
renderShortcuts(filterModel);

pressKeyDown('i', 73);
fireEvent.keyUp(document.body, { key: 'i', keyCode: 73, which: 73 });

expect(filterModel.toggleInProgress).toHaveBeenCalledTimes(1);
});

// Regression: react-hot-keys keeps an `isKeyDown` latch that is only cleared
// by a keyup on document.body. Shortcuts that open a new tab (l, shift+l, g)
// steal the keyup, leaving the latch stuck `true` so the NEXT shortcut is
// swallowed and must be pressed twice. Losing focus (window blur) must clear
// the latch so the next shortcut works on the first press.
it('recovers after a lost keyup when the window loses focus', () => {
const filterModel = makeFilterModel();
renderShortcuts(filterModel);

// First shortcut fires, but its keyup is "lost" (went to a new tab), so the
// latch stays set.
pressKeyDown('i', 73);
expect(filterModel.toggleInProgress).toHaveBeenCalledTimes(1);

// Without recovery, this second press is swallowed by the stuck latch.
pressKeyDown('i', 73);
expect(filterModel.toggleInProgress).toHaveBeenCalledTimes(1);

// The tab regains focus after the window blurred; the blur handler should
// have cleared the latch.
fireEvent.blur(window);

pressKeyDown('i', 73);
expect(filterModel.toggleInProgress).toHaveBeenCalledTimes(2);
});
});
15 changes: 14 additions & 1 deletion ui/job-view/KeyboardShortcuts.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
import Hotkeys from 'react-hot-keys';

Expand All @@ -17,6 +17,19 @@ const handledKeys =
'b,c,f,ctrl+shift+f,f,g,i,j,k,l,shift+l,n,p,q,r,s,t,u,v,ctrl+shift+u,left,right,space,shift+/,escape,ctrl+enter,ctrl+backspace';

function KeyboardShortcuts({ filterModel, showOnScreenShortcuts, children }) {
// react-hot-keys tracks an internal `isKeyDown` latch that it only clears on
// a keyup bubbling to document.body. Shortcuts that open a new tab (l, shift+l,
// g) steal that keyup, so the latch stays set and the next shortcut is
// swallowed (the "have to press it twice" bug). When the window loses focus,
// synthesize a keyup so the latch is cleared before focus returns.
useEffect(() => {
const clearHeldKeys = () => {
document.body.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true }));
};
window.addEventListener('blur', clearHeldKeys);
return () => window.removeEventListener('blur', clearHeldKeys);
}, []);

const clearScreen = useCallback(() => {
const { pinnedJobs } = usePinnedJobsStore.getState();
const {
Expand Down