Browse Source

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
kay delaney 6 years ago
parent
commit
1723ad9bdb

+ 2 - 0
package.json

@@ -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",

+ 1 - 1
public/app/features/explore/QueryField.test.tsx

@@ -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();

+ 175 - 8
public/app/features/explore/QueryField.tsx

@@ -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}

+ 4 - 1
public/app/features/explore/slate-plugins/newline.ts

@@ -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;

+ 6 - 1
yarn.lock

@@ -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"