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": [
|
"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.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
[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"]
|
|
||||||
],
|
],
|
||||||
"public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx:5381": [
|
"public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx:5381": [
|
||||||
[0, 0, 0, "Styles should be written using objects.", "0"],
|
[0, 0, 0, "Styles should be written using objects.", "0"],
|
||||||
|
@ -10,6 +10,7 @@ describe('Geomap spatial operations', () => {
|
|||||||
it('Tests location auto option', () => {
|
it('Tests location auto option', () => {
|
||||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
||||||
e2e.components.Tab.title('Transform data').should('be.visible').click();
|
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.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
|
||||||
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
|
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
|
||||||
@ -27,6 +28,7 @@ describe('Geomap spatial operations', () => {
|
|||||||
it('Tests location coords option', () => {
|
it('Tests location coords option', () => {
|
||||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
||||||
e2e.components.Tab.title('Transform data').should('be.visible').click();
|
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.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
|
||||||
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
|
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', () => {
|
it('Tests geoshash field column appears in table view', () => {
|
||||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
||||||
e2e.components.Tab.title('Transform data').should('be.visible').click();
|
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.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
|
||||||
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
|
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
|
||||||
@ -72,6 +75,7 @@ describe('Geomap spatial operations', () => {
|
|||||||
it('Tests location lookup option', () => {
|
it('Tests location lookup option', () => {
|
||||||
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
e2e.flows.openDashboard({ uid: DASHBOARD_ID, queryParams: { editPanel: 1 } });
|
||||||
e2e.components.Tab.title('Transform data').should('be.visible').click();
|
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.TransformTab.newTransform('Spatial operations').scrollIntoView().should('be.visible').click();
|
||||||
e2e.components.Transforms.SpatialOperations.actionLabel().type('Prepare spatial field{enter}');
|
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>) => {
|
e2e.components.Tab.active().within((li: JQuery<HTMLLIElement>) => {
|
||||||
expect(li.text()).equals('Transform data0'); // there's no transform so therefore Transform + 0
|
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.QueryTab.content().should('not.exist');
|
||||||
e2e.components.AlertTab.content().should('not.exist');
|
e2e.components.AlertTab.content().should('not.exist');
|
||||||
e2e.components.PanelAlertTabContent.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.flows.openDashboard({ uid: '5SdHCadmz', queryParams: { editPanel: 3 } });
|
||||||
|
|
||||||
e2e.components.Tab.title('Transform data').should('be.visible').click();
|
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.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');
|
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',
|
addTransformationButton: 'data-testid add transformation button',
|
||||||
},
|
},
|
||||||
NavBar: {
|
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);
|
standardTransformersRegistry.setInit(getStandardTransformers);
|
||||||
|
|
||||||
describe('when no transformations configured', () => {
|
describe('when no transformations configured', () => {
|
||||||
function renderList() {
|
it('renders transformation list by default and without transformationsRedesign on', () => {
|
||||||
setup();
|
setup();
|
||||||
|
|
||||||
const cards = screen.getAllByTestId(/New transform/i);
|
const cards = screen.getAllByTestId(/New transform/i);
|
||||||
expect(cards.length).toEqual(standardTransformersRegistry.list().length);
|
expect(cards.length).toEqual(standardTransformersRegistry.list().length);
|
||||||
}
|
});
|
||||||
|
|
||||||
it('renders transformations selection list', renderList);
|
it('renders transformation empty message with transformationsRedesign feature toggled on', () => {
|
||||||
it('renders transformations selection list with transformationsRedesign feature toggled on', () => {
|
|
||||||
config.featureToggles.transformationsRedesign = true;
|
config.featureToggles.transformationsRedesign = true;
|
||||||
renderList();
|
setup();
|
||||||
|
const message = screen.getAllByTestId('data-testid no transformations message');
|
||||||
|
expect(message.length).toEqual(1);
|
||||||
config.featureToggles.transformationsRedesign = false;
|
config.featureToggles.transformationsRedesign = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { cx, css } from '@emotion/css';
|
|
||||||
import React, { ChangeEvent } from 'react';
|
import React, { ChangeEvent } from 'react';
|
||||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||||
import { Unsubscribable } from 'rxjs';
|
import { Unsubscribable } from 'rxjs';
|
||||||
@ -6,64 +5,44 @@ import { Unsubscribable } from 'rxjs';
|
|||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
DataTransformerConfig,
|
DataTransformerConfig,
|
||||||
DocsId,
|
|
||||||
GrafanaTheme2,
|
|
||||||
PanelData,
|
PanelData,
|
||||||
SelectableValue,
|
SelectableValue,
|
||||||
standardTransformersRegistry,
|
standardTransformersRegistry,
|
||||||
TransformerRegistryItem,
|
|
||||||
TransformerCategory,
|
TransformerCategory,
|
||||||
DataTransformerID,
|
|
||||||
TransformationApplicabilityLevels,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { reportInteraction } from '@grafana/runtime';
|
import { reportInteraction } from '@grafana/runtime';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Button,
|
Button,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
Container,
|
Container,
|
||||||
CustomScrollbar,
|
CustomScrollbar,
|
||||||
FilterPill,
|
|
||||||
Themeable,
|
Themeable,
|
||||||
VerticalGroup,
|
|
||||||
withTheme,
|
withTheme,
|
||||||
Input,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
useStyles2,
|
ButtonGroup,
|
||||||
Card,
|
Box,
|
||||||
Switch,
|
Text,
|
||||||
|
Stack,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
|
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import { getDocsLink } from 'app/core/utils/docsLinks';
|
import { Trans } from 'app/core/internationalization';
|
||||||
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
|
|
||||||
import { categoriesLabels } from 'app/features/transformers/utils';
|
|
||||||
|
|
||||||
import { AppNotificationSeverity } from '../../../../types';
|
|
||||||
import { PanelModel } from '../../state';
|
import { PanelModel } from '../../state';
|
||||||
import { PanelNotSupported } from '../PanelEditor/PanelNotSupported';
|
import { PanelNotSupported } from '../PanelEditor/PanelNotSupported';
|
||||||
|
|
||||||
import { TransformationOperationRows } from './TransformationOperationRows';
|
import { TransformationOperationRows } from './TransformationOperationRows';
|
||||||
|
import { TransformationPicker } from './TransformationPicker';
|
||||||
|
import { TransformationPickerNg } from './TransformationPickerNg';
|
||||||
import { TransformationsEditorTransformation } from './types';
|
import { TransformationsEditorTransformation } from './types';
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = 'dashboard.components.TransformationEditor.featureInfoBox.isDismissed';
|
|
||||||
|
|
||||||
interface TransformationsEditorProps extends Themeable {
|
interface TransformationsEditorProps extends Themeable {
|
||||||
panel: PanelModel;
|
panel: PanelModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewAllType = 'viewAll';
|
const VIEW_ALL_VALUE = 'viewAll';
|
||||||
const viewAllValue = 'viewAll';
|
export type viewAllType = 'viewAll';
|
||||||
const viewAllLabel = 'View all';
|
export type FilterCategory = TransformerCategory | viewAllType;
|
||||||
|
|
||||||
type FilterCategory = TransformerCategory | viewAllType;
|
|
||||||
|
|
||||||
const filterCategoriesLabels: Array<[FilterCategory, string]> = [
|
|
||||||
[viewAllValue, viewAllLabel],
|
|
||||||
...(Object.entries(categoriesLabels) as Array<[FilterCategory, string]>),
|
|
||||||
];
|
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
data: DataFrame[];
|
data: DataFrame[];
|
||||||
@ -91,7 +70,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
|||||||
})),
|
})),
|
||||||
data: [],
|
data: [],
|
||||||
search: '',
|
search: '',
|
||||||
selectedFilter: viewAllValue,
|
selectedFilter: VIEW_ALL_VALUE,
|
||||||
showIllustrations: true,
|
showIllustrations: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -270,41 +249,82 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
|||||||
this.onChange(update);
|
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 = () => {
|
renderTransformationEditors = () => {
|
||||||
const styles = getStyles(config.theme2);
|
const { data, transformations } = this.state;
|
||||||
const { data, transformations, showPicker } = this.state;
|
|
||||||
const hide = config.featureToggles.transformationsRedesign && showPicker;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx({ [styles.hide]: hide })}>
|
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
<Droppable droppableId="transformations-list" direction="vertical">
|
||||||
<Droppable droppableId="transformations-list" direction="vertical">
|
{(provided) => {
|
||||||
{(provided) => {
|
return (
|
||||||
return (
|
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||||
<div ref={provided.innerRef} {...provided.droppableProps}>
|
<TransformationOperationRows
|
||||||
<TransformationOperationRows
|
configs={transformations}
|
||||||
configs={transformations}
|
data={data}
|
||||||
data={data}
|
onRemove={this.onTransformationRemove}
|
||||||
onRemove={this.onTransformationRemove}
|
onChange={this.onTransformationChange}
|
||||||
onChange={this.onTransformationChange}
|
/>
|
||||||
/>
|
{provided.placeholder}
|
||||||
{provided.placeholder}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}}
|
||||||
}}
|
</Droppable>
|
||||||
</Droppable>
|
</DragDropContext>
|
||||||
</DragDropContext>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
renderTransformsPicker() {
|
renderTransformsPicker() {
|
||||||
const styles = getStyles(config.theme2);
|
let { showPicker } = this.state;
|
||||||
const { transformations, search } = 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 suffix: React.ReactNode = null;
|
||||||
let xforms = standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0));
|
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(
|
xforms = xforms.filter(
|
||||||
(t) =>
|
(t) =>
|
||||||
t.categories &&
|
t.categories &&
|
||||||
@ -336,9 +356,6 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
|||||||
xforms = filtered;
|
xforms = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noTransforms = !transformations?.length;
|
|
||||||
const showPicker = noTransforms || this.state.showPicker;
|
|
||||||
|
|
||||||
if (!suffix && showPicker && !noTransforms) {
|
if (!suffix && showPicker && !noTransforms) {
|
||||||
suffix = (
|
suffix = (
|
||||||
<IconButton
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{noTransforms && !config.featureToggles.transformationsRedesign && (
|
{showPicker && picker}
|
||||||
<Container grow={1}>
|
{
|
||||||
<LocalStorageValueProvider<boolean> storageKey={LOCAL_STORAGE_KEY} defaultValue={false}>
|
// If the transformation redesign is enabled
|
||||||
{(isDismissed, onDismiss) => {
|
// and there are transforms then show actions
|
||||||
if (isDismissed) {
|
(transformationsRedesign && hasTransforms && actions) ||
|
||||||
return null;
|
// If it's not enabled only show actions when there are
|
||||||
}
|
// transformations and the (old) picker isn't being shown
|
||||||
|
(!transformationsRedesign && !showPicker && hasTransforms && actions)
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const styles = getStyles(config.theme2);
|
|
||||||
const {
|
const {
|
||||||
panel: { alert },
|
panel: { alert },
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { transformations } = this.state;
|
const { transformations } = this.state;
|
||||||
|
|
||||||
const hasTransforms = transformations.length > 0;
|
const hasTransforms = transformations.length > 0;
|
||||||
|
|
||||||
if (!hasTransforms && alert) {
|
// If there are any alerts then
|
||||||
return <PanelNotSupported message="Transformations can't be used on a panel with existing alerts" />;
|
// 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 (
|
return (
|
||||||
<CustomScrollbar scrollTop={this.state.scrollTop} autoHeightMin="100%">
|
<CustomScrollbar scrollTop={this.state.scrollTop} autoHeightMin="100%">
|
||||||
<Container padding="lg">
|
<Container padding="lg">
|
||||||
<div data-testid={selectors.components.TransformTab.content}>
|
<div data-testid={selectors.components.TransformTab.content}>
|
||||||
{hasTransforms && alert ? (
|
{!hasTransforms && config.featureToggles.transformationsRedesign && this.renderEmptyMessage()}
|
||||||
<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 && this.renderTransformationEditors()}
|
{hasTransforms && this.renderTransformationEditors()}
|
||||||
{this.renderTransformsPicker()}
|
{this.renderTransformsPicker()}
|
||||||
</div>
|
</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);
|
export const TransformationsEditor = withTheme(UnThemedTransformationsEditor);
|
||||||
|
@ -1284,6 +1284,12 @@
|
|||||||
"select-search-input": "Suchbegriff eingeben (Land, Stadt, Abkürzung)"
|
"select-search-input": "Suchbegriff eingeben (Land, Stadt, Abkürzung)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transformations": {
|
||||||
|
"empty": {
|
||||||
|
"add-transformation-body": "",
|
||||||
|
"add-transformation-header": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"user-orgs": {
|
"user-orgs": {
|
||||||
"current-org-button": "Aktuell",
|
"current-org-button": "Aktuell",
|
||||||
"name-column": "Name",
|
"name-column": "Name",
|
||||||
|
@ -1284,6 +1284,12 @@
|
|||||||
"select-search-input": "Type to search (country, city, abbreviation)"
|
"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": {
|
"user-orgs": {
|
||||||
"current-org-button": "Current",
|
"current-org-button": "Current",
|
||||||
"name-column": "Name",
|
"name-column": "Name",
|
||||||
|
@ -1290,6 +1290,12 @@
|
|||||||
"select-search-input": "Escribir para buscar (país, ciudad, abreviatura)"
|
"select-search-input": "Escribir para buscar (país, ciudad, abreviatura)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transformations": {
|
||||||
|
"empty": {
|
||||||
|
"add-transformation-body": "",
|
||||||
|
"add-transformation-header": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"user-orgs": {
|
"user-orgs": {
|
||||||
"current-org-button": "Actual",
|
"current-org-button": "Actual",
|
||||||
"name-column": "Nombre",
|
"name-column": "Nombre",
|
||||||
|
@ -1290,6 +1290,12 @@
|
|||||||
"select-search-input": "Tapez pour rechercher (pays, ville, abréviation)"
|
"select-search-input": "Tapez pour rechercher (pays, ville, abréviation)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transformations": {
|
||||||
|
"empty": {
|
||||||
|
"add-transformation-body": "",
|
||||||
|
"add-transformation-header": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"user-orgs": {
|
"user-orgs": {
|
||||||
"current-org-button": "Actuel",
|
"current-org-button": "Actuel",
|
||||||
"name-column": "Nom",
|
"name-column": "Nom",
|
||||||
|
@ -1284,6 +1284,12 @@
|
|||||||
"select-search-input": "Ŧypę ŧő şęäřčĥ (čőūʼnŧřy, čįŧy, äþþřęvįäŧįőʼn)"
|
"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": {
|
"user-orgs": {
|
||||||
"current-org-button": "Cūřřęʼnŧ",
|
"current-org-button": "Cūřřęʼnŧ",
|
||||||
"name-column": "Ńämę",
|
"name-column": "Ńämę",
|
||||||
|
@ -1278,6 +1278,12 @@
|
|||||||
"select-search-input": "输入以搜索(国家、城市、缩写)"
|
"select-search-input": "输入以搜索(国家、城市、缩写)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"transformations": {
|
||||||
|
"empty": {
|
||||||
|
"add-transformation-body": "",
|
||||||
|
"add-transformation-header": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"user-orgs": {
|
"user-orgs": {
|
||||||
"current-org-button": "当前",
|
"current-org-button": "当前",
|
||||||
"name-column": "姓名",
|
"name-column": "姓名",
|
||||||
|
Loading…
Reference in New Issue
Block a user