Graphite: Migrate to React (part 3: migrate segments) (#37309)

* Add UMLs

* Add rendered diagrams

* Move QueryCtrl to flux

* Remove redundant param in the reducer

* Use named imports for lodash and fix typing for GraphiteTagOperator

* Add missing async/await

* Extract providers to a separate file

* Clean up async await

* Rename controller functions back to main

* Simplify creating actions

* Re-order controller functions

* Separate helpers from actions

* Rename vars

* Simplify helpers

* Move controller methods to state reducers

* Remove docs (they are added in design doc)

* Move actions.ts to state folder

* Add docs

* Add old methods stubs for easier review

* Check how state dependencies will be mapped

* Rename state to store

* Rename state to store

* Rewrite spec tests for Graphite Query Controller

* Update docs

* Update docs

* Add GraphiteTextEditor

* Add play button

* Add AddGraphiteFunction

* Use Segment to simplify AddGraphiteFunction

* Memoize function defs

* Fix useCallback deps

* Update public/app/plugins/datasource/graphite/state/helpers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/helpers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/helpers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Add more type definitions

* Remove submitOnClickAwayOption

This behavior is actually needed to remove parameters in functions

* Load function definitions before parsing the target on initial load

* Add button padding

* Fix loading function definitions

* Change targetChanged to updateQuery to avoid mutating state directly

It's also needed for extra refresh/runQuery execution as handleTargetChanged doesn't handle changing the raw query

* Fix updating query after adding a function

* Simplify updating function params

* Migrate function editor to react

* Simplify setting Segment Select min width

* Remove unnecessary changes to SegmentInput

* Extract view logic to a helper and update types definitions

* Clean up types

* Update FuncDef types and add tests

* Show red border for unknown functions

* Autofocus on new params

* Extract params mapping to a helper

* Split code between params and function editor

* Focus on the first param when a function is added even if it's an optional argument

* Add function editor tests

* Remove todo marker

* Fix adding new functions

* Allow empty value in selects for removing function params

* Add placeholders and fix styling

* Add more docs

* Create basic implementation for metrics and tags

* Post merge fixes

These files are not .ts

* Remove mapping to Angular dropdowns

* Simplify mapping tag names, values and operators

* Simplify mapping metrics

* Fix removing tags and autocomplete

* Simplify debouncing providers

* Ensure options are loaded twice and segment is opened

* Remove focusing new segments logic (not supported by React's segment)

* Clean up

* Move debouncing to components

* Simplify mapping to selectable options

* Add docs

* use getStyles

* remove redundant async/await

* Remove

* remove redundant async/await

* Remove console.log and silent test console output

* Do not display the name of the selected dropdown option

* Use block docs for better doc generation

* Handle undefined values provided for autocomplete

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
Piotr Jamróz
2021-08-11 09:13:55 +02:00
committed by GitHub
parent 2a1363f175
commit 31bb3522c8
18 changed files with 448 additions and 134 deletions

View File

@@ -0,0 +1,59 @@
import React, { useCallback, useMemo } from 'react';
import { SegmentAsync } from '@grafana/ui';
import { actions } from '../state/actions';
import { Dispatch } from 'redux';
import { GraphiteSegment } from '../types';
import { SelectableValue } from '@grafana/data';
import { getAltSegmentsSelectables } from '../state/providers';
import { debounce } from 'lodash';
import { GraphiteQueryEditorState } from '../state/store';
type Props = {
segment: GraphiteSegment;
metricIndex: number;
dispatch: Dispatch;
state: GraphiteQueryEditorState;
};
/**
* Represents a single metric node in the metric path at the given index. Allows to change the metric name to one of the
* provided options or a custom value.
*
* Options for tag names and metric names are reloaded while user is typing with backend taking care of auto-complete
* (auto-complete cannot be implemented in front-end because backend returns only limited number of entries)
*
* getAltSegmentsSelectables() also returns list of tags for segment with index=0. Once a tag is selected the editor
* enters tag-adding mode (see SeriesSection and GraphiteQueryModel.seriesByTagUsed).
*/
export function MetricSegment({ dispatch, metricIndex, segment, state }: Props) {
const loadOptions = useCallback(
(value: string | undefined) => {
return getAltSegmentsSelectables(state, metricIndex, value || '');
},
[state, metricIndex]
);
const debouncedLoadOptions = useMemo(() => debounce(loadOptions, 200, { leading: true }), [loadOptions]);
const onSegmentChanged = useCallback(
(selectableValue: SelectableValue<GraphiteSegment | string>) => {
// selectableValue.value is always defined because emptyValues are not allowed in SegmentAsync by default
dispatch(actions.segmentValueChanged({ segment: selectableValue.value!, index: metricIndex }));
},
[dispatch, metricIndex]
);
// segmentValueChanged action will destroy SegmentAsync immediately if a tag is selected. To give time
// for the clean up the action is debounced.
const onSegmentChangedDebounced = useMemo(() => debounce(onSegmentChanged, 100), [onSegmentChanged]);
return (
<SegmentAsync<GraphiteSegment | string>
value={segment.value}
inputMinWidth={150}
allowCustomValue={true}
loadOptions={debouncedLoadOptions}
reloadOptionsOnChange={true}
onChange={onSegmentChangedDebounced}
/>
);
}

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Dispatch } from 'redux';
import { GraphiteSegment } from '../types';
import { GraphiteQueryEditorState } from '../state/store';
import { MetricSegment } from './MetricSegment';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
type Props = {
segments: GraphiteSegment[];
dispatch: Dispatch;
state: GraphiteQueryEditorState;
};
export function MetricsSection({ dispatch, segments = [], state }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
{segments.map((segment, index) => {
return <MetricSegment segment={segment} metricIndex={index} key={index} dispatch={dispatch} state={state} />;
})}
</div>
);
}
function getStyles() {
return {
container: css`
display: flex;
flex-direction: row;
`,
};
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Dispatch } from 'redux';
import { GraphiteQueryEditorState } from '../state/store';
import { TagsSection } from './TagsSection';
import { MetricsSection } from './MetricsSection';
type Props = {
dispatch: Dispatch;
state: GraphiteQueryEditorState;
};
export function SeriesSection({ dispatch, state }: Props) {
return state.queryModel?.seriesByTagUsed ? (
<TagsSection
dispatch={dispatch}
tags={state.queryModel?.tags}
addTagSegments={state.addTagSegments}
state={state}
/>
) : (
<MetricsSection dispatch={dispatch} segments={state.segments} state={state} />
);
}

View File

@@ -0,0 +1,91 @@
import React, { useCallback, useMemo } from 'react';
import { Dispatch } from 'redux';
import { Segment, SegmentAsync } from '@grafana/ui';
import { actions } from '../state/actions';
import { GraphiteTag, GraphiteTagOperator } from '../types';
import { getTagOperatorsSelectables, getTagsSelectables, getTagValuesSelectables } from '../state/providers';
import { GraphiteQueryEditorState } from '../state/store';
import { debounce } from 'lodash';
type Props = {
tag: GraphiteTag;
tagIndex: number;
dispatch: Dispatch;
state: GraphiteQueryEditorState;
};
/**
* Editor for a tag at given index. Allows changing the name of the tag, operator or value. Tag names are provided with
* getTagsSelectables and contain only valid tags (it may depend on currently used tags). The dropdown for tag names is
* also used for removing tag (with a special "--remove tag--" option provided by getTagsSelectables).
*
* Options for tag names and values are reloaded while user is typing with backend taking care of auto-complete
* (auto-complete cannot be implemented in front-end because backend returns only limited number of entries)
*/
export function TagEditor({ dispatch, tag, tagIndex, state }: Props) {
const getTagsOptions = useCallback(
(inputValue: string | undefined) => {
return getTagsSelectables(state, tagIndex, inputValue || '');
},
[state, tagIndex]
);
const debouncedGetTagsOptions = useMemo(() => debounce(getTagsOptions, 200, { leading: true }), [getTagsOptions]);
const getTagValueOptions = useCallback(
(inputValue: string | undefined) => {
return getTagValuesSelectables(state, tag, tagIndex, inputValue || '');
},
[state, tagIndex, tag]
);
const debouncedGetTagValueOptions = useMemo(() => debounce(getTagValueOptions, 200, { leading: true }), [
getTagValueOptions,
]);
return (
<>
<SegmentAsync
inputMinWidth={150}
value={tag.key}
loadOptions={debouncedGetTagsOptions}
reloadOptionsOnChange={true}
onChange={(value) => {
dispatch(
actions.tagChanged({
tag: { ...tag, key: value.value! },
index: tagIndex,
})
);
}}
allowCustomValue={true}
/>
<Segment<GraphiteTagOperator>
inputMinWidth={50}
value={tag.operator}
options={getTagOperatorsSelectables()}
onChange={(value) => {
dispatch(
actions.tagChanged({
tag: { ...tag, operator: value.value! },
index: tagIndex,
})
);
}}
/>
<SegmentAsync
inputMinWidth={150}
value={tag.value}
loadOptions={debouncedGetTagValueOptions}
reloadOptionsOnChange={true}
onChange={(value) => {
dispatch(
actions.tagChanged({
tag: { ...tag, value: value.value! },
index: tagIndex,
})
);
}}
allowCustomValue={true}
/>
</>
);
}

View File

@@ -0,0 +1,75 @@
import React, { useCallback, useMemo } from 'react';
import { Dispatch } from 'redux';
import { GraphiteSegment } from '../types';
import { GraphiteTag } from '../graphite_query';
import { GraphiteQueryEditorState } from '../state/store';
import { getTagsAsSegmentsSelectables } from '../state/providers';
import { Button, SegmentAsync, useStyles2 } from '@grafana/ui';
import { actions } from '../state/actions';
import { GrafanaTheme2 } from '@grafana/data';
import { css } from '@emotion/css';
import { mapSegmentsToSelectables } from './helpers';
import { TagEditor } from './TagEditor';
import { debounce } from 'lodash';
type Props = {
dispatch: Dispatch;
tags: GraphiteTag[];
addTagSegments: GraphiteSegment[];
state: GraphiteQueryEditorState;
};
/**
* Renders all tags and a button allowing to add more tags.
*
* Options for tag names are reloaded while user is typing with backend taking care of auto-complete
* (auto-complete cannot be implemented in front-end because backend returns only limited number of entries)
*/
export function TagsSection({ dispatch, tags, state, addTagSegments }: Props) {
const styles = useStyles2(getStyles);
const newTagsOptions = mapSegmentsToSelectables(addTagSegments || []);
// Options are reloaded while user is typing with backend taking care of auto-complete (auto-complete cannot be
// implemented in front-end because backend returns only limited number of entries)
const getTagsAsSegmentsOptions = useCallback(
(inputValue?: string) => {
return getTagsAsSegmentsSelectables(state, inputValue || '');
},
[state]
);
const debouncedGetTagsAsSegments = useMemo(() => debounce(getTagsAsSegmentsOptions, 200, { leading: true }), [
getTagsAsSegmentsOptions,
]);
return (
<div className={styles.container}>
{tags.map((tag, index) => {
return <TagEditor key={index} tagIndex={index} tag={tag} dispatch={dispatch} state={state} />;
})}
{newTagsOptions.length && (
<SegmentAsync<GraphiteSegment>
inputMinWidth={150}
onChange={(value) => {
dispatch(actions.addNewTag({ segment: value.value! }));
}}
loadOptions={debouncedGetTagsAsSegments}
reloadOptionsOnChange={true}
Component={<Button icon="plus" variant="secondary" className={styles.button} />}
/>
)}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css`
display: flex;
flex-direction: row;
`,
button: css`
margin-right: ${theme.spacing(0.5)};
`,
};
}

View File

@@ -2,6 +2,21 @@ import { FuncDefs, FuncInstance, ParamDef } from '../gfunc';
import { forEach, sortBy } from 'lodash';
import { SelectableValue } from '@grafana/data';
import { EditableParam } from './FunctionParamEditor';
import { GraphiteSegment } from '../types';
export function mapStringsToSelectables<T extends string>(values: T[]): Array<SelectableValue<T>> {
return values.map((value) => ({
value,
label: value,
}));
}
export function mapSegmentsToSelectables(segments: GraphiteSegment[]): Array<SelectableValue<GraphiteSegment>> {
return segments.map((segment) => ({
label: segment.value,
value: segment,
}));
}
export function mapFuncDefsToSelectables(funcDefs: FuncDefs): Array<SelectableValue<string>> {
const categories: any = {};

View File

@@ -303,11 +303,15 @@ export default class GraphiteQuery {
if (tag.key === this.removeTagValue) {
this.removeTag(tagIndex);
if (this.tags.length === 0) {
this.removeFunction(this.getSeriesByTagFunc());
this.checkOtherSegmentsIndex = 0;
this.seriesByTagUsed = false;
}
return;
}
const newTagParam = renderTagString(tag);
this.getSeriesByTagFunc()!.params[tagIndex] = newTagParam;
this.getSeriesByTagFunc()!.params[tagIndex] = renderTagString(tag);
this.tags[tagIndex] = tag;
}

View File

@@ -10,46 +10,7 @@
<label class="gf-form-label width-6 query-keyword">Series</label>
</div>
<div ng-if="ctrl.state.queryModel.seriesByTagUsed" ng-repeat="tag in ctrl.state.queryModel.tags" class="gf-form">
<gf-form-dropdown
model="tag.key"
allow-custom="true"
label-mode="true"
debounce="true"
placeholder="Tag key"
css-class="query-segment-key"
get-options="ctrl.getTags($index, $query)"
on-change="ctrl.tagChanged(tag, $index)"
></gf-form-dropdown>
<gf-form-dropdown
model="tag.operator"
label-mode="true"
css-class="query-segment-operator"
get-options="ctrl.getTagOperators()"
on-change="ctrl.tagChanged(tag, $index)"
min-input-width="30"
></gf-form-dropdown>
<gf-form-dropdown
model="tag.value"
allow-custom="true"
label-mode="true"
debounce="true"
css-class="query-segment-value"
placeholder="Tag value"
get-options="ctrl.getTagValues(tag, $index, $query)"
on-change="ctrl.tagChanged(tag, $index)"
></gf-form-dropdown>
<label class="gf-form-label query-keyword" ng-if="$index !== ctrl.state.queryModel.tags.length - 1">AND</label>
</div>
<div ng-if="ctrl.state.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.state.addTagSegments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getTagsAsSegments($query)" on-change="ctrl.addNewTag(segment)" debounce="true" />
</div>
<div ng-if="!ctrl.state.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.state.segments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getAltSegments($index, $query)" on-change="ctrl.segmentValueChanged(segment, $index)" />
</div>
<series-section dispatch="ctrl.dispatch" tags="ctrl.state.queryModel.tags" addTagSegments="ctrl.state.addTagSegments" state="ctrl.state" segments="ctrl.state.segments" rawQuery="ctrl.state.target.target"></series-section>
<div ng-if="ctrl.state.paused" class="gf-form">
<play-button dispatch="ctrl.dispatch" />

View File

@@ -3,10 +3,8 @@ import { QueryCtrl } from 'app/plugins/sdk';
import { auto } from 'angular';
import { TemplateSrv } from '@grafana/runtime';
import { actions } from './state/actions';
import { getAltSegments, getTagOperators, getTags, getTagsAsSegments, getTagValues } from './state/providers';
import { createStore, GraphiteQueryEditorState } from './state/store';
import {
AngularDropdownOptions,
GraphiteActionDispatcher,
GraphiteQueryEditorAngularDependencies,
GraphiteSegment,
@@ -30,8 +28,8 @@ export class GraphiteQueryCtrl extends QueryCtrl {
supportsTags = false;
paused = false;
private state: GraphiteQueryEditorState;
private readonly dispatch: GraphiteActionDispatcher;
state: GraphiteQueryEditorState;
readonly dispatch: GraphiteActionDispatcher;
/** @ngInject */
constructor(
@@ -104,7 +102,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
setSegmentFocus(segmentIndex: any) {
// WIP: moved to state/helpers (the same name)
// WIP: removed
}
/**
@@ -112,8 +110,8 @@ export class GraphiteQueryCtrl extends QueryCtrl {
*
* This is used for new segments and segments with metrics selected.
*/
async getAltSegments(index: number, text: string): Promise<GraphiteSegment[]> {
return await getAltSegments(this.state, index, text);
getAltSegments(index: number, text: string): void {
// WIP: moved to state/providers (the same name)
}
addAltTagSegments(prefix: string, altSegments: any[]) {
@@ -128,7 +126,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
* Apply changes to a given metric segment
*/
async segmentValueChanged(segment: GraphiteSegment, index: number) {
await this.dispatch(actions.segmentValueChanged({ segment, index }));
// WIP: moved to MetricsSegment
}
spliceSegments(index: any) {
@@ -148,7 +146,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
async addFunction(name: string) {
await this.dispatch(actions.addFunction({ name }));
// WIP: removed, called from AddGraphiteFunction
}
removeFunction(func: any) {
@@ -177,22 +175,22 @@ export class GraphiteQueryCtrl extends QueryCtrl {
/**
* Get list of tags for editing exiting tag with <gf-form-dropdown>
*/
async getTags(index: number, query: string): Promise<AngularDropdownOptions[]> {
return await getTags(this.state, index, query);
getTags(index: number, query: string): void {
// WIP: removed, called from TagsSection
}
/**
* Get tag list when adding a new tag with <metric-segment>
*/
async getTagsAsSegments(query: string): Promise<GraphiteSegment[]> {
return await getTagsAsSegments(this.state, query);
getTagsAsSegments(query: string): void {
// WIP: removed, called from TagsSection
}
/**
* Get list of available tag operators
*/
getTagOperators(): AngularDropdownOptions[] {
return getTagOperators();
getTagOperators(): void {
// WIP: removed, called from TagsSection
}
getAllTagValues(tag: { key: any }) {
@@ -202,19 +200,19 @@ export class GraphiteQueryCtrl extends QueryCtrl {
/**
* Get list of available tag values
*/
async getTagValues(tag: GraphiteTag, index: number, query: string): Promise<AngularDropdownOptions[]> {
return await getTagValues(this.state, tag, index, query);
getTagValues(tag: GraphiteTag, index: number, query: string): void {
// WIP: removed, called from TagsSection
}
/**
* Apply changes when a tag is changed
*/
async tagChanged(tag: GraphiteTag, index: number) {
await this.dispatch(actions.tagChanged({ tag, index }));
// WIP: removed, called from TagsSection
}
async addNewTag(segment: GraphiteSegment) {
await this.dispatch(actions.addNewTag({ segment }));
// WIP: removed, called from TagsSection
}
removeTag(index: any) {
@@ -235,7 +233,7 @@ export class GraphiteQueryCtrl extends QueryCtrl {
}
async unpause() {
await this.dispatch(actions.unpause());
// WIP: removed, called from PlayButton
}
getCollapsedText() {

View File

@@ -5,6 +5,8 @@ import { GraphiteQueryCtrl } from '../query_ctrl';
import { TemplateSrvStub } from 'test/specs/helpers';
import { silenceConsoleOutput } from 'test/core/utils/silenceConsoleOutput';
import { actions } from '../state/actions';
import { getAltSegmentsSelectables, getTagsSelectables, getTagsAsSegmentsSelectables } from '../state/providers';
import { GraphiteSegment } from '../types';
jest.mock('app/core/utils/promiseToDigest', () => ({
promiseToDigest: (scope: any) => {
@@ -105,8 +107,8 @@ describe('GraphiteQueryCtrl', () => {
describe('when middle segment value of test.prod.* is changed', () => {
beforeEach(async () => {
const segment = { type: 'segment', value: 'test', expandable: true };
await ctx.ctrl.segmentValueChanged(segment, 1);
const segment: GraphiteSegment = { type: 'metric', value: 'test', expandable: true };
await ctx.ctrl.dispatch(actions.segmentValueChanged({ segment: segment, index: 1 }));
});
it('should validate metric key exists', () => {
@@ -129,7 +131,7 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]);
await changeTarget(ctx, 'test.prod.*.count');
await ctx.ctrl.addFunction(gfunc.getFuncDef('aliasByNode'));
await ctx.ctrl.dispatch(actions.addFunction({ name: 'aliasByNode' }));
});
it('should add function with correct node number', () => {
@@ -149,7 +151,7 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: true }]);
await changeTarget(ctx, '');
await ctx.ctrl.addFunction(gfunc.getFuncDef('asPercent'));
await ctx.ctrl.dispatch(actions.addFunction({ name: 'asPercent' }));
});
it('should add function and remove select metric link', () => {
@@ -191,7 +193,7 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([]);
await changeTarget(ctx, 'test.count');
ctx.altSegments = await ctx.ctrl.getAltSegments(1, '');
ctx.altSegments = await getAltSegmentsSelectables(ctx.ctrl.state, 1, '');
});
it('should have no segments', () => {
@@ -210,9 +212,9 @@ describe('GraphiteQueryCtrl', () => {
);
});
it('getAltSegments should handle autocomplete errors', async () => {
it('getAltSegmentsSelectables should handle autocomplete errors', async () => {
await expect(async () => {
await ctx.ctrl.getAltSegments(0, 'any');
await getAltSegmentsSelectables(ctx.ctrl.state, 0, 'any');
expect(mockDispatch).toBeCalledWith(
expect.objectContaining({
type: 'appNotifications/notifyApp',
@@ -221,11 +223,11 @@ describe('GraphiteQueryCtrl', () => {
}).not.toThrow();
});
it('getAltSegments should display the error message only once', async () => {
await ctx.ctrl.getAltSegments(0, 'any');
it('getAltSegmentsSelectables should display the error message only once', async () => {
await getAltSegmentsSelectables(ctx.ctrl.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
await ctx.ctrl.getAltSegments(0, 'any');
await getAltSegmentsSelectables(ctx.ctrl.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
});
});
@@ -241,9 +243,9 @@ describe('GraphiteQueryCtrl', () => {
);
});
it('getTags should handle autocomplete errors', async () => {
it('getTagsSelectables should handle autocomplete errors', async () => {
await expect(async () => {
await ctx.ctrl.getTags(0, 'any');
await getTagsSelectables(ctx.ctrl.state, 0, 'any');
expect(mockDispatch).toBeCalledWith(
expect.objectContaining({
type: 'appNotifications/notifyApp',
@@ -252,17 +254,17 @@ describe('GraphiteQueryCtrl', () => {
}).not.toThrow();
});
it('getTags should display the error message only once', async () => {
await ctx.ctrl.getTags(0, 'any');
it('getTagsSelectables should display the error message only once', async () => {
await getTagsSelectables(ctx.ctrl.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
await ctx.ctrl.getTags(0, 'any');
await getTagsSelectables(ctx.ctrl.state, 0, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
});
it('getTagsAsSegments should handle autocomplete errors', async () => {
it('getTagsAsSegmentsSelectables should handle autocomplete errors', async () => {
await expect(async () => {
await ctx.ctrl.getTagsAsSegments('any');
await getTagsAsSegmentsSelectables(ctx.ctrl.state, 'any');
expect(mockDispatch).toBeCalledWith(
expect.objectContaining({
type: 'appNotifications/notifyApp',
@@ -271,11 +273,11 @@ describe('GraphiteQueryCtrl', () => {
}).not.toThrow();
});
it('getTagsAsSegments should display the error message only once', async () => {
await ctx.ctrl.getTagsAsSegments('any');
it('getTagsAsSegmentsSelectables should display the error message only once', async () => {
await getTagsAsSegmentsSelectables(ctx.ctrl.state, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
await ctx.ctrl.getTagsAsSegments('any');
await getTagsAsSegmentsSelectables(ctx.ctrl.state, 'any');
expect(mockDispatch.mock.calls.length).toBe(1);
});
});
@@ -350,7 +352,7 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]);
await changeTarget(ctx, '');
await ctx.ctrl.addFunction(gfunc.getFuncDef('seriesByTag'));
await ctx.ctrl.dispatch(actions.addFunction({ name: 'seriesByTag' }));
});
it('should update functions', () => {
@@ -393,7 +395,7 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]);
await changeTarget(ctx, 'seriesByTag()');
await ctx.ctrl.addNewTag({ value: 'tag1' });
await ctx.ctrl.dispatch(actions.addNewTag({ segment: { value: 'tag1' } }));
});
it('should update tags with default value', () => {
@@ -411,7 +413,9 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]);
await changeTarget(ctx, "seriesByTag('tag1=value1', 'tag2!=~value2')");
await ctx.ctrl.tagChanged({ key: 'tag1', operator: '=', value: 'new_value' }, 0);
await ctx.ctrl.dispatch(
actions.tagChanged({ tag: { key: 'tag1', operator: '=', value: 'new_value' }, index: 0 })
);
});
it('should update tags', () => {
@@ -432,7 +436,9 @@ describe('GraphiteQueryCtrl', () => {
beforeEach(async () => {
ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]);
await changeTarget(ctx, "seriesByTag('tag1=value1', 'tag2!=~value2')");
await ctx.ctrl.tagChanged({ key: ctx.ctrl.state.removeTagValue });
await ctx.ctrl.dispatch(
actions.tagChanged({ tag: { key: ctx.ctrl.state.removeTagValue, operator: '=', value: '' }, index: 0 })
);
});
it('should update tags', () => {

View File

@@ -12,7 +12,7 @@ import { FuncInstance } from '../gfunc';
const init = createAction<GraphiteQueryEditorAngularDependencies>('init');
// Metrics & Tags
const segmentValueChanged = createAction<{ segment: GraphiteSegment; index: number }>('segment-value-changed');
const segmentValueChanged = createAction<{ segment: GraphiteSegment | string; index: number }>('segment-value-changed');
// Tags
const addNewTag = createAction<{ segment: GraphiteSegment }>('add-new-tag');

View File

@@ -1,15 +1,16 @@
import { GraphiteQueryEditorState } from './store';
import { each, map } from 'lodash';
import { map } from 'lodash';
import { dispatch } from '../../../../store/store';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { FuncInstance } from '../gfunc';
import { GraphiteTagOperator } from '../types';
/**
* Helpers used by reducers and providers. They modify state object directly so should operate on a copy of the state.
*/
export const GRAPHITE_TAG_OPERATORS = ['=', '!=', '=~', '!=~'];
export const GRAPHITE_TAG_OPERATORS: GraphiteTagOperator[] = ['=', '!=', '=~', '!=~'];
/**
* Tag names and metric names are displayed in a single dropdown. This prefix is used to
@@ -96,18 +97,6 @@ export async function checkOtherSegments(
}
}
/**
* Changes segment being in focus. After changing the value, next segment gets focus.
*
* Note: It's a bit hidden feature. After selecting one metric, and pressing down arrow the dropdown can be expanded.
* But there's nothing indicating what's in focus and how to expand the dropdown.
*/
export function setSegmentFocus(state: GraphiteQueryEditorState, segmentIndex: number): void {
each(state.segments, (segment, index) => {
segment.focus = segmentIndex === index;
});
}
export function spliceSegments(state: GraphiteQueryEditorState, index: number): void {
state.segments = state.segments.splice(0, index);
state.queryModel.segments = state.queryModel.segments.splice(0, index);

View File

@@ -6,7 +6,9 @@ import {
handleMetricsAutoCompleteError,
handleTagsAutoCompleteError,
} from './helpers';
import { AngularDropdownOptions, GraphiteSegment, GraphiteTag } from '../types';
import { GraphiteSegment, GraphiteTag, GraphiteTagOperator } from '../types';
import { mapSegmentsToSelectables, mapStringsToSelectables } from '../components/helpers';
import { SelectableValue } from '@grafana/data';
/**
* Providers are hooks for views to provide temporal data for autocomplete. They don't modify the state.
@@ -19,7 +21,7 @@ import { AngularDropdownOptions, GraphiteSegment, GraphiteTag } from '../types';
* - mixed list of metrics and tags (only when nothing was selected)
* - list of metric names (if a metric name was selected for this segment)
*/
export async function getAltSegments(
async function getAltSegments(
state: GraphiteQueryEditorState,
index: number,
prefix: string
@@ -90,25 +92,29 @@ export async function getAltSegments(
return [];
}
export function getTagOperators(): AngularDropdownOptions[] {
return mapToDropdownOptions(GRAPHITE_TAG_OPERATORS);
export async function getAltSegmentsSelectables(
state: GraphiteQueryEditorState,
index: number,
prefix: string
): Promise<Array<SelectableValue<GraphiteSegment>>> {
return mapSegmentsToSelectables(await getAltSegments(state, index, prefix));
}
export function getTagOperatorsSelectables(): Array<SelectableValue<GraphiteTagOperator>> {
return mapStringsToSelectables(GRAPHITE_TAG_OPERATORS);
}
/**
* Returns tags as dropdown options
*/
export async function getTags(
state: GraphiteQueryEditorState,
index: number,
tagPrefix: string
): Promise<AngularDropdownOptions[]> {
async function getTags(state: GraphiteQueryEditorState, index: number, tagPrefix: string): Promise<string[]> {
try {
const tagExpressions = state.queryModel.renderTagExpressions(index);
const values = await state.datasource.getTagsAutoComplete(tagExpressions, tagPrefix);
const altTags = map(values, 'text');
altTags.splice(0, 0, state.removeTagValue);
return mapToDropdownOptions(altTags);
return altTags;
} catch (err) {
handleTagsAutoCompleteError(state, err);
}
@@ -116,15 +122,20 @@ export async function getTags(
return [];
}
export async function getTagsSelectables(
state: GraphiteQueryEditorState,
index: number,
tagPrefix: string
): Promise<Array<SelectableValue<string>>> {
return mapStringsToSelectables(await getTags(state, index, tagPrefix));
}
/**
* List of tags when a tag is added. getTags is used for editing.
* When adding - segment is used. When editing - dropdown is used.
*/
export async function getTagsAsSegments(
state: GraphiteQueryEditorState,
tagPrefix: string
): Promise<GraphiteSegment[]> {
let tagsAsSegments: GraphiteSegment[] = [];
async function getTagsAsSegments(state: GraphiteQueryEditorState, tagPrefix: string): Promise<GraphiteSegment[]> {
let tagsAsSegments: GraphiteSegment[];
try {
const tagExpressions = state.queryModel.renderTagExpressions();
const values = await state.datasource.getTagsAutoComplete(tagExpressions, tagPrefix);
@@ -143,12 +154,19 @@ export async function getTagsAsSegments(
return tagsAsSegments;
}
export async function getTagValues(
export async function getTagsAsSegmentsSelectables(
state: GraphiteQueryEditorState,
tagPrefix: string
): Promise<Array<SelectableValue<GraphiteSegment>>> {
return mapSegmentsToSelectables(await getTagsAsSegments(state, tagPrefix));
}
async function getTagValues(
state: GraphiteQueryEditorState,
tag: GraphiteTag,
index: number,
valuePrefix: string
): Promise<AngularDropdownOptions[]> {
): Promise<string[]> {
const tagExpressions = state.queryModel.renderTagExpressions(index);
const tagKey = tag.key;
const values = await state.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix, {});
@@ -158,7 +176,16 @@ export async function getTagValues(
altValues.push('${' + variable.name + ':regex}');
});
return mapToDropdownOptions(altValues);
return altValues;
}
export async function getTagValuesSelectables(
state: GraphiteQueryEditorState,
tag: GraphiteTag,
index: number,
valuePrefix: string
): Promise<Array<SelectableValue<string>>> {
return mapStringsToSelectables(await getTagValues(state, tag, index, valuePrefix));
}
/**
@@ -182,9 +209,3 @@ async function addAltTagSegments(
function removeTaggedEntry(altSegments: GraphiteSegment[]) {
remove(altSegments, (s) => s.value === '_tagged');
}
function mapToDropdownOptions(results: string[]) {
return map(results, (value) => {
return { text: value, value: value };
});
}

View File

@@ -14,7 +14,6 @@ import {
parseTarget,
pause,
removeTagPrefix,
setSegmentFocus,
smartlyHandleNewAliasByNode,
spliceSegments,
} from './helpers';
@@ -72,9 +71,22 @@ const reducer = async (action: Action, state: GraphiteQueryEditorState): Promise
await buildSegments(state, false);
}
if (actions.segmentValueChanged.match(action)) {
const { segment, index: segmentIndex } = action.payload;
const { segment: segmentOrString, index: segmentIndex } = action.payload;
let segment;
// is segment was changed to a string - create a new segment
if (typeof segmentOrString === 'string') {
segment = {
value: segmentOrString,
expandable: true,
fake: false,
};
} else {
segment = segmentOrString as GraphiteSegment;
}
state.error = null;
state.segments[segmentIndex] = segment;
state.queryModel.updateSegmentValue(segment, segmentIndex);
if (state.queryModel.functions.length > 0 && state.queryModel.functions[0].def.fake) {
@@ -88,21 +100,24 @@ const reducer = async (action: Action, state: GraphiteQueryEditorState): Promise
return state;
}
// if newly selected segment can be expanded -> check if the path is correct
if (segment.expandable) {
await checkOtherSegments(state, segmentIndex + 1);
setSegmentFocus(state, segmentIndex + 1);
handleTargetChanged(state);
} else {
// if not expandable -> remove all other segments
spliceSegments(state, segmentIndex + 1);
}
setSegmentFocus(state, segmentIndex + 1);
handleTargetChanged(state);
}
if (actions.tagChanged.match(action)) {
const { tag, index: tagIndex } = action.payload;
state.queryModel.updateTag(tag, tagIndex);
handleTargetChanged(state);
if (state.queryModel.tags.length === 0) {
await checkOtherSegments(state, 0);
state.paused = false;
}
}
if (actions.addNewTag.match(action)) {
const segment = action.payload.segment;

View File

@@ -60,7 +60,6 @@ export type GraphiteSegment = {
value: string;
type?: 'tag' | 'metric' | 'series-ref';
expandable?: boolean;
focus?: boolean;
fake?: boolean;
};