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:
@@ -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,66 +1,81 @@
|
||||
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>) {
|
||||
builder
|
||||
.addCustomEditor({
|
||||
id: 'background.color',
|
||||
path: 'background.color',
|
||||
name: 'Background Color',
|
||||
editor: ColorDimensionEditor,
|
||||
settings: {},
|
||||
defaultValue: {
|
||||
// Configured values
|
||||
fixed: '',
|
||||
},
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'background.image',
|
||||
path: 'background.image',
|
||||
name: 'Background Image',
|
||||
editor: ResourceDimensionEditor,
|
||||
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: 'Color',
|
||||
editor: ColorDimensionEditor,
|
||||
settings: {},
|
||||
defaultValue: {
|
||||
// Configured values
|
||||
fixed: '',
|
||||
},
|
||||
})
|
||||
.addCustomEditor({
|
||||
category,
|
||||
id: 'background.image',
|
||||
path: 'background.image',
|
||||
name: 'Image',
|
||||
editor: ResourceDimensionEditor,
|
||||
settings: {
|
||||
resourceType: 'image',
|
||||
},
|
||||
})
|
||||
.addRadio({
|
||||
category,
|
||||
path: 'background.size',
|
||||
name: 'Image size',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: BackgroundImageSize.Original, label: 'Original' },
|
||||
{ value: BackgroundImageSize.Contain, label: 'Contain' },
|
||||
{ value: BackgroundImageSize.Cover, label: 'Cover' },
|
||||
{ value: BackgroundImageSize.Fill, label: 'Fill' },
|
||||
{ value: BackgroundImageSize.Tile, label: 'Tile' },
|
||||
],
|
||||
},
|
||||
defaultValue: BackgroundImageSize.Cover,
|
||||
});
|
||||
},
|
||||
|
||||
addBorder: (builder, context) => {
|
||||
const category = ['Border'];
|
||||
builder.addSliderInput({
|
||||
category,
|
||||
path: 'border.width',
|
||||
name: 'Width',
|
||||
defaultValue: 2,
|
||||
settings: {
|
||||
resourceType: 'image',
|
||||
min: 0,
|
||||
max: 20,
|
||||
},
|
||||
})
|
||||
.addRadio({
|
||||
path: 'background.size',
|
||||
name: 'Backround image size',
|
||||
settings: {
|
||||
options: [
|
||||
{ value: BackgroundImageSize.Original, label: 'Original' },
|
||||
{ value: BackgroundImageSize.Contain, label: 'Contain' },
|
||||
{ value: BackgroundImageSize.Cover, label: 'Cover' },
|
||||
{ value: BackgroundImageSize.Fill, label: 'Fill' },
|
||||
{ value: BackgroundImageSize.Tile, label: 'Tile' },
|
||||
],
|
||||
},
|
||||
defaultValue: BackgroundImageSize.Cover,
|
||||
});
|
||||
}
|
||||
|
||||
export function addBorderOptions(builder: PanelOptionsEditorBuilder<any>) {
|
||||
builder.addSliderInput({
|
||||
path: 'border.width',
|
||||
name: 'Border Width',
|
||||
defaultValue: 2,
|
||||
settings: {
|
||||
min: 0,
|
||||
max: 20,
|
||||
},
|
||||
});
|
||||
|
||||
builder.addCustomEditor({
|
||||
id: 'border.color',
|
||||
path: 'border.color',
|
||||
name: 'Border Color',
|
||||
editor: ColorDimensionEditor,
|
||||
settings: {},
|
||||
defaultValue: {
|
||||
// Configured values
|
||||
fixed: '',
|
||||
},
|
||||
showIf: (cfg) => Boolean(cfg.border?.width),
|
||||
});
|
||||
}
|
||||
if (context.options?.border?.width) {
|
||||
builder.addCustomEditor({
|
||||
category,
|
||||
id: 'border.color',
|
||||
path: 'border.color',
|
||||
name: 'Color',
|
||||
editor: ColorDimensionEditor,
|
||||
settings: {},
|
||||
defaultValue: {
|
||||
// Configured values
|
||||
fixed: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
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: () => <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.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'];
|
||||
|
||||
Reference in New Issue
Block a user