diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 0551daf5100..40f642a1eaa 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -18,5 +18,5 @@ export { BasicValueMatcherOptions, RangeValueMatcherOptions, } from './transformations/matchers/valueMatchers/types'; -export { PanelPlugin, SetFieldConfigOptionsArgs } from './panel/PanelPlugin'; +export { PanelPlugin, SetFieldConfigOptionsArgs, StandardOptionConfig } from './panel/PanelPlugin'; export { createFieldConfigRegistry } from './panel/registryFactories'; diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index 7996b024a0a..ce68b477d3c 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -16,7 +16,8 @@ import { deprecationWarning } from '../utils'; import { FieldConfigOptionsRegistry } from '../field'; import { createFieldConfigRegistry } from './registryFactories'; -type StandardOptionConfig = { +/** @beta */ +export type StandardOptionConfig = { defaultValue?: any; settings?: any; }; @@ -130,6 +131,7 @@ export class PanelPlugin< set(result, editor.id, editor.defaultValue); } } + return result; } @@ -138,6 +140,10 @@ export class PanelPlugin< configDefaults.custom = {} as TFieldConfigOptions; for (const option of this.fieldConfigRegistry.list()) { + if (option.defaultValue === undefined) { + continue; + } + set(configDefaults, option.id, option.defaultValue); } diff --git a/packages/grafana-data/src/types/panel.ts b/packages/grafana-data/src/types/panel.ts index 5a6d33364b6..7de98b5c1a7 100644 --- a/packages/grafana-data/src/types/panel.ts +++ b/packages/grafana-data/src/types/panel.ts @@ -131,7 +131,8 @@ export type PanelMigrationHandler = (panel: PanelModel export type PanelTypeChangedHandler = ( panel: PanelModel, prevPluginId: string, - prevOptions: any + prevOptions: Record, + prevFieldConfig: FieldConfigSource ) => Partial; export type PanelOptionEditorsRegistry = Registry; diff --git a/public/app/features/dashboard/state/PanelModel.test.ts b/public/app/features/dashboard/state/PanelModel.test.ts index 5d2c5767044..2f38ac5920d 100644 --- a/public/app/features/dashboard/state/PanelModel.test.ts +++ b/public/app/features/dashboard/state/PanelModel.test.ts @@ -6,8 +6,6 @@ import { standardEditorsRegistry, standardFieldConfigEditorRegistry, PanelData, - FieldColorModeId, - FieldColorConfigSettings, DataLinkBuiltInVars, VariableModel, } from '@grafana/data'; @@ -51,6 +49,41 @@ describe('PanelModel', () => { let modelJson: any; let persistedOptionsMock; + const tablePlugin = getPanelPlugin( + { + id: 'table', + }, + (null as unknown) as ComponentClass, // react + {} // angular + ); + + tablePlugin.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + description: '', + }); + }); + + tablePlugin.useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Unit]: { + defaultValue: 'flop', + }, + [FieldConfigProperty.Decimals]: { + defaultValue: 2, + }, + }, + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'CustomProp', + path: 'customProp', + defaultValue: false, + }); + }, + }); + beforeEach(() => { persistedOptionsMock = { fieldOptions: { @@ -112,35 +145,7 @@ describe('PanelModel', () => { }; model = new PanelModel(modelJson); - - const panelPlugin = getPanelPlugin( - { - id: 'table', - }, - (null as unknown) as ComponentClass, // react - {} // angular - ); - - panelPlugin.setPanelOptions((builder) => { - builder.addBooleanSwitch({ - name: 'Show thresholds', - path: 'showThresholds', - defaultValue: true, - description: '', - }); - }); - - panelPlugin.useFieldConfig({ - standardOptions: { - [FieldConfigProperty.Unit]: { - defaultValue: 'flop', - }, - [FieldConfigProperty.Decimals]: { - defaultValue: 2, - }, - }, - }); - model.pluginLoaded(panelPlugin); + model.pluginLoaded(tablePlugin); }); it('should apply defaults', () => { @@ -240,6 +245,13 @@ describe('PanelModel', () => { }, }, }, + useCustomConfig: (builder) => { + builder.addNumberInput({ + path: 'customProp', + name: 'customProp', + defaultValue: 100, + }); + }, }); newPlugin.setPanelOptions((builder) => { @@ -252,6 +264,25 @@ describe('PanelModel', () => { }); model.editSourceId = 1001; + model.fieldConfig.defaults.decimals = 3; + model.fieldConfig.defaults.custom = { + customProp: true, + }; + model.fieldConfig.overrides = [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customProp', + value: false, + }, + { + id: 'decimals', + value: 0, + }, + ], + }, + ]; model.changePlugin(newPlugin); model.alert = { id: 2 }; }); @@ -268,6 +299,21 @@ describe('PanelModel', () => { expect(model.interval).toBe('5m'); }); + it('should preseve standard field config', () => { + expect(model.fieldConfig.defaults.decimals).toEqual(3); + }); + + it('should clear custom field config and apply new defaults', () => { + expect(model.fieldConfig.defaults.custom).toEqual({ + customProp: 100, + }); + }); + + it('should remove overrides with custom props', () => { + expect(model.fieldConfig.overrides.length).toEqual(1); + expect(model.fieldConfig.overrides[0].properties[0].id).toEqual('decimals'); + }); + it('should apply next panel option defaults', () => { expect(model.getOptions().showThresholdLabels).toBeFalsy(); expect(model.getOptions().showThresholds).toBeUndefined(); @@ -278,76 +324,21 @@ describe('PanelModel', () => { }); it('should restore table properties when changing back', () => { - model.changePlugin(getPanelPlugin({ id: 'table' })); + model.changePlugin(tablePlugin); expect(model.showColumns).toBe(true); }); + it('should restore custom field config to what it was and preseve standard options', () => { + model.changePlugin(tablePlugin); + expect(model.fieldConfig.defaults.custom.customProp).toBe(true); + }); + it('should remove alert rule when changing type that does not support it', () => { model.changePlugin(getPanelPlugin({ id: 'table' })); expect(model.alert).toBe(undefined); }); }); - describe('when changing panel type to one that does not support by value color mode', () => { - beforeEach(() => { - model.fieldConfig.defaults.color = { mode: FieldColorModeId.Thresholds }; - - const newPlugin = getPanelPlugin({ id: 'graph' }); - newPlugin.useFieldConfig({ - standardOptions: { - [FieldConfigProperty.Color]: { - settings: { - byValueSupport: false, - }, - }, - }, - }); - - model.editSourceId = 1001; - model.changePlugin(newPlugin); - model.alert = { id: 2 }; - }); - - it('should change color mode', () => { - expect(model.fieldConfig.defaults.color.mode).toBe(FieldColorModeId.PaletteClassic); - }); - }); - - describe('when changing panel type from one not supporting by value color mode to one that supports it', () => { - const prepareModel = (colorOptions?: FieldColorConfigSettings) => { - const newModel = new PanelModel(modelJson); - newModel.fieldConfig.defaults.color = { mode: FieldColorModeId.PaletteClassic }; - - const newPlugin = getPanelPlugin({ id: 'graph' }); - newPlugin.useFieldConfig({ - standardOptions: { - [FieldConfigProperty.Color]: { - settings: { - byValueSupport: true, - ...colorOptions, - }, - }, - }, - }); - - newModel.editSourceId = 1001; - newModel.changePlugin(newPlugin); - newModel.alert = { id: 2 }; - return newModel; - }; - - it('should keep supported mode', () => { - const testModel = prepareModel(); - - expect(testModel.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); - }); - - it('should change to thresholds mode when it prefers to', () => { - const testModel = prepareModel({ preferThresholdsMode: true }); - expect(testModel.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.Thresholds); - }); - }); - describe('when changing to react panel from angular panel', () => { let panelQueryRunner: any; diff --git a/public/app/features/dashboard/state/PanelModel.ts b/public/app/features/dashboard/state/PanelModel.ts index 0d2539dce2c..0a80079faf0 100644 --- a/public/app/features/dashboard/state/PanelModel.ts +++ b/public/app/features/dashboard/state/PanelModel.ts @@ -9,15 +9,9 @@ import { DataLink, DataQuery, DataTransformerConfig, - FieldColorConfigSettings, - FieldColorModeId, - fieldColorModeRegistry, - FieldConfigProperty, FieldConfigSource, PanelPlugin, ScopedVars, - ThresholdsConfig, - ThresholdsMode, EventBusSrv, DataFrameDTO, urlUtil, @@ -35,6 +29,12 @@ import { } from 'app/types/events'; import { getTimeSrv } from '../services/TimeSrv'; import { getAllVariableValuesForUrl } from '../../variables/getAllVariableValuesForUrl'; +import { + filterFieldConfigOverrides, + getPanelOptionsWithDefaults, + isStandardFieldProp, + restoreCustomOverrideRules, +} from './getPanelOptionsWithDefaults'; export interface GridPos { x: number; @@ -96,6 +96,7 @@ const mustKeepProps: { [str: string]: boolean } = { editSourceId: true, maxDataPoints: true, interval: true, + replaceVariables: true, }; const defaults: any = { @@ -154,7 +155,7 @@ export class PanelModel implements DataConfigSource { hasRefreshed: boolean; events: EventBusSrv; cacheTimeout?: any; - cachedPluginOptions?: any; + cachedPluginOptions: Record; legend?: { show: boolean; sort?: string; sortDesc?: boolean }; plugin?: PanelPlugin; @@ -294,53 +295,32 @@ export class PanelModel implements DataConfigSource { } private restorePanelOptions(pluginId: string) { - const prevOptions = this.cachedPluginOptions[pluginId] || {}; - - Object.keys(prevOptions).map((property) => { - (this as any)[property] = prevOptions[property]; - }); - } - - private applyPluginOptionDefaults(plugin: PanelPlugin) { - if (plugin.angularConfigCtrl) { + if (!this.cachedPluginOptions) { return; } - this.options = _.mergeWith({}, plugin.defaults, this.options || {}, (objValue: any, srcValue: any): any => { - if (_.isArray(srcValue)) { - return srcValue; - } + const prevOptions = this.cachedPluginOptions[pluginId]; + + if (!prevOptions) { + return; + } + + Object.keys(prevOptions.properties).map((property) => { + (this as any)[property] = prevOptions.properties[property]; }); - this.fieldConfig = applyFieldConfigDefaults(this.fieldConfig, plugin.fieldConfigDefaults); - this.validateFieldColorMode(plugin); + this.fieldConfig = restoreCustomOverrideRules(this.fieldConfig, prevOptions.fieldConfig); } - private validateFieldColorMode(plugin: PanelPlugin) { - // adjust to prefered field color setting if needed - const color = plugin.fieldConfigRegistry.getIfExists(FieldConfigProperty.Color); + applyPluginOptionDefaults(plugin: PanelPlugin) { + const options = getPanelOptionsWithDefaults({ + plugin, + currentOptions: this.options, + currentFieldConfig: this.fieldConfig, + }); - if (color && color.settings) { - const colorSettings = color.settings as FieldColorConfigSettings; - const mode = fieldColorModeRegistry.getIfExists(this.fieldConfig.defaults.color?.mode); - - // When no support fo value colors, use classic palette - if (!colorSettings.byValueSupport) { - if (!mode || mode.isByValue) { - this.fieldConfig.defaults.color = { mode: FieldColorModeId.PaletteClassic }; - return; - } - } - - // When supporting value colors and prefering thresholds, use Thresholds mode. - // Otherwise keep current mode - if (colorSettings.byValueSupport && colorSettings.preferThresholdsMode) { - if (!mode || !mode.isByValue) { - this.fieldConfig.defaults.color = { mode: FieldColorModeId.Thresholds }; - return; - } - } - } + this.fieldConfig = options.fieldConfig; + this.options = options.options; } pluginLoaded(plugin: PanelPlugin) { @@ -359,35 +339,54 @@ export class PanelModel implements DataConfigSource { this.resendLastResult(); } - changePlugin(newPlugin: PanelPlugin) { - const pluginId = newPlugin.meta.id; - const oldOptions: any = this.getOptionsToRemember(); - const oldPluginId = this.type; - const wasAngular = this.isAngularPlugin(); - + clearPropertiesBeforePluginChange() { // remove panel type specific options for (const key of _.keys(this)) { if (mustKeepProps[key]) { continue; } - delete (this as any)[key]; } - this.cachedPluginOptions[oldPluginId] = oldOptions; + this.options = {}; + + // clear custom options + this.fieldConfig = { + defaults: { + ...this.fieldConfig.defaults, + custom: {}, + }, + // filter out custom overrides + overrides: filterFieldConfigOverrides(this.fieldConfig.overrides, isStandardFieldProp), + }; + } + + changePlugin(newPlugin: PanelPlugin) { + const pluginId = newPlugin.meta.id; + const oldOptions: any = this.getOptionsToRemember(); + const oldFieldConfig = this.fieldConfig; + const oldPluginId = this.type; + const wasAngular = this.isAngularPlugin(); + + this.cachedPluginOptions[oldPluginId] = { + properties: oldOptions, + fieldConfig: oldFieldConfig, + }; + + this.clearPropertiesBeforePluginChange(); this.restorePanelOptions(pluginId); // Let panel plugins inspect options from previous panel and keep any that it can use if (newPlugin.onPanelTypeChanged) { - let old: any = {}; + let oldOptions: any = {}; if (wasAngular) { - old = { angular: oldOptions }; + oldOptions = { angular: oldOptions }; } else if (oldOptions && oldOptions.options) { - old = oldOptions.options; + oldOptions = oldOptions.options; } - this.options = this.options || {}; - Object.assign(this.options, newPlugin.onPanelTypeChanged(this, oldPluginId, old)); + + Object.assign(this.options, newPlugin.onPanelTypeChanged(this, oldPluginId, oldOptions, oldFieldConfig)); } // switch @@ -532,51 +531,11 @@ export class PanelModel implements DataConfigSource { } } -function applyFieldConfigDefaults(fieldConfig: FieldConfigSource, defaults: FieldConfigSource): FieldConfigSource { - const result: FieldConfigSource = { - defaults: _.mergeWith( - {}, - defaults.defaults, - fieldConfig ? fieldConfig.defaults : {}, - (objValue: any, srcValue: any): any => { - if (_.isArray(srcValue)) { - return srcValue; - } - } - ), - overrides: fieldConfig?.overrides ?? [], - }; - - // Thresholds base values are null in JSON but need to be converted to -Infinity - if (result.defaults.thresholds) { - fixThresholds(result.defaults.thresholds); - } - - for (const override of result.overrides) { - for (const property of override.properties) { - if (property.id === 'thresholds') { - fixThresholds(property.value as ThresholdsConfig); - } - } - } - - return result; -} - -function fixThresholds(thresholds: ThresholdsConfig) { - if (!thresholds.mode) { - thresholds.mode = ThresholdsMode.Absolute; - } - - if (!thresholds.steps) { - thresholds.steps = []; - } else if (thresholds.steps.length) { - // First value is always -Infinity - // JSON saves it as null - thresholds.steps[0].value = -Infinity; - } -} - function getPluginVersion(plugin: PanelPlugin): string { return plugin && plugin.meta.info.version ? plugin.meta.info.version : config.buildInfo.version; } + +interface PanelOptionsCache { + properties: any; + fieldConfig: FieldConfigSource; +} diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts new file mode 100644 index 00000000000..0d639b39b50 --- /dev/null +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.test.ts @@ -0,0 +1,390 @@ +import { + ConfigOverrideRule, + FieldColorModeId, + FieldConfig, + FieldConfigProperty, + FieldConfigSource, + PanelPlugin, + standardEditorsRegistry, + standardFieldConfigEditorRegistry, + StandardOptionConfig, +} from '@grafana/data'; +import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; +import { mockStandardFieldConfigOptions } from 'test/helpers/fieldConfig'; +import { getPanelOptionsWithDefaults, restoreCustomOverrideRules } from './getPanelOptionsWithDefaults'; + +standardFieldConfigEditorRegistry.setInit(() => mockStandardFieldConfigOptions()); +standardEditorsRegistry.setInit(() => mockStandardFieldConfigOptions()); + +const pluginA = getPanelPlugin({ id: 'graph' }); + +pluginA.useFieldConfig({ + useCustomConfig: (builder) => { + builder.addBooleanSwitch({ + name: 'Hide lines', + path: 'hideLines', + defaultValue: false, + }); + }, +}); + +pluginA.setPanelOptions((builder) => { + builder.addBooleanSwitch({ + name: 'Show thresholds', + path: 'showThresholds', + defaultValue: true, + }); + builder.addTextInput({ + name: 'Name', + path: 'name', + defaultValue: 'hello', + }); + builder.addNumberInput({ + name: 'Number', + path: 'number', + defaultValue: 10, + }); +}); + +describe('getPanelOptionsWithDefaults', () => { + describe('When panel plugin has no options', () => { + it('Should set defaults', () => { + const result = runScenario({ + plugin: getPanelPlugin({ id: 'graph' }), + options: {}, + defaults: {}, + overrides: [], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object {}, + }, + "overrides": Array [], + }, + "options": Object {}, + } + `); + }); + }); + + describe('When current options are emtpy', () => { + it('Should set defaults', () => { + const result = getPanelOptionsWithDefaults({ + plugin: pluginA, + currentOptions: {}, + currentFieldConfig: { + defaults: {}, + overrides: [], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object { + "hideLines": false, + }, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + }, + "overrides": Array [], + }, + "options": Object { + "name": "hello", + "number": 10, + "showThresholds": true, + }, + } + `); + }); + }); + + describe('When there are current options and overrides', () => { + it('Should set defaults', () => { + const result = getPanelOptionsWithDefaults({ + plugin: pluginA, + currentOptions: { + number: 20, + showThresholds: false, + }, + currentFieldConfig: { + defaults: { + unit: 'bytes', + decimals: 2, + }, + overrides: [], + }, + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "fieldConfig": Object { + "defaults": Object { + "custom": Object { + "hideLines": false, + }, + "decimals": 2, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + "unit": "bytes", + }, + "overrides": Array [], + }, + "options": Object { + "name": "hello", + "number": 20, + "showThresholds": false, + }, + } + `); + }); + }); + + describe('when changing panel type to one that does not support by value color mode', () => { + it('should change color mode', () => { + const plugin = getPanelPlugin({ id: 'graph' }).useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + }, + }, + }); + + const result = getPanelOptionsWithDefaults({ + plugin, + currentOptions: {}, + currentFieldConfig: { + defaults: { + color: { mode: FieldColorModeId.Thresholds }, + }, + overrides: [], + }, + }); + + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); + }); + }); + + describe('when changing panel type from one not supporting by value color mode to one that supports it', () => { + it('should keep supported mode', () => { + const result = runScenario({ + defaults: { + color: { mode: FieldColorModeId.PaletteClassic }, + }, + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + }, + }, + }, + }); + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.PaletteClassic); + }); + + it('should change to thresholds mode when it prefers to', () => { + const result = runScenario({ + defaults: { + color: { mode: FieldColorModeId.PaletteClassic }, + }, + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: true, + preferThresholdsMode: true, + }, + }, + }, + }); + expect(result.fieldConfig.defaults.color!.mode).toBe(FieldColorModeId.Thresholds); + }); + }); + + describe('when applying defaults clean properties that are no longer part of the registry', () => { + it('should remove custom defaults that no longer exist', () => { + const result = runScenario({ + defaults: { + unit: 'bytes', + custom: { + customProp: 20, + customPropNoExist: true, + nested: { + nestedA: 'A', + nestedB: 'B', + }, + }, + }, + }); + + expect(result.fieldConfig.defaults).toMatchInlineSnapshot(` + Object { + "custom": Object { + "customProp": 20, + "nested": Object { + "nestedA": "A", + }, + }, + "thresholds": Object { + "mode": "absolute", + "steps": Array [ + Object { + "color": "green", + "value": -Infinity, + }, + Object { + "color": "red", + "value": 80, + }, + ], + }, + "unit": "bytes", + } + `); + }); + + it('should remove custom overrides that no longer exist', () => { + const result = runScenario({ + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customPropNoExist', + value: 'google', + }, + ], + }, + { + matcher: { id: 'byName', options: 'D-series' }, + properties: [ + { + id: 'custom.customProp', + value: 30, + }, + ], + }, + ], + }); + + expect(result.fieldConfig.overrides.length).toBe(1); + expect(result.fieldConfig.overrides[0].properties[0].id).toBe('custom.customProp'); + }); + }); +}); + +describe('restoreCustomOverrideRules', () => { + it('should add back custom rules', () => { + const current = { + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'SeriesA' }, + properties: [ + { + id: 'decimals', + value: 2, + }, + ], + }, + ], + }; + const old = { + defaults: {}, + overrides: [ + { + matcher: { id: 'byName', options: 'SeriesA' }, + properties: [ + { + id: 'custom.propName', + value: 10, + }, + ], + }, + { + matcher: { id: 'byName', options: 'SeriesB' }, + properties: [ + { + id: 'custom.propName', + value: 20, + }, + ], + }, + ], + }; + + const result = restoreCustomOverrideRules(current, old); + expect(result.overrides.length).toBe(2); + expect(result.overrides[0].properties[0].id).toBe('decimals'); + expect(result.overrides[0].properties[1].id).toBe('custom.propName'); + expect(result.overrides[1].properties.length).toBe(1); + expect(result.overrides[1].matcher.options).toBe('SeriesB'); + }); +}); + +interface ScenarioOptions { + defaults?: FieldConfig; + overrides?: ConfigOverrideRule[]; + disabledStandardOptions?: FieldConfigProperty[]; + standardOptions?: Partial>; + plugin?: PanelPlugin; + options?: any; +} + +function runScenario(options: ScenarioOptions) { + const fieldConfig: FieldConfigSource = { + defaults: options.defaults || {}, + overrides: options.overrides || [], + }; + + const plugin = + options.plugin ?? + getPanelPlugin({ id: 'graph' }).useFieldConfig({ + standardOptions: options.standardOptions, + useCustomConfig: (builder) => { + builder.addNumberInput({ + name: 'Custom prop', + path: 'customProp', + defaultValue: 10, + }); + builder.addTextInput({ + name: 'Nested prop', + path: 'nested.nestedA', + }); + }, + }); + + return getPanelOptionsWithDefaults({ + plugin, + currentOptions: options.options || {}, + currentFieldConfig: fieldConfig, + }); +} diff --git a/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts new file mode 100644 index 00000000000..b8caf87c20c --- /dev/null +++ b/public/app/features/dashboard/state/getPanelOptionsWithDefaults.ts @@ -0,0 +1,195 @@ +import { + ConfigOverrideRule, + DynamicConfigValue, + FieldColorConfigSettings, + FieldColorModeId, + fieldColorModeRegistry, + FieldConfigOptionsRegistry, + FieldConfigProperty, + FieldConfigSource, + PanelPlugin, + ThresholdsConfig, + ThresholdsMode, +} from '@grafana/data'; +import { mergeWith, isArray, isObject, unset, isEqual } from 'lodash'; + +export interface Props { + plugin: PanelPlugin; + currentFieldConfig: FieldConfigSource; + currentOptions: Record; +} + +export interface OptionDefaults { + options: any; + fieldConfig: FieldConfigSource; +} + +export function getPanelOptionsWithDefaults({ plugin, currentOptions, currentFieldConfig }: Props): OptionDefaults { + const optionsWithDefaults = mergeWith( + {}, + plugin.defaults, + currentOptions || {}, + (objValue: any, srcValue: any): any => { + if (isArray(srcValue)) { + return srcValue; + } + } + ); + + const fieldConfigWithDefaults = applyFieldConfigDefaults(currentFieldConfig, plugin); + const fieldConfigWithOptimalColorMode = adaptFieldColorMode(plugin, fieldConfigWithDefaults); + + return { options: optionsWithDefaults, fieldConfig: fieldConfigWithOptimalColorMode }; +} + +function applyFieldConfigDefaults(existingFieldConfig: FieldConfigSource, plugin: PanelPlugin): FieldConfigSource { + const pluginDefaults = plugin.fieldConfigDefaults; + + const result: FieldConfigSource = { + defaults: mergeWith( + {}, + pluginDefaults.defaults, + existingFieldConfig ? existingFieldConfig.defaults : {}, + (objValue: any, srcValue: any): any => { + if (isArray(srcValue)) { + return srcValue; + } + } + ), + overrides: existingFieldConfig?.overrides ?? [], + }; + + cleanProperties(result.defaults, '', plugin.fieldConfigRegistry); + + // Thresholds base values are null in JSON but need to be converted to -Infinity + if (result.defaults.thresholds) { + fixThresholds(result.defaults.thresholds); + } + + // Filter out overrides for properties that cannot be found in registry + result.overrides = filterFieldConfigOverrides(result.overrides, (prop) => { + return plugin.fieldConfigRegistry.getIfExists(prop.id) !== undefined; + }); + + for (const override of result.overrides) { + for (const property of override.properties) { + if (property.id === 'thresholds') { + fixThresholds(property.value as ThresholdsConfig); + } + } + } + + return result; +} + +export function filterFieldConfigOverrides( + overrides: ConfigOverrideRule[], + condition: (value: DynamicConfigValue) => boolean +): ConfigOverrideRule[] { + return overrides + .map((x) => { + const properties = x.properties.filter(condition); + + return { + ...x, + properties, + }; + }) + .filter((x) => x.properties.length > 0); +} + +function cleanProperties(obj: any, parentPath: string, fieldConfigRegistry: FieldConfigOptionsRegistry) { + for (const propName of Object.keys(obj)) { + const value = obj[propName]; + const fullPath = `${parentPath}${propName}`; + const existsInRegistry = !!fieldConfigRegistry.getIfExists(fullPath); + + // need to check early here as some standard properties have nested properies + if (existsInRegistry) { + continue; + } + + if (isArray(value) || !isObject(value)) { + if (!existsInRegistry) { + unset(obj, propName); + } + } else { + cleanProperties(value, `${fullPath}.`, fieldConfigRegistry); + } + } +} + +function adaptFieldColorMode(plugin: PanelPlugin, fieldConfig: FieldConfigSource): FieldConfigSource { + // adjust to prefered field color setting if needed + const color = plugin.fieldConfigRegistry.getIfExists(FieldConfigProperty.Color); + + if (color && color.settings) { + const colorSettings = color.settings as FieldColorConfigSettings; + const mode = fieldColorModeRegistry.getIfExists(fieldConfig.defaults.color?.mode); + + // When no support fo value colors, use classic palette + if (!colorSettings.byValueSupport) { + if (!mode || mode.isByValue) { + fieldConfig.defaults.color = { mode: FieldColorModeId.PaletteClassic }; + return fieldConfig; + } + } + + // When supporting value colors and prefering thresholds, use Thresholds mode. + // Otherwise keep current mode + if (colorSettings.byValueSupport && colorSettings.preferThresholdsMode) { + if (!mode || !mode.isByValue) { + fieldConfig.defaults.color = { mode: FieldColorModeId.Thresholds }; + return fieldConfig; + } + } + } + return fieldConfig; +} + +function fixThresholds(thresholds: ThresholdsConfig) { + if (!thresholds.mode) { + thresholds.mode = ThresholdsMode.Absolute; + } + + if (!thresholds.steps) { + thresholds.steps = []; + } else if (thresholds.steps.length) { + // First value is always -Infinity + // JSON saves it as null + thresholds.steps[0].value = -Infinity; + } +} + +export function restoreCustomOverrideRules(current: FieldConfigSource, old: FieldConfigSource): FieldConfigSource { + const result = { + defaults: { + ...current.defaults, + custom: old.defaults.custom, + }, + overrides: [...current.overrides], + }; + + for (const override of old.overrides) { + for (const prop of override.properties) { + if (isCustomFieldProp(prop)) { + const currentOverride = result.overrides.find((o) => isEqual(o.matcher, override.matcher)); + if (currentOverride) { + currentOverride.properties.push(prop); + } else { + result.overrides.push(override); + } + } + } + } + + return result; +} + +export function isCustomFieldProp(prop: DynamicConfigValue): boolean { + return prop.id.startsWith('custom.'); +} + +export function isStandardFieldProp(prop: DynamicConfigValue): boolean { + return !isCustomFieldProp(prop); +} diff --git a/public/test/helpers/fieldConfig.ts b/public/test/helpers/fieldConfig.ts index f6dc74e8f5b..e265d442a66 100644 --- a/public/test/helpers/fieldConfig.ts +++ b/public/test/helpers/fieldConfig.ts @@ -1,4 +1,4 @@ -import { identityOverrideProcessor } from '@grafana/data'; +import { identityOverrideProcessor, ThresholdsMode } from '@grafana/data'; export function mockStandardFieldConfigOptions() { const unit = { @@ -79,5 +79,25 @@ export function mockStandardFieldConfigOptions() { shouldApply: () => true, }; - return [unit, decimals, boolean, fieldColor, text, number]; + const thresholds = { + id: 'thresholds', + path: 'thresholds', + name: 'thresholds', + description: '', + // @ts-ignore + editor: () => null, + // @ts-ignore + override: () => null, + process: identityOverrideProcessor, + shouldApply: () => true, + defaultValue: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 80, color: 'red' }, + ], + }, + }; + + return [unit, decimals, boolean, fieldColor, text, number, thresholds]; }