mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Panel Options: support dynamic options editors (#39491)
This commit is contained in:
parent
3a8d04603f
commit
3db98f417d
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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<TFieldConfigOptions = any> {
|
||||
useCustomConfig?: (builder: FieldConfigEditorBuilder<TFieldConfigOptions>) => void;
|
||||
}
|
||||
|
||||
export type PanelOptionsSupplier<TOptions> = (
|
||||
builder: PanelOptionsEditorBuilder<TOptions>,
|
||||
context: StandardEditorContext<TOptions>
|
||||
) => 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<TOptions>) => void;
|
||||
private optionsSupplier?: PanelOptionsSupplier<TOptions>;
|
||||
|
||||
panel: ComponentType<PanelProps<TOptions>> | null;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
@ -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;
|
||||
if (!this._defaults && this.optionsSupplier) {
|
||||
const builder = new PanelOptionsEditorBuilder<TOptions>();
|
||||
this.optionsSupplier(builder, { data: [] });
|
||||
for (const item of builder.getItems()) {
|
||||
if (item.defaultValue != null) {
|
||||
set(result, item.path, item.defaultValue);
|
||||
}
|
||||
|
||||
for (const editor of editors.list()) {
|
||||
set(result, editor.id, editor.defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
@ -177,19 +178,6 @@ export class PanelPlugin<
|
||||
return this._fieldConfigRegistry;
|
||||
}
|
||||
|
||||
get optionEditors(): PanelOptionEditorsRegistry {
|
||||
if (!this._optionEditors) {
|
||||
const builder = new PanelOptionsEditorBuilder<TOptions>();
|
||||
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<TOptions>) => void) {
|
||||
setPanelOptions(builder: PanelOptionsSupplier<TOptions>) {
|
||||
// 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<TOptions> {
|
||||
return this.optionsSupplier ?? ((() => {}) as PanelOptionsSupplier<TOptions>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells Grafana if the plugin should subscribe to annotation and alertState results.
|
||||
*
|
||||
|
@ -92,4 +92,8 @@ export abstract class OptionsUIRegistryBuilder<
|
||||
return this.properties;
|
||||
});
|
||||
}
|
||||
|
||||
getItems() {
|
||||
return this.properties;
|
||||
}
|
||||
}
|
||||
|
@ -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<TOptions> extends OptionsUIRegistryBuilder
|
||||
}
|
||||
}
|
||||
|
||||
export interface NestedValueAccess {
|
||||
getValue: (path: string) => any;
|
||||
onChange: (path: string, value: any) => void;
|
||||
}
|
||||
export interface NestedPanelOptions<TSub = any> {
|
||||
path: string;
|
||||
category?: string[];
|
||||
defaultValue?: TSub;
|
||||
build: PanelOptionsSupplier<TSub>;
|
||||
values?: (parent: NestedValueAccess) => NestedValueAccess;
|
||||
}
|
||||
|
||||
export class NestedPanelOptionsBuilder<TSub = any> implements OptionsEditorItem<TSub, any, any, any> {
|
||||
path = '';
|
||||
category?: string[];
|
||||
defaultValue?: TSub;
|
||||
id = 'nested-panel-options';
|
||||
name = 'nested';
|
||||
editor = () => null;
|
||||
|
||||
constructor(public cfg: NestedPanelOptions<TSub>) {
|
||||
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<TOptions> extends OptionsUIRegistryBuilde
|
||||
StandardEditorProps,
|
||||
PanelOptionsEditorItem<TOptions>
|
||||
> {
|
||||
addNestedOptions<Sub>(opts: NestedPanelOptions<Sub>) {
|
||||
const s = new NestedPanelOptionsBuilder<Sub>(opts);
|
||||
return this.addCustomEditor(s);
|
||||
}
|
||||
|
||||
addNumberInput<TSettings>(config: PanelOptionsEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
|
@ -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<TConfig = any, TData = any> extends RegistryI
|
||||
display: ComponentType<CanvasElementProps<TConfig, TData>>;
|
||||
|
||||
/** Build the configuraiton UI */
|
||||
registerOptionsUI?: (builder: PanelOptionsEditorBuilder<CanvasElementOptions<TConfig>>) => void;
|
||||
registerOptionsUI?: PanelOptionsSupplier<CanvasElementOptions<TConfig>>;
|
||||
}
|
||||
|
@ -103,8 +103,10 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||
|
||||
// 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<IconConfig, IconData> = {
|
||||
},
|
||||
})
|
||||
.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<IconConfig, IconData> = {
|
||||
},
|
||||
})
|
||||
.addSliderInput({
|
||||
category,
|
||||
path: 'config.stroke.width',
|
||||
name: 'Stroke',
|
||||
defaultValue: 0,
|
||||
@ -134,16 +138,17 @@ export const iconItem: CanvasElementItem<IconConfig, IconData> = {
|
||||
},
|
||||
})
|
||||
.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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
@ -97,14 +97,17 @@ export const textBoxItem: CanvasElementItem<TextBoxConfig, TextBoxData> = {
|
||||
|
||||
// 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<TextBoxConfig, TextBoxData> = {
|
||||
defaultValue: {},
|
||||
})
|
||||
.addRadio({
|
||||
category,
|
||||
path: 'config.align',
|
||||
name: 'Align text',
|
||||
settings: {
|
||||
@ -125,6 +129,7 @@ export const textBoxItem: CanvasElementItem<TextBoxConfig, TextBoxData> = {
|
||||
defaultValue: Align.Left,
|
||||
})
|
||||
.addRadio({
|
||||
category,
|
||||
path: 'config.valign',
|
||||
name: 'Vertical align',
|
||||
settings: {
|
||||
@ -137,6 +142,7 @@ export const textBoxItem: CanvasElementItem<TextBoxConfig, TextBoxData> = {
|
||||
defaultValue: VAlign.Middle,
|
||||
})
|
||||
.addNumberInput({
|
||||
category,
|
||||
path: 'config.size',
|
||||
name: 'Text size',
|
||||
settings: {
|
||||
|
@ -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();
|
||||
|
@ -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<any>,
|
||||
access: NestedValueAccess,
|
||||
getOptionsPaneCategory: categoryGetter,
|
||||
onValueChanged: (path: string, value: any) => void,
|
||||
context: StandardEditorContext<any, any>
|
||||
context: StandardEditorContext<any, any>,
|
||||
parentCategory?: OptionsPaneCategoryDescriptor
|
||||
) {
|
||||
for (const pluginOption of optionEditors) {
|
||||
const builder = new PanelOptionsEditorBuilder<any>();
|
||||
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 (
|
||||
<Editor
|
||||
value={lodashGet(context.options, pluginOption.path)}
|
||||
value={access.getValue(pluginOption.path)}
|
||||
onChange={(value: any) => {
|
||||
onValueChanged(pluginOption.path, value);
|
||||
access.onChange(pluginOption.path, value);
|
||||
}}
|
||||
item={pluginOption}
|
||||
context={context}
|
||||
|
@ -121,7 +121,7 @@ export function ResourcePicker(props: Props) {
|
||||
<TabContent>
|
||||
{tabs[0].active && (
|
||||
<div className={styles.tabContent}>
|
||||
<Select options={folders} onChange={setCurrentFolder} value={currentFolder} />
|
||||
<Select menuShouldPortal={true} options={folders} onChange={setCurrentFolder} value={currentFolder} />
|
||||
<Input placeholder="Search" onChange={onChangeSearch} />
|
||||
{filteredIndex ? (
|
||||
<div className={styles.cardsWrapper}>
|
||||
|
@ -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<PanelOptions> {}
|
||||
|
||||
@ -12,10 +14,15 @@ interface State {
|
||||
refresh: number;
|
||||
}
|
||||
|
||||
// Used to pass the scene to the editor functions
|
||||
export const theScene = new ReplaySubject<Scene>(1);
|
||||
export interface InstanceState {
|
||||
scene: Scene;
|
||||
selected?: ElementState;
|
||||
}
|
||||
|
||||
export class CanvasPanel extends Component<Props, State> {
|
||||
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<Props, State> {
|
||||
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<Props, State> {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
@ -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<TConfig = any> {
|
||||
options?: CanvasElementOptions<TConfig>;
|
||||
data: DataFrame[]; // All results
|
||||
onChange: (options: CanvasElementOptions<TConfig>) => void;
|
||||
filter?: (item: CanvasElementItem) => boolean;
|
||||
}
|
||||
|
||||
export const CanvasElementEditor: FC<CanvasElementEditorProps> = ({ 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<CanvasElementOptions>();
|
||||
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<any> = {
|
||||
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 (
|
||||
<>
|
||||
<br />
|
||||
{category.items.map((item) => item.render())}
|
||||
</>
|
||||
);
|
||||
}, [optionsEditorBuilder, onChange, data, options]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
options={layerTypes.options}
|
||||
value={layerTypes.current}
|
||||
onChange={(v) => {
|
||||
const layer = canvasElementRegistry.getIfExists(v.value);
|
||||
if (!layer) {
|
||||
console.warn('layer does not exist', v);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...options, // keep current options
|
||||
type: layer.id,
|
||||
config: cloneDeep(layer.defaultConfig ?? {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{layerOptions}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,27 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { StandardEditorProps } from '@grafana/data';
|
||||
import { PanelOptions } from '../models.gen';
|
||||
import { CanvasElementEditor } from './ElementEditor';
|
||||
import { theScene } from '../CanvasPanel';
|
||||
import { useObservable } from 'react-use';
|
||||
import { of } from 'rxjs';
|
||||
import { CanvasGroupOptions } from 'app/features/canvas';
|
||||
|
||||
export const SelectedElementEditor: FC<StandardEditorProps<CanvasGroupOptions, any, PanelOptions>> = ({ context }) => {
|
||||
const scene = useObservable(theScene);
|
||||
const selected = useObservable(scene?.selected ?? of(undefined));
|
||||
|
||||
if (!selected) {
|
||||
return <div>No item is selected</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CanvasElementEditor
|
||||
options={selected.options}
|
||||
data={context.data}
|
||||
onChange={(cfg) => {
|
||||
scene!.onChange(selected.UID, cfg);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
75
public/app/plugins/panel/canvas/editor/elementEditor.tsx
Normal file
75
public/app/plugins/panel/canvas/editor/elementEditor.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { cloneDeep, get as lodashGet } from 'lodash';
|
||||
import { optionBuilder } from './options';
|
||||
import { CanvasElementOptions, canvasElementRegistry, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/canvas';
|
||||
import { NestedPanelOptions, NestedValueAccess } from '@grafana/data/src/utils/OptionsUIBuilders';
|
||||
import { setOptionImmutably } from 'app/features/dashboard/components/PanelEditor/utils';
|
||||
import { ElementState } from 'app/features/canvas/runtime/element';
|
||||
import { Scene } from 'app/features/canvas/runtime/scene';
|
||||
|
||||
export interface CanvasEditorOptions {
|
||||
element: ElementState;
|
||||
scene: Scene;
|
||||
category?: string[];
|
||||
}
|
||||
|
||||
export function getElementEditor(opts: CanvasEditorOptions): NestedPanelOptions<CanvasElementOptions> {
|
||||
return {
|
||||
category: opts.category,
|
||||
path: '--', // not used!
|
||||
|
||||
// Note that canvas editor writes things to the scene!
|
||||
values: (parent: NestedValueAccess) => ({
|
||||
getValue: (path: string) => {
|
||||
return lodashGet(opts.element.options, path);
|
||||
},
|
||||
onChange: (path: string, value: any) => {
|
||||
let options = opts.element.options;
|
||||
if (path === 'type' && value) {
|
||||
const layer = canvasElementRegistry.getIfExists(value);
|
||||
if (!layer) {
|
||||
console.warn('layer does not exist', value);
|
||||
return;
|
||||
}
|
||||
options = {
|
||||
...options, // keep current options
|
||||
type: layer.id,
|
||||
config: cloneDeep(layer.defaultConfig ?? {}),
|
||||
};
|
||||
} else {
|
||||
options = setOptionImmutably(options, path, value);
|
||||
}
|
||||
opts.scene.onChange(opts.element.UID, options);
|
||||
},
|
||||
}),
|
||||
|
||||
// Dynamically fill the selected element
|
||||
build: (builder, context) => {
|
||||
const { options } = opts.element;
|
||||
const layerTypes = canvasElementRegistry.selectOptions(
|
||||
options?.type // the selected value
|
||||
? [options.type] // as an array
|
||||
: [DEFAULT_CANVAS_ELEMENT_CONFIG.type]
|
||||
);
|
||||
|
||||
builder.addSelect({
|
||||
path: 'type',
|
||||
name: undefined as any, // required, but hide space
|
||||
settings: {
|
||||
options: layerTypes.options,
|
||||
},
|
||||
});
|
||||
|
||||
// force clean layer configuration
|
||||
const layer = canvasElementRegistry.getIfExists(options?.type ?? DEFAULT_CANVAS_ELEMENT_CONFIG.type)!;
|
||||
const currentOptions = { ...options, type: layer.id, config: { ...layer.defaultConfig, ...options?.config } };
|
||||
const ctx = { ...context, options: currentOptions };
|
||||
|
||||
if (layer.registerOptionsUI) {
|
||||
layer.registerOptionsUI(builder, ctx);
|
||||
}
|
||||
|
||||
optionBuilder.addBackground(builder, ctx);
|
||||
optionBuilder.addBorder(builder, ctx);
|
||||
},
|
||||
};
|
||||
}
|
@ -1,13 +1,21 @@
|
||||
import { PanelOptionsEditorBuilder } from '@grafana/data';
|
||||
import { BackgroundImageSize } from 'app/features/canvas';
|
||||
import { PanelOptionsSupplier } from '@grafana/data/src/panel/PanelPlugin';
|
||||
import { BackgroundImageSize, CanvasElementOptions } from 'app/features/canvas';
|
||||
import { ColorDimensionEditor, ResourceDimensionEditor } from 'app/features/dimensions/editors';
|
||||
|
||||
export function addBackgroundOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||
interface OptionSuppliers {
|
||||
addBackground: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
addBorder: PanelOptionsSupplier<CanvasElementOptions>;
|
||||
}
|
||||
|
||||
export const optionBuilder: OptionSuppliers = {
|
||||
addBackground: (builder, context) => {
|
||||
const category = ['Background'];
|
||||
builder
|
||||
.addCustomEditor({
|
||||
category,
|
||||
id: 'background.color',
|
||||
path: 'background.color',
|
||||
name: 'Background Color',
|
||||
name: 'Color',
|
||||
editor: ColorDimensionEditor,
|
||||
settings: {},
|
||||
defaultValue: {
|
||||
@ -16,17 +24,19 @@ export function addBackgroundOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||
},
|
||||
})
|
||||
.addCustomEditor({
|
||||
category,
|
||||
id: 'background.image',
|
||||
path: 'background.image',
|
||||
name: 'Background Image',
|
||||
name: 'Image',
|
||||
editor: ResourceDimensionEditor,
|
||||
settings: {
|
||||
resourceType: 'image',
|
||||
},
|
||||
})
|
||||
.addRadio({
|
||||
category,
|
||||
path: 'background.size',
|
||||
name: 'Backround image size',
|
||||
name: 'Image size',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: BackgroundImageSize.Original, label: 'Original' },
|
||||
@ -38,12 +48,14 @@ export function addBackgroundOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||
},
|
||||
defaultValue: BackgroundImageSize.Cover,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
export function addBorderOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||
addBorder: (builder, context) => {
|
||||
const category = ['Border'];
|
||||
builder.addSliderInput({
|
||||
category,
|
||||
path: 'border.width',
|
||||
name: 'Border Width',
|
||||
name: 'Width',
|
||||
defaultValue: 2,
|
||||
settings: {
|
||||
min: 0,
|
||||
@ -51,16 +63,19 @@ export function addBorderOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||
},
|
||||
});
|
||||
|
||||
if (context.options?.border?.width) {
|
||||
builder.addCustomEditor({
|
||||
category,
|
||||
id: 'border.color',
|
||||
path: 'border.color',
|
||||
name: 'Border Color',
|
||||
name: 'Color',
|
||||
editor: ColorDimensionEditor,
|
||||
settings: {},
|
||||
defaultValue: {
|
||||
// Configured values
|
||||
fixed: '',
|
||||
},
|
||||
showIf: (cfg) => Boolean(cfg.border?.width),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -8,10 +8,12 @@ import { CanvasGroupOptions, DEFAULT_CANVAS_ELEMENT_CONFIG } from 'app/features/
|
||||
export const modelVersion = Object.freeze([1, 0]);
|
||||
|
||||
export interface PanelOptions {
|
||||
inlineEditing: boolean;
|
||||
root: CanvasGroupOptions;
|
||||
}
|
||||
|
||||
export const defaultPanelOptions: PanelOptions = {
|
||||
inlineEditing: true,
|
||||
root: ({
|
||||
elements: [
|
||||
{
|
||||
|
@ -1,19 +1,29 @@
|
||||
import { PanelPlugin } from '@grafana/data';
|
||||
|
||||
import { CanvasPanel } from './CanvasPanel';
|
||||
import { SelectedElementEditor } from './editor/SelectedElementEditor';
|
||||
import { defaultPanelOptions, PanelOptions } from './models.gen';
|
||||
import { CanvasPanel, InstanceState } from './CanvasPanel';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { getElementEditor } from './editor/elementEditor';
|
||||
|
||||
export const plugin = new PanelPlugin<PanelOptions>(CanvasPanel)
|
||||
.setNoPadding() // extend to panel edges
|
||||
.useFieldConfig()
|
||||
.setPanelOptions((builder) => {
|
||||
builder.addCustomEditor({
|
||||
category: ['Selected Element'],
|
||||
id: 'root',
|
||||
path: 'root', // multiple elements may edit root!
|
||||
name: 'Selected Element',
|
||||
editor: SelectedElementEditor,
|
||||
defaultValue: defaultPanelOptions.root,
|
||||
.setPanelOptions((builder, context) => {
|
||||
const state: InstanceState = context.instanceState;
|
||||
|
||||
builder.addBooleanSwitch({
|
||||
path: 'inlineEditing',
|
||||
name: 'Inline editing',
|
||||
description: 'Enable editing while the panel is in dashboard mode',
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
if (state?.selected) {
|
||||
builder.addNestedOptions(
|
||||
getElementEditor({
|
||||
category: ['Selected element'],
|
||||
element: state.selected,
|
||||
scene: state.scene,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -1,27 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { StandardEditorProps, MapLayerOptions, MapLayerRegistryItem, PluginState } from '@grafana/data';
|
||||
import { GeomapPanelOptions } from '../types';
|
||||
import { LayerEditor } from './LayerEditor';
|
||||
import { config, hasAlphaPanels } from 'app/core/config';
|
||||
|
||||
function baseMapFilter(layer: MapLayerRegistryItem): boolean {
|
||||
if (!layer.isBaseMap) {
|
||||
return false;
|
||||
}
|
||||
if (layer.state === PluginState.alpha) {
|
||||
return hasAlphaPanels;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const BaseLayerEditor: FC<StandardEditorProps<MapLayerOptions, any, GeomapPanelOptions>> = ({
|
||||
value,
|
||||
onChange,
|
||||
context,
|
||||
}) => {
|
||||
if (config.geomapDisableCustomBaseLayer) {
|
||||
return <div>The base layer is configured by the server admin.</div>;
|
||||
}
|
||||
|
||||
return <LayerEditor options={value} data={context.data} onChange={onChange} filter={baseMapFilter} />;
|
||||
};
|
@ -1,34 +0,0 @@
|
||||
import React, { FC } from 'react';
|
||||
import { StandardEditorProps, MapLayerOptions, PluginState, MapLayerRegistryItem } from '@grafana/data';
|
||||
import { GeomapPanelOptions } from '../types';
|
||||
import { LayerEditor } from './LayerEditor';
|
||||
import { hasAlphaPanels } from 'app/core/config';
|
||||
|
||||
function dataLayerFilter(layer: MapLayerRegistryItem): boolean {
|
||||
if (layer.isBaseMap) {
|
||||
return false;
|
||||
}
|
||||
if (layer.state === PluginState.alpha) {
|
||||
return hasAlphaPanels;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// For now this supports a *single* data layer -- eventually we should support more than one
|
||||
export const DataLayersEditor: FC<StandardEditorProps<MapLayerOptions[], any, GeomapPanelOptions>> = ({
|
||||
value,
|
||||
onChange,
|
||||
context,
|
||||
}) => {
|
||||
return (
|
||||
<LayerEditor
|
||||
options={value?.length ? value[0] : undefined}
|
||||
data={context.data}
|
||||
onChange={(cfg) => {
|
||||
console.log('Change overlays:', cfg);
|
||||
onChange([cfg]);
|
||||
}}
|
||||
filter={dataLayerFilter}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,188 +0,0 @@
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { Select } from '@grafana/ui';
|
||||
import {
|
||||
MapLayerOptions,
|
||||
DataFrame,
|
||||
MapLayerRegistryItem,
|
||||
PanelOptionsEditorBuilder,
|
||||
StandardEditorContext,
|
||||
FrameGeometrySourceMode,
|
||||
FieldType,
|
||||
Field,
|
||||
} from '@grafana/data';
|
||||
import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from '../layers/registry';
|
||||
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 { GazetteerPathEditor } from './GazetteerPathEditor';
|
||||
|
||||
export interface LayerEditorProps<TConfig = any> {
|
||||
options?: MapLayerOptions<TConfig>;
|
||||
data: DataFrame[]; // All results
|
||||
onChange: (options: MapLayerOptions<TConfig>) => void;
|
||||
filter: (item: MapLayerRegistryItem) => boolean;
|
||||
}
|
||||
|
||||
export const LayerEditor: FC<LayerEditorProps> = ({ options, onChange, data, filter }) => {
|
||||
// all basemaps
|
||||
const layerTypes = useMemo(() => {
|
||||
return geomapLayerRegistry.selectOptions(
|
||||
options?.type // the selected value
|
||||
? [options.type] // as an array
|
||||
: [DEFAULT_BASEMAP_CONFIG.type],
|
||||
filter
|
||||
);
|
||||
}, [options?.type, filter]);
|
||||
|
||||
// The options change with each layer type
|
||||
const optionsEditorBuilder = useMemo(() => {
|
||||
const layer = geomapLayerRegistry.getIfExists(options?.type);
|
||||
if (!layer || !(layer.registerOptionsUI || layer.showLocation || layer.showOpacity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const builder = new PanelOptionsEditorBuilder<MapLayerOptions>();
|
||||
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) => <div>HELLO</div>,
|
||||
})
|
||||
.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
|
||||
}
|
||||
return builder;
|
||||
}, [options?.type]);
|
||||
|
||||
// The react componnets
|
||||
const layerOptions = useMemo(() => {
|
||||
const layer = geomapLayerRegistry.getIfExists(options?.type);
|
||||
if (!optionsEditorBuilder || !layer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const category = new OptionsPaneCategoryDescriptor({
|
||||
id: 'Layer config',
|
||||
title: 'Layer config',
|
||||
});
|
||||
|
||||
const context: StandardEditorContext<any> = {
|
||||
data,
|
||||
options: options,
|
||||
};
|
||||
|
||||
const currentOptions = { ...options, type: layer.id, config: { ...layer.defaultOptions, ...options?.config } };
|
||||
|
||||
// Update the panel options if not set
|
||||
if (!options || (layer.defaultOptions && !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 (
|
||||
<>
|
||||
<br />
|
||||
{category.items.map((item) => item.render())}
|
||||
</>
|
||||
);
|
||||
}, [optionsEditorBuilder, onChange, data, options]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
menuShouldPortal
|
||||
options={layerTypes.options}
|
||||
value={layerTypes.current}
|
||||
onChange={(v) => {
|
||||
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}
|
||||
</div>
|
||||
);
|
||||
};
|
155
public/app/plugins/panel/geomap/editor/layerEditor.tsx
Normal file
155
public/app/plugins/panel/geomap/editor/layerEditor.tsx
Normal file
@ -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<MapLayerOptions> {
|
||||
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) => <div>HELLO</div>,
|
||||
})
|
||||
.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;
|
||||
}
|
@ -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<GeomapPanelOptions>(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<GeomapPanelOptions>(GeomapPanel)
|
||||
defaultValue: defaultView.shared,
|
||||
});
|
||||
|
||||
// Check server settings to disable custom basemap settings
|
||||
if (config.geomapDisableCustomBaseLayer) {
|
||||
builder.addCustomEditor({
|
||||
category: ['Base layer'],
|
||||
id: 'basemap',
|
||||
path: 'basemap',
|
||||
name: 'Base layer',
|
||||
editor: BaseLayerEditor,
|
||||
defaultValue: DEFAULT_BASEMAP_CONFIG,
|
||||
});
|
||||
|
||||
builder.addCustomEditor({
|
||||
category: ['Data layer'],
|
||||
id: 'layers',
|
||||
path: 'layers',
|
||||
name: 'Data layer',
|
||||
editor: DataLayersEditor,
|
||||
defaultValue: [defaultMarkersConfig],
|
||||
path: '',
|
||||
name: '',
|
||||
// eslint-disable-next-line react/display-name
|
||||
editor: () => <div>The base layer is configured by the server admin.</div>,
|
||||
});
|
||||
} else {
|
||||
builder.addNestedOptions(
|
||||
getLayerEditor({
|
||||
category: ['Base layer'],
|
||||
path: 'basemap', // only one for now
|
||||
basemaps: true,
|
||||
current: context.options?.layers?.[0],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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'];
|
||||
|
Loading…
Reference in New Issue
Block a user