From 82eaf0a761b57979ddfee66dc2e38a4ec9ef3893 Mon Sep 17 00:00:00 2001 From: Alex Simonok Date: Wed, 31 Jan 2024 21:17:46 +0300 Subject: [PATCH] Add visual editor for working with data sources (#211) * Clear chart when data is loaded * Add Visual Editor support * Revert changes * Add Dataset Editor * Fix typings * Add Series Editor * Add visual editor code * * Update visual editor code execution * Add DatasetEditor tests * Add tests for VisualEditor * Add tests for SeriesEditor * Fix visual editor tests * Add context object to code * Update version --------- Co-authored-by: Mikhail Co-authored-by: Mikhail Volkov <47795110+mikhail-vl@users.noreply.github.com> --- CHANGELOG.md | 1 + LICENSE | 2 +- package-lock.json | 35 +- package.json | 7 +- src/__mocks__/@grafana/ui.tsx | 43 ++ src/__mocks__/react-beautiful-dnd.tsx | 34 ++ src/components/Collapse/Collapse.styles.ts | 45 ++ src/components/Collapse/Collapse.test.tsx | 43 ++ src/components/Collapse/Collapse.tsx | 85 +++ src/components/Collapse/index.ts | 1 + .../DatasetEditor/DatasetEditor.styles.ts | 64 +++ .../DatasetEditor/DatasetEditor.test.tsx | 235 +++++++++ .../DatasetEditor/DatasetEditor.tsx | 220 ++++++++ src/components/DatasetEditor/index.ts | 1 + .../EChartsEditor/EChartsEditor.test.tsx | 53 +- .../EChartsEditor/EChartsEditor.tsx | 37 +- .../EChartsPanel/EChartsPanel.test.tsx | 83 ++- src/components/EChartsPanel/EChartsPanel.tsx | 82 ++- .../SeriesEditor/SeriesEditor.styles.ts | 24 + .../SeriesEditor/SeriesEditor.test.tsx | 489 ++++++++++++++++++ src/components/SeriesEditor/SeriesEditor.tsx | 219 ++++++++ src/components/SeriesEditor/index.ts | 1 + .../SeriesItemEditor/SeriesItemEditor.tsx | 130 +++++ src/components/SeriesItemEditor/index.ts | 1 + .../VisualEditor/VisualEditor.test.tsx | 123 +++++ src/components/VisualEditor/VisualEditor.tsx | 43 ++ src/components/VisualEditor/index.ts | 1 + src/components/index.ts | 1 + src/constants/default.ts | 20 + src/constants/editor.ts | 73 +++ src/constants/index.ts | 1 + src/constants/tests.ts | 22 + src/constants/visual-editor.ts | 6 + src/module.ts | 58 ++- src/types/index.ts | 1 + src/types/panel.ts | 15 +- src/types/visual-editor.ts | 165 ++++++ src/utils/data-frame.ts | 10 + src/utils/index.ts | 1 + src/utils/visual-editor.test.ts | 107 ++++ src/utils/visual-editor.ts | 97 ++++ 41 files changed, 2629 insertions(+), 50 deletions(-) create mode 100644 src/__mocks__/@grafana/ui.tsx create mode 100644 src/__mocks__/react-beautiful-dnd.tsx create mode 100644 src/components/Collapse/Collapse.styles.ts create mode 100644 src/components/Collapse/Collapse.test.tsx create mode 100644 src/components/Collapse/Collapse.tsx create mode 100644 src/components/Collapse/index.ts create mode 100644 src/components/DatasetEditor/DatasetEditor.styles.ts create mode 100644 src/components/DatasetEditor/DatasetEditor.test.tsx create mode 100644 src/components/DatasetEditor/DatasetEditor.tsx create mode 100644 src/components/DatasetEditor/index.ts create mode 100644 src/components/SeriesEditor/SeriesEditor.styles.ts create mode 100644 src/components/SeriesEditor/SeriesEditor.test.tsx create mode 100644 src/components/SeriesEditor/SeriesEditor.tsx create mode 100644 src/components/SeriesEditor/index.ts create mode 100644 src/components/SeriesItemEditor/SeriesItemEditor.tsx create mode 100644 src/components/SeriesItemEditor/index.ts create mode 100644 src/components/VisualEditor/VisualEditor.test.tsx create mode 100644 src/components/VisualEditor/VisualEditor.tsx create mode 100644 src/components/VisualEditor/index.ts create mode 100644 src/constants/visual-editor.ts create mode 100644 src/types/visual-editor.ts create mode 100644 src/utils/data-frame.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/visual-editor.test.ts create mode 100644 src/utils/visual-editor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6591bf5..75f6e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features / Enhancements - Updated README and documentation (#214) +- Add visual editor for working with data sources (#211) ## 5.1.0 (2023-08-11) diff --git a/LICENSE b/LICENSE index 87d8ba9..815b10e 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ identification within third-party archives. Copyright 2020 Bilibala - Copyright 2022-2023 Volkov Labs + Copyright 2022-2024 Volkov Labs Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/package-lock.json b/package-lock.json index a0227e5..a862340 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "volkovlabs-echarts-panel", - "version": "5.0.0", + "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "volkovlabs-echarts-panel", - "version": "5.0.0", + "version": "5.1.0", "license": "Apache-2.0", "dependencies": { "@emotion/css": "^11.11.2", @@ -23,8 +23,10 @@ "echarts-stat": "^1.2.0", "echarts-wordcloud": "^2.1.0", "react": "18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", - "tslib": "^2.6.1" + "tslib": "^2.6.1", + "uuid": "^9.0.0" }, "devDependencies": { "@babel/core": "^7.22.10", @@ -40,8 +42,11 @@ "@types/jest": "^29.5.3", "@types/lodash": "^4.14.196", "@types/node": "^18.17.4", + "@types/react-beautiful-dnd": "^13.1.4", + "@types/uuid": "^9.0.3", "@types/webpack-env": "^1.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", + "@volkovlabs/jest-selectors": "^1.2.0", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.8.1", "eslint-plugin-deprecation": "^1.5.0", @@ -6242,6 +6247,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.4", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.4.tgz", + "integrity": "sha512-4bIBdzOr0aavN+88q3C7Pgz+xkb7tz3whORYrmSj77wfVEMfiWiooIwVWFR7KM2e+uGTe5BVrXqSfb0aHeflJA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.2.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.7.tgz", @@ -6319,6 +6333,12 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.3.tgz", + "integrity": "sha512-taHQQH/3ZyI3zP8M/puluDEIEvtQHVYcC6y3N8ijFtAd28+Ey/G4sg1u2gB01S8MwybLOKAp9/yCMu/uR5l3Ug==", + "dev": true + }, "node_modules/@types/webpack-env": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.1.tgz", @@ -6650,6 +6670,15 @@ "dev": true, "peer": true }, + "node_modules/@volkovlabs/jest-selectors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@volkovlabs/jest-selectors/-/jest-selectors-1.2.0.tgz", + "integrity": "sha512-CUSrkhSGJ+PCHxQZ+8EdjfabZsn5Et/EIAI/CQzYR8XRHOEEHVR1FAj0U/JvfX9D5fsCw50gUdxJtAL5R9qNbQ==", + "dev": true, + "peerDependencies": { + "@testing-library/react": "^14.0.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", diff --git a/package.json b/package.json index dc1876b..1dc901f 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "echarts-stat": "^1.2.0", "echarts-wordcloud": "^2.1.0", "react": "18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "18.2.0", - "tslib": "^2.6.1" + "tslib": "^2.6.1", + "uuid": "^9.0.0" }, "description": "Apache ECharts panel for Grafana", "devDependencies": { @@ -33,8 +35,11 @@ "@types/jest": "^29.5.3", "@types/lodash": "^4.14.196", "@types/node": "^18.17.4", + "@types/react-beautiful-dnd": "^13.1.4", + "@types/uuid": "^9.0.3", "@types/webpack-env": "^1.18.1", "@typescript-eslint/eslint-plugin": "^5.62.0", + "@volkovlabs/jest-selectors": "^1.2.0", "copy-webpack-plugin": "^11.0.0", "css-loader": "^6.8.1", "eslint-plugin-deprecation": "^1.5.0", diff --git a/src/__mocks__/@grafana/ui.tsx b/src/__mocks__/@grafana/ui.tsx new file mode 100644 index 0000000..a9e1276 --- /dev/null +++ b/src/__mocks__/@grafana/ui.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +const actual = jest.requireActual('@grafana/ui'); + +/** + * Mock Select component + */ +const Select = jest.fn(({ options, onChange, value, isMulti, isClearable, ...restProps }) => ( + +)); + +module.exports = { + ...actual, + Select, +}; diff --git a/src/__mocks__/react-beautiful-dnd.tsx b/src/__mocks__/react-beautiful-dnd.tsx new file mode 100644 index 0000000..cd3dccb --- /dev/null +++ b/src/__mocks__/react-beautiful-dnd.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +const actual = jest.requireActual('@grafana/ui'); + +/** + * Mock DragDropContext + */ +const DragDropContext = jest.fn(({ children }) => children); + +/** + * Mock Droppable + */ +const Droppable = jest.fn(({ children }) => children({})); + +/** + * Draggable + */ +const Draggable = jest.fn(({ children }) => ( +
+ {children( + { + draggableProps: {}, + }, + {} + )} +
+)); + +module.exports = { + ...actual, + DragDropContext, + Droppable, + Draggable, +}; diff --git a/src/components/Collapse/Collapse.styles.ts b/src/components/Collapse/Collapse.styles.ts new file mode 100644 index 0000000..cadd47c --- /dev/null +++ b/src/components/Collapse/Collapse.styles.ts @@ -0,0 +1,45 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +/** + * Styles + */ +export const Styles = (theme: GrafanaTheme2) => { + return { + root: css` + border: 1px solid ${theme.colors.border.weak}; + background-color: ${theme.colors.background.primary}; + `, + header: css` + label: Header; + padding: ${theme.spacing(0.5, 0.5)}; + min-height: ${theme.spacing(4)}; + display: flex; + align-items: center; + justify-content: space-between; + white-space: nowrap; + + &:focus { + outline: none; + } + `, + title: css` + font-weight: ${theme.typography.fontWeightBold}; + margin-left: ${theme.spacing(0.5)}; + overflow: hidden; + text-overflow: ellipsis; + `, + collapseIcon: css` + margin-left: ${theme.spacing(0.5)}; + color: ${theme.colors.text.disabled}; + `, + actions: css` + margin-left: auto; + display: flex; + align-items: center; + `, + content: css` + padding: ${theme.spacing(1)}; + `, + }; +}; diff --git a/src/components/Collapse/Collapse.test.tsx b/src/components/Collapse/Collapse.test.tsx new file mode 100644 index 0000000..98549d9 --- /dev/null +++ b/src/components/Collapse/Collapse.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Collapse } from './Collapse'; + +type Props = React.ComponentProps; + +/** + * In Test Ids + */ +const InTestIds = { + header: 'data-testid header', + content: 'data-testid content', + buttonRemove: 'data-testid button-remove', +}; + +describe('Collapse', () => { + /** + * Get Tested Component + */ + const getComponent = (props: Partial) => { + return ; + }; + + it('Should expand content', () => { + const { rerender } = render(getComponent({ isOpen: false })); + + expect(screen.queryByTestId(InTestIds.content)).not.toBeInTheDocument(); + + rerender(getComponent({ isOpen: true })); + + expect(screen.getByTestId(InTestIds.content)).toBeInTheDocument(); + }); + + it('Actions should not affect collapse state', () => { + const onToggle = jest.fn(); + + render(getComponent({ onToggle, actions: })); + + fireEvent.click(screen.getByTestId(InTestIds.buttonRemove)); + + expect(onToggle).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Collapse/Collapse.tsx b/src/components/Collapse/Collapse.tsx new file mode 100644 index 0000000..dae055f --- /dev/null +++ b/src/components/Collapse/Collapse.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { IconButton, useStyles2 } from '@grafana/ui'; +import { Styles } from './Collapse.styles'; + +/** + * Properties + */ +interface Props { + /** + * Title + */ + title?: React.ReactElement | string; + + /** + * Actions + */ + actions?: React.ReactElement; + + /** + * Children + */ + children?: React.ReactElement | string; + + /** + * Is Open? + */ + isOpen?: boolean; + + /** + * On Toggle + */ + onToggle?: () => void; + + /** + * Header Test Id + */ + headerTestId?: string; + + /** + * Content Test Id + */ + contentTestId?: string; +} + +/** + * Collapse + */ +export const Collapse: React.FC = ({ + title, + actions, + children, + isOpen = false, + onToggle, + headerTestId, + contentTestId, +}) => { + /** + * Styles + */ + const styles = useStyles2(Styles); + + return ( +
+
+ +
{title}
+ {actions && ( +
event.stopPropagation()}> + {actions} +
+ )} +
+ {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/components/Collapse/index.ts b/src/components/Collapse/index.ts new file mode 100644 index 0000000..5fda998 --- /dev/null +++ b/src/components/Collapse/index.ts @@ -0,0 +1 @@ +export * from './Collapse'; diff --git a/src/components/DatasetEditor/DatasetEditor.styles.ts b/src/components/DatasetEditor/DatasetEditor.styles.ts new file mode 100644 index 0000000..eb88331 --- /dev/null +++ b/src/components/DatasetEditor/DatasetEditor.styles.ts @@ -0,0 +1,64 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; + +/** + * Styles + */ +export const Styles = (theme: GrafanaTheme2) => { + return { + root: css` + margin-bottom: ${theme.spacing(2)}; + `, + header: css` + label: Header; + padding: ${theme.spacing(0.5, 0.5)}; + border-radius: ${theme.shape.borderRadius(1)}; + background: ${theme.colors.background.secondary}; + min-height: ${theme.spacing(4)}; + display: grid; + grid-template-columns: minmax(100px, max-content) min-content; + align-items: center; + justify-content: space-between; + white-space: nowrap; + + &:focus { + outline: none; + } + `, + column: css` + label: Column; + display: flex; + align-items: center; + `, + dragIcon: css` + cursor: grab; + color: ${theme.colors.text.disabled}; + margin: ${theme.spacing(0, 0.5)}; + &:hover { + color: ${theme.colors.text}; + } + `, + collapseIcon: css` + margin-left: ${theme.spacing(0.5)}; + color: ${theme.colors.text.disabled}; + `, + titleWrapper: css` + display: flex; + align-items: center; + flex-grow: 1; + cursor: pointer; + overflow: hidden; + margin-right: ${theme.spacing(0.5)}; + `, + title: css` + font-weight: ${theme.typography.fontWeightBold}; + color: ${theme.colors.text.secondary}; + margin-left: ${theme.spacing(0.5)}; + overflow: hidden; + text-overflow: ellipsis; + `, + item: css` + margin-bottom: ${theme.spacing(1)}; + `, + }; +}; diff --git a/src/components/DatasetEditor/DatasetEditor.test.tsx b/src/components/DatasetEditor/DatasetEditor.test.tsx new file mode 100644 index 0000000..1905640 --- /dev/null +++ b/src/components/DatasetEditor/DatasetEditor.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { getJestSelectors } from '@volkovlabs/jest-selectors'; +import { DragDropContext, DropResult } from 'react-beautiful-dnd'; +import { TestIds } from '../../constants'; +import { DatasetEditor } from './DatasetEditor'; +import { toDataFrame } from '@grafana/data'; + +/** + * Properties + */ +type Props = React.ComponentProps; + +/** + * Dataset Editor + */ +describe('Dataset Editor', () => { + /** + * Create On Change Handler + */ + const createOnChangeHandler = (initialValue: any) => { + let value = initialValue; + return { + value, + onChange: jest.fn((newValue) => { + value = newValue; + }), + }; + }; + + /** + * Data + */ + const data = [ + toDataFrame({ + refId: 'A', + fields: [ + { + name: 'Time', + values: [], + }, + { + name: 'Value', + values: [], + }, + ], + }), + toDataFrame({ + fields: [ + { + name: 'Value', + values: [], + }, + ], + }), + ]; + + /** + * Selectors + */ + const getSelectors = getJestSelectors(TestIds.datasetEditor); + const selectors = getSelectors(screen); + + /** + * Get Tested Component + */ + const getComponent = (props: Partial) => { + return ; + }; + + it('Should render component', () => { + render(getComponent({})); + + expect(selectors.root()).toBeInTheDocument(); + }); + + it('Should render items', () => { + render( + getComponent({ + value: [ + { name: 'Time', source: 'A' }, + { name: 'Value', source: '' }, + ], + }) + ); + + expect(selectors.root()).toBeInTheDocument(); + expect(selectors.item(false, 'A:Time')).toBeInTheDocument(); + expect(selectors.item(false, 'Value')).toBeInTheDocument(); + }); + + it('Should add new item', async () => { + const { value, onChange } = createOnChangeHandler([{ name: 'Time', source: 'A' }]); + + /** + * Render + */ + const { rerender } = render( + getComponent({ + value, + onChange, + }) + ); + + expect(selectors.root()).toBeInTheDocument(); + expect(selectors.newItemName()).toBeInTheDocument(); + + await act(async () => fireEvent.change(selectors.newItemName(), { target: { value: 'A:Value' } })); + + /** + * Add New Item + */ + expect(selectors.buttonAddNew()).toBeInTheDocument(); + expect(selectors.buttonAddNew()).not.toBeDisabled(); + await act(async () => { + fireEvent.click(selectors.buttonAddNew()); + }); + + rerender( + getComponent({ + value, + onChange, + }) + ); + + expect(selectors.item(false, 'A:Value')).toBeInTheDocument(); + }); + + it('Should remove item', async () => { + const { value, onChange } = createOnChangeHandler([{ name: 'Time', source: 'A' }]); + + /** + * Render + */ + const { rerender } = render( + getComponent({ + value, + onChange, + }) + ); + + expect(selectors.root()).toBeInTheDocument(); + expect(selectors.item(false, 'A:Time')).toBeInTheDocument(); + + /** + * Remove Item + */ + const itemSelectors = getSelectors(within(selectors.item(false, 'A:Time'))); + await act(async () => fireEvent.click(itemSelectors.buttonRemove())); + + rerender( + getComponent({ + value, + onChange, + }) + ); + + expect(selectors.item(true, 'A:Value')).not.toBeInTheDocument(); + }); + + /** + * Items order + */ + describe('Items order', () => { + it('Should reorder items', async () => { + let onDragEndHandler: (result: DropResult) => void = () => {}; + jest.mocked(DragDropContext).mockImplementation(({ children, onDragEnd }: any) => { + onDragEndHandler = onDragEnd; + return children; + }); + + const timeItem = { name: 'Time', source: 'A' }; + const valueItem = { name: 'Value', source: 'B' }; + const { value, onChange } = createOnChangeHandler([timeItem, valueItem]); + + const { rerender } = render(getComponent({ value, onChange })); + + /** + * Simulate drop element 1 to index 0 + */ + await act(async () => + onDragEndHandler({ + destination: { + index: 0, + }, + source: { + index: 1, + }, + } as any) + ); + + rerender(getComponent({ value, onChange })); + + /** + * Check if items order is changed + */ + const items = screen.getAllByTestId('draggable'); + expect(getSelectors(within(items[0])).item(false, 'B:Value')).toBeInTheDocument(); + expect(getSelectors(within(items[1])).item(false, 'A:Time')).toBeInTheDocument(); + }); + + it('Should not reorder items if drop outside the list', async () => { + let onDragEndHandler: (result: DropResult) => void = () => {}; + jest.mocked(DragDropContext).mockImplementation(({ children, onDragEnd }: any) => { + onDragEndHandler = onDragEnd; + return children; + }); + + const timeItem = { name: 'Time', source: 'A' }; + const valueItem = { name: 'Value', source: 'B' }; + const { value, onChange } = createOnChangeHandler([timeItem, valueItem]); + + render(getComponent({ value, onChange })); + + /** + * Simulate drop element 1 to index 0 + */ + await act(async () => + onDragEndHandler({ + destination: null, + source: { + index: 1, + }, + } as any) + ); + + /** + * Check if items order is not changed + */ + const items = screen.getAllByTestId('draggable'); + expect(getSelectors(within(items[0])).item(false, 'A:Time')).toBeInTheDocument(); + expect(getSelectors(within(items[1])).item(false, 'B:Value')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/DatasetEditor/DatasetEditor.tsx b/src/components/DatasetEditor/DatasetEditor.tsx new file mode 100644 index 0000000..3a35f3f --- /dev/null +++ b/src/components/DatasetEditor/DatasetEditor.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { cx } from '@emotion/css'; +import { + DragDropContext, + Draggable, + DraggingStyle, + Droppable, + DropResult, + NotDraggingStyle, +} from 'react-beautiful-dnd'; +import { Button, Icon, IconButton, InlineField, InlineFieldRow, Select, useStyles2 } from '@grafana/ui'; +import { DataFrame, SelectableValue } from '@grafana/data'; +import { TestIds } from '../../constants'; +import { DatasetItem } from '../../types'; +import { getDatasetItemUniqueName, reorder } from '../../utils'; +import { Styles } from './DatasetEditor.styles'; + +/** + * Get Item Style + */ +const getItemStyle = (isDragging: boolean, draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({ + /** + * styles we need to apply on draggables + */ + ...draggableStyle, +}); + +/** + * Properties + */ +interface Props { + /** + * Value + * + * @type {DatasetItem[]} + */ + value: DatasetItem[]; + + /** + * On Change + * + * @type {Function} + */ + onChange: (items: DatasetItem[]) => void; + + /** + * Data + * + * @type {DataFrame[]} + */ + data: DataFrame[]; +} + +/** + * Dataset Editor + */ +export const DatasetEditor: React.FC = ({ value, onChange, data }) => { + /** + * Styles and Theme + */ + const styles = useStyles2(Styles); + + /** + * States + */ + const [items, setItems] = useState(value); + const [newItem, setNewItem] = useState<(DatasetItem & { value: string }) | null>(null); + + /** + * Change Items + */ + const onChangeItems = useCallback( + (items: DatasetItem[]) => { + setItems(items); + onChange(items); + }, + [onChange] + ); + + /** + * Drag End + */ + const onDragEnd = useCallback( + (result: DropResult) => { + /** + * Dropped outside the list + */ + if (!result.destination) { + return; + } + + onChangeItems(reorder(items, result.source.index, result.destination.index)); + }, + [items, onChangeItems] + ); + + /** + * Available Field Options + */ + const availableFieldOptions = useMemo(() => { + return data.reduce((acc: SelectableValue[], dataFrame) => { + return acc.concat( + dataFrame.fields.map((field) => ({ + value: `${dataFrame.refId}:${field.name}`, + fieldName: field.name, + label: `${dataFrame.refId ? `${dataFrame.refId}:` : ''}${field.name}`, + source: dataFrame.refId, + })) + ); + }, []); + }, [data]); + + /** + * Add New Item + */ + const onAddNewItem = useCallback(() => { + if (newItem) { + onChangeItems([ + ...items, + { + name: newItem.name, + source: newItem.source, + }, + ]); + setNewItem(null); + } + }, [items, newItem, onChangeItems]); + + return ( +
+ + + {(provided) => ( +
+ {items.map((item, index) => ( + + {(provided, snapshot) => ( +
+
+
+ {item.name && ( +
+
+ {item.source ? `${item.source}:` : ''} + {item.name} +
+
+ )} +
+ +
+ + onChangeItems( + items.filter( + (field) => getDatasetItemUniqueName(field) !== getDatasetItemUniqueName(item) + ) + ) + } + data-testid={TestIds.datasetEditor.buttonRemove} + /> + +
+
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ + + + setNewItem(event.currentTarget.value)} + data-testid={TestIds.seriesEditor.newItemId} + /> + + + +
+ ); +}; diff --git a/src/components/SeriesEditor/index.ts b/src/components/SeriesEditor/index.ts new file mode 100644 index 0000000..ddb1d13 --- /dev/null +++ b/src/components/SeriesEditor/index.ts @@ -0,0 +1 @@ +export * from './SeriesEditor'; diff --git a/src/components/SeriesItemEditor/SeriesItemEditor.tsx b/src/components/SeriesItemEditor/SeriesItemEditor.tsx new file mode 100644 index 0000000..6b2f756 --- /dev/null +++ b/src/components/SeriesItemEditor/SeriesItemEditor.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { InlineField, InlineFieldRow, Input, Select } from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { SeriesTypeOptions, TestIds } from '../../constants'; +import { DatasetItem, SeriesItem, SeriesType } from '../../types'; +import { getDatasetItemUniqueName, getSeriesWithNewType } from '../../utils'; + +/** + * Label Width + */ +const LabelWidth = 10; + +/** + * Properties + */ +interface Props { + /** + * Value + * + * @type {SeriesItem} + */ + value: SeriesItem; + + /** + * On Change + */ + onChange: (value: SeriesItem) => void; + + /** + * Dataset + */ + dataset: DatasetItem[]; +} + +export const SeriesItemEditor: React.FC = ({ value, onChange, dataset }) => ( + <> + + + { + onChange({ + ...value, + id: event.currentTarget.value, + }); + }} + data-testid={TestIds.seriesEditor.fieldId} + /> + + + { + onChange({ + ...value, + name: event.currentTarget.value, + }); + }} + data-testid={TestIds.seriesEditor.fieldName} + /> + + + {value.type === SeriesType.LINE && ( + <> + + + ({ + value: getDatasetItemUniqueName(item), + label: getDatasetItemUniqueName(item), + }))} + isMulti={true} + isClearable={true} + onChange={(event) => { + const values = event as SelectableValue[]; + onChange({ + ...value, + encode: { + ...value.encode, + x: values.map((item) => item.value), + }, + }); + }} + aria-label={TestIds.seriesEditor.fieldEncodeX} + /> + + + + )} + +); diff --git a/src/components/SeriesItemEditor/index.ts b/src/components/SeriesItemEditor/index.ts new file mode 100644 index 0000000..29b6d68 --- /dev/null +++ b/src/components/SeriesItemEditor/index.ts @@ -0,0 +1 @@ +export * from './SeriesItemEditor'; diff --git a/src/components/VisualEditor/VisualEditor.test.tsx b/src/components/VisualEditor/VisualEditor.test.tsx new file mode 100644 index 0000000..7a38972 --- /dev/null +++ b/src/components/VisualEditor/VisualEditor.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { getJestSelectors } from '@volkovlabs/jest-selectors'; +import { TestIds } from '../../constants'; +import { VisualEditor } from './VisualEditor'; +import { toDataFrame } from '@grafana/data'; + +/** + * Properties + */ +type Props = React.ComponentProps; + +/** + * Visual Editor + */ +describe('Visual Editor', () => { + /** + * Create On Change Handler + */ + const createOnChangeHandler = (initialValue: any) => { + let value = initialValue; + return { + value, + onChange: jest.fn((newValue) => { + value = newValue; + }), + }; + }; + + /** + * Selectors + */ + const seriesEditorSelectors = getJestSelectors(TestIds.seriesEditor)(screen); + const datasetEditorSelectors = getJestSelectors(TestIds.datasetEditor)(screen); + + /** + * Data + */ + const data = [ + toDataFrame({ + fields: [ + { + name: 'Time', + values: [], + }, + { + name: 'Value', + values: [], + }, + ], + }), + ]; + + /** + * Get Tested Component + */ + const getComponent = (props: Partial) => { + return ( + + ); + }; + + it('Should render all editors', () => { + render(getComponent({})); + + expect(seriesEditorSelectors.root()).toBeInTheDocument(); + expect(datasetEditorSelectors.root()).toBeInTheDocument(); + }); + + it('Should update dataset', () => { + const { value, onChange } = createOnChangeHandler({ + dataset: [{ name: 'Value', source: '' }], + series: [], + }); + + const { rerender } = render(getComponent({ value, onChange })); + + expect(datasetEditorSelectors.item(false, 'Value')).toBeInTheDocument(); + + /** + * Remove dataset + */ + fireEvent.click(datasetEditorSelectors.buttonRemove()); + + rerender(getComponent({ value, onChange })); + + expect(datasetEditorSelectors.item(true, 'Value')).not.toBeInTheDocument(); + }); + + it('Should update series', () => { + const { value, onChange } = createOnChangeHandler({ + dataset: [], + series: [ + { + uid: '123', + id: 'line', + name: 'Line', + }, + ], + }); + + const { rerender } = render(getComponent({ value, onChange })); + + expect(seriesEditorSelectors.itemHeader(false, 'line')).toBeInTheDocument(); + + /** + * Remove series + */ + fireEvent.click(seriesEditorSelectors.buttonRemove()); + + rerender(getComponent({ value, onChange })); + + expect(seriesEditorSelectors.itemHeader(true, 'line')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/VisualEditor/VisualEditor.tsx b/src/components/VisualEditor/VisualEditor.tsx new file mode 100644 index 0000000..312d859 --- /dev/null +++ b/src/components/VisualEditor/VisualEditor.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { StandardEditorProps } from '@grafana/data'; +import { Label } from '@grafana/ui'; +import { VisualEditorOptions } from '../../types'; +import { DatasetEditor } from '../DatasetEditor'; +import { SeriesEditor } from '../SeriesEditor'; + +/** + * Properties + */ +interface Props extends StandardEditorProps {} + +/** + * Visual Editor + */ +export const VisualEditor: React.FC = ({ value, onChange, context }) => { + return ( + <> + + { + onChange({ + ...value, + dataset: items, + }); + }} + data={context.data} + /> + + { + onChange({ + ...value, + series: items, + }); + }} + dataset={value.dataset} + /> + + ); +}; diff --git a/src/components/VisualEditor/index.ts b/src/components/VisualEditor/index.ts new file mode 100644 index 0000000..aa2dd5b --- /dev/null +++ b/src/components/VisualEditor/index.ts @@ -0,0 +1 @@ +export * from './VisualEditor'; diff --git a/src/components/index.ts b/src/components/index.ts index a87c71c..d57a976 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,2 +1,3 @@ export * from './EChartsEditor'; export * from './EChartsPanel'; +export * from './VisualEditor'; diff --git a/src/constants/default.ts b/src/constants/default.ts index 2655d67..ba4474f 100644 --- a/src/constants/default.ts +++ b/src/constants/default.ts @@ -84,6 +84,20 @@ return { series, };`; +const visualEditorCode = `console.log(context); +return { + dataset: context.editor.dataset, + series: context.editor.series, + xAxis: { + type: 'time', + }, + yAxis: { + type: 'value', + min: 'dataMin', + }, +} +`; + /** * Default Options */ @@ -105,4 +119,10 @@ export const DefaultOptions: PanelOptions = { key: '', callback: 'gmapReady', }, + visualEditor: { + dataset: [], + series: [], + code: visualEditorCode, + codeHeight: 600, + }, }; diff --git a/src/constants/editor.ts b/src/constants/editor.ts index 45d5d72..b4e5d48 100644 --- a/src/constants/editor.ts +++ b/src/constants/editor.ts @@ -6,6 +6,7 @@ import { CodeEditorSuggestionItem, CodeEditorSuggestionItemKind } from '@grafana export const enum Editor { CODE = 'getOption', THEME = 'themeEditor', + VISUALCODE = 'visualCode', } /** @@ -24,6 +25,22 @@ export enum Format { AUTO = 'auto', } +/** + * Editor Mode + */ +export enum EditorMode { + CODE = 'code', + VISUAL = 'visual', +} + +/** + * Editor Mode Options + */ +export const EditorModeOptions = [ + { value: EditorMode.CODE, label: 'Code' }, + { value: EditorMode.VISUAL, label: 'Visual' }, +]; + /** * Format Options */ @@ -86,4 +103,60 @@ export const CodeEditorSuggestions: CodeEditorSuggestionItem[] = [ kind: CodeEditorSuggestionItemKind.Method, detail: 'Display error notification.', }, + { + label: 'context', + kind: CodeEditorSuggestionItemKind.Constant, + detail: 'All passed possible properties and methods.', + }, + { + label: 'context.panel', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'Panel instance properties.', + }, + { + label: 'context.panel.data', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'Panel data.', + }, + { + label: 'context.panel.chart', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'ECharts instance.', + }, + { + label: 'context.grafana', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'Grafana properties and methods.', + }, + { + label: 'context.echarts', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'ECharts library.', + }, + { + label: 'context.ecStat', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'A statistical and data mining tool.', + }, +]; + +/** + * Visual Code Editor Suggestions + */ +export const VisualCodeEditorSuggestions: CodeEditorSuggestionItem[] = [ + { + label: 'context.editor', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'Editor properties.', + }, + { + label: 'context.editor.dataset', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'Echarts dataset.', + }, + { + label: 'context.editor.series', + kind: CodeEditorSuggestionItemKind.Property, + detail: 'Echarts series.', + }, ]; diff --git a/src/constants/index.ts b/src/constants/index.ts index b6aee97..88c7e68 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -3,3 +3,4 @@ export * from './echarts'; export * from './editor'; export * from './maps'; export * from './tests'; +export * from './visual-editor'; diff --git a/src/constants/tests.ts b/src/constants/tests.ts index 926793c..9b337b7 100644 --- a/src/constants/tests.ts +++ b/src/constants/tests.ts @@ -10,4 +10,26 @@ export const TestIds = { editor: { root: 'data-testid editor', }, + seriesEditor: { + root: 'data-testid series-editor', + buttonAddNew: 'data-testid series-editor button-add-new', + buttonRemove: 'data-testid series-editor button-remove', + itemHeader: (name: string) => `data-testid series-editor item-header-${name}`, + itemContent: (name: string) => `data-testid series-editor item-content-${name}`, + newItem: 'data-testid series-editor new-level', + newItemId: 'data-testid series-editor new-item-id', + fieldId: 'data-testid series-editor field-id', + fieldName: 'data-testid series-editor field-name', + fieldType: 'series-editor field-type', + fieldEncodeX: 'series-editor field-encode-x', + fieldEncodeY: 'series-editor field-encode-y', + }, + datasetEditor: { + buttonAddNew: 'data-testid dataset-editor button-add-new', + buttonRemove: 'data-testid dataset-editor button-remove', + item: (name: string) => `data-testid dataset-editor item-${name}`, + newItem: 'data-testid dataset-editor new-item', + newItemName: 'dataset-editor new-item-name', + root: 'data-testid dataset-editor', + }, }; diff --git a/src/constants/visual-editor.ts b/src/constants/visual-editor.ts new file mode 100644 index 0000000..ecfa87e --- /dev/null +++ b/src/constants/visual-editor.ts @@ -0,0 +1,6 @@ +import { SeriesType } from '../types'; + +export const SeriesTypeOptions = Object.values(SeriesType).map((type) => ({ + label: type.charAt(0).toUpperCase() + type.slice(1), + value: type, +})); diff --git a/src/module.ts b/src/module.ts index cde9a1e..ccf68ac 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,8 +1,10 @@ import { PanelPlugin } from '@grafana/data'; -import { EChartsEditor, EChartsPanel } from './components'; +import { EChartsEditor, EChartsPanel, VisualEditor } from './components'; import { DefaultOptions, Editor, + EditorMode, + EditorModeOptions, FormatOptions, Map, MapOptions, @@ -18,6 +20,9 @@ import { PanelOptions } from './types'; export const plugin = new PanelPlugin(EChartsPanel) .setNoPadding() .setPanelOptions((builder) => { + const isCodeEditor = (config: PanelOptions) => config.editorMode !== EditorMode.VISUAL; + const isVisualEditor = (config: PanelOptions) => config.editorMode === EditorMode.VISUAL; + builder .addRadio({ path: 'renderer', @@ -112,6 +117,54 @@ export const plugin = new PanelPlugin(EChartsPanel) /** * Editor */ + builder.addRadio({ + path: 'editorMode', + name: 'Editor Mode', + defaultValue: EditorMode.CODE, + settings: { + options: EditorModeOptions, + }, + category: ['Editor'], + }); + + /** + * Visual Editor + */ + builder + .addCustomEditor({ + id: 'visualEditor', + path: 'visualEditor', + name: 'Visual Editor', + defaultValue: DefaultOptions.visualEditor, + editor: VisualEditor, + category: ['Visual Editor'], + showIf: isVisualEditor, + }) + .addSliderInput({ + path: 'visualEditor.codeHeight', + name: 'Height, px', + defaultValue: DefaultOptions.visualEditor.codeHeight, + settings: { + min: 100, + max: 2000, + }, + category: ['Visual Editor'], + showIf: isVisualEditor, + }) + .addCustomEditor({ + id: Editor.VISUALCODE, + path: 'visualEditor.code', + name: 'Function', + description: 'Should return parameters and data for setOption() or an extended result object.', + defaultValue: DefaultOptions.visualEditor.code, + editor: EChartsEditor, + category: ['Visual Editor'], + showIf: isVisualEditor, + }); + + /** + * Code Editor + */ builder .addSliderInput({ path: 'editor.height', @@ -122,6 +175,7 @@ export const plugin = new PanelPlugin(EChartsPanel) max: 2000, }, category: ['Code'], + showIf: isCodeEditor, }) .addRadio({ path: 'editor.format', @@ -131,6 +185,7 @@ export const plugin = new PanelPlugin(EChartsPanel) }, defaultValue: DefaultOptions.editor.format, category: ['Code'], + showIf: isCodeEditor, }) .addCustomEditor({ id: Editor.CODE, @@ -140,6 +195,7 @@ export const plugin = new PanelPlugin(EChartsPanel) defaultValue: DefaultOptions.getOption, editor: EChartsEditor, category: ['Code'], + showIf: isCodeEditor, }); /** diff --git a/src/types/index.ts b/src/types/index.ts index a9b2435..15f58c3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from './gaode'; export * from './google'; export * from './panel'; export * from './theme-editor'; +export * from './visual-editor'; diff --git a/src/types/panel.ts b/src/types/panel.ts index 83800db..14a9eb9 100644 --- a/src/types/panel.ts +++ b/src/types/panel.ts @@ -1,9 +1,10 @@ -import { Map, Renderer } from '../constants'; +import { EditorMode, Map, Renderer } from '../constants'; import { BaiduOptions } from './baidu'; import { EditorOptions } from './editor'; import { GaodeOptions } from './gaode'; import { GoogleOptions } from './google'; import { ThemeEditorOptions } from './theme-editor'; +import { VisualEditorOptions } from './visual-editor'; /** * Options @@ -64,4 +65,16 @@ export interface PanelOptions { * @type {GoogleOptions} */ google: GoogleOptions; + + /** + * Editor Mode + */ + editorMode?: EditorMode; + + /** + * Visual Editor + * + * @type {VisualEditorOptions} + */ + visualEditor: VisualEditorOptions; } diff --git a/src/types/visual-editor.ts b/src/types/visual-editor.ts new file mode 100644 index 0000000..057d843 --- /dev/null +++ b/src/types/visual-editor.ts @@ -0,0 +1,165 @@ +import { EChartOption } from 'echarts'; + +/** + * Dataset Item + */ +export interface DatasetItem { + /** + * Name + * + * @type {string} + */ + name: string; + + /** + * Source + * + * @type {string} + */ + source: string; +} + +/** + * Series Type + */ +export enum SeriesType { + LINE = 'line', + BAR = 'bar', + PIE = 'pie', + SCATTER = 'scatter', + EFFECTSCATTER = 'effectScatter', + RADAR = 'radar', + TREE = 'tree', + TREEMAP = 'treemap', + SUNBURST = 'sunburst', + BOXPLOT = 'boxplot', + CANDLESTICK = 'candlestick', + HEATMAP = 'heatmap', + MAP = 'map', + PARALLEL = 'parallel', + LINES = 'lines', + GRAPH = 'graph', + SANKEY = 'sankey', + FUNNEL = 'funnel', + GAUGE = 'gauge', + PICTORIALBAR = 'pictorialBar', + THEMERIVER = 'themeRiver', + CUSTOM = 'custom', +} + +/** + * Base Series Options + */ +export interface BaseSeriesOptions { + /** + * ID + * + * @type {string} + */ + id: string; + + /** + * UID + * + * @type {string} + */ + uid: string; + + /** + * Name + * + * @type {name} + */ + name: string; +} + +/** + * Line Series Options + */ +export interface LineSeriesOptions extends EChartOption.SeriesLine { + /** + * Encode + */ + encode: { + /** + * Y + * + * @type {string[]} + */ + y: string[]; + + /** + * X + * + * @type {string[]} + */ + x: string[]; + }; +} + +/** + * Series Item + */ +export type SeriesItem = BaseSeriesOptions & + ( + | ({ type: SeriesType.LINE } & LineSeriesOptions) + | { type: SeriesType.BAR } + | { type: SeriesType.LINES } + | { type: SeriesType.BOXPLOT } + | { type: SeriesType.MAP } + | { type: SeriesType.CUSTOM } + | { type: SeriesType.HEATMAP } + | { type: SeriesType.GRAPH } + | { type: SeriesType.GAUGE } + | { type: SeriesType.PIE } + | { type: SeriesType.SCATTER } + | { type: SeriesType.EFFECTSCATTER } + | { type: SeriesType.RADAR } + | { type: SeriesType.TREE } + | { type: SeriesType.TREEMAP } + | { type: SeriesType.SUNBURST } + | { type: SeriesType.CANDLESTICK } + | { type: SeriesType.PARALLEL } + | { type: SeriesType.SANKEY } + | { type: SeriesType.FUNNEL } + | { type: SeriesType.PICTORIALBAR } + | { type: SeriesType.THEMERIVER } + ); + +/** + * Visual Editor Options + */ +export interface VisualEditorOptions { + /** + * Dataset + * + * @type {DatasetItem[]} + */ + dataset: DatasetItem[]; + + /** + * Series + * + * @type {SeriesItem[]} + */ + series: SeriesItem[]; + + /** + * Code + * + * @type {string} + */ + code: string; + + /** + * Code Height + * + * @type {number} + */ + codeHeight: number; +} + +/** + * Series By Type + */ +export type SeriesByType = Extract; diff --git a/src/utils/data-frame.ts b/src/utils/data-frame.ts new file mode 100644 index 0000000..354db87 --- /dev/null +++ b/src/utils/data-frame.ts @@ -0,0 +1,10 @@ +import { Field } from '@grafana/data'; + +/** + * Get Field Values + * @param field + */ +export const getFieldValues = (field?: Field): unknown[] => { + // eslint-disable-next-line deprecation/deprecation + return field?.values.toArray() || []; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..8fcf6ef --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from './visual-editor'; diff --git a/src/utils/visual-editor.test.ts b/src/utils/visual-editor.test.ts new file mode 100644 index 0000000..da28571 --- /dev/null +++ b/src/utils/visual-editor.test.ts @@ -0,0 +1,107 @@ +import { toDataFrame } from '@grafana/data'; +import { getDatasetSource } from './visual-editor'; + +describe('Visual Editor Utils', () => { + describe('getDatasetSource', () => { + it('Should convert to dataset', () => { + expect( + getDatasetSource( + [ + toDataFrame({ + fields: [ + { + name: 'Time', + values: [1, 2, 3], + }, + { + name: 'Value', + values: [10, 20, 30], + }, + ], + }), + ], + [ + { name: 'Time', source: '' }, + { name: 'Value', source: '' }, + ] + ) + ).toEqual([ + ['Time', 'Value'], + [1, 10], + [2, 20], + [3, 30], + ]); + }); + + it('Should set null values in row if no field value', () => { + expect( + getDatasetSource( + [ + toDataFrame({ + fields: [ + { + name: 'Time', + values: [1, 2, 3], + }, + { + name: 'Value', + values: [10, 20, 30], + }, + { + name: 'Signal', + values: ['signal1'], + }, + ], + }), + ], + [ + { name: 'Time', source: '' }, + { name: 'Signal', source: '' }, + { name: 'Value', source: '' }, + ] + ) + ).toEqual([ + ['Time', 'Signal', 'Value'], + [1, 'signal1', 10], + [2, null, 20], + [3, null, 30], + ]); + }); + + it('Should work for different frames with the same field names', () => { + expect( + getDatasetSource( + [ + toDataFrame({ + refId: 'A', + fields: [ + { + name: 'Value', + values: [10, 20, 30], + }, + ], + }), + toDataFrame({ + refId: 'B', + fields: [ + { + name: 'Value', + values: [-10, -20, -30], + }, + ], + }), + ], + [ + { name: 'Value', source: 'A' }, + { name: 'Value', source: 'B' }, + ] + ) + ).toEqual([ + ['A:Value', 'B:Value'], + [10, -10], + [20, -20], + [30, -30], + ]); + }); + }); +}); diff --git a/src/utils/visual-editor.ts b/src/utils/visual-editor.ts new file mode 100644 index 0000000..6a9664c --- /dev/null +++ b/src/utils/visual-editor.ts @@ -0,0 +1,97 @@ +import { v4 as uuidv4 } from 'uuid'; +import { DataFrame } from '@grafana/data'; +import { DatasetItem, SeriesByType, SeriesItem, SeriesType } from '../types'; +import { getFieldValues } from './data-frame'; + +/** + * Reorder + * @param list + * @param startIndex + * @param endIndex + */ +export const reorder = (list: T[], startIndex: number, endIndex: number) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; + +/** + * Get Dataset Item Unique Name + * @param item + */ +export const getDatasetItemUniqueName = (item: DatasetItem) => { + return item.source ? `${item.source}:${item.name}` : item.name; +}; + +/** + * Get Dataset Source + * @param frames + * @param items + */ +export const getDatasetSource = (frames: DataFrame[], items: DatasetItem[]): [string[], ...unknown[]] => { + const itemValuesMap = items.reduce((acc: Map, item) => { + const frame = frames.find((frame) => + item.source ? frame.refId === item.source : frame.fields.some((field) => field.name === item.name) + ); + + acc.set(getDatasetItemUniqueName(item), getFieldValues(frame?.fields.find((field) => field.name === item.name))); + + return acc; + }, new Map()); + + const maxDataLength = Array.from(itemValuesMap.values()).reduce((acc, values) => Math.max(acc, values.length), 0); + + const rows = []; + + for (let rowIndex = 0; rowIndex < maxDataLength; rowIndex += 1) { + const row = items.map((item) => { + const value = itemValuesMap.get(getDatasetItemUniqueName(item))?.[rowIndex]; + return value === undefined ? null : value; + }); + rows.push(row); + } + + return [items.map((item) => getDatasetItemUniqueName(item)), ...rows]; +}; + +/** + * Get Series With New Type + * @param item + * @param newType + */ +export const getSeriesWithNewType = ( + item: SeriesItem, + newType: SeriesType +): SeriesByType => { + const commonValues = { + uid: item.uid, + id: item.id, + name: item.name, + }; + + switch (newType) { + case SeriesType.LINE: { + return { + ...commonValues, + encode: { + x: [], + y: [], + }, + type: newType, + }; + } + default: { + return { + ...commonValues, + type: newType, + }; + } + } +}; + +/** + * Get Series Unique Id + */ +export const getSeriesUniqueId = () => uuidv4();