Skip to content
This repository has been archived by the owner on Dec 7, 2021. It is now read-only.

Commit

Permalink
Keyboard Manager Component (#523)
Browse files Browse the repository at this point in the history
* WIP - Started on keyboard manager

* Added keyboard binding component

* Updated existing unit tests

* Added unit tests for keyboard registration manager

* Added unit tests for keyboard manager component

* Added unit tests for keyboard binding component

* Added unit tests for previous/next asset in editor page

* Updated unit tests for toolbar item

* Removed direct reference to keyboard manager context - no longer needed
  • Loading branch information
wbreza authored Feb 2, 2019
1 parent 488f80a commit 39fb38b
Show file tree
Hide file tree
Showing 17 changed files with 598 additions and 100 deletions.
21 changes: 12 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import "react-toastify/dist/ReactToastify.css";
import IAppErrorActions, * as appErrorActions from "./redux/actions/appErrorActions";
import { bindActionCreators } from "redux";
import { ErrorHandler } from "./react/components/common/errorHandler/errorHandler";
import { KeyboardManager } from "./react/components/common/keyboardManager/keyboardManager";

interface IAppProps {
currentProject?: IProject;
Expand Down Expand Up @@ -62,16 +63,18 @@ export default class App extends React.Component<IAppProps> {
onClearError={this.props.actions.clearError} />
{/* Don't render app contents during a render error */}
{(!this.props.appError || this.props.appError.errorCode !== ErrorCode.GenericRenderError) &&
<Router>
<div className="app-shell">
<Navbar />
<div className="app-main">
<Sidebar project={this.props.currentProject} />
<MainContentRouter />
<KeyboardManager>
<Router>
<div className="app-shell">
<Navbar />
<div className="app-main">
<Sidebar project={this.props.currentProject} />
<MainContentRouter />
</div>
<ToastContainer />
</div>
<ToastContainer />
</div>
</Router >
</Router >
</KeyboardManager>
}
</Fragment>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from "react";
import { mount, ReactWrapper } from "enzyme";
import { KeyboardManager } from "../keyboardManager/keyboardManager";
import { KeyboardBinding, IKeyboardBindingProps } from "./keyboardBinding";

jest.mock("../keyboardManager/keyboardRegistrationManager");
import { KeyboardRegistrationManager } from "../keyboardManager/keyboardRegistrationManager";

describe("Keyboard Binding Component", () => {
let wrapper: ReactWrapper = null;
const onKeyDownHandler = jest.fn();
const deregisterFunc = jest.fn();

const defaultProps: IKeyboardBindingProps = {
accelerator: "Ctrl+1",
onKeyDown: onKeyDownHandler,
};

const registrationMock = KeyboardRegistrationManager as jest.Mocked<typeof KeyboardRegistrationManager>;
registrationMock.prototype.addHandler = jest.fn(() => deregisterFunc);

function createComponent(props?: IKeyboardBindingProps): ReactWrapper {
props = props || defaultProps;

return mount(
<KeyboardManager>
<KeyboardBinding {...props} />
</KeyboardManager>,
);
}

beforeEach(() => {
wrapper = createComponent();
});

it("is defined", () => {
expect(wrapper).not.toBeNull();
});

it("does not render anything", () => {
expect(wrapper.find(KeyboardBinding).html()).toBeNull();
});

it("registered the key code and event handler", () => {
expect(registrationMock.prototype.addHandler).toBeCalledWith(defaultProps.accelerator, defaultProps.onKeyDown);
});

it("deregisters the event handler", () => {
wrapper.unmount();
expect(deregisterFunc).toBeCalled();
});
});
27 changes: 27 additions & 0 deletions src/react/components/common/keyboardBinding/keyboardBinding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { KeyboardContext, IKeyboardContext } from "../keyboardManager/keyboardManager";
import React from "react";

export interface IKeyboardBindingProps {
accelerator: string;
onKeyDown: (evt?: KeyboardEvent) => void;
}

export class KeyboardBinding extends React.Component<IKeyboardBindingProps> {
public static contextType = KeyboardContext;
public context!: IKeyboardContext;
private deregisterBinding: () => void;

public componentDidMount() {
this.deregisterBinding = this.context.keyboard.addHandler(this.props.accelerator, this.props.onKeyDown);
}

public componentWillUnmount() {
if (this.deregisterBinding) {
this.deregisterBinding();
}
}

public render() {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from "react";
import { mount, ReactWrapper } from "enzyme";
import { KeyboardManager, IKeyboardContext } from "./keyboardManager";

jest.mock("./keyboardRegistrationManager");
import { KeyboardRegistrationManager } from "./keyboardRegistrationManager";

describe("Keyboard Manager Component", () => {
let wrapper: ReactWrapper<{}, IKeyboardContext> = null;
let addEventListenerSpy: jest.SpyInstance = null;
let removeEventListenerSpy: jest.SpyInstance = null;

function createComponent(): ReactWrapper<{}, IKeyboardContext> {
return mount(
<KeyboardManager>
<div className="child">Hello - I am a child component</div>
</KeyboardManager>,
);
}

beforeEach(() => {
addEventListenerSpy = jest.spyOn(window, "addEventListener");
removeEventListenerSpy = jest.spyOn(window, "removeEventListener");
wrapper = createComponent();
});

it("is defined", () => {
expect(wrapper).not.toBeNull();
});

it("initial state is defined", () => {
expect(wrapper.state().keyboard).toBeInstanceOf(KeyboardRegistrationManager);
});

it("registers event handlers for keydown events", () => {
expect(addEventListenerSpy).toBeCalled();
expect(removeEventListenerSpy).toBeCalled();
});

it("renders all child components", () => {
expect(wrapper.find(".child").exists()).toBe(true);
});

it("listens for Ctrl+ keydown events and invokes handlers", () => {
const keyboardEvent = new KeyboardEvent("keydown", {
ctrlKey: true,
key: "1",
});

window.dispatchEvent(keyboardEvent);

const registrationManagerMock = KeyboardRegistrationManager as jest.Mocked<typeof KeyboardRegistrationManager>;
expect(registrationManagerMock.prototype.invokeHandlers).toBeCalledWith("Ctrl+1", keyboardEvent);
});

it("listens for Alt+ keydown events and invokes handlers", () => {
const keyboardEvent = new KeyboardEvent("keydown", {
ctrlKey: false,
altKey: true,
key: "1",
});

window.dispatchEvent(keyboardEvent);

const registrationManagerMock = KeyboardRegistrationManager as jest.Mocked<typeof KeyboardRegistrationManager>;
expect(registrationManagerMock.prototype.invokeHandlers).toBeCalledWith("Alt+1", keyboardEvent);
});

it("listens for keydown events and invokes handlers", () => {
const keyboardEvent = new KeyboardEvent("keydown", {
ctrlKey: false,
altKey: false,
key: "F1",
});

window.dispatchEvent(keyboardEvent);

const registrationManagerMock = KeyboardRegistrationManager as jest.Mocked<typeof KeyboardRegistrationManager>;
expect(registrationManagerMock.prototype.invokeHandlers).toBeCalledWith("F1", keyboardEvent);
});
});
49 changes: 49 additions & 0 deletions src/react/components/common/keyboardManager/keyboardManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React from "react";
import { KeyboardRegistrationManager } from "./keyboardRegistrationManager";

export const KeyboardContext = React.createContext<IKeyboardContext>(null);

export interface IKeyboardContext {
keyboard: KeyboardRegistrationManager;
}

export class KeyboardManager extends React.Component<any, IKeyboardContext> {
public static contextType = KeyboardContext;

public state: IKeyboardContext = {
keyboard: new KeyboardRegistrationManager(),
};

public componentDidMount() {
window.addEventListener("keydown", this.onKeyDown);
}

public componentWillUnmount() {
window.removeEventListener("keydown", this.onKeyDown);
}

public render() {
return (
<KeyboardContext.Provider value={this.state}>
{this.props.children}
</KeyboardContext.Provider>
);
}

private onKeyDown = (evt: KeyboardEvent) => {
if (evt.key === "Ctrl" || evt.key === "Control" || evt.key === "Alt") {
return;
}

const keyParts = [];
if (evt.ctrlKey) {
keyParts.push("Ctrl+");
}
if (evt.altKey) {
keyParts.push("Alt+");
}
keyParts.push(evt.key);

this.state.keyboard.invokeHandlers(keyParts.join(""), evt);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { KeyboardRegistrationManager } from "./keyboardRegistrationManager";

describe("Keyboard Registration Manager", () => {
let keyboardManager: KeyboardRegistrationManager = null;

beforeEach(() => {
keyboardManager = new KeyboardRegistrationManager();
});

it("is defined", () => {
expect(KeyboardRegistrationManager).toBeDefined();
expect(keyboardManager).not.toBeNull();
});

it("can add keybard event handlers", () => {
const keyCode1 = "Ctrl+1";
const handler1 = (evt: KeyboardEvent) => null;
const keyCode2 = "Ctrl+S";
const handler2 = (evt: KeyboardEvent) => null;

keyboardManager.addHandler(keyCode1, handler1);
keyboardManager.addHandler(keyCode2, handler2);

const handlers1 = keyboardManager.getHandlers(keyCode1);
const handlers2 = keyboardManager.getHandlers(keyCode2);

expect(handlers1.length).toEqual(1);
expect(handlers2.length).toEqual(1);

expect(handlers1[0]).toBe(handler1);
expect(handlers2[0]).toBe(handler2);
});

it("can register multiple handlers for same key code", () => {
const keyCode = "Ctrl+H";
const handler1 = (evt: KeyboardEvent) => null;
const handler2 = (evt: KeyboardEvent) => null;

keyboardManager.addHandler(keyCode, handler1);
keyboardManager.addHandler(keyCode, handler2);

const handlers = keyboardManager.getHandlers(keyCode);
expect(handlers.length).toEqual(2);
});

it("list of handlers cannot be mutated outside of API", () => {
const keyCode = "Ctrl+K";
const handler1 = (evt: KeyboardEvent) => null;

keyboardManager.addHandler(keyCode, handler1);
const handlers = keyboardManager.getHandlers(keyCode);
const handlerCount = handlers.length;

// Attempt to add more handlers
handlers.push(handler1, handler1, handler1);

const newHandlers = keyboardManager.getHandlers(keyCode);
expect(newHandlers.length).toEqual(handlerCount);
});

it("can remove keyboard event handlers", () => {
const keyCode1 = "Ctrl+1";
const handler1 = (evt: KeyboardEvent) => null;

// Register keyboard handler
const deregister = keyboardManager.addHandler(keyCode1, handler1);

// Get registered handlers
let handlers1 = keyboardManager.getHandlers(keyCode1);
expect(handlers1.length).toEqual(1);

// Invode deregister functions
deregister();

// Get registered handlers after deregistered
handlers1 = keyboardManager.getHandlers(keyCode1);
expect(handlers1.length).toEqual(0);
});

it("get handlers for unregistered key code returns emtpy array", () => {
const handlers = keyboardManager.getHandlers("Alt+1");
expect(handlers.length).toEqual(0);
});

it("invokes registered keyboard handlers", () => {
const keyCode = "Ctrl+1";
const handler1 = jest.fn();
const handler2 = jest.fn();

keyboardManager.addHandler(keyCode, handler1);
keyboardManager.addHandler(keyCode, handler2);

const keyboardEvent = new KeyboardEvent("keydown", {
ctrlKey: true,
code: "1",
});

keyboardManager.invokeHandlers(keyCode, keyboardEvent);

expect(handler1).toBeCalledWith(keyboardEvent);
expect(handler2).toBeCalledWith(keyboardEvent);
});
});
Loading

0 comments on commit 39fb38b

Please sign in to comment.