mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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) {
|
||||
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
|
||||
|
@ -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',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
39
public/app/features/explore/correlationEditLogic.test.ts
Normal file
39
public/app/features/explore/correlationEditLogic.test.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
});
|
74
public/app/features/explore/correlationEditLogic.ts
Normal file
74
public/app/features/explore/correlationEditLogic.ts
Normal 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 });
|
||||
};
|
@ -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
Block a user