Transformations: Move transformation addition into drawer (#78299)

* Start splitting out code

* Use flag

* A bit of rocket surgery

* Prettify

* Cleanup behavior

* Work through behaviors

* Move empty message from other PR

* Import fixes and prettier

* Clean things up

* Add selector for tests

* Cleanups

* Working with transformation redesign

* Some more tweaks to make sure of correct behavior

* Update betterer/eslint exceptions

* Localization

* Remove unecessary fragments

* Spacing and prettier

* Update tests for new UI

* Update e2e tests

* One more e2e test fix

* Update selectors

* Fix one test and break another
pull/78504/head^2
Kyle Cunningham 2 years ago committed by GitHub
parent d894f4cc79
commit b42d652106
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .betterer.results
  2. 4
      e2e/panels-suite/geomap-spatial-operations-transform.spec.ts
  3. 2
      e2e/panels-suite/panelEdit_base.spec.ts
  4. 3
      e2e/panels-suite/panelEdit_transforms.spec.ts
  5. 3
      packages/grafana-e2e-selectors/src/selectors/components.ts
  6. 122
      public/app/features/dashboard/components/TransformationsEditor/TransformationPicker.tsx
  7. 296
      public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx
  8. 12
      public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx
  9. 647
      public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx
  10. 6
      public/locales/de-DE/grafana.json
  11. 6
      public/locales/en-US/grafana.json
  12. 6
      public/locales/es-ES/grafana.json
  13. 6
      public/locales/fr-FR/grafana.json
  14. 6
      public/locales/pseudo-LOCALE/grafana.json
  15. 6
      public/locales/zh-Hans/grafana.json

@ -3020,11 +3020,7 @@ exports[`better eslint`] = {
],
"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.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],

@ -10,6 +10,7 @@ describe('Geomap spatial operations', () => {
it('Tests location auto option', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
@ -27,6 +28,7 @@ describe('Geomap spatial operations', () => {
it('Tests location coords option', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
@ -50,6 +52,7 @@ describe('Geomap spatial operations', () => {
it('Tests geoshash field column appears in table view', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
@ -72,6 +75,7 @@ describe('Geomap spatial operations', () => {
it('Tests location lookup option', () => {
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');

@ -39,7 +39,7 @@ describe('Panel edit tests', () => {
e2e.components.Tab.active().within((li: JQuery<HTMLLIElement>) => {
expect(li.text()).equals('Transform data0'); // there's no transform so therefore Transform + 0
});
e2e.components.Transforms.card('Merge series/tables').scrollIntoView().should('be.visible');
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible');
e2e.components.QueryTab.content().should('not.exist');
e2e.components.AlertTab.content().should('not.exist');
e2e.components.PanelAlertTabContent.content().should('not.exist');

@ -9,8 +9,9 @@ describe('Panel edit tests - transformations', () => {
e2e.flows.openDashboard({ uid: '5SdHCadmz', queryParams: { editPanel: 3 } });
e2e.components.Tab.title('Transform data').should('be.visible').click();
e2e.components.Transforms.addTransformationButton().scrollIntoView().should('be.visible').click();
e2e.components.TransformTab.newTransform('Reduce').scrollIntoView().should('be.visible').click();
e2e.components.Transforms.Reduce.calculationsLabel().should('be.visible');
e2e.components.Transforms.Reduce.calculationsLabel().scrollIntoView().should('be.visible');
e2e.components.Transforms.Reduce.modeLabel().should('be.visible');
});
});

@ -256,7 +256,8 @@ export const Components = {
},
},
},
searchInput: 'search transformations',
searchInput: 'data-testid search transformations',
noTransformationsMessage: 'data-testid no transformations message',
addTransformationButton: 'data-testid add transformation button',
},
NavBar: {

@ -0,0 +1,122 @@
import { css } from '@emotion/css';
import React, { FormEventHandler, KeyboardEventHandler, ReactNode } from 'react';
import { DocsId, GrafanaTheme2, TransformerRegistryItem } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Card, Container, VerticalGroup, Alert, Input, useStyles2 } from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import { getDocsLink } from 'app/core/utils/docsLinks';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed';
interface TransformationPickerProps {
noTransforms: boolean;
search: string;
onSearchChange: FormEventHandler<HTMLInputElement>;
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
onTransformationAdd: Function;
suffix: ReactNode;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xforms: Array<TransformerRegistryItem<any>>;
}
export function TransformationPicker(props: TransformationPickerProps) {
const { noTransforms, search, xforms, onSearchChange, onSearchKeyDown, onTransformationAdd, suffix } = props;
return (
<VerticalGroup>
{noTransforms && (
<Container grow={1}>
<LocalStorageValueProvider<boolean> storageKey={LOCAL_STORAGE_KEY} defaultValue={false}>
{(isDismissed, onDismiss) => {
if (isDismissed) {
return null;
}
return (
<Alert
title="Transformations"
severity="info"
onRemove={() => {
onDismiss(true);
}}
>
<p>
Transformations allow you to join, calculate, re-order, hide, and rename your query results before
they are visualized. <br />
Many transforms are not suitable if you&apos;re using the Graph visualization, as it currently only
supports time series data. <br />
It can help to switch to the Table visualization to understand what a transformation is doing.{' '}
</p>
<a
href={getDocsLink(DocsId.Transformations)}
className="external-link"
target="_blank"
rel="noreferrer"
>
Read more
</a>
</Alert>
);
}}
</LocalStorageValueProvider>
</Container>
)}
<Input
data-testid={selectors.components.Transforms.searchInput}
value={search ?? ''}
autoFocus={!noTransforms}
placeholder="Search for transformation"
onChange={onSearchChange}
onKeyDown={onSearchKeyDown}
suffix={suffix}
/>
{xforms.map((t) => {
return (
<TransformationCard
key={t.name}
transform={t}
onClick={() => {
onTransformationAdd({ value: t.id });
}}
/>
);
})}
</VerticalGroup>
);
}
interface TransformationCardProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transform: TransformerRegistryItem<any>;
onClick: () => void;
}
function TransformationCard({ transform, onClick }: TransformationCardProps) {
const styles = useStyles2(getStyles);
return (
<Card
className={styles.card}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={onClick}
>
<Card.Heading>{transform.name}</Card.Heading>
<Card.Description>{transform.description}</Card.Description>
{transform.state && (
<Card.Tags>
<PluginStateInfo state={transform.state} />
</Card.Tags>
)}
</Card>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
card: css({
margin: '0',
padding: `${theme.spacing(1)}`,
}),
};
}

@ -0,0 +1,296 @@
import { cx, css } from '@emotion/css';
import React, { FormEventHandler, KeyboardEventHandler, ReactNode } from 'react';
import {
DataFrame,
DataTransformerID,
TransformerRegistryItem,
TransformationApplicabilityLevels,
GrafanaTheme2,
standardTransformersRegistry,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { categoriesLabels } from 'app/features/transformers/utils';
import { FilterCategory } from './TransformationsEditor';
const viewAllLabel = 'View all';
const VIEW_ALL_VALUE = 'viewAll';
const filterCategoriesLabels: Array<[FilterCategory, string]> = [
[VIEW_ALL_VALUE, viewAllLabel],
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
...(Object.entries(categoriesLabels) as Array<[FilterCategory, string]>),
];
interface TransformationPickerNgProps {
onTransformationAdd: Function;
setState: Function;
onSearchChange: FormEventHandler<HTMLInputElement>;
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
noTransforms: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xforms: Array<TransformerRegistryItem<any>>;
search: string;
suffix: ReactNode;
data: DataFrame[];
showIllustrations?: boolean;
selectedFilter?: FilterCategory;
}
export function TransformationPickerNg(props: TransformationPickerNgProps) {
const styles = useStyles2(getTransformationPickerStyles);
const {
noTransforms,
suffix,
setState,
xforms,
search,
onSearchChange,
onSearchKeyDown,
showIllustrations,
onTransformationAdd,
selectedFilter,
data,
} = props;
return (
<Drawer size="md" onClose={() => setState({ showPicker: false })} title="Add another transformation">
<div className={styles.searchWrapper}>
<Input
data-testid={selectors.components.Transforms.searchInput}
className={styles.searchInput}
value={search ?? ''}
autoFocus={!noTransforms}
placeholder="Search for transformation"
onChange={onSearchChange}
onKeyDown={onSearchKeyDown}
suffix={suffix}
/>
<div className={styles.showImages}>
<span className={styles.illustationSwitchLabel}>Show images</span>{' '}
<Switch value={showIllustrations} onChange={() => setState({ showIllustrations: !showIllustrations })} />
</div>
</div>
<div className={styles.filterWrapper}>
{filterCategoriesLabels.map(([slug, label]) => {
return (
<FilterPill
key={slug}
onClick={() => setState({ selectedFilter: slug })}
label={label}
selected={selectedFilter === slug}
/>
);
})}
</div>
<TransformationsGrid
showIllustrations={showIllustrations}
transformations={xforms}
data={data}
onClick={(id) => {
onTransformationAdd({ value: id });
}}
/>
</Drawer>
);
}
function getTransformationPickerStyles(theme: GrafanaTheme2) {
return {
showImages: css({
flexBasis: '0',
display: 'flex',
gap: '8px',
alignItems: 'center',
}),
pickerInformationLine: css({
fontSize: '16px',
marginBottom: `${theme.spacing(2)}`,
}),
pickerInformationLineHighlight: css({
verticalAlign: 'middle',
}),
searchWrapper: css({
display: 'flex',
flexWrap: 'wrap',
columnGap: '27px',
rowGap: '16px',
width: '100%',
}),
searchInput: css({
flexGrow: '1',
width: 'initial',
}),
illustationSwitchLabel: css({
whiteSpace: 'nowrap',
}),
filterWrapper: css({
padding: `${theme.spacing(1)} 0`,
display: 'flex',
flexWrap: 'wrap',
rowGap: `${theme.spacing(1)}`,
columnGap: `${theme.spacing(0.5)}`,
}),
};
}
interface TransformationsGridProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
transformations: Array<TransformerRegistryItem<any>>;
showIllustrations?: boolean;
onClick: (id: string) => void;
data: DataFrame[];
}
function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) {
const styles = useStyles2(getTransformationGridStyles);
return (
<div className={styles.grid}>
{transformations.map((transform) => {
// Check to see if the transform
// is applicable to the given data
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
if (transform.transformation.isApplicable !== undefined) {
applicabilityScore = transform.transformation.isApplicable(data);
}
const isApplicable = applicabilityScore > 0;
let applicabilityDescription = null;
if (transform.transformation.isApplicableDescription !== undefined) {
if (typeof transform.transformation.isApplicableDescription === 'function') {
applicabilityDescription = transform.transformation.isApplicableDescription(data);
} else {
applicabilityDescription = transform.transformation.isApplicableDescription;
}
}
// Add disabled styles to disabled
let cardClasses = styles.newCard;
if (!isApplicable) {
cardClasses = cx(styles.newCard, styles.cardDisabled);
}
return (
<Card
className={cardClasses}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
key={transform.id}
>
<Card.Heading className={styles.heading}>
<span>{transform.name}</span>
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo 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, !isApplicable)} alt={transform.name} />
</span>
)}
{!isApplicable && applicabilityDescription !== null && (
<IconButton
className={styles.cardApplicableInfo}
name="info-circle"
tooltip={applicabilityDescription}
/>
)}
</Card.Description>
</Card>
);
})}
</div>
);
}
function getTransformationGridStyles(theme: GrafanaTheme2) {
return {
// eslint-disable-next-line @emotion/syntax-preference
heading: css`
font-weight: 400,
> button: {
width: '100%',
display: 'flex',
justify-content: 'space-between',
align-items: 'center',
flex-wrap: 'no-wrap',
},`,
description: css({
fontSize: '12px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxEidth: '100%`',
marginTop: `${theme.spacing(2)}`,
}),
grid: css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gridAutoRows: '1fr',
gap: `${theme.spacing(2)} ${theme.spacing(1)}`,
width: '100%',
}),
cardDisabled: css({
backgroundColor: 'rgb(204, 204, 220, 0.045)',
color: `${theme.colors.text.disabled} !important`,
}),
cardApplicableInfo: css({
position: 'absolute',
bottom: `${theme.spacing(1)}`,
right: `${theme.spacing(1)}`,
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
}),
pluginStateInfoWrapper: css({
marginLeft: '5px',
}),
};
}
const getImagePath = (id: string, disabled: boolean) => {
let folder = null;
if (!disabled) {
folder = config.theme2.isDark ? 'dark' : 'light';
} else {
folder = 'disabled';
}
return `public/img/transformations/${folder}/${id}.svg`;
};
const TransformationDescriptionOverrides: { [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]: 'Remove parts of the query results using a regex pattern.',
[DataTransformerID.filterByRefId]: 'Remove rows from the data based on origin query',
[DataTransformerID.filterByValue]: 'Remove rows from the query results using user-defined filters.',
[DataTransformerID.groupBy]: 'Group data by a field value and create aggregate data.',
[DataTransformerID.groupingToMatrix]: 'Summarize and reorganize data based on three fields.',
[DataTransformerID.joinByField]: 'Combine rows from 2+ tables, based on a related field.',
[DataTransformerID.labelsToFields]: 'Group series by time and return labels or tags as fields.',
[DataTransformerID.merge]: 'Merge multiple series. Values will be combined into one row.',
[DataTransformerID.organize]: 'Re-order, hide, or rename fields.',
[DataTransformerID.partitionByValues]: 'Split a one-frame dataset into multiple series.',
[DataTransformerID.prepareTimeSeries]: '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]:
'Rename parts of the query results using a regular expression and replacement pattern.',
[DataTransformerID.seriesToRows]: 'Merge multiple series. Return time, metric and values as a row.',
};
const getTransformationsRedesignDescriptions = (id: string): string => {
return TransformationDescriptionOverrides[id] || standardTransformersRegistry.getIfExists(id)?.description || '';
};

@ -21,17 +21,17 @@ describe('TransformationsEditor', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
describe('when no transformations configured', () => {
function renderList() {
it('renders transformation list by default and without transformationsRedesign on', () => {
setup();
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', () => {
it('renders transformation empty message with transformationsRedesign feature toggled on', () => {
config.featureToggles.transformationsRedesign = true;
renderList();
setup();
const message = screen.getAllByTestId('data-testid no transformations message');
expect(message.length).toEqual(1);
config.featureToggles.transformationsRedesign = false;
});
});

@ -1,4 +1,3 @@
import { cx, css } from '@emotion/css';
import React, { ChangeEvent } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { Unsubscribable } from 'rxjs';
@ -6,64 +5,44 @@ import { Unsubscribable } from 'rxjs';
import {
DataFrame,
DataTransformerConfig,
DocsId,
GrafanaTheme2,
PanelData,
SelectableValue,
standardTransformersRegistry,
TransformerRegistryItem,
TransformerCategory,
DataTransformerID,
TransformationApplicabilityLevels,
} 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,
ButtonGroup,
Box,
Text,
Stack,
} 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 { Trans } from 'app/core/internationalization';
import { AppNotificationSeverity } from '../../../../types';
import { PanelModel } from '../../state';
import { PanelNotSupported } from '../PanelEditor/PanelNotSupported';
import { TransformationOperationRows } from './TransformationOperationRows';
import { TransformationPicker } from './TransformationPicker';
import { TransformationPickerNg } from './TransformationPickerNg';
import { TransformationsEditorTransformation } from './types';
const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed';
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]>),
];
const VIEW_ALL_VALUE = 'viewAll';
export type viewAllType = 'viewAll';
export type FilterCategory = TransformerCategory | viewAllType;
interface State {
data: DataFrame[];
@ -91,7 +70,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
})),
data: [],
search: '',
selectedFilter: viewAllValue,
selectedFilter: VIEW_ALL_VALUE,
showIllustrations: true,
};
}
@ -270,41 +249,82 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
this.onChange(update);
};
renderEmptyMessage = () => {
return (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={2}>
<Text element="h3" textAlignment="center">
<Trans key="transformations.empty.add-transformation-header">Start transforming data</Trans>
</Text>
<Text
element="p"
textAlignment="center"
data-testid={selectors.components.Transforms.noTransformationsMessage}
>
<Trans key="transformations.empty.add-transformation-body">
Transformations allow data to be changed in various ways before your visualization is shown.
<br />
This includes joining data together, renaming fields, making calculations, formatting data for display,
and more.
</Trans>
</Text>
<Button
icon="plus"
variant="primary"
size="md"
onClick={() => {
this.setState({ showPicker: true });
}}
data-testid={selectors.components.Transforms.addTransformationButton}
>
Add transformation
</Button>
</Stack>
</Box>
);
};
renderTransformationEditors = () => {
const styles = getStyles(config.theme2);
const { data, transformations, showPicker } = this.state;
const hide = config.featureToggles.transformationsRedesign && showPicker;
const { data, transformations } = this.state;
return (
<div className={cx({ [styles.hide]: hide })}>
<DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{(provided) => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
<TransformationOperationRows
configs={transformations}
data={data}
onRemove={this.onTransformationRemove}
onChange={this.onTransformationChange}
/>
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
</div>
<DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{(provided) => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
<TransformationOperationRows
configs={transformations}
data={data}
onRemove={this.onTransformationRemove}
onChange={this.onTransformationChange}
/>
{provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
);
};
renderTransformsPicker() {
const styles = getStyles(config.theme2);
let { showPicker } = this.state;
const { transformations, search } = this.state;
const { transformationsRedesign } = config.featureToggles;
const noTransforms = !transformations?.length;
const hasTransforms = transformations.length > 0;
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) {
// In the case we're not on the transformation
// redesign and there are no transformations
// then we show the picker in that case
if (!transformationsRedesign && noTransforms) {
showPicker = true;
}
if (this.state.selectedFilter !== VIEW_ALL_VALUE) {
xforms = xforms.filter(
(t) =>
t.categories &&
@ -336,9 +356,6 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
xforms = filtered;
}
const noTransforms = !transformations?.length;
const showPicker = noTransforms || this.state.showPicker;
if (!suffix && showPicker && !noTransforms) {
suffix = (
<IconButton
@ -351,211 +368,119 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
);
}
return (
<>
{noTransforms && !config.featureToggles.transformationsRedesign && (
<Container grow={1}>
<LocalStorageValueProvider<boolean> storageKey={LOCAL_STORAGE_KEY} defaultValue={false}>
{(isDismissed, onDismiss) => {
if (isDismissed) {
return null;
}
return (
<Alert
title="Transformations"
severity="info"
onRemove={() => {
onDismiss(true);
}}
>
<p>
Transformations allow you to join, calculate, re-order, hide, and rename your query results before
they are visualized. <br />
Many transforms are not suitable if you&apos;re using the Graph visualization, as it currently
only supports time series data. <br />
It can help to switch to the Table visualization to understand what a transformation is doing.{' '}
</p>
<a
href={getDocsLink(DocsId.Transformations)}
className="external-link"
target="_blank"
rel="noreferrer"
>
Read more
</a>
</Alert>
);
}}
</LocalStorageValueProvider>
</Container>
)}
{showPicker ? (
<>
{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}
data={this.state.data}
onClick={(id) => {
this.onTransformationAdd({ value: id });
}}
/>
)}
</VerticalGroup>
</>
) : (
// If we're in the transformation redesign
// we have the add transformation add the
// delete all control
let picker = null;
let deleteAll = null;
if (transformationsRedesign) {
picker = (
<TransformationPickerNg
noTransforms={noTransforms}
search={search}
suffix={suffix}
xforms={xforms}
setState={this.setState.bind(this)}
onSearchChange={this.onSearchChange}
onSearchKeyDown={this.onSearchKeyDown}
onTransformationAdd={this.onTransformationAdd}
data={this.state.data}
selectedFilter={this.state.selectedFilter}
showIllustrations={this.state.showIllustrations}
/>
);
deleteAll = (
<>
<Button
icon="plus"
icon="times"
variant="secondary"
onClick={() => {
this.setState({ showPicker: true });
}}
data-testid={selectors.components.Transforms.addTransformationButton}
onClick={() => this.setState({ showRemoveAllModal: true })}
style={{ marginLeft: this.props.theme.spacing.md }}
>
Add{config.featureToggles.transformationsRedesign ? ' another ' : ' '}transformation
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 })}
/>
</>
);
}
// Otherwise we use the old picker
else {
picker = (
<TransformationPicker
noTransforms={noTransforms}
search={search}
suffix={suffix}
xforms={xforms}
onSearchChange={this.onSearchChange}
onSearchKeyDown={this.onSearchKeyDown}
onTransformationAdd={this.onTransformationAdd}
/>
);
}
// Compose actions, if we're in the
// redesign a "Delete All Transformations"
// button (with confirm modal) is added
const actions = (
<ButtonGroup>
<Button
icon="plus"
variant="secondary"
onClick={() => {
this.setState({ showPicker: true });
}}
data-testid={selectors.components.Transforms.addTransformationButton}
>
Add another transformation
</Button>
{deleteAll}
</ButtonGroup>
);
return (
<>
{showPicker && picker}
{
// If the transformation redesign is enabled
// and there are transforms then show actions
(transformationsRedesign && hasTransforms && actions) ||
// If it's not enabled only show actions when there are
// transformations and the (old) picker isn't being shown
(!transformationsRedesign && !showPicker && hasTransforms && actions)
}
</>
);
}
render() {
const styles = getStyles(config.theme2);
const {
panel: { alert },
} = this.props;
const { transformations } = this.state;
const hasTransforms = transformations.length > 0;
if (!hasTransforms && alert) {
return <PanelNotSupported message="Transformations can't be used on a panel with existing alerts" />;
// If there are any alerts then
// we can't use transformations
if (alert) {
const message = hasTransforms
? "Transformations can't be used on a panel with alerts"
: "Transformations can't be used on a panel with existing alerts";
return <PanelNotSupported message={message} />;
}
return (
<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 && 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.renderEmptyMessage()}
{hasTransforms && this.renderTransformationEditors()}
{this.renderTransformsPicker()}
</div>
@ -565,240 +490,4 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
}
}
interface TransformationCardProps {
transform: TransformerRegistryItem<any>;
onClick: () => void;
}
function TransformationCard({ transform, onClick }: TransformationCardProps) {
const styles = useStyles2(getStyles);
return (
<Card
className={styles.card}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={onClick}
>
<Card.Heading>{transform.name}</Card.Heading>
<Card.Description>{transform.description}</Card.Description>
{transform.state && (
<Card.Tags>
<PluginStateInfo state={transform.state} />
</Card.Tags>
)}
</Card>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
hide: css({
display: 'none',
}),
card: css({
margin: '0',
padding: `${theme.spacing(1)}`,
}),
grid: css({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gridAutoRows: '1fr',
gap: `${theme.spacing(2)} ${theme.spacing(1)}`,
width: '100%',
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
}),
cardDisabled: css({
backgroundColor: 'rgb(204, 204, 220, 0.045)',
color: `${theme.colors.text.disabled} !important`,
}),
heading: css`
font-weight: 400,
> button: {
width: '100%',
display: 'flex',
justify-content: 'space-between',
align-items: 'center',
flex-wrap: 'no-wrap',
},
`,
description: css({
fontSize: '12px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxEidth: '100%`',
marginTop: `${theme.spacing(2)}`,
}),
searchWrapper: css({
display: 'flex',
flexWrap: 'wrap',
columnGap: '27px',
rowGap: '16px',
width: '100%',
}),
searchInput: css({
flexGrow: '1',
width: 'initial',
}),
showImages: css({
flexBasis: '0',
display: 'flex',
gap: '8px',
alignItems: 'center',
}),
pickerInformationLine: css({
fontSize: '16px',
marginBottom: `${theme.spacing(2)}`,
}),
pickerInformationLineHighlight: css({
verticalAlign: 'middle',
}),
illustationSwitchLabel: css({
whiteSpace: 'nowrap',
}),
filterWrapper: css({
padding: `${theme.spacing(1)} 0`,
display: 'flex',
flexWrap: 'wrap',
rowGap: `${theme.spacing(1)}`,
columnGap: `${theme.spacing(0.5)}`,
}),
listInformationLineWrapper: css({
display: 'flex',
justifyContent: 'space-between',
marginBottom: '24px',
}),
listInformationLineText: css({
fontSize: '16px',
}),
pluginStateInfoWrapper: css({
marginLeft: '5px',
}),
cardApplicableInfo: css({
position: 'absolute',
bottom: `${theme.spacing(1)}`,
right: `${theme.spacing(1)}`,
}),
};
};
interface TransformationsGridProps {
transformations: Array<TransformerRegistryItem<any>>;
showIllustrations?: boolean;
onClick: (id: string) => void;
data: DataFrame[];
}
function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) {
const styles = useStyles2(getStyles);
return (
<div className={styles.grid}>
{transformations.map((transform) => {
// Check to see if the transform
// is applicable to the given data
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
if (transform.transformation.isApplicable !== undefined) {
applicabilityScore = transform.transformation.isApplicable(data);
}
const isApplicable = applicabilityScore > 0;
let applicabilityDescription = null;
if (transform.transformation.isApplicableDescription !== undefined) {
if (typeof transform.transformation.isApplicableDescription === 'function') {
applicabilityDescription = transform.transformation.isApplicableDescription(data);
} else {
applicabilityDescription = transform.transformation.isApplicableDescription;
}
}
// Add disabled styles to disabled
let cardClasses = styles.newCard;
if (!isApplicable) {
cardClasses = cx(styles.newCard, styles.cardDisabled);
}
return (
<Card
className={cardClasses}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
key={transform.id}
>
<Card.Heading className={styles.heading}>
<>
<span>{transform.name}</span>
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo 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, !isApplicable)}
alt={transform.name}
/>
</span>
)}
{!isApplicable && applicabilityDescription !== null && (
<IconButton
className={styles.cardApplicableInfo}
name="info-circle"
tooltip={applicabilityDescription}
/>
)}
</>
</Card.Description>
</Card>
);
})}
</div>
);
}
const getImagePath = (id: string, disabled: boolean) => {
let folder = null;
if (!disabled) {
folder = config.theme2.isDark ? 'dark' : 'light';
} else {
folder = 'disabled';
}
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]: 'Remove parts of the query results using a regex pattern.',
[DataTransformerID.filterByRefId]: 'Remove rows from the data based on origin query',
[DataTransformerID.filterByValue]: 'Remove rows from the query results using user-defined filters.',
[DataTransformerID.groupBy]: 'Group data by a field value and create aggregate data.',
[DataTransformerID.groupingToMatrix]: 'Summarize and reorganize data based on three fields.',
[DataTransformerID.joinByField]: 'Combine rows from 2+ tables, based on a related field.',
[DataTransformerID.labelsToFields]: 'Group series by time and return labels or tags as fields.',
[DataTransformerID.merge]: 'Merge multiple series. Values will be combined into one row.',
[DataTransformerID.organize]: 'Re-order, hide, or rename fields.',
[DataTransformerID.partitionByValues]: 'Split a one-frame dataset into multiple series.',
[DataTransformerID.prepareTimeSeries]: '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]:
'Rename parts of the query results using a regular expression and replacement pattern.',
[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);

@ -1284,6 +1284,12 @@
"select-search-input": "Suchbegriff eingeben (Land, Stadt, Abkürzung)"
}
},
"transformations": {
"empty": {
"add-transformation-body": "",
"add-transformation-header": ""
}
},
"user-orgs": {
"current-org-button": "Aktuell",
"name-column": "Name",

@ -1284,6 +1284,12 @@
"select-search-input": "Type to search (country, city, abbreviation)"
}
},
"transformations": {
"empty": {
"add-transformation-body": "Transformations allow data to be changed in various ways before your visualization is shown.<1></1>This includes joining data together, renaming fields, making calculations, formatting data for display, and more.",
"add-transformation-header": "Start transforming data"
}
},
"user-orgs": {
"current-org-button": "Current",
"name-column": "Name",

@ -1290,6 +1290,12 @@
"select-search-input": "Escribir para buscar (país, ciudad, abreviatura)"
}
},
"transformations": {
"empty": {
"add-transformation-body": "",
"add-transformation-header": ""
}
},
"user-orgs": {
"current-org-button": "Actual",
"name-column": "Nombre",

@ -1290,6 +1290,12 @@
"select-search-input": "Tapez pour rechercher (pays, ville, abréviation)"
}
},
"transformations": {
"empty": {
"add-transformation-body": "",
"add-transformation-header": ""
}
},
"user-orgs": {
"current-org-button": "Actuel",
"name-column": "Nom",

@ -1284,6 +1284,12 @@
"select-search-input": "Ŧypę ŧő şęäřčĥ (čőūʼnŧřy, čįŧy, äþþřęvįäŧįőʼn)"
}
},
"transformations": {
"empty": {
"add-transformation-body": "Ŧřäʼnşƒőřmäŧįőʼnş äľľőŵ đäŧä ŧő þę čĥäʼnģęđ įʼn väřįőūş ŵäyş þęƒőřę yőūř vįşūäľįžäŧįőʼn įş şĥőŵʼn.<1></1>Ŧĥįş įʼnčľūđęş ĵőįʼnįʼnģ đäŧä ŧőģęŧĥęř, řęʼnämįʼnģ ƒįęľđş, mäĸįʼnģ čäľčūľäŧįőʼnş, ƒőřmäŧŧįʼnģ đäŧä ƒőř đįşpľäy, äʼnđ mőřę.",
"add-transformation-header": "Ŝŧäřŧ ŧřäʼnşƒőřmįʼnģ đäŧä"
}
},
"user-orgs": {
"current-org-button": "Cūřřęʼnŧ",
"name-column": "Ńämę",

@ -1278,6 +1278,12 @@
"select-search-input": "输入以搜索(国家、城市、缩写)"
}
},
"transformations": {
"empty": {
"add-transformation-body": "",
"add-transformation-header": ""
}
},
"user-orgs": {
"current-org-button": "当前",
"name-column": "姓名",

Loading…
Cancel
Save