diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx index b4c6e16cf31..13635a0d36c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/DimensionFields.test.tsx @@ -10,6 +10,7 @@ import createMockPanelData from '../../__mocks__/panelData'; import createMockQuery from '../../__mocks__/query'; import DimensionFields from './DimensionFields'; +import NewDimensionFields from './NewDimensionFields'; import { appendDimensionFilter, setDimensionFilterValue } from './setQueryValue'; const variableOptionGroup = { @@ -18,342 +19,365 @@ const variableOptionGroup = { }; const user = userEvent.setup(); -describe('Azure Monitor QueryEditor', () => { - const mockDatasource = createMockDatasource(); +const tests = [ + { + component: DimensionFields, + label: 'Dimension Fields', + addDimension: async () => { + const addDimension = await screen.findByText('Add new dimension'); + await user.click(addDimension); + }, + }, + { + component: NewDimensionFields, + label: 'Dimension Fields experimental UI', + addDimension: async () => { + const addDimension = await screen.findByLabelText('Add'); + await user.click(addDimension); + }, + }, +]; - it('should render a dimension filter', async () => { - let mockQuery = createMockQuery(); - const mockPanelData = createMockPanelData(); - const onQueryChange = jest.fn(); - const dimensionOptions = [ - { label: 'Test Dimension 1', value: 'TestDimension1' }, - { label: 'Test Dimension 2', value: 'TestDimension2' }, - ]; - const { rerender } = render( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const addDimension = await screen.findByText('Add new dimension'); - await user.click(addDimension); - mockQuery = appendDimensionFilter(mockQuery); - expect(onQueryChange).toHaveBeenCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - dimensionFilters: [{ dimension: '', operator: 'eq', filters: [] }], - }, +for (const t of tests) { + describe(`Azure Monitor QueryEditor: ${t.label}`, () => { + const mockDatasource = createMockDatasource(); + + it('should render a dimension filter', async () => { + let mockQuery = createMockQuery(); + const mockPanelData = createMockPanelData(); + const onQueryChange = jest.fn(); + const dimensionOptions = [ + { label: 'Test Dimension 1', value: 'TestDimension1' }, + { label: 'Test Dimension 2', value: 'TestDimension2' }, + ]; + const { rerender } = render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + + await t.addDimension(); + + mockQuery = appendDimensionFilter(mockQuery); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: '', operator: 'eq', filters: [] }], + }, + }); + rerender( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const dimensionSelect = await screen.findByText('Field'); + await selectOptionInTest(dimensionSelect, 'Test Dimension 1'); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }], + }, + }); + expect(screen.queryByText('Test Dimension 1')).toBeInTheDocument(); + expect(screen.queryByText('==')).toBeInTheDocument(); }); - rerender( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const dimensionSelect = await screen.findByText('Field'); - await selectOptionInTest(dimensionSelect, 'Test Dimension 1'); - expect(onQueryChange).toHaveBeenCalledWith({ - ...mockQuery, - azureMonitor: { + + it('correctly filters out dimensions when selected', async () => { + let mockQuery = createMockQuery(); + const mockPanelData = createMockPanelData(); + mockQuery.azureMonitor = { ...mockQuery.azureMonitor, dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }], - }, + }; + const onQueryChange = jest.fn(); + const dimensionOptions = [ + { label: 'Test Dimension 1', value: 'TestDimension1' }, + { label: 'Test Dimension 2', value: 'TestDimension2' }, + ]; + const { rerender } = render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + + await t.addDimension(); + + mockQuery = appendDimensionFilter(mockQuery); + rerender( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const dimensionSelect = await screen.findByText('Field'); + await user.click(dimensionSelect); + const options = await screen.findAllByLabelText('Select option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Test Dimension 2'); }); - expect(screen.queryByText('Test Dimension 1')).toBeInTheDocument(); - expect(screen.queryByText('==')).toBeInTheDocument(); - }); - it('correctly filters out dimensions when selected', async () => { - let mockQuery = createMockQuery(); - const mockPanelData = createMockPanelData(); - mockQuery.azureMonitor = { - ...mockQuery.azureMonitor, - dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }], - }; - const onQueryChange = jest.fn(); - const dimensionOptions = [ - { label: 'Test Dimension 1', value: 'TestDimension1' }, - { label: 'Test Dimension 2', value: 'TestDimension2' }, - ]; - const { rerender } = render( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const addDimension = await screen.findByText('Add new dimension'); - await user.click(addDimension); - mockQuery = appendDimensionFilter(mockQuery); - rerender( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const dimensionSelect = await screen.findByText('Field'); - await user.click(dimensionSelect); - const options = await screen.findAllByLabelText('Select option'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('Test Dimension 2'); - }); - - it('correctly displays dimension labels', async () => { - let mockQuery = createMockQuery(); - const mockPanelData = createMockPanelData(); - mockQuery.azureMonitor = { - ...mockQuery.azureMonitor, - dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }], - }; - - mockPanelData.series = [ - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel' }, - }, - ], - }, - ]; - const onQueryChange = jest.fn(); - const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; - render( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const labelSelect = await screen.findByText('Select value(s)'); - await user.click(labelSelect); - const options = await screen.findAllByLabelText('Select option'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('testlabel'); - }); - - it('correctly updates dimension labels', async () => { - let mockQuery = createMockQuery(); - const mockPanelData = createMockPanelData(); - mockQuery.azureMonitor = { - ...mockQuery.azureMonitor, - dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel'] }], - }; - - mockPanelData.series = [ - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel' }, - }, - ], - }, - ]; - const onQueryChange = jest.fn(); - const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; - const { rerender } = render( - {}} - dimensionOptions={dimensionOptions} - /> - ); - await screen.findByText('testlabel'); - const labelClear = await screen.findByLabelText('Remove testlabel'); - await user.click(labelClear); - mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', []); - expect(onQueryChange).toHaveBeenCalledWith({ - ...mockQuery, - azureMonitor: { + it('correctly displays dimension labels', async () => { + let mockQuery = createMockQuery(); + const mockPanelData = createMockPanelData(); + mockQuery.azureMonitor = { ...mockQuery.azureMonitor, dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }], - }, - }); - mockPanelData.series = [ - ...mockPanelData.series, - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel2' }, - }, - ], - }, - ]; - rerender( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const labelSelect = await screen.getByLabelText('dimension-labels-select'); - await openMenu(labelSelect); - const options = await screen.findAllByLabelText('Select option'); - expect(options).toHaveLength(2); - expect(options[0]).toHaveTextContent('testlabel'); - expect(options[1]).toHaveTextContent('testlabel2'); - }); + }; - it('correctly selects multiple dimension labels', async () => { - let mockQuery = createMockQuery(); - const mockPanelData = createMockPanelData(); - mockPanelData.series = [ - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel' }, - }, - ], - }, - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel2' }, - }, - ], - }, - ]; - const onQueryChange = jest.fn(); - const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; - mockQuery = appendDimensionFilter(mockQuery, 'TestDimension1'); - const { rerender } = render( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const labelSelect = await screen.getByLabelText('dimension-labels-select'); - await user.click(labelSelect); - await openMenu(labelSelect); - await screen.getByText('testlabel'); - await screen.getByText('testlabel2'); - await selectOptionInTest(labelSelect, 'testlabel'); - mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', ['testlabel']); - expect(onQueryChange).toHaveBeenCalledWith({ - ...mockQuery, - azureMonitor: { + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + ]; + const onQueryChange = jest.fn(); + const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; + render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const labelSelect = await screen.findByText('Select value(s)'); + await user.click(labelSelect); + const options = await screen.findAllByLabelText('Select option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('testlabel'); + }); + + it('correctly updates dimension labels', async () => { + let mockQuery = createMockQuery(); + const mockPanelData = createMockPanelData(); + mockQuery.azureMonitor = { ...mockQuery.azureMonitor, dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel'] }], - }, + }; + + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + ]; + const onQueryChange = jest.fn(); + const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; + const { rerender } = render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + await screen.findByText('testlabel'); + const labelClear = await screen.findByLabelText('Remove testlabel'); + await user.click(labelClear); + mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', []); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: [] }], + }, + }); + mockPanelData.series = [ + ...mockPanelData.series, + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel2' }, + }, + ], + }, + ]; + rerender( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const labelSelect = screen.getByLabelText('dimension-labels-select'); + await openMenu(labelSelect); + const options = await screen.findAllByLabelText('Select option'); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent('testlabel'); + expect(options[1]).toHaveTextContent('testlabel2'); }); - mockPanelData.series = [ - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel' }, - }, - ], - }, - ]; - rerender( - {}} - dimensionOptions={dimensionOptions} - /> - ); - const labelSelect2 = await screen.getByLabelText('dimension-labels-select'); - await openMenu(labelSelect2); - const refreshedOptions = await screen.findAllByLabelText('Select options menu'); - expect(refreshedOptions).toHaveLength(1); - expect(refreshedOptions[0]).toHaveTextContent('testlabel2'); - await selectOptionInTest(labelSelect2, 'testlabel2'); - mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', ['testlabel', 'testlabel2']); - expect(onQueryChange).toHaveBeenCalledWith({ - ...mockQuery, - azureMonitor: { - ...mockQuery.azureMonitor, - dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel', 'testlabel2'] }], - }, + + it('correctly selects multiple dimension labels', async () => { + let mockQuery = createMockQuery(); + const mockPanelData = createMockPanelData(); + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel2' }, + }, + ], + }, + ]; + const onQueryChange = jest.fn(); + const dimensionOptions = [{ label: 'Test Dimension 1', value: 'TestDimension1' }]; + mockQuery = appendDimensionFilter(mockQuery, 'TestDimension1'); + const { rerender } = render( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const labelSelect = screen.getByLabelText('dimension-labels-select'); + await user.click(labelSelect); + await openMenu(labelSelect); + screen.getByText('testlabel'); + screen.getByText('testlabel2'); + await selectOptionInTest(labelSelect, 'testlabel'); + mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', ['testlabel']); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel'] }], + }, + }); + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + ]; + rerender( + {}} + dimensionOptions={dimensionOptions} + /> + ); + const labelSelect2 = screen.getByLabelText('dimension-labels-select'); + await openMenu(labelSelect2); + const refreshedOptions = await screen.findAllByLabelText('Select options menu'); + expect(refreshedOptions).toHaveLength(1); + expect(refreshedOptions[0]).toHaveTextContent('testlabel2'); + await selectOptionInTest(labelSelect2, 'testlabel2'); + mockQuery = setDimensionFilterValue(mockQuery, 0, 'filters', ['testlabel', 'testlabel2']); + expect(onQueryChange).toHaveBeenCalledWith({ + ...mockQuery, + azureMonitor: { + ...mockQuery.azureMonitor, + dimensionFilters: [{ dimension: 'TestDimension1', operator: 'eq', filters: ['testlabel', 'testlabel2'] }], + }, + }); + mockPanelData.series = [ + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel' }, + }, + ], + }, + { + ...mockPanelData.series[0], + fields: [ + { + ...mockPanelData.series[0].fields[0], + name: 'Test Dimension 1', + labels: { testdimension1: 'testlabel2' }, + }, + ], + }, + ]; }); - mockPanelData.series = [ - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel' }, - }, - ], - }, - { - ...mockPanelData.series[0], - fields: [ - { - ...mockPanelData.series[0].fields[0], - name: 'Test Dimension 1', - labels: { testdimension1: 'testlabel2' }, - }, - ], - }, - ]; }); -}); +} diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/NewDimensionFields.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/NewDimensionFields.tsx new file mode 100644 index 00000000000..8531fcd3457 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/NewDimensionFields.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { SelectableValue, DataFrame, PanelData, Labels } from '@grafana/data'; +import { AccessoryButton, EditorList } from '@grafana/experimental'; +import { Select, HorizontalGroup, MultiSelect } from '@grafana/ui'; + +import { AzureMetricDimension, AzureMonitorOption, AzureMonitorQuery, AzureQueryEditorFieldProps } from '../../types'; +import { Field } from '../Field'; + +import { setDimensionFilters } from './setQueryValue'; + +interface DimensionFieldsProps extends AzureQueryEditorFieldProps { + dimensionOptions: AzureMonitorOption[]; +} + +interface DimensionLabels { + [key: string]: Set; +} + +const useDimensionLabels = (data: PanelData | undefined, query: AzureMonitorQuery) => { + const [dimensionLabels, setDimensionLabels] = useState({}); + useEffect(() => { + let labelsObj: DimensionLabels = {}; + if (data?.series?.length) { + // Identify which series' in the dataframe are relevant to the current query + const series: DataFrame[] = data.series.flat().filter((series) => series.refId === query.refId); + const fields = series.flatMap((series) => series.fields); + // Retrieve labels for series fields + const labels = fields + .map((fields) => fields.labels) + .flat() + .filter((item): item is Labels => item !== null && item !== undefined); + for (const label of labels) { + // Labels only exist for series that have a dimension selected + for (const [dimension, value] of Object.entries(label)) { + if (labelsObj[dimension]) { + labelsObj[dimension].add(value); + } else { + labelsObj[dimension] = new Set([value]); + } + } + } + } + setDimensionLabels((prevLabels) => { + const newLabels: DimensionLabels = {}; + const currentLabels = Object.keys(labelsObj); + if (currentLabels.length === 0) { + return prevLabels; + } + for (const label of currentLabels) { + if (prevLabels[label] && labelsObj[label].size < prevLabels[label].size) { + newLabels[label] = prevLabels[label]; + } else { + newLabels[label] = labelsObj[label]; + } + } + return newLabels; + }); + }, [data?.series, query.refId]); + return dimensionLabels; +}; + +const NewDimensionFields: React.FC = ({ data, query, dimensionOptions, onQueryChange }) => { + const dimensionFilters = useMemo( + () => query.azureMonitor?.dimensionFilters ?? [], + [query.azureMonitor?.dimensionFilters] + ); + + const dimensionLabels = useDimensionLabels(data, query); + + const dimensionOperators: Array> = [ + { label: '==', value: 'eq' }, + { label: '!=', value: 'ne' }, + { label: 'starts with', value: 'sw' }, + ]; + + const validDimensionOptions = useMemo(() => { + // We filter out any dimensions that have already been used in a filter as the API doesn't support having multiple filters with the same dimension name. + // The Azure portal also doesn't support this feature so it makes sense for consistency. + let t = dimensionOptions; + if (dimensionFilters.length) { + t = dimensionOptions.filter( + (val) => !dimensionFilters.some((dimensionFilter) => dimensionFilter.dimension === val.value) + ); + } + return t; + }, [dimensionFilters, dimensionOptions]); + + const onFieldChange = ( + fieldName: Key, + item: Partial, + value: AzureMetricDimension[Key], + onChange: (item: Partial) => void + ) => { + item[fieldName] = value; + onChange(item); + }; + + const getValidDimensionOptions = (selectedDimension: string) => { + return validDimensionOptions.concat(dimensionOptions.filter((item) => item.value === selectedDimension)); + }; + + const getValidFilterOptions = (selectedFilter: string | undefined, dimension: string) => { + const dimensionFilters = Array.from(dimensionLabels[dimension.toLowerCase()] ?? []); + if (dimensionFilters.find((filter) => filter === selectedFilter)) { + return dimensionFilters.map((filter) => ({ value: filter, label: filter })); + } + return [...dimensionFilters, ...(selectedFilter && selectedFilter !== '*' ? [selectedFilter] : [])].map((item) => ({ + value: item, + label: item, + })); + }; + + const getValidMultiSelectOptions = (selectedFilters: string[] | undefined, dimension: string) => { + const labelOptions = getValidFilterOptions(undefined, dimension); + if (selectedFilters) { + for (const filter of selectedFilters) { + if (!labelOptions.find((label) => label.value === filter)) { + labelOptions.push({ value: filter, label: filter }); + } + } + } + return labelOptions; + }; + const getValidOperators = (selectedOperator: string) => { + if (dimensionOperators.find((operator: SelectableValue) => operator.value === selectedOperator)) { + return dimensionOperators; + } + return [...dimensionOperators, ...(selectedOperator ? [{ label: selectedOperator, value: selectedOperator }] : [])]; + }; + + const changedFunc = (changed: Array>) => { + const properData: AzureMetricDimension[] = changed.map((x) => { + return { + dimension: x.dimension ?? '', + operator: x.operator ?? 'eq', + filters: x.filters ?? [], + }; + }); + onQueryChange(setDimensionFilters(query, properData)); + }; + + const renderFilters = ( + item: Partial, + onChange: (item: Partial) => void, + onDelete: () => void + ) => { + return ( + + onFieldChange('operator', item, e.value ?? '', onChange)} + allowCustomValue + /> + {item.operator === 'eq' || item.operator === 'ne' ? ( + + onFieldChange( + 'filters', + item, + e.map((x) => x.value ?? ''), + onChange + ) + } + aria-label={'dimension-labels-select'} + allowCustomValue + /> + ) : ( + // The API does not currently allow for multiple "starts with" clauses to be used. +