Refactor: Suggestion plugin for slate (#19825)

This commit is contained in:
Andrej Ocenas 2019-10-21 18:53:35 +02:00 committed by GitHub
parent 3119f35715
commit 80a6853fd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 124 additions and 121 deletions

View File

@ -15,8 +15,6 @@ import IndentationPlugin from './slate-plugins/indentation';
import ClipboardPlugin from './slate-plugins/clipboard'; import ClipboardPlugin from './slate-plugins/clipboard';
import RunnerPlugin from './slate-plugins/runner'; import RunnerPlugin from './slate-plugins/runner';
import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions'; import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions';
import { Typeahead } from './Typeahead';
import { makeValue, SCHEMA } from '@grafana/ui'; import { makeValue, SCHEMA } from '@grafana/ui';
export interface QueryFieldProps { export interface QueryFieldProps {
@ -66,8 +64,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
mounted: boolean; mounted: boolean;
runOnChangeDebounced: Function; runOnChangeDebounced: Function;
editor: Editor; editor: Editor;
// Is required by SuggestionsPlugin
typeaheadRef: Typeahead;
lastExecutedValue: Value | null = null; lastExecutedValue: Value | null = null;
constructor(props: QueryFieldProps, context: Context<any>) { constructor(props: QueryFieldProps, context: Context<any>) {
@ -80,7 +76,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
// Base plugins // Base plugins
this.plugins = [ this.plugins = [
NewlinePlugin(), NewlinePlugin(),
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }), SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion }),
ClearPlugin(), ClearPlugin(),
RunnerPlugin({ handler: this.runOnChangeAndRunQuery }), RunnerPlugin({ handler: this.runOnChangeAndRunQuery }),
SelectionShortcutsPlugin(), SelectionShortcutsPlugin(),

View File

@ -6,7 +6,7 @@ import { Editor as CoreEditor } from 'slate';
import { Plugin as SlatePlugin } from '@grafana/slate-react'; import { Plugin as SlatePlugin } from '@grafana/slate-react';
import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types'; import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types';
import { QueryField, TypeaheadInput } from '../QueryField'; import { TypeaheadInput } from '../QueryField';
import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK'; import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK';
import { TypeaheadWithTheme, Typeahead } from '../Typeahead'; import { TypeaheadWithTheme, Typeahead } from '../Typeahead';
@ -14,6 +14,12 @@ import { makeFragment } from '@grafana/ui';
export const TYPEAHEAD_DEBOUNCE = 100; export const TYPEAHEAD_DEBOUNCE = 100;
// Commands added to the editor by this plugin.
interface SuggestionsPluginCommands {
selectSuggestion: (suggestion: CompletionItem) => CoreEditor;
applyTypeahead: (suggestion: CompletionItem) => CoreEditor;
}
export interface SuggestionsState { export interface SuggestionsState {
groupedItems: CompletionItemGroup[]; groupedItems: CompletionItemGroup[];
typeaheadPrefix: string; typeaheadPrefix: string;
@ -21,28 +27,33 @@ export interface SuggestionsState {
typeaheadText: string; typeaheadText: string;
} }
let state: SuggestionsState = {
groupedItems: [],
typeaheadPrefix: '',
typeaheadContext: '',
typeaheadText: '',
};
export default function SuggestionsPlugin({ export default function SuggestionsPlugin({
onTypeahead, onTypeahead,
cleanText, cleanText,
onWillApplySuggestion, onWillApplySuggestion,
syntax,
portalOrigin, portalOrigin,
component,
}: { }: {
onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>; onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
cleanText?: (text: string) => string; cleanText?: (text: string) => string;
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string; onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
syntax?: string;
portalOrigin: string; portalOrigin: string;
component: QueryField; // Need to attach typeaheadRef here
}): SlatePlugin { }): SlatePlugin {
let typeaheadRef: Typeahead;
let state: SuggestionsState = {
groupedItems: [],
typeaheadPrefix: '',
typeaheadContext: '',
typeaheadText: '',
};
const handleTypeaheadDebounced = debounce(handleTypeahead, TYPEAHEAD_DEBOUNCE);
const setState = (update: Partial<SuggestionsState>) => {
state = {
...state,
...update,
};
};
return { return {
onBlur: (event, editor, next) => { onBlur: (event, editor, next) => {
state = { state = {
@ -88,7 +99,7 @@ export default function SuggestionsPlugin({
case 'ArrowUp': case 'ArrowUp':
if (hasSuggestions) { if (hasSuggestions) {
event.preventDefault(); event.preventDefault();
component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1); typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1);
return; return;
} }
@ -98,14 +109,14 @@ export default function SuggestionsPlugin({
case 'Tab': { case 'Tab': {
if (hasSuggestions) { if (hasSuggestions) {
event.preventDefault(); event.preventDefault();
return component.typeaheadRef.insertSuggestion(); return typeaheadRef.insertSuggestion();
} }
break; break;
} }
default: { default: {
handleTypeahead(editor, onTypeahead, cleanText); handleTypeaheadDebounced(editor, setState, onTypeahead, cleanText);
break; break;
} }
} }
@ -122,7 +133,7 @@ export default function SuggestionsPlugin({
// @ts-ignore // @ts-ignore
const ed = editor.applyTypeahead(suggestion); const ed = editor.applyTypeahead(suggestion);
handleTypeahead(editor, onTypeahead, cleanText); handleTypeaheadDebounced(editor, setState, onTypeahead, cleanText);
return ed; return ed;
}, },
@ -186,13 +197,12 @@ export default function SuggestionsPlugin({
<> <>
{children} {children}
<TypeaheadWithTheme <TypeaheadWithTheme
menuRef={(el: Typeahead) => (component.typeaheadRef = el)} menuRef={(el: Typeahead) => (typeaheadRef = el)}
origin={portalOrigin} origin={portalOrigin}
prefix={state.typeaheadPrefix} prefix={state.typeaheadPrefix}
isOpen={!!state.groupedItems.length} isOpen={!!state.groupedItems.length}
groupedItems={state.groupedItems} groupedItems={state.groupedItems}
//@ts-ignore onSelectSuggestion={(editor as CoreEditor & SuggestionsPluginCommands).selectSuggestion}
onSelectSuggestion={editor.selectSuggestion}
/> />
</> </>
); );
@ -200,114 +210,111 @@ export default function SuggestionsPlugin({
}; };
} }
const handleTypeahead = debounce( const handleTypeahead = async (
async ( editor: CoreEditor,
editor: CoreEditor, onStateChange: (state: Partial<SuggestionsState>) => void,
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>, onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>,
cleanText?: (text: string) => string cleanText?: (text: string) => string
) => { ): Promise<void> => {
if (!onTypeahead) { if (!onTypeahead) {
return null; return null;
} }
const { value } = editor; const { value } = editor;
const { selection } = value; const { selection } = value;
// Get decorations associated with the current line // Get decorations associated with the current line
const parentBlock = value.document.getClosestBlock(value.focusBlock.key); const parentBlock = value.document.getClosestBlock(value.focusBlock.key);
const myOffset = value.selection.start.offset - 1; const myOffset = value.selection.start.offset - 1;
const decorations = parentBlock.getDecorations(editor as any); const decorations = parentBlock.getDecorations(editor as any);
const filteredDecorations = decorations const filteredDecorations = decorations
.filter( .filter(
decoration => decoration =>
decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK
) )
.toArray(); .toArray();
// Find the first label key to the left of the cursor // Find the first label key to the left of the cursor
const labelKeyDec = decorations const labelKeyDec = decorations
.filter(decoration => { .filter(decoration => {
return ( return (
decoration.end.offset <= myOffset && decoration.end.offset <= myOffset &&
decoration.type === TOKEN_MARK && decoration.type === TOKEN_MARK &&
decoration.data.get('className').includes('label-key') decoration.data.get('className').includes('label-key')
); );
}) })
.last(); .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 const wrapperClasses = filteredDecorations
.map(decoration => decoration.data.get('className')) .map(decoration => decoration.data.get('className'))
.join(' ') .join(' ')
.split(' ') .split(' ')
.filter(className => className.length); .filter(className => className.length);
let text = value.focusText.text; let text = value.focusText.text;
let prefix = text.slice(0, selection.focus.offset); let prefix = text.slice(0, selection.focus.offset);
if (filteredDecorations.length) { if (filteredDecorations.length) {
text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset); 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); prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset);
} }
// Label values could have valid characters erased if `cleanText()` is // Label values could have valid characters erased if `cleanText()` is
// blindly applied, which would undesirably interfere with suggestions // blindly applied, which would undesirably interfere with suggestions
const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/); const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
if (labelValueMatch) { if (labelValueMatch) {
prefix = labelValueMatch[1]; prefix = labelValueMatch[1];
} else if (cleanText) { } else if (cleanText) {
prefix = cleanText(prefix); prefix = cleanText(prefix);
} }
const { suggestions, context } = await onTypeahead({ const { suggestions, context } = await onTypeahead({
prefix, prefix,
text, text,
value, value,
wrapperClasses, wrapperClasses,
labelKey, 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 filteredSuggestions = suggestions
.map(group => {
if (!group.items) {
return group; return group;
}) }
.filter(group => group.items && group.items.length); // Filter out empty groups
state = { if (prefix) {
...state, // Filter groups based on prefix
groupedItems: filteredSuggestions, if (!group.skipFilter) {
typeaheadPrefix: prefix, group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
typeaheadContext: context, if (group.prefixMatch) {
typeaheadText: text, 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 // Filter out the already typed value (prefix) unless it inserts custom text
return editor.blur().focus(); group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
}, }
TYPEAHEAD_DEBOUNCE
); 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();
};