mirror of https://github.com/grafana/grafana
Test plugins: Add datasource test plugin with field tests (#95472)
* add new test plugin * add some field validation tests * update lockfile * fix bad test file namepull/95717/head
parent
f3bdf4455c
commit
c29ed503db
@ -0,0 +1 @@ |
||||
# Changelog |
||||
@ -0,0 +1,96 @@ |
||||
import { ChangeEvent } from 'react'; |
||||
import { Checkbox, InlineField, InlineSwitch, Input, SecretInput, Select } from '@grafana/ui'; |
||||
import { DataSourcePluginOptionsEditorProps, SelectableValue, toOption } from '@grafana/data'; |
||||
import { MyDataSourceOptions, MySecureJsonData } from '../types'; |
||||
|
||||
interface Props extends DataSourcePluginOptionsEditorProps<MyDataSourceOptions, MySecureJsonData> {} |
||||
|
||||
export function ConfigEditor(props: Props) { |
||||
const { onOptionsChange, options } = props; |
||||
const { jsonData, secureJsonFields, secureJsonData } = options; |
||||
|
||||
const onJsonDataChange = (key: string, value: string | number | boolean) => { |
||||
onOptionsChange({ |
||||
...options, |
||||
jsonData: { |
||||
...jsonData, |
||||
[key]: value, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
// Secure field (only sent to the backend)
|
||||
const onSecureJsonDataChange = (key: string, value: string | number) => { |
||||
onOptionsChange({ |
||||
...options, |
||||
secureJsonData: { |
||||
[key]: value, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
const onResetAPIKey = () => { |
||||
onOptionsChange({ |
||||
...options, |
||||
secureJsonFields: { |
||||
...options.secureJsonFields, |
||||
apiKey: false, |
||||
}, |
||||
secureJsonData: { |
||||
...options.secureJsonData, |
||||
apiKey: '', |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<InlineField label="Path" labelWidth={14} interactive tooltip={'Json field returned to frontend'}> |
||||
<Input |
||||
id="config-editor-path" |
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('path', e.target.value)} |
||||
value={jsonData.path} |
||||
placeholder="Enter the path, e.g. /api/v1" |
||||
width={40} |
||||
/> |
||||
</InlineField> |
||||
<InlineField label="API Key" labelWidth={14} interactive tooltip={'Secure json field (backend only)'}> |
||||
<SecretInput |
||||
required |
||||
id="config-editor-api-key" |
||||
isConfigured={secureJsonFields.apiKey} |
||||
value={secureJsonData?.apiKey} |
||||
placeholder="Enter your API key" |
||||
width={40} |
||||
onReset={onResetAPIKey} |
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onSecureJsonDataChange('path', e.target.value)} |
||||
/> |
||||
</InlineField> |
||||
<InlineField label="Switch Enabled"> |
||||
<InlineSwitch |
||||
width={40} |
||||
label="Switch Enabled" |
||||
value={jsonData.switchEnabled ?? false} |
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('switchEnabled', e.target.checked)} |
||||
/> |
||||
</InlineField> |
||||
<InlineField label="Checkbox Enabled"> |
||||
<Checkbox |
||||
width={40} |
||||
id="config-checkbox-enabled" |
||||
value={jsonData.checkboxEnabled} |
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onJsonDataChange('checkboxEnabled', e.target.checked)} |
||||
/> |
||||
</InlineField> |
||||
<InlineField label="Auth type"> |
||||
<Select |
||||
width={40} |
||||
inputId="config-auth-type" |
||||
value={jsonData.authType ?? 'keys'} |
||||
options={['keys', 'credentials'].map(toOption)} |
||||
onChange={(e: SelectableValue<string>) => onJsonDataChange('authType', e.value!)} |
||||
/> |
||||
</InlineField> |
||||
</> |
||||
); |
||||
} |
||||
@ -0,0 +1,45 @@ |
||||
import { ChangeEvent } from 'react'; |
||||
import { InlineField, Input, Stack } from '@grafana/ui'; |
||||
import { QueryEditorProps } from '@grafana/data'; |
||||
import { DataSource } from '../datasource'; |
||||
import { MyDataSourceOptions, MyQuery } from '../types'; |
||||
|
||||
type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>; |
||||
|
||||
export function QueryEditor({ query, onChange, onRunQuery }: Props) { |
||||
const onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => { |
||||
onChange({ ...query, queryText: event.target.value }); |
||||
}; |
||||
|
||||
const onConstantChange = (event: ChangeEvent<HTMLInputElement>) => { |
||||
onChange({ ...query, constant: parseFloat(event.target.value) }); |
||||
// executes the query
|
||||
onRunQuery(); |
||||
}; |
||||
|
||||
const { queryText, constant } = query; |
||||
|
||||
return ( |
||||
<Stack gap={0}> |
||||
<InlineField label="Constant"> |
||||
<Input |
||||
id="query-editor-constant" |
||||
onChange={onConstantChange} |
||||
value={constant} |
||||
width={8} |
||||
type="number" |
||||
step="0.1" |
||||
/> |
||||
</InlineField> |
||||
<InlineField label="Query Text" labelWidth={16} tooltip="Not used yet"> |
||||
<Input |
||||
id="query-editor-query-text" |
||||
onChange={onQueryTextChange} |
||||
value={queryText || ''} |
||||
required |
||||
placeholder="Enter a query" |
||||
/> |
||||
</InlineField> |
||||
</Stack> |
||||
); |
||||
} |
||||
@ -0,0 +1,93 @@ |
||||
import { getBackendSrv, isFetchError } from '@grafana/runtime'; |
||||
import { |
||||
CoreApp, |
||||
DataQueryRequest, |
||||
DataQueryResponse, |
||||
DataSourceApi, |
||||
DataSourceInstanceSettings, |
||||
createDataFrame, |
||||
FieldType, |
||||
} from '@grafana/data'; |
||||
|
||||
import { MyQuery, MyDataSourceOptions, DEFAULT_QUERY, DataSourceResponse } from './types'; |
||||
import { lastValueFrom } from 'rxjs'; |
||||
|
||||
export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> { |
||||
baseUrl: string; |
||||
|
||||
constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) { |
||||
super(instanceSettings); |
||||
this.baseUrl = instanceSettings.url!; |
||||
} |
||||
|
||||
getDefaultQuery(_: CoreApp): Partial<MyQuery> { |
||||
return DEFAULT_QUERY; |
||||
} |
||||
|
||||
filterQuery(query: MyQuery): boolean { |
||||
// if no query has been provided, prevent the query from being executed
|
||||
return !!query.queryText; |
||||
} |
||||
|
||||
async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> { |
||||
const { range } = options; |
||||
const from = range!.from.valueOf(); |
||||
const to = range!.to.valueOf(); |
||||
|
||||
// Return a constant for each query.
|
||||
const data = options.targets.map((target) => { |
||||
return createDataFrame({ |
||||
refId: target.refId, |
||||
fields: [ |
||||
{ name: 'Time', values: [from, to], type: FieldType.time }, |
||||
{ name: 'Value', values: [target.constant, target.constant], type: FieldType.number }, |
||||
], |
||||
}); |
||||
}); |
||||
|
||||
return { data }; |
||||
} |
||||
|
||||
async request(url: string, params?: string) { |
||||
const response = getBackendSrv().fetch<DataSourceResponse>({ |
||||
url: `${this.baseUrl}${url}${params?.length ? `?${params}` : ''}`, |
||||
}); |
||||
return lastValueFrom(response); |
||||
} |
||||
|
||||
/** |
||||
* Checks whether we can connect to the API. |
||||
*/ |
||||
async testDatasource() { |
||||
const defaultErrorMessage = 'Cannot connect to API'; |
||||
|
||||
try { |
||||
const response = await this.request('/health'); |
||||
if (response.status === 200) { |
||||
return { |
||||
status: 'success', |
||||
message: 'Success', |
||||
}; |
||||
} else { |
||||
return { |
||||
status: 'error', |
||||
message: response.statusText ? response.statusText : defaultErrorMessage, |
||||
}; |
||||
} |
||||
} catch (err) { |
||||
let message = ''; |
||||
if (typeof err === 'string') { |
||||
message = err; |
||||
} else if (isFetchError(err)) { |
||||
message = 'Fetch error: ' + (err.statusText ? err.statusText : defaultErrorMessage); |
||||
if (err.data && err.data.error && err.data.error.code) { |
||||
message += ': ' + err.data.error.code + '. ' + err.data.error.message; |
||||
} |
||||
} |
||||
return { |
||||
status: 'error', |
||||
message, |
||||
}; |
||||
} |
||||
} |
||||
} |
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,9 @@ |
||||
import { DataSourcePlugin } from '@grafana/data'; |
||||
import { DataSource } from './datasource'; |
||||
import { ConfigEditor } from './components/ConfigEditor'; |
||||
import { QueryEditor } from './components/QueryEditor'; |
||||
import { MyQuery, MyDataSourceOptions } from './types'; |
||||
|
||||
export const plugin = new DataSourcePlugin<DataSource, MyQuery, MyDataSourceOptions>(DataSource) |
||||
.setConfigEditor(ConfigEditor) |
||||
.setQueryEditor(QueryEditor); |
||||
@ -0,0 +1,48 @@ |
||||
{ |
||||
"name": "@test-plugins/grafana-e2etest-datasource", |
||||
"version": "11.4.0-pre", |
||||
"private": true, |
||||
"scripts": { |
||||
"build": "webpack -c ./webpack.config.ts --env production", |
||||
"dev": "webpack -w -c ./webpack.config.ts --env development", |
||||
"typecheck": "tsc --noEmit", |
||||
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx ." |
||||
}, |
||||
"author": "Grafana", |
||||
"license": "Apache-2.0", |
||||
"devDependencies": { |
||||
"@grafana/eslint-config": "7.0.0", |
||||
"@grafana/plugin-configs": "11.4.0-pre", |
||||
"@types/lodash": "4.17.7", |
||||
"@types/node": "20.14.14", |
||||
"@types/prismjs": "1.26.4", |
||||
"@types/react": "18.3.3", |
||||
"@types/react-dom": "18.2.25", |
||||
"@types/semver": "7.5.8", |
||||
"@types/uuid": "9.0.8", |
||||
"glob": "10.4.1", |
||||
"ts-node": "10.9.2", |
||||
"typescript": "5.5.4", |
||||
"webpack": "5.95.0", |
||||
"webpack-merge": "5.10.0" |
||||
}, |
||||
"engines": { |
||||
"node": ">=20" |
||||
}, |
||||
"dependencies": { |
||||
"@emotion/css": "11.11.2", |
||||
"@grafana/data": "workspace:*", |
||||
"@grafana/runtime": "workspace:*", |
||||
"@grafana/schema": "workspace:*", |
||||
"@grafana/ui": "workspace:*", |
||||
"react": "18.2.0", |
||||
"react-dom": "18.2.0", |
||||
"react-router-dom": "^6.22.0", |
||||
"rxjs": "7.8.1", |
||||
"tslib": "2.6.3" |
||||
}, |
||||
"peerDependencies": { |
||||
"@grafana/runtime": "*" |
||||
}, |
||||
"packageManager": "yarn@4.4.0" |
||||
} |
||||
@ -0,0 +1,26 @@ |
||||
{ |
||||
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", |
||||
"type": "datasource", |
||||
"name": "Test", |
||||
"id": "grafana-e2etest-datasource", |
||||
"metrics": true, |
||||
"info": { |
||||
"description": "", |
||||
"author": { |
||||
"name": "Grafana" |
||||
}, |
||||
"keywords": ["datasource"], |
||||
"logos": { |
||||
"small": "img/logo.svg", |
||||
"large": "img/logo.svg" |
||||
}, |
||||
"links": [], |
||||
"screenshots": [], |
||||
"version": "%VERSION%", |
||||
"updated": "%TODAY%" |
||||
}, |
||||
"dependencies": { |
||||
"grafanaDependency": ">=10.4.0", |
||||
"plugins": [] |
||||
} |
||||
} |
||||
@ -0,0 +1,39 @@ |
||||
import { test, expect, DataSourceConfigPage } from '@grafana/plugin-e2e'; |
||||
|
||||
// The following tests verify that label and input field association is working correctly.
|
||||
// If these tests break, e2e tests in external plugins will break too.
|
||||
|
||||
test.describe('config editor ', () => { |
||||
let configPage: DataSourceConfigPage; |
||||
test.beforeEach(async ({ createDataSourceConfigPage }) => { |
||||
configPage = await createDataSourceConfigPage({ type: 'grafana-e2etest-datasource' }); |
||||
}); |
||||
|
||||
test('text input field', async ({ page }) => { |
||||
const field = page.getByRole('textbox', { name: 'API key' }); |
||||
await expect(field).toBeEmpty(); |
||||
await field.fill('test text'); |
||||
await expect(field).toHaveValue('test text'); |
||||
}); |
||||
|
||||
test('switch field', async ({ page }) => { |
||||
const field = page.getByLabel('Switch Enabled'); |
||||
await expect(field).not.toBeChecked(); |
||||
await field.check(); |
||||
await expect(field).toBeChecked(); |
||||
}); |
||||
|
||||
test('checkbox field', async ({ page }) => { |
||||
const field = page.getByRole('checkbox', { name: 'Checkbox Enabled' }); |
||||
await expect(field).not.toBeChecked(); |
||||
await field.check({ force: true }); |
||||
await expect(field).toBeChecked(); |
||||
}); |
||||
|
||||
test('select field', async ({ page, selectors }) => { |
||||
const field = page.getByRole('combobox', { name: 'Auth type' }); |
||||
await field.click(); |
||||
const option = selectors.components.Select.option; |
||||
await expect(configPage.getByGrafanaSelector(option)).toHaveText(['keys', 'credentials']); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,8 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
"jsx": "react-jsx", |
||||
"types": ["node", "jest", "@testing-library/jest-dom"] |
||||
}, |
||||
"extends": "@grafana/plugin-configs/tsconfig.json", |
||||
"include": ["."] |
||||
} |
||||
@ -0,0 +1,37 @@ |
||||
import { DataSourceJsonData } from '@grafana/data'; |
||||
import { DataQuery } from '@grafana/schema'; |
||||
|
||||
export interface MyQuery extends DataQuery { |
||||
queryText?: string; |
||||
constant: number; |
||||
} |
||||
|
||||
export const DEFAULT_QUERY: Partial<MyQuery> = { |
||||
constant: 6.5, |
||||
}; |
||||
|
||||
export interface DataPoint { |
||||
Time: number; |
||||
Value: number; |
||||
} |
||||
|
||||
export interface DataSourceResponse { |
||||
datapoints: DataPoint[]; |
||||
} |
||||
|
||||
/** |
||||
* These are options configured for each DataSource instance |
||||
*/ |
||||
export interface MyDataSourceOptions extends DataSourceJsonData { |
||||
switchEnabled: boolean; |
||||
checkboxEnabled: boolean; |
||||
authType: string; |
||||
path?: string; |
||||
} |
||||
|
||||
/** |
||||
* Value that is used in the backend, but never sent over HTTP to the frontend |
||||
*/ |
||||
export interface MySecureJsonData { |
||||
apiKey?: string; |
||||
} |
||||
@ -0,0 +1,44 @@ |
||||
import CopyWebpackPlugin from 'copy-webpack-plugin'; |
||||
import grafanaConfig from '@grafana/plugin-configs/webpack.config'; |
||||
import { mergeWithCustomize, unique } from 'webpack-merge'; |
||||
import { Configuration } from 'webpack'; |
||||
|
||||
function skipFiles(f: string): boolean { |
||||
if (f.includes('/dist/')) { |
||||
// avoid copying files already in dist
|
||||
return false; |
||||
} |
||||
if (f.includes('/node_modules/')) { |
||||
// avoid copying tsconfig.json
|
||||
return false; |
||||
} |
||||
if (f.includes('/package.json')) { |
||||
// avoid copying package.json
|
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
const config = async (env: Record<string, unknown>): Promise<Configuration> => { |
||||
const baseConfig = await grafanaConfig(env); |
||||
const customConfig = { |
||||
plugins: [ |
||||
new CopyWebpackPlugin({ |
||||
patterns: [ |
||||
// To `compiler.options.output`
|
||||
{ from: 'README.md', to: '.', force: true }, |
||||
{ from: 'plugin.json', to: '.' }, |
||||
{ from: 'CHANGELOG.md', to: '.', force: true }, |
||||
{ from: '**/*.json', to: '.', filter: skipFiles }, |
||||
{ from: '**/*.svg', to: '.', noErrorOnMissing: true, filter: skipFiles }, // Optional
|
||||
], |
||||
}), |
||||
], |
||||
}; |
||||
|
||||
return mergeWithCustomize({ |
||||
customizeArray: unique('plugins', ['CopyPlugin'], (plugin) => plugin.constructor && plugin.constructor.name), |
||||
})(baseConfig, customConfig); |
||||
}; |
||||
|
||||
export default config; |
||||
Loading…
Reference in new issue