From 80a6853fd4b79f7d50521f32349a8975871fb1e7 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Mon, 21 Oct 2019 18:53:35 +0200 Subject: [PATCH] Refactor: Suggestion plugin for slate (#19825) --- public/app/features/explore/QueryField.tsx | 6 +- .../explore/slate-plugins/suggestions.tsx | 239 +++++++++--------- 2 files changed, 124 insertions(+), 121 deletions(-) diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index c9c8cc70f49..226f6ffee04 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/public/app/features/explore/QueryField.tsx @@ -15,8 +15,6 @@ import IndentationPlugin from './slate-plugins/indentation'; import ClipboardPlugin from './slate-plugins/clipboard'; import RunnerPlugin from './slate-plugins/runner'; import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions'; -import { Typeahead } from './Typeahead'; - import { makeValue, SCHEMA } from '@grafana/ui'; export interface QueryFieldProps { @@ -66,8 +64,6 @@ export class QueryField extends React.PureComponent) { @@ -80,7 +76,7 @@ export class QueryField extends React.PureComponent CoreEditor; + applyTypeahead: (suggestion: CompletionItem) => CoreEditor; +} + export interface SuggestionsState { groupedItems: CompletionItemGroup[]; typeaheadPrefix: string; @@ -21,28 +27,33 @@ export interface SuggestionsState { typeaheadText: string; } -let state: SuggestionsState = { - groupedItems: [], - typeaheadPrefix: '', - typeaheadContext: '', - typeaheadText: '', -}; - export default function SuggestionsPlugin({ onTypeahead, cleanText, onWillApplySuggestion, - syntax, portalOrigin, - component, }: { onTypeahead: (typeahead: TypeaheadInput) => Promise; cleanText?: (text: string) => string; onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; - syntax?: string; portalOrigin: string; - component: QueryField; // Need to attach typeaheadRef here }): SlatePlugin { + let typeaheadRef: Typeahead; + let state: SuggestionsState = { + groupedItems: [], + typeaheadPrefix: '', + typeaheadContext: '', + typeaheadText: '', + }; + const handleTypeaheadDebounced = debounce(handleTypeahead, TYPEAHEAD_DEBOUNCE); + + const setState = (update: Partial) => { + state = { + ...state, + ...update, + }; + }; + return { onBlur: (event, editor, next) => { state = { @@ -88,7 +99,7 @@ export default function SuggestionsPlugin({ case 'ArrowUp': if (hasSuggestions) { event.preventDefault(); - component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1); + typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1); return; } @@ -98,14 +109,14 @@ export default function SuggestionsPlugin({ case 'Tab': { if (hasSuggestions) { event.preventDefault(); - return component.typeaheadRef.insertSuggestion(); + return typeaheadRef.insertSuggestion(); } break; } default: { - handleTypeahead(editor, onTypeahead, cleanText); + handleTypeaheadDebounced(editor, setState, onTypeahead, cleanText); break; } } @@ -122,7 +133,7 @@ export default function SuggestionsPlugin({ // @ts-ignore const ed = editor.applyTypeahead(suggestion); - handleTypeahead(editor, onTypeahead, cleanText); + handleTypeaheadDebounced(editor, setState, onTypeahead, cleanText); return ed; }, @@ -186,13 +197,12 @@ export default function SuggestionsPlugin({ <> {children} (component.typeaheadRef = el)} + menuRef={(el: Typeahead) => (typeaheadRef = el)} origin={portalOrigin} prefix={state.typeaheadPrefix} isOpen={!!state.groupedItems.length} groupedItems={state.groupedItems} - //@ts-ignore - onSelectSuggestion={editor.selectSuggestion} + onSelectSuggestion={(editor as CoreEditor & SuggestionsPluginCommands).selectSuggestion} /> ); @@ -200,114 +210,111 @@ export default function SuggestionsPlugin({ }; } -const handleTypeahead = debounce( - async ( - editor: CoreEditor, - onTypeahead?: (typeahead: TypeaheadInput) => Promise, - cleanText?: (text: string) => string - ) => { - if (!onTypeahead) { - return null; - } +const handleTypeahead = async ( + editor: CoreEditor, + onStateChange: (state: Partial) => void, + onTypeahead?: (typeahead: TypeaheadInput) => Promise, + cleanText?: (text: string) => string +): Promise => { + if (!onTypeahead) { + return null; + } - const { value } = editor; - const { selection } = value; + const { value } = editor; + const { selection } = value; - // Get decorations associated with the current line - const parentBlock = value.document.getClosestBlock(value.focusBlock.key); - const myOffset = value.selection.start.offset - 1; - const decorations = parentBlock.getDecorations(editor as any); + // Get decorations associated with the current line + const parentBlock = value.document.getClosestBlock(value.focusBlock.key); + const myOffset = value.selection.start.offset - 1; + const decorations = parentBlock.getDecorations(editor as any); - const filteredDecorations = decorations - .filter( - decoration => - decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK - ) - .toArray(); + const filteredDecorations = decorations + .filter( + decoration => + decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK + ) + .toArray(); - // Find the first label key to the left of the cursor - const labelKeyDec = decorations - .filter(decoration => { - return ( - decoration.end.offset <= myOffset && - decoration.type === TOKEN_MARK && - decoration.data.get('className').includes('label-key') - ); - }) - .last(); + // Find the first label key to the left of the cursor + const labelKeyDec = decorations + .filter(decoration => { + return ( + decoration.end.offset <= myOffset && + decoration.type === TOKEN_MARK && + decoration.data.get('className').includes('label-key') + ); + }) + .last(); - const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset); + const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset); - const wrapperClasses = filteredDecorations - .map(decoration => decoration.data.get('className')) - .join(' ') - .split(' ') - .filter(className => className.length); + const wrapperClasses = filteredDecorations + .map(decoration => decoration.data.get('className')) + .join(' ') + .split(' ') + .filter(className => className.length); - let text = value.focusText.text; - let prefix = text.slice(0, selection.focus.offset); + let text = value.focusText.text; + let prefix = text.slice(0, selection.focus.offset); - if (filteredDecorations.length) { - text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset); - prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset); - } + if (filteredDecorations.length) { + text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset); + prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset); + } - // Label values could have valid characters erased if `cleanText()` is - // blindly applied, which would undesirably interfere with suggestions - const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); - if (labelValueMatch) { - prefix = labelValueMatch[1]; - } else if (cleanText) { - prefix = cleanText(prefix); - } + // Label values could have valid characters erased if `cleanText()` is + // blindly applied, which would undesirably interfere with suggestions + const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); + if (labelValueMatch) { + prefix = labelValueMatch[1]; + } else if (cleanText) { + prefix = cleanText(prefix); + } - const { suggestions, context } = await onTypeahead({ - prefix, - text, - value, - wrapperClasses, - labelKey, - }); - - const filteredSuggestions = suggestions - .map(group => { - if (!group.items) { - return group; - } - - if (prefix) { - // Filter groups based on prefix - if (!group.skipFilter) { - group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); - if (group.prefixMatch) { - group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix)); - } else { - group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix)); - } - } - - // Filter out the already typed value (prefix) unless it inserts custom text - group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); - } - - if (!group.skipSort) { - group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); - } + const { suggestions, context } = await onTypeahead({ + prefix, + text, + value, + wrapperClasses, + labelKey, + }); + const filteredSuggestions = suggestions + .map(group => { + if (!group.items) { return group; - }) - .filter(group => group.items && group.items.length); // Filter out empty groups + } - state = { - ...state, - groupedItems: filteredSuggestions, - typeaheadPrefix: prefix, - typeaheadContext: context, - typeaheadText: text, - }; + if (prefix) { + // Filter groups based on prefix + if (!group.skipFilter) { + group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length); + if (group.prefixMatch) { + group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix)); + } else { + group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix)); + } + } - // Bogus edit to force re-render - return editor.blur().focus(); - }, - TYPEAHEAD_DEBOUNCE -); + // Filter out the already typed value (prefix) unless it inserts custom text + group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix); + } + + if (!group.skipSort) { + group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label); + } + + return group; + }) + .filter(group => group.items && group.items.length); // Filter out empty groups + + onStateChange({ + groupedItems: filteredSuggestions, + typeaheadPrefix: prefix, + typeaheadContext: context, + typeaheadText: text, + }); + + // Bogus edit to force re-render + editor.blur().focus(); +};