Implement new visual query builder designs

pull/10616/head
Christopher Henn 2018-12-21 10:31:32 -08:00 committed by Chris Henn
parent 5de0e2e8d2
commit 2770ae5e7b
38 changed files with 1601 additions and 1227 deletions

5
ui/package-lock.json generated
View File

@ -6656,6 +6656,11 @@
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.2.3.tgz",
"integrity": "sha1-0UD6j2FGWb1lQSMwl92qwlzdmRw="
},
"immer": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-1.9.3.tgz",
"integrity": "sha512-bUyz3fOHGn82V7h4oVgJGmFglZt53JWwSyVNAT4sO0d7IovHLwLuHbh14uYKY0tewFoDcEdiQW7HuL0NsRVziw=="
},
"import-fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz",

View File

@ -123,6 +123,7 @@
"encoding-down": "^5.0.4",
"fast.js": "^0.1.1",
"history": "^3.2",
"immer": "^1.9.3",
"level-js": "^3.0.0",
"levelup": "^3.1.1",
"lodash": "^4.3.0",

View File

@ -9,6 +9,7 @@ import DropdownDivider from 'src/clockface/components/dropdowns/DropdownDivider'
import DropdownItem from 'src/clockface/components/dropdowns/DropdownItem'
import DropdownButton from 'src/clockface/components/dropdowns/DropdownButton'
import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar'
import WaitingText from 'src/shared/components/WaitingText'
// Types
import {
@ -124,8 +125,17 @@ class Dropdown extends Component<Props, State> {
const {expanded} = this.state
const selectedChild = children.find(child => child.props.id === selectedID)
const dropdownLabel =
(selectedChild && selectedChild.props.children) || titleText
const isLoading = status === ComponentStatus.Loading
let dropdownLabel
if (isLoading) {
dropdownLabel = <WaitingText text="Loading" />
} else if (selectedChild) {
dropdownLabel = selectedChild.props.children
} else {
dropdownLabel = titleText
}
return (
<DropdownButton
@ -211,6 +221,14 @@ class Dropdown extends Component<Props, State> {
}
}
private get shouldHaveChildren(): boolean {
const {status} = this.props
return (
status === ComponentStatus.Default || status === ComponentStatus.Valid
)
}
private handleItemClick = (value: any): void => {
const {onChange} = this.props
onChange(value)
@ -220,7 +238,7 @@ class Dropdown extends Component<Props, State> {
private validateChildCount = (): void => {
const {children} = this.props
if (React.Children.count(children) === 0) {
if (this.shouldHaveChildren && React.Children.count(children) === 0) {
throw new Error(
'Dropdowns require at least 1 child element. We recommend using Dropdown.Item and/or Dropdown.Divider.'
)
@ -236,6 +254,7 @@ class Dropdown extends Component<Props, State> {
if (
mode === DropdownMode.Radio &&
this.shouldHaveChildren &&
(isUndefined(selectedID) || isNull(selectedID))
) {
throw new Error('Dropdowns in Radio mode require a selectedID prop.')

View File

@ -34,29 +34,51 @@ class DropdownButton extends Component<Props> {
}
public render() {
const {onClick, status, children, title} = this.props
const {onClick, children, title} = this.props
return (
<button
className={this.classname}
onClick={onClick}
disabled={status === ComponentStatus.Disabled}
disabled={this.isDisabled}
title={title}
>
{this.icon}
<span className="dropdown--selected">{children}</span>
<span className="dropdown--caret icon caret-down" />
{this.caret}
</button>
)
}
private get caret(): JSX.Element {
const {active} = this.props
if (active) {
return <span className="dropdown--caret icon caret-up" />
}
return <span className="dropdown--caret icon caret-down" />
}
private get isDisabled(): boolean {
const {status} = this.props
const isDisabled = [
ComponentStatus.Disabled,
ComponentStatus.Loading,
ComponentStatus.Error,
].includes(status)
return isDisabled
}
private get classname(): string {
const {status, active, color, size} = this.props
const {active, color, size} = this.props
return classnames('dropdown--button button', {
'button-stretch': true,
'button-disabled': this.isDisabled,
[`button-${color}`]: color,
[`button-${size}`]: size,
disabled: status === ComponentStatus.Disabled,
active,
})
}

View File

@ -142,8 +142,7 @@ export const query: DashboardQuery = {
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
tags: [],
functions: [],
},
}

View File

@ -0,0 +1,348 @@
// APIs
import {
QueryBuilderFetcher,
CancellationError,
} from 'src/shared/apis/v2/queryBuilder'
// Utils
import {
getActiveQuerySource,
getActiveQuery,
} from 'src/shared/selectors/timeMachines'
// Types
import {Dispatch} from 'redux-thunk'
import {GetState} from 'src/types/v2'
import {RemoteDataState} from 'src/types'
const fetcher = new QueryBuilderFetcher()
export type Action =
| SetBuilderBucketSelectionAction
| SetBuilderBucketsAction
| SetBuilderBucketsStatusAction
| SetBuilderTagKeysAction
| SetBuilderTagKeysStatusAction
| SetBuilderTagValuesAction
| SetBuilderTagValuesStatusAction
| SetBuilderTagKeySelectionAction
| SetBuilderTagValuesSelectionAction
| AddTagSelectorAction
| RemoveTagSelectorAction
| SelectFunctionAction
interface SetBuilderBucketsStatusAction {
type: 'SET_BUILDER_BUCKETS_STATUS'
payload: {bucketsStatus: RemoteDataState}
}
const setBuilderBucketsStatus = (
bucketsStatus: RemoteDataState
): SetBuilderBucketsStatusAction => ({
type: 'SET_BUILDER_BUCKETS_STATUS',
payload: {bucketsStatus},
})
interface SetBuilderBucketsAction {
type: 'SET_BUILDER_BUCKETS'
payload: {buckets: string[]}
}
const setBuilderBuckets = (buckets: string[]): SetBuilderBucketsAction => ({
type: 'SET_BUILDER_BUCKETS',
payload: {buckets},
})
interface SetBuilderBucketSelectionAction {
type: 'SET_BUILDER_BUCKET_SELECTION'
payload: {bucket: string}
}
const setBuilderBucket = (bucket: string): SetBuilderBucketSelectionAction => ({
type: 'SET_BUILDER_BUCKET_SELECTION',
payload: {bucket},
})
interface SetBuilderTagKeysAction {
type: 'SET_BUILDER_TAG_KEYS'
payload: {index: number; keys: string[]}
}
const setBuilderTagKeys = (
index: number,
keys: string[]
): SetBuilderTagKeysAction => ({
type: 'SET_BUILDER_TAG_KEYS',
payload: {index, keys},
})
interface SetBuilderTagKeysStatusAction {
type: 'SET_BUILDER_TAG_KEYS_STATUS'
payload: {index: number; status: RemoteDataState}
}
const setBuilderTagKeysStatus = (
index: number,
status: RemoteDataState
): SetBuilderTagKeysStatusAction => ({
type: 'SET_BUILDER_TAG_KEYS_STATUS',
payload: {index, status},
})
interface SetBuilderTagValuesAction {
type: 'SET_BUILDER_TAG_VALUES'
payload: {index: number; values: string[]}
}
const setBuilderTagValues = (
index: number,
values: string[]
): SetBuilderTagValuesAction => ({
type: 'SET_BUILDER_TAG_VALUES',
payload: {index, values},
})
interface SetBuilderTagValuesStatusAction {
type: 'SET_BUILDER_TAG_VALUES_STATUS'
payload: {index: number; status: RemoteDataState}
}
const setBuilderTagValuesStatus = (
index: number,
status: RemoteDataState
): SetBuilderTagValuesStatusAction => ({
type: 'SET_BUILDER_TAG_VALUES_STATUS',
payload: {index, status},
})
interface SetBuilderTagKeySelectionAction {
type: 'SET_BUILDER_TAG_KEY_SELECTION'
payload: {index: number; key: string}
}
const setBuilderTagKeySelection = (
index: number,
key: string
): SetBuilderTagKeySelectionAction => ({
type: 'SET_BUILDER_TAG_KEY_SELECTION',
payload: {index, key},
})
interface SetBuilderTagValuesSelectionAction {
type: 'SET_BUILDER_TAG_VALUES_SELECTION'
payload: {index: number; values: string[]}
}
const setBuilderTagValuesSelection = (
index: number,
values: string[]
): SetBuilderTagValuesSelectionAction => ({
type: 'SET_BUILDER_TAG_VALUES_SELECTION',
payload: {index, values},
})
interface AddTagSelectorAction {
type: 'ADD_TAG_SELECTOR'
}
const addTagSelectorSync = (): AddTagSelectorAction => ({
type: 'ADD_TAG_SELECTOR',
})
interface RemoveTagSelectorAction {
type: 'REMOVE_TAG_SELECTOR'
payload: {index: number}
}
const removeTagSelectorSync = (index: number): RemoveTagSelectorAction => ({
type: 'REMOVE_TAG_SELECTOR',
payload: {index},
})
interface SelectFunctionAction {
type: 'SELECT_BUILDER_FUNCTION'
payload: {name: string}
}
export const selectFunction = (name: string): SelectFunctionAction => ({
type: 'SELECT_BUILDER_FUNCTION',
payload: {name},
})
export const loadBuckets = () => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const queryURL = getActiveQuerySource(getState()).links.query
dispatch(setBuilderBucketsStatus(RemoteDataState.Loading))
try {
const buckets = await fetcher.findBuckets(queryURL)
const selectedBucket = getActiveQuery(getState()).builderConfig.buckets[0]
dispatch(setBuilderBuckets(buckets))
if (selectedBucket && buckets.includes(selectedBucket)) {
dispatch(selectBucket(selectedBucket))
} else {
dispatch(selectBucket(buckets[0]))
}
} catch (e) {
if (e instanceof CancellationError) {
return
}
console.error(e)
dispatch(setBuilderBucketsStatus(RemoteDataState.Error))
}
}
export const selectBucket = (bucket: string) => async (
dispatch: Dispatch<Action>
) => {
dispatch(setBuilderBucket(bucket))
dispatch(loadTagSelector(0))
}
export const loadTagSelector = (index: number) => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const {buckets, tags} = getActiveQuery(getState()).builderConfig
if (!tags[index] || !buckets[0]) {
return
}
const tagPredicates = tags.slice(0, index)
const queryURL = getActiveQuerySource(getState()).links.query
dispatch(setBuilderTagKeysStatus(index, RemoteDataState.Loading))
try {
const keys = await fetcher.findKeys(
index,
queryURL,
buckets[0],
tagPredicates
)
const {key} = tags[index]
if (!key) {
dispatch(setBuilderTagKeySelection(index, keys[0]))
} else if (!keys.includes(key)) {
// Even if the selected key didn't come back in the results, let it be
// selected anyway
keys.unshift(key)
}
dispatch(setBuilderTagKeys(index, keys))
dispatch(loadTagSelectorValues(index))
} catch (e) {
if (e instanceof CancellationError) {
return
}
console.error(e)
dispatch(setBuilderTagKeysStatus(index, RemoteDataState.Error))
}
}
const loadTagSelectorValues = (index: number) => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const {buckets, tags} = getActiveQuery(getState()).builderConfig
const tagPredicates = tags.slice(0, index)
const queryURL = getActiveQuerySource(getState()).links.query
dispatch(setBuilderTagValuesStatus(index, RemoteDataState.Loading))
try {
const key = getActiveQuery(getState()).builderConfig.tags[index].key
const values = await fetcher.findValues(
index,
queryURL,
buckets[0],
tagPredicates,
key
)
const {values: selectedValues} = tags[index]
for (const selectedValue of selectedValues) {
// Even if the selected values didn't come back in the results, let them
// be selected anyway
if (!values.includes(selectedValue)) {
values.unshift(selectedValue)
}
}
dispatch(setBuilderTagValues(index, values))
dispatch(loadTagSelector(index + 1))
} catch (e) {
if (e instanceof CancellationError) {
return
}
console.error(e)
dispatch(setBuilderTagValuesStatus(index, RemoteDataState.Error))
}
}
export const selectTagValue = (index: number, value: string) => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
const tags = getActiveQuery(getState()).builderConfig.tags
const values = tags[index].values
let newValues: string[]
if (values.includes(value)) {
newValues = values.filter(v => v !== value)
} else {
newValues = [...values, value]
}
dispatch(setBuilderTagValuesSelection(index, newValues))
if (index === tags.length - 1 && newValues.length) {
dispatch(addTagSelector())
} else {
dispatch(loadTagSelector(index + 1))
}
}
export const selectTagKey = (index: number, key: string) => async (
dispatch: Dispatch<Action>
) => {
dispatch(setBuilderTagKeySelection(index, key))
dispatch(loadTagSelectorValues(index))
}
// TODO
export const searchTagValues = (/* index: number, searchTerm: string */) => async () => {}
export const addTagSelector = () => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
dispatch(addTagSelectorSync())
const newIndex = getActiveQuery(getState()).builderConfig.tags.length - 1
dispatch(loadTagSelector(newIndex))
}
export const removeTagSelector = (index: number) => async (
dispatch: Dispatch<Action>
) => {
fetcher.cancelFindValues(index)
fetcher.cancelFindKeys(index)
dispatch(removeTagSelectorSync(index))
dispatch(loadTagSelector(index))
}

View File

@ -1,6 +1,11 @@
// Actions
import {loadBuckets} from 'src/shared/actions/v2/queryBuilder'
// Types
import {Dispatch} from 'redux-thunk'
import {TimeMachineState} from 'src/shared/reducers/v2/timeMachines'
import {TimeRange, ViewType, BuilderConfig} from 'src/types/v2'
import {Action as QueryBuilderAction} from 'src/shared/actions/v2/queryBuilder'
import {TimeRange, ViewType} from 'src/types/v2'
import {
Axes,
DecimalPlaces,
@ -12,6 +17,7 @@ import {TimeMachineTab} from 'src/types/v2/timeMachine'
import {Color} from 'src/types/colors'
export type Action =
| QueryBuilderAction
| SetActiveTimeMachineAction
| SetActiveTabAction
| SetNameAction
@ -34,7 +40,6 @@ export type Action =
| SetYAxisSuffix
| SetYAxisBase
| SetYAxisScale
| SetQuerySourceAction
| SetPrefix
| SetSuffix
| IncrementSubmitToken
@ -44,7 +49,6 @@ export type Action =
| EditActiveQueryAsFluxAction
| EditActiveQueryAsInfluxQLAction
| EditActiveQueryWithBuilderAction
| BuildQueryAction
| UpdateActiveQueryNameAction
| SetFieldOptionsAction
| SetTableOptionsAction
@ -296,16 +300,6 @@ export const setTextThresholdColoring = (): SetTextThresholdColoringAction => ({
type: 'SET_TEXT_THRESHOLD_COLORING',
})
interface SetQuerySourceAction {
type: 'SET_QUERY_SOURCE'
payload: {sourceID: string}
}
export const setQuerySource = (sourceID: string): SetQuerySourceAction => ({
type: 'SET_QUERY_SOURCE',
payload: {sourceID},
})
interface IncrementSubmitToken {
type: 'INCREMENT_SUBMIT_TOKEN'
}
@ -343,41 +337,50 @@ interface SetActiveQueryIndexAction {
payload: {activeQueryIndex: number}
}
export const setActiveQueryIndex = (
export const setActiveQueryIndexSync = (
activeQueryIndex: number
): SetActiveQueryIndexAction => ({
type: 'SET_ACTIVE_QUERY_INDEX',
payload: {activeQueryIndex},
})
export const setActiveQueryIndex = (activeQueryIndex: number) => (
dispatch: Dispatch<Action>
) => {
dispatch(setActiveQueryIndexSync(activeQueryIndex))
dispatch(loadBuckets())
}
interface AddQueryAction {
type: 'ADD_QUERY'
}
export const addQuery = (): AddQueryAction => ({
export const addQuerySync = (): AddQueryAction => ({
type: 'ADD_QUERY',
})
export const addQuery = () => (dispatch: Dispatch<Action>) => {
dispatch(addQuerySync())
dispatch(loadBuckets())
}
interface RemoveQueryAction {
type: 'REMOVE_QUERY'
payload: {queryIndex: number}
}
export const removeQuery = (queryIndex: number): RemoveQueryAction => ({
export const removeQuerySync = (queryIndex: number): RemoveQueryAction => ({
type: 'REMOVE_QUERY',
payload: {queryIndex},
})
interface BuildQueryAction {
type: 'BUILD_QUERY'
payload: {builderConfig: BuilderConfig}
export const removeQuery = (queryIndex: number) => (
dispatch: Dispatch<Action>
) => {
dispatch(removeQuerySync(queryIndex))
dispatch(loadBuckets())
}
export const buildQuery = (builderConfig: BuilderConfig): BuildQueryAction => ({
type: 'BUILD_QUERY',
payload: {builderConfig},
})
interface UpdateActiveQueryNameAction {
type: 'UPDATE_ACTIVE_QUERY_NAME'
payload: {queryName: string}

View File

@ -1,135 +1,81 @@
// Libraries
import {get} from 'lodash'
import uuid from 'uuid'
// APIs
import {executeQuery, ExecuteFluxQueryResult} from 'src/shared/apis/v2/query'
import {parseResponse} from 'src/shared/parsing/flux/response'
// Types
import {InfluxLanguage} from 'src/types/v2'
import {Source} from 'src/api'
// Utils
import {formatTagFilterCall} from 'src/shared/utils/queryBuilder'
export const SEARCH_DURATION = '30d'
// Types
import {InfluxLanguage, BuilderConfig} from 'src/types/v2'
export const SEARCH_DURATION = '5m'
export const LIMIT = 200
export async function findBuckets(
url: string,
sourceType: Source.TypeEnum,
searchTerm?: string
) {
if (sourceType === Source.TypeEnum.V1) {
throw new Error('metaqueries not yet implemented for SourceType.V1')
}
async function findBuckets(url: string): Promise<string[]> {
const query = `buckets()
|> sort(columns: ["name"])
|> limit(n: ${LIMIT})`
const resp = await findBucketsFlux(url, searchTerm)
const resp = await executeQuery(url, query, InfluxLanguage.Flux)
const parsed = extractCol(resp, 'name')
return parsed
}
export async function findMeasurements(
async function findKeys(
url: string,
sourceType: Source.TypeEnum,
bucket: string,
tagsSelections: BuilderConfig['tags'],
searchTerm: string = ''
): Promise<string[]> {
if (sourceType === Source.TypeEnum.V1) {
throw new Error('metaqueries not yet implemented for SourceType.V1')
}
const tagFilters = formatTagFilterCall(tagsSelections)
const searchFilter = formatSearchFilterCall(searchTerm)
const previousKeyFilter = formatTagKeyFilterCall(tagsSelections)
const resp = await findMeasurementsFlux(url, bucket, searchTerm)
const parsed = extractCol(resp, '_measurement')
const query = `from(bucket: "${bucket}")
|> range(start: -${SEARCH_DURATION})${tagFilters}
|> keys(except: ["_time", "_start", "_stop", "_value"])
|> group()
|> distinct()
|> keep(columns: ["_value"])${searchFilter}${previousKeyFilter}
|> sort()
|> limit(n: ${LIMIT})`
const resp = await executeQuery(url, query, InfluxLanguage.Flux)
const parsed = extractCol(resp, '_value')
return parsed
}
export async function findFields(
async function findValues(
url: string,
sourceType: Source.TypeEnum,
bucket: string,
measurements: string[],
tagsSelections: BuilderConfig['tags'],
key: string,
searchTerm: string = ''
): Promise<string[]> {
if (sourceType === Source.TypeEnum.V1) {
throw new Error('metaqueries not yet implemented for SourceType.V1')
}
const tagFilters = formatTagFilterCall(tagsSelections)
const searchFilter = formatSearchFilterCall(searchTerm)
const resp = await findFieldsFlux(url, bucket, measurements, searchTerm)
const parsed = extractCol(resp, '_field')
const query = `from(bucket: "${bucket}")
|> range(start: -${SEARCH_DURATION})${tagFilters}
|> group(columns: ["${key}"])
|> distinct(column: "${key}")
|> group()
|> keep(columns: ["_value"])${searchFilter}
|> sort()
|> limit(n: ${LIMIT})`
const resp = await executeQuery(url, query, InfluxLanguage.Flux)
const parsed = extractCol(resp, '_value')
return parsed
}
function findBucketsFlux(
url: string,
searchTerm: string
): Promise<ExecuteFluxQueryResult> {
let query = 'buckets()'
if (searchTerm) {
query += `
|> filter(fn: (r) => r.name =~ /(?i:${searchTerm})/)`
}
query += `
|> sort(columns: ["name"])
|> limit(n: ${LIMIT})`
return executeQuery(url, query, InfluxLanguage.Flux)
}
function findMeasurementsFlux(
url: string,
bucket: string,
searchTerm: string
): Promise<ExecuteFluxQueryResult> {
let query = `from(bucket: "${bucket}")
|> range(start: -${SEARCH_DURATION})`
if (searchTerm) {
query += `
|> filter(fn: (r) => r._measurement =~ /(?i:${searchTerm})/)`
}
query += `
|> group(columns: ["_measurement"])
|> distinct(column: "_measurement")
|> group()
|> sort(columns: ["_measurement"])
|> limit(n: ${LIMIT})`
return executeQuery(url, query, InfluxLanguage.Flux)
}
function findFieldsFlux(
url: string,
bucket: string,
measurements: string[],
searchTerm: string
): Promise<ExecuteFluxQueryResult> {
const measurementPredicate = measurements
.map(m => `r._measurement == "${m}"`)
.join(' or ')
let query = `from(bucket: "${bucket}")
|> range(start: -${SEARCH_DURATION})
|> filter(fn: (r) => ${measurementPredicate})`
if (searchTerm) {
query += `
|> filter(fn: (r) => r._field =~ /(?i:${searchTerm})/)`
}
query += `
|> group(columns: ["_field"])
|> distinct(column: "_field")
|> group()
|> sort(columns: ["_field"])
|> limit(n: ${LIMIT})`
return executeQuery(url, query, InfluxLanguage.Flux)
}
function extractCol(resp: ExecuteFluxQueryResult, colName: string): string[] {
const tables = parseResponse(resp.csv)
const data = get(tables, '0.data', [])
@ -152,3 +98,100 @@ function extractCol(resp: ExecuteFluxQueryResult, colName: string): string[] {
return colValues
}
function formatTagKeyFilterCall(tagsSelections: BuilderConfig['tags']) {
const keys = tagsSelections.map(({key}) => key)
if (!keys.length) {
return ''
}
const fnBody = keys.map(key => `r._value != "${key}"`).join(' and ')
return `\n |> filter(fn: (r) => ${fnBody})`
}
function formatSearchFilterCall(searchTerm: string) {
if (!searchTerm) {
return ''
}
return `\n |> filter(fn: (r) => r._value =~ /(?i:${searchTerm})/)`
}
export class CancellationError extends Error {}
export class QueryBuilderFetcher {
private findBucketsToken: string = ''
private findKeysTokens = []
private findValuesTokens = []
public async findBuckets(url: string): Promise<string[]> {
const token = uuid.v4()
this.findBucketsToken = token
const result = await findBuckets(url)
if (token !== this.findBucketsToken) {
throw new CancellationError()
}
return result
}
public async findKeys(
index: number,
url: string,
bucket: string,
tagsSelections: BuilderConfig['tags'],
searchTerm: string = ''
): Promise<string[]> {
const token = uuid.v4()
this.findKeysTokens[index] = token
const result = await findKeys(url, bucket, tagsSelections, searchTerm)
if (token !== this.findKeysTokens[index]) {
throw new CancellationError()
}
return result
}
public cancelFindKeys(index) {
this.findKeysTokens[index] = uuid.v4()
}
public async findValues(
index: number,
url: string,
bucket: string,
tagsSelections: BuilderConfig['tags'],
key: string,
searchTerm: string = ''
): Promise<string[]> {
const token = uuid.v4()
this.findValuesTokens[index] = token
const result = await findValues(
url,
bucket,
tagsSelections,
key,
searchTerm
)
if (token !== this.findValuesTokens[index]) {
throw new CancellationError()
}
return result
}
public cancelFindValues(index) {
this.findValuesTokens[index] = uuid.v4()
}
}

View File

@ -1,73 +0,0 @@
@import "src/style/modules";
.builder-card {
position: relative;
overflow: hidden;
min-height: 150px;
background: $g4-onyx;
border-radius: 4px;
display: flex;
flex-direction: column;
font-size: 13px;
}
.builder-card-search-bar {
margin: 10px;
flex-grow: 0;
flex-shrink: 0;
}
.builder-card--items {
flex: 1 1 0;
overflow: scroll;
}
.builder-card--item {
font-weight: 500;
padding: 5px 15px;
margin-bottom: 1px;
cursor: pointer;
color: $g14-chromium;
font-family: $code-font;
&.selected {
background: $c-pool;
color: $g18-cloud;
}
&.selected:hover {
background: $c-pool;
opacity: 0.9;
}
&:hover {
background: $g4-onyx;
}
}
.builder-card--empty {
position: absolute;
top: 50%;
transform: translate(0, -50%);
color: $g8-storm;
font-weight: 500;
font-size: 16px;
text-transform: uppercase;
text-align: center;
width: 100%;
padding: 20px;
.waiting-text {
text-align: start;
width: 85px;
margin: 0 auto;
}
}
.builder-card-limit-message {
padding: 15px;
color: $g9-mountain;
text-align: center;
font-style: italic;
}

View File

@ -1,119 +0,0 @@
// Libraries
import React, {PureComponent} from 'react'
// Components
import BuilderCardSearchBar from 'src/shared/components/BuilderCardSearchBar'
import BuilderCardLimitMessage from 'src/shared/components/BuilderCardLimitMessage'
import WaitingText from 'src/shared/components/WaitingText'
import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar'
// Styles
import 'src/shared/components/BuilderCard.scss'
// Types
import {RemoteDataState} from 'src/types'
interface Props {
items: string[]
selectedItems: string[]
onSelectItems: (selection: string[]) => void
onSearch: (searchTerm: string) => Promise<void>
status?: RemoteDataState
emptyText?: string
limitCount?: number
singleSelect?: boolean
}
class BuilderCard extends PureComponent<Props> {
public static defaultProps: Partial<Props> = {
status: RemoteDataState.Done,
emptyText: 'None Found',
limitCount: Infinity,
singleSelect: false,
}
public render() {
return <div className="builder-card">{this.body}</div>
}
private get body(): JSX.Element {
const {status, onSearch, emptyText} = this.props
if (status === RemoteDataState.Error) {
return <div className="builder-card--empty">Failed to load</div>
}
if (status === RemoteDataState.NotStarted) {
return <div className="builder-card--empty">{emptyText}</div>
}
return (
<>
<BuilderCardSearchBar onSearch={onSearch} />
{this.items}
</>
)
}
private get items(): JSX.Element {
const {status, items, limitCount} = this.props
if (status === RemoteDataState.Loading) {
return (
<div className="builder-card--empty">
<WaitingText text="Loading" />
</div>
)
}
return (
<>
<div className="builder-card--items">
{items.length ? (
<FancyScrollbar>
{items.map(item => (
<div
key={item}
className={this.itemClass(item)}
onClick={this.handleToggleItem(item)}
>
{item}
</div>
))}
</FancyScrollbar>
) : (
<div className="builder-card--empty">Nothing found</div>
)}
</div>
<BuilderCardLimitMessage
itemCount={items.length}
limitCount={limitCount}
/>
</>
)
}
private itemClass = (item): string => {
if (this.props.selectedItems.includes(item)) {
return 'builder-card--item selected'
}
return 'builder-card--item'
}
private handleToggleItem = (item: string) => (): void => {
const {singleSelect, selectedItems, onSelectItems} = this.props
if (singleSelect && selectedItems[0] === item) {
onSelectItems([])
} else if (singleSelect) {
onSelectItems([item])
} else if (selectedItems.includes(item)) {
onSelectItems(selectedItems.filter(x => x !== item))
} else {
onSelectItems([...selectedItems, item])
}
}
}
export default BuilderCard

View File

@ -1,20 +0,0 @@
import React, {SFC} from 'react'
interface Props {
itemCount: number
limitCount: number
}
const BuilderCardLimitMessage: SFC<Props> = ({itemCount, limitCount}) => {
if (itemCount < limitCount) {
return null
}
return (
<div className="builder-card-limit-message">
Showing first {limitCount} results. Use the search bar to find more.
</div>
)
}
export default BuilderCardLimitMessage

View File

@ -1,63 +0,0 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
// Components
import {Input, ComponentSize} from 'src/clockface'
// Utils
import DefaultDebouncer, {Debouncer} from 'src/shared/utils/debouncer'
import {CancellationError} from 'src/utils/restartable'
const SEARCH_DEBOUNCE_MS = 350
interface Props {
onSearch: (searchTerm: string) => Promise<void>
}
interface State {
searchTerm: string
}
class BuilderCardSearchBar extends PureComponent<Props, State> {
public state: State = {searchTerm: ''}
private debouncer: Debouncer = new DefaultDebouncer()
public render() {
const {searchTerm} = this.state
return (
<div className="builder-card-search-bar">
<Input
size={ComponentSize.Small}
placeholder="Search..."
value={searchTerm}
onChange={this.handleChange}
/>
</div>
)
}
private handleChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({searchTerm: e.target.value}, () =>
this.debouncer.call(this.emitChange, SEARCH_DEBOUNCE_MS)
)
}
private emitChange = async () => {
const {onSearch} = this.props
const {searchTerm} = this.state
try {
await onSearch(searchTerm)
} catch (e) {
if (e instanceof CancellationError) {
return
}
this.setState({searchTerm: ''})
}
}
}
export default BuilderCardSearchBar

View File

@ -0,0 +1,26 @@
@import "src/style/modules";
.function-selector {
display: flex;
flex-direction: column;
border-radius: 4px;
background: $g4-onyx;
h3 {
margin: 10px 5px 10px 10px;
padding: 5px 0 0 0;
text-transform: uppercase;
color: $g10-wolf;
font-weight: 500;
font-size: 16px;
}
}
.function-selector--search {
margin: 0 10px 10px 10px;
width: initial !important;
}
.function-selector .selector-list {
flex: 1 1 0;
}

View File

@ -0,0 +1,91 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import {connect} from 'react-redux'
// Components
import {Input} from 'src/clockface'
import SelectorList from 'src/shared/components/SelectorList'
// Actions
import {selectFunction} from 'src/shared/actions/v2/queryBuilder'
// Utils
import {getActiveQuery} from 'src/shared/selectors/timeMachines'
// Constants
import {FUNCTIONS} from 'src/shared/constants/queryBuilder'
// Styles
import 'src/shared/components/FunctionSelector.scss'
// Types
import {AppState, BuilderConfig} from 'src/types/v2'
const FUNCTION_NAMES = FUNCTIONS.map(f => f.name)
interface StateProps {
selectedFunctions: BuilderConfig['functions']
}
interface DispatchProps {
onSelectFunction: (fnName: string) => void
}
type Props = StateProps & DispatchProps
interface State {
searchTerm: string
}
class FunctionSelector extends PureComponent<Props, State> {
public state: State = {searchTerm: ''}
public render() {
const {onSelectFunction} = this.props
const {searchTerm} = this.state
return (
<div className="function-selector">
<h3>Aggregate Functions</h3>
<Input
customClass={'function-selector--search'}
value={searchTerm}
onChange={this.handleSetSearchTerm}
placeholder="Search functions..."
/>
<SelectorList
items={this.functions}
selectedItems={this.selectedFunctions}
onSelectItem={onSelectFunction}
/>
</div>
)
}
private get functions(): string[] {
return FUNCTION_NAMES.filter(f => f.includes(this.state.searchTerm))
}
private get selectedFunctions(): string[] {
return this.props.selectedFunctions.map(f => f.name)
}
private handleSetSearchTerm = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({searchTerm: e.target.value})
}
}
const mstp = (state: AppState) => {
const selectedFunctions = getActiveQuery(state).builderConfig.functions
return {selectedFunctions}
}
const mdtp = {
onSelectFunction: selectFunction,
}
export default connect<StateProps, DispatchProps>(
mstp,
mdtp
)(FunctionSelector)

View File

@ -0,0 +1,70 @@
// Libraries
import React, {SFC} from 'react'
import {connect} from 'react-redux'
// Components
import {Dropdown, ComponentSize} from 'src/clockface'
// Actions
import {selectBucket} from 'src/shared/actions/v2/queryBuilder'
// Utils
import {
getActiveTimeMachine,
getActiveQuery,
} from 'src/shared/selectors/timeMachines'
import {toComponentStatus} from 'src/shared/utils/toComponentStatus'
// Types
import {AppState} from 'src/types/v2'
import {RemoteDataState} from 'src/types'
interface StateProps {
selectedBucket: string
buckets: string[]
bucketsStatus: RemoteDataState
}
interface DispatchProps {
onSelectBucket: (bucket: string) => void
}
interface OwnProps {}
type Props = StateProps & DispatchProps & OwnProps
const QueryBuilderBucketDropdown: SFC<Props> = props => {
const {selectedBucket, buckets, bucketsStatus, onSelectBucket} = props
return (
<Dropdown
selectedID={selectedBucket}
onChange={onSelectBucket}
buttonSize={ComponentSize.Small}
status={toComponentStatus(bucketsStatus)}
>
{buckets.map(bucket => (
<Dropdown.Item key={bucket} id={bucket} value={bucket}>
{bucket}
</Dropdown.Item>
))}
</Dropdown>
)
}
const mstp = (state: AppState) => {
const {buckets, bucketsStatus} = getActiveTimeMachine(state).queryBuilder
const selectedBucket =
getActiveQuery(state).builderConfig.buckets[0] || buckets[0]
return {selectedBucket, buckets, bucketsStatus}
}
const mdtp = {
onSelectBucket: selectBucket as any,
}
export default connect<StateProps, DispatchProps, OwnProps>(
mstp,
mdtp
)(QueryBuilderBucketDropdown)

View File

@ -0,0 +1,25 @@
@import "src/style/modules";
.selector-list--item {
font-weight: 500;
padding: 5px 15px;
margin-bottom: 1px;
cursor: pointer;
color: $g14-chromium;
font-family: $code-font;
font-size: 13px;
&.selected {
background: $c-pool;
color: $g18-cloud;
}
&.selected:hover {
background: $c-pool;
opacity: 0.9;
}
&:hover {
background: $g5-pepper;
}
}

View File

@ -0,0 +1,37 @@
import React, {SFC} from 'react'
import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar'
import 'src/shared/components/SelectorList.scss'
interface Props {
items: string[]
selectedItems: string[]
onSelectItem: (item: string) => void
}
const SelectorList: SFC<Props> = props => {
const {items, selectedItems, onSelectItem} = props
return (
<div className="selector-list">
<FancyScrollbar>
{items.map(item => {
const selectedClass = selectedItems.includes(item) ? 'selected' : ''
return (
<div
className={`selector-list--item ${selectedClass}`}
key={item}
onClick={() => onSelectItem(item)}
>
{item}
</div>
)
})}
</FancyScrollbar>
</div>
)
}
export default SelectorList

View File

@ -0,0 +1,53 @@
@import "src/style/modules";
.tag-selector {
min-height: 130px;
background: $g4-onyx;
border-radius: 4px;
padding: 10px 0;
overflow: hidden;
display: flex;
flex-direction: column;
font-size: 13px;
input {
margin-top: 5px;
}
}
.tag-selector--top, .tag-selector--search {
margin: 0 10px;
}
.tag-selector--top {
display: flex;
}
.tag-selector--search {
margin-bottom: 10px;
width: initial !important;
}
button.tag-selector--remove {
margin-left: 3px;
}
.tag-selector .selector-list {
flex: 1 1 0;
}
.tag-selector--empty {
flex: 1 1 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
color: $g8-storm;
font-weight: 500;
font-size: 16px;
text-transform: uppercase;
text-align: center;
padding: 10px;
}

View File

@ -0,0 +1,233 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import {connect} from 'react-redux'
// Components
import {Dropdown, Input, Button, ButtonShape, IconFont} from 'src/clockface'
import WaitingText from 'src/shared/components/WaitingText'
import SelectorList from 'src/shared/components/SelectorList'
// Actions
import {
selectTagKey,
selectTagValue,
searchTagValues,
removeTagSelector,
} from 'src/shared/actions/v2/queryBuilder'
// Utils
import {toComponentStatus} from 'src/shared/utils/toComponentStatus'
import DefaultDebouncer from 'src/shared/utils/debouncer'
import {
getActiveQuery,
getActiveTimeMachine,
} from 'src/shared/selectors/timeMachines'
// Styles
import 'src/shared/components/TagSelector.scss'
// Types
import {AppState} from 'src/types/v2'
import {RemoteDataState} from 'src/types'
const SEARCH_DEBOUNCE_MS = 500
interface StateProps {
emptyText: string
keys: string[]
keysStatus: RemoteDataState
selectedKey: string
values: string[]
valuesStatus: RemoteDataState
selectedValues: string[]
}
interface DispatchProps {
onSelectValue: (index: number, value: string) => void
onSelectTag: (index: number, tag: string) => void
onSearchValues: (index: number, searchTerm: string) => void
onRemoveTagSelector: (index: number) => void
}
interface OwnProps {
index: number
}
type Props = StateProps & DispatchProps & OwnProps
interface State {
searchTerm: string
}
class TagSelector extends PureComponent<Props, State> {
public state: State = {searchTerm: ''}
private debouncer = new DefaultDebouncer()
public render() {
return <div className="tag-selector">{this.body}</div>
}
private get body() {
const {index, keys, keysStatus, selectedKey, emptyText} = this.props
const {searchTerm} = this.state
if (keysStatus === RemoteDataState.NotStarted) {
return <div className="tag-selector--empty">{emptyText}</div>
}
if (keysStatus === RemoteDataState.Loading) {
return (
<div className="tag-selector--empty">
<WaitingText text="Loading tag keys" />
</div>
)
}
if (keysStatus === RemoteDataState.Error) {
return <div className="tag-selector--empty">Failed to load tag keys</div>
}
if (keysStatus === RemoteDataState.Done && !keys.length) {
return <div className="tag-selector--empty">No more tag keys found</div>
}
return (
<>
<div className="tag-selector--top">
<Dropdown
selectedID={selectedKey}
onChange={this.handleSelectTag}
status={toComponentStatus(keysStatus)}
titleText="No Tags Found"
>
{keys.map(key => (
<Dropdown.Item key={key} id={key} value={key}>
{key}
</Dropdown.Item>
))}
</Dropdown>
{index !== 0 && (
<Button
shape={ButtonShape.Square}
icon={IconFont.Remove}
onClick={this.handleRemoveTagSelector}
customClass="tag-selector--remove"
/>
)}
</div>
<Input
value={searchTerm}
placeholder={`Search ${selectedKey} tag values`}
customClass="tag-selector--search"
onChange={this.handleSearch}
/>
{this.values}
</>
)
}
private get values() {
const {selectedKey, values, valuesStatus, selectedValues} = this.props
if (valuesStatus === RemoteDataState.Error) {
return (
<div className="tag-selector--empty">
{`Failed to load tag values for ${selectedKey}`}
</div>
)
}
if (valuesStatus === RemoteDataState.Loading) {
return (
<div className="tag-selector--empty">
<WaitingText text="Loading tag values" />
</div>
)
}
if (valuesStatus === RemoteDataState.Done && !values.length) {
return <div className="tag-selector--empty">Nothing found</div>
}
return (
<SelectorList
items={values}
selectedItems={selectedValues}
onSelectItem={this.handleSelectValue}
/>
)
}
private handleSelectTag = (tag: string): void => {
const {index, onSelectTag} = this.props
onSelectTag(index, tag)
}
private handleSelectValue = (value: string): void => {
const {index, onSelectValue} = this.props
onSelectValue(index, value)
}
private handleRemoveTagSelector = () => {
const {index, onRemoveTagSelector} = this.props
onRemoveTagSelector(index)
}
private handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
const {value} = e.target
this.setState({searchTerm: value}, () => {
this.debouncer.call(this.emitSearch, SEARCH_DEBOUNCE_MS)
})
}
private emitSearch = () => {
const {index, onSearchValues} = this.props
const {searchTerm} = this.state
onSearchValues(index, searchTerm)
}
}
const mstp = (state: AppState, ownProps: OwnProps): StateProps => {
const {keys, keysStatus, values, valuesStatus} = getActiveTimeMachine(
state
).queryBuilder.tags[ownProps.index]
const tags = getActiveQuery(state).builderConfig.tags
const {key: selectedKey, values: selectedValues} = tags[ownProps.index]
let emptyText: string
if (ownProps.index === 0 || !tags[ownProps.index - 1].key) {
emptyText = ''
} else {
emptyText = `Select a ${tags[ownProps.index - 1].key} value first`
}
return {
emptyText,
keys,
keysStatus,
selectedKey,
values,
valuesStatus,
selectedValues,
}
}
const mdtp = {
onSelectValue: selectTagValue,
onSelectTag: selectTagKey,
onSearchValues: searchTagValues,
onRemoveTagSelector: removeTagSelector,
}
export default connect<StateProps, DispatchProps, OwnProps>(
mstp,
mdtp
)(TagSelector)

View File

@ -11,7 +11,7 @@ import TimeMachineVis from 'src/shared/components/TimeMachineVis'
import TimeSeries from 'src/shared/components/TimeSeries'
// Constants
const INITIAL_RESIZER_HANDLE = 0.6
const INITIAL_RESIZER_HANDLE = 0.5
// Utils
import {getActiveTimeMachine} from 'src/shared/selectors/timeMachines'

View File

@ -11,7 +11,6 @@ import {
ComponentSpacer,
Alignment,
} from 'src/clockface'
import TimeMachineSourceDropdown from 'src/shared/components/TimeMachineSourceDropdown'
import TimeMachineRefreshDropdown from 'src/shared/components/TimeMachineRefreshDropdown'
import ViewTypeDropdown from 'src/shared/components/view_options/ViewTypeDropdown'
@ -56,7 +55,6 @@ class TimeMachineControls extends PureComponent<Props> {
return (
<div className="time-machine--controls">
<ComponentSpacer align={Alignment.Left}>
<TimeMachineSourceDropdown />
<ViewTypeDropdown />
</ComponentSpacer>
<ComponentSpacer align={Alignment.Right}>

View File

@ -88,7 +88,9 @@ const TimeMachineQueries: SFC<Props> = props => {
</div>
<div className="time-machine-queries--buttons">
<TimeMachineQueriesSwitcher />
<SubmitQueryButton queryStatus={queryStatus} />
{activeQuery.editMode !== QueryEditMode.Builder && (
<SubmitQueryButton queryStatus={queryStatus} />
)}
</div>
</div>
<div className="time-machine-queries--body">{queryEditor}</div>

View File

@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'
import {connect} from 'react-redux'
// Components
import {Button, Dropdown} from 'src/clockface'
import {Button} from 'src/clockface'
// Actions
import {
@ -13,14 +13,18 @@ import {
} from 'src/shared/actions/v2/timeMachines'
// Utils
import {getActiveQuery} from 'src/shared/selectors/timeMachines'
import {
getActiveQuery,
getActiveQuerySource,
} from 'src/shared/selectors/timeMachines'
import {CONFIRM_LEAVE_ADVANCED_MODE} from 'src/shared/copy/v2'
// Types
import {AppState, QueryEditMode} from 'src/types/v2'
import {AppState, QueryEditMode, Source} from 'src/types/v2'
interface StateProps {
editMode: QueryEditMode
sourceType: Source.TypeEnum
}
interface DispatchProps {
@ -33,7 +37,7 @@ type Props = StateProps & DispatchProps
class TimeMachineQueriesSwitcher extends PureComponent<Props> {
public render() {
const {editMode, onEditAsFlux, onEditAsInfluxQL} = this.props
const {editMode, sourceType, onEditAsFlux, onEditAsInfluxQL} = this.props
if (editMode !== QueryEditMode.Builder) {
return (
@ -44,21 +48,11 @@ class TimeMachineQueriesSwitcher extends PureComponent<Props> {
)
}
return (
<Dropdown
selectedID=""
titleText="Edit Query As..."
widthPixels={130}
onChange={this.handleChooseLanguage}
>
<Dropdown.Item id={'influxQL'} value={onEditAsFlux}>
Flux
</Dropdown.Item>
<Dropdown.Item id={'flux'} value={onEditAsInfluxQL}>
InfluxQL
</Dropdown.Item>
</Dropdown>
)
if (sourceType === Source.TypeEnum.V1) {
return <Button text="Edit Query As InfluxQL" onClick={onEditAsInfluxQL} />
}
return <Button text="Edit Query As Flux" onClick={onEditAsFlux} />
}
private handleEditWithBuilder = (): void => {
@ -68,16 +62,13 @@ class TimeMachineQueriesSwitcher extends PureComponent<Props> {
onEditWithBuilder()
}
}
private handleChooseLanguage = (actionCreator): void => {
actionCreator()
}
}
const mstp = (state: AppState) => {
const editMode = getActiveQuery(state).editMode
const sourceType = getActiveQuerySource(state).type
return {editMode}
return {editMode, sourceType}
}
const mdtp = {

View File

@ -6,46 +6,55 @@
right: 0;
left: 0;
bottom: 0;
display: flex;
padding: 10px;
display: flex;
flex-direction: column;
@include no-user-select();
background: $g3-castle;
}
.query-builder--panel {
margin-left: 5px;
height: 100%;
flex: 1 1 0;
.query-builder--buttons {
display: flex;
flex-direction: column;
justify-content: flex-start;
&:first-child {
margin-left: 0;
> .form--element {
width: 200px;
margin-right: 5px;
}
}
.query-builder--panel-header {
padding: 10px 0;
border-radius: 5px 5px 0 0;
text-transform: uppercase;
font-weight: 500;
font-size: 13px;
color: $g8-storm;
.query-builder--cards {
flex: 1 1 0;
display: flex;
justify-content: center;
align-items: center;
justify-content: space-between;
}
small {
margin-left: 10px;
padding: 2px 4px 2px 3px;
border-radius: 2px;
background-color: $g4-onyx;
font-style: italic;
font-size: 11px;
.query-builder--tag-selectors {
display: flex;
flex-wrap: nowrap;
flex: 1 1 0;
height: 100%;
.tag-selector {
margin-right: 5px;
flex: 0 0 250px;
&:last-child {
margin-right: 0;
}
}
}
.builder-card {
height: 100%;
flex: 1 1 0;
.query-builder .function-selector {
flex: 0 0 250px;
margin-left: 10px;
}
button.query-builder--add-tag-selector {
height: 100%;
background-color: $g4-onyx;
border-color: $g4-onyx;
margin-right: 5px;
flex-grow: 0;
flex-shrink: 0;
}

View File

@ -1,375 +1,85 @@
// Libraries
import React, {PureComponent} from 'react'
import {connect} from 'react-redux'
import {range} from 'lodash'
// Components
import BuilderCard from 'src/shared/components/BuilderCard'
// APIs
import {
findBuckets,
findMeasurements,
findFields,
LIMIT,
} from 'src/shared/apis/v2/queryBuilder'
import {Form, Button, ButtonShape, IconFont} from 'src/clockface'
import TagSelector from 'src/shared/components/TagSelector'
import QueryBuilderBucketDropdown from 'src/shared/components/QueryBuilderBucketDropdown'
import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar'
import FunctionSelector from 'src/shared/components/FunctionSelector'
// Actions
import {buildQuery} from 'src/shared/actions/v2/timeMachines'
import {loadBuckets, addTagSelector} from 'src/shared/actions/v2/queryBuilder'
// Utils
import {restartable, CancellationError} from 'src/utils/restartable'
import {getActiveQuery} from 'src/shared/selectors/timeMachines'
import {getSources, getActiveSource} from 'src/sources/selectors'
// Constants
import {FUNCTIONS} from 'src/shared/constants/queryBuilder'
// Styles
import 'src/shared/components/TimeMachineQueryBuilder.scss'
// Types
import {RemoteDataState} from 'src/types'
import {AppState, BuilderConfig} from 'src/types/v2'
import {Source} from 'src/api'
const EMPTY_FIELDS_MESSAGE = 'Select at least one bucket and measurement'
const EMPTY_FUNCTIONS_MESSAGE = 'Select at least one bucket and measurement'
const EMPTY_MEASUREMENTS_MESSAGE = 'Select a bucket'
const mergeUnique = (items: string[], selection: string[]) =>
[...new Set([...items, ...selection])].sort()
import {AppState} from 'src/types/v2'
interface StateProps {
queryURL: string
sourceType: Source.TypeEnum
tagFiltersLength: number
}
interface DispatchProps {
onBuildQuery: typeof buildQuery
onLoadBuckets: () => Promise<void>
onAddTagSelector: () => void
}
type Props = StateProps & DispatchProps
interface State {
buckets: string[]
bucketsStatus: RemoteDataState
bucketsSelection: string[]
measurements: string[]
measurementsStatus: RemoteDataState
measurementsSelection: string[]
fields: string[]
fieldsStatus: RemoteDataState
fieldsSelection: string[]
functions: string[]
functionsStatus: RemoteDataState
functionsSelection: string[]
}
interface State {}
class TimeMachineQueryBuilder extends PureComponent<Props, State> {
public state: State = {
buckets: [],
bucketsStatus: RemoteDataState.Loading,
bucketsSelection: [],
measurements: [],
measurementsStatus: RemoteDataState.NotStarted,
measurementsSelection: [],
fields: [],
fieldsStatus: RemoteDataState.NotStarted,
fieldsSelection: [],
functions: FUNCTIONS.map(f => f.name),
functionsStatus: RemoteDataState.NotStarted,
functionsSelection: [],
}
private findBuckets = restartable(findBuckets)
private findMeasurements = restartable(findMeasurements)
private findFields = restartable(findFields)
public componentDidMount() {
this.findAndSetBuckets()
}
public componentDidUpdate(prevProps: Props) {
if (prevProps.queryURL !== this.props.queryURL) {
this.findAndSetBuckets()
}
this.props.onLoadBuckets()
}
public render() {
const {
buckets,
bucketsStatus,
bucketsSelection,
measurements,
measurementsStatus,
measurementsSelection,
fields,
fieldsStatus,
fieldsSelection,
functions,
functionsSelection,
functionsStatus,
} = this.state
const {tagFiltersLength, onAddTagSelector} = this.props
return (
<div className="query-builder">
<div className="query-builder--panel">
<div className="query-builder--panel-header">Select a Bucket</div>
<BuilderCard
status={bucketsStatus}
items={buckets}
selectedItems={bucketsSelection}
onSelectItems={this.handleSelectBuckets}
onSearch={this.findAndSetBuckets}
limitCount={LIMIT}
singleSelect={true}
/>
<div className="query-builder--buttons">
<Form.Element label="Bucket">
<QueryBuilderBucketDropdown />
</Form.Element>
</div>
<div className="query-builder--panel">
<div className="query-builder--panel-header">Select Measurements</div>
<BuilderCard
status={measurementsStatus}
items={measurements}
selectedItems={measurementsSelection}
onSelectItems={this.handleSelectMeasurement}
onSearch={this.findAndSetMeasurements}
emptyText={EMPTY_MEASUREMENTS_MESSAGE}
limitCount={LIMIT}
/>
</div>
<div className="query-builder--panel">
<div className="query-builder--panel-header">
Select Fields
<small>Optional</small>
</div>
<BuilderCard
status={fieldsStatus}
items={fields}
selectedItems={fieldsSelection}
onSelectItems={this.handleSelectFields}
onSearch={this.findAndSetFields}
emptyText={EMPTY_FIELDS_MESSAGE}
limitCount={LIMIT}
/>
</div>
<div className="query-builder--panel">
<div className="query-builder--panel-header">
Select Functions
<small>Optional</small>
</div>
<BuilderCard
status={functionsStatus}
items={functions}
selectedItems={functionsSelection}
onSelectItems={this.handleSelectFunctions}
onSearch={this.handleSearchFunctions}
emptyText={EMPTY_FUNCTIONS_MESSAGE}
limitCount={LIMIT}
/>
<div className="query-builder--cards">
<FancyScrollbar>
<div className="query-builder--tag-selectors">
{range(tagFiltersLength).map(i => (
<TagSelector key={i} index={i} />
))}
<Button
shape={ButtonShape.Square}
icon={IconFont.Plus}
onClick={onAddTagSelector}
customClass="query-builder--add-tag-selector"
/>
</div>
</FancyScrollbar>
<FunctionSelector />
</div>
</div>
)
}
private findAndSetBuckets = async (
searchTerm: string = ''
): Promise<void> => {
const {queryURL, sourceType} = this.props
this.setState({bucketsStatus: RemoteDataState.Loading})
try {
const buckets = await this.findBuckets(queryURL, sourceType, searchTerm)
const {bucketsSelection} = this.state
this.setState({
buckets: mergeUnique(buckets, bucketsSelection),
bucketsStatus: RemoteDataState.Done,
})
} catch (e) {
if (e instanceof CancellationError) {
return
}
this.setState({bucketsStatus: RemoteDataState.Error})
}
}
private handleSelectBuckets = (bucketsSelection: string[]) => {
if (bucketsSelection.length) {
this.setState({bucketsSelection}, () => {
this.findAndSetMeasurements()
this.emitConfig()
})
return
}
this.setState(
{
bucketsSelection: [],
measurements: [],
measurementsStatus: RemoteDataState.NotStarted,
measurementsSelection: [],
fields: [],
fieldsStatus: RemoteDataState.NotStarted,
fieldsSelection: [],
functionsStatus: RemoteDataState.NotStarted,
functionsSelection: [],
},
this.emitConfig
)
}
private findAndSetMeasurements = async (
searchTerm: string = ''
): Promise<void> => {
const {queryURL, sourceType} = this.props
const [selectedBucket] = this.state.bucketsSelection
this.setState({measurementsStatus: RemoteDataState.Loading})
try {
const measurements = await this.findMeasurements(
queryURL,
sourceType,
selectedBucket,
searchTerm
)
const {measurementsSelection} = this.state
this.setState({
measurements: mergeUnique(measurements, measurementsSelection),
measurementsStatus: RemoteDataState.Done,
})
} catch (e) {
if (e instanceof CancellationError) {
return
}
this.setState({measurementsStatus: RemoteDataState.Error})
}
}
private handleSelectMeasurement = (measurementsSelection: string[]) => {
if (measurementsSelection.length) {
this.setState(
{
measurementsSelection,
functionsStatus: RemoteDataState.Done,
},
() => {
this.findAndSetFields()
this.emitConfig()
}
)
return
}
this.setState(
{
measurementsSelection: [],
fields: [],
fieldsStatus: RemoteDataState.NotStarted,
fieldsSelection: [],
functionsStatus: RemoteDataState.NotStarted,
functionsSelection: [],
},
this.emitConfig
)
}
private findAndSetFields = async (searchTerm: string = ''): Promise<void> => {
const {queryURL, sourceType} = this.props
const {measurementsSelection} = this.state
const [selectedBucket] = this.state.bucketsSelection
this.setState({fieldsStatus: RemoteDataState.Loading})
try {
const fields = await this.findFields(
queryURL,
sourceType,
selectedBucket,
measurementsSelection,
searchTerm
)
const {fieldsSelection} = this.state
this.setState({
fields: mergeUnique(fields, fieldsSelection),
fieldsStatus: RemoteDataState.Done,
})
} catch (e) {
if (e instanceof CancellationError) {
return
}
this.setState({fieldsStatus: RemoteDataState.Error})
}
}
private handleSelectFields = (fieldsSelection: string[]) => {
this.setState({fieldsSelection}, this.emitConfig)
}
private handleSelectFunctions = (functionsSelection: string[]) => {
this.setState({functionsSelection}, this.emitConfig)
}
private handleSearchFunctions = async (searchTerm: string) => {
const {functionsSelection} = this.state
const functions = FUNCTIONS.map(f => f.name).filter(name =>
name.toLowerCase().includes(searchTerm.toLowerCase())
)
this.setState({functions: mergeUnique(functions, functionsSelection)})
}
private emitConfig = (): void => {
const {onBuildQuery} = this.props
const {
bucketsSelection,
measurementsSelection,
fieldsSelection,
functionsSelection,
} = this.state
const config: BuilderConfig = {
buckets: bucketsSelection,
measurements: measurementsSelection,
fields: fieldsSelection,
functions: functionsSelection,
}
onBuildQuery(config)
}
}
const mstp = (state: AppState): StateProps => {
const sources = getSources(state)
const activeSource = getActiveSource(state)
const activeQuery = getActiveQuery(state)
const tagFiltersLength = getActiveQuery(state).builderConfig.tags.length
let source: Source
if (activeQuery.sourceID) {
source = sources.find(source => source.id === activeQuery.sourceID)
} else {
source = activeSource
}
const queryURL = source.links.query
const sourceType = source.type
return {queryURL, sourceType}
return {tagFiltersLength}
}
const mdtp = {
onBuildQuery: buildQuery,
onLoadBuckets: loadBuckets as any,
onAddTagSelector: addTagSelector,
}
export default connect<StateProps, DispatchProps>(

View File

@ -1,71 +0,0 @@
// Libraries
import React, {SFC} from 'react'
import {connect} from 'react-redux'
import {get} from 'lodash'
// Components
import {Dropdown, ComponentSize} from 'src/clockface'
// Actions
import {setQuerySource} from 'src/shared/actions/v2/timeMachines'
// Utils
import {getActiveTimeMachine} from 'src/shared/selectors/timeMachines'
import {getSources} from 'src/sources/selectors'
// Types
import {AppState, Source} from 'src/types/v2'
const DYNAMIC_SOURCE = {id: '', name: 'Dynamic Source'}
const DROPDOWN_WIDTH = 200
interface StateProps {
sources: Source[]
selectedSourceID: string | null
}
interface DispatchProps {
onSelectSource: (sourceID: string) => void
}
type Props = StateProps & DispatchProps
const TimeMachineSourceDropdown: SFC<Props> = props => {
const {sources, selectedSourceID, onSelectSource} = props
return (
<Dropdown
selectedID={selectedSourceID}
onChange={onSelectSource}
buttonSize={ComponentSize.Small}
widthPixels={DROPDOWN_WIDTH}
>
{[DYNAMIC_SOURCE, ...sources].map(source => (
<Dropdown.Item key={source.id} id={source.id} value={source.id}>
{source.name}
</Dropdown.Item>
))}
</Dropdown>
)
}
const mstp = (state: AppState) => {
const sources = getSources(state)
const {activeQueryIndex, view} = getActiveTimeMachine(state)
const selectedSourceID: string = get(
view,
`properties.queries.${activeQueryIndex}.sourceID`,
''
)
return {sources, selectedSourceID}
}
const mdtp = {
onSelectSource: setQuerySource,
}
export default connect<StateProps, DispatchProps, {}>(
mstp,
mdtp
)(TimeMachineSourceDropdown)

View File

@ -113,7 +113,7 @@ class CellComponent extends Component<Props> {
} = this.props
return (
<Conditional isRendered={viewStatus !== RemoteDataState.Done}>
<Conditional isRendered={viewStatus === RemoteDataState.Done}>
<ViewComponent
view={view}
onZoom={onZoom}

View File

@ -1,2 +1,2 @@
export const CONFIRM_LEAVE_ADVANCED_MODE =
'You will lose any edits you have made to your query. Are you sure?'
'You will lose any manual edits you have made to your query. Are you sure?'

View File

@ -9,15 +9,14 @@ import {
// Actions
import {
submitScript,
setQuerySource,
setActiveTab,
setActiveTimeMachine,
setActiveQueryIndex,
setActiveQueryIndexSync,
editActiveQueryWithBuilder,
editActiveQueryAsFlux,
editActiveQueryAsInfluxQL,
addQuery,
removeQuery,
addQuerySync,
removeQuerySync,
updateActiveQueryName,
setBackgroundThresholdColoring,
setTextThresholdColoring,
@ -80,24 +79,14 @@ describe('timeMachinesReducer', () => {
type: InfluxLanguage.InfluxQL,
sourceID: '123',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '456',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
@ -127,12 +116,7 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '123',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
}
const queryB: DashboardQuery = {
@ -140,12 +124,7 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '456',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
}
state.view.properties.queries = [queryA, queryB]
@ -160,18 +139,6 @@ describe('timeMachineReducer', () => {
})
})
describe('SET_QUERY_SOURCE', () => {
test('replaces the sourceID for the active query', () => {
const state = initialStateHelper()
expect(state.draftQueries[0].sourceID).toEqual('')
const nextState = timeMachineReducer(state, setQuerySource('howdy'))
expect(nextState.draftQueries[0].sourceID).toEqual('howdy')
})
})
describe('EDIT_ACTIVE_QUERY_WITH_BUILDER', () => {
test('changes the activeQueryEditor and editMode for the currently active query', () => {
const state = initialStateHelper()
@ -183,24 +150,14 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
@ -213,12 +170,7 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: '',
@ -227,8 +179,7 @@ describe('timeMachineReducer', () => {
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
tags: [{key: '_measurement', values: []}],
functions: [],
},
},
@ -247,24 +198,14 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
@ -277,24 +218,14 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
])
})
@ -311,24 +242,14 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
@ -341,24 +262,14 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
])
})
@ -376,28 +287,18 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
const nextState = timeMachineReducer(state, setActiveQueryIndex(0))
const nextState = timeMachineReducer(state, setActiveQueryIndexSync(0))
expect(nextState.activeQueryIndex).toEqual(0)
})
@ -412,28 +313,18 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
const nextState = timeMachineReducer(state, setActiveQueryIndex(0))
const nextState = timeMachineReducer(state, setActiveQueryIndexSync(0))
expect(nextState.activeQueryIndex).toEqual(0)
})
@ -448,28 +339,18 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
const nextState = timeMachineReducer(state, setActiveQueryIndex(0))
const nextState = timeMachineReducer(state, setActiveQueryIndexSync(0))
expect(nextState.activeQueryIndex).toEqual(0)
})
@ -487,16 +368,11 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
const nextState = timeMachineReducer(state, addQuery())
const nextState = timeMachineReducer(state, addQuerySync())
expect(nextState.activeQueryIndex).toEqual(1)
expect(nextState.draftQueries).toEqual([
@ -505,12 +381,7 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: '',
@ -519,8 +390,7 @@ describe('timeMachineReducer', () => {
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
tags: [{key: '_measurement', values: []}],
functions: [],
},
},
@ -538,36 +408,21 @@ describe('timeMachineReducer', () => {
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'b',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
{
text: 'c',
type: InfluxLanguage.InfluxQL,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
functions: [],
},
builderConfig: {buckets: [], tags: [], functions: []},
},
]
})
@ -579,9 +434,8 @@ describe('timeMachineReducer', () => {
state.draftQueries = queries
state.activeQueryIndex = 1
const nextState = timeMachineReducer(state, removeQuery(1))
const nextState = timeMachineReducer(state, removeQuerySync(1))
expect(nextState.view.properties.queries).toEqual(queries)
expect(nextState.draftQueries).toEqual([queries[0], queries[2]])
expect(nextState.activeQueryIndex).toEqual(1)
})
@ -593,9 +447,8 @@ describe('timeMachineReducer', () => {
state.draftQueries = queries
state.activeQueryIndex = 2
const nextState = timeMachineReducer(state, removeQuery(2))
const nextState = timeMachineReducer(state, removeQuerySync(2))
expect(nextState.view.properties.queries).toEqual(queries)
expect(nextState.draftQueries).toEqual([queries[0], queries[1]])
expect(nextState.activeQueryIndex).toEqual(1)
})
@ -606,12 +459,7 @@ describe('timeMachineReducer', () => {
const state = initialStateHelper()
state.activeQueryIndex = 1
const builderConfig = {
buckets: [],
measurements: [],
fields: [],
functions: [],
}
const builderConfig = {buckets: [], tags: [], functions: []}
state.draftQueries = [
{

View File

@ -1,5 +1,6 @@
// Libraries
import {get, cloneDeep} from 'lodash'
import {produce} from 'immer'
// Utils
import {createView, defaultViewQuery} from 'src/shared/utils/view'
@ -17,6 +18,7 @@ import {
View,
ViewType,
DashboardQuery,
BuilderConfig,
InfluxLanguage,
QueryEditMode,
QueryView,
@ -25,6 +27,18 @@ import {
} from 'src/types/v2/dashboards'
import {Action} from 'src/shared/actions/v2/timeMachines'
import {TimeMachineTab} from 'src/types/v2/timeMachine'
import {RemoteDataState} from 'src/types'
interface QueryBuilderState {
buckets: string[]
bucketsStatus: RemoteDataState
tags: Array<{
keys: string[]
keysStatus: RemoteDataState
values: string[]
valuesStatus: RemoteDataState
}>
}
export interface TimeMachineState {
view: QueryView
@ -34,6 +48,7 @@ export interface TimeMachineState {
activeTab: TimeMachineTab
activeQueryIndex: number | null
submitToken: number
queryBuilder: QueryBuilderState
}
export interface TimeMachinesState {
@ -51,6 +66,11 @@ export const initialStateHelper = (): TimeMachineState => ({
activeTab: TimeMachineTab.Queries,
activeQueryIndex: 0,
submitToken: 0,
queryBuilder: {
buckets: [],
bucketsStatus: RemoteDataState.NotStarted,
tags: [],
},
})
export const initialState = (): TimeMachinesState => ({
@ -70,6 +90,7 @@ export const timeMachinesReducer = (
const activeTimeMachine = state.timeMachines[activeTimeMachineID]
const view = initialState.view || activeTimeMachine.view
const draftQueries = cloneDeep(view.properties.queries)
const queryBuilder = initialQueryBuilderState(draftQueries[0].builderConfig)
const activeQueryIndex = 0
return {
@ -83,6 +104,7 @@ export const timeMachinesReducer = (
activeTab: TimeMachineTab.Queries,
activeQueryIndex,
draftQueries,
queryBuilder,
},
},
}
@ -122,32 +144,11 @@ export const timeMachineReducer = (
}
case 'SET_TIME_RANGE': {
const {timeRange} = action.payload
const {view} = state
return produce(state, draftState => {
draftState.timeRange = action.payload.timeRange
const rebuildConfig = query => ({
...query,
text: buildQuery(query.builderConfig, timeRange.duration),
buildAndSubmitAllQueries(draftState)
})
const draftQueries = state.draftQueries.map(rebuildConfig)
const queries = view.properties.queries.map(rebuildConfig)
const newView = {
...view,
properties: {
...view.properties,
queries,
},
}
return {
...state,
timeRange,
view: newView,
draftQueries,
submitToken: Date.now(),
}
}
case 'SET_VIEW_TYPE': {
@ -170,19 +171,9 @@ export const timeMachineReducer = (
}
case 'SUBMIT_SCRIPT': {
const {view, draftQueries} = state
return {
...state,
submitToken: Date.now(),
view: {
...view,
properties: {
...view.properties,
queries: draftQueries,
},
},
}
return produce(state, draftState => {
submitQueries(draftState)
})
}
case 'SET_IS_VIEWING_RAW_DATA': {
@ -342,20 +333,6 @@ export const timeMachineReducer = (
return setViewProperties(state, {staticLegend})
}
case 'SET_QUERY_SOURCE': {
const {sourceID} = action.payload
const {activeQueryIndex} = state
const draftQueries = [...state.draftQueries]
draftQueries[activeQueryIndex] = {
...draftQueries[activeQueryIndex],
sourceID,
}
return {...state, draftQueries}
}
case 'INCREMENT_SUBMIT_TOKEN': {
return {
...state,
@ -408,59 +385,186 @@ export const timeMachineReducer = (
}
case 'SET_ACTIVE_QUERY_INDEX': {
const {activeQueryIndex} = action.payload
return produce(state, draftState => {
const {activeQueryIndex} = action.payload
return {...state, activeQueryIndex}
draftState.activeQueryIndex = activeQueryIndex
resetBuilderState(draftState)
})
}
case 'ADD_QUERY': {
const draftQueries = [...state.draftQueries, defaultViewQuery()]
const activeQueryIndex: number = draftQueries.length - 1
return produce(state, draftState => {
draftState.draftQueries = [...state.draftQueries, defaultViewQuery()]
draftState.activeQueryIndex = draftState.draftQueries.length - 1
return {...state, activeQueryIndex, draftQueries}
resetBuilderState(draftState)
})
}
case 'REMOVE_QUERY': {
const {queryIndex} = action.payload
const draftQueries = state.draftQueries.filter(
(__, i) => i !== queryIndex
)
const queryLength = draftQueries.length
return produce(state, draftState => {
const {queryIndex} = action.payload
let activeQueryIndex: number
draftState.draftQueries.splice(queryIndex, 1)
if (queryIndex < queryLength) {
activeQueryIndex = queryIndex
} else if (queryLength === queryIndex && queryLength > 0) {
activeQueryIndex = queryLength - 1
} else {
activeQueryIndex = 0
}
const queryLength = draftState.draftQueries.length
return {...state, activeQueryIndex, draftQueries}
let activeQueryIndex: number
if (queryIndex < queryLength) {
activeQueryIndex = queryIndex
} else if (queryLength === queryIndex && queryLength > 0) {
activeQueryIndex = queryLength - 1
} else {
activeQueryIndex = 0
}
draftState.activeQueryIndex = activeQueryIndex
resetBuilderState(draftState)
submitQueries(draftState)
})
}
case 'BUILD_QUERY': {
const {builderConfig} = action.payload
const {activeQueryIndex, timeRange} = state
const draftQueries = [...state.draftQueries]
case 'SET_BUILDER_BUCKET_SELECTION': {
return produce(state, draftState => {
const draftQuery = draftState.draftQueries[draftState.activeQueryIndex]
let text: string
if (!isConfigValid(builderConfig)) {
text = ''
} else {
text = buildQuery(builderConfig, timeRange.duration)
}
draftQueries[activeQueryIndex] = {
...draftQueries[activeQueryIndex],
text,
builderConfig,
}
return {...state, draftQueries}
draftQuery.builderConfig.buckets = [action.payload.bucket]
})
}
case 'SET_BUILDER_BUCKETS': {
return produce(state, draftState => {
draftState.queryBuilder.buckets = action.payload.buckets
draftState.queryBuilder.bucketsStatus = RemoteDataState.Done
})
}
case 'SET_BUILDER_BUCKETS_STATUS': {
return produce(state, draftState => {
draftState.queryBuilder.bucketsStatus = action.payload.bucketsStatus
})
}
case 'SET_BUILDER_TAG_KEYS': {
return produce(state, draftState => {
const {index, keys} = action.payload
draftState.queryBuilder.tags[index].keys = keys
draftState.queryBuilder.tags[index].keysStatus = RemoteDataState.Done
})
}
case 'SET_BUILDER_TAG_KEYS_STATUS': {
return produce(state, draftState => {
const {index, status} = action.payload
const tags = draftState.queryBuilder.tags
tags[index].keysStatus = status
if (status === RemoteDataState.Loading) {
for (let i = index + 1; i < tags.length; i++) {
tags[i].keysStatus = RemoteDataState.NotStarted
}
}
})
}
case 'SET_BUILDER_TAG_VALUES': {
return produce(state, draftState => {
const {index, values} = action.payload
draftState.queryBuilder.tags[index].values = values
draftState.queryBuilder.tags[index].valuesStatus = RemoteDataState.Done
})
}
case 'SET_BUILDER_TAG_VALUES_STATUS': {
return produce(state, draftState => {
const {index, status} = action.payload
draftState.queryBuilder.tags[index].valuesStatus = status
})
}
case 'SET_BUILDER_TAG_KEY_SELECTION': {
return produce(state, draftState => {
const {index, key} = action.payload
const draftQuery = draftState.draftQueries[draftState.activeQueryIndex]
const tag = draftQuery.builderConfig.tags[index]
tag.key = key
tag.values = []
})
}
case 'SET_BUILDER_TAG_VALUES_SELECTION': {
return produce(state, draftState => {
const {index, values} = action.payload
const draftQuery = draftState.draftQueries[draftState.activeQueryIndex]
draftQuery.builderConfig.tags[index].values = values
buildAndSubmitActiveQuery(draftState)
})
}
case 'ADD_TAG_SELECTOR': {
return produce(state, draftState => {
const draftQuery = draftState.draftQueries[draftState.activeQueryIndex]
draftQuery.builderConfig.tags.push({key: '', values: []})
draftState.queryBuilder.tags.push({
keys: [],
keysStatus: RemoteDataState.NotStarted,
values: [],
valuesStatus: RemoteDataState.NotStarted,
})
})
}
case 'REMOVE_TAG_SELECTOR': {
return produce(state, draftState => {
const {index} = action.payload
const draftQuery = draftState.draftQueries[draftState.activeQueryIndex]
const selectedValues = draftQuery.builderConfig.tags[index].values
draftQuery.builderConfig.tags.splice(index, 1)
draftState.queryBuilder.tags.splice(index, 1)
if (selectedValues.length) {
buildAndSubmitActiveQuery(draftState)
}
})
}
case 'SELECT_BUILDER_FUNCTION': {
return produce(state, draftState => {
const {name} = action.payload
const functions =
draftState.draftQueries[draftState.activeQueryIndex].builderConfig
.functions
let newFunctions
if (functions.find(f => f.name === name)) {
newFunctions = functions.filter(f => f.name !== name)
} else {
newFunctions = [...functions, {name}]
}
draftState.draftQueries[
draftState.activeQueryIndex
].builderConfig.functions = newFunctions
buildAndSubmitActiveQuery(draftState)
})
}
case 'UPDATE_ACTIVE_QUERY_NAME': {
const {activeQueryIndex} = state
const {queryName} = action.payload
@ -484,6 +588,7 @@ export const timeMachineReducer = (
return {...state, view}
}
case 'SET_TABLE_OPTIONS': {
const workingView = state.view as ExtractWorkingView<
typeof action.payload
@ -549,3 +654,62 @@ const convertView = (
return newView
}
const initialQueryBuilderState = (
builderConfig: BuilderConfig
): QueryBuilderState => {
return {
buckets: builderConfig.buckets,
bucketsStatus: RemoteDataState.NotStarted,
tags: builderConfig.tags.map(_ => ({
keys: [],
keysStatus: RemoteDataState.NotStarted,
values: [],
valuesStatus: RemoteDataState.NotStarted,
})),
}
}
const buildAndSubmitActiveQuery = (draftState: TimeMachineState) => {
const draftQuery = draftState.draftQueries[draftState.activeQueryIndex]
if (isConfigValid(draftQuery.builderConfig)) {
draftQuery.text = buildQuery(
draftQuery.builderConfig,
draftState.timeRange.duration
)
} else {
draftQuery.text = ''
}
submitQueries(draftState)
}
const buildAndSubmitAllQueries = (draftState: TimeMachineState) => {
draftState.draftQueries
.filter(query => query.editMode === QueryEditMode.Builder)
.forEach(query => {
if (isConfigValid(query.builderConfig)) {
query.text = buildQuery(
query.builderConfig,
draftState.timeRange.duration
)
} else {
query.text = ''
}
})
submitQueries(draftState)
}
const resetBuilderState = (draftState: TimeMachineState) => {
const newBuilderConfig =
draftState.draftQueries[draftState.activeQueryIndex].builderConfig
draftState.queryBuilder = initialQueryBuilderState(newBuilderConfig)
}
const submitQueries = (draftState: TimeMachineState) => {
draftState.submitToken = Date.now()
draftState.view.properties.queries = draftState.draftQueries
}

View File

@ -1,4 +1,6 @@
import {AppState, DashboardQuery} from 'src/types/v2'
import {AppState, DashboardQuery, Source} from 'src/types/v2'
import {getSources} from 'src/sources/selectors'
export const getActiveTimeMachine = (state: AppState) => {
const {activeTimeMachineID, timeMachines} = state.timeMachines
@ -12,3 +14,9 @@ export const getActiveQuery = (state: AppState): DashboardQuery => {
return draftQueries[activeQueryIndex]
}
export const getActiveQuerySource = (state: AppState): Source => {
// We only support the self source for now, but in the future the active
// query may be using some other source or the “dynamic source”
return getSources(state).find(s => s.type === Source.TypeEnum.Self)
}

View File

@ -3,11 +3,10 @@ import {buildQuery} from 'src/shared/utils/queryBuilder'
import {BuilderConfig} from 'src/types/v2'
describe('buildQuery', () => {
test('single bucket, single measurement', () => {
test('single tag', () => {
const config: BuilderConfig = {
buckets: ['b0'],
measurements: ['m0'],
fields: [],
tags: [{key: '_measurement', values: ['m0']}],
functions: [],
}
@ -20,11 +19,13 @@ describe('buildQuery', () => {
expect(actual).toEqual(expected)
})
test('single bucket, multiple measurements, multiple fields', () => {
test('multiple tags', () => {
const config: BuilderConfig = {
buckets: ['b0'],
measurements: ['m0', 'm1'],
fields: ['f0', 'f1'],
tags: [
{key: '_measurement', values: ['m0', 'm1']},
{key: '_field', values: ['f0', 'f1']},
],
functions: [],
}
@ -38,12 +39,11 @@ describe('buildQuery', () => {
expect(actual).toEqual(expected)
})
test('multiple buckets, single measurement, multiple functions', () => {
test('single tag, multiple functions', () => {
const config: BuilderConfig = {
buckets: ['b0', 'b1'],
measurements: ['m0'],
fields: [],
functions: ['mean', 'median'],
buckets: ['b0'],
tags: [{key: '_measurement', values: ['m0']}],
functions: [{name: 'mean'}, {name: 'median'}],
}
const expected = `from(bucket: "b0")
@ -55,23 +55,6 @@ describe('buildQuery', () => {
|> yield(name: "mean")
from(bucket: "b0")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "m0")
|> window(every: 1m)
|> toFloat()
|> median()
|> group(columns: ["_value", "_time", "_start", "_stop"], mode: "except")
|> yield(name: "median")
from(bucket: "b1")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "m0")
|> window(every: 1m)
|> mean()
|> group(columns: ["_value", "_time", "_start", "_stop"], mode: "except")
|> yield(name: "mean")
from(bucket: "b1")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "m0")
|> window(every: 1m)

View File

@ -28,8 +28,11 @@ export const timeRangeVariables = (
}
export function isConfigValid(builderConfig: BuilderConfig): boolean {
const {buckets, measurements} = builderConfig
const isConfigValid = buckets.length >= 1 && measurements.length >= 1
const {buckets, tags} = builderConfig
const isConfigValid =
buckets.length >= 1 &&
tags.length >= 1 &&
tags.some(({key, values}) => key && values.length > 0)
return isConfigValid
}
@ -38,64 +41,72 @@ export function buildQuery(
builderConfig: BuilderConfig,
duration: string = '1h'
): string {
const {buckets, measurements, fields, functions} = builderConfig
const {functions} = builderConfig
let bucketFunctionPairs: Array<[string, string]>
let query: string
if (functions.length) {
bucketFunctionPairs = [].concat(
...buckets.map(b => functions.map(f => [b, f]))
)
query = functions
.map(f => buildQueryHelper(builderConfig, duration, f))
.join('\n\n')
} else {
bucketFunctionPairs = buckets.map(b => [b, null] as [string, string])
query = buildQueryHelper(builderConfig, duration)
}
const query = bucketFunctionPairs
.map(([b, f]) => buildQueryHelper(b, measurements, fields, f, duration))
.join('\n\n')
return query
}
function buildQueryHelper(
bucket: string,
measurements: string[],
fields: string[],
functionName: string,
duration: string
builderConfig: BuilderConfig,
duration: string,
fn?: BuilderConfig['functions'][0]
): string {
let query = `from(bucket: "${bucket}")
|> range(start: -${duration})`
const [bucket] = builderConfig.buckets
const tagFilterCall = formatTagFilterCall(builderConfig.tags)
const fnCall = fn ? formatFunctionCall(fn, duration) : ''
if (measurements.length) {
const measurementsPredicate = measurements
.map(m => `r._measurement == "${m}"`)
.join(' or ')
query += `
|> filter(fn: (r) => ${measurementsPredicate})`
}
if (fields.length) {
const fieldsPredicate = fields.map(f => `r._field == "${f}"`).join(' or ')
query += `
|> filter(fn: (r) => ${fieldsPredicate})`
}
const fn = FUNCTIONS.find(f => f.name === functionName)
if (fn && fn.aggregate) {
query += `
|> window(every: ${WINDOW_INTERVALS[duration] || DEFAULT_WINDOW_INTERVAL})
${fn.flux}
|> group(columns: ["_value", "_time", "_start", "_stop"], mode: "except")
|> yield(name: "${fn.name}")`
} else if (fn) {
query += `
${fn.flux}
|> yield(name: "${fn.name}")`
}
const query = `from(bucket: "${bucket}")
|> range(start: -${duration})${tagFilterCall}${fnCall}`
return query
}
export function formatFunctionCall(
fn: BuilderConfig['functions'][0],
duration: string
) {
const fnSpec = FUNCTIONS.find(f => f.name === fn.name)
let fnCall: string = ''
if (fnSpec && fnSpec.aggregate) {
fnCall = `
|> window(every: ${WINDOW_INTERVALS[duration] || DEFAULT_WINDOW_INTERVAL})
${fnSpec.flux}
|> group(columns: ["_value", "_time", "_start", "_stop"], mode: "except")
|> yield(name: "${fn.name}")`
} else {
fnCall = `
${fnSpec.flux}
|> yield(name: "${fn.name}")`
}
return fnCall
}
export function formatTagFilterCall(tagsSelections: BuilderConfig['tags']) {
if (!tagsSelections.length) {
return ''
}
const calls = tagsSelections
.filter(({key, values}) => key && values.length)
.map(({key, values}) => {
const fnBody = values.map(value => `r.${key} == "${value}"`).join(' or ')
return `|> filter(fn: (r) => ${fnBody})`
})
.join('\n ')
return `\n ${calls}`
}

View File

@ -0,0 +1,18 @@
import {ComponentStatus} from 'src/clockface'
import {RemoteDataState} from 'src/types'
export const toComponentStatus = (status: RemoteDataState): ComponentStatus => {
if (status === RemoteDataState.NotStarted) {
return ComponentStatus.Disabled
}
if (status === RemoteDataState.Loading) {
return ComponentStatus.Loading
}
if (status === RemoteDataState.Error) {
return ComponentStatus.Error
}
return ComponentStatus.Default
}

View File

@ -33,8 +33,7 @@ export function defaultViewQuery(): DashboardQuery {
editMode: QueryEditMode.Builder,
builderConfig: {
buckets: [],
measurements: [],
fields: [],
tags: [{key: '_measurement', values: []}],
functions: [],
},
}

View File

@ -48,9 +48,8 @@ export enum QueryEditMode {
export interface BuilderConfig {
buckets: string[]
measurements: string[]
fields: string[]
functions: string[]
tags: Array<{key: string; values: string[]}>
functions: Array<{name: string}>
}
export interface DashboardQuery {

View File

@ -23,6 +23,7 @@
],
"no-empty": false,
"jsx-no-lambda": false,
"max-classes-per-file": false,
"no-empty-interface": false,
"no-shadowed-variable": false,
"prefer-for-of": false,

12
view.go
View File

@ -428,10 +428,14 @@ type DashboardQuery struct {
}
type BuilderConfig struct {
Buckets []string `json:"buckets"`
Measurements []string `json:"measurements"`
Fields []string `json:"fields"`
Functions []string `json:"functions"`
Buckets []string `json:"buckets"`
Tags []struct {
Key string `json:"key"`
Values []string `json:"values"`
} `json:"tags"`
Functions []struct {
Name string `json:"name"`
} `json:"functions"`
}
// Axis represents the visible extents of a visualization