Transformations: enable transformations reordering (#27197)

* Transformations: enable queries reorder by drag and drop

* Satisfy ts

* Update unicons and replace ellipsis with draggabledot

* remove import

* Remove that snap

* Review

* review 2
This commit is contained in:
Dominik Prokop 2020-08-31 08:47:27 +02:00 committed by GitHub
parent 1a69bcfeff
commit 6dbb803b3f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 338 additions and 161 deletions

View File

@ -103,7 +103,9 @@ export const Components = {
}, },
TransformTab: { TransformTab: {
content: 'Transform editor tab content', content: 'Transform editor tab content',
newTransform: (title: string) => `New transform ${title}`, newTransform: (name: string) => `New transform ${name}`,
transformationEditor: (name: string) => `Transformation editor ${name}`,
transformationEditorDebugger: (name: string) => `Transformation editor debugger ${name}`,
}, },
Transforms: { Transforms: {
Reduce: { Reduce: {
@ -144,4 +146,7 @@ export const Components = {
container: 'Time zone picker select container', container: 'Time zone picker select container',
}, },
QueryField: { container: 'Query field' }, QueryField: { container: 'Query field' },
ValuePicker: {
select: (name: string) => `Value picker select ${name}`,
},
}; };

View File

@ -31,7 +31,7 @@
"@grafana/e2e-selectors": "7.2.0-pre.0", "@grafana/e2e-selectors": "7.2.0-pre.0",
"@grafana/slate-react": "0.22.9-grafana", "@grafana/slate-react": "0.22.9-grafana",
"@grafana/tsconfig": "^1.0.0-rc1", "@grafana/tsconfig": "^1.0.0-rc1",
"@iconscout/react-unicons": "^1.0.0", "@iconscout/react-unicons": "1.1.4",
"@torkelo/react-select": "3.0.8", "@torkelo/react-select": "3.0.8",
"@types/react-beautiful-dnd": "12.1.2", "@types/react-beautiful-dnd": "12.1.2",
"@types/react-color": "3.0.1", "@types/react-color": "3.0.1",

View File

@ -5,6 +5,7 @@ import { Button, ButtonVariant } from '../Button';
import { Select } from '../Select/Select'; import { Select } from '../Select/Select';
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer'; import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
import { ComponentSize } from '../../types/size'; import { ComponentSize } from '../../types/size';
import { selectors } from '@grafana/e2e-selectors';
interface ValuePickerProps<T> { interface ValuePickerProps<T> {
/** Label to display on the picker button */ /** Label to display on the picker button */
@ -42,6 +43,7 @@ export function ValuePicker<T>({
{!isPicking && (isFullWidth ? <FullWidthButtonContainer>{buttonEl}</FullWidthButtonContainer> : buttonEl)} {!isPicking && (isFullWidth ? <FullWidthButtonContainer>{buttonEl}</FullWidthButtonContainer> : buttonEl)}
{isPicking && ( {isPicking && (
<span aria-label={selectors.components.ValuePicker.select(label)}>
<Select <Select
placeholder={label} placeholder={label}
options={options} options={options}
@ -54,6 +56,7 @@ export function ValuePicker<T>({
}} }}
menuPlacement={menuPlacement} menuPlacement={menuPlacement}
/> />
</span>
)} )}
</> </>
); );

View File

@ -114,7 +114,8 @@ export type IconName =
| 'favorite' | 'favorite'
| 'line-alt' | 'line-alt'
| 'sort-amount-down' | 'sort-amount-down'
| 'cloud'; | 'cloud'
| 'draggabledots';
export const getAvailableIcons = (): IconName[] => [ export const getAvailableIcons = (): IconName[] => [
'fa fa-spinner', 'fa fa-spinner',
@ -228,4 +229,5 @@ export const getAvailableIcons = (): IconName[] => [
'favorite', 'favorite',
'sort-amount-down', 'sort-amount-down',
'cloud', 'cloud',
'draggabledots',
]; ];

View File

@ -7,7 +7,7 @@ describe('QueryOperationRow', () => {
it('renders', () => { it('renders', () => {
expect(() => expect(() =>
shallow( shallow(
<QueryOperationRow> <QueryOperationRow id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
) )
@ -20,7 +20,7 @@ describe('QueryOperationRow', () => {
// @ts-ignore strict null error, you shouldn't use promise like approach with act but I don't know what the intention is here // @ts-ignore strict null error, you shouldn't use promise like approach with act but I don't know what the intention is here
await act(async () => { await act(async () => {
shallow( shallow(
<QueryOperationRow onOpen={onOpenSpy}> <QueryOperationRow onOpen={onOpenSpy} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -32,7 +32,7 @@ describe('QueryOperationRow', () => {
const onOpenSpy = jest.fn(); const onOpenSpy = jest.fn();
const onCloseSpy = jest.fn(); const onCloseSpy = jest.fn();
const wrapper = mount( const wrapper = mount(
<QueryOperationRow onOpen={onOpenSpy} onClose={onCloseSpy} isOpen={false}> <QueryOperationRow onOpen={onOpenSpy} onClose={onCloseSpy} isOpen={false} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -60,7 +60,7 @@ describe('QueryOperationRow', () => {
it('should render title provided as element', () => { it('should render title provided as element', () => {
const title = <div aria-label="test title">Test</div>; const title = <div aria-label="test title">Test</div>;
const wrapper = shallow( const wrapper = shallow(
<QueryOperationRow title={title}> <QueryOperationRow title={title} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -71,7 +71,7 @@ describe('QueryOperationRow', () => {
it('should render title provided as function', () => { it('should render title provided as function', () => {
const title = () => <div aria-label="test title">Test</div>; const title = () => <div aria-label="test title">Test</div>;
const wrapper = shallow( const wrapper = shallow(
<QueryOperationRow title={title}> <QueryOperationRow title={title} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -87,7 +87,7 @@ describe('QueryOperationRow', () => {
return <div aria-label="test title">Test</div>; return <div aria-label="test title">Test</div>;
}; };
shallow( shallow(
<QueryOperationRow title={title}> <QueryOperationRow title={title} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -100,7 +100,7 @@ describe('QueryOperationRow', () => {
it('should render actions provided as element', () => { it('should render actions provided as element', () => {
const actions = <div aria-label="test actions">Test</div>; const actions = <div aria-label="test actions">Test</div>;
const wrapper = shallow( const wrapper = shallow(
<QueryOperationRow actions={actions}> <QueryOperationRow actions={actions} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -111,7 +111,7 @@ describe('QueryOperationRow', () => {
it('should render actions provided as function', () => { it('should render actions provided as function', () => {
const actions = () => <div aria-label="test actions">Test</div>; const actions = () => <div aria-label="test actions">Test</div>;
const wrapper = shallow( const wrapper = shallow(
<QueryOperationRow actions={actions}> <QueryOperationRow actions={actions} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -127,7 +127,7 @@ describe('QueryOperationRow', () => {
return <div aria-label="test actions">Test</div>; return <div aria-label="test actions">Test</div>;
}; };
shallow( shallow(
<QueryOperationRow actions={actions}> <QueryOperationRow actions={actions} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );

View File

@ -1,10 +1,13 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import { HorizontalGroup, Icon, renderOrCallToRender, stylesFactory, useTheme } from '@grafana/ui'; import { HorizontalGroup, Icon, renderOrCallToRender, stylesFactory, useTheme } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { css } from 'emotion'; import { css } from 'emotion';
import { useUpdateEffect } from 'react-use'; import { useUpdateEffect } from 'react-use';
import { Draggable } from 'react-beautiful-dnd';
interface QueryOperationRowProps { interface QueryOperationRowProps {
index: number;
id: string;
title?: ((props: { isOpen: boolean }) => React.ReactNode) | React.ReactNode; title?: ((props: { isOpen: boolean }) => React.ReactNode) | React.ReactNode;
headerElement?: React.ReactNode; headerElement?: React.ReactNode;
actions?: actions?:
@ -14,6 +17,7 @@ interface QueryOperationRowProps {
onClose?: () => void; onClose?: () => void;
children: React.ReactNode; children: React.ReactNode;
isOpen?: boolean; isOpen?: boolean;
draggable?: boolean;
} }
export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
@ -24,10 +28,16 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
onClose, onClose,
onOpen, onOpen,
isOpen, isOpen,
draggable,
index,
id,
}: QueryOperationRowProps) => { }: QueryOperationRowProps) => {
const [isContentVisible, setIsContentVisible] = useState(isOpen !== undefined ? isOpen : true); const [isContentVisible, setIsContentVisible] = useState(isOpen !== undefined ? isOpen : true);
const theme = useTheme(); const theme = useTheme();
const styles = getQueryOperationRowStyles(theme); const styles = getQueryOperationRowStyles(theme);
const onRowToggle = useCallback(() => {
setIsContentVisible(!isContentVisible);
}, [isContentVisible, setIsContentVisible]);
useUpdateEffect(() => { useUpdateEffect(() => {
if (isContentVisible) { if (isContentVisible) {
@ -54,17 +64,13 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
}, },
}); });
return ( const rowHeader = (
<div className={styles.wrapper}>
<div className={styles.header}> <div className={styles.header}>
<HorizontalGroup justify="space-between"> <HorizontalGroup justify="space-between">
<div <div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
className={styles.titleWrapper} {draggable && (
onClick={() => { <Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} />
setIsContentVisible(!isContentVisible); )}
}}
aria-label="Query operation row title"
>
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} /> <Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
{title && <span className={styles.title}>{titleElement}</span>} {title && <span className={styles.title}>{titleElement}</span>}
{headerElement} {headerElement}
@ -72,6 +78,23 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
{actions && actionsElement} {actions && actionsElement}
</HorizontalGroup> </HorizontalGroup>
</div> </div>
);
return draggable ? (
<Draggable draggableId={id} index={index}>
{provided => {
return (
<>
<div ref={provided.innerRef} className={styles.wrapper} {...provided.draggableProps}>
<div {...provided.dragHandleProps}>{rowHeader}</div>
{isContentVisible && <div className={styles.content}>{children}</div>}
</div>
</>
);
}}
</Draggable>
) : (
<div className={styles.wrapper}>
{rowHeader}
{isContentVisible && <div className={styles.content}>{children}</div>} {isContentVisible && <div className={styles.content}>{children}</div>}
</div> </div>
); );
@ -92,6 +115,10 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
`, `,
dragIcon: css`
opacity: 0.4;
cursor: drag;
`,
collapseIcon: css` collapseIcon: css`
color: ${theme.colors.textWeak}; color: ${theme.colors.textWeak};
&:hover { &:hover {

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { css, cx } from 'emotion'; import { css } from 'emotion';
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'; import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
import { import {
DataFrame, DataFrame,
@ -10,7 +10,7 @@ import {
TransformerUIProps, TransformerUIProps,
getFieldDisplayName, getFieldDisplayName,
} from '@grafana/data'; } from '@grafana/data';
import { stylesFactory, useTheme, Input, IconButton } from '@grafana/ui'; import { stylesFactory, useTheme, Input, IconButton, Icon } from '@grafana/ui';
import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize'; import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize';
import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order'; import { createOrderFieldsComparer } from '@grafana/data/src/transformations/transformers/order';
@ -135,7 +135,7 @@ const DraggableFieldName: React.FC<DraggableFieldProps> = ({
> >
<div className="gf-form gf-form--grow"> <div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--justify-left width-30"> <div className="gf-form-label gf-form-label--justify-left width-30">
<i className={cx('fa fa-ellipsis-v', styles.draggable)} /> <Icon name="draggabledots" title="Drag and drop to reorder" size="lg" className={styles.draggable} />
<IconButton <IconButton
className={styles.toggle} className={styles.toggle}
size="md" size="md"
@ -168,8 +168,6 @@ const getFieldNameStyles = stylesFactory((theme: GrafanaTheme) => ({
color: ${theme.colors.textWeak}; color: ${theme.colors.textWeak};
`, `,
draggable: css` draggable: css`
padding: 0 ${theme.spacing.xs};
font-size: ${theme.typography.size.md};
opacity: 0.4; opacity: 0.4;
&:hover { &:hover {
color: ${theme.colors.textStrong}; color: ${theme.colors.textStrong};

View File

@ -64,7 +64,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
const { timeIndex, timeField } = getTimeField(dataFrame); const { timeIndex, timeField } = getTimeField(dataFrame);
if (timeField) { if (timeField) {
// Use the configurd date or standandard time display // Use the configured date or standard time display
let processor: DisplayProcessor | undefined = timeField.display; let processor: DisplayProcessor | undefined = timeField.display;
if (!processor) { if (!processor) {
processor = getDisplayProcessor({ processor = getDisplayProcessor({
@ -224,6 +224,8 @@ export class InspectDataTab extends PureComponent<Props, State> {
return ( return (
<QueryOperationRow <QueryOperationRow
id="Table data options"
index={0}
title="Table data options" title="Table data options"
headerElement={<DetailText>{this.getActiveString()}</DetailText>} headerElement={<DetailText>{this.getActiveString()}</DetailText>}
isOpen={false} isOpen={false}

View File

@ -2,6 +2,7 @@ import React, { useContext } from 'react';
import { css } from 'emotion'; import { css } from 'emotion';
import { Icon, JSONFormatter, ThemeContext } from '@grafana/ui'; import { Icon, JSONFormatter, ThemeContext } from '@grafana/ui';
import { GrafanaTheme, DataFrame } from '@grafana/data'; import { GrafanaTheme, DataFrame } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
interface TransformationEditorProps { interface TransformationEditorProps {
name: string; name: string;
@ -12,15 +13,18 @@ interface TransformationEditorProps {
debugMode?: boolean; debugMode?: boolean;
} }
export const TransformationEditor = ({ editor, input, output, debugMode }: TransformationEditorProps) => { export const TransformationEditor = ({ editor, input, output, debugMode, name }: TransformationEditorProps) => {
const theme = useContext(ThemeContext); const theme = useContext(ThemeContext);
const styles = getStyles(theme); const styles = getStyles(theme);
return ( return (
<div className={styles.editor}> <div className={styles.editor} aria-label={selectors.components.TransformTab.transformationEditor(name)}>
{editor} {editor}
{debugMode && ( {debugMode && (
<div className={styles.debugWrapper}> <div
className={styles.debugWrapper}
aria-label={selectors.components.TransformTab.transformationEditorDebugger(name)}
>
<div className={styles.debug}> <div className={styles.debug}>
<div className={styles.debugTitle}>Transformation input data</div> <div className={styles.debugTitle}>Transformation input data</div>
<div className={styles.debugJson}> <div className={styles.debugJson}>

View File

@ -6,17 +6,21 @@ import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOp
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
interface TransformationOperationRowProps { interface TransformationOperationRowProps {
id: string;
index: number;
name: string; name: string;
description?: string; description?: string;
editor?: JSX.Element;
onRemove: () => void;
input: DataFrame[]; input: DataFrame[];
output: DataFrame[]; output: DataFrame[];
editor?: JSX.Element;
onRemove: () => void;
} }
export const TransformationOperationRow: React.FC<TransformationOperationRowProps> = ({ export const TransformationOperationRow: React.FC<TransformationOperationRowProps> = ({
children, children,
onRemove, onRemove,
index,
id,
...props ...props
}) => { }) => {
const [showDebug, setShowDebug] = useState(false); const [showDebug, setShowDebug] = useState(false);
@ -39,7 +43,7 @@ export const TransformationOperationRow: React.FC<TransformationOperationRowProp
}; };
return ( return (
<QueryOperationRow title={props.name} actions={renderActions}> <QueryOperationRow id={id} index={index} title={props.name} draggable actions={renderActions}>
<TransformationEditor {...props} debugMode={showDebug} /> <TransformationEditor {...props} debugMode={showDebug} />
</QueryOperationRow> </QueryOperationRow>
); );

View File

@ -0,0 +1,89 @@
import React from 'react';
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
import { render, screen, fireEvent } from '@testing-library/react';
import { TransformationsEditor } from './TransformationsEditor';
import { PanelModel } from '../../state';
import { getStandardTransformers } from 'app/core/utils/standardTransformers';
import { selectors } from '@grafana/e2e-selectors';
const setup = (transformations: DataTransformerConfig[] = []) => {
const panel = new PanelModel({});
panel.setTransformations(transformations);
render(<TransformationsEditor panel={panel} />);
};
describe('TransformationsEditor', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
describe('when no transformations configured', () => {
it('renders transformations selection list', () => {
setup();
const cards = screen.getAllByLabelText(/^New transform/i);
expect(cards.length).toEqual(standardTransformersRegistry.list().length);
});
});
describe('when transformations configured', () => {
it('renders transformation editors', () => {
setup([
{
id: 'reduce',
options: {},
},
]);
const editors = screen.getAllByLabelText(/^Transformation editor/g);
expect(editors).toHaveLength(1);
});
});
describe('when Add transformation clicked', () => {
it('renders transformations picker', () => {
const buttonLabel = 'Add transformation';
setup([
{
id: 'reduce',
options: {},
},
]);
const addTransformationButton = screen.getByText(buttonLabel);
fireEvent(
addTransformationButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
})
);
const picker = screen.getByLabelText(selectors.components.ValuePicker.select(buttonLabel));
expect(picker).toBeDefined();
});
});
describe('actions', () => {
describe('debug', () => {
it('should show/hide debugger', () => {
setup([
{
id: 'reduce',
options: {},
},
]);
const debuggerSelector = selectors.components.TransformTab.transformationEditorDebugger('Reduce');
expect(screen.queryByLabelText(debuggerSelector)).toBeNull();
const debugButton = screen.getByLabelText(selectors.components.QueryEditorRow.actionButton('Debug'));
fireEvent(
debugButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
})
);
expect(screen.getByLabelText(debuggerSelector)).toBeInTheDocument();
});
});
});
});

View File

@ -26,28 +26,55 @@ import { selectors } from '@grafana/e2e-selectors';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
import { PanelModel } from '../../state'; import { PanelModel } from '../../state';
import { getDocsLink } from 'app/core/utils/docsLinks'; import { getDocsLink } from 'app/core/utils/docsLinks';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
interface Props { interface TransformationsEditorProps {
panel: PanelModel; panel: PanelModel;
} }
interface TransformationsEditorTransformation {
transformation: DataTransformerConfig;
id: string;
}
interface State { interface State {
data: DataFrame[]; data: DataFrame[];
transformations: DataTransformerConfig[]; transformations: TransformationsEditorTransformation[];
} }
export class TransformationsEditor extends React.PureComponent<Props, State> { export class TransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
subscription?: Unsubscribable; subscription?: Unsubscribable;
constructor(props: Props) { constructor(props: TransformationsEditorProps) {
super(props); super(props);
const transformations = props.panel.transformations || [];
const ids = this.buildTransformationIds(transformations);
this.state = { this.state = {
transformations: props.panel.transformations || [], transformations: transformations.map((t, i) => ({
transformation: t,
id: ids[i],
})),
data: [], data: [],
}; };
} }
buildTransformationIds(transformations: DataTransformerConfig[]) {
const transformationCounters: Record<string, number> = {};
const transformationIds: string[] = [];
for (let i = 0; i < transformations.length; i++) {
const transformation = transformations[i];
if (transformationCounters[transformation.id] === undefined) {
transformationCounters[transformation.id] = 0;
} else {
transformationCounters[transformation.id] += 1;
}
transformationIds.push(`${transformations[i].id}-${transformationCounters[transformations[i].id]}`);
}
return transformationIds;
}
componentDidMount() { componentDidMount() {
this.subscription = this.props.panel this.subscription = this.props.panel
.getQueryRunner() .getQueryRunner()
@ -63,27 +90,45 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
} }
} }
onChange(transformations: DataTransformerConfig[]) { onChange(transformations: TransformationsEditorTransformation[]) {
this.props.panel.setTransformations(transformations);
this.setState({ transformations }); this.setState({ transformations });
this.props.panel.setTransformations(transformations.map(t => t.transformation));
} }
// Transformation uid are stored in a name-X form. name is NOT unique hence we need to parse the ids and increase X
// for transformations with the same name
getTransformationNextId = (name: string) => {
const { transformations } = this.state;
let nextId = 0;
const existingIds = transformations.filter(t => t.id.startsWith(name)).map(t => t.id);
if (existingIds.length !== 0) {
nextId = Math.max(...existingIds.map(i => parseInt(i.match(/\d+/)![0], 10))) + 1;
}
return `${name}-${nextId}`;
};
onTransformationAdd = (selectable: SelectableValue<string>) => { onTransformationAdd = (selectable: SelectableValue<string>) => {
const { transformations } = this.state; const { transformations } = this.state;
const nextId = this.getTransformationNextId(selectable.value!);
this.onChange([ this.onChange([
...transformations, ...transformations,
{ {
id: nextId,
transformation: {
id: selectable.value as string, id: selectable.value as string,
options: {}, options: {},
}, },
},
]); ]);
}; };
onTransformationChange = (idx: number, config: DataTransformerConfig) => { onTransformationChange = (idx: number, config: DataTransformerConfig) => {
const { transformations } = this.state; const { transformations } = this.state;
const next = Array.from(transformations); const next = Array.from(transformations);
next[idx] = config; next[idx].transformation = config;
this.onChange(next); this.onChange(next);
}; };
@ -122,29 +167,60 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
); );
}; };
onDragEnd = (result: DropResult) => {
const { transformations } = this.state;
if (!result || !result.destination) {
return;
}
const startIndex = result.source.index;
const endIndex = result.destination.index;
if (startIndex === endIndex) {
return;
}
const update = Array.from(transformations);
const [removed] = update.splice(startIndex, 1);
update.splice(endIndex, 0, removed);
this.onChange(update);
};
renderTransformationEditors = () => { renderTransformationEditors = () => {
const { data, transformations } = this.state; const { data, transformations } = this.state;
return ( return (
<> <DragDropContext onDragEnd={this.onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{provided => {
return (
<div ref={provided.innerRef} {...provided.droppableProps}>
{transformations.map((t, i) => { {transformations.map((t, i) => {
// Transformations are not identified uniquely by any property apart from array index.
// For drag and drop to work we need to generate unique ids. This record stores counters for each transformation type
// based on which ids are generated
let editor; let editor;
const transformationUI = standardTransformersRegistry.getIfExists(t.id); const transformationUI = standardTransformersRegistry.getIfExists(t.transformation.id);
if (!transformationUI) { if (!transformationUI) {
return null; return null;
} }
const input = transformDataFrame(transformations.slice(0, i), data); const input = transformDataFrame(
const output = transformDataFrame(transformations.slice(i), input); transformations.slice(0, i).map(t => t.transformation),
data
);
const output = transformDataFrame(
transformations.slice(i).map(t => t.transformation),
input
);
if (transformationUI) { if (transformationUI) {
editor = React.createElement(transformationUI.editor, { editor = React.createElement(transformationUI.editor, {
options: { ...transformationUI.transformation.defaultOptions, ...t.options }, options: { ...transformationUI.transformation.defaultOptions, ...t.transformation.options },
input, input,
onChange: (options: any) => { onChange: (options: any) => {
this.onTransformationChange(i, { this.onTransformationChange(i, {
id: t.id, id: t.transformation.id,
options, options,
}); });
}, },
@ -153,17 +229,24 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
return ( return (
<TransformationOperationRow <TransformationOperationRow
key={`${t.id}-${i}`} index={i}
id={`${t.id}`}
key={`${t.id}`}
input={input || []} input={input || []}
output={output || []} output={output || []}
onRemove={() => this.onTransformationRemove(i)} onRemove={() => this.onTransformationRemove(i)}
editor={editor} editor={editor}
name={transformationUI ? transformationUI.name : ''} name={transformationUI.name}
description={transformationUI ? transformationUI.description : ''} description={transformationUI.description}
/> />
); );
})} })}
</> {provided.placeholder}
</div>
);
}}
</Droppable>
</DragDropContext>
); );
}; };

View File

@ -31,12 +31,14 @@ interface Props {
data: PanelData; data: PanelData;
query: DataQuery; query: DataQuery;
dashboard: DashboardModel; dashboard: DashboardModel;
dataSourceValue: string | null;
inMixedMode?: boolean;
id: string;
index: number;
onAddQuery: (query?: DataQuery) => void; onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void; onRemoveQuery: (query: DataQuery) => void;
onMoveQuery: (query: DataQuery, direction: number) => void; onMoveQuery: (query: DataQuery, direction: number) => void;
onChange: (query: DataQuery) => void; onChange: (query: DataQuery) => void;
dataSourceValue: string | null;
inMixedMode?: boolean;
} }
interface State { interface State {
@ -276,7 +278,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
}; };
render() { render() {
const { query } = this.props; const { query, id, index } = this.props;
const { datasource } = this.state; const { datasource } = this.state;
const isDisabled = query.hide; const isDisabled = query.hide;
@ -293,7 +295,13 @@ export class QueryEditorRow extends PureComponent<Props, State> {
return ( return (
<div aria-label={selectors.components.QueryEditorRows.rows}> <div aria-label={selectors.components.QueryEditorRows.rows}>
<QueryOperationRow title={this.renderTitle} actions={this.renderActions} onOpen={this.onOpen}> <QueryOperationRow
id={id}
index={index}
title={this.renderTitle}
actions={this.renderActions}
onOpen={this.onOpen}
>
<div className={rowClasses}> <div className={rowClasses}>
<ErrorBoundaryAlert>{editor}</ErrorBoundaryAlert> <ErrorBoundaryAlert>{editor}</ErrorBoundaryAlert>
</div> </div>

View File

@ -81,6 +81,8 @@ export class QueryEditorRows extends PureComponent<Props> {
return props.queries.map((query, index) => ( return props.queries.map((query, index) => (
<QueryEditorRow <QueryEditorRow
dataSourceValue={query.datasource || props.datasource.value} dataSourceValue={query.datasource || props.datasource.value}
id={query.refId}
index={index}
key={query.refId} key={query.refId}
panel={props.panel} panel={props.panel}
dashboard={props.dashboard} dashboard={props.dashboard}

View File

@ -307,6 +307,8 @@ export class QueryOptions extends PureComponent<Props, State> {
return ( return (
<QueryOperationRow <QueryOperationRow
id="Query options"
index={0}
title="Query options" title="Query options"
headerElement={this.renderCollapsedText(styles)} headerElement={this.renderCollapsedText(styles)}
isOpen={isOpen} isOpen={isOpen}

View File

@ -8,10 +8,6 @@ const setup = (isSynced: boolean) => {
}; };
describe('TimeSyncButton', () => { describe('TimeSyncButton', () => {
it('should render component', () => {
const wrapper = setup(true);
expect(wrapper).toMatchSnapshot();
});
it('should change style when synced', () => { it('should change style when synced', () => {
const wrapper = setup(true); const wrapper = setup(true);
expect(wrapper.find('button').props()['aria-label']).toEqual('Synced times'); expect(wrapper.find('button').props()['aria-label']).toEqual('Synced times');

View File

@ -1,55 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TimeSyncButton should render component 1`] = `
<TimeSyncButton
isSynced={true}
onClick={[Function]}
>
<Component
content={[Function]}
placement="bottom"
>
<PopoverController
content={[Function]}
placement="bottom"
>
<button
aria-label="Synced times"
className="btn navbar-button navbar-button--attached explore-active-button"
onClick={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<Icon
className="css-1c3xqzq-topPadding icon-brand-gradient"
name="link"
size="lg"
>
<div
className="css-1cvxpvr"
>
<UilLink
className="icon-brand-gradient css-1q2xpj8-topPadding"
color="currentColor"
size={18}
>
<svg
className="icon-brand-gradient css-1q2xpj8-topPadding"
fill="currentColor"
height={18}
viewBox="0 0 24 24"
width={18}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10,17.55,8.23,19.27a2.47,2.47,0,0,1-3.5-3.5l4.54-4.55a2.46,2.46,0,0,1,3.39-.09l.12.1a1,1,0,0,0,1.4-1.43A2.75,2.75,0,0,0,14,9.59a4.46,4.46,0,0,0-6.09.22L3.31,14.36a4.48,4.48,0,0,0,6.33,6.33L11.37,19A1,1,0,0,0,10,17.55ZM20.69,3.31a4.49,4.49,0,0,0-6.33,0L12.63,5A1,1,0,0,0,14,6.45l1.73-1.72a2.47,2.47,0,0,1,3.5,3.5l-4.54,4.55a2.46,2.46,0,0,1-3.39.09l-.12-.1a1,1,0,0,0-1.4,1.43,2.75,2.75,0,0,0,.23.21,4.47,4.47,0,0,0,6.09-.22l4.55-4.55A4.49,4.49,0,0,0,20.69,3.31Z"
/>
</svg>
</UilLink>
</div>
</Icon>
</button>
</PopoverController>
</Component>
</TimeSyncButton>
`;

View File

@ -3430,6 +3430,13 @@
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==
"@iconscout/react-unicons@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@iconscout/react-unicons/-/react-unicons-1.1.4.tgz#30731707e1baa49ab1c3198a5e0121be86b8928a"
integrity sha512-lhTSU3nKvzt1OwsRfFyK194QcQbE1Q4rRm6d5BOnKyZB+SN4qRv7tS4wLQgwk/pQyzn14Qw70nGA+tuKLRqcJg==
dependencies:
react ">=15.0.0 <17.0.0"
"@iconscout/react-unicons@^1.0.0": "@iconscout/react-unicons@^1.0.0":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@iconscout/react-unicons/-/react-unicons-1.0.1.tgz#b5309fac3b0cc27014da1e48edf29dd3d54a672f" resolved "https://registry.yarnpkg.com/@iconscout/react-unicons/-/react-unicons-1.0.1.tgz#b5309fac3b0cc27014da1e48edf29dd3d54a672f"