PanelEditor: Adds panel instance state and a way to share this between panel & option editors (#39637)

* PanelEditor: Adds panel instance state and a way to share this between panel & option editors

* Refactoring to use PanelContext
This commit is contained in:
Torkel Ödegaard
2021-10-01 11:08:11 +02:00
committed by GitHub
parent 0b89bdd47d
commit 962745ff21
18 changed files with 104 additions and 47 deletions

View File

@@ -4,20 +4,21 @@ import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
import { DataFrame, InterpolateFunction, VariableSuggestionsScope, VariableSuggestion } from '../types'; import { DataFrame, InterpolateFunction, VariableSuggestionsScope, VariableSuggestion } from '../types';
import { EventBus } from '../events'; import { EventBus } from '../events';
export interface StandardEditorContext<TOptions> { export interface StandardEditorContext<TOptions, TState = any> {
data: DataFrame[]; // All results data: DataFrame[]; // All results
replaceVariables?: InterpolateFunction; replaceVariables?: InterpolateFunction;
eventBus?: EventBus; eventBus?: EventBus;
getSuggestions?: (scope?: VariableSuggestionsScope) => VariableSuggestion[]; getSuggestions?: (scope?: VariableSuggestionsScope) => VariableSuggestion[];
options?: TOptions; options?: TOptions;
instanceState?: TState;
isOverride?: boolean; isOverride?: boolean;
} }
export interface StandardEditorProps<TValue = any, TSettings = any, TOptions = any> { export interface StandardEditorProps<TValue = any, TSettings = any, TOptions = any, TState = any> {
value: TValue; value: TValue;
onChange: (value?: TValue) => void; onChange: (value?: TValue) => void;
item: StandardEditorsRegistryItem<TValue, TSettings>; item: StandardEditorsRegistryItem<TValue, TSettings>;
context: StandardEditorContext<TOptions>; context: StandardEditorContext<TOptions, TState>;
} }
export interface StandardEditorsRegistryItem<TValue = any, TSettings = any> extends RegistryItem { export interface StandardEditorsRegistryItem<TValue = any, TSettings = any> extends RegistryItem {
editor: ComponentType<StandardEditorProps<TValue, TSettings>>; editor: ComponentType<StandardEditorProps<TValue, TSettings>>;

View File

@@ -57,7 +57,7 @@ export interface FieldConfigSource<TOptions extends object = any> {
overrides: ConfigOverrideRule[]; overrides: ConfigOverrideRule[];
} }
export interface FieldOverrideContext extends StandardEditorContext<any> { export interface FieldOverrideContext extends StandardEditorContext<any, any> {
field?: Field; field?: Field;
dataFrameIndex?: number; // The index for the selected field frame dataFrameIndex?: number; // The index for the selected field frame
} }

View File

@@ -59,7 +59,7 @@ export interface PanelData {
timeRange: TimeRange; timeRange: TimeRange;
} }
export interface PanelProps<T = any> { export interface PanelProps<T = any, S = any> {
/** ID of the panel within the current dashboard */ /** ID of the panel within the current dashboard */
id: number; id: number;

View File

@@ -48,6 +48,12 @@ export interface PanelContext {
* For example TimeSeries panel. * For example TimeSeries panel.
*/ */
onSplitOpen?: SplitOpen; onSplitOpen?: SplitOpen;
/** For instance state that can be shared between panel & options UI */
instanceState?: any;
/** Update instance state, this is only supported in dashboard panel context currently */
onInstanceStateChange?: (state: any) => void;
} }
export const PanelContextRoot = React.createContext<PanelContext>({ export const PanelContextRoot = React.createContext<PanelContext>({

View File

@@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { FieldConfigSource, GrafanaTheme, PanelPlugin } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state';
import { useStyles } from '@grafana/ui'; import { useStyles } from '@grafana/ui';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@@ -10,26 +9,17 @@ import { useSelector } from 'react-redux';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { VisualizationSelectPane } from './VisualizationSelectPane'; import { VisualizationSelectPane } from './VisualizationSelectPane';
import { usePanelLatestData } from './usePanelLatestData'; import { usePanelLatestData } from './usePanelLatestData';
import { OptionPaneRenderProps } from './types';
interface Props { export const OptionsPane: React.FC<OptionPaneRenderProps> = ({
plugin: PanelPlugin;
panel: PanelModel;
width: number;
dashboard: DashboardModel;
onFieldConfigsChange: (config: FieldConfigSource) => void;
onPanelOptionsChanged: (options: any) => void;
onPanelConfigChange: (configKey: keyof PanelModel, value: any) => void;
}
export const OptionsPane: React.FC<Props> = ({
plugin, plugin,
panel, panel,
width,
onFieldConfigsChange, onFieldConfigsChange,
onPanelOptionsChanged, onPanelOptionsChanged,
onPanelConfigChange, onPanelConfigChange,
dashboard, dashboard,
}: Props) => { instanceState,
}) => {
const styles = useStyles(getStyles); const styles = useStyles(getStyles);
const isVizPickerOpen = useSelector((state: StoreState) => state.panelEditor.isVizPickerOpen); const isVizPickerOpen = useSelector((state: StoreState) => state.panelEditor.isVizPickerOpen);
const { data } = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false }, true); const { data } = usePanelLatestData(panel, { withTransforms: true, withFieldConfig: false }, true);
@@ -46,6 +36,7 @@ export const OptionsPane: React.FC<Props> = ({
panel={panel} panel={panel}
dashboard={dashboard} dashboard={dashboard}
plugin={plugin} plugin={plugin}
instanceState={instanceState}
data={data} data={data}
onFieldConfigsChange={onFieldConfigsChange} onFieldConfigsChange={onFieldConfigsChange}
onPanelOptionsChanged={onPanelOptionsChanged} onPanelOptionsChanged={onPanelOptionsChanged}

View File

@@ -96,6 +96,7 @@ class OptionsPaneOptionsTestScenario {
onFieldConfigsChange={this.onFieldConfigsChange} onFieldConfigsChange={this.onFieldConfigsChange}
onPanelConfigChange={this.onPanelConfigChange} onPanelConfigChange={this.onPanelConfigChange}
onPanelOptionsChanged={this.onPanelOptionsChanged} onPanelOptionsChanged={this.onPanelOptionsChanged}
instanceState={undefined}
/> />
</Provider> </Provider>
); );

View File

@@ -1,7 +1,6 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { FieldConfigSource, GrafanaTheme2, PanelData, PanelPlugin, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { DashboardModel, PanelModel } from '../../state'; import { CustomScrollbar, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { CustomScrollbar, RadioButtonGroup, useStyles2, FilterInput } from '@grafana/ui';
import { getPanelFrameCategory } from './getPanelFrameOptions'; import { getPanelFrameCategory } from './getPanelFrameOptions';
import { getVizualizationOptions } from './getVizualizationOptions'; import { getVizualizationOptions } from './getVizualizationOptions';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
@@ -13,18 +12,9 @@ import { AngularPanelOptions } from './AngularPanelOptions';
import { getRecentOptions } from './state/getRecentOptions'; import { getRecentOptions } from './state/getRecentOptions';
import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
import { getLibraryPanelOptionsCategory } from './getLibraryPanelOptions'; import { getLibraryPanelOptionsCategory } from './getLibraryPanelOptions';
import { OptionPaneRenderProps } from './types';
interface Props { export const OptionsPaneOptions: React.FC<OptionPaneRenderProps> = (props) => {
plugin: PanelPlugin;
panel: PanelModel;
dashboard: DashboardModel;
data?: PanelData;
onFieldConfigsChange: (config: FieldConfigSource) => void;
onPanelOptionsChanged: (options: any) => void;
onPanelConfigChange: (configKey: keyof PanelModel, value: any) => void;
}
export const OptionsPaneOptions: React.FC<Props> = (props) => {
const { plugin, dashboard, panel } = props; const { plugin, dashboard, panel } = props;
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [listMode, setListMode] = useState(OptionFilter.All); const [listMode, setListMode] = useState(OptionFilter.All);
@@ -39,7 +29,7 @@ export const OptionsPaneOptions: React.FC<Props> = (props) => {
], ],
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[panel.configRev, props.data] [panel.configRev, props.data, props.instanceState]
); );
const mainBoxElements: React.ReactNode[] = []; const mainBoxElements: React.ReactNode[] = [];

View File

@@ -64,11 +64,12 @@ interface OwnProps {
const mapStateToProps = (state: StoreState) => { const mapStateToProps = (state: StoreState) => {
const panel = state.panelEditor.getPanel(); const panel = state.panelEditor.getPanel();
const { plugin } = getPanelStateById(state.dashboard, panel.id); const { plugin, instanceState } = getPanelStateById(state.dashboard, panel.id);
return { return {
plugin: plugin, plugin: plugin,
panel, panel,
instanceState,
initDone: state.panelEditor.initDone, initDone: state.panelEditor.initDone,
uiState: state.panelEditor.ui, uiState: state.panelEditor.ui,
tableViewEnabled: state.panelEditor.tableViewEnabled, tableViewEnabled: state.panelEditor.tableViewEnabled,
@@ -395,12 +396,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
} }
renderOptionsPane() { renderOptionsPane() {
const { plugin, dashboard, panel, uiState } = this.props; const { plugin, dashboard, panel, instanceState } = this.props;
const rightPaneSize =
uiState.rightPaneSize <= 1
? (uiState.rightPaneSize as number) * window.innerWidth
: (uiState.rightPaneSize as number);
if (!plugin) { if (!plugin) {
return <div />; return <div />;
@@ -411,7 +407,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
plugin={plugin} plugin={plugin}
dashboard={dashboard} dashboard={dashboard}
panel={panel} panel={panel}
width={rightPaneSize} instanceState={instanceState}
onFieldConfigsChange={this.onFieldConfigChange} onFieldConfigsChange={this.onFieldConfigChange}
onPanelOptionsChanged={this.onPanelOptionsChanged} onPanelOptionsChanged={this.onPanelOptionsChanged}
onPanelConfigChange={this.onPanelConfigChanged} onPanelConfigChange={this.onPanelConfigChanged}

View File

@@ -10,12 +10,12 @@ import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
type categoryGetter = (categoryNames?: string[]) => OptionsPaneCategoryDescriptor; type categoryGetter = (categoryNames?: string[]) => OptionsPaneCategoryDescriptor;
export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] { export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPaneCategoryDescriptor[] {
const { plugin, panel, onPanelOptionsChanged, onFieldConfigsChange, data, dashboard } = props; const { plugin, panel, onPanelOptionsChanged, onFieldConfigsChange, data, dashboard, instanceState } = props;
const currentOptions = panel.getOptions(); const currentOptions = panel.getOptions();
const currentFieldConfig = panel.fieldConfig; const currentFieldConfig = panel.fieldConfig;
const categoryIndex: Record<string, OptionsPaneCategoryDescriptor> = {}; const categoryIndex: Record<string, OptionsPaneCategoryDescriptor> = {};
const context: StandardEditorContext<any> = { const context: StandardEditorContext<any, any> = {
data: data?.series || [], data: data?.series || [],
replaceVariables: panel.replaceVariables, replaceVariables: panel.replaceVariables,
options: currentOptions, options: currentOptions,
@@ -23,6 +23,7 @@ export function getVizualizationOptions(props: OptionPaneRenderProps): OptionsPa
getSuggestions: (scope?: VariableSuggestionsScope) => { getSuggestions: (scope?: VariableSuggestionsScope) => {
return data ? getDataLinksVariableSuggestions(data.series, scope) : []; return data ? getDataLinksVariableSuggestions(data.series, scope) : [];
}, },
instanceState,
}; };
const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => { const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => {
@@ -109,7 +110,7 @@ export function fillOptionsPaneItems(
optionEditors: PanelOptionsEditorItem[], optionEditors: PanelOptionsEditorItem[],
getOptionsPaneCategory: categoryGetter, getOptionsPaneCategory: categoryGetter,
onValueChanged: (path: string, value: any) => void, onValueChanged: (path: string, value: any) => void,
context: StandardEditorContext<any> context: StandardEditorContext<any, any>
) { ) {
for (const pluginOption of optionEditors) { for (const pluginOption of optionEditors) {
if (pluginOption.showIf && !pluginOption.showIf(context.options, context.data)) { if (pluginOption.showIf && !pluginOption.showIf(context.options, context.data)) {

View File

@@ -54,6 +54,7 @@ export interface OptionPaneRenderProps {
plugin: PanelPlugin; plugin: PanelPlugin;
data?: PanelData; data?: PanelData;
dashboard: DashboardModel; dashboard: DashboardModel;
instanceState: any;
onPanelConfigChange: (configKey: keyof PanelModel, value: any) => void; onPanelConfigChange: (configKey: keyof PanelModel, value: any) => void;
onPanelOptionsChanged: (options: any) => void; onPanelOptionsChanged: (options: any) => void;
onFieldConfigsChange: (config: FieldConfigSource) => void; onFieldConfigsChange: (config: FieldConfigSource) => void;

View File

@@ -36,10 +36,13 @@ const mapStateToProps = (state: StoreState, props: OwnProps) => {
return { return {
plugin: panelState.plugin, plugin: panelState.plugin,
instanceState: panelState.instanceState,
}; };
}; };
const mapDispatchToProps = { initDashboardPanel }; const mapDispatchToProps = {
initDashboardPanel,
};
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@@ -36,6 +36,8 @@ import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annota
import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner'; import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { liveTimer } from './liveTimer'; import { liveTimer } from './liveTimer';
import { isSoloRoute } from '../../../routes/utils'; import { isSoloRoute } from '../../../routes/utils';
import { setPanelInstanceState } from '../state/reducers';
import { store } from 'app/store/store';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
@@ -84,11 +86,24 @@ export class PanelChrome extends Component<Props, State> {
onAnnotationUpdate: this.onAnnotationUpdate, onAnnotationUpdate: this.onAnnotationUpdate,
onAnnotationDelete: this.onAnnotationDelete, onAnnotationDelete: this.onAnnotationDelete,
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable), canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
onInstanceStateChange: this.onInstanceStateChange,
}, },
data: this.getInitialPanelDataState(), data: this.getInitialPanelDataState(),
}; };
} }
onInstanceStateChange = (value: any) => {
this.setState({
context: {
...this.state.context,
instanceState: value,
},
});
// Set redux panel state so panel options can get notified
store.dispatch(setPanelInstanceState({ panelId: this.props.panel.id, value }));
};
onSeriesColorChange = (label: string, color: string) => { onSeriesColorChange = (label: string, color: string) => {
this.onFieldConfigChange(changeSeriesColorConfigFactory(label, color, this.props.panel.fieldConfig)); this.onFieldConfigChange(changeSeriesColorConfigFactory(label, color, this.props.panel.fieldConfig));
}; };

View File

@@ -78,6 +78,9 @@ const dashbardSlice = createSlice({
cleanUpEditPanel: (state) => { cleanUpEditPanel: (state) => {
delete state.panels[EDIT_PANEL_ID]; delete state.panels[EDIT_PANEL_ID];
}, },
setPanelInstanceState: (state, action: PayloadAction<SetPanelInstanceStatePayload>) => {
updatePanelState(state, action.payload.panelId, { instanceState: action.payload.value });
},
setPanelAngularComponent: (state, action: PayloadAction<SetPanelAngularComponentPayload>) => { setPanelAngularComponent: (state, action: PayloadAction<SetPanelAngularComponentPayload>) => {
updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent }); updatePanelState(state, action.payload.panelId, { angularComponent: action.payload.angularComponent });
}, },
@@ -105,6 +108,11 @@ export interface SetPanelAngularComponentPayload {
angularComponent: AngularComponent | null; angularComponent: AngularComponent | null;
} }
export interface SetPanelInstanceStatePayload {
panelId: number;
value: any;
}
export const { export const {
loadDashboardPermissions, loadDashboardPermissions,
dashboardInitFetching, dashboardInitFetching,
@@ -119,6 +127,7 @@ export const {
addPanel, addPanel,
cleanUpEditPanel, cleanUpEditPanel,
setPanelAngularComponent, setPanelAngularComponent,
setPanelInstanceState,
} = dashbardSlice.actions; } = dashbardSlice.actions;
export const dashboardReducer = dashbardSlice.reducer; export const dashboardReducer = dashbardSlice.reducer;

View File

@@ -5,19 +5,26 @@ import { DebugPanelOptions, DebugMode } from './types';
import { EventBusLoggerPanel } from './EventBusLogger'; import { EventBusLoggerPanel } from './EventBusLogger';
import { RenderInfoViewer } from './RenderInfoViewer'; import { RenderInfoViewer } from './RenderInfoViewer';
import { CursorView } from './CursorView'; import { CursorView } from './CursorView';
import { StateView } from './StateView';
type Props = PanelProps<DebugPanelOptions>; type Props = PanelProps<DebugPanelOptions>;
export class DebugPanel extends Component<Props> { export class DebugPanel extends Component<Props> {
render() { render() {
const { options } = this.props; const { options } = this.props;
if (options.mode === DebugMode.Events) { if (options.mode === DebugMode.Events) {
return <EventBusLoggerPanel eventBus={this.props.eventBus} />; return <EventBusLoggerPanel eventBus={this.props.eventBus} />;
} }
if (options.mode === DebugMode.Cursor) { if (options.mode === DebugMode.Cursor) {
return <CursorView eventBus={this.props.eventBus} />; return <CursorView eventBus={this.props.eventBus} />;
} }
if (options.mode === DebugMode.State) {
return <StateView {...this.props} />;
}
return <RenderInfoViewer {...this.props} />; return <RenderInfoViewer {...this.props} />;
} }
} }

View File

@@ -0,0 +1,24 @@
import React, { FormEvent } from 'react';
import { PanelOptionsEditorProps, PanelProps } from '@grafana/data';
import { Field, Input, usePanelContext } from '@grafana/ui';
import { DebugPanelOptions } from './types';
export function StateView(props: PanelProps<DebugPanelOptions>) {
const context = usePanelContext();
const onChangeName = (e: FormEvent<HTMLInputElement>) => {
context.onInstanceStateChange!({
name: e.currentTarget.value,
});
};
return (
<Field label="State name">
<Input value={context.instanceState?.name ?? ''} onChange={onChangeName} />
</Field>
);
}
export function StateViewEditor({ value, context, onChange, item }: PanelOptionsEditorProps<string>) {
return <div>Current value: {context.instanceState?.name} </div>;
}

View File

@@ -1,5 +1,6 @@
import { PanelPlugin } from '@grafana/data'; import { PanelPlugin } from '@grafana/data';
import { DebugPanel } from './DebugPanel'; import { DebugPanel } from './DebugPanel';
import { StateViewEditor } from './StateView';
import { DebugMode, DebugPanelOptions } from './types'; import { DebugMode, DebugPanelOptions } from './types';
export const plugin = new PanelPlugin<DebugPanelOptions>(DebugPanel).useFieldConfig().setPanelOptions((builder) => { export const plugin = new PanelPlugin<DebugPanelOptions>(DebugPanel).useFieldConfig().setPanelOptions((builder) => {
@@ -13,9 +14,18 @@ export const plugin = new PanelPlugin<DebugPanelOptions>(DebugPanel).useFieldCon
{ label: 'Render', value: DebugMode.Render }, { label: 'Render', value: DebugMode.Render },
{ label: 'Events', value: DebugMode.Events }, { label: 'Events', value: DebugMode.Events },
{ label: 'Cursor', value: DebugMode.Cursor }, { label: 'Cursor', value: DebugMode.Cursor },
{ label: 'Share state', value: DebugMode.State },
], ],
}, },
}) })
.addCustomEditor({
id: 'stateView',
path: 'stateView',
name: 'State view',
defaultValue: '',
showIf: ({ mode }) => mode === DebugMode.State,
editor: StateViewEditor,
})
.addBooleanSwitch({ .addBooleanSwitch({
path: 'counters.render', path: 'counters.render',
name: 'Render Count', name: 'Render Count',

View File

@@ -12,6 +12,7 @@ export enum DebugMode {
Render = 'render', Render = 'render',
Events = 'events', Events = 'events',
Cursor = 'cursor', Cursor = 'cursor',
State = 'State',
} }
export interface DebugPanelOptions { export interface DebugPanelOptions {

View File

@@ -79,6 +79,7 @@ export interface PanelState {
pluginId: string; pluginId: string;
plugin?: PanelPlugin; plugin?: PanelPlugin;
angularComponent?: AngularComponent | null; angularComponent?: AngularComponent | null;
instanceState?: any | null;
} }
export interface DashboardState { export interface DashboardState {