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:
Kristina 2023-11-02 15:09:30 -05:00 committed by GitHub
parent a2629f3dd3
commit 9e0ca0d113
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 844 additions and 174 deletions

View File

@ -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>;
}

View File

@ -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);

View File

@ -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,
};
});
};

View File

@ -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,
};
});
};

View File

@ -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}`
: '';
});
};

View File

@ -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) {
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 && (
<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

View File

@ -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<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 (
<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:
<pre>
{Object.entries(correlations.vars).map((entry) => {
return `\$\{${entry[0]}\} = ${entry[1]}\n`;
})}
</pre>
<Collapse
collapsible
isOpen={isOpen}
onToggle={() => {
setIsOpen(!isOpen);
}}
label="Label/Description"
>
<Field label="Label" htmlFor={`${id}-label`}>
<Input {...register('label')} id={`${id}-label`} />
</Field>
<Field label="Description" htmlFor={`${id}-description`}>
<Input {...register('description')} id={`${id}-description`} />
</Field>
</Collapse>
</Alert>
<>
{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:
<pre>
{Object.entries(correlations.vars).map((entry) => {
return `\$\{${entry[0]}\} = ${entry[1]}\n`;
})}
</pre>
<Collapse
collapsible
isOpen={isLabelDescOpen}
onToggle={() => {
setIsLabelDescOpen(!isLabelDescOpen);
}}
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`}
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',
}),
};
};

View File

@ -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>
);
};

View File

@ -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

View File

@ -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 (

View File

@ -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,
},
})
);

View File

@ -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,

View File

@ -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);
}
);
});

View File

@ -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 });
};

View File

@ -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)

View File

@ -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,
},

View File

@ -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) {

View File

@ -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) => {

View File

@ -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 },
},
},
});

View File

@ -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