From a9ab533fbe3f024eea0f33b126cb44056129e0e9 Mon Sep 17 00:00:00 2001 From: Idriss Neumann Date: Fri, 8 Dec 2023 09:48:53 +0100 Subject: [PATCH] Issue #34: add DataLinks and DataLink webcomponent --- src/components/Divider.tsx | 25 ++++ src/configuration/ConfigEditor.tsx | 24 ++++ src/configuration/DataLink.tsx | 176 +++++++++++++++++++++++++++ src/configuration/DataLinks.test.tsx | 67 ++++++++++ src/configuration/DataLinks.tsx | 88 ++++++++++++++ 5 files changed, 380 insertions(+) create mode 100644 src/components/Divider.tsx create mode 100644 src/configuration/DataLink.tsx create mode 100644 src/configuration/DataLinks.test.tsx create mode 100644 src/configuration/DataLinks.tsx diff --git a/src/components/Divider.tsx b/src/components/Divider.tsx new file mode 100644 index 0000000..11f0239 --- /dev/null +++ b/src/components/Divider.tsx @@ -0,0 +1,25 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +export const Divider = ({ hideLine = false }) => { + const styles = useStyles2(getStyles); + + if (hideLine) { + return
; + } + + return
; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + divider: css({ + margin: theme.spacing(4, 0), + }), + dividerHideLine: css({ + border: 'none', + margin: theme.spacing(3, 0), + }), +}); diff --git a/src/configuration/ConfigEditor.tsx b/src/configuration/ConfigEditor.tsx index efa011c..18e7dba 100644 --- a/src/configuration/ConfigEditor.tsx +++ b/src/configuration/ConfigEditor.tsx @@ -3,6 +3,8 @@ import { DataSourceHttpSettings, Input, InlineField, FieldSet } from '@grafana/u import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; import { QuickwitOptions } from 'quickwit'; import { coerceOptions } from './utils'; +import { Divider } from 'components/Divider'; +import { DataLinks } from './DataLinks'; interface Props extends DataSourcePluginOptionsEditorProps {} @@ -27,6 +29,7 @@ export const ConfigEditor = (props: Props) => { onChange={onOptionsChange} /> + ); }; @@ -35,6 +38,27 @@ type DetailsProps = { value: DataSourceSettings; onChange: (value: DataSourceSettings) => void; }; + +export const QuickwitDataLinks = ({ value, onChange }: DetailsProps) => { + return ( +
+ + { + onChange({ + ...value, + jsonData: { + ...value.jsonData, + dataLinks: newValue, + }, + }); + }} + /> +
+ ) +}; + export const QuickwitDetails = ({ value, onChange }: DetailsProps) => { return ( <> diff --git a/src/configuration/DataLink.tsx b/src/configuration/DataLink.tsx new file mode 100644 index 0000000..24eeccc --- /dev/null +++ b/src/configuration/DataLink.tsx @@ -0,0 +1,176 @@ +import { css } from '@emotion/css'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { usePrevious } from 'react-use'; + +import { DataSourceInstanceSettings, VariableSuggestion } from '@grafana/data'; +import { + Button, + DataLinkInput, + InlineField, + InlineSwitch, + InlineFieldRow, + InlineLabel, + Input, + useStyles2 +} from '@grafana/ui'; + +import { DataSourcePicker } from '@grafana/runtime' + +import { DataLinkConfig } from '../types'; + +interface Props { + value: DataLinkConfig; + onChange: (value: DataLinkConfig) => void; + onDelete: () => void; + suggestions: VariableSuggestion[]; + className?: string; +} + +export const DataLink = (props: Props) => { + const { value, onChange, onDelete, suggestions, className } = props; + const styles = useStyles2(getStyles); + const [showInternalLink, setShowInternalLink] = useInternalLink(value.datasourceUid); + + const handleChange = (field: keyof typeof value) => (event: React.ChangeEvent) => { + onChange({ + ...value, + [field]: event.currentTarget.value, + }); + }; + + return ( +
+
+ + + +
+ + +
+ + {showInternalLink ? 'Query' : 'URL'} + + + onChange({ + ...value, + url: newValue, + }) + } + suggestions={suggestions} + /> +
+ +
+ + + +
+
+ +
+ + { + if (showInternalLink) { + onChange({ + ...value, + datasourceUid: undefined, + }); + } + setShowInternalLink(!showInternalLink); + }} + /> + + + {showInternalLink && ( + { + onChange({ + ...value, + datasourceUid: ds.uid, + }); + }} + current={value.datasourceUid} + /> + )} +
+
+ ); +}; + +function useInternalLink(datasourceUid?: string): [boolean, Dispatch>] { + const [showInternalLink, setShowInternalLink] = useState(!!datasourceUid); + const previousUid = usePrevious(datasourceUid); + + // Force internal link visibility change if uid changed outside of this component. + useEffect(() => { + if (!previousUid && datasourceUid && !showInternalLink) { + setShowInternalLink(true); + } + if (previousUid && !datasourceUid && showInternalLink) { + setShowInternalLink(false); + } + }, [previousUid, datasourceUid, showInternalLink]); + + return [showInternalLink, setShowInternalLink]; +} + +const getStyles = () => ({ + firstRow: css` + display: flex; + `, + nameField: css` + flex: 2; + `, + regexField: css` + flex: 3; + `, + row: css` + display: flex; + align-items: baseline; + `, + urlField: css` + display: flex; + flex: 1; + `, + urlDisplayLabelField: css` + flex: 1; + `, +}); diff --git a/src/configuration/DataLinks.test.tsx b/src/configuration/DataLinks.test.tsx new file mode 100644 index 0000000..a623d68 --- /dev/null +++ b/src/configuration/DataLinks.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { DataLinkConfig } from '../types'; + +import { DataLinks, Props } from './DataLinks'; + +const setup = (propOverrides?: Partial) => { + const props: Props = { + value: [], + onChange: jest.fn(), + ...propOverrides, + }; + + return render(); +}; + +describe('DataLinks tests', () => { + it('should render correctly with no fields', async () => { + setup(); + + expect(screen.getByRole('heading', { name: 'Data links' })); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(await screen.findAllByRole('button')).toHaveLength(1); + }); + + it('should render correctly when passed fields', async () => { + setup({ value: testValue }); + + expect(await screen.findAllByRole('button', { name: 'Remove field' })).toHaveLength(2); + expect(await screen.findAllByRole('checkbox', { name: 'Internal link' })).toHaveLength(2); + }); + + it('should call onChange to add a new field when the add button is clicked', async () => { + const onChangeMock = jest.fn(); + setup({ onChange: onChangeMock }); + + expect(onChangeMock).not.toHaveBeenCalled(); + const addButton = screen.getByRole('button', { name: 'Add' }); + await userEvent.click(addButton); + + expect(onChangeMock).toHaveBeenCalled(); + }); + + it('should call onChange to remove a field when the remove button is clicked', async () => { + const onChangeMock = jest.fn(); + setup({ value: testValue, onChange: onChangeMock }); + + expect(onChangeMock).not.toHaveBeenCalled(); + const removeButton = await screen.findAllByRole('button', { name: 'Remove field' }); + await userEvent.click(removeButton[0]); + + expect(onChangeMock).toHaveBeenCalled(); + }); +}); + +const testValue: DataLinkConfig[] = [ + { + field: 'regex1', + url: 'localhost1', + }, + { + field: 'regex2', + url: 'localhost2', + }, +]; diff --git a/src/configuration/DataLinks.tsx b/src/configuration/DataLinks.tsx new file mode 100644 index 0000000..2ece07a --- /dev/null +++ b/src/configuration/DataLinks.tsx @@ -0,0 +1,88 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, VariableOrigin, DataLinkBuiltInVars } from '@grafana/data'; +import { ConfigSubSection } from '@grafana/experimental'; +import { Button, useStyles2 } from '@grafana/ui'; + +import { DataLinkConfig } from '../types'; + +import { DataLink } from './DataLink'; + +const getStyles = (theme: GrafanaTheme2) => { + return { + addButton: css` + margin-right: 10px; + `, + container: css` + margin-bottom: ${theme.spacing(2)}; + `, + dataLink: css` + margin-bottom: ${theme.spacing(1)}; + `, + }; +}; + +export type Props = { + value?: DataLinkConfig[]; + onChange: (value: DataLinkConfig[]) => void; +}; +export const DataLinks = (props: Props) => { + const { value, onChange } = props; + const styles = useStyles2(getStyles); + + return ( + +
+ {value && value.length > 0 && ( +
+ {value.map((field, index) => { + return ( + { + const newDataLinks = [...value]; + newDataLinks.splice(index, 1, newField); + onChange(newDataLinks); + }} + onDelete={() => { + const newDataLinks = [...value]; + newDataLinks.splice(index, 1); + onChange(newDataLinks); + }} + suggestions={[ + { + value: DataLinkBuiltInVars.valueRaw, + label: 'Raw value', + documentation: 'Raw value of the field', + origin: VariableOrigin.Value, + }, + ]} + /> + ); + })} +
+ )} + + +
+
+ ); +};