influxdb: switch the raw influxql editor from angular to react (#31860)

* influxdb: switch the raw influxql editor from angular to react

* influxdb: raw-influxql: better callback-naming

* influxdb: raw-influxql: use custom hook

* influxdb: flux: raw-editor: add unit tests
This commit is contained in:
Gábor Farkas
2021-03-16 10:47:33 +01:00
committed by GitHub
parent ecbc98ba5d
commit cbaf700d64
11 changed files with 301 additions and 39 deletions

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { select } from 'react-select-event';
import { RawInfluxQLEditor } from './RawInfluxQLEditor';
import { InfluxQuery } from '../types';
const query: InfluxQuery = {
refId: 'A',
query: 'test query 1',
resultFormat: 'table',
alias: 'alias42',
};
describe('RawInfluxQLEditor', () => {
it('should render', () => {
render(<RawInfluxQLEditor onRunQuery={() => null} onChange={() => null} query={query} />);
const queryTextarea = screen.getByLabelText('query');
const aliasInput = screen.getByLabelText('Alias by');
const formatSelect = screen.getByLabelText('Format as');
expect(formatSelect).toBeInTheDocument();
expect(queryTextarea).toBeInTheDocument();
expect(aliasInput).toBeInTheDocument();
expect(queryTextarea).toHaveValue('test query 1');
expect(aliasInput).toHaveValue('alias42');
// the only way to validate the text-displayed on the select-box
expect(screen.getByText('Table')).toBeInTheDocument();
});
it('should handle no-alias, no-query, no-resultFormat', () => {
const emptyQuery = { refId: 'B' };
render(<RawInfluxQLEditor onRunQuery={() => null} onChange={() => null} query={emptyQuery} />);
const queryTextarea = screen.getByLabelText('query');
const aliasInput = screen.getByLabelText('Alias by');
const formatSelect = screen.getByLabelText('Format as');
expect(formatSelect).toBeInTheDocument();
expect(queryTextarea).toBeInTheDocument();
expect(aliasInput).toBeInTheDocument();
expect(queryTextarea).toHaveValue('');
expect(aliasInput).toHaveValue('');
// the only way to validate the text-displayed on the select-box
expect(screen.getByText('Time series')).toBeInTheDocument();
});
it('should call onChange immediately when resultFormat change', async () => {
const onChange = jest.fn();
render(<RawInfluxQLEditor onRunQuery={() => null} onChange={onChange} query={query} />);
const formatSelect = screen.getByLabelText('Format as');
expect(formatSelect).toBeInTheDocument();
await select(formatSelect, 'Time series');
expect(onChange).toHaveBeenCalledWith({ ...query, resultFormat: 'time_series' });
});
it('should only call onChange on blur when query changes', async () => {
const onChange = jest.fn();
render(<RawInfluxQLEditor onRunQuery={() => null} onChange={onChange} query={query} />);
const queryTextarea = screen.getByLabelText('query');
expect(queryTextarea).toBeInTheDocument();
const aliasInput = screen.getByLabelText('Alias by');
expect(aliasInput).toBeInTheDocument();
// value before
expect(queryTextarea).toHaveValue('test query 1');
userEvent.type(queryTextarea, 'new changes');
// the field should have a new value, but no onChange yet.
expect(queryTextarea).toHaveValue('test query 1new changes');
expect(onChange).toHaveBeenCalledTimes(0);
aliasInput.focus(); // this should trigger blur on queryTextarea
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ ...query, query: 'test query 1new changes' });
});
it('should only call onChange on blur when alias changes', async () => {
const onChange = jest.fn();
render(<RawInfluxQLEditor onRunQuery={() => null} onChange={onChange} query={query} />);
const queryTextarea = screen.getByLabelText('query');
expect(queryTextarea).toBeInTheDocument();
const aliasInput = screen.getByLabelText('Alias by');
expect(aliasInput).toBeInTheDocument();
// value before
expect(aliasInput).toHaveValue('alias42');
userEvent.type(aliasInput, 'new changes');
// the field should have a new value, but no onChange yet.
expect(aliasInput).toHaveValue('alias42new changes');
expect(onChange).toHaveBeenCalledTimes(0);
queryTextarea.focus(); // this should trigger blur on queryTextarea
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith({ ...query, alias: 'alias42new changes' });
});
});

View File

@@ -0,0 +1,79 @@
import React, { FC } from 'react';
import { TextArea, InlineFormLabel, Input, Select, HorizontalGroup } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { ResultFormat, InfluxQuery } from '../types';
import { useShadowedState } from './useShadowedState';
import { useUniqueId } from './useUniqueId';
const RESULT_FORMATS: Array<SelectableValue<ResultFormat>> = [
{ label: 'Time series', value: 'time_series' },
{ label: 'Table', value: 'table' },
{ label: 'Logs', value: 'logs' },
];
const DEFAULT_RESULT_FORMAT: ResultFormat = 'time_series';
type Props = {
query: InfluxQuery;
onChange: (query: InfluxQuery) => void;
onRunQuery: () => void;
};
// we handle 3 fields: "query", "alias", "resultFormat"
// "resultFormat" changes are applied immediately
// "query" and "alias" changes only happen on onblur
export const RawInfluxQLEditor: FC<Props> = ({ query, onChange, onRunQuery }) => {
const [currentQuery, setCurrentQuery] = useShadowedState(query.query);
const [currentAlias, setCurrentAlias] = useShadowedState(query.alias);
const aliasElementId = useUniqueId();
const selectElementId = useUniqueId();
const applyDelayedChangesAndRunQuery = () => {
onChange({
...query,
query: currentQuery,
alias: currentAlias,
});
onRunQuery();
};
return (
<div>
<TextArea
aria-label="query"
rows={3}
spellCheck={false}
placeholder="InfluxDB Query"
onBlur={applyDelayedChangesAndRunQuery}
onChange={(e) => {
setCurrentQuery(e.currentTarget.value);
}}
value={currentQuery ?? ''}
/>
<HorizontalGroup>
<InlineFormLabel htmlFor={selectElementId}>Format as</InlineFormLabel>
<Select
inputId={selectElementId}
onChange={(v) => {
onChange({ ...query, resultFormat: v.value });
onRunQuery();
}}
value={query.resultFormat ?? DEFAULT_RESULT_FORMAT}
options={RESULT_FORMATS}
/>
<InlineFormLabel htmlFor={aliasElementId}>Alias by</InlineFormLabel>
<Input
id={aliasElementId}
type="text"
spellCheck={false}
placeholder="Naming pattern"
onBlur={applyDelayedChangesAndRunQuery}
onChange={(e) => {
setCurrentAlias(e.currentTarget.value);
}}
value={currentAlias ?? ''}
/>
</HorizontalGroup>
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useShadowedState } from './useShadowedState';
describe('useShadowedState', () => {
it('should work correctly', () => {
const { result, rerender } = renderHook(({ outsideVal }) => useShadowedState(outsideVal), {
initialProps: { outsideVal: '42' },
});
// first we verify it has the initial value
expect(result.current[0]).toBe('42');
// then we change it
act(() => {
result.current[1]('53');
});
// and verify it has the changed value
expect(result.current[0]).toBe('53');
// then we change the value from the outside
rerender({ outsideVal: '71' });
// and verify the now has the value from the outside
expect(result.current[0]).toBe('71');
});
});

View File

@@ -0,0 +1,16 @@
import { useState, useEffect, useRef } from 'react';
export function useShadowedState<T>(outsideVal: T): [T, (newVal: T) => void] {
const [currentVal, setCurrentVal] = useState(outsideVal);
const prevOutsideVal = useRef(outsideVal);
useEffect(() => {
// if the value changes from the outside, we accept it
if (prevOutsideVal.current !== outsideVal && currentVal !== outsideVal) {
prevOutsideVal.current = outsideVal;
setCurrentVal(outsideVal);
}
}, [outsideVal, currentVal]);
return [currentVal, setCurrentVal];
}

View File

@@ -0,0 +1,23 @@
import { renderHook } from '@testing-library/react-hooks';
import { useUniqueId } from './useUniqueId';
describe('useUniqueId', () => {
it('should work correctly', () => {
const { result: resultA, rerender: rerenderA } = renderHook(() => useUniqueId());
const { result: resultB, rerender: rerenderB } = renderHook(() => useUniqueId());
// the values of the separate hooks should be different
expect(resultA.current).not.toBe(resultB.current);
// we copy the current values after the first render
const firstValueA = resultA.current;
const firstValueB = resultB.current;
rerenderA();
rerenderB();
// we check that the value did not change
expect(resultA.current).toBe(firstValueA);
expect(resultB.current).toBe(firstValueB);
});
});

View File

@@ -0,0 +1,17 @@
import { useRef } from 'react';
import { uniqueId } from 'lodash';
export function useUniqueId(): string {
// we need to lazy-init this ref.
// otherwise we would call `uniqueId`
// on every render. unfortunately
// useRef does not have lazy-init builtin,
// like useState does. we do it manually.
const idRefLazy = useRef<string | null>(null);
if (idRefLazy.current == null) {
idRefLazy.current = uniqueId();
}
return idRefLazy.current;
}

View File

@@ -8,6 +8,9 @@ import VariableQueryEditor from './components/VariableQueryEditor';
// This adds a directive that is used in the query editor
import './components/FluxQueryEditor';
// This adds a directive that is used in the query editor
import './registerRawInfluxQLEditor';
class InfluxAnnotationsQueryCtrl {
static templateUrl = 'partials/annotations.editor.html';
}

View File

@@ -9,44 +9,11 @@
<query-editor-row ng-if="!ctrl.datasource.isFlux" query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
<div ng-if="ctrl.target.rawQuery">
<div class="gf-form">
<textarea
rows="3"
class="gf-form-input"
ng-model="ctrl.target.query"
spellcheck="false"
placeholder="InfluxDB Query"
ng-model-onblur
ng-change="ctrl.refresh()"
></textarea>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword">FORMAT AS</label>
<div class="gf-form-select-wrapper">
<select
class="gf-form-input gf-size-auto"
ng-model="ctrl.target.resultFormat"
ng-options="f.value as f.text for f in ctrl.resultFormats"
ng-change="ctrl.refresh()"
></select>
</div>
</div>
<div class="gf-form max-width-25" ng-hide="ctrl.target.resultFormat === 'table'">
<label class="gf-form-label query-keyword">ALIAS BY</label>
<input
type="text"
class="gf-form-input"
ng-model="ctrl.target.alias"
spellcheck="false"
placeholder="Naming pattern"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<raw-influx-editor
query="ctrl.target"
on-change="ctrl.onRawInfluxQLChange"
onRunQuery="ctrl.onRunQuery"
></raw-influx-editor>
</div>
<div ng-if="!ctrl.target.rawQuery">

View File

@@ -83,6 +83,13 @@ export class InfluxQueryCtrl extends QueryCtrl {
this.target.query = target.query;
};
// only called from raw-mode influxql-editor
onRawInfluxQLChange = (target: InfluxQuery) => {
this.target.query = target.query;
this.target.resultFormat = target.resultFormat;
this.target.alias = target.alias;
};
onRunQuery = () => {
this.panelCtrl.refresh();
};

View File

@@ -0,0 +1,9 @@
import coreModule from 'app/core/core_module';
import { RawInfluxQLEditor } from './components/RawInfluxQLEditor';
coreModule.directive('rawInfluxEditor', [
'reactDirective',
(reactDirective: any) => {
return reactDirective(RawInfluxQLEditor, ['query', 'onChange', 'onRunQuery']);
},
]);

View File

@@ -38,10 +38,12 @@ export interface InfluxQueryTag {
value: string;
}
export type ResultFormat = 'time_series' | 'table' | 'logs';
export interface InfluxQuery extends DataQuery {
policy?: string;
measurement?: string;
resultFormat?: 'time_series' | 'table';
resultFormat?: ResultFormat;
orderByTime?: string;
tags?: InfluxQueryTag[];
groupBy?: InfluxQueryPart[];
@@ -52,4 +54,5 @@ export interface InfluxQuery extends DataQuery {
fill?: string;
rawQuery?: boolean;
query?: string;
alias?: string;
}