mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
1a69bcfeff
commit
6dbb803b3f
@ -103,7 +103,9 @@ export const Components = {
|
||||
},
|
||||
TransformTab: {
|
||||
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: {
|
||||
Reduce: {
|
||||
@ -144,4 +146,7 @@ export const Components = {
|
||||
container: 'Time zone picker select container',
|
||||
},
|
||||
QueryField: { container: 'Query field' },
|
||||
ValuePicker: {
|
||||
select: (name: string) => `Value picker select ${name}`,
|
||||
},
|
||||
};
|
||||
|
@ -31,7 +31,7 @@
|
||||
"@grafana/e2e-selectors": "7.2.0-pre.0",
|
||||
"@grafana/slate-react": "0.22.9-grafana",
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@iconscout/react-unicons": "^1.0.0",
|
||||
"@iconscout/react-unicons": "1.1.4",
|
||||
"@torkelo/react-select": "3.0.8",
|
||||
"@types/react-beautiful-dnd": "12.1.2",
|
||||
"@types/react-color": "3.0.1",
|
||||
|
@ -5,6 +5,7 @@ import { Button, ButtonVariant } from '../Button';
|
||||
import { Select } from '../Select/Select';
|
||||
import { FullWidthButtonContainer } from '../Button/FullWidthButtonContainer';
|
||||
import { ComponentSize } from '../../types/size';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
interface ValuePickerProps<T> {
|
||||
/** Label to display on the picker button */
|
||||
@ -42,18 +43,20 @@ export function ValuePicker<T>({
|
||||
{!isPicking && (isFullWidth ? <FullWidthButtonContainer>{buttonEl}</FullWidthButtonContainer> : buttonEl)}
|
||||
|
||||
{isPicking && (
|
||||
<Select
|
||||
placeholder={label}
|
||||
options={options}
|
||||
isOpen
|
||||
onCloseMenu={() => setIsPicking(false)}
|
||||
autoFocus={true}
|
||||
onChange={value => {
|
||||
setIsPicking(false);
|
||||
onChange(value);
|
||||
}}
|
||||
menuPlacement={menuPlacement}
|
||||
/>
|
||||
<span aria-label={selectors.components.ValuePicker.select(label)}>
|
||||
<Select
|
||||
placeholder={label}
|
||||
options={options}
|
||||
isOpen
|
||||
onCloseMenu={() => setIsPicking(false)}
|
||||
autoFocus={true}
|
||||
onChange={value => {
|
||||
setIsPicking(false);
|
||||
onChange(value);
|
||||
}}
|
||||
menuPlacement={menuPlacement}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -114,7 +114,8 @@ export type IconName =
|
||||
| 'favorite'
|
||||
| 'line-alt'
|
||||
| 'sort-amount-down'
|
||||
| 'cloud';
|
||||
| 'cloud'
|
||||
| 'draggabledots';
|
||||
|
||||
export const getAvailableIcons = (): IconName[] => [
|
||||
'fa fa-spinner',
|
||||
@ -228,4 +229,5 @@ export const getAvailableIcons = (): IconName[] => [
|
||||
'favorite',
|
||||
'sort-amount-down',
|
||||
'cloud',
|
||||
'draggabledots',
|
||||
];
|
||||
|
@ -7,7 +7,7 @@ describe('QueryOperationRow', () => {
|
||||
it('renders', () => {
|
||||
expect(() =>
|
||||
shallow(
|
||||
<QueryOperationRow>
|
||||
<QueryOperationRow id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</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
|
||||
await act(async () => {
|
||||
shallow(
|
||||
<QueryOperationRow onOpen={onOpenSpy}>
|
||||
<QueryOperationRow onOpen={onOpenSpy} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -32,7 +32,7 @@ describe('QueryOperationRow', () => {
|
||||
const onOpenSpy = jest.fn();
|
||||
const onCloseSpy = jest.fn();
|
||||
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>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -60,7 +60,7 @@ describe('QueryOperationRow', () => {
|
||||
it('should render title provided as element', () => {
|
||||
const title = <div aria-label="test title">Test</div>;
|
||||
const wrapper = shallow(
|
||||
<QueryOperationRow title={title}>
|
||||
<QueryOperationRow title={title} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -71,7 +71,7 @@ describe('QueryOperationRow', () => {
|
||||
it('should render title provided as function', () => {
|
||||
const title = () => <div aria-label="test title">Test</div>;
|
||||
const wrapper = shallow(
|
||||
<QueryOperationRow title={title}>
|
||||
<QueryOperationRow title={title} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -87,7 +87,7 @@ describe('QueryOperationRow', () => {
|
||||
return <div aria-label="test title">Test</div>;
|
||||
};
|
||||
shallow(
|
||||
<QueryOperationRow title={title}>
|
||||
<QueryOperationRow title={title} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -100,7 +100,7 @@ describe('QueryOperationRow', () => {
|
||||
it('should render actions provided as element', () => {
|
||||
const actions = <div aria-label="test actions">Test</div>;
|
||||
const wrapper = shallow(
|
||||
<QueryOperationRow actions={actions}>
|
||||
<QueryOperationRow actions={actions} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -111,7 +111,7 @@ describe('QueryOperationRow', () => {
|
||||
it('should render actions provided as function', () => {
|
||||
const actions = () => <div aria-label="test actions">Test</div>;
|
||||
const wrapper = shallow(
|
||||
<QueryOperationRow actions={actions}>
|
||||
<QueryOperationRow actions={actions} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
@ -127,7 +127,7 @@ describe('QueryOperationRow', () => {
|
||||
return <div aria-label="test actions">Test</div>;
|
||||
};
|
||||
shallow(
|
||||
<QueryOperationRow actions={actions}>
|
||||
<QueryOperationRow actions={actions} id="test-id" index={0}>
|
||||
<div>Test</div>
|
||||
</QueryOperationRow>
|
||||
);
|
||||
|
@ -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 { GrafanaTheme } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import { useUpdateEffect } from 'react-use';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
|
||||
interface QueryOperationRowProps {
|
||||
index: number;
|
||||
id: string;
|
||||
title?: ((props: { isOpen: boolean }) => React.ReactNode) | React.ReactNode;
|
||||
headerElement?: React.ReactNode;
|
||||
actions?:
|
||||
@ -14,6 +17,7 @@ interface QueryOperationRowProps {
|
||||
onClose?: () => void;
|
||||
children: React.ReactNode;
|
||||
isOpen?: boolean;
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
|
||||
@ -24,10 +28,16 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
|
||||
onClose,
|
||||
onOpen,
|
||||
isOpen,
|
||||
draggable,
|
||||
index,
|
||||
id,
|
||||
}: QueryOperationRowProps) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(isOpen !== undefined ? isOpen : true);
|
||||
const theme = useTheme();
|
||||
const styles = getQueryOperationRowStyles(theme);
|
||||
const onRowToggle = useCallback(() => {
|
||||
setIsContentVisible(!isContentVisible);
|
||||
}, [isContentVisible, setIsContentVisible]);
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (isContentVisible) {
|
||||
@ -54,24 +64,37 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
const rowHeader = (
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
|
||||
{draggable && (
|
||||
<Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} />
|
||||
)}
|
||||
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
|
||||
{title && <span className={styles.title}>{titleElement}</span>}
|
||||
{headerElement}
|
||||
</div>
|
||||
{actions && actionsElement}
|
||||
</HorizontalGroup>
|
||||
</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}>
|
||||
<div className={styles.header}>
|
||||
<HorizontalGroup justify="space-between">
|
||||
<div
|
||||
className={styles.titleWrapper}
|
||||
onClick={() => {
|
||||
setIsContentVisible(!isContentVisible);
|
||||
}}
|
||||
aria-label="Query operation row title"
|
||||
>
|
||||
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
|
||||
{title && <span className={styles.title}>{titleElement}</span>}
|
||||
{headerElement}
|
||||
</div>
|
||||
{actions && actionsElement}
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
{rowHeader}
|
||||
{isContentVisible && <div className={styles.content}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
@ -92,6 +115,10 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
`,
|
||||
dragIcon: css`
|
||||
opacity: 0.4;
|
||||
cursor: drag;
|
||||
`,
|
||||
collapseIcon: css`
|
||||
color: ${theme.colors.textWeak};
|
||||
&:hover {
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 {
|
||||
DataFrame,
|
||||
@ -10,7 +10,7 @@ import {
|
||||
TransformerUIProps,
|
||||
getFieldDisplayName,
|
||||
} 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 { 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-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
|
||||
className={styles.toggle}
|
||||
size="md"
|
||||
@ -168,8 +168,6 @@ const getFieldNameStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
color: ${theme.colors.textWeak};
|
||||
`,
|
||||
draggable: css`
|
||||
padding: 0 ${theme.spacing.xs};
|
||||
font-size: ${theme.typography.size.md};
|
||||
opacity: 0.4;
|
||||
&:hover {
|
||||
color: ${theme.colors.textStrong};
|
||||
|
@ -64,7 +64,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
const { timeIndex, timeField } = getTimeField(dataFrame);
|
||||
|
||||
if (timeField) {
|
||||
// Use the configurd date or standandard time display
|
||||
// Use the configured date or standard time display
|
||||
let processor: DisplayProcessor | undefined = timeField.display;
|
||||
if (!processor) {
|
||||
processor = getDisplayProcessor({
|
||||
@ -224,6 +224,8 @@ export class InspectDataTab extends PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<QueryOperationRow
|
||||
id="Table data options"
|
||||
index={0}
|
||||
title="Table data options"
|
||||
headerElement={<DetailText>{this.getActiveString()}</DetailText>}
|
||||
isOpen={false}
|
||||
|
@ -2,6 +2,7 @@ import React, { useContext } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { Icon, JSONFormatter, ThemeContext } from '@grafana/ui';
|
||||
import { GrafanaTheme, DataFrame } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
interface TransformationEditorProps {
|
||||
name: string;
|
||||
@ -12,15 +13,18 @@ interface TransformationEditorProps {
|
||||
debugMode?: boolean;
|
||||
}
|
||||
|
||||
export const TransformationEditor = ({ editor, input, output, debugMode }: TransformationEditorProps) => {
|
||||
export const TransformationEditor = ({ editor, input, output, debugMode, name }: TransformationEditorProps) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<div className={styles.editor}>
|
||||
<div className={styles.editor} aria-label={selectors.components.TransformTab.transformationEditor(name)}>
|
||||
{editor}
|
||||
{debugMode && (
|
||||
<div className={styles.debugWrapper}>
|
||||
<div
|
||||
className={styles.debugWrapper}
|
||||
aria-label={selectors.components.TransformTab.transformationEditorDebugger(name)}
|
||||
>
|
||||
<div className={styles.debug}>
|
||||
<div className={styles.debugTitle}>Transformation input data</div>
|
||||
<div className={styles.debugJson}>
|
||||
|
@ -6,17 +6,21 @@ import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOp
|
||||
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
|
||||
|
||||
interface TransformationOperationRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
editor?: JSX.Element;
|
||||
onRemove: () => void;
|
||||
input: DataFrame[];
|
||||
output: DataFrame[];
|
||||
editor?: JSX.Element;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const TransformationOperationRow: React.FC<TransformationOperationRowProps> = ({
|
||||
children,
|
||||
onRemove,
|
||||
index,
|
||||
id,
|
||||
...props
|
||||
}) => {
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
@ -39,7 +43,7 @@ export const TransformationOperationRow: React.FC<TransformationOperationRowProp
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryOperationRow title={props.name} actions={renderActions}>
|
||||
<QueryOperationRow id={id} index={index} title={props.name} draggable actions={renderActions}>
|
||||
<TransformationEditor {...props} debugMode={showDebug} />
|
||||
</QueryOperationRow>
|
||||
);
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -26,28 +26,55 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
import { PanelModel } from '../../state';
|
||||
import { getDocsLink } from 'app/core/utils/docsLinks';
|
||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
interface Props {
|
||||
interface TransformationsEditorProps {
|
||||
panel: PanelModel;
|
||||
}
|
||||
|
||||
interface TransformationsEditorTransformation {
|
||||
transformation: DataTransformerConfig;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
data: DataFrame[];
|
||||
transformations: DataTransformerConfig[];
|
||||
transformations: TransformationsEditorTransformation[];
|
||||
}
|
||||
|
||||
export class TransformationsEditor extends React.PureComponent<Props, State> {
|
||||
export class TransformationsEditor extends React.PureComponent<TransformationsEditorProps, State> {
|
||||
subscription?: Unsubscribable;
|
||||
|
||||
constructor(props: Props) {
|
||||
constructor(props: TransformationsEditorProps) {
|
||||
super(props);
|
||||
const transformations = props.panel.transformations || [];
|
||||
|
||||
const ids = this.buildTransformationIds(transformations);
|
||||
this.state = {
|
||||
transformations: props.panel.transformations || [],
|
||||
transformations: transformations.map((t, i) => ({
|
||||
transformation: t,
|
||||
id: ids[i],
|
||||
})),
|
||||
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() {
|
||||
this.subscription = this.props.panel
|
||||
.getQueryRunner()
|
||||
@ -63,19 +90,37 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
onChange(transformations: DataTransformerConfig[]) {
|
||||
this.props.panel.setTransformations(transformations);
|
||||
onChange(transformations: TransformationsEditorTransformation[]) {
|
||||
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>) => {
|
||||
const { transformations } = this.state;
|
||||
|
||||
const nextId = this.getTransformationNextId(selectable.value!);
|
||||
this.onChange([
|
||||
...transformations,
|
||||
{
|
||||
id: selectable.value as string,
|
||||
options: {},
|
||||
id: nextId,
|
||||
transformation: {
|
||||
id: selectable.value as string,
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
@ -83,7 +128,7 @@ export class TransformationsEditor extends React.PureComponent<Props, State> {
|
||||
onTransformationChange = (idx: number, config: DataTransformerConfig) => {
|
||||
const { transformations } = this.state;
|
||||
const next = Array.from(transformations);
|
||||
next[idx] = config;
|
||||
next[idx].transformation = config;
|
||||
this.onChange(next);
|
||||
};
|
||||
|
||||
@ -122,48 +167,86 @@ 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 = () => {
|
||||
const { data, transformations } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{transformations.map((t, i) => {
|
||||
let editor;
|
||||
<DragDropContext onDragEnd={this.onDragEnd}>
|
||||
<Droppable droppableId="transformations-list" direction="vertical">
|
||||
{provided => {
|
||||
return (
|
||||
<div ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{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;
|
||||
|
||||
const transformationUI = standardTransformersRegistry.getIfExists(t.id);
|
||||
if (!transformationUI) {
|
||||
return null;
|
||||
}
|
||||
const transformationUI = standardTransformersRegistry.getIfExists(t.transformation.id);
|
||||
if (!transformationUI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = transformDataFrame(transformations.slice(0, i), data);
|
||||
const output = transformDataFrame(transformations.slice(i), input);
|
||||
const input = transformDataFrame(
|
||||
transformations.slice(0, i).map(t => t.transformation),
|
||||
data
|
||||
);
|
||||
const output = transformDataFrame(
|
||||
transformations.slice(i).map(t => t.transformation),
|
||||
input
|
||||
);
|
||||
|
||||
if (transformationUI) {
|
||||
editor = React.createElement(transformationUI.editor, {
|
||||
options: { ...transformationUI.transformation.defaultOptions, ...t.options },
|
||||
input,
|
||||
onChange: (options: any) => {
|
||||
this.onTransformationChange(i, {
|
||||
id: t.id,
|
||||
options,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (transformationUI) {
|
||||
editor = React.createElement(transformationUI.editor, {
|
||||
options: { ...transformationUI.transformation.defaultOptions, ...t.transformation.options },
|
||||
input,
|
||||
onChange: (options: any) => {
|
||||
this.onTransformationChange(i, {
|
||||
id: t.transformation.id,
|
||||
options,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TransformationOperationRow
|
||||
key={`${t.id}-${i}`}
|
||||
input={input || []}
|
||||
output={output || []}
|
||||
onRemove={() => this.onTransformationRemove(i)}
|
||||
editor={editor}
|
||||
name={transformationUI ? transformationUI.name : ''}
|
||||
description={transformationUI ? transformationUI.description : ''}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
return (
|
||||
<TransformationOperationRow
|
||||
index={i}
|
||||
id={`${t.id}`}
|
||||
key={`${t.id}`}
|
||||
input={input || []}
|
||||
output={output || []}
|
||||
onRemove={() => this.onTransformationRemove(i)}
|
||||
editor={editor}
|
||||
name={transformationUI.name}
|
||||
description={transformationUI.description}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -31,12 +31,14 @@ interface Props {
|
||||
data: PanelData;
|
||||
query: DataQuery;
|
||||
dashboard: DashboardModel;
|
||||
dataSourceValue: string | null;
|
||||
inMixedMode?: boolean;
|
||||
id: string;
|
||||
index: number;
|
||||
onAddQuery: (query?: DataQuery) => void;
|
||||
onRemoveQuery: (query: DataQuery) => void;
|
||||
onMoveQuery: (query: DataQuery, direction: number) => void;
|
||||
onChange: (query: DataQuery) => void;
|
||||
dataSourceValue: string | null;
|
||||
inMixedMode?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@ -276,7 +278,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { query } = this.props;
|
||||
const { query, id, index } = this.props;
|
||||
const { datasource } = this.state;
|
||||
const isDisabled = query.hide;
|
||||
|
||||
@ -293,7 +295,13 @@ export class QueryEditorRow extends PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<ErrorBoundaryAlert>{editor}</ErrorBoundaryAlert>
|
||||
</div>
|
||||
|
@ -81,6 +81,8 @@ export class QueryEditorRows extends PureComponent<Props> {
|
||||
return props.queries.map((query, index) => (
|
||||
<QueryEditorRow
|
||||
dataSourceValue={query.datasource || props.datasource.value}
|
||||
id={query.refId}
|
||||
index={index}
|
||||
key={query.refId}
|
||||
panel={props.panel}
|
||||
dashboard={props.dashboard}
|
||||
|
@ -307,6 +307,8 @@ export class QueryOptions extends PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<QueryOperationRow
|
||||
id="Query options"
|
||||
index={0}
|
||||
title="Query options"
|
||||
headerElement={this.renderCollapsedText(styles)}
|
||||
isOpen={isOpen}
|
||||
|
@ -8,10 +8,6 @@ const setup = (isSynced: boolean) => {
|
||||
};
|
||||
|
||||
describe('TimeSyncButton', () => {
|
||||
it('should render component', () => {
|
||||
const wrapper = setup(true);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
it('should change style when synced', () => {
|
||||
const wrapper = setup(true);
|
||||
expect(wrapper.find('button').props()['aria-label']).toEqual('Synced times');
|
||||
|
@ -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>
|
||||
`;
|
@ -3430,6 +3430,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
|
||||
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":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@iconscout/react-unicons/-/react-unicons-1.0.1.tgz#b5309fac3b0cc27014da1e48edf29dd3d54a672f"
|
||||
|
Loading…
Reference in New Issue
Block a user