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:
Oscar Kilhed 2024-01-24 14:14:48 +01:00 committed by GitHub
parent a06197188f
commit 57ba8dc75d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 405 additions and 68 deletions

View File

@ -334,6 +334,7 @@ export const Components = {
searchInput: 'data-testid search transformations',
noTransformationsMessage: 'data-testid no transformations message',
addTransformationButton: 'data-testid add transformation button',
removeAllTransformationsButton: 'data-testid remove all transformations button',
},
NavBar: {
Configuration: {

View File

@ -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 { 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 { 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 { 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';
function createPanelManagerMock(sceneDataTransformer: SceneDataTransformer) {
function createModelMock(
panelData: PanelData,
transformations?: DataTransformerConfig[],
onChangeTransformationsMock?: Function
) {
return {
getDataTransformer: () => sceneDataTransformer,
getDataTransformer: () => new SceneDataTransformer({ data: panelData, transformations: transformations || [] }),
getQueryRunner: () => new SceneQueryRunner({ queries: [], data: panelData }),
onChangeTransformations: onChangeTransformationsMock,
} 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', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
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>);
await screen.findByTestId(selectors.components.Transforms.noTransformationsMessage);
});
it('renders transformations when there are transformations', async () => {
standardTransformersRegistry.setInit(getStandardTransformers);
const modelMock = createPanelManagerMock(
new SceneDataTransformer({
data: {
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: {},
},
],
})
);
const modelMock = createModelMock(mockData, [
{
id: 'calculateField',
options: {},
},
]);
render(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
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;
};

View File

@ -1,16 +1,17 @@
import { css } from '@emotion/css';
import React from 'react';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import React, { useState } from 'react';
import { DragDropContext, DropResult, Droppable } from 'react-beautiful-dnd';
import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data';
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 { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows';
import { VizPanelManager } from '../VizPanelManager';
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTabState, PanelDataPaneTab } from './types';
interface PanelDataTransformationsTabState extends PanelDataPaneTabState {}
@ -29,7 +30,7 @@ export class PanelDataTransformationsTab
}
getItemsCount() {
return this.getDataTransformer().state.transformations.length;
return this._panelManager.dataTransformer.state.transformations.length;
}
constructor(panelManager: VizPanelManager) {
@ -38,60 +39,94 @@ export class PanelDataTransformationsTab
this._panelManager = panelManager;
}
public getDataTransformer(): SceneDataTransformer {
const provider = this._panelManager.state.panel.state.$data;
if (!provider || !(provider instanceof SceneDataTransformer)) {
throw new Error('Could not find SceneDataTransformer for panel');
}
return provider;
public getQueryRunner(): SceneQueryRunner {
return this._panelManager.queryRunner;
}
public changeTransformations(transformations: DataTransformerConfig[]) {
const dataProvider = this.getDataTransformer();
dataProvider.setState({ transformations });
dataProvider.reprocessTransformations();
public getDataTransformer(): SceneDataTransformer {
return this._panelManager.dataTransformer;
}
public onChangeTransformations(transformations: DataTransformerConfig[]) {
this._panelManager.changeTransformations(transformations);
}
}
export function PanelDataTransformationsTabRendered({ model }: SceneComponentProps<PanelDataTransformationsTab>) {
const styles = useStyles2(getStyles);
const sourceData = model.getQueryRunner().useState();
const { data, transformations: transformsWrongType } = model.getDataTransformer().useState();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const transformations: DataTransformerConfig[] = transformsWrongType as unknown as DataTransformerConfig[];
if (transformations.length < 1) {
return <EmptyTransformationsMessage onShowPicker={() => {}}></EmptyTransformationsMessage>;
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
if (!data || !sourceData.data) {
return;
}
if (!data) {
return;
const transformationsDrawer = (
<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 (
<>
<TransformationsEditor data={data} transformations={transformations} model={model} />
<TransformationsEditor data={sourceData.data} transformations={transformations} model={model} />
<ButtonGroup>
<Button
icon="plus"
variant="secondary"
onClick={() => {}}
onClick={openDrawer}
data-testid={selectors.components.Transforms.addTransformationButton}
>
Add another transformation
</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
</Button>
</ButtonGroup>
<ConfirmModal
isOpen={false}
isOpen={confirmModalOpen}
title="Delete all transformations?"
body="By deleting all transformations, you will go back to the main selection screen."
confirmText="Delete all"
onConfirm={() => {}}
onDismiss={() => {}}
onConfirm={() => {
model.onChangeTransformations([]);
setConfirmModalOpen(false);
}}
onDismiss={() => setConfirmModalOpen(false)}
/>
{transformationsDrawer}
</>
);
}
@ -105,8 +140,24 @@ interface TransformationEditorProps {
function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) {
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 (
<DragDropContext onDragEnd={() => {}}>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="transformations-list" direction="vertical">
{(provided) => {
return (
@ -115,12 +166,12 @@ function TransformationsEditor({ transformations, model, data }: TransformationE
onChange={(index, transformation) => {
const newTransformations = transformations.slice();
newTransformations[index] = transformation;
model.changeTransformations(newTransformations);
model.onChangeTransformations(newTransformations);
}}
onRemove={(index) => {
const newTransformations = transformations.slice();
newTransformations.splice(index);
model.changeTransformations(newTransformations);
newTransformations.splice(index, 1);
model.onChangeTransformations(newTransformations);
}}
configs={transformationEditorRows}
data={data}

View File

@ -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} &nbsp;&nbsp;
<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}
/>
);
}

View File

@ -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('plugin queries', () => {
it('should update queries', () => {

View File

@ -20,8 +20,9 @@ import {
SceneQueryRunner,
sceneGraph,
SceneDataProvider,
SceneDataTransformer,
} from '@grafana/scenes';
import { DataQuery } from '@grafana/schema';
import { DataQuery, DataTransformerConfig } from '@grafana/schema';
import { getPluginVersion } from 'app/features/dashboard/state/PanelModel';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
import { updateQueries } from 'app/features/query/state/updateQueries';
@ -217,6 +218,12 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
runner.setState({ queries });
}
public changeTransformations(transformations: DataTransformerConfig[]) {
const dataprovider = this.dataTransformer;
dataprovider.setState({ transformations });
dataprovider.reprocessTransformations();
}
public inspectPanel() {
const panel = this.state.panel;
const panelId = getPanelIdForVizPanel(panel);
@ -237,6 +244,14 @@ export class VizPanelManager extends SceneObjectBase<VizPanelManagerState> {
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 {
return this.state.panel.state.$data!;
}

View File

@ -8,6 +8,7 @@ import {
TransformationApplicabilityLevels,
GrafanaTheme2,
standardTransformersRegistry,
SelectableValue,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Card, Drawer, FilterPill, IconButton, Input, Switch, useStyles2 } from '@grafana/ui';
@ -26,10 +27,10 @@ const filterCategoriesLabels: Array<[FilterCategory, string]> = [
];
interface TransformationPickerNgProps {
onTransformationAdd: Function;
setState: Function;
onTransformationAdd: (selectedItem: SelectableValue<string>) => void;
onSearchChange: FormEventHandler<HTMLInputElement>;
onSearchKeyDown: KeyboardEventHandler<HTMLInputElement>;
onClose?: () => void;
noTransforms: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
xforms: Array<TransformerRegistryItem<any>>;
@ -37,6 +38,8 @@ interface TransformationPickerNgProps {
suffix: ReactNode;
data: DataFrame[];
showIllustrations?: boolean;
onShowIllustrationsChange?: (showIllustrations: boolean) => void;
onSelectedFilterChange?: (category: FilterCategory) => void;
selectedFilter?: FilterCategory;
}
@ -44,7 +47,6 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
const styles = useStyles2(getTransformationPickerStyles);
const {
suffix,
setState,
xforms,
search,
onSearchChange,
@ -53,6 +55,9 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
onTransformationAdd,
selectedFilter,
data,
onClose,
onShowIllustrationsChange,
onSelectedFilterChange,
} = props;
// Use a callback ref to call "click" on the search input
@ -62,7 +67,13 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
}, []);
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}>
<Input
data-testid={selectors.components.Transforms.searchInput}
@ -76,7 +87,10 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
/>
<div className={styles.showImages}>
<span className={styles.illustationSwitchLabel}>Show images</span>{' '}
<Switch value={showIllustrations} onChange={() => setState({ showIllustrations: !showIllustrations })} />
<Switch
value={showIllustrations}
onChange={() => onShowIllustrationsChange && onShowIllustrationsChange(!showIllustrations)}
/>
</div>
</div>
@ -85,7 +99,7 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) {
return (
<FilterPill
key={slug}
onClick={() => setState({ selectedFilter: slug })}
onClick={() => onSelectedFilterChange && onSelectedFilterChange(slug)}
label={label}
selected={selectedFilter === slug}
/>

View File

@ -37,7 +37,7 @@ interface TransformationsEditorProps extends Themeable {
panel: PanelModel;
}
const VIEW_ALL_VALUE = 'viewAll';
export const VIEW_ALL_VALUE = 'viewAll';
export type viewAllType = 'viewAll';
export type FilterCategory = TransformerCategory | viewAllType;
@ -359,7 +359,9 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
search={search}
suffix={suffix}
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}
onSearchKeyDown={this.onSearchKeyDown}
onTransformationAdd={this.onTransformationAdd}