Skip to content

Commit

Permalink
#85: hotkeys
Browse files Browse the repository at this point in the history
  • Loading branch information
aschonfeld committed Jul 6, 2020
1 parent d1e8ffe commit 2cda1bb
Show file tree
Hide file tree
Showing 12 changed files with 240 additions and 29 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ D-Tale was the product of a SAS to Python conversion. What was originally a per
- [Column Menu Functions](#column-menu-functions)
- [Filtering](#filtering), [Moving Columns](#moving-columns), [Hiding Columns](#hiding-columns), [Delete](#delete), [Rename](#rename), [Replacements](#replacements), [Lock](#lock), [Unlock](#unlock), [Sorting](#sorting), [Formats](#formats), [Column Analysis](#column-analysis)
- [Charts](#charts)
- [Hotkeys](#hotkeys)
- [Menu Functions Depending on Browser Dimensions](#menu-functions-depending-on-browser-dimensions)
- [For Developers](#for-developers)
- [Cloning](#cloning)
Expand Down Expand Up @@ -929,6 +930,22 @@ Based on the data type of a column different charts will be shown.

**Category (Category Breakdown)** when viewing float columns you can also see them broken down by a categorical column (string, date, int, etc...). This means that when you select a category column this will then display the frequency of each category in a line as well as bars based on the float column you're analyzing grouped by that category and computed by your aggregation (default: mean).

### Hotkeys

These are key combinations you can use in place of clicking actual buttons to save a little time:

| Keymap | Action |
|-------------|----------------|
|`shift+m` | Opens main menu*|
|`shift+d` | Opens "Describe" page*|
|`shift+f` | Opens "Custom Filter"*|
|`shift+b` | Opens "Build Column"*|
|`shift+c` | Opens "Charts" page*|
|`shift+x` | Opens "Code Export"*|
|`esc` | Closes any open modal window & exits cell editing|

`*` Does not fire if user is actively editing a cell.

### Menu Functions Depending on Browser Dimensions
Depending on the dimensions of your browser window the following buttons will not open modals, but rather separate browser windows: Correlations, Describe & Instances (see images from [Jupyter Notebook](#jupyter-notebook), also Charts will always open in a separate browser window)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"react": "16.13.1",
"react-addons-shallow-compare": "15.6.2",
"react-dom": "16.13.1",
"react-hotkeys": "^2.0.0",
"react-modal-bootstrap": "1.1.1",
"react-motion": "0.5.2",
"react-redux": "7.2.0",
Expand Down
108 changes: 108 additions & 0 deletions static/__tests__/dtale/DtaleHotkeys-test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { mount } from "enzyme";
import _ from "lodash";
import React from "react";
import { GlobalHotKeys } from "react-hotkeys";
import { Provider } from "react-redux";

import { expect, it } from "@jest/globals";

import { DtaleHotkeys, ReactDtaleHotkeys } from "../../dtale/DtaleHotkeys";
import menuUtils from "../../menuUtils";
import reduxUtils from "../redux-test-utils";
import { buildInnerHTML } from "../test-utils";

describe("DtaleHotkeys tests", () => {
const { open, innerWidth, innerHeight } = window;
let result, propagateState, openChart, buildClickHandlerSpy;

beforeAll(() => {
propagateState = jest.fn();
openChart = jest.fn();
delete window.open;
window.open = jest.fn();
window.innerHeight = 800;
window.innerWidth = 1400;
});

beforeEach(() => {
buildClickHandlerSpy = jest.spyOn(menuUtils, "buildClickHandler");
buildInnerHTML({ settings: "" });
result = mount(<ReactDtaleHotkeys {...{ propagateState, dataId: "1", openChart }} />, {
attachTo: document.getElementById("content"),
});
});

afterEach(() => {
buildClickHandlerSpy.mockClear();
jest.clearAllMocks();
});

afterAll(() => {
window.open = open;
window.innerHeight = innerHeight;
window.innerWidth = innerWidth;
});

it("renders GlobalHotKeys", () => {
expect(result.find(GlobalHotKeys).length).toBe(1);
});

it("does not render when cell being edited", () => {
result.setProps({ editedCell: "true" });
expect(result.find(GlobalHotKeys).length).toBe(0);
});

it("sets state and fires click handler on menu open", () => {
const hotkeys = result.find(GlobalHotKeys);
const menuHandler = hotkeys.prop("handlers").MENU;
menuHandler();
expect(propagateState.mock.calls).toHaveLength(1);
expect(propagateState.mock.calls[0][0]).toEqual({ menuOpen: true });
expect(buildClickHandlerSpy.mock.calls).toHaveLength(1);
buildClickHandlerSpy.mock.calls[0][1]();
expect(propagateState.mock.calls).toHaveLength(2);
expect(propagateState.mock.calls[1][0]).toEqual({ menuOpen: false });
});

it("opens new tab on describe open", () => {
const hotkeys = result.find(GlobalHotKeys);
const describeHandler = hotkeys.prop("handlers").DESCRIBE;
describeHandler();
expect(window.open.mock.calls).toHaveLength(1);
expect(window.open.mock.calls[0][0]).toBe("/dtale/popup/describe/1");
});

it("calls window.open on code export", () => {
const hotkeys = result.find(GlobalHotKeys);
const codeHandler = hotkeys.prop("handlers").CODE;
codeHandler();
expect(window.open.mock.calls).toHaveLength(1);
expect(window.open.mock.calls[0][0]).toBe("/dtale/popup/code-export/1");
});

it("calls window.open on code export", () => {
const hotkeys = result.find(GlobalHotKeys);
const chartsHandler = hotkeys.prop("handlers").CHARTS;
chartsHandler();
expect(window.open.mock.calls).toHaveLength(1);
expect(window.open.mock.calls[0][0]).toBe("/charts/1");
});

it("calls openChart from redux", () => {
const store = reduxUtils.createDtaleStore();
buildInnerHTML({ settings: "" }, store);
const reduxResult = mount(
<Provider store={store}>
<DtaleHotkeys propagateState={propagateState} />
</Provider>,
{ attachTo: document.getElementById("content") }
);
const hotkeys = reduxResult.find(GlobalHotKeys);
const filterHandler = hotkeys.prop("handlers").FILTER;
filterHandler();
expect(_.pick(store.getState().chartData, ["type", "visible"])).toEqual({
type: "filter",
visible: true,
});
});
});
2 changes: 2 additions & 0 deletions static/dtale/DataViewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { fetchJsonPromise, logException } from "../fetcher";
import { Popup } from "../popups/Popup";
import Formatting from "../popups/formats/Formatting";
import { DataViewerInfo } from "./DataViewerInfo";
import { DtaleHotkeys } from "./DtaleHotkeys";
import { GridCell } from "./GridCell";
import { MeasureText } from "./MeasureText";
import { ColumnMenu } from "./column/ColumnMenu";
Expand Down Expand Up @@ -222,6 +223,7 @@ class ReactDataViewer extends React.Component {
const { formattingOpen } = this.state;
return (
<div key={1} style={{ height: "100%", width: "100%" }} onClick={this.handleClicks}>
<DtaleHotkeys propagateState={this.propagateState} />
<InfiniteLoader
isRowLoaded={({ index }) => _.has(this.state, ["data", index])}
loadMoreRows={_.noop}
Expand Down
45 changes: 45 additions & 0 deletions static/dtale/DtaleHotkeys.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import _ from "lodash";
import PropTypes from "prop-types";
import React from "react";
import { GlobalHotKeys } from "react-hotkeys";
import { connect } from "react-redux";

import { openChart } from "../actions/charts";
import menuFuncs from "./menu/dataViewerMenuUtils";

class ReactDtaleHotkeys extends React.Component {
constructor(props) {
super(props);
}

render() {
if (this.props.editedCell) {
return null;
}
const keyMap = {
MENU: "shift+m",
DESCRIBE: "shift+d",
FILTER: "shift+f",
BUILD: "shift+b",
CHARTS: "shift+c",
CODE: "shift+x",
};
const handlers = _.pick(menuFuncs.buildHotkeyHandlers(this.props), _.keys(keyMap));
return <GlobalHotKeys keyMap={keyMap} handlers={handlers} />;
}
}
ReactDtaleHotkeys.displayName = "DtaleHotkeys";
ReactDtaleHotkeys.propTypes = {
dataId: PropTypes.string.isRequired, // eslint-disable-line react/no-unused-prop-types
editedCell: PropTypes.string,
propagateState: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
openChart: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
};
const ReduxDtaleHotkeys = connect(
state => _.pick(state, ["dataId", "editedCell"]),
dispatch => ({
openChart: chartProps => dispatch(openChart(chartProps)),
})
)(ReactDtaleHotkeys);

export { ReactDtaleHotkeys, ReduxDtaleHotkeys as DtaleHotkeys };
1 change: 0 additions & 1 deletion static/dtale/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ class ReactHeader extends React.Component {
() => propagateState({ menuOpen: false }),
"div.menu-toggle"
);

return (
<div style={style} className="menu-toggle">
<div className="crossed">
Expand Down
23 changes: 7 additions & 16 deletions static/dtale/menu/DataViewerMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,8 @@ class ReactDataViewerMenu extends React.Component {
render() {
const { hideShutdown, dataId } = this.props;
const iframe = global.top !== global.self;
const openPopup = (type, height = 450, width = 500) => () => {
if (menuFuncs.shouldOpenPopup(height, width)) {
menuFuncs.open(`/dtale/popup/${type}`, dataId, height, width);
} else {
this.props.openChart(_.assignIn({ type, title: _.capitalize(type) }, this.props));
}
};
const openTab = type => () => window.open(menuFuncs.fullPath(`/dtale/popup/${type}`, dataId), "_blank");
const openCodeExport = () => menuFuncs.open("/dtale/popup/code-export", dataId, 450, 700);
const buttonHandlers = menuFuncs.buildHotkeyHandlers(this.props);
const { openTab, openPopup } = buttonHandlers;
const refreshWidths = () =>
this.props.propagateState({
columns: _.map(this.props.columns, c => _.assignIn({}, c)),
Expand Down Expand Up @@ -57,10 +50,10 @@ class ReactDataViewerMenu extends React.Component {
<header className="title-font">D-TALE</header>
<ul>
<XArrayOption columns={_.reject(this.props.columns, { name: "dtale_index" })} />
<DescribeOption open={openTab("describe")} />
<DescribeOption open={buttonHandlers.DESCRIBE} />
<li className="hoverable">
<span className="toggler-action">
<button className="btn btn-plain" onClick={openPopup("filter", 500, 1100)}>
<button className="btn btn-plain" onClick={buttonHandlers.FILTER}>
<i className="fa fa-filter ml-2 mr-4" />
<span className="font-weight-bold">Custom Filter</span>
</button>
Expand All @@ -69,7 +62,7 @@ class ReactDataViewerMenu extends React.Component {
</li>
<li className="hoverable">
<span className="toggler-action">
<button className="btn btn-plain" onClick={openPopup("build", 400, 770)}>
<button className="btn btn-plain" onClick={buttonHandlers.BUILD}>
<i className="ico-build" />
<span className="font-weight-bold">Build Column</span>
</button>
Expand All @@ -96,9 +89,7 @@ class ReactDataViewerMenu extends React.Component {
</li>
<li className="hoverable">
<span className="toggler-action">
<button
className="btn btn-plain"
onClick={() => window.open(menuFuncs.fullPath("/charts", dataId), "_blank")}>
<button className="btn btn-plain" onClick={buttonHandlers.CHARTS}>
<i className="ico-show-chart" />
<span className="font-weight-bold">Charts</span>
</button>
Expand Down Expand Up @@ -167,7 +158,7 @@ class ReactDataViewerMenu extends React.Component {
<InstancesOption open={openPopup("instances", 450, 750)} />
<li className="hoverable">
<span className="toggler-action">
<button className="btn btn-plain" onClick={openCodeExport}>
<button className="btn btn-plain" onClick={buttonHandlers.CODE}>
<i className="ico-code" />
<span className="font-weight-bold">Code Export</span>
</button>
Expand Down
37 changes: 36 additions & 1 deletion static/dtale/menu/dataViewerMenuUtils.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import _ from "lodash";

import { cleanupEndpoint } from "../../actions/url-utils";
import menuUtils from "../../menuUtils";

function updateSort(selectedCols, dir, { sortInfo, propagateState }) {
let updatedSortInfo = _.filter(sortInfo, ([col, _dir]) => !_.includes(selectedCols, col));
Expand Down Expand Up @@ -54,4 +55,38 @@ function shouldOpenPopup(height, width) {
return true;
}

export default { updateSort, buildStyling, fullPath, open, shouldOpenPopup };
function buildHotkeyHandlers(props) {
const { propagateState, openChart, dataId } = props;
const openMenu = () => {
propagateState({ menuOpen: true });
menuUtils.buildClickHandler("gridActions", () => propagateState({ menuOpen: false }));
};
const openPopup = (type, height = 450, width = 500) => () => {
if (shouldOpenPopup(height, width)) {
open(`/dtale/popup/${type}`, dataId, height, width);
} else {
openChart(_.assignIn({ type, title: _.capitalize(type) }, props));
}
};
const openTab = type => () => window.open(fullPath(`/dtale/popup/${type}`, dataId), "_blank");
const openCodeExport = () => open("/dtale/popup/code-export", dataId, 450, 700);
return {
openTab,
openPopup,
MENU: openMenu,
DESCRIBE: openTab("describe"),
FILTER: openPopup("filter", 500, 1100),
BUILD: openPopup("build", 400, 770),
CHARTS: () => window.open(fullPath("/charts", dataId), "_blank"),
CODE: openCodeExport,
};
}

export default {
updateSort,
buildStyling,
fullPath,
open,
shouldOpenPopup,
buildHotkeyHandlers,
};
1 change: 0 additions & 1 deletion static/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ if (_.startsWith(pathname, "/dtale/popup")) {
} else {
const store = createStore(app.store);
store.dispatch(actions.init());

ReactDOM.render(
<Provider store={store}>
<DataViewer settings={settings} />
Expand Down
25 changes: 15 additions & 10 deletions static/menuUtils.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import $ from "jquery";

function buildClickHandler(namespace, close, container = null, clickFilters = () => false) {
$(document).bind(`click.${namespace}`, function (e) {
if (clickFilters(e)) {
return;
}
const unbind = !container || (!container.is(e.target) && container.has(e.target).length === 0);
if (unbind) {
$(document).unbind(`click.${namespace}`);
close();
}
});
}

function openMenu(namespace, open, close, selector = "div.column-toggle", clickFilters = () => false) {
return e => {
const container = $(e.target).closest(selector);
// add handler to close menu
$(document).bind(`click.${namespace}`, function (e) {
if (clickFilters(e)) {
return;
}
if (!container.is(e.target) && container.has(e.target).length === 0) {
$(document).unbind(`click.${namespace}`);
close();
}
});
buildClickHandler(namespace, close, container, clickFilters);
open(e);
};
}

export default { openMenu };
export default { openMenu, buildClickHandler };
2 changes: 2 additions & 0 deletions static/popups/Popup.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import _ from "lodash";
import PropTypes from "prop-types";
import React from "react";
import { GlobalHotKeys } from "react-hotkeys";
import { Modal, ModalClose, ModalHeader, ModalTitle } from "react-modal-bootstrap";
import { connect } from "react-redux";

Expand Down Expand Up @@ -215,6 +216,7 @@ class ReactPopup extends React.Component {
backdrop: backdrop || false,
className: `${type}-modal`,
}}>
<GlobalHotKeys keyMap={{ CLOSE_MODAL: "esc" }} handlers={{ CLOSE_MODAL: onClose }} />
<ModalHeader>
{modalTitle}
<ModalClose onClick={onClose} />
Expand Down
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7234,6 +7234,13 @@ react-dom@16.13.1:
prop-types "^15.6.2"
scheduler "^0.19.1"

react-hotkeys@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/react-hotkeys/-/react-hotkeys-2.0.0.tgz#a7719c7340cbba888b0e9184f806a9ec0ac2c53f"
integrity sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==
dependencies:
prop-types "^15.6.1"

react-input-autosize@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.2.tgz#fcaa7020568ec206bc04be36f4eb68e647c4d8c2"
Expand Down

0 comments on commit 2cda1bb

Please sign in to comment.