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

@@ -2178,16 +2178,14 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
"public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx:5381": [ "public/app/features/dashboard/components/TransformationsEditor/TransformationEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"]
], ],
"public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx:5381": [ "public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"], [0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"], [0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"] [0, 0, 0, "Unexpected any. Specify a different type.", "4"]
], ],
"public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx:5381": [ "public/app/features/dashboard/components/VersionHistory/useDashboardRestore.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "0"],

View File

@@ -124,6 +124,7 @@ Experimental features might be changed or removed without prior notice.
| `prometheusIncrementalQueryInstrumentation` | Adds RudderStack events to incremental queries | | `prometheusIncrementalQueryInstrumentation` | Adds RudderStack events to incremental queries |
| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | | `logsExploreTableVisualisation` | A table visualisation for logs in Explore |
| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | | `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers |
| `transformationsRedesign` | Enables the transformations redesign |
## Development feature toggles ## Development feature toggles

View File

@@ -111,4 +111,5 @@ export interface FeatureToggles {
prometheusIncrementalQueryInstrumentation?: boolean; prometheusIncrementalQueryInstrumentation?: boolean;
logsExploreTableVisualisation?: boolean; logsExploreTableVisualisation?: boolean;
awsDatasourcesTempCredentials?: boolean; awsDatasourcesTempCredentials?: boolean;
transformationsRedesign?: boolean;
} }

View File

@@ -208,13 +208,13 @@ export const Components = {
alertV2: (severity: string) => `data-testid Alert ${severity}`, alertV2: (severity: string) => `data-testid Alert ${severity}`,
}, },
TransformTab: { TransformTab: {
content: 'Transform editor tab content', content: 'data-testid Transform editor tab content',
newTransform: (name: string) => `New transform ${name}`, newTransform: (name: string) => `data-testid New transform ${name}`,
transformationEditor: (name: string) => `Transformation editor ${name}`, transformationEditor: (name: string) => `data-testid Transformation editor ${name}`,
transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`, transformationEditorDebugger: (name: string) => `data-testid Transformation editor debugger ${name}`,
}, },
Transforms: { Transforms: {
card: (name: string) => `New transform ${name}`, card: (name: string) => `data-testid New transform ${name}`,
Reduce: { Reduce: {
modeLabel: 'Transform mode label', modeLabel: 'Transform mode label',
calculationsLabel: 'Transform calculations label', calculationsLabel: 'Transform calculations label',
@@ -241,6 +241,7 @@ export const Components = {
}, },
}, },
searchInput: 'search transformations', searchInput: 'search transformations',
addTransformationButton: 'data-testid add transformation button',
}, },
NavBar: { NavBar: {
Configuration: { Configuration: {

View File

@@ -41,6 +41,7 @@ const getStyles = (theme: GrafanaTheme2) => {
height: 32px; height: 32px;
position: relative; position: relative;
border: 1px solid ${theme.colors.background.secondary}; border: 1px solid ${theme.colors.background.secondary};
white-space: nowrap;
&:hover { &:hover {
background: ${theme.colors.action.hover}; background: ${theme.colors.action.hover};

View File

@@ -634,5 +634,12 @@ var (
Stage: FeatureStageExperimental, Stage: FeatureStageExperimental,
Owner: awsDatasourcesSquad, Owner: awsDatasourcesSquad,
}, },
{
Name: "transformationsRedesign",
Description: "Enables the transformations redesign",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
} }
) )

View File

@@ -92,3 +92,4 @@ vizAndWidgetSplit,experimental,@grafana/dashboards-squad,false,false,false,true
prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,false,false,false,true prometheusIncrementalQueryInstrumentation,experimental,@grafana/observability-metrics,false,false,false,true
logsExploreTableVisualisation,experimental,@grafana/observability-logs,false,false,false,true logsExploreTableVisualisation,experimental,@grafana/observability-logs,false,false,false,true
awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false,false awsDatasourcesTempCredentials,experimental,@grafana/aws-datasources,false,false,false,false
transformationsRedesign,experimental,@grafana/observability-metrics,false,false,false,true
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
92 prometheusIncrementalQueryInstrumentation experimental @grafana/observability-metrics false false false true
93 logsExploreTableVisualisation experimental @grafana/observability-logs false false false true
94 awsDatasourcesTempCredentials experimental @grafana/aws-datasources false false false false
95 transformationsRedesign experimental @grafana/observability-metrics false false false true

View File

@@ -378,4 +378,8 @@ const (
// FlagAwsDatasourcesTempCredentials // FlagAwsDatasourcesTempCredentials
// Support temporary security credentials in AWS plugins for Grafana Cloud customers // Support temporary security credentials in AWS plugins for Grafana Cloud customers
FlagAwsDatasourcesTempCredentials = "awsDatasourcesTempCredentials" FlagAwsDatasourcesTempCredentials = "awsDatasourcesTempCredentials"
// FlagTransformationsRedesign
// Enables the transformations redesign
FlagTransformationsRedesign = "transformationsRedesign"
) )

View File

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

View File

@@ -74,12 +74,12 @@ export const TransformationEditor = ({
); );
return ( 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} {editor}
{debugMode && ( {debugMode && (
<div <div
className={styles.debugWrapper} 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.debug}>
<div className={styles.debugTitle}>Transformation input data</div> <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 { DataFrame, DataTransformerConfig, TransformerRegistryItem, FrameMatcherID } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime'; 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 { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import { import {
QueryOperationAction, QueryOperationAction,
@@ -13,6 +13,7 @@ import {
QueryOperationRow, QueryOperationRow,
QueryOperationRowRenderProps, QueryOperationRowRenderProps,
} from 'app/core/components/QueryOperationRow/QueryOperationRow'; } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import config from 'app/core/config';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { TransformationEditor } from './TransformationEditor'; import { TransformationEditor } from './TransformationEditor';
@@ -38,6 +39,7 @@ export const TransformationOperationRow = ({
uiConfig, uiConfig,
onChange, onChange,
}: TransformationOperationRowProps) => { }: TransformationOperationRowProps) => {
const [showDeleteModal, setShowDeleteModal] = useToggle(false);
const [showDebug, toggleDebug] = useToggle(false); const [showDebug, toggleDebug] = useToggle(false);
const [showHelp, toggleHelp] = useToggle(false); const [showHelp, toggleHelp] = useToggle(false);
const disabled = !!configs[index].transformation.disabled; const disabled = !!configs[index].transformation.disabled;
@@ -73,7 +75,12 @@ export const TransformationOperationRow = ({
const instrumentToggleCallback = useCallback( const instrumentToggleCallback = useCallback(
(callback: (e: React.MouseEvent) => void, toggleId: string, active: boolean | undefined) => (callback: (e: React.MouseEvent) => void, toggleId: string, active: boolean | undefined) =>
(e: React.MouseEvent) => { (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', action: active ? 'off' : 'on',
toggleId, toggleId,
transformationId: configs[index].transformation.id, transformationId: configs[index].transformation.id,
@@ -115,7 +122,25 @@ export const TransformationOperationRow = ({
onClick={instrumentToggleCallback(() => onDisableToggle(index), 'disabled', disabled)} onClick={instrumentToggleCallback(() => onDisableToggle(index), 'disabled', disabled)}
active={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> </HorizontalGroup>
); );
}; };

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data'; import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import config from 'app/core/config';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { PanelModel } from '../../state'; import { PanelModel } from '../../state';
@@ -20,30 +21,43 @@ describe('TransformationsEditor', () => {
standardTransformersRegistry.setInit(getStandardTransformers); standardTransformersRegistry.setInit(getStandardTransformers);
describe('when no transformations configured', () => { describe('when no transformations configured', () => {
it('renders transformations selection list', () => { function renderList() {
setup(); setup();
const cards = screen.getAllByLabelText(/^New transform/i); const cards = screen.getAllByTestId(/New transform/i);
expect(cards.length).toEqual(standardTransformersRegistry.list().length); 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', () => { describe('when transformations configured', () => {
it('renders transformation editors', () => { function renderEditors() {
setup([ setup([
{ {
id: 'reduce', id: 'reduce',
options: {}, options: {},
}, },
]); ]);
const editors = screen.getAllByLabelText(/^Transformation editor/); const editors = screen.getAllByTestId(/Transformation editor/);
expect(editors).toHaveLength(1); 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', () => { describe('when Add transformation clicked', () => {
it('renders transformations picker', async () => { async function renderPicker() {
const buttonLabel = 'Add transformation';
setup([ setup([
{ {
id: 'reduce', 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); await userEvent.click(addTransformationButton);
const search = screen.getByLabelText(selectors.components.Transforms.searchInput); const search = screen.getByTestId(selectors.components.Transforms.searchInput);
expect(search).toBeDefined(); 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('actions', () => {
describe('debug', () => { describe('debug', () => {
it('should show/hide debugger', async () => { async function showHideDebugger() {
setup([ setup([
{ {
id: 'reduce', id: 'reduce',
@@ -70,12 +91,19 @@ describe('TransformationsEditor', () => {
]); ]);
const debuggerSelector = selectors.components.TransformTab.transformationEditorDebugger('Reduce'); 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')); const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug'));
await userEvent.click(debugButton); 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, SelectableValue,
standardTransformersRegistry, standardTransformersRegistry,
TransformerRegistryItem, TransformerRegistryItem,
TransformerCategory,
DataTransformerID,
} from '@grafana/data'; } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { import {
Alert, Alert,
Button, Button,
ConfirmModal,
Container, Container,
CustomScrollbar, CustomScrollbar,
FilterPill,
Themeable, Themeable,
VerticalGroup, VerticalGroup,
withTheme, withTheme,
Input, Input,
Icon,
IconButton, IconButton,
useStyles2, useStyles2,
Card, Card,
Switch,
} from '@grafana/ui'; } from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import config from 'app/core/config';
import { getDocsLink } from 'app/core/utils/docsLinks'; import { getDocsLink } from 'app/core/utils/docsLinks';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { categoriesLabels } from 'app/features/transformers/utils';
import { AppNotificationSeverity } from '../../../../types'; import { AppNotificationSeverity } from '../../../../types';
import { PanelModel } from '../../state'; import { PanelModel } from '../../state';
@@ -45,11 +53,26 @@ interface TransformationsEditorProps extends Themeable {
panel: PanelModel; 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 { interface State {
data: DataFrame[]; data: DataFrame[];
transformations: TransformationsEditorTransformation[]; transformations: TransformationsEditorTransformation[];
search: string; search: string;
showPicker?: boolean; showPicker?: boolean;
scrollTop?: number;
showRemoveAllModal?: boolean;
selectedFilter?: FilterCategory;
showIllustrations?: boolean;
} }
class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> { class UnThemedTransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
@@ -67,6 +90,8 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
})), })),
data: [], data: [],
search: '', 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[]) { onChange(transformations: TransformationsEditorTransformation[]) {
this.setState({ transformations }); this.setState({ transformations });
this.props.panel.setTransformations(transformations.map((t) => t.transformation)); this.props.panel.setTransformations(transformations.map((t) => t.transformation));
@@ -145,7 +189,12 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
}; };
onTransformationAdd = (selectable: SelectableValue<string>) => { 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', action: 'add',
transformationId: selectable.value, 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 { transformations } = this.state;
const next = Array.from(transformations); 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', action: 'change',
transformationId: next[idx].transformation.id, transformationId: next[idx].transformation.id,
}); });
next[idx].transformation = config; next[idx].transformation = dataConfig;
this.onChange(next); this.onChange(next);
}; };
onTransformationRemove = (idx: number) => { onTransformationRemove = (idx: number) => {
const { transformations } = this.state; const { transformations } = this.state;
const next = Array.from(transformations); 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', action: 'remove',
transformationId: next[idx].transformation.id, transformationId: next[idx].transformation.id,
}); });
@@ -187,6 +246,11 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
this.onChange(next); this.onChange(next);
}; };
onTransformationRemoveAll = () => {
this.onChange([]);
this.setState({ showRemoveAllModal: false });
};
onDragEnd = (result: DropResult) => { onDragEnd = (result: DropResult) => {
const { transformations } = this.state; const { transformations } = this.state;
@@ -230,10 +294,20 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
}; };
renderTransformsPicker() { renderTransformsPicker() {
const styles = getStyles(config.theme2);
const { transformations, search } = this.state; const { transformations, search } = this.state;
let suffix: React.ReactNode = null; let suffix: React.ReactNode = null;
let xforms = standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)); 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) { if (search) {
const lower = search.toLowerCase(); const lower = search.toLowerCase();
const filtered = xforms.filter((t) => { 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 ( return (
<> <>
{noTransforms && ( {noTransforms && (
@@ -312,29 +487,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
</Container> </Container>
)} )}
{showPicker ? ( {showPicker ? (
<VerticalGroup> <Picker />
<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>
) : ( ) : (
<Button <Button
icon="plus" icon="plus"
@@ -342,8 +495,9 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
onClick={() => { onClick={() => {
this.setState({ showPicker: true }); this.setState({ showPicker: true });
}} }}
data-testid={selectors.components.Transforms.addTransformationButton}
> >
Add transformation Add{config.featureToggles.transformationsRedesign ? ' another ' : ' '}transformation
</Button> </Button>
)} )}
</> </>
@@ -351,6 +505,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
} }
render() { render() {
const styles = getStyles(config.theme2);
const { const {
panel: { alert }, panel: { alert },
} = this.props; } = this.props;
@@ -363,16 +518,40 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
} }
return ( return (
<CustomScrollbar autoHeightMin="100%"> <CustomScrollbar scrollTop={this.state.scrollTop} autoHeightMin="100%">
<Container padding="md"> <Container padding="lg">
<div aria-label={selectors.components.TransformTab.content}> <div data-testid={selectors.components.TransformTab.content}>
{hasTransforms && alert ? ( {hasTransforms && alert ? (
<Alert <Alert
severity={AppNotificationSeverity.Error} severity={AppNotificationSeverity.Error}
title="Transformations can't be used on a panel with alerts" title="Transformations can't be used on a panel with alerts"
/> />
) : null} ) : 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()} {this.renderTransformsPicker()}
</div> </div>
</Container> </Container>
@@ -391,7 +570,7 @@ function TransformationCard({ transform, onClick }: TransformationCardProps) {
return ( return (
<Card <Card
className={styles.card} className={styles.card}
aria-label={selectors.components.TransformTab.newTransform(transform.name)} data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={onClick} onClick={onClick}
> >
<Card.Heading>{transform.name}</Card.Heading> <Card.Heading>{transform.name}</Card.Heading>
@@ -411,7 +590,159 @@ const getStyles = (theme: GrafanaTheme2) => {
margin: 0; margin: 0;
padding: ${theme.spacing(1)}; 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); export const TransformationsEditor = withTheme(UnThemedTransformationsEditor);

View File

@@ -5,6 +5,7 @@ import { Badge, BadgeProps } from '@grafana/ui';
interface Props { interface Props {
state?: PluginState; state?: PluginState;
className?: string;
} }
export const PluginStateInfo = (props: Props) => { export const PluginStateInfo = (props: Props) => {
@@ -14,7 +15,15 @@ export const PluginStateInfo = (props: Props) => {
return null; return null;
} }
return <Badge color={display.color} title={display.tooltip} text={display.text} icon={display.icon} />; return (
<Badge
className={props.className}
color={display.color}
title={display.tooltip}
text={display.text}
icon={display.icon}
/>
);
}; };
function getFeatureStateInfo(state?: PluginState): BadgeProps | null { function getFeatureStateInfo(state?: PluginState): BadgeProps | null {