mirror of
https://github.com/grafana/grafana.git
synced 2024-11-27 11:20:27 -06:00
Refactor: Suggestion plugin for slate (#19825)
This commit is contained in:
parent
3119f35715
commit
80a6853fd4
@ -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<QueryFieldProps, QueryFieldS
|
||||
mounted: boolean;
|
||||
runOnChangeDebounced: Function;
|
||||
editor: Editor;
|
||||
// Is required by SuggestionsPlugin
|
||||
typeaheadRef: Typeahead;
|
||||
lastExecutedValue: Value | null = null;
|
||||
|
||||
constructor(props: QueryFieldProps, context: Context<any>) {
|
||||
@ -80,7 +76,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
|
||||
// Base plugins
|
||||
this.plugins = [
|
||||
NewlinePlugin(),
|
||||
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }),
|
||||
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion }),
|
||||
ClearPlugin(),
|
||||
RunnerPlugin({ handler: this.runOnChangeAndRunQuery }),
|
||||
SelectionShortcutsPlugin(),
|
||||
|
@ -6,7 +6,7 @@ import { Editor as CoreEditor } from 'slate';
|
||||
import { Plugin as SlatePlugin } from '@grafana/slate-react';
|
||||
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 { TypeaheadWithTheme, Typeahead } from '../Typeahead';
|
||||
|
||||
@ -14,6 +14,12 @@ import { makeFragment } from '@grafana/ui';
|
||||
|
||||
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 {
|
||||
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<TypeaheadOutput>;
|
||||
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<SuggestionsState>) => {
|
||||
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}
|
||||
<TypeaheadWithTheme
|
||||
menuRef={(el: Typeahead) => (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<TypeaheadOutput>,
|
||||
cleanText?: (text: string) => string
|
||||
) => {
|
||||
if (!onTypeahead) {
|
||||
return null;
|
||||
}
|
||||
const handleTypeahead = async (
|
||||
editor: CoreEditor,
|
||||
onStateChange: (state: Partial<SuggestionsState>) => void,
|
||||
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>,
|
||||
cleanText?: (text: string) => string
|
||||
): Promise<void> => {
|
||||
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();
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user