Commit 1723ad9b by kay delaney Committed by GitHub

Fixes several usability issues with QueryField component (#18681)

* Fixes several usability issues with QueryField component
- Can now indent with tab and `mod+[`, `mod+]`
- Copy/Cut preserves new lines, and paste correctly splits blocks now
- Adds support for selection hotkeys
parent 1c365420
......@@ -26,6 +26,7 @@
"@types/enzyme-adapter-react-16": "1.0.5",
"@types/expect-puppeteer": "3.3.1",
"@types/file-saver": "2.0.1",
"@types/is-hotkey": "0.1.1",
"@types/jest": "24.0.13",
"@types/jquery": "1.10.35",
"@types/lodash": "4.14.123",
......@@ -203,6 +204,7 @@
"fast-text-encoding": "^1.0.0",
"file-saver": "1.3.8",
"immutable": "3.8.2",
"is-hotkey": "0.1.4",
"jquery": "3.4.1",
"lodash": "4.17.14",
"marked": "0.6.2",
......
......@@ -23,7 +23,7 @@ describe('<QueryField />', () => {
const wrapper = shallow(<QueryField initialQuery="my query" />);
const instance = wrapper.instance() as QueryField;
instance.executeOnChangeAndRunQueries = jest.fn();
const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterAndTabKey');
const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey');
instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {});
expect(handleEnterAndTabKeySpy).toBeCalled();
expect(instance.executeOnChangeAndRunQueries).toBeCalled();
......
......@@ -2,12 +2,13 @@ import _ from 'lodash';
import React, { Context } from 'react';
import ReactDOM from 'react-dom';
// @ts-ignore
import { Change, Value } from 'slate';
import { Change, Range, Value, Block } from 'slate';
// @ts-ignore
import { Editor } from 'slate-react';
// @ts-ignore
import Plain from 'slate-plain-serializer';
import classnames from 'classnames';
import { isKeyHotkey } from 'is-hotkey';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
......@@ -19,6 +20,14 @@ import { makeFragment, makeValue } from '@grafana/ui';
export const TYPEAHEAD_DEBOUNCE = 100;
export const HIGHLIGHT_WAIT = 500;
const SLATE_TAB = ' ';
const isIndentLeftHotkey = isKeyHotkey('mod+[');
const isIndentRightHotkey = isKeyHotkey('mod+]');
const isSelectLeftHotkey = isKeyHotkey('shift+left');
const isSelectRightHotkey = isKeyHotkey('shift+right');
const isSelectUpHotkey = isKeyHotkey('shift+up');
const isSelectDownHotkey = isKeyHotkey('shift+down');
const isSelectLineHotkey = isKeyHotkey('mod+l');
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups
......@@ -305,8 +314,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
.focus();
}
handleEnterAndTabKey = (event: KeyboardEvent, change: Change) => {
const { typeaheadIndex, suggestions } = this.state;
handleEnterKey = (event: KeyboardEvent, change: Change) => {
event.preventDefault();
if (event.shiftKey) {
......@@ -315,7 +323,16 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
} else if (!this.menuEl) {
this.executeOnChangeAndRunQueries();
return true;
} else if (!suggestions || suggestions.length === 0) {
} else {
return this.selectSuggestion(change);
}
};
selectSuggestion = (change: Change) => {
const { typeaheadIndex, suggestions } = this.state;
event.preventDefault();
if (!suggestions || suggestions.length === 0) {
return undefined;
}
......@@ -326,9 +343,132 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
return insertTextOperation ? true : undefined;
};
handleTabKey = (change: Change): void => {
const {
startBlock,
endBlock,
selection: { startOffset, startKey, endOffset, endKey },
} = change.value;
if (this.menuEl) {
this.selectSuggestion(change);
return;
}
const first = startBlock.getFirstText();
const startBlockIsSelected =
startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
if (startBlockIsSelected || !startBlock.equals(endBlock)) {
this.handleIndent(change, 'right');
} else {
change.insertText(SLATE_TAB);
}
};
handleIndent = (change: Change, indentDirection: 'left' | 'right') => {
const curSelection = change.value.selection;
const selectedBlocks = change.value.document.getBlocksAtRange(curSelection);
if (indentDirection === 'left') {
for (const block of selectedBlocks) {
const blockWhitespace = block.text.length - block.text.trimLeft().length;
const rangeProperties = {
anchorKey: block.getFirstText().key,
anchorOffset: blockWhitespace,
focusKey: block.getFirstText().key,
focusOffset: blockWhitespace,
};
// @ts-ignore
const whitespaceToDelete = Range.create(rangeProperties);
change.deleteBackwardAtRange(whitespaceToDelete, Math.min(SLATE_TAB.length, blockWhitespace));
}
} else {
const { startText } = change.value;
const textBeforeCaret = startText.text.slice(0, curSelection.startOffset);
const isWhiteSpace = /^\s*$/.test(textBeforeCaret);
for (const block of selectedBlocks) {
change.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB);
}
if (isWhiteSpace) {
change.moveStart(-SLATE_TAB.length);
}
}
};
handleSelectVertical = (change: Change, direction: 'up' | 'down') => {
const { focusBlock } = change.value;
const adjacentBlock =
direction === 'up'
? change.value.document.getPreviousBlock(focusBlock.key)
: change.value.document.getNextBlock(focusBlock.key);
if (!adjacentBlock) {
return true;
}
const adjacentText = adjacentBlock.getFirstText();
change.moveFocusTo(adjacentText.key, Math.min(change.value.anchorOffset, adjacentText.text.length)).focus();
return true;
};
handleSelectUp = (change: Change) => this.handleSelectVertical(change, 'up');
handleSelectDown = (change: Change) => this.handleSelectVertical(change, 'down');
onKeyDown = (event: KeyboardEvent, change: Change) => {
const { typeaheadIndex } = this.state;
// Shortcuts
if (isIndentLeftHotkey(event)) {
event.preventDefault();
this.handleIndent(change, 'left');
return true;
} else if (isIndentRightHotkey(event)) {
event.preventDefault();
this.handleIndent(change, 'right');
return true;
} else if (isSelectLeftHotkey(event)) {
event.preventDefault();
if (change.value.focusOffset > 0) {
change.moveFocus(-1);
}
return true;
} else if (isSelectRightHotkey(event)) {
event.preventDefault();
if (change.value.focusOffset < change.value.startText.text.length) {
change.moveFocus(1);
}
return true;
} else if (isSelectUpHotkey(event)) {
event.preventDefault();
this.handleSelectUp(change);
return true;
} else if (isSelectDownHotkey(event)) {
event.preventDefault();
this.handleSelectDown(change);
return true;
} else if (isSelectLineHotkey(event)) {
event.preventDefault();
const { focusBlock, document } = change.value;
change.moveAnchorToStartOfBlock(focusBlock.key);
const nextBlock = document.getNextBlock(focusBlock.key);
if (nextBlock) {
change.moveFocusToStartOfNextBlock();
} else {
change.moveFocusToEndOfText();
}
return true;
}
switch (event.key) {
case 'Escape': {
if (this.menuEl) {
......@@ -348,10 +488,13 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
break;
}
case 'Enter':
return this.handleEnterKey(event, change);
case 'Tab': {
return this.handleEnterAndTabKey(event, change);
break;
event.preventDefault();
return this.handleTabKey(change);
}
case 'ArrowDown': {
......@@ -476,10 +619,32 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
);
};
handleCopy = (event: ClipboardEvent, change: Editor) => {
event.preventDefault();
const selectedBlocks = change.value.document.getBlocksAtRange(change.value.selection);
event.clipboardData.setData('Text', selectedBlocks.map((block: Block) => block.text).join('\n'));
return true;
};
handlePaste = (event: ClipboardEvent, change: Editor) => {
event.preventDefault();
const pastedValue = event.clipboardData.getData('Text');
const newValue = change.value.change().insertText(pastedValue);
this.onChange(newValue);
const lines = pastedValue.split('\n');
if (lines.length) {
change.insertText(lines[0]);
for (const line of lines.slice(1)) {
change.splitBlock().insertText(line);
}
}
return true;
};
handleCut = (event: ClipboardEvent, change: Editor) => {
this.handleCopy(event, change);
change.deleteAtRange(change.value.selection);
return true;
};
......@@ -499,7 +664,9 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onCopy={this.handleCopy}
onPaste={this.handlePaste}
onCut={this.handleCut}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
......
// @ts-ignore
import { Change } from 'slate';
function getIndent(text: any) {
let offset = text.length - text.trimLeft().length;
if (offset) {
......@@ -12,7 +15,7 @@ function getIndent(text: any) {
export default function NewlinePlugin() {
return {
onKeyDown(event: any, change: { value?: any; splitBlock?: any }) {
onKeyDown(event: KeyboardEvent, change: Change) {
const { value } = change;
if (!value.isCollapsed) {
return undefined;
......
......@@ -3097,6 +3097,11 @@
"@types/through" "*"
rxjs "^6.4.0"
"@types/is-hotkey@0.1.1":
version "0.1.1"
resolved "https://registry.yarnpkg.com/@types/is-hotkey/-/is-hotkey-0.1.1.tgz#802e294c2a02f26fbcbe8639c77ef05e38cfdc8c"
integrity sha512-QzVKww91fJv/KzARJBS/Im5GS2A8iE64E1HxOed72EmYOvPLG4PBw77QCIUjFl7VwWB3G/SVrxsHedJD/wtn1A==
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff"
......@@ -10000,7 +10005,7 @@ is-hexadecimal@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835"
is-hotkey@^0.1.1:
is-hotkey@0.1.4, is-hotkey@^0.1.1:
version "0.1.4"
resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.4.tgz#c34d2c85d6ec8d09a871dcf71931c8067a824c7d"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment