mirror of https://github.com/grafana/grafana.git
				
				
				
			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
This commit is contained in:
		
							parent
							
								
									a2629f3dd3
								
							
						
					
					
						commit
						9e0ca0d113
					
				|  | @ -33,10 +33,13 @@ export interface ExplorePanelsState extends Partial<Record<PreferredVisualisatio | |||
| 
 | ||||
| /** | ||||
|  * Keep a list of vars the correlations editor / helper in explore will use | ||||
|  * | ||||
|  * vars can be modified by transformation variables, origVars is so we can rebuild the original list | ||||
|  */ | ||||
| /** @internal */ | ||||
| export interface ExploreCorrelationHelperData { | ||||
|   resultField: string; | ||||
|   origVars: Record<string, string>; | ||||
|   vars: Record<string, string>; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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) => { | |||
|                               <Label htmlFor={`config.transformations.${fieldVal.id}.expression`}> | ||||
|                                 Expression | ||||
|                                 {getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) | ||||
|                                   .requireExpression | ||||
|                                   .expressionDetails.required | ||||
|                                   ? ' *' | ||||
|                                   : ''} | ||||
|                               </Label> | ||||
|  | @ -184,7 +186,7 @@ export const TransformationsEditor = (props: Props) => { | |||
|                           <Input | ||||
|                             {...register(`config.transformations.${index}.expression`, { | ||||
|                               required: getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) | ||||
|                                 .requireExpression | ||||
|                                 .expressionDetails.required | ||||
|                                 ? 'Please define an expression' | ||||
|                                 : undefined, | ||||
|                             })} | ||||
|  | @ -192,7 +194,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, | ||||
|     }; | ||||
|   }); | ||||
| }; | ||||
|  |  | |||
|  | @ -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, | ||||
|     }; | ||||
|   }); | ||||
| }; | ||||
|  |  | |||
|  | @ -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<CreateCorrelationResponse> => { | ||||
|   return getBackendSrv().post<CreateCorrelationResponse>(`/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}` | ||||
|       : ''; | ||||
|   }); | ||||
| }; | ||||
|  |  | |||
|  | @ -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<string | undefined>(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) { | ||||
|     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({ | ||||
|           editorMode: false, | ||||
|           dirty: false, | ||||
|                 isExiting: false, | ||||
|               }) | ||||
|             ); | ||||
|           } | ||||
|   }, [correlationDetails?.dirty, correlationDetails?.isExiting, dispatch]); | ||||
|         } 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, 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,69 +112,62 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl | |||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   const closePaneAndReset = (exploreId: string) => { | ||||
|     setShowSavePrompt(false); | ||||
|   const resetEditor = () => { | ||||
|     dispatch( | ||||
|       changeCorrelationEditorDetails({ | ||||
|         editorMode: true, | ||||
|         isExiting: false, | ||||
|         correlationDirty: false, | ||||
|         label: undefined, | ||||
|         description: undefined, | ||||
|         canSave: false, | ||||
|       }) | ||||
|     ); | ||||
| 
 | ||||
|     panes.forEach((pane) => { | ||||
|       dispatch( | ||||
|         changeCorrelationHelperData({ | ||||
|           exploreId: pane[0], | ||||
|           correlationEditorHelperData: undefined, | ||||
|         }) | ||||
|       ); | ||||
|       dispatch(runQueries({ exploreId: pane[0] })); | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const closePane = (exploreId: string) => { | ||||
|     setSaveMessage(undefined); | ||||
|     dispatch(splitClose(exploreId)); | ||||
|     reportInteraction('grafana_explore_split_view_closed'); | ||||
|     dispatch( | ||||
|       changeCorrelationEditorDetails({ | ||||
|         editorMode: true, | ||||
|         isExiting: false, | ||||
|         dirty: false, | ||||
|         label: undefined, | ||||
|         description: undefined, | ||||
|         canSave: false, | ||||
|       }) | ||||
|     ); | ||||
| 
 | ||||
|     panes.forEach((pane) => { | ||||
|       dispatch( | ||||
|         changeCorrelationHelperData({ | ||||
|           exploreId: pane[0], | ||||
|           correlationEditorHelperData: undefined, | ||||
|         }) | ||||
|       ); | ||||
|       dispatch(runQueries({ exploreId: pane[0] })); | ||||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const changeDatasourceAndReset = (exploreId: string, datasourceUid: string) => { | ||||
|     setShowSavePrompt(false); | ||||
|   const changeDatasourcePostAction = (exploreId: string, datasourceUid: string) => { | ||||
|     setSaveMessage(undefined); | ||||
|     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 saveCorrelation = (skipPostConfirmAction: boolean) => { | ||||
|     dispatch(saveCurrentCorrelation(correlationDetails?.label, correlationDetails?.description)); | ||||
|   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 && ( | ||||
|         <CorrelationUnsavedChangesModal | ||||
|           onDiscard={() => { | ||||
|             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} | ||||
|         /> | ||||
|       )} | ||||
|       <div className={styles.correlationEditorTop}> | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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,37 +38,124 @@ interface FormValues { | |||
|   description: string; | ||||
| } | ||||
| 
 | ||||
| export const CorrelationHelper = ({ correlations }: Props) => { | ||||
| export const CorrelationHelper = ({ exploreId, correlations }: Props) => { | ||||
|   const dispatch = useDispatch(); | ||||
|   const { register, watch } = useForm<FormValues>(); | ||||
|   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<FormValues>(); | ||||
|   const [isLabelDescOpen, setIsLabelDescOpen] = useState(false); | ||||
|   const [isTransformOpen, setIsTransformOpen] = useState(false); | ||||
|   const [showTransformationAddModal, setShowTransformationAddModal] = useState(false); | ||||
|   const [transformations, setTransformations] = useState<DataLinkTransformationConfig[]>([]); | ||||
|   const [transformationIdxToEdit, setTransformationIdxToEdit] = useState<number | undefined>(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<string, string> = {}; | ||||
|     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 ( | ||||
|     <> | ||||
|       {showTransformationAddModal && ( | ||||
|         <CorrelationTransformationAddModal | ||||
|           onCancel={() => { | ||||
|             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 | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       <Alert title="Correlation details" severity="info"> | ||||
|         The correlation link will appear by the <code>{correlations.resultField}</code> field. You can use the following | ||||
|         variables to set up your correlations: | ||||
|  | @ -58,19 +166,114 @@ export const CorrelationHelper = ({ correlations }: Props) => { | |||
|         </pre> | ||||
|         <Collapse | ||||
|           collapsible | ||||
|         isOpen={isOpen} | ||||
|           isOpen={isLabelDescOpen} | ||||
|           onToggle={() => { | ||||
|           setIsOpen(!isOpen); | ||||
|             setIsLabelDescOpen(!isLabelDescOpen); | ||||
|           }} | ||||
|         label="Label/Description" | ||||
|           label={ | ||||
|             <Stack gap={1} direction="row" wrap="wrap" alignItems="center"> | ||||
|               Label / Description | ||||
|               {!isLabelDescOpen && !loadingLabel && ( | ||||
|                 <span className={styles.labelCollapseDetails}>{`Label: ${getValues('label') || defaultLabel}`}</span> | ||||
|               )} | ||||
|             </Stack> | ||||
|           } | ||||
|         > | ||||
|           <Field label="Label" htmlFor={`${id}-label`}> | ||||
|           <Input {...register('label')} id={`${id}-label`} /> | ||||
|             <Input | ||||
|               {...register('label')} | ||||
|               id={`${id}-label`} | ||||
|               onBlur={() => { | ||||
|                 if (getValues('label') === '' && defaultLabel !== undefined) { | ||||
|                   setValue('label', defaultLabel); | ||||
|                 } | ||||
|               }} | ||||
|             /> | ||||
|           </Field> | ||||
|           <Field label="Description" htmlFor={`${id}-description`}> | ||||
|             <Input {...register('description')} id={`${id}-description`} /> | ||||
|           </Field> | ||||
|         </Collapse> | ||||
|         <Collapse | ||||
|           collapsible | ||||
|           isOpen={isTransformOpen} | ||||
|           onToggle={() => { | ||||
|             setIsTransformOpen(!isTransformOpen); | ||||
|           }} | ||||
|           label={ | ||||
|             <Stack gap={1} direction="row" wrap="wrap" alignItems="center"> | ||||
|               Transformations | ||||
|               <Tooltip content="A transformation extracts one or more variables out of a single field."> | ||||
|                 <Icon name="info-circle" size="sm" /> | ||||
|               </Tooltip> | ||||
|             </Stack> | ||||
|           } | ||||
|         > | ||||
|           <Button | ||||
|             variant="secondary" | ||||
|             fill="outline" | ||||
|             onClick={() => { | ||||
|               setShowTransformationAddModal(true); | ||||
|             }} | ||||
|             className={styles.transformationAction} | ||||
|           > | ||||
|             Add transformation | ||||
|           </Button> | ||||
|           {transformations.map((transformation, i) => { | ||||
|             const { type, field, expression, mapValue } = transformation; | ||||
|             const detailsString = [ | ||||
|               (mapValue ?? '').length > 0 ? `Variable name: ${mapValue}` : undefined, | ||||
|               (expression ?? '').length > 0 ? ( | ||||
|                 <> | ||||
|                   Expression: <code>{expression}</code> | ||||
|                 </> | ||||
|               ) : undefined, | ||||
|             ].filter((val) => val); | ||||
|             return ( | ||||
|               <Card key={`trans-${i}`}> | ||||
|                 <Card.Heading> | ||||
|                   {field}: {type} | ||||
|                 </Card.Heading> | ||||
|                 {detailsString.length > 0 && ( | ||||
|                   <Card.Meta className={styles.transformationMeta}>{detailsString}</Card.Meta> | ||||
|                 )} | ||||
|                 <Card.SecondaryActions> | ||||
|                   <IconButton | ||||
|                     key="edit" | ||||
|                     name="edit" | ||||
|                     aria-label="edit transformation" | ||||
|                     onClick={() => { | ||||
|                       setTransformationIdxToEdit(i); | ||||
|                       setShowTransformationAddModal(true); | ||||
|                     }} | ||||
|                   /> | ||||
|                   <DeleteButton | ||||
|                     aria-label="delete transformation" | ||||
|                     onConfirm={() => setTransformations(transformations.filter((_, idx) => i !== idx))} | ||||
|                     closeOnConfirm | ||||
|                   /> | ||||
|                 </Card.SecondaryActions> | ||||
|               </Card> | ||||
|             ); | ||||
|           })} | ||||
|         </Collapse> | ||||
|       </Alert> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| 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', | ||||
|     }), | ||||
|   }; | ||||
| }; | ||||
|  |  | |||
|  | @ -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<string, string>; | ||||
|   transformationToEdit?: DataLinkTransformationConfig; | ||||
| } | ||||
| 
 | ||||
| interface ShowFormFields { | ||||
|   expressionDetails: TransformationFieldDetails; | ||||
|   mapValueDetails: TransformationFieldDetails; | ||||
| } | ||||
| 
 | ||||
| const LabelWithTooltip = ({ label, tooltipText }: { label: string; tooltipText: string }) => ( | ||||
|   <Stack gap={1} direction="row" wrap="wrap" alignItems="flex-start"> | ||||
|     <Label>{label}</Label> | ||||
|     <Tooltip content={tooltipText}> | ||||
|       <Icon name="info-circle" size="sm" /> | ||||
|     </Tooltip> | ||||
|   </Stack> | ||||
| ); | ||||
| 
 | ||||
| export const CorrelationTransformationAddModal = ({ | ||||
|   onSave, | ||||
|   onCancel, | ||||
|   fieldList, | ||||
|   transformationToEdit, | ||||
| }: CorrelationTransformationAddModalProps) => { | ||||
|   const [exampleValue, setExampleValue] = useState<string | undefined>(undefined); | ||||
|   const [transformationVars, setTransformationVars] = useState<ScopedVars>({}); | ||||
|   const [formFieldsVis, setFormFieldsVis] = useState<ShowFormFields>({ | ||||
|     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<DataLinkTransformationConfig>({ | ||||
|     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 ( | ||||
|     <Modal | ||||
|       isOpen={true} | ||||
|       title={`${transformationToEdit ? 'Edit' : 'Add'} transformation`} | ||||
|       onDismiss={onCancel} | ||||
|       className={css({ width: '700px' })} | ||||
|     > | ||||
|       <p> | ||||
|         A transformation extracts variables out of a single field. These variables will be available along with your | ||||
|         field variables. | ||||
|       </p> | ||||
|       <Field label="Field"> | ||||
|         <InputControl | ||||
|           control={control} | ||||
|           render={({ field: { onChange, ref, ...field } }) => ( | ||||
|             <Select | ||||
|               {...field} | ||||
|               onChange={(value) => { | ||||
|                 if (value.value) { | ||||
|                   onChange(value.value); | ||||
|                   setExampleValue(fieldList[value.value]); | ||||
|                 } | ||||
|               }} | ||||
|               options={Object.entries(fieldList).map((entry) => { | ||||
|                 return { label: entry[0], value: entry[0] }; | ||||
|               })} | ||||
|               aria-label="field" | ||||
|             /> | ||||
|           )} | ||||
|           name={`field` as const} | ||||
|         /> | ||||
|       </Field> | ||||
| 
 | ||||
|       {exampleValue && ( | ||||
|         <> | ||||
|           <pre> | ||||
|             <Highlighter | ||||
|               textToHighlight={exampleValue} | ||||
|               searchWords={[isExpValid ? getValues('expression') ?? '' : '']} | ||||
|               autoEscape={false} | ||||
|             /> | ||||
|           </pre> | ||||
|           <Field label="Type"> | ||||
|             <InputControl | ||||
|               control={control} | ||||
|               render={({ field: { onChange, ref, ...field } }) => ( | ||||
|                 <Select | ||||
|                   {...field} | ||||
|                   onChange={(value) => { | ||||
|                     onChange(value.value); | ||||
|                     const transformationTypeDetails = getSupportedTransTypeDetails(value.value!); | ||||
|                     setFormFieldsVis({ | ||||
|                       mapValueDetails: transformationTypeDetails.mapValueDetails, | ||||
|                       expressionDetails: transformationTypeDetails.expressionDetails, | ||||
|                     }); | ||||
|                   }} | ||||
|                   options={getTransformOptions()} | ||||
|                   aria-label="type" | ||||
|                 /> | ||||
|               )} | ||||
|               name={`type` as const} | ||||
|             /> | ||||
|           </Field> | ||||
|           {formFieldsVis.expressionDetails.show && ( | ||||
|             <Field | ||||
|               label={ | ||||
|                 formFieldsVis.expressionDetails.helpText ? ( | ||||
|                   <LabelWithTooltip label="Expression" tooltipText={formFieldsVis.expressionDetails.helpText} /> | ||||
|                 ) : ( | ||||
|                   'Expression' | ||||
|                 ) | ||||
|               } | ||||
|               htmlFor={`${id}-expression`} | ||||
|               required={formFieldsVis.expressionDetails.required} | ||||
|             > | ||||
|               <Input {...register('expression')} id={`${id}-expression`} /> | ||||
|             </Field> | ||||
|           )} | ||||
|           {formFieldsVis.mapValueDetails.show && ( | ||||
|             <Field | ||||
|               label={ | ||||
|                 formFieldsVis.mapValueDetails.helpText ? ( | ||||
|                   <LabelWithTooltip label="Variable Name" tooltipText={formFieldsVis.mapValueDetails.helpText} /> | ||||
|                 ) : ( | ||||
|                   'Variable Name' | ||||
|                 ) | ||||
|               } | ||||
|               htmlFor={`${id}-mapValue`} | ||||
|             > | ||||
|               <Input {...register('mapValue')} id={`${id}-mapValue`} /> | ||||
|             </Field> | ||||
|           )} | ||||
|           {Object.entries(transformationVars).length > 0 && ( | ||||
|             <> | ||||
|               This transformation will add the following variables: | ||||
|               <pre> | ||||
|                 {Object.entries(transformationVars).map((entry) => { | ||||
|                   return `\$\{${entry[0]}\} = ${entry[1]?.value}\n`; | ||||
|                 })} | ||||
|               </pre> | ||||
|             </> | ||||
|           )} | ||||
|         </> | ||||
|       )} | ||||
|       <Modal.ButtonRow> | ||||
|         <Button variant="secondary" onClick={onCancel} fill="outline"> | ||||
|           Cancel | ||||
|         </Button> | ||||
|         <Button variant="primary" onClick={() => onSave(getValues())} disabled={!validToSave}> | ||||
|           {transformationToEdit ? 'Edit transformation' : 'Add transformation to correlation'} | ||||
|         </Button> | ||||
|       </Modal.ButtonRow> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | @ -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 ( | ||||
|     <Modal | ||||
|       isOpen={true} | ||||
|       title="Unsaved changes to correlation" | ||||
|       title={`Unsaved changes to correlation`} | ||||
|       onDismiss={onCancel} | ||||
|       icon="exclamation-triangle" | ||||
|       className={css({ width: '500px' })} | ||||
|       className={css({ width: '600px' })} | ||||
|     > | ||||
|       <h5>Do you want to save changes to this Correlation?</h5> | ||||
|       <h5>{message}</h5> | ||||
|       <Modal.ButtonRow> | ||||
|         <Button variant="secondary" onClick={onCancel} fill="outline"> | ||||
|           Cancel | ||||
|         </Button> | ||||
|         <Button variant="destructive" onClick={onDiscard}> | ||||
|           Discard correlation | ||||
|           Continue without saving | ||||
|         </Button> | ||||
|         <Button variant="primary" onClick={onSave}> | ||||
|           Save correlation | ||||
|  |  | |||
|  | @ -549,9 +549,9 @@ export class Explore extends React.PureComponent<Props, ExploreState> { | |||
| 
 | ||||
|     let correlationsBox = undefined; | ||||
|     const isCorrelationsEditorMode = correlationEditorDetails?.editorMode; | ||||
|     const showCorrelationHelper = Boolean(isCorrelationsEditorMode || correlationEditorDetails?.dirty); | ||||
|     const showCorrelationHelper = Boolean(isCorrelationsEditorMode || correlationEditorDetails?.correlationDirty); | ||||
|     if (showCorrelationHelper && correlationEditorHelperData !== undefined) { | ||||
|       correlationsBox = <CorrelationHelper correlations={correlationEditorHelperData} />; | ||||
|       correlationsBox = <CorrelationHelper exploreId={exploreId} correlations={correlationEditorHelperData} />; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|  |  | |||
|  | @ -115,7 +115,7 @@ export function ExploreToolbar({ | |||
|     if (!isCorrelationsEditorMode) { | ||||
|       dispatch(changeDatasource(exploreId, dsSettings.uid, { importQueries: true })); | ||||
|     } else { | ||||
|       if (correlationDetails?.dirty) { | ||||
|       if (correlationDetails?.correlationDirty || correlationDetails?.queryEditorDirty) { | ||||
|         // prompt will handle datasource change if needed
 | ||||
|         dispatch( | ||||
|           changeCorrelationEditorDetails({ | ||||
|  | @ -124,6 +124,7 @@ export function ExploreToolbar({ | |||
|               exploreId: exploreId, | ||||
|               action: CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE, | ||||
|               changeDatasourceUid: dsSettings.uid, | ||||
|               isActionLeft: isLeftPane, | ||||
|             }, | ||||
|           }) | ||||
|         ); | ||||
|  | @ -162,7 +163,7 @@ export function ExploreToolbar({ | |||
| 
 | ||||
|   const onCloseSplitView = () => { | ||||
|     if (isCorrelationsEditorMode) { | ||||
|       if (correlationDetails?.dirty) { | ||||
|       if (correlationDetails?.correlationDirty || correlationDetails?.queryEditorDirty) { | ||||
|         // if dirty, prompt
 | ||||
|         dispatch( | ||||
|           changeCorrelationEditorDetails({ | ||||
|  | @ -170,6 +171,7 @@ export function ExploreToolbar({ | |||
|             postConfirmAction: { | ||||
|               exploreId: exploreId, | ||||
|               action: CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE, | ||||
|               isActionLeft: isLeftPane, | ||||
|             }, | ||||
|           }) | ||||
|         ); | ||||
|  |  | |||
|  | @ -59,7 +59,7 @@ function setup(queries: DataQuery[]) { | |||
|         correlations: [], | ||||
|       }, | ||||
|     }, | ||||
|     correlationEditorDetails: { editorMode: false, dirty: false, isExiting: false }, | ||||
|     correlationEditorDetails: { editorMode: false, correlationDirty: false, queryEditorDirty: false, isExiting: false }, | ||||
|     syncedTimes: false, | ||||
|     richHistoryStorageFull: false, | ||||
|     richHistoryLimitExceededWarningShown: false, | ||||
|  |  | |||
|  | @ -0,0 +1,39 @@ | |||
| import { CORRELATION_EDITOR_POST_CONFIRM_ACTION } from 'app/types'; | ||||
| 
 | ||||
| import { showModalMessage } from './correlationEditLogic'; | ||||
| 
 | ||||
| // note, closing the editor does not care if isLeft is true or not. Both are covered for regression purposes.
 | ||||
| describe('correlationEditLogic', function () { | ||||
|   it.each` | ||||
|     action                                                      | isLeft   | dirCor   | dirQuer  | expected | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${false} | ${false} | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${false} | ${true}  | ${false} | ${'Closing the pane will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${false} | ${false} | ${true}  | ${'Closing the pane will lose the changed query. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${false} | ${true}  | ${true}  | ${'Closing the pane will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${true}  | ${false} | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${true}  | ${true}  | ${false} | ${'Closing the pane will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${true}  | ${false} | ${true}  | ${'Closing the pane will cause the query in the right pane to be re-ran and links added to that data. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE}        | ${true}  | ${true}  | ${true}  | ${'Closing the pane will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${false} | ${false} | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${false} | ${true}  | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${false} | ${false} | ${true}  | ${'Changing the datasource will lose the changed query. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${false} | ${true}  | ${true}  | ${'Changing the datasource will lose the changed query. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${true}  | ${false} | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${true}  | ${true}  | ${false} | ${'Changing the datasource will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${true}  | ${false} | ${true}  | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE} | ${true}  | ${true}  | ${true}  | ${'Changing the datasource will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${false} | ${false} | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${false} | ${true}  | ${false} | ${'Closing the editor will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${false} | ${false} | ${true}  | ${'Closing the editor will remove the variables, and your changed query may no longer be valid. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${false} | ${true}  | ${true}  | ${'Closing the editor will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${true}  | ${false} | ${false} | ${undefined} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${true}  | ${true}  | ${false} | ${'Closing the editor will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${true}  | ${false} | ${true}  | ${'Closing the editor will remove the variables, and your changed query may no longer be valid. Would you like to save before continuing?'} | ||||
|     ${CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR}      | ${true}  | ${true}  | ${true}  | ${'Closing the editor will cause the correlation in progress to be lost. Would you like to save before continuing?'} | ||||
|   `(
 | ||||
|     "Action $action, isLeft=$isLeft, dirtyCorrelation=$dirCor, dirtyQueryEditor=$dirQuer should return message '$expected'", | ||||
|     ({ action, isLeft, dirCor, dirQuer, expected }) => { | ||||
|       expect(showModalMessage(action, isLeft, dirCor, dirQuer)).toEqual(expected); | ||||
|     } | ||||
|   ); | ||||
| }); | ||||
|  | @ -0,0 +1,74 @@ | |||
| import { template } from 'lodash'; | ||||
| 
 | ||||
| import { CORRELATION_EDITOR_POST_CONFIRM_ACTION } from 'app/types'; | ||||
| 
 | ||||
| enum CONSEQUENCES { | ||||
|   SOURCE_TARGET_CHANGE = 'cause the query in the right pane to be re-ran and links added to that data', | ||||
|   FULL_QUERY_LOSS = 'lose the changed query', | ||||
|   FULL_CORR_LOSS = 'cause the correlation in progress to be lost', | ||||
|   INVALID_VAR = 'remove the variables, and your changed query may no longer be valid', | ||||
| } | ||||
| 
 | ||||
| // returns a string if the modal should show, with what the message string should be
 | ||||
| // returns undefined if the modal shouldn't show
 | ||||
| export const showModalMessage = ( | ||||
|   action: CORRELATION_EDITOR_POST_CONFIRM_ACTION, | ||||
|   isActionLeft: boolean, | ||||
|   dirtyCorrelation: boolean, | ||||
|   dirtyQueryEditor: boolean | ||||
| ) => { | ||||
|   const messageTemplate = template( | ||||
|     '<%= actionStr %> will <%= consequenceStr %>. Would you like to save before continuing?' | ||||
|   ); | ||||
|   let actionStr = ''; | ||||
|   let consequenceStr = ''; | ||||
| 
 | ||||
|   // dirty correlation message always takes priority over dirty query
 | ||||
|   if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) { | ||||
|     actionStr = 'Closing the pane'; | ||||
|     if (isActionLeft) { | ||||
|       if (dirtyCorrelation) { | ||||
|         consequenceStr = CONSEQUENCES.FULL_CORR_LOSS; | ||||
|       } else if (dirtyQueryEditor) { | ||||
|         consequenceStr = CONSEQUENCES.SOURCE_TARGET_CHANGE; | ||||
|       } else { | ||||
|         return undefined; | ||||
|       } | ||||
|     } else { | ||||
|       // right pane close
 | ||||
|       if (dirtyCorrelation) { | ||||
|         consequenceStr = CONSEQUENCES.FULL_CORR_LOSS; | ||||
|       } else if (dirtyQueryEditor) { | ||||
|         consequenceStr = CONSEQUENCES.FULL_QUERY_LOSS; | ||||
|       } else { | ||||
|         return undefined; | ||||
|       } | ||||
|     } | ||||
|   } else if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE) { | ||||
|     actionStr = 'Changing the datasource'; | ||||
|     if (isActionLeft) { | ||||
|       if (dirtyCorrelation) { | ||||
|         consequenceStr = CONSEQUENCES.FULL_CORR_LOSS; | ||||
|       } else { | ||||
|         return undefined; | ||||
|       } | ||||
|     } else { | ||||
|       // right datasource change
 | ||||
|       if (dirtyQueryEditor) { | ||||
|         consequenceStr = CONSEQUENCES.FULL_QUERY_LOSS; | ||||
|       } else { | ||||
|         return undefined; | ||||
|       } | ||||
|     } | ||||
|   } else if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR) { | ||||
|     actionStr = 'Closing the editor'; | ||||
|     if (dirtyCorrelation) { | ||||
|       consequenceStr = CONSEQUENCES.FULL_CORR_LOSS; | ||||
|     } else if (dirtyQueryEditor) { | ||||
|       consequenceStr = CONSEQUENCES.INVALID_VAR; | ||||
|     } else { | ||||
|       return undefined; | ||||
|     } | ||||
|   } | ||||
|   return messageTemplate({ actionStr, consequenceStr }); | ||||
| }; | ||||
|  | @ -1,11 +1,12 @@ | |||
| import { Observable } from 'rxjs'; | ||||
| 
 | ||||
| import { DataLinkTransformationConfig } from '@grafana/data'; | ||||
| import { getDataSourceSrv, reportInteraction } from '@grafana/runtime'; | ||||
| import { notifyApp } from 'app/core/actions'; | ||||
| import { createErrorNotification } from 'app/core/copy/appNotification'; | ||||
| import { CreateCorrelationParams } from 'app/features/correlations/types'; | ||||
| import { CorrelationData } from 'app/features/correlations/useCorrelations'; | ||||
| import { getCorrelationsBySourceUIDs, createCorrelation } from 'app/features/correlations/utils'; | ||||
| import { getCorrelationsBySourceUIDs, createCorrelation, generateDefaultLabel } from 'app/features/correlations/utils'; | ||||
| import { store } from 'app/store/store'; | ||||
| import { ThunkResult } from 'app/types'; | ||||
| 
 | ||||
|  | @ -50,7 +51,11 @@ function reloadCorrelations(exploreId: string): ThunkResult<Promise<void>> { | |||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function saveCurrentCorrelation(label?: string, description?: string): ThunkResult<Promise<void>> { | ||||
| export function saveCurrentCorrelation( | ||||
|   label?: string, | ||||
|   description?: string, | ||||
|   transformations?: DataLinkTransformationConfig[] | ||||
| ): ThunkResult<Promise<void>> { | ||||
|   return async (dispatch, getState) => { | ||||
|     const keys = Object.keys(getState().explore?.panes); | ||||
|     const sourcePane = getState().explore?.panes[keys[0]]; | ||||
|  | @ -74,12 +79,13 @@ export function saveCurrentCorrelation(label?: string, description?: string): Th | |||
|       const correlation: CreateCorrelationParams = { | ||||
|         sourceUID: sourceDatasource.uid, | ||||
|         targetUID: targetDatasource.uid, | ||||
|         label: label || `${sourceDatasource?.name} to ${targetDatasource.name}`, | ||||
|         label: label || (await generateDefaultLabel(sourcePane, targetPane)), | ||||
|         description, | ||||
|         config: { | ||||
|           field: targetPane.correlationEditorHelperData.resultField, | ||||
|           target: targetPane.queries[0], | ||||
|           type: 'query', | ||||
|           transformations: transformations, | ||||
|         }, | ||||
|       }; | ||||
|       await createCorrelation(sourceDatasource.uid, correlation) | ||||
|  |  | |||
|  | @ -149,7 +149,7 @@ const initialExploreItemState = makeExplorePaneState(); | |||
| export const initialExploreState: ExploreState = { | ||||
|   syncedTimes: false, | ||||
|   panes: {}, | ||||
|   correlationEditorDetails: { editorMode: false, dirty: false, isExiting: false }, | ||||
|   correlationEditorDetails: { editorMode: false, correlationDirty: false, queryEditorDirty: false, isExiting: false }, | ||||
|   richHistoryStorageFull: false, | ||||
|   richHistoryLimitExceededWarningShown: false, | ||||
|   largerExploreId: undefined, | ||||
|  | @ -263,7 +263,17 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): | |||
|   } | ||||
| 
 | ||||
|   if (changeCorrelationEditorDetails.match(action)) { | ||||
|     const { editorMode, label, description, canSave, dirty, isExiting, postConfirmAction } = action.payload; | ||||
|     const { | ||||
|       editorMode, | ||||
|       label, | ||||
|       description, | ||||
|       canSave, | ||||
|       correlationDirty, | ||||
|       queryEditorDirty, | ||||
|       isExiting, | ||||
|       postConfirmAction, | ||||
|       transformations, | ||||
|     } = action.payload; | ||||
|     return { | ||||
|       ...state, | ||||
|       correlationEditorDetails: { | ||||
|  | @ -271,7 +281,9 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): | |||
|         canSave: Boolean(canSave ?? state.correlationEditorDetails?.canSave), | ||||
|         label: label ?? state.correlationEditorDetails?.label, | ||||
|         description: description ?? state.correlationEditorDetails?.description, | ||||
|         dirty: Boolean(dirty ?? state.correlationEditorDetails?.dirty), | ||||
|         transformations: transformations ?? state.correlationEditorDetails?.transformations, | ||||
|         correlationDirty: Boolean(correlationDirty ?? state.correlationEditorDetails?.correlationDirty), | ||||
|         queryEditorDirty: Boolean(queryEditorDirty ?? state.correlationEditorDetails?.queryEditorDirty), | ||||
|         isExiting: Boolean(isExiting ?? state.correlationEditorDetails?.isExiting), | ||||
|         postConfirmAction, | ||||
|       }, | ||||
|  |  | |||
|  | @ -324,8 +324,8 @@ export const changeQueries = createAsyncThunk<void, ChangeQueriesPayload>( | |||
|     const isCorrelationsEditorMode = correlationDetails?.editorMode || false; | ||||
|     const isLeftPane = Object.keys(getState().explore.panes)[0] === exploreId; | ||||
| 
 | ||||
|     if (!isLeftPane && isCorrelationsEditorMode && !correlationDetails?.dirty) { | ||||
|       dispatch(changeCorrelationEditorDetails({ dirty: true })); | ||||
|     if (!isLeftPane && isCorrelationsEditorMode && !correlationDetails?.queryEditorDirty) { | ||||
|       dispatch(changeCorrelationEditorDetails({ queryEditorDirty: true })); | ||||
|     } | ||||
| 
 | ||||
|     for (const newQuery of queries) { | ||||
|  |  | |||
|  | @ -11,6 +11,9 @@ export const selectPanesEntries = createSelector< | |||
| >(selectPanes, Object.entries); | ||||
| 
 | ||||
| export const isSplit = createSelector(selectPanesEntries, (panes) => panes.length > 1); | ||||
| export const selectIsHelperShowing = createSelector(selectPanesEntries, (panes) => | ||||
|   panes.some((pane) => pane[1].correlationEditorHelperData !== undefined) | ||||
| ); | ||||
| 
 | ||||
| export const isLeftPaneSelector = (exploreId: string) => | ||||
|   createSelector(selectPanes, (panes) => { | ||||
|  |  | |||
|  | @ -128,7 +128,7 @@ export const decorateWithCorrelations = ({ | |||
|               datasourceName: defaultTargetDatasource.name, | ||||
|               query: { datasource: { uid: defaultTargetDatasource.uid } }, | ||||
|               meta: { | ||||
|                 correlationData: { resultField: field.name, vars: availableVars }, | ||||
|                 correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars }, | ||||
|               }, | ||||
|             }, | ||||
|           }); | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ import { | |||
|   SupplementaryQueryType, | ||||
|   UrlQueryMap, | ||||
|   ExploreCorrelationHelperData, | ||||
|   DataLinkTransformationConfig, | ||||
| } from '@grafana/data'; | ||||
| import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes'; | ||||
| 
 | ||||
|  | @ -27,21 +28,25 @@ export type ExploreQueryParams = UrlQueryMap; | |||
| export enum CORRELATION_EDITOR_POST_CONFIRM_ACTION { | ||||
|   CLOSE_PANE, | ||||
|   CHANGE_DATASOURCE, | ||||
|   CLOSE_EDITOR, | ||||
| } | ||||
| 
 | ||||
| export interface CorrelationEditorDetails { | ||||
|   editorMode: boolean; | ||||
|   dirty: boolean; | ||||
|   correlationDirty: boolean; | ||||
|   queryEditorDirty: boolean; | ||||
|   isExiting: boolean; | ||||
|   postConfirmAction?: { | ||||
|     // perform an action after a confirmation modal instead of exiting editor mode
 | ||||
|     exploreId: string; | ||||
|     action: CORRELATION_EDITOR_POST_CONFIRM_ACTION; | ||||
|     changeDatasourceUid?: string; | ||||
|     isActionLeft: boolean; | ||||
|   }; | ||||
|   canSave?: boolean; | ||||
|   label?: string; | ||||
|   description?: string; | ||||
|   transformations?: DataLinkTransformationConfig[]; | ||||
| } | ||||
| 
 | ||||
| // updates can have any properties
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue