| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562 |
- import React from 'react';
- import ReactDOM from 'react-dom';
- import { Value } from 'slate';
- import { Editor } from 'slate-react';
- import Plain from 'slate-plain-serializer';
- // dom also includes Element polyfills
- import { getNextCharacter, getPreviousCousin } from './utils/dom';
- import BracesPlugin from './slate-plugins/braces';
- import ClearPlugin from './slate-plugins/clear';
- import NewlinePlugin from './slate-plugins/newline';
- import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index';
- import RunnerPlugin from './slate-plugins/runner';
- import debounce from './utils/debounce';
- import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
- import Typeahead from './Typeahead';
- const EMPTY_METRIC = '';
- const TYPEAHEAD_DEBOUNCE = 300;
- function flattenSuggestions(s) {
- return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
- }
- const getInitialValue = query =>
- Value.fromJSON({
- document: {
- nodes: [
- {
- object: 'block',
- type: 'paragraph',
- nodes: [
- {
- object: 'text',
- leaves: [
- {
- text: query,
- },
- ],
- },
- ],
- },
- ],
- },
- });
- class Portal extends React.Component {
- node: any;
- constructor(props) {
- super(props);
- this.node = document.createElement('div');
- this.node.classList.add(`query-field-portal-${props.index}`);
- document.body.appendChild(this.node);
- }
- componentWillUnmount() {
- document.body.removeChild(this.node);
- }
- render() {
- return ReactDOM.createPortal(this.props.children, this.node);
- }
- }
- class QueryField extends React.Component<any, any> {
- menuEl: any;
- plugins: any;
- resetTimer: any;
- constructor(props, context) {
- super(props, context);
- this.plugins = [
- BracesPlugin(),
- ClearPlugin(),
- RunnerPlugin({ handler: props.onPressEnter }),
- NewlinePlugin(),
- PluginPrism(),
- ];
- this.state = {
- labelKeys: {},
- labelValues: {},
- metrics: props.metrics || [],
- suggestions: [],
- typeaheadIndex: 0,
- typeaheadPrefix: '',
- value: getInitialValue(props.initialQuery || ''),
- };
- }
- componentDidMount() {
- this.updateMenu();
- if (this.props.metrics === undefined) {
- this.fetchMetricNames();
- }
- }
- componentWillUnmount() {
- clearTimeout(this.resetTimer);
- }
- componentDidUpdate() {
- this.updateMenu();
- }
- componentWillReceiveProps(nextProps) {
- if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
- this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
- }
- // initialQuery is null in case the user typed
- if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
- this.setState({ value: getInitialValue(nextProps.initialQuery) });
- }
- }
- onChange = ({ value }) => {
- const changed = value.document !== this.state.value.document;
- this.setState({ value }, () => {
- if (changed) {
- this.handleChangeQuery();
- }
- });
- window.requestAnimationFrame(this.handleTypeahead);
- };
- onMetricsReceived = () => {
- if (!this.state.metrics) {
- return;
- }
- configurePrismMetricsTokens(this.state.metrics);
- // Trigger re-render
- window.requestAnimationFrame(() => {
- // Bogus edit to trigger highlighting
- const change = this.state.value
- .change()
- .insertText(' ')
- .deleteBackward(1);
- this.onChange(change);
- });
- };
- request = url => {
- if (this.props.request) {
- return this.props.request(url);
- }
- return fetch(url);
- };
- handleChangeQuery = () => {
- // Send text change to parent
- const { onQueryChange } = this.props;
- if (onQueryChange) {
- onQueryChange(Plain.serialize(this.state.value));
- }
- };
- handleTypeahead = debounce(() => {
- const selection = window.getSelection();
- if (selection.anchorNode) {
- const wrapperNode = selection.anchorNode.parentElement;
- const editorNode = wrapperNode.closest('.query-field');
- if (!editorNode || this.state.value.isBlurred) {
- // Not inside this editor
- return;
- }
- const range = selection.getRangeAt(0);
- const text = selection.anchorNode.textContent;
- const offset = range.startOffset;
- const prefix = cleanText(text.substr(0, offset));
- // Determine candidates by context
- const suggestionGroups = [];
- const wrapperClasses = wrapperNode.classList;
- let typeaheadContext = null;
- // Take first metric as lucky guess
- const metricNode = editorNode.querySelector('.metric');
- if (wrapperClasses.contains('context-range')) {
- // Rate ranges
- typeaheadContext = 'context-range';
- suggestionGroups.push({
- label: 'Range vector',
- items: [...RATE_RANGES],
- });
- } else if (wrapperClasses.contains('context-labels') && metricNode) {
- const metric = metricNode.textContent;
- const labelKeys = this.state.labelKeys[metric];
- if (labelKeys) {
- if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
- // Label values
- const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
- if (labelKeyNode) {
- const labelKey = labelKeyNode.textContent;
- const labelValues = this.state.labelValues[metric][labelKey];
- typeaheadContext = 'context-label-values';
- suggestionGroups.push({
- label: 'Label values',
- items: labelValues,
- });
- }
- } else {
- // Label keys
- typeaheadContext = 'context-labels';
- suggestionGroups.push({ label: 'Labels', items: labelKeys });
- }
- } else {
- this.fetchMetricLabels(metric);
- }
- } else if (wrapperClasses.contains('context-labels') && !metricNode) {
- // Empty name queries
- const defaultKeys = ['job', 'instance'];
- // Munge all keys that we have seen together
- const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
- return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
- }, defaultKeys);
- if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
- // Label values
- const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
- if (labelKeyNode) {
- const labelKey = labelKeyNode.textContent;
- if (this.state.labelValues[EMPTY_METRIC]) {
- const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
- typeaheadContext = 'context-label-values';
- suggestionGroups.push({
- label: 'Label values',
- items: labelValues,
- });
- } else {
- // Can only query label values for now (API to query keys is under development)
- this.fetchLabelValues(labelKey);
- }
- }
- } else {
- // Label keys
- typeaheadContext = 'context-labels';
- suggestionGroups.push({ label: 'Labels', items: labelKeys });
- }
- } else if (metricNode && wrapperClasses.contains('context-aggregation')) {
- typeaheadContext = 'context-aggregation';
- const metric = metricNode.textContent;
- const labelKeys = this.state.labelKeys[metric];
- if (labelKeys) {
- suggestionGroups.push({ label: 'Labels', items: labelKeys });
- } else {
- this.fetchMetricLabels(metric);
- }
- } else if (
- (this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
- wrapperClasses.contains('context-function')
- ) {
- // Need prefix for metrics
- typeaheadContext = 'context-metrics';
- suggestionGroups.push({
- label: 'Metrics',
- items: this.state.metrics,
- });
- }
- let results = 0;
- const filteredSuggestions = suggestionGroups.map(group => {
- if (group.items) {
- group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
- results += group.items.length;
- }
- return group;
- });
- console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
- this.setState({
- typeaheadPrefix: prefix,
- typeaheadContext,
- typeaheadText: text,
- suggestions: results > 0 ? filteredSuggestions : [],
- });
- }
- }, TYPEAHEAD_DEBOUNCE);
- applyTypeahead(change, suggestion) {
- const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
- // Modify suggestion based on context
- switch (typeaheadContext) {
- case 'context-labels': {
- const nextChar = getNextCharacter();
- if (!nextChar || nextChar === '}' || nextChar === ',') {
- suggestion += '=';
- }
- break;
- }
- case 'context-label-values': {
- // Always add quotes and remove existing ones instead
- if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
- suggestion = `"${suggestion}`;
- }
- if (getNextCharacter() !== '"') {
- suggestion = `${suggestion}"`;
- }
- break;
- }
- default:
- }
- this.resetTypeahead();
- // Remove the current, incomplete text and replace it with the selected suggestion
- let backward = typeaheadPrefix.length;
- const text = cleanText(typeaheadText);
- const suffixLength = text.length - typeaheadPrefix.length;
- const offset = typeaheadText.indexOf(typeaheadPrefix);
- const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
- const forward = midWord ? suffixLength + offset : 0;
- return (
- change
- // TODO this line breaks if cursor was moved left and length is longer than whole prefix
- .deleteBackward(backward)
- .deleteForward(forward)
- .insertText(suggestion)
- .focus()
- );
- }
- onKeyDown = (event, change) => {
- if (this.menuEl) {
- const { typeaheadIndex, suggestions } = this.state;
- switch (event.key) {
- case 'Escape': {
- if (this.menuEl) {
- event.preventDefault();
- this.resetTypeahead();
- return true;
- }
- break;
- }
- case 'Tab': {
- // Dont blur input
- event.preventDefault();
- if (!suggestions || suggestions.length === 0) {
- return undefined;
- }
- // Get the currently selected suggestion
- const flattenedSuggestions = flattenSuggestions(suggestions);
- const selected = Math.abs(typeaheadIndex);
- const selectedIndex = selected % flattenedSuggestions.length || 0;
- const suggestion = flattenedSuggestions[selectedIndex];
- this.applyTypeahead(change, suggestion);
- return true;
- }
- case 'ArrowDown': {
- // Select next suggestion
- event.preventDefault();
- this.setState({ typeaheadIndex: typeaheadIndex + 1 });
- break;
- }
- case 'ArrowUp': {
- // Select previous suggestion
- event.preventDefault();
- this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
- break;
- }
- default: {
- // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
- break;
- }
- }
- }
- return undefined;
- };
- resetTypeahead = () => {
- this.setState({
- suggestions: [],
- typeaheadIndex: 0,
- typeaheadPrefix: '',
- typeaheadContext: null,
- });
- };
- async fetchLabelValues(key) {
- const url = `/api/v1/label/${key}/values`;
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- const pairs = this.state.labelValues[EMPTY_METRIC];
- const values = {
- ...pairs,
- [key]: body.data,
- };
- // const labelKeys = {
- // ...this.state.labelKeys,
- // [EMPTY_METRIC]: keys,
- // };
- const labelValues = {
- ...this.state.labelValues,
- [EMPTY_METRIC]: values,
- };
- this.setState({ labelValues }, this.handleTypeahead);
- } catch (e) {
- if (this.props.onRequestError) {
- this.props.onRequestError(e);
- } else {
- console.error(e);
- }
- }
- }
- async fetchMetricLabels(name) {
- const url = `/api/v1/series?match[]=${name}`;
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- const { keys, values } = processLabels(body.data);
- const labelKeys = {
- ...this.state.labelKeys,
- [name]: keys,
- };
- const labelValues = {
- ...this.state.labelValues,
- [name]: values,
- };
- this.setState({ labelKeys, labelValues }, this.handleTypeahead);
- } catch (e) {
- if (this.props.onRequestError) {
- this.props.onRequestError(e);
- } else {
- console.error(e);
- }
- }
- }
- async fetchMetricNames() {
- const url = '/api/v1/label/__name__/values';
- try {
- const res = await this.request(url);
- const body = await (res.data || res.json());
- this.setState({ metrics: body.data }, this.onMetricsReceived);
- } catch (error) {
- if (this.props.onRequestError) {
- this.props.onRequestError(error);
- } else {
- console.error(error);
- }
- }
- }
- handleBlur = () => {
- const { onBlur } = this.props;
- // If we dont wait here, menu clicks wont work because the menu
- // will be gone.
- this.resetTimer = setTimeout(this.resetTypeahead, 100);
- if (onBlur) {
- onBlur();
- }
- };
- handleFocus = () => {
- const { onFocus } = this.props;
- if (onFocus) {
- onFocus();
- }
- };
- handleClickMenu = item => {
- // Manually triggering change
- const change = this.applyTypeahead(this.state.value.change(), item);
- this.onChange(change);
- };
- updateMenu = () => {
- const { suggestions } = this.state;
- const menu = this.menuEl;
- const selection = window.getSelection();
- const node = selection.anchorNode;
- // No menu, nothing to do
- if (!menu) {
- return;
- }
- // No suggestions or blur, remove menu
- const hasSuggesstions = suggestions && suggestions.length > 0;
- if (!hasSuggesstions) {
- menu.removeAttribute('style');
- return;
- }
- // Align menu overlay to editor node
- if (node) {
- const rect = node.parentElement.getBoundingClientRect();
- menu.style.opacity = 1;
- menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`;
- menu.style.left = `${rect.left + window.scrollX - 2}px`;
- }
- };
- menuRef = el => {
- this.menuEl = el;
- };
- renderMenu = () => {
- const { suggestions } = this.state;
- const hasSuggesstions = suggestions && suggestions.length > 0;
- if (!hasSuggesstions) {
- return null;
- }
- // Guard selectedIndex to be within the length of the suggestions
- let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
- const flattenedSuggestions = flattenSuggestions(suggestions);
- selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
- const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : [];
- // Create typeahead in DOM root so we can later position it absolutely
- return (
- <Portal>
- <Typeahead
- menuRef={this.menuRef}
- selectedItems={selectedKeys}
- onClickItem={this.handleClickMenu}
- groupedItems={suggestions}
- />
- </Portal>
- );
- };
- render() {
- return (
- <div className="query-field">
- {this.renderMenu()}
- <Editor
- autoCorrect={false}
- onBlur={this.handleBlur}
- onKeyDown={this.onKeyDown}
- onChange={this.onChange}
- onFocus={this.handleFocus}
- placeholder={this.props.placeholder}
- plugins={this.plugins}
- spellCheck={false}
- value={this.state.value}
- />
- </div>
- );
- }
- }
- export default QueryField;
|