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 @@ ...@@ -26,6 +26,7 @@
"@types/enzyme-adapter-react-16": "1.0.5", "@types/enzyme-adapter-react-16": "1.0.5",
"@types/expect-puppeteer": "3.3.1", "@types/expect-puppeteer": "3.3.1",
"@types/file-saver": "2.0.1", "@types/file-saver": "2.0.1",
"@types/is-hotkey": "0.1.1",
"@types/jest": "24.0.13", "@types/jest": "24.0.13",
"@types/jquery": "1.10.35", "@types/jquery": "1.10.35",
"@types/lodash": "4.14.123", "@types/lodash": "4.14.123",
...@@ -203,6 +204,7 @@ ...@@ -203,6 +204,7 @@
"fast-text-encoding": "^1.0.0", "fast-text-encoding": "^1.0.0",
"file-saver": "1.3.8", "file-saver": "1.3.8",
"immutable": "3.8.2", "immutable": "3.8.2",
"is-hotkey": "0.1.4",
"jquery": "3.4.1", "jquery": "3.4.1",
"lodash": "4.17.14", "lodash": "4.17.14",
"marked": "0.6.2", "marked": "0.6.2",
......
...@@ -23,7 +23,7 @@ describe('<QueryField />', () => { ...@@ -23,7 +23,7 @@ describe('<QueryField />', () => {
const wrapper = shallow(<QueryField initialQuery="my query" />); const wrapper = shallow(<QueryField initialQuery="my query" />);
const instance = wrapper.instance() as QueryField; const instance = wrapper.instance() as QueryField;
instance.executeOnChangeAndRunQueries = jest.fn(); instance.executeOnChangeAndRunQueries = jest.fn();
const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterAndTabKey'); const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey');
instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {}); instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {});
expect(handleEnterAndTabKeySpy).toBeCalled(); expect(handleEnterAndTabKeySpy).toBeCalled();
expect(instance.executeOnChangeAndRunQueries).toBeCalled(); expect(instance.executeOnChangeAndRunQueries).toBeCalled();
......
...@@ -2,12 +2,13 @@ import _ from 'lodash'; ...@@ -2,12 +2,13 @@ import _ from 'lodash';
import React, { Context } from 'react'; import React, { Context } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
// @ts-ignore // @ts-ignore
import { Change, Value } from 'slate'; import { Change, Range, Value, Block } from 'slate';
// @ts-ignore // @ts-ignore
import { Editor } from 'slate-react'; import { Editor } from 'slate-react';
// @ts-ignore // @ts-ignore
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
import classnames from 'classnames'; import classnames from 'classnames';
import { isKeyHotkey } from 'is-hotkey';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore'; import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
...@@ -19,6 +20,14 @@ import { makeFragment, makeValue } from '@grafana/ui'; ...@@ -19,6 +20,14 @@ import { makeFragment, makeValue } from '@grafana/ui';
export const TYPEAHEAD_DEBOUNCE = 100; export const TYPEAHEAD_DEBOUNCE = 100;
export const HIGHLIGHT_WAIT = 500; 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 { function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups // Flatten suggestion groups
...@@ -305,8 +314,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS ...@@ -305,8 +314,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
.focus(); .focus();
} }
handleEnterAndTabKey = (event: KeyboardEvent, change: Change) => { handleEnterKey = (event: KeyboardEvent, change: Change) => {
const { typeaheadIndex, suggestions } = this.state;
event.preventDefault(); event.preventDefault();
if (event.shiftKey) { if (event.shiftKey) {
...@@ -315,7 +323,16 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS ...@@ -315,7 +323,16 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
} else if (!this.menuEl) { } else if (!this.menuEl) {
this.executeOnChangeAndRunQueries(); this.executeOnChangeAndRunQueries();
return true; 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; return undefined;
} }
...@@ -326,9 +343,132 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS ...@@ -326,9 +343,132 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
return insertTextOperation ? true : undefined; 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) => { onKeyDown = (event: KeyboardEvent, change: Change) => {
const { typeaheadIndex } = this.state; 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) { switch (event.key) {
case 'Escape': { case 'Escape': {
if (this.menuEl) { if (this.menuEl) {
...@@ -348,10 +488,13 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS ...@@ -348,10 +488,13 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
} }
break; break;
} }
case 'Enter': case 'Enter':
return this.handleEnterKey(event, change);
case 'Tab': { case 'Tab': {
return this.handleEnterAndTabKey(event, change); event.preventDefault();
break; return this.handleTabKey(change);
} }
case 'ArrowDown': { case 'ArrowDown': {
...@@ -476,10 +619,32 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS ...@@ -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) => { handlePaste = (event: ClipboardEvent, change: Editor) => {
event.preventDefault();
const pastedValue = event.clipboardData.getData('Text'); const pastedValue = event.clipboardData.getData('Text');
const newValue = change.value.change().insertText(pastedValue); const lines = pastedValue.split('\n');
this.onChange(newValue);
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; return true;
}; };
...@@ -499,7 +664,9 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS ...@@ -499,7 +664,9 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
onBlur={this.handleBlur} onBlur={this.handleBlur}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onChange={this.onChange} onChange={this.onChange}
onCopy={this.handleCopy}
onPaste={this.handlePaste} onPaste={this.handlePaste}
onCut={this.handleCut}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
plugins={this.plugins} plugins={this.plugins}
spellCheck={false} spellCheck={false}
......
// @ts-ignore
import { Change } from 'slate';
function getIndent(text: any) { function getIndent(text: any) {
let offset = text.length - text.trimLeft().length; let offset = text.length - text.trimLeft().length;
if (offset) { if (offset) {
...@@ -12,7 +15,7 @@ function getIndent(text: any) { ...@@ -12,7 +15,7 @@ function getIndent(text: any) {
export default function NewlinePlugin() { export default function NewlinePlugin() {
return { return {
onKeyDown(event: any, change: { value?: any; splitBlock?: any }) { onKeyDown(event: KeyboardEvent, change: Change) {
const { value } = change; const { value } = change;
if (!value.isCollapsed) { if (!value.isCollapsed) {
return undefined; return undefined;
......
...@@ -3097,6 +3097,11 @@ ...@@ -3097,6 +3097,11 @@
"@types/through" "*" "@types/through" "*"
rxjs "^6.4.0" 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": "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" 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: ...@@ -10000,7 +10005,7 @@ is-hexadecimal@^1.0.0:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835" 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" version "0.1.4"
resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.4.tgz#c34d2c85d6ec8d09a871dcf71931c8067a824c7d" 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