From 9e0ca0d113a5890a1aa755ee979ec5f0936e880c Mon Sep 17 00:00:00 2001 From: Kristina Date: Thu, 2 Nov 2023 15:09:30 -0500 Subject: [PATCH] Correlations: Add transformations to Explore Editor (#75930) * Add transformation add modal and use it * Hook up saving * Add transformation vars to var list, show added transformations * Form validation * Remove type assertion, start building out transformation data in helper (WIP) * Style expression better, add delete logic * Add ability to edit, additional styling on transformation card in helper * simplify styling, conditionally run edit set up logic * Keep more field information in function, integrate it with new editor * Show default label on collapsed section, use deleteButton for confirmation of deleting transformations * Change transformation add calculations from function to hook, add label to collapsed header, add transformation tooltip * Make correlation and editor dirty state distinctive and integrate, WIP * Track action pane for more detailed messaging/actions * Better cancel modal logic * Remove changes to adminsitration transformation editor * Remove debugging line * Remove unneeded comment * Add in logic for closing editor mode * Add tests for modal logic * Use state to build vars list for helper * WIP * Fix labels and dirty state * Fix bad message and stop exiting mode if discard action is performed * Fix tests * Update to not use unstable component and tweak default label --- packages/grafana-data/src/types/explore.ts | 3 + .../src/valueFormats/valueFormats.test.ts | 2 +- .../Forms/TransformationsEditor.tsx | 62 +--- .../app/features/correlations/Forms/types.ts | 64 ++++ public/app/features/correlations/utils.ts | 19 +- .../explore/CorrelationEditorModeBar.tsx | 153 +++++---- .../features/explore/CorrelationHelper.tsx | 293 +++++++++++++++--- .../CorrelationTransformationAddModal.tsx | 240 ++++++++++++++ .../CorrelationUnsavedChangesModal.tsx | 11 +- public/app/features/explore/Explore.tsx | 4 +- .../app/features/explore/ExploreToolbar.tsx | 6 +- .../app/features/explore/QueryRows.test.tsx | 2 +- .../explore/correlationEditLogic.test.ts | 39 +++ .../features/explore/correlationEditLogic.ts | 74 +++++ .../features/explore/state/correlations.ts | 12 +- public/app/features/explore/state/main.ts | 18 +- public/app/features/explore/state/query.ts | 4 +- .../app/features/explore/state/selectors.ts | 3 + .../app/features/explore/utils/decorators.ts | 2 +- public/app/types/explore.ts | 7 +- 20 files changed, 844 insertions(+), 174 deletions(-) create mode 100644 public/app/features/explore/CorrelationTransformationAddModal.tsx create mode 100644 public/app/features/explore/correlationEditLogic.test.ts create mode 100644 public/app/features/explore/correlationEditLogic.ts diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index 0bd99acbfbd..ea196a1fa03 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -33,10 +33,13 @@ export interface ExplorePanelsState extends Partial; vars: Record; } diff --git a/packages/grafana-data/src/valueFormats/valueFormats.test.ts b/packages/grafana-data/src/valueFormats/valueFormats.test.ts index 5bac61ae896..b0776ae4b41 100644 --- a/packages/grafana-data/src/valueFormats/valueFormats.test.ts +++ b/packages/grafana-data/src/valueFormats/valueFormats.test.ts @@ -72,7 +72,7 @@ describe('valueFormats', () => { ${'dtdurationms'} | ${undefined} | ${100000} | ${'1 minute'} ${'dtdurationms'} | ${undefined} | ${150000} | ${'2 minutes'} `( - 'With format=$format decimals=$decimals and value=$value then result shoudl be = $expected', + 'With format=$format decimals=$decimals and value=$value then result should be = $expected', async ({ format, value, decimals, expected }) => { const result = getValueFormat(format)(value, decimals, undefined, undefined); const full = formattedValueToString(result); diff --git a/public/app/features/correlations/Forms/TransformationsEditor.tsx b/public/app/features/correlations/Forms/TransformationsEditor.tsx index 3f811738137..8003ee2836c 100644 --- a/public/app/features/correlations/Forms/TransformationsEditor.tsx +++ b/public/app/features/correlations/Forms/TransformationsEditor.tsx @@ -3,7 +3,7 @@ import { compact, fill } from 'lodash'; import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { GrafanaTheme2, SupportedTransformationType } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { Button, @@ -19,6 +19,8 @@ import { useStyles2, } from '@grafana/ui'; +import { getSupportedTransTypeDetails, getTransformOptions } from './types'; + type Props = { readOnly: boolean }; const getStyles = (theme: GrafanaTheme2) => ({ @@ -95,7 +97,7 @@ export const TransformationsEditor = (props: Props) => { const newValueDetails = getSupportedTransTypeDetails(value.value); - if (newValueDetails.showExpression) { + if (newValueDetails.expressionDetails.show) { setValue( `config.transformations.${index}.expression`, keptVals[index]?.expression || '' @@ -104,7 +106,7 @@ export const TransformationsEditor = (props: Props) => { setValue(`config.transformations.${index}.expression`, ''); } - if (newValueDetails.showMapValue) { + if (newValueDetails.mapValueDetails.show) { setValue( `config.transformations.${index}.mapValue`, keptVals[index]?.mapValue || '' @@ -160,7 +162,7 @@ export const TransformationsEditor = (props: Props) => { @@ -184,7 +186,7 @@ export const TransformationsEditor = (props: Props) => { { readOnly={readOnly} disabled={ !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) - .showExpression + .expressionDetails.show } id={`config.transformations.${fieldVal.id}.expression`} /> @@ -221,7 +223,8 @@ export const TransformationsEditor = (props: Props) => { defaultValue={fieldVal.mapValue} readOnly={readOnly} disabled={ - !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)).showMapValue + !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) + .mapValueDetails.show } id={`config.transformations.${fieldVal.id}.mapValue`} /> @@ -266,48 +269,3 @@ export const TransformationsEditor = (props: Props) => { ); }; - -interface SupportedTransformationTypeDetails { - label: string; - value: string; - description?: string; - showExpression: boolean; - showMapValue: boolean; - requireExpression?: boolean; -} - -function getSupportedTransTypeDetails(transType: SupportedTransformationType): SupportedTransformationTypeDetails { - switch (transType) { - case SupportedTransformationType.Logfmt: - return { - label: 'Logfmt', - value: SupportedTransformationType.Logfmt, - description: 'Parse provided field with logfmt to get variables', - showExpression: false, - showMapValue: false, - }; - case SupportedTransformationType.Regex: - return { - label: 'Regular expression', - value: SupportedTransformationType.Regex, - description: - 'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value.', - showExpression: true, - showMapValue: true, - requireExpression: true, - }; - default: - return { label: transType, value: transType, showExpression: false, showMapValue: false }; - } -} - -const getTransformOptions = () => { - return Object.values(SupportedTransformationType).map((transformationType) => { - const transType = getSupportedTransTypeDetails(transformationType); - return { - label: transType.label, - value: transType.value, - description: transType.description, - }; - }); -}; diff --git a/public/app/features/correlations/Forms/types.ts b/public/app/features/correlations/Forms/types.ts index d3e10e581c4..747ef8f97ba 100644 --- a/public/app/features/correlations/Forms/types.ts +++ b/public/app/features/correlations/Forms/types.ts @@ -17,3 +17,67 @@ export type TransformationDTO = { expression?: string; mapValue?: string; }; + +export interface TransformationFieldDetails { + show: boolean; + required?: boolean; + helpText?: string; +} + +interface SupportedTransformationTypeDetails { + label: string; + value: SupportedTransformationType; + description?: string; + expressionDetails: TransformationFieldDetails; + mapValueDetails: TransformationFieldDetails; +} + +export function getSupportedTransTypeDetails( + transType: SupportedTransformationType +): SupportedTransformationTypeDetails { + switch (transType) { + case SupportedTransformationType.Logfmt: + return { + label: 'Logfmt', + value: SupportedTransformationType.Logfmt, + description: 'Parse provided field with logfmt to get variables', + expressionDetails: { show: false }, + mapValueDetails: { show: false }, + }; + case SupportedTransformationType.Regex: + return { + label: 'Regular expression', + value: SupportedTransformationType.Regex, + description: + 'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value. Regex is case insensitive.', + expressionDetails: { + show: true, + required: true, + helpText: 'Use capture groups to extract a portion of the field.', + }, + mapValueDetails: { + show: true, + required: false, + helpText: 'Defines the name of the variable if the capture group is not named.', + }, + }; + default: + return { + label: transType, + value: transType, + expressionDetails: { show: false }, + mapValueDetails: { show: false }, + }; + } +} + +export const getTransformOptions = () => { + return Object.values(SupportedTransformationType).map((transformationType) => { + const transType = getSupportedTransTypeDetails(transformationType); + return { + label: transType.label, + value: transType.value, + description: transType.description, + }; + }); +}; diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index cba91591e92..14abefccc0d 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -1,7 +1,8 @@ import { lastValueFrom } from 'rxjs'; import { DataFrame, DataLinkConfigOrigin } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { ExploreItemState } from 'app/types'; import { formatValueName } from '../explore/PrometheusListView/ItemLabels'; @@ -90,3 +91,19 @@ export const createCorrelation = async ( ): Promise => { return getBackendSrv().post(`/api/datasources/uid/${sourceUID}/correlations`, correlation); }; + +const getDSInstanceForPane = async (pane: ExploreItemState) => { + if (pane.datasourceInstance?.meta.mixed) { + return await getDataSourceSrv().get(pane.queries[0].datasource); + } else { + return pane.datasourceInstance; + } +}; + +export const generateDefaultLabel = async (sourcePane: ExploreItemState, targetPane: ExploreItemState) => { + return Promise.all([getDSInstanceForPane(sourcePane), getDSInstanceForPane(targetPane)]).then((dsInstances) => { + return dsInstances[0]?.name !== undefined && dsInstances[1]?.name !== undefined + ? `${dsInstances[0]?.name} to ${dsInstances[1]?.name}` + : ''; + }); +}; diff --git a/public/app/features/explore/CorrelationEditorModeBar.tsx b/public/app/features/explore/CorrelationEditorModeBar.tsx index e4f4a761b9e..c714b951c07 100644 --- a/public/app/features/explore/CorrelationEditorModeBar.tsx +++ b/public/app/features/explore/CorrelationEditorModeBar.tsx @@ -9,36 +9,84 @@ import { Button, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui' import { CORRELATION_EDITOR_POST_CONFIRM_ACTION, ExploreItemState, useDispatch, useSelector } from 'app/types'; import { CorrelationUnsavedChangesModal } from './CorrelationUnsavedChangesModal'; +import { showModalMessage } from './correlationEditLogic'; import { saveCurrentCorrelation } from './state/correlations'; import { changeDatasource } from './state/datasource'; import { changeCorrelationHelperData } from './state/explorePane'; import { changeCorrelationEditorDetails, splitClose } from './state/main'; import { runQueries } from './state/query'; -import { selectCorrelationDetails } from './state/selectors'; +import { selectCorrelationDetails, selectIsHelperShowing } from './state/selectors'; export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, ExploreItemState]> }) => { const dispatch = useDispatch(); const styles = useStyles2(getStyles); const correlationDetails = useSelector(selectCorrelationDetails); - const [showSavePrompt, setShowSavePrompt] = useState(false); + const isHelperShowing = useSelector(selectIsHelperShowing); + const [saveMessage, setSaveMessage] = useState(undefined); // undefined means do not show // handle refreshing and closing the tab - useBeforeUnload(correlationDetails?.dirty || false, 'Save correlation?'); + useBeforeUnload(correlationDetails?.correlationDirty || false, 'Save correlation?'); + useBeforeUnload( + (!correlationDetails?.correlationDirty && correlationDetails?.queryEditorDirty) || false, + 'The query editor was changed. Save correlation before continuing?' + ); - // handle exiting (staying within explore) + // decide if we are displaying prompt, perform action if not useEffect(() => { - if (correlationDetails?.isExiting && correlationDetails?.dirty) { - setShowSavePrompt(true); - } else if (correlationDetails?.isExiting && !correlationDetails?.dirty) { - dispatch( - changeCorrelationEditorDetails({ - editorMode: false, - dirty: false, - isExiting: false, - }) - ); + if (correlationDetails?.isExiting) { + const { correlationDirty, queryEditorDirty } = correlationDetails; + let isActionLeft = undefined; + let action = undefined; + if (correlationDetails.postConfirmAction) { + isActionLeft = correlationDetails.postConfirmAction.isActionLeft; + action = correlationDetails.postConfirmAction.action; + } else { + // closing the editor only + action = CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR; + isActionLeft = false; + } + + const modalMessage = showModalMessage(action, isActionLeft, correlationDirty, queryEditorDirty); + if (modalMessage !== undefined) { + setSaveMessage(modalMessage); + } else { + // if no prompt, perform action + if ( + action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && + correlationDetails.postConfirmAction + ) { + const { exploreId, changeDatasourceUid } = correlationDetails?.postConfirmAction; + if (exploreId && changeDatasourceUid) { + dispatch(changeDatasource(exploreId, changeDatasourceUid, { importQueries: true })); + dispatch( + changeCorrelationEditorDetails({ + isExiting: false, + }) + ); + } + } else if ( + action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE && + correlationDetails.postConfirmAction + ) { + const { exploreId } = correlationDetails?.postConfirmAction; + if (exploreId !== undefined) { + dispatch(splitClose(exploreId)); + dispatch( + changeCorrelationEditorDetails({ + isExiting: false, + }) + ); + } + } else if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR) { + dispatch( + changeCorrelationEditorDetails({ + editorMode: false, + }) + ); + } + } } - }, [correlationDetails?.dirty, correlationDetails?.isExiting, dispatch]); + }, [correlationDetails, dispatch, isHelperShowing]); // clear data when unmounted useUnmount(() => { @@ -46,7 +94,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl changeCorrelationEditorDetails({ editorMode: false, isExiting: false, - dirty: false, + correlationDirty: false, label: undefined, description: undefined, canSave: false, @@ -64,15 +112,12 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl }); }); - const closePaneAndReset = (exploreId: string) => { - setShowSavePrompt(false); - dispatch(splitClose(exploreId)); - reportInteraction('grafana_explore_split_view_closed'); + const resetEditor = () => { dispatch( changeCorrelationEditorDetails({ editorMode: true, isExiting: false, - dirty: false, + correlationDirty: false, label: undefined, description: undefined, canSave: false, @@ -90,43 +135,39 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl }); }; - const changeDatasourceAndReset = (exploreId: string, datasourceUid: string) => { - setShowSavePrompt(false); - dispatch(changeDatasource(exploreId, datasourceUid, { importQueries: true })); - dispatch( - changeCorrelationEditorDetails({ - editorMode: true, - isExiting: false, - dirty: false, - label: undefined, - description: undefined, - canSave: false, - }) - ); - panes.forEach((pane) => { - dispatch( - changeCorrelationHelperData({ - exploreId: pane[0], - correlationEditorHelperData: undefined, - }) - ); - }); + const closePane = (exploreId: string) => { + setSaveMessage(undefined); + dispatch(splitClose(exploreId)); + reportInteraction('grafana_explore_split_view_closed'); }; - const saveCorrelation = (skipPostConfirmAction: boolean) => { - dispatch(saveCurrentCorrelation(correlationDetails?.label, correlationDetails?.description)); + const changeDatasourcePostAction = (exploreId: string, datasourceUid: string) => { + setSaveMessage(undefined); + dispatch(changeDatasource(exploreId, datasourceUid, { importQueries: true })); + }; + + const saveCorrelationPostAction = (skipPostConfirmAction: boolean) => { + dispatch( + saveCurrentCorrelation( + correlationDetails?.label, + correlationDetails?.description, + correlationDetails?.transformations + ) + ); if (!skipPostConfirmAction && correlationDetails?.postConfirmAction !== undefined) { const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction; if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) { - closePaneAndReset(exploreId); + closePane(exploreId); + resetEditor(); } else if ( action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && changeDatasourceUid !== undefined ) { - changeDatasourceAndReset(exploreId, changeDatasourceUid); + changeDatasource(exploreId, changeDatasourceUid); + resetEditor(); } } else { - dispatch(changeCorrelationEditorDetails({ editorMode: false, dirty: false, isExiting: false })); + dispatch(changeCorrelationEditorDetails({ editorMode: false, correlationDirty: false, isExiting: false })); } }; @@ -138,7 +179,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl if ( location.pathname !== '/explore' && (correlationDetails?.editorMode || false) && - (correlationDetails?.dirty || false) + (correlationDetails?.correlationDirty || false) ) { return 'You have unsaved correlation data. Continue?'; } else { @@ -147,19 +188,20 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl }} /> - {showSavePrompt && ( + {saveMessage !== undefined && ( { if (correlationDetails?.postConfirmAction !== undefined) { const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction; if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) { - closePaneAndReset(exploreId); + closePane(exploreId); } else if ( action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && changeDatasourceUid !== undefined ) { - changeDatasourceAndReset(exploreId, changeDatasourceUid); + changeDatasourcePostAction(exploreId, changeDatasourceUid); } + dispatch(changeCorrelationEditorDetails({ isExiting: false })); } else { // exit correlations mode // if we are discarding the in progress correlation, reset everything @@ -167,7 +209,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl dispatch( changeCorrelationEditorDetails({ editorMode: false, - dirty: false, + correlationDirty: false, isExiting: false, }) ); @@ -176,11 +218,12 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl onCancel={() => { // if we are cancelling the exit, set the editor mode back to true and hide the prompt dispatch(changeCorrelationEditorDetails({ isExiting: false })); - setShowSavePrompt(false); + setSaveMessage(undefined); }} onSave={() => { - saveCorrelation(false); + saveCorrelationPostAction(false); }} + message={saveMessage} /> )}
@@ -194,7 +237,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl fill="outline" className={correlationDetails?.canSave ? styles.buttonColor : styles.disabledButtonColor} onClick={() => { - saveCorrelation(true); + saveCorrelationPostAction(true); }} > Save diff --git a/public/app/features/explore/CorrelationHelper.tsx b/public/app/features/explore/CorrelationHelper.tsx index fecf3d03af5..c0b704c3502 100644 --- a/public/app/features/explore/CorrelationHelper.tsx +++ b/public/app/features/explore/CorrelationHelper.tsx @@ -1,14 +1,35 @@ +import { css } from '@emotion/css'; import React, { useState, useEffect, useId } from 'react'; import { useForm } from 'react-hook-form'; +import { useAsync } from 'react-use'; -import { ExploreCorrelationHelperData } from '@grafana/data'; -import { Collapse, Alert, Field, Input } from '@grafana/ui'; +import { DataLinkTransformationConfig, ExploreCorrelationHelperData, GrafanaTheme2 } from '@grafana/data'; +import { + Collapse, + Alert, + Field, + Input, + Button, + Card, + IconButton, + useStyles2, + DeleteButton, + Tooltip, + Icon, + Stack, +} from '@grafana/ui'; import { useDispatch, useSelector } from 'app/types'; +import { getTransformationVars } from '../correlations/transformations'; +import { generateDefaultLabel } from '../correlations/utils'; + +import { CorrelationTransformationAddModal } from './CorrelationTransformationAddModal'; +import { changeCorrelationHelperData } from './state/explorePane'; import { changeCorrelationEditorDetails } from './state/main'; -import { selectCorrelationDetails } from './state/selectors'; +import { selectCorrelationDetails, selectPanes } from './state/selectors'; interface Props { + exploreId: string; correlations: ExploreCorrelationHelperData; } @@ -17,60 +38,242 @@ interface FormValues { description: string; } -export const CorrelationHelper = ({ correlations }: Props) => { +export const CorrelationHelper = ({ exploreId, correlations }: Props) => { const dispatch = useDispatch(); - const { register, watch } = useForm(); - const [isOpen, setIsOpen] = useState(false); + const styles = useStyles2(getStyles); + const panes = useSelector(selectPanes); + const panesVals = Object.values(panes); + const { value: defaultLabel, loading: loadingLabel } = useAsync( + async () => await generateDefaultLabel(panesVals[0]!, panesVals[1]!), + [ + panesVals[0]?.datasourceInstance, + panesVals[0]?.queries[0].datasource, + panesVals[1]?.datasourceInstance, + panesVals[1]?.queries[0].datasource, + ] + ); + + const { register, watch, getValues, setValue } = useForm(); + const [isLabelDescOpen, setIsLabelDescOpen] = useState(false); + const [isTransformOpen, setIsTransformOpen] = useState(false); + const [showTransformationAddModal, setShowTransformationAddModal] = useState(false); + const [transformations, setTransformations] = useState([]); + const [transformationIdxToEdit, setTransformationIdxToEdit] = useState(undefined); const correlationDetails = useSelector(selectCorrelationDetails); const id = useId(); - useEffect(() => { - const subscription = watch((value) => { - let dirty = false; - - if (!correlationDetails?.dirty && (value.label !== '' || value.description !== '')) { - dirty = true; - } else if (correlationDetails?.dirty && value.label.trim() === '' && value.description.trim() === '') { - dirty = false; - } - dispatch(changeCorrelationEditorDetails({ label: value.label, description: value.description, dirty: dirty })); - }); - return () => subscription.unsubscribe(); - }, [correlationDetails?.dirty, dispatch, watch]); - // only fire once on mount to allow save button to enable / disable when unmounted useEffect(() => { dispatch(changeCorrelationEditorDetails({ canSave: true })); - return () => { dispatch(changeCorrelationEditorDetails({ canSave: false })); }; }, [dispatch]); + useEffect(() => { + if ( + !loadingLabel && + defaultLabel !== undefined && + !correlationDetails?.correlationDirty && + getValues('label') !== '' + ) { + setValue('label', defaultLabel); + } + }, [correlationDetails?.correlationDirty, defaultLabel, getValues, loadingLabel, setValue]); + + useEffect(() => { + const subscription = watch((value) => { + let dirty = correlationDetails?.correlationDirty || false; + + if (!dirty && (value.label !== defaultLabel || value.description !== '')) { + dirty = true; + } else if (dirty && value.label === defaultLabel && value.description.trim() === '') { + dirty = false; + } + dispatch( + changeCorrelationEditorDetails({ label: value.label, description: value.description, correlationDirty: dirty }) + ); + }); + return () => subscription.unsubscribe(); + }, [correlationDetails?.correlationDirty, defaultLabel, dispatch, watch]); + + useEffect(() => { + const dirty = + !correlationDetails?.correlationDirty && transformations.length > 0 ? true : correlationDetails?.correlationDirty; + dispatch(changeCorrelationEditorDetails({ transformations: transformations, correlationDirty: dirty })); + let transVarRecords: Record = {}; + transformations.forEach((transformation) => { + const transformationVars = getTransformationVars( + { + type: transformation.type, + expression: transformation.expression, + mapValue: transformation.mapValue, + }, + correlations.vars[transformation.field!], + transformation.field! + ); + + Object.keys(transformationVars).forEach((key) => { + transVarRecords[key] = transformationVars[key]?.value; + }); + }); + + dispatch( + changeCorrelationHelperData({ + exploreId: exploreId, + correlationEditorHelperData: { + resultField: correlations.resultField, + origVars: correlations.origVars, + vars: { ...correlations.origVars, ...transVarRecords }, + }, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, transformations]); + return ( - - The correlation link will appear by the {correlations.resultField} field. You can use the following - variables to set up your correlations: -
-        {Object.entries(correlations.vars).map((entry) => {
-          return `\$\{${entry[0]}\} = ${entry[1]}\n`;
-        })}
-      
- { - setIsOpen(!isOpen); - }} - label="Label/Description" - > - - - - - - - -
+ <> + {showTransformationAddModal && ( + { + setTransformationIdxToEdit(undefined); + setShowTransformationAddModal(false); + }} + onSave={(transformation: DataLinkTransformationConfig) => { + if (transformationIdxToEdit !== undefined) { + const editTransformations = [...transformations]; + editTransformations[transformationIdxToEdit] = transformation; + setTransformations(editTransformations); + setTransformationIdxToEdit(undefined); + } else { + setTransformations([...transformations, transformation]); + } + setShowTransformationAddModal(false); + }} + fieldList={correlations.vars} + transformationToEdit={ + transformationIdxToEdit !== undefined ? transformations[transformationIdxToEdit] : undefined + } + /> + )} + + The correlation link will appear by the {correlations.resultField} field. You can use the following + variables to set up your correlations: +
+          {Object.entries(correlations.vars).map((entry) => {
+            return `\$\{${entry[0]}\} = ${entry[1]}\n`;
+          })}
+        
+ { + setIsLabelDescOpen(!isLabelDescOpen); + }} + label={ + + Label / Description + {!isLabelDescOpen && !loadingLabel && ( + {`Label: ${getValues('label') || defaultLabel}`} + )} + + } + > + + { + if (getValues('label') === '' && defaultLabel !== undefined) { + setValue('label', defaultLabel); + } + }} + /> + + + + + + { + setIsTransformOpen(!isTransformOpen); + }} + label={ + + Transformations + + + + + } + > + + {transformations.map((transformation, i) => { + const { type, field, expression, mapValue } = transformation; + const detailsString = [ + (mapValue ?? '').length > 0 ? `Variable name: ${mapValue}` : undefined, + (expression ?? '').length > 0 ? ( + <> + Expression: {expression} + + ) : undefined, + ].filter((val) => val); + return ( + + + {field}: {type} + + {detailsString.length > 0 && ( + {detailsString} + )} + + { + setTransformationIdxToEdit(i); + setShowTransformationAddModal(true); + }} + /> + setTransformations(transformations.filter((_, idx) => i !== idx))} + closeOnConfirm + /> + + + ); + })} + +
+ ); }; + +const getStyles = (theme: GrafanaTheme2) => { + return { + labelCollapseDetails: css({ + marginLeft: theme.spacing(2), + ...theme.typography['bodySmall'], + fontStyle: 'italic', + }), + transformationAction: css({ + marginBottom: theme.spacing(2), + }), + transformationMeta: css({ + alignItems: 'baseline', + }), + }; +}; diff --git a/public/app/features/explore/CorrelationTransformationAddModal.tsx b/public/app/features/explore/CorrelationTransformationAddModal.tsx new file mode 100644 index 00000000000..63388852e3e --- /dev/null +++ b/public/app/features/explore/CorrelationTransformationAddModal.tsx @@ -0,0 +1,240 @@ +import { css } from '@emotion/css'; +import React, { useId, useState, useMemo, useEffect } from 'react'; +import Highlighter from 'react-highlight-words'; +import { useForm } from 'react-hook-form'; + +import { DataLinkTransformationConfig, ScopedVars } from '@grafana/data'; +import { Button, Field, Icon, Input, InputControl, Label, Modal, Select, Tooltip, Stack } from '@grafana/ui'; + +import { + getSupportedTransTypeDetails, + getTransformOptions, + TransformationFieldDetails, +} from '../correlations/Forms/types'; +import { getTransformationVars } from '../correlations/transformations'; + +interface CorrelationTransformationAddModalProps { + onCancel: () => void; + onSave: (transformation: DataLinkTransformationConfig) => void; + fieldList: Record; + transformationToEdit?: DataLinkTransformationConfig; +} + +interface ShowFormFields { + expressionDetails: TransformationFieldDetails; + mapValueDetails: TransformationFieldDetails; +} + +const LabelWithTooltip = ({ label, tooltipText }: { label: string; tooltipText: string }) => ( + + + + + + +); + +export const CorrelationTransformationAddModal = ({ + onSave, + onCancel, + fieldList, + transformationToEdit, +}: CorrelationTransformationAddModalProps) => { + const [exampleValue, setExampleValue] = useState(undefined); + const [transformationVars, setTransformationVars] = useState({}); + const [formFieldsVis, setFormFieldsVis] = useState({ + mapValueDetails: { show: false }, + expressionDetails: { show: false }, + }); + const [isExpValid, setIsExpValid] = useState(false); // keep the highlighter from erroring on bad expressions + const [validToSave, setValidToSave] = useState(false); + const { getValues, control, register, watch } = useForm({ + defaultValues: useMemo(() => { + if (transformationToEdit) { + const exampleVal = fieldList[transformationToEdit?.field!]; + setExampleValue(exampleVal); + if (transformationToEdit?.expression) { + setIsExpValid(true); + } + const transformationTypeDetails = getSupportedTransTypeDetails(transformationToEdit?.type!); + setFormFieldsVis({ + mapValueDetails: transformationTypeDetails.mapValueDetails, + expressionDetails: transformationTypeDetails.expressionDetails, + }); + + const transformationVars = getTransformationVars( + { + type: transformationToEdit?.type!, + expression: transformationToEdit?.expression, + mapValue: transformationToEdit?.mapValue, + }, + exampleVal || '', + transformationToEdit?.field! + ); + setTransformationVars({ ...transformationVars }); + setValidToSave(true); + return { + type: transformationToEdit?.type, + field: transformationToEdit?.field, + mapValue: transformationToEdit?.mapValue, + expression: transformationToEdit?.expression, + }; + } else { + return undefined; + } + }, [fieldList, transformationToEdit]), + }); + const id = useId(); + + useEffect(() => { + const subscription = watch((formValues) => { + const expression = formValues.expression; + let isExpressionValid = false; + if (expression !== undefined) { + isExpressionValid = true; + try { + new RegExp(expression); + } catch (e) { + isExpressionValid = false; + } + } else { + isExpressionValid = !formFieldsVis.expressionDetails.show; + } + setIsExpValid(isExpressionValid); + const transformationVars = getTransformationVars( + { + type: formValues.type, + expression: isExpressionValid ? expression : '', + mapValue: formValues.mapValue, + }, + fieldList[formValues.field!] || '', + formValues.field! + ); + + const transKeys = Object.keys(transformationVars); + setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {}); + + if (transKeys.length === 0 || !isExpressionValid) { + setValidToSave(false); + } else { + setValidToSave(true); + } + }); + return () => subscription.unsubscribe(); + }, [fieldList, formFieldsVis.expressionDetails.show, watch]); + + return ( + +

+ A transformation extracts variables out of a single field. These variables will be available along with your + field variables. +

+ + ( + { + onChange(value.value); + const transformationTypeDetails = getSupportedTransTypeDetails(value.value!); + setFormFieldsVis({ + mapValueDetails: transformationTypeDetails.mapValueDetails, + expressionDetails: transformationTypeDetails.expressionDetails, + }); + }} + options={getTransformOptions()} + aria-label="type" + /> + )} + name={`type` as const} + /> + + {formFieldsVis.expressionDetails.show && ( + + ) : ( + 'Expression' + ) + } + htmlFor={`${id}-expression`} + required={formFieldsVis.expressionDetails.required} + > + + + )} + {formFieldsVis.mapValueDetails.show && ( + + ) : ( + 'Variable Name' + ) + } + htmlFor={`${id}-mapValue`} + > + + + )} + {Object.entries(transformationVars).length > 0 && ( + <> + This transformation will add the following variables: +
+                {Object.entries(transformationVars).map((entry) => {
+                  return `\$\{${entry[0]}\} = ${entry[1]?.value}\n`;
+                })}
+              
+ + )} + + )} + + + + +
+ ); +}; diff --git a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx index 19100150fd9..45883d7a87b 100644 --- a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx +++ b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx @@ -4,27 +4,28 @@ import React from 'react'; import { Button, Modal } from '@grafana/ui'; interface UnsavedChangesModalProps { + message: string; onDiscard: () => void; onCancel: () => void; onSave: () => void; } -export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel }: UnsavedChangesModalProps) => { +export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel, message }: UnsavedChangesModalProps) => { return ( -
Do you want to save changes to this Correlation?
+
{message}