From b42d6521063a3d5c9e3928a53aaa7f839e9257b8 Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Fri, 1 Dec 2023 14:08:54 -0600 Subject: [PATCH] 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 --- .betterer.results | 6 +- ...eomap-spatial-operations-transform.spec.ts | 4 + e2e/panels-suite/panelEdit_base.spec.ts | 2 +- e2e/panels-suite/panelEdit_transforms.spec.ts | 3 +- .../src/selectors/components.ts | 3 +- .../TransformationPicker.tsx | 122 ++++ .../TransformationPickerNg.tsx | 296 ++++++++ .../TransformationsEditor.test.tsx | 12 +- .../TransformationsEditor.tsx | 651 +++++------------- public/locales/de-DE/grafana.json | 6 + public/locales/en-US/grafana.json | 6 + public/locales/es-ES/grafana.json | 6 + public/locales/fr-FR/grafana.json | 6 + public/locales/pseudo-LOCALE/grafana.json | 6 + public/locales/zh-Hans/grafana.json | 6 + 15 files changed, 640 insertions(+), 495 deletions(-) create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationPicker.tsx create mode 100644 public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx diff --git a/.betterer.results b/.betterer.results index 99eaa495034..1d8794210a1 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts b/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts index 79608bbce68..52ca7ed0fac 100644 --- a/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts +++ b/e2e/panels-suite/geomap-spatial-operations-transform.spec.ts @@ -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}'); diff --git a/e2e/panels-suite/panelEdit_base.spec.ts b/e2e/panels-suite/panelEdit_base.spec.ts index 248e8e72ae9..744c41ed8ce 100644 --- a/e2e/panels-suite/panelEdit_base.spec.ts +++ b/e2e/panels-suite/panelEdit_base.spec.ts @@ -39,7 +39,7 @@ describe('Panel edit tests', () => { e2e.components.Tab.active().within((li: JQuery) => { 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'); diff --git a/e2e/panels-suite/panelEdit_transforms.spec.ts b/e2e/panels-suite/panelEdit_transforms.spec.ts index 5027da1670c..c71f649791c 100644 --- a/e2e/panels-suite/panelEdit_transforms.spec.ts +++ b/e2e/panels-suite/panelEdit_transforms.spec.ts @@ -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'); }); }); diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 210ab059ed2..5814e805b30 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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: { diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPicker.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPicker.tsx new file mode 100644 index 00000000000..48656e7289b --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPicker.tsx @@ -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; + onSearchKeyDown: KeyboardEventHandler; + onTransformationAdd: Function; + suffix: ReactNode; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + xforms: Array>; +} + +export function TransformationPicker(props: TransformationPickerProps) { + const { noTransforms, search, xforms, onSearchChange, onSearchKeyDown, onTransformationAdd, suffix } = props; + + return ( + + {noTransforms && ( + + storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> + {(isDismissed, onDismiss) => { + if (isDismissed) { + return null; + } + + return ( + { + onDismiss(true); + }} + > +

+ Transformations allow you to join, calculate, re-order, hide, and rename your query results before + they are visualized.
+ Many transforms are not suitable if you're using the Graph visualization, as it currently only + supports time series data.
+ It can help to switch to the Table visualization to understand what a transformation is doing.{' '} +

+ + Read more + +
+ ); + }} + +
+ )} + + {xforms.map((t) => { + return ( + { + onTransformationAdd({ value: t.id }); + }} + /> + ); + })} +
+ ); +} + +interface TransformationCardProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transform: TransformerRegistryItem; + onClick: () => void; +} + +function TransformationCard({ transform, onClick }: TransformationCardProps) { + const styles = useStyles2(getStyles); + return ( + + {transform.name} + {transform.description} + {transform.state && ( + + + + )} + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + card: css({ + margin: '0', + padding: `${theme.spacing(1)}`, + }), + }; +} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx new file mode 100644 index 00000000000..1866f294e73 --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx @@ -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; + onSearchKeyDown: KeyboardEventHandler; + noTransforms: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + xforms: Array>; + 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 ( + setState({ showPicker: false })} title="Add another transformation"> +
+ +
+ Show images{' '} + setState({ showIllustrations: !showIllustrations })} /> +
+
+ +
+ {filterCategoriesLabels.map(([slug, label]) => { + return ( + setState({ selectedFilter: slug })} + label={label} + selected={selectedFilter === slug} + /> + ); + })} +
+ + { + onTransformationAdd({ value: id }); + }} + /> +
+ ); +} + +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>; + showIllustrations?: boolean; + onClick: (id: string) => void; + data: DataFrame[]; +} + +function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) { + const styles = useStyles2(getTransformationGridStyles); + + return ( +
+ {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 ( + onClick(transform.id)} + key={transform.id} + > + + {transform.name} + + + + + + {getTransformationsRedesignDescriptions(transform.id)} + {showIllustrations && ( + + {transform.name} + + )} + {!isApplicable && applicabilityDescription !== null && ( + + )} + + + ); + })} +
+ ); +} + +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 || ''; +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx index 16aa54cf8ce..225ded8d755 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.test.tsx @@ -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; }); }); diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index 0a714b49c5a..e65cd6c7c08 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -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 { + return ( + + + + Start transforming data + + + + Transformations allow data to be changed in various ways before your visualization is shown. +
+ This includes joining data together, renaming fields, making calculations, formatting data for display, + and more. +
+
+ +
+
+ ); + }; + renderTransformationEditors = () => { - const styles = getStyles(config.theme2); - const { data, transformations, showPicker } = this.state; - const hide = config.featureToggles.transformationsRedesign && showPicker; + const { data, transformations } = this.state; return ( -
- - - {(provided) => { - return ( -
- - {provided.placeholder} -
- ); - }} -
-
-
+ + + {(provided) => { + return ( +
+ + {provided.placeholder} +
+ ); + }} +
+
); }; 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 + ); + + deleteAll = ( + <> + + this.onTransformationRemoveAll()} + onDismiss={() => this.setState({ showRemoveAllModal: false })} + /> + + ); + } + // Otherwise we use the old picker + else { + picker = ( + + ); + } + + // Compose actions, if we're in the + // redesign a "Delete All Transformations" + // button (with confirm modal) is added + const actions = ( + + + {deleteAll} + + ); + return ( <> - {noTransforms && !config.featureToggles.transformationsRedesign && ( - - storageKey={LOCAL_STORAGE_KEY} defaultValue={false}> - {(isDismissed, onDismiss) => { - if (isDismissed) { - return null; - } - - return ( - { - onDismiss(true); - }} - > -

- Transformations allow you to join, calculate, re-order, hide, and rename your query results before - they are visualized.
- Many transforms are not suitable if you're using the Graph visualization, as it currently - only supports time series data.
- It can help to switch to the Table visualization to understand what a transformation is doing.{' '} -

- - Read more - -
- ); - }} - -
- )} - {showPicker ? ( - <> - {config.featureToggles.transformationsRedesign && ( - <> - {!noTransforms && ( - - )} -
- - Transformations{' '} - - -  allow you to manipulate your data before a visualization is applied. -
- - )} - - {!config.featureToggles.transformationsRedesign && ( - - )} - - {!config.featureToggles.transformationsRedesign && - xforms.map((t) => { - return ( - { - this.onTransformationAdd({ value: t.id }); - }} - /> - ); - })} - - {config.featureToggles.transformationsRedesign && ( -
- -
- Show images{' '} - this.setState({ showIllustrations: !this.state.showIllustrations })} - /> -
-
- )} - - {config.featureToggles.transformationsRedesign && ( -
- {filterCategoriesLabels.map(([slug, label]) => { - return ( - this.setState({ selectedFilter: slug })} - label={label} - selected={this.state.selectedFilter === slug} - /> - ); - })} -
- )} - - {config.featureToggles.transformationsRedesign && ( - { - this.onTransformationAdd({ value: id }); - }} - /> - )} -
- - ) : ( - - )} + {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 ; + // 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 ; } return (
- {hasTransforms && alert ? ( - - ) : null} - {hasTransforms && config.featureToggles.transformationsRedesign && !this.state.showPicker && ( -
- Transformations in use{' '} - - this.onTransformationRemoveAll()} - onDismiss={() => this.setState({ showRemoveAllModal: false })} - /> -
- )} + {!hasTransforms && config.featureToggles.transformationsRedesign && this.renderEmptyMessage()} {hasTransforms && this.renderTransformationEditors()} {this.renderTransformsPicker()}
@@ -565,240 +490,4 @@ class UnThemedTransformationsEditor extends React.PureComponent; - onClick: () => void; -} - -function TransformationCard({ transform, onClick }: TransformationCardProps) { - const styles = useStyles2(getStyles); - return ( - - {transform.name} - {transform.description} - {transform.state && ( - - - - )} - - ); -} - -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>; - showIllustrations?: boolean; - onClick: (id: string) => void; - data: DataFrame[]; -} - -function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) { - const styles = useStyles2(getStyles); - - return ( -
- {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 ( - onClick(transform.id)} - key={transform.id} - > - - <> - {transform.name} - - - - - - - <> - {getTransformationsRedesignDescriptions(transform.id)} - {showIllustrations && ( - - {transform.name} - - )} - {!isApplicable && applicabilityDescription !== null && ( - - )} - - - - ); - })} -
- ); -} - -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); diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 1cbf1a4fcd6..03878dfc2c0 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -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", diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 09ec0cf674f..24320d27405 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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>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", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 097bc39f59d..f0cdda2f4c6 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -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", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index b4b24f26fb3..f1939b6c2e2 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -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", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index afbd2dc4898..1e361c7addb 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -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>Ŧĥįş įʼ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ę", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 0d63eb4a5a4..95e3b972830 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1278,6 +1278,12 @@ "select-search-input": "输入以搜索(国家、城市、缩写)" } }, + "transformations": { + "empty": { + "add-transformation-body": "", + "add-transformation-header": "" + } + }, "user-orgs": { "current-org-button": "当前", "name-column": "姓名",