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
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// @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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user