From d13c0bdeca10c0c262bae004b4965d7dbc0b11a6 Mon Sep 17 00:00:00 2001 From: Bucky Schwarz Date: Wed, 27 Nov 2019 13:00:41 -0800 Subject: [PATCH] feat: Add ability to group in Query Builder --- CHANGELOG.md | 12 +- ui/cypress/e2e/explorer.test.ts | 17 ++- ui/src/shared/utils/featureFlag.ts | 2 + ui/src/timeMachine/actions/queryBuilder.ts | 17 ++- ui/src/timeMachine/components/TagSelector.tsx | 74 +++++++++--- .../components/builderCard/BuilderCard.tsx | 2 + .../builderCard/BuilderCardDropdownHeader.tsx | 47 ++++++++ ui/src/timeMachine/reducers/index.ts | 16 ++- ui/src/timeMachine/selectors/index.test.ts | 114 +++++++++++++++++- ui/src/timeMachine/selectors/index.ts | 34 +++++- ui/src/timeMachine/utils/queryBuilder.ts | 71 ++++++++++- ui/src/types/dashboards.ts | 2 + 12 files changed, 366 insertions(+), 42 deletions(-) create mode 100644 ui/src/timeMachine/components/builderCard/BuilderCardDropdownHeader.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9772f3c95be..92f3efd3f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ ### Features -1. [16234](https://github.com/influxdata/influxdb/pull/16234): add support for notification endpoints to influx templates/pkgs. -2. [16242](https://github.com/influxdata/influxdb/pull/16242): drop id prefix for secret key requirement for notification endpoints -3. [16259](https://github.com/influxdata/influxdb/pull/16259): add support for check resource to pkger parser -4. [16262](https://github.com/influxdata/influxdb/pull/16262): add support for check resource dry run functionality -5. [16275](https://github.com/influxdata/influxdb/pull/16275): add support for check resource apply functionality -6. [16283](https://github.com/influxdata/influxdb/pull/16283): add support for check resource export functionality +1. [16234](https://github.com/influxdata/influxdb/pull/16234): Add support for notification endpoints to influx templates/pkgs. +1. [16242](https://github.com/influxdata/influxdb/pull/16242): Drop id prefix for secret key requirement for notification endpoints +1. [16259](https://github.com/influxdata/influxdb/pull/16259): Add support for check resource to pkger parser +1. [16262](https://github.com/influxdata/influxdb/pull/16262): Add support for check resource dry run functionality +1. [16275](https://github.com/influxdata/influxdb/pull/16275): Add support for check resource apply functionality 1. [16212](https://github.com/influxdata/influxdb/pull/16212): Add new kv.ForwardCursor interface +1. [16226](https://github.com/influxdata/influxdb/pull/16226): Add group() to Query Builder ### Bug Fixes diff --git a/ui/cypress/e2e/explorer.test.ts b/ui/cypress/e2e/explorer.test.ts index 5bd58e880c4..a5107296aba 100644 --- a/ui/cypress/e2e/explorer.test.ts +++ b/ui/cypress/e2e/explorer.test.ts @@ -448,7 +448,13 @@ describe('DataExplorer', () => { }) it('shows the empty state when the query returns no results', () => { - cy.getByTestID('time-machine--bottom').within(() => { + cy.get<$CM>('.CodeMirror').then($cm => { + const cm = $cm[0].CodeMirror + cy.wrap(cm.doc).as('flux') + expect(cm.doc.getValue()).to.eq('') + }) + + cy.get('@flux').then(() => { cy.get('textarea').type( `from(bucket: "defbuck") |> range(start: -10s) @@ -464,7 +470,14 @@ describe('DataExplorer', () => { it('can save query as task even when it has a variable', () => { const taskName = 'tax' // begin flux - cy.getByTestID('flux-editor').within(() => { + + cy.get<$CM>('.CodeMirror').then($cm => { + const cm = $cm[0].CodeMirror + cy.wrap(cm.doc).as('flux') + expect(cm.doc.getValue()).to.eq('') + }) + + cy.get('@flux').then(() => { cy.get('textarea').type( `from(bucket: "defbuck") |> range(start: -15m, stop: now()) diff --git a/ui/src/shared/utils/featureFlag.ts b/ui/src/shared/utils/featureFlag.ts index b133d31e87b..6cfc4321e61 100644 --- a/ui/src/shared/utils/featureFlag.ts +++ b/ui/src/shared/utils/featureFlag.ts @@ -7,6 +7,7 @@ export const OSS_FLAGS = { monacoEditor: false, downloadCellCSV: false, telegrafEditor: false, + queryBuilderGrouping: false, } export const CLOUD_FLAGS = { @@ -16,6 +17,7 @@ export const CLOUD_FLAGS = { cloudBilling: CLOUD_BILLING_VISIBLE, // should be visible in dev and acceptance, but not in cloud downloadCellCSV: false, telegrafEditor: false, + queryBuilderGrouping: false, } export const isFlagEnabled = (flagName: string, equals?: string | boolean) => { diff --git a/ui/src/timeMachine/actions/queryBuilder.ts b/ui/src/timeMachine/actions/queryBuilder.ts index 40b7e46734a..041aa3be334 100644 --- a/ui/src/timeMachine/actions/queryBuilder.ts +++ b/ui/src/timeMachine/actions/queryBuilder.ts @@ -10,8 +10,11 @@ import { // Types import {Dispatch} from 'redux-thunk' -import {GetState, RemoteDataState} from 'src/types' -import {BuilderAggregateFunctionType} from 'src/client/generatedRoutes' +import { + BuilderAggregateFunctionType, + GetState, + RemoteDataState, +} from 'src/types' import {BuilderFunctionsType} from '@influxdata/influx' export type Action = @@ -285,7 +288,8 @@ export const selectTagValue = (index: number, value: string) => ( timeMachines: {activeTimeMachineID}, } = state const tags = getActiveQuery(state).builderConfig.tags - const values = tags[index].values + const currentTag = tags[index] + const values = currentTag.values let newValues: string[] @@ -293,7 +297,7 @@ export const selectTagValue = (index: number, value: string) => ( newValues = values.filter(v => v !== value) } else if ( activeTimeMachineID === 'alerting' && - tags[index].key === '_field' + currentTag.key === '_field' ) { newValues = [value] } else { @@ -302,6 +306,11 @@ export const selectTagValue = (index: number, value: string) => ( dispatch(setBuilderTagValuesSelection(index, newValues)) + // don't add a new tag filter if we're grouping + if (currentTag.aggregateFunctionType === 'group') { + return + } + if (index === tags.length - 1 && newValues.length) { dispatch(addTagSelector()) } else { diff --git a/ui/src/timeMachine/components/TagSelector.tsx b/ui/src/timeMachine/components/TagSelector.tsx index 4ee03f1b476..57073b71f5d 100644 --- a/ui/src/timeMachine/components/TagSelector.tsx +++ b/ui/src/timeMachine/components/TagSelector.tsx @@ -31,17 +31,22 @@ import { } from 'src/timeMachine/actions/queryBuilder' // Utils -import {toComponentStatus} from 'src/shared/utils/toComponentStatus' import DefaultDebouncer from 'src/shared/utils/debouncer' +import {isFlagEnabled} from 'src/shared/utils/featureFlag' +import {toComponentStatus} from 'src/shared/utils/toComponentStatus' import { getActiveQuery, + getActiveTagValues, getActiveTimeMachine, getIsInCheckOverlay, } from 'src/timeMachine/selectors' // Types -import {AppState, RemoteDataState} from 'src/types' -import {BuilderAggregateFunctionType} from 'src/client' +import { + AppState, + BuilderAggregateFunctionType, + RemoteDataState, +} from 'src/types' const SEARCH_DEBOUNCE_MS = 500 @@ -80,8 +85,6 @@ type Props = StateProps & DispatchProps & OwnProps class TagSelector extends PureComponent { private debouncer = new DefaultDebouncer() - // bucky: this will currently always be 'Filter' - // updates to this are imminent private renderAggregateFunctionType( aggregateFunctionType: BuilderAggregateFunctionType ) { @@ -92,19 +95,32 @@ class TagSelector extends PureComponent { } public render() { - const {aggregateFunctionType, index} = this.props - return ( - + {this.header} {this.body} ) } + private get header() { + const {aggregateFunctionType, index} = this.props + + return isFlagEnabled('queryBuilderGrouping') ? ( + + ) : ( + + ) + } + private get body() { const { index, @@ -265,33 +281,51 @@ class TagSelector extends PureComponent { onSearchValues(index) } + + private handleAggregateFunctionSelect = ( + option: BuilderAggregateFunctionType + ) => { + const {index, onSetBuilderAggregateFunctionType} = this.props + onSetBuilderAggregateFunctionType(option, index) + } } const mstp = (state: AppState, ownProps: OwnProps): StateProps => { + const activeQueryBuilder = getActiveTimeMachine(state).queryBuilder + const { keys, keysSearchTerm, keysStatus, - values, valuesSearchTerm, valuesStatus, - } = getActiveTimeMachine(state).queryBuilder.tags[ownProps.index] + } = activeQueryBuilder.tags[ownProps.index] const tags = getActiveQuery(state).builderConfig.tags - const { - key: selectedKey, - values: selectedValues, - aggregateFunctionType, - } = tags[ownProps.index] let emptyText: string - - if (ownProps.index === 0 || !tags[ownProps.index - 1].key) { + const previousTagSelector = tags[ownProps.index - 1] + if ( + ownProps.index === 0 || + !previousTagSelector || + !previousTagSelector.key + ) { emptyText = '' } else { emptyText = `Select a ${tags[ownProps.index - 1].key} value first` } + const { + key: selectedKey, + values: selectedValues, + aggregateFunctionType, + } = tags[ownProps.index] + + const values = getActiveTagValues( + activeQueryBuilder.tags, + aggregateFunctionType, + ownProps.index + ) const isInCheckOverlay = getIsInCheckOverlay(state) return { diff --git a/ui/src/timeMachine/components/builderCard/BuilderCard.tsx b/ui/src/timeMachine/components/builderCard/BuilderCard.tsx index a60bc245593..39f8aa1b72d 100644 --- a/ui/src/timeMachine/components/builderCard/BuilderCard.tsx +++ b/ui/src/timeMachine/components/builderCard/BuilderCard.tsx @@ -4,6 +4,7 @@ import classnames from 'classnames' // Components import BuilderCardHeader from 'src/timeMachine/components/builderCard/BuilderCardHeader' +import BuilderCardDropdownHeader from 'src/timeMachine/components/builderCard/BuilderCardDropdownHeader' import BuilderCardMenu from 'src/timeMachine/components/builderCard/BuilderCardMenu' import BuilderCardBody from 'src/timeMachine/components/builderCard/BuilderCardBody' import BuilderCardEmpty from 'src/timeMachine/components/builderCard/BuilderCardEmpty' @@ -16,6 +17,7 @@ interface Props { export default class BuilderCard extends PureComponent { public static Header = BuilderCardHeader + public static DropdownHeader = BuilderCardDropdownHeader public static Menu = BuilderCardMenu public static Body = BuilderCardBody public static Empty = BuilderCardEmpty diff --git a/ui/src/timeMachine/components/builderCard/BuilderCardDropdownHeader.tsx b/ui/src/timeMachine/components/builderCard/BuilderCardDropdownHeader.tsx new file mode 100644 index 00000000000..a1e2f178e91 --- /dev/null +++ b/ui/src/timeMachine/components/builderCard/BuilderCardDropdownHeader.tsx @@ -0,0 +1,47 @@ +// Libraries +import React, {PureComponent} from 'react' + +import {SelectDropdown} from '@influxdata/clockface' +import {BuilderAggregateFunctionType} from 'src/types' + +interface Props { + options: string[] + selectedOption: string + testID: string + onSelect?: (option: BuilderAggregateFunctionType) => void + onDelete?: () => void +} + +const emptyFunction = () => {} + +export default class BuilderCardDropdownHeader extends PureComponent { + public static defaultProps = { + testID: 'builder-card--header', + } + + public render() { + const {children, options, onSelect, selectedOption, testID} = this.props + + return ( +
+ + + {children} + {this.deleteButton} +
+ ) + } + + private get deleteButton(): JSX.Element | undefined { + const {onDelete} = this.props + + if (onDelete) { + return
+ } + } +} diff --git a/ui/src/timeMachine/reducers/index.ts b/ui/src/timeMachine/reducers/index.ts index 60830b27c31..db26a7b7f57 100644 --- a/ui/src/timeMachine/reducers/index.ts +++ b/ui/src/timeMachine/reducers/index.ts @@ -671,11 +671,16 @@ export const timeMachineReducer = ( const {index, builderAggregateFunctionType} = action.payload const draftQuery = draftState.draftQueries[draftState.activeQueryIndex] + buildActiveQuery(draftState) if ( draftQuery && draftQuery.builderConfig && draftQuery.builderConfig.tags[index] ) { + // When switching between filtering and grouping + // we want to clear out any previously selected values + draftQuery.builderConfig.tags[index].values = [] + draftQuery.builderConfig.tags[ index ].aggregateFunctionType = builderAggregateFunctionType @@ -761,6 +766,8 @@ export const timeMachineReducer = ( draftState.queryBuilder.tags[index].values = values draftState.queryBuilder.tags[index].valuesStatus = RemoteDataState.Done + + buildActiveQuery(draftState) }) } @@ -829,10 +836,13 @@ export const timeMachineReducer = ( const {index} = action.payload const draftQuery = draftState.draftQueries[draftState.activeQueryIndex] - const selectedValues = draftQuery.builderConfig.tags[index].values + let selectedValues = [] + if (draftQuery) { + selectedValues = draftQuery.builderConfig.tags[index].values - draftQuery.builderConfig.tags.splice(index, 1) - draftState.queryBuilder.tags.splice(index, 1) + draftQuery.builderConfig.tags.splice(index, 1) + draftState.queryBuilder.tags.splice(index, 1) + } if (selectedValues.length) { buildActiveQuery(draftState) diff --git a/ui/src/timeMachine/selectors/index.test.ts b/ui/src/timeMachine/selectors/index.test.ts index da99d1f5e3e..fcecc61dcf4 100644 --- a/ui/src/timeMachine/selectors/index.test.ts +++ b/ui/src/timeMachine/selectors/index.test.ts @@ -1,5 +1,13 @@ // Funcs -import {getStartTime, getEndTime} from 'src/timeMachine/selectors/index' +import {isFlagEnabled} from 'src/shared/utils/featureFlag' +import {mocked} from 'ts-jest/utils' +jest.mock('src/shared/utils/featureFlag') + +import { + getActiveTagValues, + getStartTime, + getEndTime, +} from 'src/timeMachine/selectors/index' import moment from 'moment' import { @@ -59,3 +67,107 @@ describe('TimeMachine.Selectors.Index', () => { expect(getEndTime(pastThirtyDaysTimeRange)).toBeGreaterThanOrEqual(now) }) }) + +describe('getting active tag values', () => { + const activeQueryTags = [ + { + keys: [ + '_field', + '_measurement', + 'cpu', + 'device', + 'fstype', + 'host', + 'interface', + 'mode', + 'name', + 'path', + ], + values: [ + 'cpu', + 'disk', + 'diskio', + 'mem', + 'net', + 'processes', + 'swap', + 'system', + ], + }, + { + keys: ['_field', 'host'], + values: [ + 'active', + 'available', + 'available_percent', + 'buffered', + 'cached', + 'commit_limit', + 'committed_as', + 'dirty', + 'free', + 'high_free', + 'high_total', + 'huge_page_size', + 'huge_pages_free', + 'huge_pages_total', + 'inactive', + 'low_free', + 'low_total', + 'mapped', + 'page_tables', + 'shared', + 'slab', + 'swap_cached', + 'swap_free', + 'swap_total', + 'total', + 'used', + 'used_percent', + 'vmalloc_chunk', + 'vmalloc_total', + 'vmalloc_used', + 'wired', + 'write_back', + 'write_back_tmp', + ], + }, + { + keys: ['host'], + values: ['foo_computer'], + }, + ] + beforeEach(() => { + mocked(isFlagEnabled).mockReset() + }) + + it("returns the active query tag values when the isFlagEnabled('queryBuilderGrouping') is toggled off", () => { + mocked(isFlagEnabled).mockImplementation(() => { + return false + }) + + const actualTags = getActiveTagValues(activeQueryTags, 'filter', 2) + expect(actualTags).toEqual(activeQueryTags[2].values) + }) + + it("returns the active query tag values when the isFlagEnabled('queryBuilderGrouping') is toggled on, but the function is filter", () => { + mocked(isFlagEnabled).mockImplementation(() => { + return true + }) + + const actualTags = getActiveTagValues(activeQueryTags, 'filter', 2) + expect(actualTags).toEqual(activeQueryTags[2].values) + }) + + it("returns all previous tag values when the isFlagEnabled('queryBuilderGrouping') is toggled on and the function is group", () => { + mocked(isFlagEnabled).mockImplementation(() => { + return true + }) + + const actualTags = getActiveTagValues(activeQueryTags, 'group', 2) + expect(actualTags).toEqual([ + ...activeQueryTags[0].values, + ...activeQueryTags[1].values, + ]) + }) +}) diff --git a/ui/src/timeMachine/selectors/index.ts b/ui/src/timeMachine/selectors/index.ts index bb8a21ff5e7..962dcc19403 100644 --- a/ui/src/timeMachine/selectors/index.ts +++ b/ui/src/timeMachine/selectors/index.ts @@ -22,8 +22,12 @@ import { durationToMilliseconds, } from 'src/shared/utils/duration' +import {isFlagEnabled} from 'src/shared/utils/featureFlag' + // Types import { + BuilderAggregateFunctionType, + BuilderTagsType, DashboardQuery, FluxTable, QueryView, @@ -82,8 +86,8 @@ export const getActiveWindowPeriod = (state: AppState) => { return getWindowPeriod(text, variables) } -const getTablesMemoized = memoizeOne( - (files: string[]): FluxTable[] => (files ? flatMap(files, parseResponse) : []) +const getTablesMemoized = memoizeOne((files: string[]): FluxTable[] => + files ? flatMap(files, parseResponse) : [] ) export const getTables = (state: AppState): FluxTable[] => @@ -309,3 +313,29 @@ export const getActiveTimeRange = ( } return null } + +export const getActiveTagValues = ( + activeQueryBuilderTags: BuilderTagsType[], + aggregateFunctionType: BuilderAggregateFunctionType, + index: number +): string[] => { + // if we're grouping, we want to be able to group on all previous tags + if ( + isFlagEnabled('queryBuilderGrouping') && + aggregateFunctionType === 'group' + ) { + const values = [] + activeQueryBuilderTags.forEach((tag, i) => { + // if we don't skip the current set of tags, we'll double render them at the bottom of the selector list + if (i === index) { + return + } + tag.values.forEach(value => { + values.push(value) + }) + }) + return values + } + + return activeQueryBuilderTags[index].values +} diff --git a/ui/src/timeMachine/utils/queryBuilder.ts b/ui/src/timeMachine/utils/queryBuilder.ts index e9569bd3123..ebfb2995fad 100644 --- a/ui/src/timeMachine/utils/queryBuilder.ts +++ b/ui/src/timeMachine/utils/queryBuilder.ts @@ -1,5 +1,10 @@ import {get, isEmpty} from 'lodash' -import {BuilderConfig, DashboardDraftQuery, Check} from 'src/types' +import { + BuilderConfig, + BuilderTagsType, + DashboardDraftQuery, + Check, +} from 'src/types' import {FUNCTIONS} from 'src/timeMachine/constants/queryBuilder' import { TIME_RANGE_START, @@ -8,7 +13,7 @@ import { WINDOW_PERIOD, } from 'src/variables/constants' import {AGG_WINDOW_AUTO} from 'src/timeMachine/constants/queryBuilder' -import {BuilderTagsType} from '@influxdata/influx' +import {isFlagEnabled} from 'src/shared/utils/featureFlag' export function isConfigValid(builderConfig: BuilderConfig): boolean { const {buckets, tags} = builderConfig @@ -85,11 +90,14 @@ export function buildQuery(builderConfig: BuilderConfig): string { const {functions} = builderConfig let query: string + const helper = isFlagEnabled('queryBuilderGrouping') + ? buildQueryHelperButWithGrouping + : buildQueryHelper if (functions.length) { - query = functions.map(f => buildQueryHelper(builderConfig, f)).join('\n\n') + query = functions.map(f => helper(builderConfig, f)).join('\n\n') } else { - query = buildQueryHelper(builderConfig) + query = helper(builderConfig, null) } return query @@ -110,6 +118,30 @@ function buildQueryHelper( return query } +function buildQueryHelperButWithGrouping( + builderConfig: BuilderConfig, + fn?: BuilderConfig['functions'][0] +): string { + const [bucket] = builderConfig.buckets + + const tags = Array.from(builderConfig.tags) + + // todo: (bucky) - check to see if we can combine filter calls + // https://github.com/influxdata/influxdb/issues/16076 + let tagsFunctionCalls = '' + tags.forEach(tag => { + tagsFunctionCalls += convertTagsToFluxFunctionString(tag) + }) + + const {aggregateWindow} = builderConfig + const fnCall = fn ? formatFunctionCall(fn, aggregateWindow.period) : '' + + const query = `from(bucket: "${bucket}") + |> range(start: ${OPTION_NAME}.${TIME_RANGE_START}, stop: ${OPTION_NAME}.${TIME_RANGE_STOP})${tagsFunctionCalls}${fnCall}` + + return query +} + export function formatFunctionCall( fn: BuilderConfig['functions'][0], period: string @@ -125,6 +157,37 @@ export function formatFunctionCall( return `\n ${fnSpec.flux(formattedPeriod)}\n |> yield(name: "${fn.name}")` } +const convertTagsToFluxFunctionString = function convertTagsToFluxFunctionString( + tag: BuilderTagsType +) { + if (!tag.key) { + return '' + } + + if (tag.aggregateFunctionType === 'filter') { + if (!tag.values.length) { + return '' + } + + const fnBody = tag.values + .map(value => `r.${tag.key} == "${value}"`) + .join(' or ') + return `\n |> filter(fn: (r) => ${fnBody})` + } + + if (tag.aggregateFunctionType === 'group') { + const quotedValues = tag.values.map(value => `"${value}"`) // wrap the value in double quotes + + if (quotedValues.length) { + return `\n |> group(columns: [${quotedValues.join(', ')}])` // join with a comma (e.g. "foo","bar","baz") + } + + return '\n |> group()' + } + + return '' +} + const formatPeriod = (period: string): string => { if (period === AGG_WINDOW_AUTO || !period) { return `${OPTION_NAME}.${WINDOW_PERIOD}` diff --git a/ui/src/types/dashboards.ts b/ui/src/types/dashboards.ts index 9cf76c133fd..52707a37d54 100644 --- a/ui/src/types/dashboards.ts +++ b/ui/src/types/dashboards.ts @@ -88,6 +88,8 @@ export type TableOptions = TableViewProperties['tableOptions'] export { DashboardQuery, + BuilderAggregateFunctionType, + BuilderTagsType, BuilderConfig, ViewProperties, QueryEditMode,