mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scenes: Add transformation flow for panel edit (#80738)
* Adding transformations works * Use source data as data input for transformation settings, add search box suffix * remove useCallback that are probably not needed, fix tests * remove unused import * add tests for adding and removing transformations * use view all constant * Add reordering functionality * Fix removing one transformation removes all consecutive transformations * use closeDrawer function * Add tests for changing transformations * Remove any --------- Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
parent
a06197188f
commit
57ba8dc75d
@ -334,6 +334,7 @@ export const Components = {
|
|||||||
searchInput: 'data-testid search transformations',
|
searchInput: 'data-testid search transformations',
|
||||||
noTransformationsMessage: 'data-testid no transformations message',
|
noTransformationsMessage: 'data-testid no transformations message',
|
||||||
addTransformationButton: 'data-testid add transformation button',
|
addTransformationButton: 'data-testid add transformation button',
|
||||||
|
removeAllTransformationsButton: 'data-testid remove all transformations button',
|
||||||
},
|
},
|
||||||
NavBar: {
|
NavBar: {
|
||||||
Configuration: {
|
Configuration: {
|
||||||
|
@ -1,54 +1,197 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { FieldType, LoadingState, TimeRange, standardTransformersRegistry, toDataFrame } from '@grafana/data';
|
import {
|
||||||
|
DataTransformerConfig,
|
||||||
|
FieldType,
|
||||||
|
LoadingState,
|
||||||
|
PanelData,
|
||||||
|
TimeRange,
|
||||||
|
standardTransformersRegistry,
|
||||||
|
toDataFrame,
|
||||||
|
} from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { SceneDataTransformer } from '@grafana/scenes';
|
import { SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes';
|
||||||
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
|
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
|
||||||
|
import { DashboardDataDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene';
|
||||||
|
import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper';
|
||||||
|
import { findVizPanelByKey } from '../../utils/utils';
|
||||||
|
import { VizPanelManager } from '../VizPanelManager';
|
||||||
|
import { testDashboard } from '../testfiles/testDashboard';
|
||||||
|
|
||||||
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
|
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
|
||||||
|
|
||||||
function createPanelManagerMock(sceneDataTransformer: SceneDataTransformer) {
|
function createModelMock(
|
||||||
|
panelData: PanelData,
|
||||||
|
transformations?: DataTransformerConfig[],
|
||||||
|
onChangeTransformationsMock?: Function
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
getDataTransformer: () => sceneDataTransformer,
|
getDataTransformer: () => new SceneDataTransformer({ data: panelData, transformations: transformations || [] }),
|
||||||
|
getQueryRunner: () => new SceneQueryRunner({ queries: [], data: panelData }),
|
||||||
|
onChangeTransformations: onChangeTransformationsMock,
|
||||||
} as unknown as PanelDataTransformationsTab;
|
} as unknown as PanelDataTransformationsTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
timeRange: {} as unknown as TimeRange,
|
||||||
|
state: {} as unknown as LoadingState,
|
||||||
|
series: [
|
||||||
|
toDataFrame({
|
||||||
|
name: 'A',
|
||||||
|
fields: [
|
||||||
|
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
|
||||||
|
{ name: 'values', type: FieldType.number, values: [1, 2, 3] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PanelDataTransformationsModel', () => {
|
||||||
|
it('can change transformations', () => {
|
||||||
|
const vizPanelManager = setupVizPanelManger('panel-1');
|
||||||
|
const model = new PanelDataTransformationsTab(vizPanelManager);
|
||||||
|
model.onChangeTransformations([{ id: 'calculateField', options: {} }]);
|
||||||
|
expect(model.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('PanelDataTransformationsTab', () => {
|
describe('PanelDataTransformationsTab', () => {
|
||||||
|
standardTransformersRegistry.setInit(getStandardTransformers);
|
||||||
|
|
||||||
it('renders empty message when there are no transformations', async () => {
|
it('renders empty message when there are no transformations', async () => {
|
||||||
const modelMock = createPanelManagerMock(new SceneDataTransformer({ transformations: [] }));
|
const modelMock = createModelMock({} as PanelData);
|
||||||
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
|
||||||
await screen.findByTestId(selectors.components.Transforms.noTransformationsMessage);
|
await screen.findByTestId(selectors.components.Transforms.noTransformationsMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders transformations when there are transformations', async () => {
|
it('renders transformations when there are transformations', async () => {
|
||||||
standardTransformersRegistry.setInit(getStandardTransformers);
|
const modelMock = createModelMock(mockData, [
|
||||||
const modelMock = createPanelManagerMock(
|
{
|
||||||
new SceneDataTransformer({
|
id: 'calculateField',
|
||||||
data: {
|
options: {},
|
||||||
timeRange: {} as unknown as TimeRange,
|
},
|
||||||
state: {} as unknown as LoadingState,
|
]);
|
||||||
series: [
|
|
||||||
toDataFrame({
|
|
||||||
name: 'A',
|
|
||||||
fields: [
|
|
||||||
{ name: 'time', type: FieldType.time, values: [100, 200, 300] },
|
|
||||||
{ name: 'values', type: FieldType.number, values: [1, 2, 3] },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
transformations: [
|
|
||||||
{
|
|
||||||
id: 'calculateField',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
|
||||||
await screen.findByText('1 - Add field from calculation');
|
await screen.findByText('1 - Add field from calculation');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows show the transformation selection drawer', async () => {
|
||||||
|
const modelMock = createModelMock(mockData);
|
||||||
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton);
|
||||||
|
userEvent.click(addButton);
|
||||||
|
await screen.findByTestId(selectors.components.Transforms.searchInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a transformation when a transformation is clicked in the drawer and there are no previous transformations', async () => {
|
||||||
|
const onChangeTransformation = jest.fn();
|
||||||
|
const modelMock = createModelMock(mockData, [], onChangeTransformation);
|
||||||
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton);
|
||||||
|
await act(async () => {
|
||||||
|
userEvent.click(addButton);
|
||||||
|
});
|
||||||
|
const transformationCard = await screen.findByTestId(
|
||||||
|
selectors.components.TransformTab.newTransform('Add field from calculation')
|
||||||
|
);
|
||||||
|
const button = transformationCard.getElementsByTagName('button').item(0);
|
||||||
|
|
||||||
|
await userEvent.click(button!);
|
||||||
|
|
||||||
|
expect(onChangeTransformation).toHaveBeenCalledWith([{ id: 'calculateField', options: {} }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a transformation when a transformation is clicked in the drawer and there are transformations', async () => {
|
||||||
|
const onChangeTransformation = jest.fn();
|
||||||
|
const modelMock = createModelMock(
|
||||||
|
mockData,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'calculateField',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChangeTransformation
|
||||||
|
);
|
||||||
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton);
|
||||||
|
await act(async () => {
|
||||||
|
userEvent.click(addButton);
|
||||||
|
});
|
||||||
|
const transformationCard = await screen.findByTestId(
|
||||||
|
selectors.components.TransformTab.newTransform('Add field from calculation')
|
||||||
|
);
|
||||||
|
const button = transformationCard.getElementsByTagName('button').item(0);
|
||||||
|
|
||||||
|
await userEvent.click(button!);
|
||||||
|
|
||||||
|
expect(onChangeTransformation).toHaveBeenCalledWith([
|
||||||
|
{ id: 'calculateField', options: {} },
|
||||||
|
{ id: 'calculateField', options: {} },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes all transformations', async () => {
|
||||||
|
const onChangeTransformation = jest.fn();
|
||||||
|
const modelMock = createModelMock(
|
||||||
|
mockData,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'calculateField',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChangeTransformation
|
||||||
|
);
|
||||||
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
const removeButton = await screen.findByTestId(selectors.components.Transforms.removeAllTransformationsButton);
|
||||||
|
await act(async () => {
|
||||||
|
userEvent.click(removeButton);
|
||||||
|
});
|
||||||
|
const confirmButton = await screen.findByTestId(selectors.pages.ConfirmModal.delete);
|
||||||
|
await act(async () => {
|
||||||
|
await userEvent.click(confirmButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChangeTransformation).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can filter transformations in the drawer', async () => {
|
||||||
|
const modelMock = createModelMock(mockData);
|
||||||
|
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
|
||||||
|
const addButton = await screen.findByTestId(selectors.components.Transforms.addTransformationButton);
|
||||||
|
await act(async () => {
|
||||||
|
await userEvent.click(addButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = await screen.findByTestId(selectors.components.Transforms.searchInput);
|
||||||
|
|
||||||
|
await screen.findByTestId(selectors.components.TransformTab.newTransform('Reduce'));
|
||||||
|
|
||||||
|
await userEvent.type(searchInput, 'add field');
|
||||||
|
|
||||||
|
await screen.findByTestId(selectors.components.TransformTab.newTransform('Add field from calculation'));
|
||||||
|
const reduce = await screen.queryByTestId(selectors.components.TransformTab.newTransform('Reduce'));
|
||||||
|
expect(reduce).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setupVizPanelManger = (panelId: string) => {
|
||||||
|
const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
|
||||||
|
const panel = findVizPanelByKey(scene, panelId)!;
|
||||||
|
|
||||||
|
const vizPanelManager = new VizPanelManager(panel.clone());
|
||||||
|
|
||||||
|
// The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it
|
||||||
|
// @ts-expect-error
|
||||||
|
getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene));
|
||||||
|
|
||||||
|
return vizPanelManager;
|
||||||
|
};
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
|
import { DragDropContext, DropResult, Droppable } from 'react-beautiful-dnd';
|
||||||
|
|
||||||
import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data';
|
import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { SceneObjectBase, SceneComponentProps, SceneDataTransformer } from '@grafana/scenes';
|
import { SceneObjectBase, SceneComponentProps, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes';
|
||||||
import { Button, ButtonGroup, ConfirmModal, useStyles2 } from '@grafana/ui';
|
import { Button, ButtonGroup, ConfirmModal, useStyles2 } from '@grafana/ui';
|
||||||
import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows';
|
import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows';
|
||||||
|
|
||||||
import { VizPanelManager } from '../VizPanelManager';
|
import { VizPanelManager } from '../VizPanelManager';
|
||||||
|
|
||||||
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
|
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
|
||||||
|
import { TransformationsDrawer } from './TransformationsDrawer';
|
||||||
import { PanelDataPaneTabState, PanelDataPaneTab } from './types';
|
import { PanelDataPaneTabState, PanelDataPaneTab } from './types';
|
||||||
|
|
||||||
interface PanelDataTransformationsTabState extends PanelDataPaneTabState {}
|
interface PanelDataTransformationsTabState extends PanelDataPaneTabState {}
|
||||||
@ -29,7 +30,7 @@ export class PanelDataTransformationsTab
|
|||||||
}
|
}
|
||||||
|
|
||||||
getItemsCount() {
|
getItemsCount() {
|
||||||
return this.getDataTransformer().state.transformations.length;
|
return this._panelManager.dataTransformer.state.transformations.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(panelManager: VizPanelManager) {
|
constructor(panelManager: VizPanelManager) {
|
||||||
@ -38,60 +39,94 @@ export class PanelDataTransformationsTab
|
|||||||
this._panelManager = panelManager;
|
this._panelManager = panelManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDataTransformer(): SceneDataTransformer {
|
public getQueryRunner(): SceneQueryRunner {
|
||||||
const provider = this._panelManager.state.panel.state.$data;
|
return this._panelManager.queryRunner;
|
||||||
if (!provider || !(provider instanceof SceneDataTransformer)) {
|
|
||||||
throw new Error('Could not find SceneDataTransformer for panel');
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public changeTransformations(transformations: DataTransformerConfig[]) {
|
public getDataTransformer(): SceneDataTransformer {
|
||||||
const dataProvider = this.getDataTransformer();
|
return this._panelManager.dataTransformer;
|
||||||
dataProvider.setState({ transformations });
|
}
|
||||||
dataProvider.reprocessTransformations();
|
|
||||||
|
public onChangeTransformations(transformations: DataTransformerConfig[]) {
|
||||||
|
this._panelManager.changeTransformations(transformations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) {
|
export function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const sourceData = model.getQueryRunner().useState();
|
||||||
const { data, transformations: transformsWrongType } = model.getDataTransformer().useState();
|
const { data, transformations: transformsWrongType } = model.getDataTransformer().useState();
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const transformations: DataTransformerConfig[] = transformsWrongType as unknown as DataTransformerConfig[];
|
const transformations: DataTransformerConfig[] = transformsWrongType as unknown as DataTransformerConfig[];
|
||||||
|
|
||||||
if (transformations.length < 1) {
|
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
|
||||||
return <EmptyTransformationsMessage onShowPicker={() => {}}></EmptyTransformationsMessage>;
|
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const openDrawer = () => setDrawerOpen(true);
|
||||||
|
const closeDrawer = () => setDrawerOpen(false);
|
||||||
|
|
||||||
|
if (!data || !sourceData.data) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
const transformationsDrawer = (
|
||||||
return;
|
<TransformationsDrawer
|
||||||
|
onClose={closeDrawer}
|
||||||
|
onTransformationAdd={(selected) => {
|
||||||
|
if (selected.value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
model.onChangeTransformations([...transformations, { id: selected.value, options: {} }]);
|
||||||
|
closeDrawer();
|
||||||
|
}}
|
||||||
|
isOpen={drawerOpen}
|
||||||
|
series={data.series}
|
||||||
|
></TransformationsDrawer>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (transformations.length < 1) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EmptyTransformationsMessage onShowPicker={openDrawer}></EmptyTransformationsMessage>
|
||||||
|
{transformationsDrawer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TransformationsEditor data={data} transformations={transformations} model={model} />
|
<TransformationsEditor data={sourceData.data} transformations={transformations} model={model} />
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button
|
<Button
|
||||||
icon="plus"
|
icon="plus"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {}}
|
onClick={openDrawer}
|
||||||
data-testid={selectors.components.Transforms.addTransformationButton}
|
data-testid={selectors.components.Transforms.addTransformationButton}
|
||||||
>
|
>
|
||||||
Add another transformation
|
Add another transformation
|
||||||
</Button>
|
</Button>
|
||||||
<Button className={styles.removeAll} icon="times" variant="secondary" onClick={() => {}}>
|
<Button
|
||||||
|
data-testid={selectors.components.Transforms.removeAllTransformationsButton}
|
||||||
|
className={styles.removeAll}
|
||||||
|
icon="times"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setConfirmModalOpen(true)}
|
||||||
|
>
|
||||||
Delete all transformations
|
Delete all transformations
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={false}
|
isOpen={confirmModalOpen}
|
||||||
title="Delete all transformations?"
|
title="Delete all transformations?"
|
||||||
body="By deleting all transformations, you will go back to the main selection screen."
|
body="By deleting all transformations, you will go back to the main selection screen."
|
||||||
confirmText="Delete all"
|
confirmText="Delete all"
|
||||||
onConfirm={() => {}}
|
onConfirm={() => {
|
||||||
onDismiss={() => {}}
|
model.onChangeTransformations([]);
|
||||||
|
setConfirmModalOpen(false);
|
||||||
|
}}
|
||||||
|
onDismiss={() => setConfirmModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
{transformationsDrawer}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -105,8 +140,24 @@ interface TransformationEditorProps {
|
|||||||
function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) {
|
function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) {
|
||||||
const transformationEditorRows = transformations.map((t, i) => ({ id: `${i} - ${t.id}`, transformation: t }));
|
const transformationEditorRows = transformations.map((t, i) => ({ id: `${i} - ${t.id}`, transformation: t }));
|
||||||
|
|
||||||
|
const onDragEnd = (result: DropResult) => {
|
||||||
|
if (!result || !result.destination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = result.source.index;
|
||||||
|
const endIndex = result.destination.index;
|
||||||
|
if (startIndex === endIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update = Array.from(transformationEditorRows);
|
||||||
|
const [removed] = update.splice(startIndex, 1);
|
||||||
|
update.splice(endIndex, 0, removed);
|
||||||
|
model.onChangeTransformations(update.map((t) => t.transformation));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={() => {}}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<Droppable droppableId="transformations-list" direction="vertical">
|
<Droppable droppableId="transformations-list" direction="vertical">
|
||||||
{(provided) => {
|
{(provided) => {
|
||||||
return (
|
return (
|
||||||
@ -115,12 +166,12 @@ function TransformationsEditor({ transformations, model, data }: TransformationE
|
|||||||
onChange={(index, transformation) => {
|
onChange={(index, transformation) => {
|
||||||
const newTransformations = transformations.slice();
|
const newTransformations = transformations.slice();
|
||||||
newTransformations[index] = transformation;
|
newTransformations[index] = transformation;
|
||||||
model.changeTransformations(newTransformations);
|
model.onChangeTransformations(newTransformations);
|
||||||
}}
|
}}
|
||||||
onRemove={(index) => {
|
onRemove={(index) => {
|
||||||
const newTransformations = transformations.slice();
|
const newTransformations = transformations.slice();
|
||||||
newTransformations.splice(index);
|
newTransformations.splice(index, 1);
|
||||||
model.changeTransformations(newTransformations);
|
model.onChangeTransformations(newTransformations);
|
||||||
}}
|
}}
|
||||||
configs={transformationEditorRows}
|
configs={transformationEditorRows}
|
||||||
data={data}
|
data={data}
|
||||||
|
@ -0,0 +1,91 @@
|
|||||||
|
import React, { FormEvent, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { DataFrame, SelectableValue, standardTransformersRegistry } from '@grafana/data';
|
||||||
|
import { IconButton } from '@grafana/ui';
|
||||||
|
import { TransformationPickerNg } from 'app/features/dashboard/components/TransformationsEditor/TransformationPickerNg';
|
||||||
|
import {
|
||||||
|
FilterCategory,
|
||||||
|
VIEW_ALL_VALUE,
|
||||||
|
} from 'app/features/dashboard/components/TransformationsEditor/TransformationsEditor';
|
||||||
|
|
||||||
|
interface DrawerState {
|
||||||
|
search: string;
|
||||||
|
showIllustrations: boolean;
|
||||||
|
selectedFilter?: FilterCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransformationsDrawerProps {
|
||||||
|
series: DataFrame[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onTransformationAdd: (selectedItem: SelectableValue<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransformationsDrawer(props: TransformationsDrawerProps) {
|
||||||
|
const { isOpen, series, onClose, onTransformationAdd } = props;
|
||||||
|
|
||||||
|
const [drawerState, setDrawerState] = useState<DrawerState>({
|
||||||
|
search: '',
|
||||||
|
showIllustrations: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSearchChange = (e: FormEvent<HTMLInputElement>) =>
|
||||||
|
setDrawerState({ ...drawerState, ...{ search: e.currentTarget.value } });
|
||||||
|
|
||||||
|
const onShowIllustrationsChange = (showIllustrations: boolean): void =>
|
||||||
|
setDrawerState({ ...drawerState, ...{ showIllustrations } });
|
||||||
|
|
||||||
|
const onSelectedFilterChange = (selectedFilter: FilterCategory): void =>
|
||||||
|
setDrawerState({ ...drawerState, ...{ selectedFilter } });
|
||||||
|
|
||||||
|
const allTransformations = useMemo(
|
||||||
|
() => standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transformations = allTransformations.filter((t) => {
|
||||||
|
if (
|
||||||
|
drawerState.selectedFilter &&
|
||||||
|
drawerState.selectedFilter !== VIEW_ALL_VALUE &&
|
||||||
|
!t.categories?.has(drawerState.selectedFilter)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return t.name.toLocaleLowerCase().includes(drawerState.search.toLocaleLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchBoxSuffix = (
|
||||||
|
<>
|
||||||
|
{transformations.length} / {allTransformations.length}
|
||||||
|
<IconButton
|
||||||
|
name="times"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawerState({ ...drawerState, ...{ search: '' } });
|
||||||
|
}}
|
||||||
|
tooltip="Clear search"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TransformationPickerNg
|
||||||
|
data={series}
|
||||||
|
onTransformationAdd={onTransformationAdd}
|
||||||
|
xforms={transformations}
|
||||||
|
search={drawerState.search}
|
||||||
|
noTransforms={false}
|
||||||
|
suffix={drawerState.search !== '' ? searchBoxSuffix : <></>}
|
||||||
|
selectedFilter={drawerState.selectedFilter}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onSearchKeyDown={() => {}}
|
||||||
|
showIllustrations={drawerState.showIllustrations}
|
||||||
|
onShowIllustrationsChange={onShowIllustrationsChange}
|
||||||
|
onSelectedFilterChange={onSelectedFilterChange}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -503,6 +503,26 @@ describe('VizPanelManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('change transformations', () => {
|
||||||
|
it('should update and reprocess transformations', () => {
|
||||||
|
const { scene, panel } = setupTest('panel-3');
|
||||||
|
scene.setState({
|
||||||
|
editPanel: buildPanelEditScene(panel),
|
||||||
|
});
|
||||||
|
|
||||||
|
const vizPanelManager = scene.state.editPanel!.state.panelRef.resolve();
|
||||||
|
vizPanelManager.activate();
|
||||||
|
vizPanelManager.state.panel.state.$data?.activate();
|
||||||
|
|
||||||
|
const reprocessMock = jest.fn();
|
||||||
|
vizPanelManager.dataTransformer.reprocessTransformations = reprocessMock;
|
||||||
|
vizPanelManager.changeTransformations([{ id: 'calculateField', options: {} }]);
|
||||||
|
|
||||||
|
expect(reprocessMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vizPanelManager.dataTransformer.state.transformations).toEqual([{ id: 'calculateField', options: {} }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('change queries', () => {
|
describe('change queries', () => {
|
||||||
describe('plugin queries', () => {
|
describe('plugin queries', () => {
|
||||||
it('should update queries', () => {
|
it('should update queries', () => {
|
||||||
|
@ -20,8 +20,9 @@ import {
|
|||||||
SceneQueryRunner,
|
SceneQueryRunner,
|
||||||
sceneGraph,
|
sceneGraph,
|
||||||
SceneDataProvider,
|
SceneDataProvider,
|
||||||
|
SceneDataTransformer,
|
||||||
} from '@grafana/scenes';
|
} from '@grafana/scenes';
|
||||||
import { DataQuery } from '@grafana/schema';
|
import { DataQuery, DataTransformerConfig } from '@grafana/schema';
|
||||||
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
|
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
|
||||||
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
|
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
|
||||||
import { updateQueries } from 'app/features/query/state/updateQueries';
|
import { updateQueries } from 'app/features/query/state/updateQueries';
|
||||||
@ -217,6 +218,12 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
|
|||||||
runner.setState({ queries });
|
runner.setState({ queries });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public changeTransformations(transformations: DataTransformerConfig[]) {
|
||||||
|
const dataprovider = this.dataTransformer;
|
||||||
|
dataprovider.setState({ transformations });
|
||||||
|
dataprovider.reprocessTransformations();
|
||||||
|
}
|
||||||
|
|
||||||
public inspectPanel() {
|
public inspectPanel() {
|
||||||
const panel = this.state.panel;
|
const panel = this.state.panel;
|
||||||
const panelId = getPanelIdForVizPanel(panel);
|
const panelId = getPanelIdForVizPanel(panel);
|
||||||
@ -237,6 +244,14 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
|
|||||||
return runner;
|
return runner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get dataTransformer(): SceneDataTransformer {
|
||||||
|
const provider = this.state.panel.state.$data;
|
||||||
|
if (!provider || !(provider instanceof SceneDataTransformer)) {
|
||||||
|
throw new Error('Could not find SceneDataTransformer for panel');
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
get panelData(): SceneDataProvider {
|
get panelData(): SceneDataProvider {
|
||||||
return this.state.panel.state.$data!;
|
return this.state.panel.state.$data!;
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
TransformationApplicabilityLevels,
|
TransformationApplicabilityLevels,
|
||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
standardTransformersRegistry,
|
standardTransformersRegistry,
|
||||||
|
SelectableValue,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui';
|
import { Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui';
|
||||||
@ -26,10 +27,10 @@ const filterCategoriesLabels: Array<[FilterCategory, string]> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface TransformationPickerNgProps {
|
interface TransformationPickerNgProps {
|
||||||
onTransformationAdd: Function;
|
onTransformationAdd: (selectedItem: SelectableValue<string>) => void;
|
||||||
setState: Function;
|
|
||||||
onSearchChange: FormEventHandler<HTMLInputElement>;
|
onSearchChange: FormEventHandler<HTMLInputElement>;
|
||||||
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
|
||||||
|
onClose?: () => void;
|
||||||
noTransforms: boolean;
|
noTransforms: boolean;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
xforms: Array<TransformerRegistryItem<any>>;
|
xforms: Array<TransformerRegistryItem<any>>;
|
||||||
@ -37,6 +38,8 @@ interface TransformationPickerNgProps {
|
|||||||
suffix: ReactNode;
|
suffix: ReactNode;
|
||||||
data: DataFrame[];
|
data: DataFrame[];
|
||||||
showIllustrations?: boolean;
|
showIllustrations?: boolean;
|
||||||
|
onShowIllustrationsChange?: (showIllustrations: boolean) => void;
|
||||||
|
onSelectedFilterChange?: (category: FilterCategory) => void;
|
||||||
selectedFilter?: FilterCategory;
|
selectedFilter?: FilterCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +47,6 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
|
|||||||
const styles = useStyles2(getTransformationPickerStyles);
|
const styles = useStyles2(getTransformationPickerStyles);
|
||||||
const {
|
const {
|
||||||
suffix,
|
suffix,
|
||||||
setState,
|
|
||||||
xforms,
|
xforms,
|
||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@ -53,6 +55,9 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
|
|||||||
onTransformationAdd,
|
onTransformationAdd,
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
data,
|
data,
|
||||||
|
onClose,
|
||||||
|
onShowIllustrationsChange,
|
||||||
|
onSelectedFilterChange,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// Use a callback ref to call "click" on the search input
|
// Use a callback ref to call "click" on the search input
|
||||||
@ -62,7 +67,13 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer size="md" onClose={() => setState({ showPicker: false })} title="Add another transformation">
|
<Drawer
|
||||||
|
size="md"
|
||||||
|
onClose={() => {
|
||||||
|
onClose && onClose();
|
||||||
|
}}
|
||||||
|
title="Add another transformation"
|
||||||
|
>
|
||||||
<div className={styles.searchWrapper}>
|
<div className={styles.searchWrapper}>
|
||||||
<Input
|
<Input
|
||||||
data-testid={selectors.components.Transforms.searchInput}
|
data-testid={selectors.components.Transforms.searchInput}
|
||||||
@ -76,7 +87,10 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
|
|||||||
/>
|
/>
|
||||||
<div className={styles.showImages}>
|
<div className={styles.showImages}>
|
||||||
<span className={styles.illustationSwitchLabel}>Show images</span>{' '}
|
<span className={styles.illustationSwitchLabel}>Show images</span>{' '}
|
||||||
<Switch value={showIllustrations} onChange={() => setState({ showIllustrations: !showIllustrations })} />
|
<Switch
|
||||||
|
value={showIllustrations}
|
||||||
|
onChange={() => onShowIllustrationsChange && onShowIllustrationsChange(!showIllustrations)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -85,7 +99,7 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
|
|||||||
return (
|
return (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
key={slug}
|
key={slug}
|
||||||
onClick={() => setState({ selectedFilter: slug })}
|
onClick={() => onSelectedFilterChange && onSelectedFilterChange(slug)}
|
||||||
label={label}
|
label={label}
|
||||||
selected={selectedFilter === slug}
|
selected={selectedFilter === slug}
|
||||||
/>
|
/>
|
||||||
|
@ -37,7 +37,7 @@ interface TransformationsEditorProps extends Themeable {
|
|||||||
panel: PanelModel;
|
panel: PanelModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VIEW_ALL_VALUE = 'viewAll';
|
export const VIEW_ALL_VALUE = 'viewAll';
|
||||||
export type viewAllType = 'viewAll';
|
export type viewAllType = 'viewAll';
|
||||||
export type FilterCategory = TransformerCategory | viewAllType;
|
export type FilterCategory = TransformerCategory | viewAllType;
|
||||||
|
|
||||||
@ -359,7 +359,9 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
|||||||
search={search}
|
search={search}
|
||||||
suffix={suffix}
|
suffix={suffix}
|
||||||
xforms={xforms}
|
xforms={xforms}
|
||||||
setState={this.setState.bind(this)}
|
onClose={() => this.setState({ showPicker: false })}
|
||||||
|
onSelectedFilterChange={(filter) => this.setState({ selectedFilter: filter })}
|
||||||
|
onShowIllustrationsChange={(showIllustrations) => this.setState({ showIllustrations })}
|
||||||
onSearchChange={this.onSearchChange}
|
onSearchChange={this.onSearchChange}
|
||||||
onSearchKeyDown={this.onSearchKeyDown}
|
onSearchKeyDown={this.onSearchKeyDown}
|
||||||
onTransformationAdd={this.onTransformationAdd}
|
onTransformationAdd={this.onTransformationAdd}
|
||||||
|
Loading…
Reference in New Issue
Block a user