From a1347f7382b072037ffd6a07e3f9730149b4ea8e Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Wed, 10 Nov 2021 10:09:15 +0100 Subject: [PATCH 1/5] Add ConfigEditor Component --- package.json | 1 + rollup.config.ts | 2 +- src/index.ts | 1 + src/sql/ConfigEditor/ConfigEditor.test.tsx | 48 +++++++++++++ src/sql/ConfigEditor/ConfigEditor.tsx | 63 +++++++++++++++++ src/sql/ConfigEditor/InlineText.test.tsx | 56 +++++++++++++++ src/sql/ConfigEditor/InlineText.tsx | 63 +++++++++++++++++ src/sql/ConfigEditor/Selector.test.tsx | 69 +++++++++++++++++++ src/sql/ConfigEditor/Selector.tsx | 71 ++++++++++++++++++++ src/sql/ConfigEditor/__mocks__/datasource.ts | 33 +++++++++ src/sql/ConfigEditor/index.ts | 3 + src/sql/ResourceSelector.tsx | 3 +- src/types.ts | 4 +- yarn.lock | 37 +++++++--- 14 files changed, 441 insertions(+), 13 deletions(-) create mode 100644 src/sql/ConfigEditor/ConfigEditor.test.tsx create mode 100644 src/sql/ConfigEditor/ConfigEditor.tsx create mode 100644 src/sql/ConfigEditor/InlineText.test.tsx create mode 100644 src/sql/ConfigEditor/InlineText.tsx create mode 100644 src/sql/ConfigEditor/Selector.test.tsx create mode 100644 src/sql/ConfigEditor/Selector.tsx create mode 100644 src/sql/ConfigEditor/__mocks__/datasource.ts create mode 100644 src/sql/ConfigEditor/index.ts diff --git a/package.json b/package.json index 5c569bc..0821e7a 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@babel/preset-react": "^7.13.13", "@babel/preset-typescript": "^7.13.0", "@grafana/data": "^8.2.1", + "@grafana/runtime": "^8.2.1", "@grafana/toolkit": "^8.2.1", "@grafana/tsconfig": "^1.0.0-rc1", "@grafana/ui": "^8.2.1", diff --git a/rollup.config.ts b/rollup.config.ts index d798fc9..5252067 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -26,7 +26,7 @@ const buildCjsPackage = ({ env }) => { }, }, ], - external: ['react', '@grafana/data', '@grafana/ui', 'lodash'], + external: ['react', '@grafana/data', '@grafana/ui', 'lodash', '@grafana/runtime'], plugins: [ typescript({ rollupCommonJSResolveHack: false, diff --git a/src/index.ts b/src/index.ts index 6b6f2a1..1096fd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { ConnectionConfig, ConnectionConfigProps } from './ConnectionConfig'; +export { SQLConfigEditor, SQLConfigEditorProps, SelectorInput, TextInput } from './sql/ConfigEditor'; export { ResourceSelector, ResourceSelectorProps } from './sql/ResourceSelector'; export * from './types'; export * from './regions'; diff --git a/src/sql/ConfigEditor/ConfigEditor.test.tsx b/src/sql/ConfigEditor/ConfigEditor.test.tsx new file mode 100644 index 0000000..7fc8195 --- /dev/null +++ b/src/sql/ConfigEditor/ConfigEditor.test.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { SQLConfigEditor } from './ConfigEditor'; +import { mockDatasourceOptions } from './__mocks__/datasource'; +import { SelectorInput } from './Selector'; +import { TextInput } from './InlineText'; +import { AwsAuthType } from '../../types'; + +const props = { + ...mockDatasourceOptions, + inputs: [] as Array, +}; + +const resetWindow = () => { + (window as any).grafanaBootData = { + settings: { + awsAllowedAuthProviders: [AwsAuthType.EC2IAMRole, AwsAuthType.Keys], + awsAssumeRoleEnabled: false, + }, + }; +}; + +describe('SQLConfigEditor', () => { + beforeEach(() => resetWindow()); + afterEach(() => resetWindow()); + + it('should render a custom component', () => { + render(hello!]} />); + expect(screen.queryByText('hello!')).toBeInTheDocument(); + }); + + it('should render a selector', () => { + const input: SelectorInput = { + id: 'foo', + fetch: jest.fn(), + }; + render(); + expect(screen.queryByText('bar')).toBeInTheDocument(); + }); + + it('should render a text input', () => { + const input: TextInput = { + id: 'foo', + }; + render(); + expect(screen.queryByDisplayValue('bar')).toBeInTheDocument(); + }); +}); diff --git a/src/sql/ConfigEditor/ConfigEditor.tsx b/src/sql/ConfigEditor/ConfigEditor.tsx new file mode 100644 index 0000000..ec744a1 --- /dev/null +++ b/src/sql/ConfigEditor/ConfigEditor.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; +import { AwsAuthDataSourceJsonData, AwsAuthDataSourceSecureJsonData, AwsAuthDataSourceSettings } from '../../types'; +import { getBackendSrv } from '@grafana/runtime'; +import { Selector, SelectorInput } from './Selector'; +import { InlineText, TextInput } from './InlineText'; +import { ConnectionConfig } from 'ConnectionConfig'; + +type SQLDataSourceJsonData = T & { + [n: string]: any; +}; + +export interface SQLConfigEditorProps + extends DataSourcePluginOptionsEditorProps, AwsAuthDataSourceSecureJsonData> { + inputs: Array; +} + +export function SQLConfigEditor(props: SQLConfigEditorProps) { + const baseURL = `/api/datasources/${props.options.id}`; + const [saved, setSaved] = useState(!!props.options.jsonData.defaultRegion); + const saveOptions = async () => { + if (saved) { + return; + } + await getBackendSrv() + .put(baseURL, props.options) + .then((result: { datasource: AwsAuthDataSourceSettings }) => { + props.onOptionsChange({ + ...props.options, + version: result.datasource.version, + }); + }); + setSaved(true); + }; + + const inputElements = props.inputs.map((i) => { + const elem = i as JSX.Element; + if (elem.type) { + return elem; + } + if ('fetch' in i) { + // The input is a selector + return ; + } else { + // The input is a text field + const input = i as TextInput; + return ; + } + }); + + const onOptionsChange = (options: DataSourceSettings) => { + setSaved(false); + props.onOptionsChange(options); + }; + + return ( +
+ +

Data Source Details

+ {inputElements} +
+ ); +} diff --git a/src/sql/ConfigEditor/InlineText.test.tsx b/src/sql/ConfigEditor/InlineText.test.tsx new file mode 100644 index 0000000..10b22b0 --- /dev/null +++ b/src/sql/ConfigEditor/InlineText.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InlineText, TextInput } from './InlineText'; +import { mockDatasourceOptions } from './__mocks__/datasource'; + +const props = { + ...mockDatasourceOptions, +}; + +describe('SQLTextInput', () => { + it('should show jsonData value', () => { + const input: TextInput = { + id: 'foo', + }; + render(); + expect(screen.queryByDisplayValue('bar')).toBeInTheDocument(); + }); + + it('should show a custom value', () => { + const input: TextInput = { + id: 'foo', + value: 'foobar', + }; + render(); + expect(screen.queryByDisplayValue('foobar')).toBeInTheDocument(); + }); + + it('should update jsonData', () => { + const input: TextInput = { + id: 'foo', + 'data-testid': 'foo-id', + }; + const onOptionsChange = jest.fn(); + render(); + fireEvent.change(screen.getByTestId('foo-id'), { target: { value: 'bar' } }); + expect(onOptionsChange).toHaveBeenCalledWith({ + ...props.options, + jsonData: { + ...props.options.jsonData, + foo: 'bar', + }, + }); + }); + + it('should call custom onChange', () => { + const onChange = jest.fn(); + const input: TextInput = { + id: 'foo', + 'data-testid': 'foo-id', + onChange, + }; + render(); + fireEvent.change(screen.getByTestId('foo-id'), { target: { value: 'bar' } }); + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/src/sql/ConfigEditor/InlineText.tsx b/src/sql/ConfigEditor/InlineText.tsx new file mode 100644 index 0000000..bbbe966 --- /dev/null +++ b/src/sql/ConfigEditor/InlineText.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data'; +import { AwsAuthDataSourceJsonData, AwsAuthDataSourceSecureJsonData } from '../../types'; +import { InlineField, Input } from '@grafana/ui'; +import { FormEvent } from 'react-dom/node_modules/@types/react'; + +export type TextInput = { + id: string; + label?: string; + tooltip?: string; + placeholder?: string; + 'data-testid'?: string; + hidden?: boolean; + disabled?: boolean; + value?: string; + onChange?: (e: SelectableValue) => void; +}; + +type SQLDataSourceJsonData = T & { + [n: string]: any; +}; + +export interface TextProps + extends DataSourcePluginOptionsEditorProps, AwsAuthDataSourceSecureJsonData> { + input: TextInput; +} + +export function InlineText(props: TextProps) { + const { input } = props; + + const onChange = (e: FormEvent) => { + if (input.onChange) { + input.onChange(e); + } else { + props.onOptionsChange({ + ...props.options, + jsonData: { + ...props.options.jsonData, + [input.id]: e.currentTarget.value || '', + }, + }); + } + }; + return ( + + ); +} diff --git a/src/sql/ConfigEditor/Selector.test.tsx b/src/sql/ConfigEditor/Selector.test.tsx new file mode 100644 index 0000000..22aaa95 --- /dev/null +++ b/src/sql/ConfigEditor/Selector.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Selector, SelectorInput } from './Selector'; +import { mockDatasourceOptions } from './__mocks__/datasource'; +import { select } from 'react-select-event'; + +const props = { + ...mockDatasourceOptions, + saveOptions: jest.fn(), +}; + +describe('SQLTextInput', () => { + it('should show jsonData value', () => { + const input: SelectorInput = { + id: 'foo', + fetch: jest.fn(), + }; + render(); + expect(screen.queryByText('bar')).toBeInTheDocument(); + }); + + it('should show a custom value', () => { + const input: SelectorInput = { + id: 'foo', + value: 'foobar', + fetch: jest.fn(), + }; + render(); + expect(screen.queryByText('foobar')).toBeInTheDocument(); + }); + + it('should update jsonData', async () => { + const input: SelectorInput = { + id: 'foo', + label: 'foo-id', + fetch: jest.fn().mockResolvedValue(['bar']), + }; + const onOptionsChange = jest.fn(); + render(); + + const selectEl = screen.getByLabelText(input.label); + expect(selectEl).toBeInTheDocument(); + await select(selectEl, 'bar', { container: document.body }); + expect(input.fetch).toHaveBeenCalled(); + expect(onOptionsChange).toHaveBeenCalledWith({ + ...props.options, + jsonData: { + ...props.options.jsonData, + foo: 'bar', + }, + }); + }); + + it('should call custom onChange', async () => { + const onChange = jest.fn(); + const input: SelectorInput = { + id: 'foo', + label: 'foo-id', + fetch: jest.fn().mockResolvedValue(['bar']), + onChange, + }; + render(); + const selectEl = screen.getByLabelText(input.label); + expect(selectEl).toBeInTheDocument(); + await select(selectEl, 'bar', { container: document.body }); + expect(input.fetch).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); + }); +}); diff --git a/src/sql/ConfigEditor/Selector.tsx b/src/sql/ConfigEditor/Selector.tsx new file mode 100644 index 0000000..51b6574 --- /dev/null +++ b/src/sql/ConfigEditor/Selector.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { DataSourcePluginOptionsEditorProps, SelectableValue } from '@grafana/data'; +import { AwsAuthDataSourceJsonData, AwsAuthDataSourceSecureJsonData } from '../../types'; +import { ResourceSelector } from '../ResourceSelector'; + +export type SelectorInput = { + id: string; + fetch: () => Promise>>; + dependencies?: string[]; + label?: string; + 'data-testid'?: string; + hidden?: boolean; + disabled?: boolean; + onChange?: (e: SelectableValue) => void; + value?: string; +}; + +type SQLDataSourceJsonData = T & { + [n: string]: any; +}; + +export interface SelectorProps + extends DataSourcePluginOptionsEditorProps, AwsAuthDataSourceSecureJsonData> { + input: SelectorInput; + saveOptions: () => Promise; +} + +export function Selector(props: SelectorProps) { + const { jsonData } = props.options; + const commonProps = { + title: jsonData.defaultRegion ? '' : 'select a default region', + disabled: !jsonData.defaultRegion, + labelWidth: 28, + className: 'width-30', + }; + + // The input is a selector + const { input } = props; + const onChange = (e: SelectableValue | null) => { + if (input.onChange) { + input.onChange(e); + } else { + props.onOptionsChange({ + ...props.options, + jsonData: { + ...props.options.jsonData, + [input.id]: e ? e.value || '' : e, + }, + }); + } + }; + const dependencies: string[] = []; + if (input.dependencies) { + input.dependencies.forEach((dep) => dependencies.push(props.options.jsonData[dep])); + } + return ( +