From 3db98f417d465f22a9c50798b8d16dabbc413498 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 6 Oct 2021 12:41:42 -0700 Subject: [PATCH] Panel Options: support dynamic options editors (#39491) --- .../src/panel/PanelPlugin.test.tsx | 9 +- .../grafana-data/src/panel/PanelPlugin.ts | 53 +++-- .../src/types/OptionsUIRegistryBuilder.ts | 4 + .../src/utils/OptionsUIBuilders.ts | 55 ++++- public/app/features/canvas/element.ts | 5 +- public/app/features/canvas/elements/icon.tsx | 11 +- .../app/features/canvas/elements/textBox.tsx | 6 + .../OptionsPaneCategoryDescriptor.tsx | 13 ++ .../PanelEditor/getVizualizationOptions.tsx | 64 ++++-- .../dimensions/editors/ResourcePicker.tsx | 2 +- .../app/plugins/panel/canvas/CanvasPanel.tsx | 34 +++- .../panel/canvas/editor/ElementEditor.tsx | 123 ------------ .../canvas/editor/SelectedElementEditor.tsx | 27 --- .../panel/canvas/editor/elementEditor.tsx | 75 +++++++ .../plugins/panel/canvas/editor/options.ts | 135 +++++++------ public/app/plugins/panel/canvas/models.gen.ts | 2 + public/app/plugins/panel/canvas/module.tsx | 32 ++- .../panel/geomap/editor/BaseLayerEditor.tsx | 27 --- .../panel/geomap/editor/DataLayersEditor.tsx | 34 ---- .../panel/geomap/editor/LayerEditor.tsx | 188 ------------------ .../panel/geomap/editor/layerEditor.tsx | 155 +++++++++++++++ public/app/plugins/panel/geomap/module.tsx | 53 +++-- 22 files changed, 554 insertions(+), 553 deletions(-) delete mode 100644 public/app/plugins/panel/canvas/editor/ElementEditor.tsx delete mode 100644 public/app/plugins/panel/canvas/editor/SelectedElementEditor.tsx create mode 100644 public/app/plugins/panel/canvas/editor/elementEditor.tsx delete mode 100644 public/app/plugins/panel/geomap/editor/BaseLayerEditor.tsx delete mode 100644 public/app/plugins/panel/geomap/editor/DataLayersEditor.tsx delete mode 100644 public/app/plugins/panel/geomap/editor/LayerEditor.tsx create mode 100644 public/app/plugins/panel/geomap/editor/layerEditor.tsx diff --git a/packages/grafana-data/src/panel/PanelPlugin.test.tsx b/packages/grafana-data/src/panel/PanelPlugin.test.tsx index 9b705d01fae..ad403c73779 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.test.tsx +++ b/packages/grafana-data/src/panel/PanelPlugin.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { identityOverrideProcessor, standardEditorsRegistry, standardFieldConfigEditorRegistry } from '../field'; import { PanelPlugin } from './PanelPlugin'; import { FieldConfigProperty } from '../types'; +import { PanelOptionsEditorBuilder } from '..'; describe('PanelPlugin', () => { describe('declarative options', () => { @@ -70,8 +71,12 @@ describe('PanelPlugin', () => { }); }); - expect(panel.optionEditors).toBeDefined(); - expect(panel.optionEditors!.list()).toHaveLength(1); + const supplier = panel.getPanelOptionsSupplier(); + expect(supplier).toBeDefined(); + + const builder = new PanelOptionsEditorBuilder(); + supplier(builder, { data: [] }); + expect(builder.getItems()).toHaveLength(1); }); }); diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index c04898baf6a..8ad6ea3ce18 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -3,7 +3,6 @@ import { GrafanaPlugin, PanelEditorProps, PanelMigrationHandler, - PanelOptionEditorsRegistry, PanelPluginMeta, PanelProps, PanelTypeChangedHandler, @@ -14,7 +13,7 @@ import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/Op import { ComponentClass, ComponentType } from 'react'; import { set } from 'lodash'; import { deprecationWarning } from '../utils'; -import { FieldConfigOptionsRegistry } from '../field'; +import { FieldConfigOptionsRegistry, StandardEditorContext } from '../field'; import { createFieldConfigRegistry } from './registryFactories'; /** @beta */ @@ -84,6 +83,11 @@ export interface SetFieldConfigOptionsArgs { useCustomConfig?: (builder: FieldConfigEditorBuilder) => void; } +export type PanelOptionsSupplier = ( + builder: PanelOptionsEditorBuilder, + context: StandardEditorContext +) => void; + export class PanelPlugin< TOptions = any, TFieldConfigOptions extends object = any @@ -99,8 +103,7 @@ export class PanelPlugin< return new FieldConfigOptionsRegistry(); }; - private _optionEditors?: PanelOptionEditorsRegistry; - private registerOptionEditors?: (builder: PanelOptionsEditorBuilder) => void; + private optionsSupplier?: PanelOptionsSupplier; panel: ComponentType> | null; editor?: ComponentClass>; @@ -125,15 +128,13 @@ export class PanelPlugin< get defaults() { let result = this._defaults || {}; - if (!this._defaults) { - const editors = this.optionEditors; - - if (!editors || editors.list().length === 0) { - return null; - } - - for (const editor of editors.list()) { - set(result, editor.id, editor.defaultValue); + if (!this._defaults && this.optionsSupplier) { + const builder = new PanelOptionsEditorBuilder(); + this.optionsSupplier(builder, { data: [] }); + for (const item of builder.getItems()) { + if (item.defaultValue != null) { + set(result, item.path, item.defaultValue); + } } } @@ -177,19 +178,6 @@ export class PanelPlugin< return this._fieldConfigRegistry; } - get optionEditors(): PanelOptionEditorsRegistry { - if (!this._optionEditors) { - const builder = new PanelOptionsEditorBuilder(); - this._optionEditors = builder.getRegistry(); - - if (this.registerOptionEditors) { - this.registerOptionEditors(builder); - } - } - - return this._optionEditors; - } - /** * @deprecated setEditor is deprecated in favor of setPanelOptions */ @@ -258,12 +246,21 @@ export class PanelPlugin< * * @public **/ - setPanelOptions(builder: (builder: PanelOptionsEditorBuilder) => void) { + setPanelOptions(builder: PanelOptionsSupplier) { // builder is applied lazily when options UI is created - this.registerOptionEditors = builder; + this.optionsSupplier = builder; return this; } + /** + * This is used while building the panel options editor. + * + * @internal + */ + getPanelOptionsSupplier(): PanelOptionsSupplier { + return this.optionsSupplier ?? ((() => {}) as PanelOptionsSupplier); + } + /** * Tells Grafana if the plugin should subscribe to annotation and alertState results. * diff --git a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts index 6d2db5b1950..7881cf4df69 100644 --- a/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts +++ b/packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts @@ -92,4 +92,8 @@ export abstract class OptionsUIRegistryBuilder< return this.properties; }); } + + getItems() { + return this.properties; + } } diff --git a/packages/grafana-data/src/utils/OptionsUIBuilders.ts b/packages/grafana-data/src/utils/OptionsUIBuilders.ts index f9082e05033..1317997db3c 100644 --- a/packages/grafana-data/src/utils/OptionsUIBuilders.ts +++ b/packages/grafana-data/src/utils/OptionsUIBuilders.ts @@ -1,5 +1,5 @@ import { FieldConfigEditorProps, FieldConfigPropertyItem, FieldConfigEditorConfig } from '../types/fieldOverrides'; -import { OptionsUIRegistryBuilder } from '../types/OptionsUIRegistryBuilder'; +import { OptionsEditorItem, OptionsUIRegistryBuilder } from '../types/OptionsUIRegistryBuilder'; import { PanelOptionsEditorConfig, PanelOptionsEditorItem } from '../types/panel'; import { numberOverrideProcessor, @@ -17,6 +17,7 @@ import { unitOverrideProcessor, FieldNamePickerConfigSettings, } from '../field'; +import { PanelOptionsSupplier } from '../panel/PanelPlugin'; /** * Fluent API for declarative creation of field config option editors @@ -129,6 +130,53 @@ export class FieldConfigEditorBuilder extends OptionsUIRegistryBuilder } } +export interface NestedValueAccess { + getValue: (path: string) => any; + onChange: (path: string, value: any) => void; +} +export interface NestedPanelOptions { + path: string; + category?: string[]; + defaultValue?: TSub; + build: PanelOptionsSupplier; + values?: (parent: NestedValueAccess) => NestedValueAccess; +} + +export class NestedPanelOptionsBuilder implements OptionsEditorItem { + path = ''; + category?: string[]; + defaultValue?: TSub; + id = 'nested-panel-options'; + name = 'nested'; + editor = () => null; + + constructor(public cfg: NestedPanelOptions) { + this.path = cfg.path; + this.category = cfg.category; + this.defaultValue = cfg.defaultValue; + } + + getBuilder = () => { + return this.cfg.build; + }; + + getNestedValueAccess = (parent: NestedValueAccess) => { + const values = this.cfg.values; + if (values) { + return values(parent); + } + // by default prefix the path + return { + getValue: (path: string) => parent.getValue(`${this.path}.${path}`), + onChange: (path: string, value: any) => parent.onChange(`${this.path}.${path}`, value), + }; + }; +} + +export function isNestedPanelOptions(item: any): item is NestedPanelOptionsBuilder { + return item.id === 'nested-panel-options'; +} + /** * Fluent API for declarative creation of panel options */ @@ -137,6 +185,11 @@ export class PanelOptionsEditorBuilder extends OptionsUIRegistryBuilde StandardEditorProps, PanelOptionsEditorItem > { + addNestedOptions(opts: NestedPanelOptions) { + const s = new NestedPanelOptionsBuilder(opts); + return this.addCustomEditor(s); + } + addNumberInput(config: PanelOptionsEditorConfig) { return this.addCustomEditor({ ...config, diff --git a/public/app/features/canvas/element.ts b/public/app/features/canvas/element.ts index c2683858800..fa2bc8c65de 100644 --- a/public/app/features/canvas/element.ts +++ b/public/app/features/canvas/element.ts @@ -1,7 +1,8 @@ import { ComponentType } from 'react'; -import { PanelOptionsEditorBuilder, RegistryItem } from '@grafana/data'; +import { RegistryItem } from '@grafana/data'; import { Anchor, BackgroundConfig, LineConfig, Placement } from './types'; import { DimensionContext } from '../dimensions/context'; +import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin'; /** * This gets saved in panel json @@ -52,5 +53,5 @@ export interface CanvasElementItem extends RegistryI display: ComponentType>; /** Build the configuraiton UI */ - registerOptionsUI?: (builder: PanelOptionsEditorBuilder>) => void; + registerOptionsUI?: PanelOptionsSupplier>; } diff --git a/public/app/features/canvas/elements/icon.tsx b/public/app/features/canvas/elements/icon.tsx index a885ea950e7..b4dedd128ac 100644 --- a/public/app/features/canvas/elements/icon.tsx +++ b/public/app/features/canvas/elements/icon.tsx @@ -103,8 +103,10 @@ export const iconItem: CanvasElementItem = { // Heatmap overlay options registerOptionsUI: (builder) => { + const category = ['Icon']; builder .addCustomEditor({ + category, id: 'iconSelector', path: 'config.path', name: 'SVG Path', @@ -114,9 +116,10 @@ export const iconItem: CanvasElementItem = { }, }) .addCustomEditor({ + category, id: 'config.fill', path: 'config.fill', - name: 'Icon fill color', + name: 'Fill color', editor: ColorDimensionEditor, settings: {}, defaultValue: { @@ -125,6 +128,7 @@ export const iconItem: CanvasElementItem = { }, }) .addSliderInput({ + category, path: 'config.stroke.width', name: 'Stroke', defaultValue: 0, @@ -134,16 +138,17 @@ export const iconItem: CanvasElementItem = { }, }) .addCustomEditor({ + category, id: 'config.stroke.color', path: 'config.stroke.color', - name: 'Icon Stroke color', + name: 'Stroke color', editor: ColorDimensionEditor, settings: {}, defaultValue: { // Configured values fixed: 'grey', }, - showIf: (cfg) => Boolean(cfg.config?.stroke?.width), + showIf: (cfg) => Boolean(cfg?.config?.stroke?.width), }); }, }; diff --git a/public/app/features/canvas/elements/textBox.tsx b/public/app/features/canvas/elements/textBox.tsx index 6436ff7d5e1..f3cfa28da50 100644 --- a/public/app/features/canvas/elements/textBox.tsx +++ b/public/app/features/canvas/elements/textBox.tsx @@ -97,14 +97,17 @@ export const textBoxItem: CanvasElementItem = { // Heatmap overlay options registerOptionsUI: (builder) => { + const category = ['Text box']; builder .addCustomEditor({ + category, id: 'textSelector', path: 'config.text', name: 'Text', editor: TextDimensionEditor, }) .addCustomEditor({ + category, id: 'config.color', path: 'config.color', name: 'Text color', @@ -113,6 +116,7 @@ export const textBoxItem: CanvasElementItem = { defaultValue: {}, }) .addRadio({ + category, path: 'config.align', name: 'Align text', settings: { @@ -125,6 +129,7 @@ export const textBoxItem: CanvasElementItem = { defaultValue: Align.Left, }) .addRadio({ + category, path: 'config.valign', name: 'Vertical align', settings: { @@ -137,6 +142,7 @@ export const textBoxItem: CanvasElementItem = { defaultValue: VAlign.Middle, }) .addNumberInput({ + category, path: 'config.size', name: 'Text size', settings: { diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx index a71447ec788..113dbc0dd49 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx @@ -37,6 +37,19 @@ export class OptionsPaneCategoryDescriptor { return this; } + getCategory(name: string): OptionsPaneCategoryDescriptor { + let sub = this.categories.find((c) => c.props.id === name); + if (sub) { + return sub; + } + sub = new OptionsPaneCategoryDescriptor({ + title: name, + id: name, + }); + this.addCategory(sub); + return sub; + } + render(searchQuery?: string) { if (this.props.customRender) { return this.props.customRender(); diff --git a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx index fa5dc566546..7d18b4dc39b 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVizualizationOptions.tsx @@ -1,11 +1,17 @@ import React from 'react'; -import { PanelOptionsEditorItem, StandardEditorContext, VariableSuggestionsScope } from '@grafana/data'; +import { StandardEditorContext, VariableSuggestionsScope } from '@grafana/data'; import { get as lodashGet } from 'lodash'; import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; import { OptionPaneRenderProps } from './types'; import { updateDefaultFieldConfigValue, setOptionImmutably } from './utils'; import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor'; import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor'; +import { + isNestedPanelOptions, + NestedValueAccess, + PanelOptionsEditorBuilder, +} from '../../../../../../packages/grafana-data/src/utils/OptionsUIBuilders'; +import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin'; type categoryGetter = (categoryNames?: string[]) => OptionsPaneCategoryDescriptor; @@ -40,16 +46,16 @@ export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPa })); }; - // Load the options into categories - fillOptionsPaneItems( - plugin.optionEditors.list(), - getOptionsPaneCategory, - (path: string, value: any) => { - const newOptions = setOptionImmutably(context.options, path, value); + const access: NestedValueAccess = { + getValue: (path: string) => lodashGet(currentOptions, path), + onChange: (path: string, value: any) => { + const newOptions = setOptionImmutably(currentOptions, path, value); onPanelOptionsChanged(newOptions); }, - context - ); + }; + + // Load the options into categories + fillOptionsPaneItems(plugin.getPanelOptionsSupplier(), access, getOptionsPaneCategory, context); /** * Field options @@ -107,21 +113,41 @@ export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPa * @internal */ export function fillOptionsPaneItems( - optionEditors: PanelOptionsEditorItem[], + supplier: PanelOptionsSupplier, + access: NestedValueAccess, getOptionsPaneCategory: categoryGetter, - onValueChanged: (path: string, value: any) => void, - context: StandardEditorContext + context: StandardEditorContext, + parentCategory?: OptionsPaneCategoryDescriptor ) { - for (const pluginOption of optionEditors) { + const builder = new PanelOptionsEditorBuilder(); + supplier(builder, context); + + for (const pluginOption of builder.getItems()) { if (pluginOption.showIf && !pluginOption.showIf(context.options, context.data)) { continue; } - const category = getOptionsPaneCategory(pluginOption.category); + let category = parentCategory; + if (!category) { + category = getOptionsPaneCategory(pluginOption.category); + } else if (pluginOption.category?.[0]?.length) { + category = category.getCategory(pluginOption.category[0]); + } + + // Nested options get passed up one level + if (isNestedPanelOptions(pluginOption)) { + const sub = access.getValue(pluginOption.path); + fillOptionsPaneItems( + pluginOption.getBuilder(), + pluginOption.getNestedValueAccess(access), + getOptionsPaneCategory, + { ...context, options: sub }, + category // parent category + ); + continue; + } + const Editor = pluginOption.editor; - - // TODO? can some options recursivly call: fillOptionsPaneItems? - category.addItem( new OptionsPaneItemDescriptor({ title: pluginOption.name, @@ -129,9 +155,9 @@ export function fillOptionsPaneItems( render: function renderEditor() { return ( { - onValueChanged(pluginOption.path, value); + access.onChange(pluginOption.path, value); }} item={pluginOption} context={context} diff --git a/public/app/features/dimensions/editors/ResourcePicker.tsx b/public/app/features/dimensions/editors/ResourcePicker.tsx index 4992c8f9937..0f7e27363f1 100644 --- a/public/app/features/dimensions/editors/ResourcePicker.tsx +++ b/public/app/features/dimensions/editors/ResourcePicker.tsx @@ -121,7 +121,7 @@ export function ResourcePicker(props: Props) { {tabs[0].active && (
- {filteredIndex ? (
diff --git a/public/app/plugins/panel/canvas/CanvasPanel.tsx b/public/app/plugins/panel/canvas/CanvasPanel.tsx index 8bf4f0ab942..ef6328242f8 100644 --- a/public/app/plugins/panel/canvas/CanvasPanel.tsx +++ b/public/app/plugins/panel/canvas/CanvasPanel.tsx @@ -1,10 +1,12 @@ import { Component } from 'react'; -import { PanelProps } from '@grafana/data'; +import { CoreApp, PanelProps } from '@grafana/data'; import { PanelOptions } from './models.gen'; -import { ReplaySubject, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { PanelEditExitedEvent } from 'app/types/events'; import { CanvasGroupOptions } from 'app/features/canvas'; import { Scene } from 'app/features/canvas/runtime/scene'; +import { PanelContext, PanelContextRoot } from '@grafana/ui'; +import { ElementState } from 'app/features/canvas/runtime/element'; interface Props extends PanelProps {} @@ -12,10 +14,15 @@ interface State { refresh: number; } -// Used to pass the scene to the editor functions -export const theScene = new ReplaySubject(1); +export interface InstanceState { + scene: Scene; + selected?: ElementState; +} export class CanvasPanel extends Component { + static contextType = PanelContextRoot; + panelContext: PanelContext = {} as PanelContext; + readonly scene: Scene; private subs = new Subscription(); needsReload = false; @@ -31,7 +38,6 @@ export class CanvasPanel extends Component { this.scene = new Scene(this.props.options.root, this.onUpdateScene); this.scene.updateSize(props.width, props.height); this.scene.updateData(props.data); - theScene.next(this.scene); // used in the editors this.subs.add( this.props.eventBus.subscribe(PanelEditExitedEvent, (evt) => { @@ -43,7 +49,23 @@ export class CanvasPanel extends Component { } componentDidMount() { - theScene.next(this.scene); + this.panelContext = this.context as PanelContext; + if (this.panelContext.onInstanceStateChange && this.panelContext.app === CoreApp.PanelEditor) { + this.panelContext.onInstanceStateChange({ + scene: this.scene, + }); + + this.subs.add( + this.scene.selected.subscribe({ + next: (v) => { + this.panelContext.onInstanceStateChange!({ + scene: this.scene, + selected: v, + }); + }, + }) + ); + } } componentWillUnmount() { diff --git a/public/app/plugins/panel/canvas/editor/ElementEditor.tsx b/public/app/plugins/panel/canvas/editor/ElementEditor.tsx deleted file mode 100644 index 0a036b5fb49..00000000000 --- a/public/app/plugins/panel/canvas/editor/ElementEditor.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { FC, useMemo } from 'react'; -import { Select } from '@grafana/ui'; -import { DataFrame, PanelOptionsEditorBuilder, StandardEditorContext } from '@grafana/data'; -import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils'; -import { fillOptionsPaneItems } from 'app/features/dashboard/components/PanelEditor/getVizualizationOptions'; -import { cloneDeep } from 'lodash'; -import { addBackgroundOptions, addBorderOptions } from './options'; -import { - CanvasElementItem, - CanvasElementOptions, - canvasElementRegistry, - DEFAULT_CANVAS_ELEMENT_CONFIG, -} from 'app/features/canvas'; - -export interface CanvasElementEditorProps { - options?: CanvasElementOptions; - data: DataFrame[]; // All results - onChange: (options: CanvasElementOptions) => void; - filter?: (item: CanvasElementItem) => boolean; -} - -export const CanvasElementEditor: FC = ({ options, onChange, data, filter }) => { - // all basemaps - const layerTypes = useMemo(() => { - return canvasElementRegistry.selectOptions( - options?.type // the selected value - ? [options.type] // as an array - : [DEFAULT_CANVAS_ELEMENT_CONFIG.type], - filter - ); - }, [options?.type, filter]); - - // The options change with each layer type - const optionsEditorBuilder = useMemo(() => { - const layer = canvasElementRegistry.getIfExists(options?.type); - if (!layer || !layer.registerOptionsUI) { - return null; - } - - const builder = new PanelOptionsEditorBuilder(); - if (layer.registerOptionsUI) { - layer.registerOptionsUI(builder); - } - - addBackgroundOptions(builder); - addBorderOptions(builder); - return builder; - }, [options?.type]); - - // The react componnets - const layerOptions = useMemo(() => { - const layer = canvasElementRegistry.getIfExists(options?.type); - if (!optionsEditorBuilder || !layer) { - return null; - } - - const category = new OptionsPaneCategoryDescriptor({ - id: 'CanvasElement config', - title: 'CanvasElement config', - }); - - const context: StandardEditorContext = { - data, - options: options, - }; - - const currentOptions = { ...options, type: layer.id, config: { ...layer.defaultConfig, ...options?.config } }; - - // Update the panel options if not set - if (!options || (layer.defaultConfig && !options.config)) { - onChange(currentOptions as any); - } - - const reg = optionsEditorBuilder.getRegistry(); - - // Load the options into categories - fillOptionsPaneItems( - reg.list(), - - // Always use the same category - (categoryNames) => category, - - // Custom upate function - (path: string, value: any) => { - onChange(setOptionImmutably(currentOptions, path, value) as any); - }, - context - ); - - return ( - <> -
- {category.items.map((item) => item.render())} - - ); - }, [optionsEditorBuilder, onChange, data, options]); - - return ( -
- { - const layer = geomapLayerRegistry.getIfExists(v.value); - if (!layer) { - console.warn('layer does not exist', v); - return; - } - - onChange({ - ...options, // keep current options - type: layer.id, - config: { ...layer.defaultOptions }, // clone? - }); - }} - /> - - {layerOptions} -
- ); -}; diff --git a/public/app/plugins/panel/geomap/editor/layerEditor.tsx b/public/app/plugins/panel/geomap/editor/layerEditor.tsx new file mode 100644 index 00000000000..8a45f521b05 --- /dev/null +++ b/public/app/plugins/panel/geomap/editor/layerEditor.tsx @@ -0,0 +1,155 @@ +import { + MapLayerOptions, + FrameGeometrySourceMode, + FieldType, + Field, + MapLayerRegistryItem, + PluginState, +} from '@grafana/data'; +import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from '../layers/registry'; +import { GazetteerPathEditor } from './GazetteerPathEditor'; +import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders'; +import { defaultMarkersConfig } from '../layers/data/markersLayer'; +import { hasAlphaPanels } from 'app/core/config'; + +export interface LayerEditorOptions { + category: string[]; + path: string; + basemaps: boolean; // only basemaps + current?: MapLayerOptions; +} + +export function getLayerEditor(opts: LayerEditorOptions): NestedPanelOptions { + return { + category: opts.category, + path: opts.path, + defaultValue: opts.basemaps ? DEFAULT_BASEMAP_CONFIG : defaultMarkersConfig, + values: (parent: NestedValueAccess) => ({ + getValue: (path: string) => parent.getValue(`${opts.path}.${path}`), + onChange: (path: string, value: any) => { + if (path === 'type' && value) { + const layer = geomapLayerRegistry.getIfExists(value); + if (layer) { + parent.onChange(opts.path, { + ...opts.current, // keep current shared options + type: layer.id, + config: { ...layer.defaultOptions }, // clone? + }); + return; // reset current values + } + } + parent.onChange(`${opts.path}.${path}`, value); + }, + }), + build: (builder, context) => { + const { options } = context; + const layer = geomapLayerRegistry.getIfExists(options?.type); + + const layerTypes = geomapLayerRegistry.selectOptions( + options?.type // the selected value + ? [options.type] // as an array + : [DEFAULT_BASEMAP_CONFIG.type], + opts.basemaps ? baseMapFilter : dataLayerFilter + ); + + builder.addSelect({ + path: 'type', + name: undefined as any, // required, but hide space + settings: { + options: layerTypes.options, + }, + }); + + if (layer) { + if (layer.showLocation) { + builder + .addRadio({ + path: 'location.mode', + name: 'Location', + description: '', + defaultValue: FrameGeometrySourceMode.Auto, + settings: { + options: [ + { value: FrameGeometrySourceMode.Auto, label: 'Auto' }, + { value: FrameGeometrySourceMode.Coords, label: 'Coords' }, + { value: FrameGeometrySourceMode.Geohash, label: 'Geohash' }, + { value: FrameGeometrySourceMode.Lookup, label: 'Lookup' }, + ], + }, + }) + .addFieldNamePicker({ + path: 'location.latitude', + name: 'Latitude field', + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Coords, + }) + .addFieldNamePicker({ + path: 'location.longitude', + name: 'Longitude field', + settings: { + filter: (f: Field) => f.type === FieldType.number, + noFieldsMessage: 'No numeric fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Coords, + }) + .addFieldNamePicker({ + path: 'location.geohash', + name: 'Geohash field', + settings: { + filter: (f: Field) => f.type === FieldType.string, + noFieldsMessage: 'No strings fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Geohash, + // eslint-disable-next-line react/display-name + // info: (props) =>
HELLO
, + }) + .addFieldNamePicker({ + path: 'location.lookup', + name: 'Lookup field', + settings: { + filter: (f: Field) => f.type === FieldType.string, + noFieldsMessage: 'No strings fields found', + }, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup, + }) + .addCustomEditor({ + id: 'gazetteer', + path: 'location.gazetteer', + name: 'Gazetteer', + editor: GazetteerPathEditor, + showIf: (opts) => opts.location?.mode === FrameGeometrySourceMode.Lookup, + }); + } + if (layer.registerOptionsUI) { + layer.registerOptionsUI(builder); + } + if (layer.showOpacity) { + // TODO -- add opacity check + } + } + }, + }; +} + +function baseMapFilter(layer: MapLayerRegistryItem): boolean { + if (!layer.isBaseMap) { + return false; + } + if (layer.state === PluginState.alpha) { + return hasAlphaPanels; + } + return true; +} + +function dataLayerFilter(layer: MapLayerRegistryItem): boolean { + if (layer.isBaseMap) { + return false; + } + if (layer.state === PluginState.alpha) { + return hasAlphaPanels; + } + return true; +} diff --git a/public/app/plugins/panel/geomap/module.tsx b/public/app/plugins/panel/geomap/module.tsx index e5eef3b01de..c7edc522803 100644 --- a/public/app/plugins/panel/geomap/module.tsx +++ b/public/app/plugins/panel/geomap/module.tsx @@ -1,18 +1,17 @@ +import React from 'react'; import { PanelPlugin } from '@grafana/data'; -import { BaseLayerEditor } from './editor/BaseLayerEditor'; -import { DataLayersEditor } from './editor/DataLayersEditor'; import { GeomapPanel } from './GeomapPanel'; import { MapViewEditor } from './editor/MapViewEditor'; import { defaultView, GeomapPanelOptions } from './types'; import { mapPanelChangedHandler } from './migrations'; -import { defaultMarkersConfig } from './layers/data/markersLayer'; -import { DEFAULT_BASEMAP_CONFIG } from './layers/registry'; +import { getLayerEditor } from './editor/layerEditor'; +import { config } from '@grafana/runtime'; export const plugin = new PanelPlugin(GeomapPanel) .setNoPadding() .setPanelChangeHandler(mapPanelChangedHandler) .useFieldConfig() - .setPanelOptions((builder) => { + .setPanelOptions((builder, context) => { let category = ['Map view']; builder.addCustomEditor({ category, @@ -32,23 +31,35 @@ export const plugin = new PanelPlugin(GeomapPanel) defaultValue: defaultView.shared, }); - builder.addCustomEditor({ - category: ['Base layer'], - id: 'basemap', - path: 'basemap', - name: 'Base layer', - editor: BaseLayerEditor, - defaultValue: DEFAULT_BASEMAP_CONFIG, - }); + // Check server settings to disable custom basemap settings + if (config.geomapDisableCustomBaseLayer) { + builder.addCustomEditor({ + category: ['Base layer'], + id: 'layers', + path: '', + name: '', + // eslint-disable-next-line react/display-name + editor: () =>
The base layer is configured by the server admin.
, + }); + } else { + builder.addNestedOptions( + getLayerEditor({ + category: ['Base layer'], + path: 'basemap', // only one for now + basemaps: true, + current: context.options?.layers?.[0], + }) + ); + } - builder.addCustomEditor({ - category: ['Data layer'], - id: 'layers', - path: 'layers', - name: 'Data layer', - editor: DataLayersEditor, - defaultValue: [defaultMarkersConfig], - }); + builder.addNestedOptions( + getLayerEditor({ + category: ['Data layer'], + path: 'layers[0]', // only one for now + basemaps: false, + current: context.options?.layers?.[0], + }) + ); // The controls section category = ['Map controls'];