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