mirror of https://github.com/grafana/grafana
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 anotherpull/78504/head^2
parent
d894f4cc79
commit
b42d652106
@ -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'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 || ''; |
||||||
|
}; |
Loading…
Reference in new issue