diff --git a/package.json b/package.json index 5bae29f8f1..0190ea31c6 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,7 @@ "@types/history": "^4.7.5", "@types/jest": "^29.5.12", "@types/lodash": "^4.14.149", + "@types/mousetrap": "^1.6.15", "@types/node": "^22.18.10", "@types/pikaday": "^1.7.4", "@types/puppeteer": "^2.0.1", diff --git a/src/components/hotkeys/HotkeyContext.js b/src/components/hotkeys/HotkeyContext.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyContext.js rename to src/components/hotkeys/HotkeyContext.js.flow diff --git a/src/components/hotkeys/HotkeyContext.ts b/src/components/hotkeys/HotkeyContext.ts new file mode 100644 index 0000000000..aad13764ae --- /dev/null +++ b/src/components/hotkeys/HotkeyContext.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; + +import type HotkeyService from './HotkeyService'; + +export const HotkeyContext = React.createContext(null); + +HotkeyContext.displayName = 'HotkeyContext'; diff --git a/src/components/hotkeys/HotkeyFriendlyModal.js b/src/components/hotkeys/HotkeyFriendlyModal.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyFriendlyModal.js rename to src/components/hotkeys/HotkeyFriendlyModal.js.flow diff --git a/src/components/hotkeys/HotkeyFriendlyModal.tsx b/src/components/hotkeys/HotkeyFriendlyModal.tsx new file mode 100644 index 0000000000..7cf3499bfa --- /dev/null +++ b/src/components/hotkeys/HotkeyFriendlyModal.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; + +import HotkeyLayer from './HotkeyLayer'; +// @ts-ignore flow import +import { Modal } from '../modal'; + +export interface HotkeyFriendlyModalProps { + /** Modal contents */ + children: React.ReactNode; + /** Additional CSS classname of the `.modal` element */ + className?: string; + /** Whether the modal is open; when false nothing is rendered */ + isOpen?: boolean; + /** Called when the modal requests to close */ + onRequestClose?: Function; + /** Modal title */ + title?: React.ReactNode; +} + +const HotkeyFriendlyModal = ({ isOpen, ...rest }: HotkeyFriendlyModalProps) => { + if (!isOpen) { + return null; + } + + return ( + + + + ); +}; + +export default HotkeyFriendlyModal; diff --git a/src/components/hotkeys/HotkeyFriendlyOverlay.js b/src/components/hotkeys/HotkeyFriendlyOverlay.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyFriendlyOverlay.js rename to src/components/hotkeys/HotkeyFriendlyOverlay.js.flow diff --git a/src/components/hotkeys/HotkeyFriendlyOverlay.tsx b/src/components/hotkeys/HotkeyFriendlyOverlay.tsx new file mode 100644 index 0000000000..a70d4eef21 --- /dev/null +++ b/src/components/hotkeys/HotkeyFriendlyOverlay.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +// @ts-ignore flow import +import { Overlay } from '../flyout'; + +import HotkeyLayer from './HotkeyLayer'; + +export interface HotkeyFriendlyOverlayProps { + /** Overlay contents */ + children: React.ReactNode; + /** Component class names */ + className?: string; + /** Click handler for the overlay */ + onClick?: Function; + /** Called when the overlay requests to close */ + onClose?: Function; + /** Whether the overlay should focus the first focusable element by default */ + shouldDefaultFocus?: boolean; +} + +/* + * Note that this is expected to be used within a Flyout component that only renders this + * when it is actually to be put on screen. + */ +const HotkeyFriendlyOverlay = ({ ...props }: HotkeyFriendlyOverlayProps) => ( + + + +); + +export default HotkeyFriendlyOverlay; diff --git a/src/components/hotkeys/HotkeyHelpModal.js b/src/components/hotkeys/HotkeyHelpModal.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyHelpModal.js rename to src/components/hotkeys/HotkeyHelpModal.js.flow diff --git a/src/components/hotkeys/HotkeyHelpModal.tsx b/src/components/hotkeys/HotkeyHelpModal.tsx new file mode 100644 index 0000000000..253cec556d --- /dev/null +++ b/src/components/hotkeys/HotkeyHelpModal.tsx @@ -0,0 +1,221 @@ +import * as React from 'react'; +import { Component } from 'react'; +import { FormattedMessage } from 'react-intl'; + +// @ts-ignore flow import +import { ModalActions } from '../modal'; +import Button, { ButtonType } from '../button'; +import PlainButton from '../plain-button'; +import DropdownMenu, { MenuToggle } from '../dropdown-menu'; +// @ts-ignore flow import +import { Menu, MenuItem } from '../menu'; +import { HotkeyContext } from './HotkeyContext'; +import HotkeyFriendlyModal from './HotkeyFriendlyModal'; +import type { HotkeyConfig } from './HotkeyRecord'; +import type HotkeyService from './HotkeyService'; + +// @ts-ignore flow import +import commonMessages from '../../common/messages'; +import messages from './messages'; + +import './HotkeyHelpModal.scss'; + +export interface HotkeyHelpModalProps { + /** Whether the help modal is open */ + isOpen?: boolean; + /** Called when the modal requests to close */ + onRequestClose: Function; +} + +interface HotkeyHelpModalState { + currentType: string | null; +} + +const specialCharacters: { [key: string]: React.ReactNode } = { + backspace: '\u232b', + down: '\u2193', + left: '\u2190', + meta: '\u2318', + right: '\u2192', + up: '\u2191', + enter: , + spacebar: , + shift: '\u21e7', + ctrl: , + alt: , + esc: , +}; + +class HotkeyHelpModal extends Component { + static contextType = HotkeyContext; + + context: HotkeyService | null; + + hotkeys: { [type: string]: HotkeyConfig[] }; + + types: string[]; + + constructor(props: HotkeyHelpModalProps) { + super(props); + + this.hotkeys = {}; + this.types = []; + this.state = { + currentType: null, + }; + } + + componentDidMount() { + const hotkeyLayer = this.context; + if (hotkeyLayer) { + this.hotkeys = hotkeyLayer.getActiveHotkeys(); + this.types = hotkeyLayer.getActiveTypes(); + this.setState({ + currentType: this.types.length ? this.types[0] : null, + }); + } + } + + componentDidUpdate({ isOpen: prevIsOpen }: HotkeyHelpModalProps, { currentType: prevType }: HotkeyHelpModalState) { + const { isOpen } = this.props; + const hotkeyLayer = this.context; + + if (!isOpen || !hotkeyLayer) { + return; + } + + // modal is being opened; refresh hotkeys + if (!prevIsOpen && isOpen) { + this.hotkeys = hotkeyLayer.getActiveHotkeys(); + this.types = hotkeyLayer.getActiveTypes(); + } + + if (!prevType && this.types.length) { + this.setState({ + currentType: this.types[0], + }); + } + } + + /** + * Converts a "raw" hotkey to translated JSX version + */ + prettyPrintHotkey = (hotkeyConfig: HotkeyConfig) => { + const hotkeys = Array.isArray(hotkeyConfig.key) ? hotkeyConfig.key : [hotkeyConfig.key]; + + const prettyHotkeys = hotkeys + .map(hotkey => + hotkey.split(' ').reduce((prettyHotkey: React.ReactNode, combo, i) => { + // Convert a "raw" combo to a "pretty" combo: + // e.g. "shift+g" => [ Shift, '+', G ] + const prettyCombo = combo + .split('+') + .map(key => { + // Convert special key characters into their respective icons or translated components: + // e.g. "shift" => "Shift", "meta" => "⌘" + if (key in specialCharacters) { + return specialCharacters[key]; + } + // If it's not a special character, just return the uppercased key: + // e.g. "g" => "G" + return key.length === 1 ? key.toUpperCase() : key; + }) + .map((key, j) => {key}); + // If this hotkey is a sequence of keys, return a translated message to combine them: + // e.g. "Shift+G Shift+A" => "Shift+G then Shift+A" + return i === 0 ? ( + prettyCombo + ) : ( + {prettyHotkey}, + key2: {prettyCombo}, + }} + {...messages.hotkeySequence} + /> + ); + }, [] as React.ReactNode), + ) + .reduce( + (finalHotkey: React.ReactNode[], hotkey, i) => + // For shortcuts with multiple hotkeys, separate each hotkey with a "/" joiner: + // e.g. "Cmd+S Ctrl+S" => "Cmd+S / Ctrl+S" + i === 0 ? [hotkey] : [...finalHotkey, ' / ', hotkey], + [] as React.ReactNode[], + ) as React.ReactNode[]; + + return prettyHotkeys.map((element, i) => {element}); + }; + + renderDropdownMenu() { + const { currentType } = this.state; + + if (!currentType) { + return null; + } + + return ( +
+ + + {currentType} + + + {this.types.map((hotkeyType, i) => ( + this.setState({ currentType: hotkeyType })}> + {hotkeyType} + + ))} + + +
+ ); + } + + renderHotkey = (hotkey: HotkeyConfig, i: number) => ( +
  • +
    {hotkey.description}
    +
    {this.prettyPrintHotkey(hotkey)}
    +
  • + ); + + renderHotkeyList() { + const { currentType } = this.state; + + if (!currentType) { + return null; + } + + const hotkeys = this.hotkeys[currentType]; + + return
      {hotkeys.map(this.renderHotkey)}
    ; + } + + render() { + const { isOpen, onRequestClose } = this.props; + const { currentType } = this.state; + + if (!currentType) { + return null; + } + + return ( + } + > + {this.renderDropdownMenu()} + {this.renderHotkeyList()} + + + + + ); + } +} + +export default HotkeyHelpModal; diff --git a/src/components/hotkeys/HotkeyLayer.js b/src/components/hotkeys/HotkeyLayer.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyLayer.js rename to src/components/hotkeys/HotkeyLayer.js.flow diff --git a/src/components/hotkeys/HotkeyLayer.tsx b/src/components/hotkeys/HotkeyLayer.tsx new file mode 100644 index 0000000000..5bed3b61e3 --- /dev/null +++ b/src/components/hotkeys/HotkeyLayer.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { Component } from 'react'; + +import HotkeyRecord from './HotkeyRecord'; +import type { HotkeyConfig } from './HotkeyRecord'; +import HotkeyService from './HotkeyService'; +import { HotkeyContext } from './HotkeyContext'; + +import Hotkeys from './Hotkeys'; +import HotkeyHelpModal from './HotkeyHelpModal'; + +import './HotkeyLayer.scss'; + +export interface HotkeyLayerProps { + /** Layer contents */ + children?: React.ReactNode; + /** Additional CSS class name applied when the help modal is enabled */ + className?: string; + /** Array of hotkey configs, either in the specified shape, or instances of HotkeyRecord */ + configs?: HotkeyConfig[]; + /** Whether to enable the keyboard shortcut help modal */ + enableHelpModal?: boolean; + /** Shortcut to trigger the help modal, if it's enabled */ + helpModalShortcut?: string; +} + +interface HotkeyLayerState { + isHelpModalOpen: boolean; +} + +class HotkeyLayer extends Component { + static defaultProps = { + helpModalShortcut: '?', + enableHelpModal: false, + }; + + hotkeyService: HotkeyService; + + constructor(props: HotkeyLayerProps) { + super(props); + + this.hotkeyService = new HotkeyService(); + } + + state = { + isHelpModalOpen: false, + }; + + componentWillUnmount() { + this.hotkeyService.destroyLayer(); + } + + getHotkeyConfigs() { + const { configs = [], helpModalShortcut, enableHelpModal } = this.props; + + if (!enableHelpModal) { + return configs; + } + + return [ + new HotkeyRecord({ + key: helpModalShortcut, + handler: () => this.openHelpModal(), + }), + ...configs, + ]; + } + + openHelpModal = () => { + this.setState({ + isHelpModalOpen: true, + }); + }; + + closeHelpModal = () => { + this.setState({ + isHelpModalOpen: false, + }); + }; + + render() { + const { children, className = '', enableHelpModal } = this.props; + + return ( + + + {enableHelpModal ? ( + + + {children} + + ) : ( + children + )} + + + ); + } +} + +export default HotkeyLayer; diff --git a/src/components/hotkeys/HotkeyManager.js b/src/components/hotkeys/HotkeyManager.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyManager.js rename to src/components/hotkeys/HotkeyManager.js.flow diff --git a/src/components/hotkeys/HotkeyManager.ts b/src/components/hotkeys/HotkeyManager.ts new file mode 100644 index 0000000000..fc9cc35f95 --- /dev/null +++ b/src/components/hotkeys/HotkeyManager.ts @@ -0,0 +1,21 @@ +class HotkeyManager { + layerStack: string[] = []; + + setActiveLayer = (layerID: string): void => { + this.layerStack.push(layerID); + }; + + removeLayer = (layerID: string): void => { + this.layerStack = this.layerStack.filter(thisLayerID => thisLayerID !== layerID); + }; + + getActiveLayerID = (): string | null => { + if (this.layerStack.length === 0) { + return null; + } + return this.layerStack[this.layerStack.length - 1]; + }; +} + +// This is a singleton service to maintain the global hotkey layer stack +export default new HotkeyManager(); diff --git a/src/components/hotkeys/HotkeyRecord.js b/src/components/hotkeys/HotkeyRecord.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyRecord.js rename to src/components/hotkeys/HotkeyRecord.js.flow diff --git a/src/components/hotkeys/HotkeyRecord.ts b/src/components/hotkeys/HotkeyRecord.ts new file mode 100644 index 0000000000..7b5283f09f --- /dev/null +++ b/src/components/hotkeys/HotkeyRecord.ts @@ -0,0 +1,32 @@ +import { Record } from 'immutable'; +import noop from 'lodash/noop'; +import PropTypes from 'prop-types'; +import * as React from 'react'; + +export interface HotkeyConfig { + /** Optional description shown in the help modal */ + description?: React.ReactNode | null; + /** Handler invoked when the hotkey is pressed */ + handler: (event: KeyboardEvent, combo?: string) => void; + /** Key or keys that trigger the handler */ + key: string | string[]; + /** Category used to group hotkeys in the help modal */ + type?: string; +} + +const HotkeyRecord = Record({ + description: null as React.ReactNode | null, + handler: noop as HotkeyConfig['handler'], + key: '' as string | string[], + type: undefined as string | undefined, +}); + +const HotkeyPropType = PropTypes.shape({ + description: PropTypes.node, + handler: PropTypes.func.isRequired, + key: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).isRequired, + type: PropTypes.string, +}); + +export { HotkeyPropType }; +export default HotkeyRecord; diff --git a/src/components/hotkeys/HotkeyService.js b/src/components/hotkeys/HotkeyService.js.flow similarity index 100% rename from src/components/hotkeys/HotkeyService.js rename to src/components/hotkeys/HotkeyService.js.flow diff --git a/src/components/hotkeys/HotkeyService.ts b/src/components/hotkeys/HotkeyService.ts new file mode 100644 index 0000000000..9e0bdaa8fe --- /dev/null +++ b/src/components/hotkeys/HotkeyService.ts @@ -0,0 +1,120 @@ +import { OrderedMap, OrderedSet } from 'immutable'; +import Mousetrap from 'mousetrap'; +import uniqueId from 'lodash/uniqueId'; + +import HotkeyManager from './HotkeyManager'; +import type { HotkeyConfig } from './HotkeyRecord'; + +// An instance of this class represents one hotkey "layer" +class HotkeyService { + hotkeys: OrderedMap; + + layerID: string; + + mousetrap: Mousetrap.MousetrapInstance; + + mousetrapEventHandler: (event: KeyboardEvent) => void; + + constructor() { + // create a fake HTML element to grab the event listener from mousetrap. + // hacky, but mousetrap unfortunately doesn't expose this handler :( + this.mousetrap = new Mousetrap({ + addEventListener: (_type: string, callback: (event: KeyboardEvent) => void) => { + this.mousetrapEventHandler = (event: KeyboardEvent) => { + if (HotkeyManager.getActiveLayerID() !== this.layerID) { + return; + } + // event should not propagate past this layer, no matter what + event.stopPropagation(); + callback(event); + }; + }, + } as unknown as Element); + this.reset(); + + this.layerID = uniqueId('hotkey-layer'); + HotkeyManager.setActiveLayer(this.layerID); + + window.addEventListener('keypress', this.mousetrapEventHandler); + window.addEventListener('keydown', this.mousetrapEventHandler); + window.addEventListener('keyup', this.mousetrapEventHandler); + } + + destroyLayer(): void { + HotkeyManager.removeLayer(this.layerID); + window.removeEventListener('keypress', this.mousetrapEventHandler); + window.removeEventListener('keydown', this.mousetrapEventHandler); + window.removeEventListener('keyup', this.mousetrapEventHandler); + } + + reset(): void { + // Use an ordered collection since we ultimately display keys in the order they were added + this.hotkeys = OrderedMap(); + this.mousetrap.reset(); + } + + getActiveHotkeys(): { [type: string]: HotkeyConfig[] } { + // Sort hotkeys into buckets by "type" + return this.hotkeys.toOrderedSet().reduce( + (hotkeys, hotkey) => { + const { type } = hotkey; + if (!type) { + return hotkeys; + } + if (!(type in hotkeys)) { + hotkeys[type] = []; + } + hotkeys[type].push(hotkey); + return hotkeys; + }, + {} as { [type: string]: HotkeyConfig[] }, + ); + } + + getActiveTypes(): string[] { + // Get "types" of hotkeys in sorted order, by first hotkey + // e.g. if the current layer has: + // [ + // { key: 'shift+a', type: 'File Selection' }, + // { key: 'shift+g+a', type: 'Navigation' }, + // { key: 'shift+x', type: 'File Selection' }, + // ] + // then this function would output [ 'File Selection', 'Navigation' ]. + // Used to help generate the hotkey help modal menu options. + return this.hotkeys.reduce((types, { type }) => (type ? types.add(type) : types), OrderedSet()).toJS(); + } + + registerHotkey(hotkeyConfig: HotkeyConfig): void { + const { key, handler } = hotkeyConfig; + const keys = Array.isArray(key) ? key : [key]; + const badKeys = keys.filter(candidate => this.hotkeys.has(candidate)); + const existingConfig = this.hotkeys.get(keys[0]); + + // Ignore the whole config if it has already been registered + if (existingConfig === hotkeyConfig) { + return; + } + + // If any of the keys are being used by another config, abort rudely. + if (badKeys.length !== 0) { + throw new Error(`This app is trying to bind multiple actions to the hot keys: ${badKeys}.`); + } + + this.mousetrap.bind(keys, handler); + keys.forEach(keyBinding => { + this.hotkeys = this.hotkeys.set(keyBinding, hotkeyConfig); + }); + } + + deregisterHotkey(hotkeyConfig: HotkeyConfig): void { + const { key } = hotkeyConfig; + const keys = Array.isArray(key) ? key : [key]; + + keys.forEach(keyBinding => { + this.hotkeys = this.hotkeys.delete(keyBinding); + }); + this.mousetrap.unbind(keys); + } +} + +export default HotkeyService; diff --git a/src/components/hotkeys/Hotkeys.js b/src/components/hotkeys/Hotkeys.js.flow similarity index 100% rename from src/components/hotkeys/Hotkeys.js rename to src/components/hotkeys/Hotkeys.js.flow diff --git a/src/components/hotkeys/Hotkeys.stories.js b/src/components/hotkeys/Hotkeys.stories.tsx similarity index 99% rename from src/components/hotkeys/Hotkeys.stories.js rename to src/components/hotkeys/Hotkeys.stories.tsx index 3c071d1aba..677c103d7e 100644 --- a/src/components/hotkeys/Hotkeys.stories.js +++ b/src/components/hotkeys/Hotkeys.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Blueprint Text/InlineNotice typings reject mixed ReactNode children used in this demo import * as React from 'react'; import { Card, Text, InlineNotice } from '@box/blueprint-web'; diff --git a/src/components/hotkeys/Hotkeys.ts b/src/components/hotkeys/Hotkeys.ts new file mode 100644 index 0000000000..fdb5e244de --- /dev/null +++ b/src/components/hotkeys/Hotkeys.ts @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { Children, Component } from 'react'; + +import { HotkeyContext } from './HotkeyContext'; +import type { HotkeyConfig } from './HotkeyRecord'; +import type HotkeyService from './HotkeyService'; + +export interface HotkeysProps { + /** Single child element to render */ + children?: React.ReactNode; + /** Array of hotkey configs, either in the specified shape, or instances of HotkeyRecord */ + configs: HotkeyConfig[]; +} + +class Hotkeys extends Component { + /* eslint-disable no-underscore-dangle */ + + static contextType = HotkeyContext; + + context: HotkeyService | null; + + componentDidMount() { + const { configs } = this.props; + const hotkeyLayer = this.context; + + if (!hotkeyLayer) { + throw new Error('You must instantiate a HotkeyLayer before using Hotkeys'); + } + + this._addHotkeys(configs); + } + + componentDidUpdate(prevProps: HotkeysProps) { + const { configs: newConfigs } = this.props; + const { configs: prevConfigs } = prevProps; + + const additions = newConfigs.filter(config => prevConfigs.indexOf(config) === -1); + const removals = prevConfigs.filter(config => newConfigs.indexOf(config) === -1); + + this._removeHotkeys(removals); + this._addHotkeys(additions); + } + + componentWillUnmount() { + const { configs } = this.props; + + this._removeHotkeys(configs); + } + + _addHotkeys(hotkeyConfigs: HotkeyConfig[]) { + hotkeyConfigs.forEach(hotkeyConfig => this.context.registerHotkey(hotkeyConfig)); + } + + _removeHotkeys(hotkeyConfigs: HotkeyConfig[]) { + if (this.context) { + hotkeyConfigs.forEach(hotkeyConfig => this.context.deregisterHotkey(hotkeyConfig)); + } + } + + render() { + if (!this.props.children) { + return null; + } + return Children.only(this.props.children); + } +} + +export default Hotkeys; diff --git a/src/components/hotkeys/__tests__/HotkeyFriendlyModal.test.js b/src/components/hotkeys/__tests__/HotkeyFriendlyModal.test.tsx similarity index 85% rename from src/components/hotkeys/__tests__/HotkeyFriendlyModal.test.js rename to src/components/hotkeys/__tests__/HotkeyFriendlyModal.test.tsx index e71efd36c8..78a86cf696 100644 --- a/src/components/hotkeys/__tests__/HotkeyFriendlyModal.test.js +++ b/src/components/hotkeys/__tests__/HotkeyFriendlyModal.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; import HotkeyFriendlyModal from '../HotkeyFriendlyModal'; @@ -11,10 +12,10 @@ describe('components/hotkeys/HotkeyFriendlyModal', () => { ); const hotkeyLayer = wrapper.find('HotkeyLayer'); - expect(hotkeyLayer.length).toBe(1); + expect(hotkeyLayer).toHaveLength(1); expect(hotkeyLayer.prop('enableHelpModal')).toBeFalsy(); - expect(wrapper.find('Modal').length).toBe(1); + expect(wrapper.find('Modal')).toHaveLength(1); }); test('should render null when isOpen is falsy', () => { diff --git a/src/components/hotkeys/__tests__/HotkeyFriendlyOverlay.test.js b/src/components/hotkeys/__tests__/HotkeyFriendlyOverlay.test.tsx similarity index 67% rename from src/components/hotkeys/__tests__/HotkeyFriendlyOverlay.test.js rename to src/components/hotkeys/__tests__/HotkeyFriendlyOverlay.test.tsx index 1f9ec8a28e..09376c7d51 100644 --- a/src/components/hotkeys/__tests__/HotkeyFriendlyOverlay.test.js +++ b/src/components/hotkeys/__tests__/HotkeyFriendlyOverlay.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; +import { shallow } from 'enzyme'; -import HotkeyFriendlyOverlay from '../HotkeyFriendlyOverlay'; +import HotkeyFriendlyOverlay, { HotkeyFriendlyOverlayProps } from '../HotkeyFriendlyOverlay'; describe('components/hotkeys/HotkeyFriendlyOverlay', () => { test('should render a HotkeyLayer and an Overlay', () => { @@ -22,7 +23,7 @@ describe('components/hotkeys/HotkeyFriendlyOverlay', () => { const overlay = wrapper.find('Overlay'); - expect(overlay.props().shouldDefaultFocus).toBe(true); - expect(overlay.props().className).toBe('test-class'); + expect((overlay.props() as HotkeyFriendlyOverlayProps).shouldDefaultFocus).toBe(true); + expect((overlay.props() as HotkeyFriendlyOverlayProps).className).toBe('test-class'); }); }); diff --git a/src/components/hotkeys/__tests__/HotkeyHelpModal.test.js b/src/components/hotkeys/__tests__/HotkeyHelpModal.test.tsx similarity index 76% rename from src/components/hotkeys/__tests__/HotkeyHelpModal.test.js rename to src/components/hotkeys/__tests__/HotkeyHelpModal.test.tsx index d060aeaac7..fde55fc55e 100644 --- a/src/components/hotkeys/__tests__/HotkeyHelpModal.test.js +++ b/src/components/hotkeys/__tests__/HotkeyHelpModal.test.tsx @@ -1,29 +1,42 @@ import * as React from 'react'; import { act } from 'react'; import sinon from 'sinon'; -import { mount } from 'enzyme'; +import { mount, ReactWrapper } from 'enzyme'; import HotkeyRecord from '../HotkeyRecord'; -import HotkeyHelpModal from '../HotkeyHelpModal'; +import HotkeyHelpModal, { HotkeyHelpModalProps } from '../HotkeyHelpModal'; import { HotkeyContext } from '../HotkeyContext'; +import type HotkeyService from '../HotkeyService'; import { HotkeyTestWrapper } from './HotkeyTestWrapper'; const sandbox = sinon.sandbox.create(); +type HotkeyServiceStub = { + getActiveHotkeys: sinon.SinonStub; + getActiveTypes: sinon.SinonStub; +}; + +type HelpModalOpenState = { + isOpen: boolean; +}; + describe('components/hotkeys/components/HotkeyHelpModal', () => { - let HotkeyServiceMock; + let HotkeyServiceMock: HotkeyServiceStub; - const getWrapper = (props = {}, contextValue = HotkeyServiceMock) => { - let wrapper; + const getWrapper = ( + props: Partial = {}, + contextValue: HotkeyServiceStub = HotkeyServiceMock, + ) => { + let wrapper: ReactWrapper; act(() => { wrapper = mount( - + , ); }); wrapper.update(); - const hotkeyHelpModal = wrapper.find('HotkeyHelpModal'); + const hotkeyHelpModal = wrapper.find(HotkeyHelpModal); return { wrapper, hotkeyHelpModal }; }; @@ -43,10 +56,10 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { const { wrapper, hotkeyHelpModal } = getWrapper({ isOpen: true }); const modal = hotkeyHelpModal.find('HotkeyFriendlyModal'); - expect(modal.length).toBe(1); + expect(modal).toHaveLength(1); expect(modal.prop('onRequestClose')).toBeTruthy(); expect(modal.prop('isOpen')).toBeTruthy(); - expect(wrapper.find('ModalActions').length).toBe(1); + expect(wrapper.find('ModalActions')).toHaveLength(1); }); test('should pass isOpen prop to modal when modal is open', () => { @@ -57,7 +70,7 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { }); test('should return null when no hotkeys exist', () => { - const emptyContext = { + const emptyContext: HotkeyServiceStub = { getActiveHotkeys: sandbox.stub().returns({}), getActiveTypes: sandbox.stub().returns([]), }; @@ -70,34 +83,34 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { describe('componentDidUpdate()', () => { test('should set state.currentType when state.currentType is null', () => { const wrapper = mount( - + contextValue={HotkeyServiceMock as unknown as HotkeyService} initialState={{ isOpen: false }} renderChild={state => } />, ); - const hotkeyHelpModal = wrapper.find('HotkeyHelpModal'); - const instance = hotkeyHelpModal.instance(); + const hotkeyHelpModal = wrapper.find(HotkeyHelpModal); + const instance = hotkeyHelpModal.instance() as InstanceType; // Verify that currentType was set to the first available type expect(instance.state.currentType).toBe('other'); }); test('should not call setState when no types are available', () => { - const emptyContext = { + const emptyContext: HotkeyServiceStub = { getActiveHotkeys: sandbox.stub().returns({}), getActiveTypes: sandbox.stub().returns([]), }; const wrapper = mount( - + contextValue={emptyContext as unknown as HotkeyService} initialState={{ isOpen: false }} renderChild={state => } />, ); - const instance = wrapper.find('HotkeyHelpModal').instance(); + const instance = wrapper.find(HotkeyHelpModal).instance() as InstanceType; const setStateSpy = sandbox.spy(instance, 'setState'); act(() => { @@ -111,8 +124,8 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { test('should refresh hotkey and hotkey types from hotkeyService when modal is opened', () => { const wrapper = mount( - + contextValue={HotkeyServiceMock as unknown as HotkeyService} initialState={{ isOpen: false }} renderChild={state => } />, @@ -130,7 +143,7 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { describe('renderDropdownMenu()', () => { test('should render DropdownMenu with correct items', () => { - const customMock = { + const customMock: HotkeyServiceStub = { getActiveHotkeys: sandbox.stub().returns({ hello: [new HotkeyRecord()], hi: [new HotkeyRecord()], @@ -140,24 +153,24 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { }; const { hotkeyHelpModal } = getWrapper({ isOpen: true }, customMock); - const instance = hotkeyHelpModal.instance(); + const instance = hotkeyHelpModal.instance() as InstanceType; // Verify that the component has the correct types expect(instance.types).toEqual(['hello', 'hi', 'hey']); // Verify DropdownMenu is rendered const dropdown = hotkeyHelpModal.find('DropdownMenu'); - expect(dropdown.length).toBe(1); + expect(dropdown).toHaveLength(1); // Verify the dropdown container exists const dropdownContainer = hotkeyHelpModal.find('.hotkey-dropdown'); - expect(dropdownContainer.length).toBe(1); + expect(dropdownContainer).toHaveLength(1); }); }); describe('renderHotkeyList()', () => { test('should render hotkeys for currently selected type', () => { - const customMock = { + const customMock: HotkeyServiceStub = { getActiveHotkeys: sandbox.stub().returns({ navigation: [ { @@ -189,13 +202,13 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { const wrapper = mount( } + renderChild={() => } />, ); - const instance = wrapper.find('HotkeyHelpModal').instance(); + const instance = wrapper.find(HotkeyHelpModal).instance() as InstanceType; act(() => { instance.setState({ currentType: 'navigation' }); @@ -203,7 +216,7 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { wrapper.update(); // should render the two 'navigation' hotkeys - expect(wrapper.find('.hotkey-item').length).toBe(2); + expect(wrapper.find('.hotkey-item')).toHaveLength(2); act(() => { instance.setState({ currentType: 'other' }); @@ -211,7 +224,7 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { wrapper.update(); // should render the three 'other' hotkeys - expect(wrapper.find('.hotkey-item').length).toBe(3); + expect(wrapper.find('.hotkey-item')).toHaveLength(3); }); }); @@ -230,10 +243,10 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { const { hotkeyHelpModal } = getWrapper({ isOpen: true }); // should render one hotkey - expect(hotkeyHelpModal.find('.hotkey-key').children().length).toBe(1); + expect(hotkeyHelpModal.find('.hotkey-key').children()).toHaveLength(1); // kbd elements should be [ "shift", "a", "b", "c" ] - expect(hotkeyHelpModal.find('kbd').length).toBe(4); + expect(hotkeyHelpModal.find('kbd')).toHaveLength(4); }); test('should render all keys when a hotkey has multiple hotkeys', () => { @@ -250,10 +263,10 @@ describe('components/hotkeys/components/HotkeyHelpModal', () => { const { hotkeyHelpModal } = getWrapper({ isOpen: true }); // elements should be [ "shift+a", "/", "alt+a" ] (i.e. length 3) - expect(hotkeyHelpModal.find('.hotkey-key').children().length).toBe(3); + expect(hotkeyHelpModal.find('.hotkey-key').children()).toHaveLength(3); // kbd elements should be [ "shift", "a", "alt", "a" ] - expect(hotkeyHelpModal.find('.hotkey-key kbd').length).toBe(4); + expect(hotkeyHelpModal.find('.hotkey-key kbd')).toHaveLength(4); }); }); }); diff --git a/src/components/hotkeys/__tests__/HotkeyLayer.test.js b/src/components/hotkeys/__tests__/HotkeyLayer.test.tsx similarity index 73% rename from src/components/hotkeys/__tests__/HotkeyLayer.test.js rename to src/components/hotkeys/__tests__/HotkeyLayer.test.tsx index c6da992609..cbdd98e951 100644 --- a/src/components/hotkeys/__tests__/HotkeyLayer.test.js +++ b/src/components/hotkeys/__tests__/HotkeyLayer.test.tsx @@ -3,6 +3,7 @@ import sinon from 'sinon'; import { shallow } from 'enzyme'; import HotkeyRecord from '../HotkeyRecord'; +import type { HotkeyConfig } from '../HotkeyRecord'; import HotkeyLayer from '../HotkeyLayer'; import HotkeyService from '../HotkeyService'; @@ -12,11 +13,13 @@ jest.mock('../HotkeyService'); describe('components/hotkeys/HotkeyLayer', () => { // This is required to prevent actually invoking HotkeyService, which causes // HotkeyService tests to fail - HotkeyService.mockImplementation(() => {}); + (HotkeyService as unknown as jest.Mock).mockImplementation(() => ({})); + + const getInstance = (wrapper: ReturnType) => wrapper.instance() as InstanceType; afterEach(() => { sandbox.verifyAndRestore(); - HotkeyService.mockClear(); + (HotkeyService as unknown as jest.Mock).mockClear(); }); describe('HotkeyContext.Provider', () => { @@ -28,8 +31,8 @@ describe('components/hotkeys/HotkeyLayer', () => { ); const provider = wrapper.find('ContextProvider'); - expect(provider.length).toBe(1); - expect(provider.prop('value')).toEqual(wrapper.instance().hotkeyService); + expect(provider).toHaveLength(1); + expect(provider.prop('value')).toEqual(getInstance(wrapper).hotkeyService); }); }); @@ -43,11 +46,11 @@ describe('components/hotkeys/HotkeyLayer', () => { disableLifecycleMethods: true, }, ); - wrapper.instance().hotkeyService = { + getInstance(wrapper).hotkeyService = { destroyLayer: sandbox.mock(), - }; + } as unknown as HotkeyService; - wrapper.instance().componentWillUnmount(); + getInstance(wrapper).componentWillUnmount(); }); }); @@ -59,7 +62,7 @@ describe('components/hotkeys/HotkeyLayer', () => { , ); - expect(wrapper.find('div.content').length).toBe(1); + expect(wrapper.find('div.content')).toHaveLength(1); }); describe('help modal enabled', () => { @@ -70,8 +73,8 @@ describe('components/hotkeys/HotkeyLayer', () => { , ); - expect(wrapper.find('Hotkeys').length).toBe(1); - expect(wrapper.find('HotkeyHelpModal').length).toBe(1); + expect(wrapper.find('Hotkeys')).toHaveLength(1); + expect(wrapper.find('HotkeyHelpModal')).toHaveLength(1); expect(wrapper.contains(
    hi
    )).toBe(true); }); @@ -90,8 +93,9 @@ describe('components/hotkeys/HotkeyLayer', () => { ); const hotkeys = wrapper.find('Hotkeys'); - expect(hotkeys.prop('configs').length).toBe(2); - expect(hotkeys.prop('configs')[0].key).toEqual('?'); + const configs = hotkeys.prop('configs') as HotkeyConfig[]; + expect(configs).toHaveLength(2); + expect(configs[0].key).toEqual('?'); }); }); @@ -103,7 +107,7 @@ describe('components/hotkeys/HotkeyLayer', () => { , ); - expect(wrapper.find('HotkeyHelpModal').length).toBe(0); + expect(wrapper.find('HotkeyHelpModal')).toHaveLength(0); }); }); }); @@ -112,10 +116,10 @@ describe('components/hotkeys/HotkeyLayer', () => { test('should return "?" shortcut to open help modal when help modal is enabled', () => { const wrapper = shallow(); - sandbox.mock(wrapper.instance()).expects('openHelpModal'); + sandbox.mock(getInstance(wrapper)).expects('openHelpModal'); - const configs = wrapper.instance().getHotkeyConfigs(); - configs[0].handler(); + const configs = getInstance(wrapper).getHotkeyConfigs(); + configs[0].handler({} as KeyboardEvent); expect(configs[0].key).toEqual('?'); }); @@ -126,7 +130,7 @@ describe('components/hotkeys/HotkeyLayer', () => { , ); - const configs = wrapper.instance().getHotkeyConfigs(); + const configs = getInstance(wrapper).getHotkeyConfigs(); expect(configs[0].key).toEqual('!'); }); @@ -137,8 +141,8 @@ describe('components/hotkeys/HotkeyLayer', () => { , ); - const configs = wrapper.instance().getHotkeyConfigs(); - expect(configs.length).toBe(0); + const configs = getInstance(wrapper).getHotkeyConfigs(); + expect(configs).toHaveLength(0); }); }); @@ -146,7 +150,7 @@ describe('components/hotkeys/HotkeyLayer', () => { test('should set state.isHelpModalOpen to true', () => { const wrapper = shallow(); - wrapper.instance().openHelpModal(); + getInstance(wrapper).openHelpModal(); expect(wrapper.state('isHelpModalOpen')).toBe(true); }); @@ -156,7 +160,7 @@ describe('components/hotkeys/HotkeyLayer', () => { test('should set state.isHelpModalOpen to false', () => { const wrapper = shallow(); - wrapper.instance().closeHelpModal(); + getInstance(wrapper).closeHelpModal(); expect(wrapper.state('isHelpModalOpen')).toBe(false); }); diff --git a/src/components/hotkeys/__tests__/HotkeyManager.test.js b/src/components/hotkeys/__tests__/HotkeyManager.test.ts similarity index 53% rename from src/components/hotkeys/__tests__/HotkeyManager.test.js rename to src/components/hotkeys/__tests__/HotkeyManager.test.ts index 6f4bc5628e..bbb90cf3ed 100644 --- a/src/components/hotkeys/__tests__/HotkeyManager.test.js +++ b/src/components/hotkeys/__tests__/HotkeyManager.test.ts @@ -3,27 +3,27 @@ import HotkeyManager from '../HotkeyManager'; describe('components/hotkeys/HotkeyManager', () => { describe('setActiveLayer()', () => { test('should add layer to stack', () => { - HotkeyManager.setActiveLayer(123); + HotkeyManager.setActiveLayer('123'); - expect(HotkeyManager.layerStack[0]).toBe(123); + expect(HotkeyManager.layerStack[0]).toBe('123'); }); }); describe('removeLayer()', () => { test('should remove layer from stack', () => { - HotkeyManager.layerStack = [123, 456, 789]; + HotkeyManager.layerStack = ['123', '456', '789']; - HotkeyManager.removeLayer(456); + HotkeyManager.removeLayer('456'); - expect(HotkeyManager.layerStack).toEqual([123, 789]); + expect(HotkeyManager.layerStack).toEqual(['123', '789']); }); }); describe('getActiveLayerID()', () => { test('should return layer on the top of the stack', () => { - HotkeyManager.layerStack = [123, 456, 789]; + HotkeyManager.layerStack = ['123', '456', '789']; - expect(HotkeyManager.getActiveLayerID()).toBe(789); + expect(HotkeyManager.getActiveLayerID()).toBe('789'); }); }); }); diff --git a/src/components/hotkeys/__tests__/HotkeyService.test.js b/src/components/hotkeys/__tests__/HotkeyService.test.ts similarity index 77% rename from src/components/hotkeys/__tests__/HotkeyService.test.js rename to src/components/hotkeys/__tests__/HotkeyService.test.ts index a2a2405a66..b499c18668 100644 --- a/src/components/hotkeys/__tests__/HotkeyService.test.js +++ b/src/components/hotkeys/__tests__/HotkeyService.test.ts @@ -1,10 +1,11 @@ -import { OrderedSet } from 'immutable'; +import { OrderedMap, OrderedSet } from 'immutable'; import sinon from 'sinon'; import Mousetrap from 'mousetrap'; import HotkeyService from '../HotkeyService'; import HotkeyRecord from '../HotkeyRecord'; +import type { HotkeyConfig } from '../HotkeyRecord'; import HotkeyManager from '../HotkeyManager'; jest.mock('../HotkeyManager', () => ({ @@ -17,13 +18,13 @@ jest.mock('mousetrap'); const sandbox = sinon.sandbox.create(); describe('components/hotkeys/HotkeyService', () => { - let instance; - let callbackSpy; + let instance: HotkeyService; + let callbackSpy: jest.Mock; beforeEach(() => { callbackSpy = jest.fn(); - const MousetrapMock = el => { + const MousetrapMock = (el: { addEventListener: (type: string, callback: EventListener) => void }) => { el.addEventListener('keydown', callbackSpy); return { bind: sandbox.spy(), @@ -32,7 +33,7 @@ describe('components/hotkeys/HotkeyService', () => { }; }; - Mousetrap.mockImplementation(MousetrapMock); + (Mousetrap as unknown as jest.Mock).mockImplementation(MousetrapMock); instance = new HotkeyService(); }); @@ -50,10 +51,7 @@ describe('components/hotkeys/HotkeyService', () => { }); test('should add event listeners', () => { - sandbox - .mock(window) - .expects('addEventListener') - .thrice(); + sandbox.mock(window).expects('addEventListener').thrice(); instance = new HotkeyService(); }); @@ -65,20 +63,20 @@ describe('components/hotkeys/HotkeyService', () => { describe('mousetrapEventHandler()', () => { test('should call stopPropagation and callback when this layer is currently active', () => { - HotkeyManager.getActiveLayerID.mockReturnValueOnce(instance.layerID); + (HotkeyManager.getActiveLayerID as jest.Mock).mockReturnValueOnce(instance.layerID); const stopPropagation = jest.fn(); - instance.mousetrapEventHandler({ stopPropagation }); + instance.mousetrapEventHandler({ stopPropagation } as unknown as KeyboardEvent); expect(stopPropagation).toHaveBeenCalled(); expect(callbackSpy).toHaveBeenCalled(); }); test('should immediately return when this layer is not currently active', () => { - HotkeyManager.getActiveLayerID.mockReturnValueOnce(`${instance.layerID}-not-this-layer`); + (HotkeyManager.getActiveLayerID as jest.Mock).mockReturnValueOnce(`${instance.layerID}-not-this-layer`); const stopPropagation = jest.fn(); - instance.mousetrapEventHandler({ stopPropagation }); + instance.mousetrapEventHandler({ stopPropagation } as unknown as KeyboardEvent); expect(stopPropagation).not.toHaveBeenCalled(); }); @@ -86,10 +84,7 @@ describe('components/hotkeys/HotkeyService', () => { describe('destroyLayer()', () => { test('should remove event listeners', () => { - sandbox - .mock(window) - .expects('removeEventListener') - .thrice(); + sandbox.mock(window).expects('removeEventListener').thrice(); instance.destroyLayer(); }); @@ -102,13 +97,14 @@ describe('components/hotkeys/HotkeyService', () => { describe('reset()', () => { test('should reset hotkeys and call mousetrap.reset() when called', () => { - instance.hotkeys = new OrderedSet(['hi', 'hello']); + // Test only cares that reset() clears the collection; OrderedSet stands in for OrderedMap + instance.hotkeys = OrderedSet(['hi', 'hello']) as unknown as OrderedMap; instance.reset(); expect(instance.hotkeys.size).toEqual(0); // called twice bc this.reset() is called in the constructor - expect(instance.mousetrap.reset.calledTwice).toBe(true); + expect((instance.mousetrap.reset as sinon.SinonSpy).calledTwice).toBe(true); }); }); @@ -126,9 +122,9 @@ describe('components/hotkeys/HotkeyService', () => { key: 'c', type: 'preview', }); - const hotkeys = new OrderedSet([navigationHotkey, otherHotkey, previewHotkey]); + const hotkeys = OrderedSet([navigationHotkey, otherHotkey, previewHotkey]); - instance.hotkeys = hotkeys; + instance.hotkeys = hotkeys as unknown as OrderedMap; const expected = { navigation: [navigationHotkey], @@ -154,9 +150,9 @@ describe('components/hotkeys/HotkeyService', () => { key: 'c', type: 'preview', }); - const hotkeys = new OrderedSet([navigationHotkey, otherHotkey, previewHotkey, navigationHotkey]); + const hotkeys = OrderedSet([navigationHotkey, otherHotkey, previewHotkey, navigationHotkey]); - instance.hotkeys = hotkeys; + instance.hotkeys = hotkeys as unknown as OrderedMap; const expected = ['navigation', 'other', 'preview']; @@ -173,7 +169,7 @@ describe('components/hotkeys/HotkeyService', () => { instance.registerHotkey(config); expect(instance.hotkeys.contains(config)).toBe(true); - expect(instance.mousetrap.bind.calledOnce).toBe(true); + expect((instance.mousetrap.bind as sinon.SinonSpy).calledOnce).toBe(true); }); test('should ignore the request to register if the config was already registered', () => { @@ -184,7 +180,7 @@ describe('components/hotkeys/HotkeyService', () => { instance.registerHotkey(config); instance.registerHotkey(config); - expect(instance.mousetrap.bind.calledOnce).toBe(true); + expect((instance.mousetrap.bind as sinon.SinonSpy).calledOnce).toBe(true); }); test('should throw an exception if a key is already in use by another config', () => { @@ -204,9 +200,9 @@ describe('components/hotkeys/HotkeyService', () => { }); describe('deregisterHotkey()', () => { - let hotkeyConfigA; - let hotkeyConfigB; - let hotkeyConfigC; + let hotkeyConfigA: HotkeyConfig; + let hotkeyConfigB: HotkeyConfig; + let hotkeyConfigC: HotkeyConfig; beforeEach(() => { hotkeyConfigA = new HotkeyRecord({ diff --git a/src/components/hotkeys/__tests__/HotkeyTestWrapper.js b/src/components/hotkeys/__tests__/HotkeyTestWrapper.js deleted file mode 100644 index 8925eb2ada..0000000000 --- a/src/components/hotkeys/__tests__/HotkeyTestWrapper.js +++ /dev/null @@ -1,22 +0,0 @@ -import * as React from 'react'; -import { HotkeyContext } from '../HotkeyContext'; - -/** - * Test wrapper component for hotkey-related tests - * Manages state to test componentDidUpdate behavior - */ -export class HotkeyTestWrapper extends React.Component { - constructor(props) { - super(props); - this.state = props.initialState || {}; - } - - render() { - const { contextValue, renderChild } = this.props; - return ( - - {renderChild(this.state, this.setState.bind(this))} - - ); - } -} diff --git a/src/components/hotkeys/__tests__/HotkeyTestWrapper.tsx b/src/components/hotkeys/__tests__/HotkeyTestWrapper.tsx new file mode 100644 index 0000000000..0fc32bd21b --- /dev/null +++ b/src/components/hotkeys/__tests__/HotkeyTestWrapper.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { HotkeyContext } from '../HotkeyContext'; +import type HotkeyService from '../HotkeyService'; + +interface HotkeyTestWrapperProps> { + /** Value provided to HotkeyContext */ + contextValue?: HotkeyService | null; + /** Initial component state used by renderChild */ + initialState?: TState; + /** Render prop that receives state and setState for update tests */ + renderChild: ( + state: TState, + setState: React.Component, TState>['setState'], + ) => React.ReactNode; +} + +/** + * Test wrapper component for hotkey-related tests + * Manages state to test componentDidUpdate behavior + */ +export class HotkeyTestWrapper> extends React.Component< + HotkeyTestWrapperProps, + TState +> { + constructor(props: HotkeyTestWrapperProps) { + super(props); + this.state = (props.initialState || {}) as TState; + } + + render() { + const { contextValue, renderChild } = this.props; + return ( + + {renderChild(this.state, this.setState.bind(this))} + + ); + } +} diff --git a/src/components/hotkeys/__tests__/Hotkeys.test.js b/src/components/hotkeys/__tests__/Hotkeys.test.tsx similarity index 89% rename from src/components/hotkeys/__tests__/Hotkeys.test.js rename to src/components/hotkeys/__tests__/Hotkeys.test.tsx index 6653b7ae5f..9bff01a43e 100644 --- a/src/components/hotkeys/__tests__/Hotkeys.test.js +++ b/src/components/hotkeys/__tests__/Hotkeys.test.tsx @@ -4,13 +4,19 @@ import sinon from 'sinon'; import { mount, shallow } from 'enzyme'; import HotkeyRecord from '../HotkeyRecord'; +import type { HotkeyConfig } from '../HotkeyRecord'; import { HotkeyContext } from '../HotkeyContext'; +import type HotkeyService from '../HotkeyService'; import { HotkeyTestWrapper } from './HotkeyTestWrapper'; import Hotkeys from '../Hotkeys'; const sandbox = sinon.sandbox.create(); +type HotkeysUpdateState = { + configs: HotkeyConfig[]; +}; + describe('components/hotkeys/Hotkeys', () => { afterEach(() => { sandbox.verifyAndRestore(); @@ -20,7 +26,7 @@ describe('components/hotkeys/Hotkeys', () => { test('should call hotkeyLayer.registerHotkey for each hotkey config', () => { const mockHotkeyLayer = { registerHotkey: sandbox.mock().thrice(), - }; + } as unknown as HotkeyService; mount( @@ -64,10 +70,10 @@ describe('components/hotkeys/Hotkeys', () => { const mockHotkeyLayer = { registerHotkey: sandbox.stub(), deregisterHotkey: sandbox.mock().twice(), - }; + } as unknown as HotkeyService; const wrapper = mount( - contextValue={mockHotkeyLayer} initialState={{ configs }} renderChild={state => ( @@ -103,7 +109,7 @@ describe('components/hotkeys/Hotkeys', () => { // componentDidUpdate would throw when trying to add hotkeys if context is null expect(() => { - wrapper.instance().componentDidUpdate({ + (wrapper.instance() as InstanceType).componentDidUpdate({ configs: [new HotkeyRecord({ key: 'a' })], }); }).toThrow(); @@ -115,7 +121,7 @@ describe('components/hotkeys/Hotkeys', () => { const mockHotkeyLayer = { registerHotkey: sandbox.stub(), deregisterHotkey: sandbox.mock().thrice(), - }; + } as unknown as HotkeyService; const wrapper = mount( @@ -139,7 +145,7 @@ describe('components/hotkeys/Hotkeys', () => { test('should render children', () => { const mockHotkeyLayer = { registerHotkey: sandbox.stub(), - }; + } as unknown as HotkeyService; const wrapper = mount( @@ -155,7 +161,7 @@ describe('components/hotkeys/Hotkeys', () => { test('should render null when no children', () => { const mockHotkeyLayer = { registerHotkey: sandbox.stub(), - }; + } as unknown as HotkeyService; const wrapper = mount( @@ -163,7 +169,7 @@ describe('components/hotkeys/Hotkeys', () => { , ); - expect(wrapper.find('Hotkeys').children().length).toBe(0); + expect(wrapper.find('Hotkeys').children()).toHaveLength(0); }); }); }); diff --git a/src/components/hotkeys/__tests__/__snapshots__/HotkeyFriendlyOverlay.test.js.snap b/src/components/hotkeys/__tests__/__snapshots__/HotkeyFriendlyOverlay.test.tsx.snap similarity index 100% rename from src/components/hotkeys/__tests__/__snapshots__/HotkeyFriendlyOverlay.test.js.snap rename to src/components/hotkeys/__tests__/__snapshots__/HotkeyFriendlyOverlay.test.tsx.snap diff --git a/src/components/hotkeys/index.js b/src/components/hotkeys/index.js.flow similarity index 100% rename from src/components/hotkeys/index.js rename to src/components/hotkeys/index.js.flow diff --git a/src/components/hotkeys/index.ts b/src/components/hotkeys/index.ts new file mode 100644 index 0000000000..a9d57f0526 --- /dev/null +++ b/src/components/hotkeys/index.ts @@ -0,0 +1,11 @@ +export { default as HotkeyFriendlyModal } from './HotkeyFriendlyModal'; +export type { HotkeyFriendlyModalProps } from './HotkeyFriendlyModal'; +export { default as HotkeyHelpModal } from './HotkeyHelpModal'; +export type { HotkeyHelpModalProps } from './HotkeyHelpModal'; +export { default as HotkeyLayer } from './HotkeyLayer'; +export type { HotkeyLayerProps } from './HotkeyLayer'; +export { default as HotkeyRecord, HotkeyPropType } from './HotkeyRecord'; +export type { HotkeyConfig } from './HotkeyRecord'; +export { default as HotkeyService } from './HotkeyService'; +export { default as Hotkeys } from './Hotkeys'; +export type { HotkeysProps } from './Hotkeys'; diff --git a/src/components/hotkeys/messages.js b/src/components/hotkeys/messages.js.flow similarity index 100% rename from src/components/hotkeys/messages.js rename to src/components/hotkeys/messages.js.flow diff --git a/src/components/hotkeys/messages.ts b/src/components/hotkeys/messages.ts new file mode 100644 index 0000000000..b00d5233ec --- /dev/null +++ b/src/components/hotkeys/messages.ts @@ -0,0 +1,47 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + hotkeyModalTitle: { + defaultMessage: 'Keyboard Shortcuts', + description: 'Title for keyboard shortcut help modal', + id: 'boxui.core.hotkeys.hotkeyModalTitle', + }, + enterKey: { + defaultMessage: 'Enter', + description: 'Label for "Enter" key', + id: 'boxui.core.hotkeys.enterKey', + }, + spacebarKey: { + defaultMessage: 'Spacebar', + description: 'Label for "Spacebar" key', + id: 'boxui.core.hotkeys.spacebarKey', + }, + shiftKey: { + defaultMessage: 'Shift', + description: 'Label for "Shift" key', + id: 'boxui.core.hotkeys.shiftKey', + }, + ctrlKey: { + defaultMessage: 'Ctrl', + description: 'Label for "Control" key', + id: 'boxui.core.hotkeys.ctrlKey', + }, + altKey: { + defaultMessage: 'Alt', + description: 'Label for "Alt" key', + id: 'boxui.core.hotkeys.altKey', + }, + escKey: { + defaultMessage: 'Esc', + description: 'Label for "Esc" key', + id: 'boxui.core.hotkeys.escKey', + }, + hotkeySequence: { + defaultMessage: '{key1} then {key2}', + description: + 'Describes a hotkey sequence, e.g. "shift+g then shift+a". {key1} is the first key ("shift+g" in our example) and {key2} is the second ("shift+a" in our example).', + id: 'boxui.core.hotkeys.hotkeySequence', + }, +}); + +export default messages; diff --git a/yarn.lock b/yarn.lock index d145fb8fde..bedb8f9d31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5870,6 +5870,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== +"@types/mousetrap@^1.6.15": + version "1.6.15" + resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86" + integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw== + "@types/ms@*": version "2.1.0" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"