Transformation redesign (#70834)

Transformation redesign
This commit is contained in:
Ludovic Viaud
2023-07-12 18:35:49 +02:00
committed by GitHub
parent ee28e9320c
commit 5099e88227
14 changed files with 475 additions and 63 deletions

View File

@@ -28,8 +28,13 @@ export const PanelEditorTabs = React.memo(({ panel, dashboard, tabs, onChangeTab
const instrumentedOnChangeTab = useCallback(
(tab: PanelEditorTab) => {
let eventName = 'panel_editor_tabs_changed';
if (config.featureToggles.transformationsRedesign) {
eventName = 'transformations_redesign_' + eventName;
}
if (!tab.active) {
reportInteraction('panel_editor_tabs_changed', { tab_id: tab.id });
reportInteraction(eventName, { tab_id: tab.id });
}
onChangeTab(tab);

View File

@@ -74,12 +74,12 @@ export const TransformationEditor = ({
);
return (
<div className={styles.editor} aria-label={selectors.components.TransformTab.transformationEditor(uiConfig.name)}>
<div className={styles.editor} data-testid={selectors.components.TransformTab.transformationEditor(uiConfig.name)}>
{editor}
{debugMode && (
<div
className={styles.debugWrapper}
aria-label={selectors.components.TransformTab.transformationEditorDebugger(uiConfig.name)}
data-testid={selectors.components.TransformTab.transformationEditorDebugger(uiConfig.name)}
>
<div className={styles.debug}>
<div className={styles.debugTitle}>Transformation input data</div>

View File

@@ -3,7 +3,7 @@ import { useToggle } from 'react-use';
import { DataFrame, DataTransformerConfig, TransformerRegistryItem, FrameMatcherID } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { HorizontalGroup } from '@grafana/ui';
import { ConfirmModal, HorizontalGroup } from '@grafana/ui';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import {
QueryOperationAction,
@@ -13,6 +13,7 @@ import {
QueryOperationRow,
QueryOperationRowRenderProps,
} from 'app/core/components/QueryOperationRow/QueryOperationRow';
import config from 'app/core/config';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { TransformationEditor } from './TransformationEditor';
@@ -38,6 +39,7 @@ export const TransformationOperationRow = ({
uiConfig,
onChange,
}: TransformationOperationRowProps) => {
const [showDeleteModal, setShowDeleteModal] = useToggle(false);
const [showDebug, toggleDebug] = useToggle(false);
const [showHelp, toggleHelp] = useToggle(false);
const disabled = !!configs[index].transformation.disabled;
@@ -73,7 +75,12 @@ export const TransformationOperationRow = ({
const instrumentToggleCallback = useCallback(
(callback: (e: React.MouseEvent) => void, toggleId: string, active: boolean | undefined) =>
(e: React.MouseEvent) => {
reportInteraction('panel_editor_tabs_transformations_toggle', {
let eventName = 'panel_editor_tabs_transformations_toggle';
if (config.featureToggles.transformationsRedesign) {
eventName = 'transformations_redesign_' + eventName;
}
reportInteraction(eventName, {
action: active ? 'off' : 'on',
toggleId,
transformationId: configs[index].transformation.id,
@@ -115,7 +122,25 @@ export const TransformationOperationRow = ({
onClick={instrumentToggleCallback(() => onDisableToggle(index), 'disabled', disabled)}
active={disabled}
/>
<QueryOperationAction title="Remove" icon="trash-alt" onClick={() => onRemove(index)} />
<QueryOperationAction
title="Remove"
icon="trash-alt"
onClick={() => (config.featureToggles.transformationsRedesign ? setShowDeleteModal(true) : onRemove(index))}
/>
{config.featureToggles.transformationsRedesign && (
<ConfirmModal
isOpen={showDeleteModal}
title={`Delete ${uiConfig.name}?`}
body="Note that removing one transformation may break others. If there is only a single transformation, you will go back to the main selection screen."
confirmText="Delete"
onConfirm={() => {
setShowDeleteModal(false);
onRemove(index);
}}
onDismiss={() => setShowDeleteModal(false)}
/>
)}
</HorizontalGroup>
);
};

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import config from 'app/core/config';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { PanelModel } from '../../state';
@@ -20,30 +21,43 @@ describe('TransformationsEditor', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
describe('when no transformations configured', () => {
it('renders transformations selection list', () => {
function renderList() {
setup();
const cards = screen.getAllByLabelText(/^New transform/i);
const cards = screen.getAllByTestId(/New transform/i);
expect(cards.length).toEqual(standardTransformersRegistry.list().length);
}
it('renders transformations selection list', renderList);
it('renders transformations selection list with transformationsRedesign feature toggled on', () => {
config.featureToggles.transformationsRedesign = true;
renderList();
config.featureToggles.transformationsRedesign = false;
});
});
describe('when transformations configured', () => {
it('renders transformation editors', () => {
function renderEditors() {
setup([
{
id: 'reduce',
options: {},
},
]);
const editors = screen.getAllByLabelText(/^Transformation editor/);
const editors = screen.getAllByTestId(/Transformation editor/);
expect(editors).toHaveLength(1);
}
it('renders transformation editors', renderEditors);
it('renders transformation editors with transformationsRedesign feature toggled on', () => {
config.featureToggles.transformationsRedesign = true;
renderEditors();
config.featureToggles.transformationsRedesign = false;
});
});
describe('when Add transformation clicked', () => {
it('renders transformations picker', async () => {
const buttonLabel = 'Add transformation';
async function renderPicker() {
setup([
{
id: 'reduce',
@@ -51,17 +65,24 @@ describe('TransformationsEditor', () => {
},
]);
const addTransformationButton = screen.getByText(buttonLabel);
const addTransformationButton = screen.getByTestId(selectors.components.Transforms.addTransformationButton);
await userEvent.click(addTransformationButton);
const search = screen.getByLabelText(selectors.components.Transforms.searchInput);
const search = screen.getByTestId(selectors.components.Transforms.searchInput);
expect(search).toBeDefined();
}
it('renders transformations picker', renderPicker);
it('renders transformation picker with transformationsRedesign feature toggled on', async () => {
config.featureToggles.transformationsRedesign = true;
await renderPicker();
config.featureToggles.transformationsRedesign = false;
});
});
describe('actions', () => {
describe('debug', () => {
it('should show/hide debugger', async () => {
async function showHideDebugger() {
setup([
{
id: 'reduce',
@@ -70,12 +91,19 @@ describe('TransformationsEditor', () => {
]);
const debuggerSelector = selectors.components.TransformTab.transformationEditorDebugger('Reduce');
expect(screen.queryByLabelText(debuggerSelector)).toBeNull();
expect(screen.queryByTestId(debuggerSelector)).toBeNull();
const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug'));
await userEvent.click(debugButton);
expect(screen.getByLabelText(debuggerSelector)).toBeInTheDocument();
expect(screen.getByTestId(debuggerSelector)).toBeInTheDocument();
}
it('should show/hide debugger', showHideDebugger);
it('renders transformation editors with transformationsRedesign feature toggled on', async () => {
config.featureToggles.transformationsRedesign = true;
await showHideDebugger();
config.featureToggles.transformationsRedesign = false;
});
});
});

View File

@@ -12,25 +12,33 @@ import {
SelectableValue,
standardTransformersRegistry,
TransformerRegistryItem,
TransformerCategory,
DataTransformerID,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import {
Alert,
Button,
ConfirmModal,
Container,
CustomScrollbar,
FilterPill,
Themeable,
VerticalGroup,
withTheme,
Input,
Icon,
IconButton,
useStyles2,
Card,
Switch,
} from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import config from 'app/core/config';
import { getDocsLink } from 'app/core/utils/docsLinks';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { categoriesLabels } from 'app/features/transformers/utils';
import { AppNotificationSeverity } from '../../../../types';
import { PanelModel } from '../../state';
@@ -45,11 +53,26 @@ interface TransformationsEditorProps extends Themeable {
panel: PanelModel;
}
type viewAllType = 'viewAll';
const viewAllValue = 'viewAll';
const viewAllLabel = 'View all';
type FilterCategory = TransformerCategory | viewAllType;
const filterCategoriesLabels: Array<[FilterCategory, string]> = [
[viewAllValue, viewAllLabel],
...(Object.entries(categoriesLabels) as Array<[FilterCategory, string]>),
];
interface State {
data: DataFrame[];
transformations: TransformationsEditorTransformation[];
search: string;
showPicker?: boolean;
scrollTop?: number;
showRemoveAllModal?: boolean;
selectedFilter?: FilterCategory;
showIllustrations?: boolean;
}
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
@@ -67,6 +90,8 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
})),
data: [],
search: '',
selectedFilter: viewAllValue,
showIllustrations: true,
};
}
@@ -125,6 +150,25 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
}
}
componentDidUpdate(prevProps: Readonly<TransformationsEditorProps>, prevState: Readonly<State>): void {
if (config.featureToggles.transformationsRedesign) {
const prevHasTransforms = prevState.transformations.length > 0;
const prevShowPicker = !prevHasTransforms || prevState.showPicker;
const currentHasTransforms = this.state.transformations.length > 0;
const currentShowPicker = !currentHasTransforms || this.state.showPicker;
if (prevShowPicker !== currentShowPicker) {
// kindOfZero will be a random number between 0 and 0.5. It will be rounded to 0 by the scrollable component.
// We cannot always use 0 as it will not trigger a rerender of the scrollable component consistently
// due to React changes detection algo.
const kindOfZero = Math.random() / 2;
this.setState({ scrollTop: currentShowPicker ? kindOfZero : Number.MAX_SAFE_INTEGER });
}
}
}
onChange(transformations: TransformationsEditorTransformation[]) {
this.setState({ transformations });
this.props.panel.setTransformations(transformations.map((t) => t.transformation));
@@ -145,7 +189,12 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
};
onTransformationAdd = (selectable: SelectableValue<string>) => {
reportInteraction('panel_editor_tabs_transformations_management', {
let eventName = 'panel_editor_tabs_transformations_management';
if (config.featureToggles.transformationsRedesign) {
eventName = 'transformations_redesign_' + eventName;
}
reportInteraction(eventName, {
action: 'add',
transformationId: selectable.value,
});
@@ -165,21 +214,31 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
]);
};
onTransformationChange = (idx: number, config: DataTransformerConfig) => {
onTransformationChange = (idx: number, dataConfig: DataTransformerConfig) => {
const { transformations } = this.state;
const next = Array.from(transformations);
reportInteraction('panel_editor_tabs_transformations_management', {
let eventName = 'panel_editor_tabs_transformations_management';
if (config.featureToggles.transformationsRedesign) {
eventName = 'transformations_redesign_' + eventName;
}
reportInteraction(eventName, {
action: 'change',
transformationId: next[idx].transformation.id,
});
next[idx].transformation = config;
next[idx].transformation = dataConfig;
this.onChange(next);
};
onTransformationRemove = (idx: number) => {
const { transformations } = this.state;
const next = Array.from(transformations);
reportInteraction('panel_editor_tabs_transformations_management', {
let eventName = 'panel_editor_tabs_transformations_management';
if (config.featureToggles.transformationsRedesign) {
eventName = 'transformations_redesign_' + eventName;
}
reportInteraction(eventName, {
action: 'remove',
transformationId: next[idx].transformation.id,
});
@@ -187,6 +246,11 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
this.onChange(next);
};
onTransformationRemoveAll = () => {
this.onChange([]);
this.setState({ showRemoveAllModal: false });
};
onDragEnd = (result: DropResult) => {
const { transformations } = this.state;
@@ -230,10 +294,20 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
};
renderTransformsPicker() {
const styles = getStyles(config.theme2);
const { transformations, search } = this.state;
let suffix: React.ReactNode = null;
let xforms = standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0));
if (this.state.selectedFilter !== viewAllValue) {
xforms = xforms.filter(
(t) =>
t.categories &&
this.state.selectedFilter &&
t.categories.has(this.state.selectedFilter as TransformerCategory)
);
}
if (search) {
const lower = search.toLowerCase();
const filtered = xforms.filter((t) => {
@@ -272,6 +346,107 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
);
}
const Picker = () => (
<>
{config.featureToggles.transformationsRedesign && (
<>
{!noTransforms && (
<Button
variant="secondary"
fill="text"
icon="angle-left"
onClick={() => {
this.setState({ showPicker: false });
}}
>
Go back to&nbsp;<i>Transformations in use</i>
</Button>
)}
<div className={styles.pickerInformationLine}>
<a href={getDocsLink(DocsId.Transformations)} className="external-link" target="_blank" rel="noreferrer">
<span className={styles.pickerInformationLineHighlight}>Transformations</span>{' '}
<Icon name="external-link-alt" />
</a>
&nbsp;allow you to manipulate your data before a visualization is applied.
</div>
</>
)}
<VerticalGroup>
{!config.featureToggles.transformationsRedesign && (
<Input
data-testid={selectors.components.Transforms.searchInput}
value={search ?? ''}
autoFocus={!noTransforms}
placeholder="Search for transformation"
onChange={this.onSearchChange}
onKeyDown={this.onSearchKeyDown}
suffix={suffix}
/>
)}
{!config.featureToggles.transformationsRedesign &&
xforms.map((t) => {
return (
<TransformationCard
key={t.name}
transform={t}
onClick={() => {
this.onTransformationAdd({ value: t.id });
}}
/>
);
})}
{config.featureToggles.transformationsRedesign && (
<div className={styles.searchWrapper}>
<Input
data-testid={selectors.components.Transforms.searchInput}
className={styles.searchInput}
value={search ?? ''}
autoFocus={!noTransforms}
placeholder="Search for transformation"
onChange={this.onSearchChange}
onKeyDown={this.onSearchKeyDown}
suffix={suffix}
/>
<div className={styles.showImages}>
<span className={styles.illustationSwitchLabel}>Show images</span>{' '}
<Switch
value={this.state.showIllustrations}
onChange={() => this.setState({ showIllustrations: !this.state.showIllustrations })}
/>
</div>
</div>
)}
{config.featureToggles.transformationsRedesign && (
<div className={styles.filterWrapper}>
{filterCategoriesLabels.map(([slug, label]) => {
return (
<FilterPill
key={slug}
onClick={() => this.setState({ selectedFilter: slug })}
label={label}
selected={this.state.selectedFilter === slug}
/>
);
})}
</div>
)}
{config.featureToggles.transformationsRedesign && (
<TransformationsGrid
showIllustrations={this.state.showIllustrations}
transformations={xforms}
onClick={(id) => {
this.onTransformationAdd({ value: id });
}}
/>
)}
</VerticalGroup>
</>
);
return (
<>
{noTransforms && (
@@ -312,29 +487,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
</Container>
)}
{showPicker ? (
<VerticalGroup>
<Input
aria-label={selectors.components.Transforms.searchInput}
value={search ?? ''}
autoFocus={!noTransforms}
placeholder="Add transformation"
onChange={this.onSearchChange}
onKeyDown={this.onSearchKeyDown}
suffix={suffix}
/>
{xforms.map((t) => {
return (
<TransformationCard
key={t.name}
transform={t}
onClick={() => {
this.onTransformationAdd({ value: t.id });
}}
/>
);
})}
</VerticalGroup>
<Picker />
) : (
<Button
icon="plus"
@@ -342,8 +495,9 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
onClick={() => {
this.setState({ showPicker: true });
}}
data-testid={selectors.components.Transforms.addTransformationButton}
>
Add transformation
Add{config.featureToggles.transformationsRedesign ? ' another ' : ' '}transformation
</Button>
)}
</>
@@ -351,6 +505,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
}
render() {
const styles = getStyles(config.theme2);
const {
panel: { alert },
} = this.props;
@@ -363,16 +518,40 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
}
return (
<CustomScrollbar autoHeightMin="100%">
<Container padding="md">
<div aria-label={selectors.components.TransformTab.content}>
<CustomScrollbar scrollTop={this.state.scrollTop} autoHeightMin="100%">
<Container padding="lg">
<div data-testid={selectors.components.TransformTab.content}>
{hasTransforms && alert ? (
<Alert
severity={AppNotificationSeverity.Error}
title="Transformations can't be used on a panel with alerts"
/>
) : null}
{hasTransforms && this.renderTransformationEditors()}
{hasTransforms && config.featureToggles.transformationsRedesign && !this.state.showPicker && (
<div className={styles.listInformationLineWrapper}>
<span className={styles.listInformationLineText}>Transformations in use</span>{' '}
<Button
size="sm"
variant="secondary"
onClick={() => {
this.setState({ showRemoveAllModal: true });
}}
>
Delete all transformations
</Button>
<ConfirmModal
isOpen={Boolean(this.state.showRemoveAllModal)}
title="Delete all transformations?"
body="By deleting all transformations, you will go back to the main selection screen."
confirmText="Delete all"
onConfirm={() => this.onTransformationRemoveAll()}
onDismiss={() => this.setState({ showRemoveAllModal: false })}
/>
</div>
)}
{hasTransforms &&
(!config.featureToggles.transformationsRedesign || !this.state.showPicker) &&
this.renderTransformationEditors()}
{this.renderTransformsPicker()}
</div>
</Container>
@@ -391,7 +570,7 @@ function TransformationCard({ transform, onClick }: TransformationCardProps) {
return (
<Card
className={styles.card}
aria-label={selectors.components.TransformTab.newTransform(transform.name)}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={onClick}
>
<Card.Heading>{transform.name}</Card.Heading>
@@ -411,7 +590,159 @@ const getStyles = (theme: GrafanaTheme2) => {
margin: 0;
padding: ${theme.spacing(1)};
`,
grid: css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: 1fr;
gap: ${theme.spacing(2)} ${theme.spacing(1)};
width: 100%;
`,
newCard: css`
grid-template-rows: min-content 0 1fr 0;
`,
badge: css`
padding: 4px 3px;
`,
heading: css`
font-weight: 400;
> button {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: no-wrap;
}
`,
description: css`
font-size: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
`,
image: css`
display: block;
max-width: 100%;
margin-top: ${theme.spacing(2)};
`,
searchWrapper: css`
display: flex;
flex-wrap: wrap;
column-gap: 27px;
row-gap: 16px;
width: 100%;
`,
searchInput: css`
flex-grow: 1;
width: initial;
`,
showImages: css`
flex-basis: 0;
display: flex;
gap: 8px;
align-items: center;
`,
pickerInformationLine: css`
font-size: 16px;
margin-bottom: ${theme.spacing(2)};
`,
pickerInformationLineHighlight: css`
vertical-align: middle;
`,
illustationSwitchLabel: css`
white-space: nowrap;
`,
filterWrapper: css`
padding: ${theme.spacing(1)} 0;
display: flex;
flex-wrap: wrap;
row-gap: ${theme.spacing(1)};
column-gap: ${theme.spacing(0.5)};
`,
listInformationLineWrapper: css`
display: flex;
justify-content: space-between;
margin-bottom: 24px;
`,
listInformationLineText: css`
font-size: 16px;
`,
pluginStateInfoWrapper: css`
margin-left: 5px;
`,
};
};
interface TransformationsGridProps {
transformations: Array<TransformerRegistryItem<any>>;
showIllustrations?: boolean;
onClick: (id: string) => void;
}
function TransformationsGrid({ showIllustrations, transformations, onClick }: TransformationsGridProps) {
const styles = useStyles2(getStyles);
return (
<div className={styles.grid}>
{transformations.map((transform) => (
<Card
key={transform.id}
className={styles.newCard}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
>
<Card.Heading className={styles.heading}>
<>
<span>{transform.name}</span>
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo className={styles.badge} state={transform.state} />
</span>
</>
</Card.Heading>
<Card.Description className={styles.description}>
<>
<span>{getTransformationsRedesignDescriptions(transform.id)}</span>
{showIllustrations && (
<span>
<img className={styles.image} src={getImagePath(transform.id)} alt={transform.name} />
</span>
)}
</>
</Card.Description>
</Card>
))}
</div>
);
}
const getImagePath = (id: string) => {
const folder = config.theme2.isDark ? 'dark' : 'light';
return `public/img/transformations/${folder}/${id}.svg`;
};
const getTransformationsRedesignDescriptions = (id: string): string => {
const overrides: { [key: string]: string } = {
[DataTransformerID.concatenate]: 'Combine all fields into a single frame.',
[DataTransformerID.configFromData]: 'Set unit, min, max and more.',
[DataTransformerID.fieldLookup]: 'Use a field value to lookup countries, states, or airports.',
[DataTransformerID.filterFieldsByName]: 'Removes part of the query results using a regex pattern.',
[DataTransformerID.filterByRefId]: 'Filter out queries in panels that have multiple queries.',
[DataTransformerID.filterByValue]: 'Removes rows of the query results using user-defined filters.',
[DataTransformerID.groupBy]: 'Group the data by a field value then process calculations.',
[DataTransformerID.groupingToMatrix]: 'Summarizes and reorganizes data based on three fields.',
[DataTransformerID.joinByField]: 'Combine rows from 2+ tables, based on a related field.',
[DataTransformerID.labelsToFields]: 'Groups series by time and return labels or tags as fields.',
[DataTransformerID.merge]: 'Merge multiple series. Values will be combined into one row.',
[DataTransformerID.organize]: 'Allows the user to re-order, hide, or rename fields / columns.',
[DataTransformerID.partitionByValues]: 'Splits a one-frame dataset into multiple series.',
[DataTransformerID.prepareTimeSeries]: 'Will stretch data frames from the wide format into the long format.',
[DataTransformerID.reduce]: 'Reduce all rows or data points to a single value (ex. max, mean).',
[DataTransformerID.renameByRegex]: 'Reduce all rows or data points to a single value (ex. max, mean).',
[DataTransformerID.seriesToRows]: 'Merge multiple series. Return time, metric and values as a row.',
};
return overrides[id] || standardTransformersRegistry.getIfExists(id)?.description || '';
};
export const TransformationsEditor = withTheme(UnThemedTransformationsEditor);