Feature: Enable React based options editors for Datasource plugins (#16748)

This commit is contained in:
Dominik Prokop 2019-04-24 23:18:51 +02:00 committed by GitHub
parent 2d6b33ab61
commit 7aeae84c52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 106 additions and 24 deletions

View File

@ -4,15 +4,24 @@ import { PluginMeta } from './plugin';
import { TableData, TimeSeries, SeriesData } from './data'; import { TableData, TimeSeries, SeriesData } from './data';
import { PanelData } from './panel'; import { PanelData } from './panel';
export class DataSourcePlugin<TQuery extends DataQuery = DataQuery> { export interface DataSourcePluginOptionsEditorProps<TOptions> {
options: TOptions;
onOptionsChange: (options: TOptions) => void;
}
export class DataSourcePlugin<TOptions = {}, TQuery extends DataQuery = DataQuery> {
DataSourceClass: DataSourceConstructor<TQuery>; DataSourceClass: DataSourceConstructor<TQuery>;
components: DataSourcePluginComponents<TQuery>; components: DataSourcePluginComponents<TOptions, TQuery>;
constructor(DataSourceClass: DataSourceConstructor<TQuery>) { constructor(DataSourceClass: DataSourceConstructor<TQuery>) {
this.DataSourceClass = DataSourceClass; this.DataSourceClass = DataSourceClass;
this.components = {}; this.components = {};
} }
setConfigEditor(editor: React.ComponentType<DataSourcePluginOptionsEditorProps<TOptions>>) {
this.components.ConfigEditor = editor;
return this;
}
setConfigCtrl(ConfigCtrl: any) { setConfigCtrl(ConfigCtrl: any) {
this.components.ConfigCtrl = ConfigCtrl; this.components.ConfigCtrl = ConfigCtrl;
return this; return this;
@ -59,7 +68,7 @@ export class DataSourcePlugin<TQuery extends DataQuery = DataQuery> {
} }
} }
export interface DataSourcePluginComponents<TQuery extends DataQuery = DataQuery> { export interface DataSourcePluginComponents<TOptions = {}, TQuery extends DataQuery = DataQuery> {
QueryCtrl?: any; QueryCtrl?: any;
ConfigCtrl?: any; ConfigCtrl?: any;
AnnotationsQueryCtrl?: any; AnnotationsQueryCtrl?: any;
@ -67,9 +76,10 @@ export interface DataSourcePluginComponents<TQuery extends DataQuery = DataQuery
QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi, TQuery>>; QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi, TQuery>>;
ExploreQueryField?: ComponentClass<ExploreQueryFieldProps<DataSourceApi, TQuery>>; ExploreQueryField?: ComponentClass<ExploreQueryFieldProps<DataSourceApi, TQuery>>;
ExploreStartPage?: ComponentClass<ExploreStartPageProps>; ExploreStartPage?: ComponentClass<ExploreStartPageProps>;
ConfigEditor?: React.ComponentType<DataSourcePluginOptionsEditorProps<TOptions>>;
} }
interface DataSourceConstructor<TQuery extends DataQuery = DataQuery> { export interface DataSourceConstructor<TQuery extends DataQuery = DataQuery> {
new (instanceSettings: DataSourceInstanceSettings, ...args: any[]): DataSourceApi<TQuery>; new (instanceSettings: DataSourceInstanceSettings, ...args: any[]): DataSourceApi<TQuery>;
} }

View File

@ -2,11 +2,13 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage'; import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage';
import { NavModel } from 'app/types'; import { NavModel } from 'app/types';
import { DataSourceSettings } from '@grafana/ui'; import { DataSourceSettings, DataSourcePlugin, DataSourceConstructor } from '@grafana/ui';
import { getMockDataSource } from '../__mocks__/dataSourcesMocks'; import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
import { setDataSourceName, setIsDefault } from '../state/actions'; import { setDataSourceName, setIsDefault } from '../state/actions';
const pluginMock = new DataSourcePlugin({} as DataSourceConstructor<any>);
const setup = (propOverrides?: object) => { const setup = (propOverrides?: object) => {
const props: Props = { const props: Props = {
navModel: {} as NavModel, navModel: {} as NavModel,
@ -18,10 +20,10 @@ const setup = (propOverrides?: object) => {
setDataSourceName, setDataSourceName,
updateDataSource: jest.fn(), updateDataSource: jest.fn(),
setIsDefault, setIsDefault,
plugin: pluginMock,
...propOverrides,
}; };
Object.assign(props, propOverrides);
return shallow(<DataSourceSettingsPage {...props} />); return shallow(<DataSourceSettingsPage {...props} />);
}; };
@ -35,6 +37,7 @@ describe('Render', () => {
it('should render loader', () => { it('should render loader', () => {
const wrapper = setup({ const wrapper = setup({
dataSource: {} as DataSourceSettings, dataSource: {} as DataSourceSettings,
plugin: pluginMock,
}); });
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
@ -43,6 +46,7 @@ describe('Render', () => {
it('should render beta info text', () => { it('should render beta info text', () => {
const wrapper = setup({ const wrapper = setup({
dataSourceMeta: { ...getMockPlugin(), state: 'beta' }, dataSourceMeta: { ...getMockPlugin(), state: 'beta' },
plugin: pluginMock,
}); });
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
@ -51,6 +55,7 @@ describe('Render', () => {
it('should render alpha info text', () => { it('should render alpha info text', () => {
const wrapper = setup({ const wrapper = setup({
dataSourceMeta: { ...getMockPlugin(), state: 'alpha' }, dataSourceMeta: { ...getMockPlugin(), state: 'alpha' },
plugin: pluginMock,
}); });
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
@ -59,6 +64,7 @@ describe('Render', () => {
it('should render is ready only message', () => { it('should render is ready only message', () => {
const wrapper = setup({ const wrapper = setup({
dataSource: { ...getMockDataSource(), readOnly: true }, dataSource: { ...getMockDataSource(), readOnly: true },
plugin: pluginMock,
}); });
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();

View File

@ -22,9 +22,10 @@ import { getRouteParamsId } from 'app/core/selectors/location';
// Types // Types
import { NavModel, Plugin, StoreState } from 'app/types/'; import { NavModel, Plugin, StoreState } from 'app/types/';
import { DataSourceSettings } from '@grafana/ui/src/types/'; import { DataSourceSettings, DataSourcePlugin } from '@grafana/ui/src/types/';
import { getDataSourceLoadingNav } from '../state/navModel'; import { getDataSourceLoadingNav } from '../state/navModel';
import PluginStateinfo from 'app/features/plugins/PluginStateInfo'; import PluginStateinfo from 'app/features/plugins/PluginStateInfo';
import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
export interface Props { export interface Props {
navModel: NavModel; navModel: NavModel;
@ -36,10 +37,12 @@ export interface Props {
setDataSourceName: typeof setDataSourceName; setDataSourceName: typeof setDataSourceName;
updateDataSource: typeof updateDataSource; updateDataSource: typeof updateDataSource;
setIsDefault: typeof setIsDefault; setIsDefault: typeof setIsDefault;
plugin?: DataSourcePlugin;
} }
interface State { interface State {
dataSource: DataSourceSettings; dataSource: DataSourceSettings;
plugin: DataSourcePlugin;
isTesting?: boolean; isTesting?: boolean;
testingMessage?: string; testingMessage?: string;
testingStatus?: string; testingStatus?: string;
@ -50,14 +53,30 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
super(props); super(props);
this.state = { this.state = {
dataSource: {} as DataSourceSettings, dataSource: props.dataSource,
plugin: props.plugin,
}; };
} }
async loadPlugin(pluginId?: string) {
const { dataSourceMeta } = this.props;
let importedPlugin: DataSourcePlugin;
try {
importedPlugin = await importDataSourcePlugin(dataSourceMeta.module);
} catch (e) {
console.log('Failed to import plugin module', e);
}
this.setState({ plugin: importedPlugin });
}
async componentDidMount() { async componentDidMount() {
const { loadDataSource, pageId } = this.props; const { loadDataSource, pageId } = this.props;
await loadDataSource(pageId); await loadDataSource(pageId);
if (!this.state.plugin) {
await this.loadPlugin();
}
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
@ -71,7 +90,7 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => { onSubmit = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault(); evt.preventDefault();
await this.props.updateDataSource({ ...this.state.dataSource, name: this.props.dataSource.name }); await this.props.updateDataSource({ ...this.state.dataSource });
this.testDataSource(); this.testDataSource();
}; };
@ -156,8 +175,8 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
} }
render() { render() {
const { dataSource, dataSourceMeta, navModel, setDataSourceName, setIsDefault } = this.props; const { dataSourceMeta, navModel, setDataSourceName, setIsDefault } = this.props;
const { testingMessage, testingStatus } = this.state; const { testingMessage, testingStatus, plugin, dataSource } = this.state;
return ( return (
<Page navModel={navModel}> <Page navModel={navModel}>
@ -175,9 +194,10 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
onNameChange={name => setDataSourceName(name)} onNameChange={name => setDataSourceName(name)}
/> />
{dataSourceMeta.module && ( {dataSourceMeta.module && plugin && (
<PluginSettings <PluginSettings
dataSource={dataSource} plugin={plugin}
dataSource={this.state.dataSource}
dataSourceMeta={dataSourceMeta} dataSourceMeta={dataSourceMeta}
onModelChange={this.onModelChange} onModelChange={this.onModelChange}
/> />
@ -218,7 +238,6 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
function mapStateToProps(state: StoreState) { function mapStateToProps(state: StoreState) {
const pageId = getRouteParamsId(state.location); const pageId = getRouteParamsId(state.location);
const dataSource = getDataSource(state.dataSources, pageId); const dataSource = getDataSource(state.dataSources, pageId);
return { return {
navModel: getNavModel(state.navIndex, `datasource-settings-${pageId}`, getDataSourceLoadingNav('settings')), navModel: getNavModel(state.navIndex, `datasource-settings-${pageId}`, getDataSourceLoadingNav('settings')),
dataSource: getDataSource(state.dataSources, pageId), dataSource: getDataSource(state.dataSources, pageId),

View File

@ -1,10 +1,11 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { Plugin } from 'app/types'; import { Plugin } from 'app/types';
import { DataSourceSettings } from '@grafana/ui/src/types'; import { DataSourceSettings, DataSourcePlugin } from '@grafana/ui/src/types';
import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader'; import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
export interface Props { export interface Props {
plugin: DataSourcePlugin;
dataSource: DataSourceSettings; dataSource: DataSourceSettings;
dataSourceMeta: Plugin; dataSourceMeta: Plugin;
onModelChange: (dataSource: DataSourceSettings) => void; onModelChange: (dataSource: DataSourceSettings) => void;
@ -25,21 +26,29 @@ export class PluginSettings extends PureComponent<Props> {
ctrl: { datasourceMeta: props.dataSourceMeta, current: _.cloneDeep(props.dataSource) }, ctrl: { datasourceMeta: props.dataSourceMeta, current: _.cloneDeep(props.dataSource) },
onModelChanged: this.onModelChanged, onModelChanged: this.onModelChanged,
}; };
this.onModelChanged = this.onModelChanged.bind(this);
} }
componentDidMount() { componentDidMount() {
const { plugin } = this.props;
if (!this.element) { if (!this.element) {
return; return;
} }
if (!plugin.components.ConfigEditor) {
// React editor is not specified, let's render angular editor
// How to apprach this better? Introduce ReactDataSourcePlugin interface and typeguard it here?
const loader = getAngularLoader(); const loader = getAngularLoader();
const template = '<plugin-component type="datasource-config-ctrl" />'; const template = '<plugin-component type="datasource-config-ctrl" />';
this.component = loader.load(this.element, this.scopeProps, template); this.component = loader.load(this.element, this.scopeProps, template);
} }
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.dataSource !== prevProps.dataSource) { const { plugin } = this.props;
if (!plugin.components.ConfigEditor && this.props.dataSource !== prevProps.dataSource) {
this.scopeProps.ctrl.current = _.cloneDeep(this.props.dataSource); this.scopeProps.ctrl.current = _.cloneDeep(this.props.dataSource);
this.component.digest(); this.component.digest();
@ -57,7 +66,21 @@ export class PluginSettings extends PureComponent<Props> {
}; };
render() { render() {
return <div ref={element => (this.element = element)} />; const { plugin, dataSource } = this.props;
if (!plugin) {
return null;
}
return (
<div ref={element => (this.element = element)}>
{plugin.components.ConfigEditor &&
React.createElement(plugin.components.ConfigEditor, {
options: dataSource,
onOptionsChange: this.onModelChanged,
})}
</div>
);
} }
} }

View File

@ -85,6 +85,12 @@ exports[`Render should render alpha info text 1`] = `
} }
} }
onModelChange={[Function]} onModelChange={[Function]}
plugin={
DataSourcePlugin {
"DataSourceClass": Object {},
"components": Object {},
}
}
/> />
<div <div
className="gf-form-group" className="gf-form-group"
@ -186,6 +192,12 @@ exports[`Render should render beta info text 1`] = `
} }
} }
onModelChange={[Function]} onModelChange={[Function]}
plugin={
DataSourcePlugin {
"DataSourceClass": Object {},
"components": Object {},
}
}
/> />
<div <div
className="gf-form-group" className="gf-form-group"
@ -284,6 +296,12 @@ exports[`Render should render component 1`] = `
} }
} }
onModelChange={[Function]} onModelChange={[Function]}
plugin={
DataSourcePlugin {
"DataSourceClass": Object {},
"components": Object {},
}
}
/> />
<div <div
className="gf-form-group" className="gf-form-group"
@ -387,6 +405,12 @@ exports[`Render should render is ready only message 1`] = `
} }
} }
onModelChange={[Function]} onModelChange={[Function]}
plugin={
DataSourcePlugin {
"DataSourceClass": Object {},
"components": Object {},
}
}
/> />
<div <div
className="gf-form-group" className="gf-form-group"

View File

@ -160,10 +160,10 @@ export function importPluginModule(path: string): Promise<any> {
return System.import(path); return System.import(path);
} }
export function importDataSourcePlugin(path: string): Promise<DataSourcePlugin> { export function importDataSourcePlugin(path: string): Promise<DataSourcePlugin<any>> {
return importPluginModule(path).then(pluginExports => { return importPluginModule(path).then(pluginExports => {
if (pluginExports.plugin) { if (pluginExports.plugin) {
return pluginExports.plugin as DataSourcePlugin; return pluginExports.plugin as DataSourcePlugin<any>;
} }
if (pluginExports.Datasource) { if (pluginExports.Datasource) {