mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
d894f4cc79
commit
b42d652106
@ -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'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
|
||||
);
|
||||
}
|
||||
|
||||
// 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="times"
|
||||
variant="secondary"
|
||||
onClick={() => this.setState({ showRemoveAllModal: true })}
|
||||
style={{ marginLeft: this.props.theme.spacing.md }}
|
||||
>
|
||||
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 (
|
||||
<>
|
||||
{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'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 <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>
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
this.setState({ showPicker: true });
|
||||
}}
|
||||
data-testid={selectors.components.Transforms.addTransformationButton}
|
||||
>
|
||||
Add{config.featureToggles.transformationsRedesign ? ' another ' : ' '}transformation
|
||||
</Button>
|
||||
)}
|
||||
{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…
Reference in New Issue
Block a user