Panel Options: support dynamic options editors (#39491)

This commit is contained in:
Ryan McKinley 2021-10-06 12:41:42 -07:00 committed by GitHub
parent 3a8d04603f
commit 3db98f417d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 554 additions and 553 deletions

View File

@ -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);
});
});

View File

@ -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.
*

View File

@ -92,4 +92,8 @@ export abstract class OptionsUIRegistryBuilder<
return this.properties;
});
}
getItems() {
return this.properties;
}
}

View File

@ -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,

View File

@ -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>>;
}

View File

@ -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),
});
},
};

View File

@ -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: {

View File

@ -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();

View File

@ -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}

View File

@ -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}>

View File

@ -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() {

View File

@ -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>
);
};

View File

@ -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);
}}
/>
);
};

View 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);
},
};
}

View File

@ -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),
});
}
},
};

View File

@ -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: [
{

View File

@ -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,
})
);
}
});

View File

@ -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} />;
};

View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View 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;
}

View File

@ -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'];