Scenes: Add panel frame options and visualization options to panel editor (#80884)

This commit is contained in:
kay delaney 2024-01-24 14:01:00 +00:00 committed by GitHub
parent 7218e11e23
commit b2f2864628
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 394 additions and 104 deletions

View File

@ -2554,16 +2554,7 @@ exports[`better eslint`] = {
],
"public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"]
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"]
],
"public/app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@ -138,6 +138,12 @@ export function buildPanelEditScene(panel: VizPanel): PanelEditor {
body: new PanelOptionsPane(vizPanelMgr),
width: '100%',
}),
primaryPaneStyles: {
minWidth: '0',
},
secondaryPaneStyles: {
minWidth: '0',
},
}),
});
}

View File

@ -5,6 +5,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { ButtonGroup, FilterInput, RadioButtonGroup, ToolbarButton, useStyles2 } from '@grafana/ui';
import { getPanelFrameCategory2 } from 'app/features/dashboard/components/PanelEditor/getPanelFrameOptions';
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import { getAllPanelPluginMeta } from 'app/features/panel/state/util';
import { PanelVizTypePicker } from './PanelVizTypePicker';
@ -24,9 +26,27 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
const { panelManager } = model;
const { panel } = panelManager.state;
const { pluginId } = panel.useState();
const { pluginId, options } = panel.useState();
const styles = useStyles2(getStyles);
const [isVizPickerOpen, setVizPickerOpen] = useState(true);
const panelFrameOptions = useMemo(() => getPanelFrameCategory2(panel), [panel]);
const visualizationOptions = useMemo(() => {
const plugin = panel.getPlugin();
if (!plugin) {
return undefined;
}
return getVisualizationOptions2({
panel,
plugin: plugin,
eventBus: panel.getPanelContext().eventBus,
instanceState: panel.getPanelContext().instanceState!,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panel, options]);
const mainBoxElements = [panelFrameOptions.render(), ...(visualizationOptions?.map((v) => v.render()) ?? [])];
return (
<div className={styles.wrapper}>
@ -44,18 +64,23 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
)}
{!isVizPickerOpen && (
<>
<FilterInput value={''} placeholder="Search options" onChange={() => {}} />
<RadioButtonGroup
options={[
{ label: 'All', value: 'All' },
{ label: 'Overrides', value: 'Overrides' },
]}
value={'All'}
fullWidth
></RadioButtonGroup>
{/* <OptionsPaneCategory id="test" title="Panel options">
Placeholder
</OptionsPaneCategory> */}
<div className={styles.top}>
<FilterInput
className={styles.searchOptions}
value={''}
placeholder="Search options"
onChange={() => {}}
/>
<RadioButtonGroup
options={[
{ label: 'All', value: 'All' },
{ label: 'Overrides', value: 'Overrides' },
]}
value={'All'}
fullWidth
></RadioButtonGroup>
</div>
<div className={styles.mainBox}>{mainBoxElements}</div>
</>
)}
</div>
@ -66,20 +91,38 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
function getStyles(theme: GrafanaTheme2) {
return {
top: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(1),
gap: theme.spacing(1),
border: `1px solid ${theme.colors.border.weak}`,
borderBottom: 'none',
borderTopLeftRadius: theme.shape.radius.default,
background: theme.colors.background.primary,
}),
box: css({
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
padding: theme.spacing(1),
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
gap: theme.spacing(1),
overflow: 'hidden',
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
flexGrow: '1',
gap: theme.spacing(2),
}),
mainBox: css({
flexGrow: 1,
background: theme.colors.background.primary,
border: `1px solid ${theme.components.panel.borderColor}`,
borderTop: 'none',
overflow: 'auto',
}),
searchOptions: css({
minHeight: theme.spacing(4),
}),
};
}

View File

@ -49,8 +49,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
padding: theme.spacing(1),
height: '100%',
gap: theme.spacing(1),
border: `1px solid ${theme.colors.border.weak}`,
borderRight: 'none',
borderBottom: 'none',
borderTopLeftRadius: theme.shape.radius.default,
}),
filter: css({
minHeight: theme.spacing(4),

View File

@ -95,7 +95,7 @@ const instance2SettingsMock = {
jest.mock('app/core/store', () => ({
exists: jest.fn(),
get: jest.fn(),
getObject: jest.fn(),
getObject: jest.fn((_a, b) => b),
setObject: jest.fn(),
}));

View File

@ -6,7 +6,11 @@ import { restore, versions } from './__mocks__/dashboardHistoryMocks';
const getMock = jest.fn().mockResolvedValue({});
const postMock = jest.fn().mockResolvedValue({});
jest.mock('app/core/store');
jest.mock('app/core/store', () => ({
get: jest.fn(),
getObject: jest.fn((_a, b) => b),
}));
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');

View File

@ -16,7 +16,7 @@ import { DashboardExporter, LibraryElementExport } from './DashboardExporter';
jest.mock('app/core/store', () => {
return {
getBool: jest.fn(),
getObject: jest.fn(),
getObject: jest.fn((_a, b) => b),
get: jest.fn(),
};
});

View File

@ -22,7 +22,7 @@ export interface OptionsPaneCategoryProps {
sandboxId?: string;
}
const CATEGORY_PARAM_NAME = 'showCategory';
const CATEGORY_PARAM_NAME = 'showCategory' as const;
export const OptionsPaneCategory = React.memo(
({
@ -30,23 +30,21 @@ export const OptionsPaneCategory = React.memo(
title,
children,
forceOpen,
isOpenDefault,
isOpenDefault = true,
renderTitle,
className,
itemsCount,
isNested = false,
sandboxId,
}: OptionsPaneCategoryProps) => {
const initialIsExpanded = isOpenDefault !== false;
const [savedState, setSavedState] = useLocalStorage(getOptionGroupStorageKey(id), {
isExpanded: initialIsExpanded,
isExpanded: isOpenDefault,
});
const styles = useStyles2(getStyles);
const [queryParams, updateQueryParams] = useQueryParams();
const [isExpanded, setIsExpanded] = useState(savedState?.isExpanded ?? initialIsExpanded);
const [isExpanded, setIsExpanded] = useState(savedState?.isExpanded ?? isOpenDefault);
const manualClickTime = useRef(0);
const ref = useRef<HTMLDivElement>(null);
const [queryParams, updateQueryParams] = useQueryParams();
const isOpenFromUrl = queryParams[CATEGORY_PARAM_NAME] === id;
useEffect(() => {
@ -92,13 +90,13 @@ export const OptionsPaneCategory = React.memo(
};
}
const styles = useStyles2(getStyles);
const boxStyles = cx(
{
[styles.box]: true,
[styles.boxNestedExpanded]: isNested && isExpanded,
},
className,
'options-group'
className
);
const headerStyles = cx(styles.header, {
@ -149,61 +147,60 @@ export const OptionsPaneCategory = React.memo(
OptionsPaneCategory.displayName = 'OptionsPaneCategory';
const getStyles = (theme: GrafanaTheme2) => {
return {
box: css`
border-top: 1px solid ${theme.colors.border.weak};
`,
boxNestedExpanded: css`
margin-bottom: ${theme.spacing(2)};
`,
title: css`
flex-grow: 1;
overflow: hidden;
line-height: 1.5;
font-size: 1rem;
padding-left: 6px;
font-weight: ${theme.typography.fontWeightMedium};
margin: 0;
`,
header: css`
display: flex;
cursor: pointer;
align-items: center;
padding: ${theme.spacing(0.5)};
color: ${theme.colors.text.primary};
font-weight: ${theme.typography.fontWeightMedium};
const getStyles = (theme: GrafanaTheme2) => ({
box: css({
borderTop: `1px solid ${theme.colors.border.weak}`,
}),
boxNestedExpanded: css({
marginBottom: theme.spacing(2),
}),
title: css({
flexGrow: 1,
overflow: 'hidden',
lineHeight: 1.5,
fontSize: '1rem',
paddingLeft: '6px',
fontWeight: theme.typography.fontWeightMedium,
margin: 0,
}),
header: css({
display: 'flex',
cursor: 'pointer',
alignItems: 'center',
padding: theme.spacing(0.5),
color: theme.colors.text.primary,
fontWeight: theme.typography.fontWeightMedium,
&:hover {
background: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
}
`,
toggleButton: css`
align-self: baseline;
`,
headerExpanded: css`
color: ${theme.colors.text.primary};
`,
headerNested: css`
padding: ${theme.spacing(0.5, 0, 0.5, 0)};
`,
body: css`
padding: ${theme.spacing(1, 2, 1, 4)};
`,
bodyNested: css`
position: relative;
padding-right: 0;
&:before {
content: '';
position: absolute;
top: 0;
left: 8px;
width: 1px;
height: 100%;
background: ${theme.colors.border.weak};
}
`,
};
};
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
},
}),
toggleButton: css({
alignSelf: 'baseline',
}),
headerExpanded: css({
color: theme.colors.text.primary,
}),
headerNested: css({
padding: theme.spacing(0.5, 0, 0.5, 0),
}),
body: css({
padding: theme.spacing(1, 2, 1, 4),
}),
bodyNested: css({
position: 'relative',
paddingRight: 0,
const getOptionGroupStorageKey = (id: string): string => `${PANEL_EDITOR_UI_STATE_STORAGE_KEY}.optionGroup[${id}]`;
'&:before': {
content: "''",
position: 'absolute',
top: 0,
left: '8px',
width: '1px',
height: '100%',
background: theme.colors.border.weak,
},
}),
});
const getOptionGroupStorageKey = (id: string) => `${PANEL_EDITOR_UI_STATE_STORAGE_KEY}.optionGroup[${id}]`;

View File

@ -15,10 +15,10 @@ export interface OptionsPaneCategoryDescriptorProps {
customRender?: () => React.ReactNode;
sandboxId?: string;
}
/**
* This is not a real React component but an intermediary to enable deep option search without traversing a React node tree.
*/
export class OptionsPaneCategoryDescriptor {
items: OptionsPaneItemDescriptor[] = [];
categories: OptionsPaneCategoryDescriptor[] = [];
@ -41,14 +41,14 @@ export class OptionsPaneCategoryDescriptor {
getCategory(name: string): OptionsPaneCategoryDescriptor {
let sub = this.categories.find((c) => c.props.id === name);
if (sub) {
return sub;
if (!sub) {
sub = new OptionsPaneCategoryDescriptor({
title: name,
id: name,
});
this.addCategory(sub);
}
sub = new OptionsPaneCategoryDescriptor({
title: name,
id: name,
});
this.addCategory(sub);
return sub;
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { DataLinksInlineEditor, Input, RadioButtonGroup, Select, Switch, TextArea } from '@grafana/ui';
import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
@ -171,3 +172,150 @@ export function getPanelFrameCategory(props: OptionPaneRenderProps): OptionsPane
)
);
}
export function getPanelFrameCategory2(panel: VizPanel): OptionsPaneCategoryDescriptor {
const descriptor = new OptionsPaneCategoryDescriptor({
title: 'Panel options',
id: 'Panel options',
isOpenDefault: true,
});
return descriptor
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
return (
<Input
id="PanelFrameTitle"
defaultValue={panel.state.title}
onBlur={(e) => panel.setState({ title: e.currentTarget.value })}
/>
);
},
// addon: config.featureToggles.dashgpt && <GenAIPanelTitleButton onGenerate={setPanelTitle} panel={panel} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Description',
description: panel.state.description,
value: panel.state.description,
render: function renderDescription() {
return (
<TextArea
id="description-text-area"
defaultValue={panel.state.description}
onBlur={(e) => panel.setState({ description: e.currentTarget.value })}
/>
);
},
// addon: config.featureToggles.dashgpt && (
// <GenAIPanelDescriptionButton onGenerate={setPanelDescription} panel={panel} />
// ),
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Transparent background',
render: function renderTransparent() {
return (
<Switch
value={panel.state.displayMode === 'transparent'}
id="transparent-background"
onChange={() => {
panel.setState({
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
});
}}
/>
);
},
})
);
// .addCategory(
// new OptionsPaneCategoryDescriptor({
// title: 'Panel links',
// id: 'Panel links',
// isOpenDefault: false,
// itemsCount: panel.state.links?.length,
// }).addItem(
// new OptionsPaneItemDescriptor({
// title: 'Panel links',
// render: function renderLinks() {
// return (
// <DataLinksInlineEditor
// links={panel.links}
// onChange={(links) => onPanelConfigChange('links', links)}
// getSuggestions={getPanelLinksVariableSuggestions}
// data={[]}
// />
// );
// },
// })
// )
// )
// .addCategory(
// new OptionsPaneCategoryDescriptor({
// title: 'Repeat options',
// id: 'Repeat options',
// isOpenDefault: false,
// })
// .addItem(
// new OptionsPaneItemDescriptor({
// title: 'Repeat by variable',
// description:
// 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.',
// render: function renderRepeatOptions() {
// return (
// <RepeatRowSelect
// id="repeat-by-variable-select"
// repeat={panel.repeat}
// onChange={(value?: string) => {
// onPanelConfigChange('repeat', value);
// }}
// />
// );
// },
// })
// )
// .addItem(
// new OptionsPaneItemDescriptor({
// title: 'Repeat direction',
// showIf: () => !!panel.repeat,
// render: function renderRepeatOptions() {
// const directionOptions = [
// { label: 'Horizontal', value: 'h' },
// { label: 'Vertical', value: 'v' },
// ];
// return (
// <RadioButtonGroup
// options={directionOptions}
// value={panel.repeatDirection || 'h'}
// onChange={(value) => onPanelConfigChange('repeatDirection', value)}
// />
// );
// },
// })
// )
// .addItem(
// new OptionsPaneItemDescriptor({
// title: 'Max per row',
// showIf: () => Boolean(panel.repeat && panel.repeatDirection === 'h'),
// render: function renderOption() {
// const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value }));
// return (
// <Select
// options={maxPerRowOptions}
// value={panel.maxPerRow}
// onChange={(value) => onPanelConfigChange('maxPerRow', value.value)}
// />
// );
// },
// })
// )
// );
}

View File

@ -5,6 +5,7 @@ import {
EventBus,
InterpolateFunction,
PanelData,
PanelPlugin,
StandardEditorContext,
VariableSuggestionsScope,
} from '@grafana/data';
@ -14,6 +15,7 @@ import {
NestedValueAccess,
PanelOptionsEditorBuilder,
} from '@grafana/data/src/utils/OptionsUIBuilders';
import { VizPanel } from '@grafana/scenes';
import { getDataLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
import { OptionsPaneCategoryDescriptor } from './OptionsPaneCategoryDescriptor';
@ -146,6 +148,100 @@ export function getVisualizationOptions(props: OptionPaneRenderProps): OptionsPa
return Object.values(categoryIndex);
}
export interface OptionPaneRenderProps2 {
panel: VizPanel;
eventBus: EventBus;
plugin: PanelPlugin;
data?: PanelData;
instanceState: unknown;
}
export function getVisualizationOptions2(props: OptionPaneRenderProps2): OptionsPaneCategoryDescriptor[] {
const { plugin, panel, data, eventBus, instanceState } = props;
const categoryIndex: Record<string, OptionsPaneCategoryDescriptor> = {};
const getOptionsPaneCategory = (categoryNames?: string[]): OptionsPaneCategoryDescriptor => {
const categoryName = categoryNames?.[0] ?? plugin.meta.name;
const category = categoryIndex[categoryName];
if (category) {
return category;
}
return (categoryIndex[categoryName] = new OptionsPaneCategoryDescriptor({
title: categoryName,
id: categoryName,
sandboxId: plugin.meta.id,
}));
};
const currentOptions = panel.state.options;
const access: NestedValueAccess = {
getValue: (path) => lodashGet(currentOptions, path),
onChange: (path, value) => {
const newOptions = setOptionImmutably(currentOptions, path, value);
panel.onOptionsChange(newOptions);
},
};
const context = getStandardEditorContext({
data,
replaceVariables: panel.interpolate,
options: currentOptions,
eventBus: eventBus,
instanceState,
});
// Load the options into categories
fillOptionsPaneItems(plugin.getPanelOptionsSupplier(), access, getOptionsPaneCategory, context);
// Field options
const currentFieldConfig = panel.state.fieldConfig;
for (const fieldOption of plugin.fieldConfigRegistry.list()) {
const hideOption =
fieldOption.showIf &&
(fieldOption.isCustom
? !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series)
: !fieldOption.showIf(currentFieldConfig.defaults, data?.series));
if (fieldOption.hideFromDefaults || hideOption) {
continue;
}
const category = getOptionsPaneCategory(fieldOption.category);
const Editor = fieldOption.editor;
const defaults = currentFieldConfig.defaults;
const value = fieldOption.isCustom
? defaults.custom
? lodashGet(defaults.custom, fieldOption.path)
: undefined
: lodashGet(defaults, fieldOption.path);
if (fieldOption.getItemsCount) {
category.props.itemsCount = fieldOption.getItemsCount(value);
}
category.addItem(
new OptionsPaneItemDescriptor({
title: fieldOption.name,
description: fieldOption.description,
overrides: getOptionOverrides(fieldOption, currentFieldConfig, data?.series),
render: function renderEditor() {
const onChange = (v: unknown) => {
panel.onFieldConfigChange(
updateDefaultFieldConfigValue(currentFieldConfig, fieldOption.path, v, fieldOption.isCustom)
);
};
return <Editor value={value} onChange={onChange} item={fieldOption} context={context} id={fieldOption.id} />;
},
})
);
}
return Object.values(categoryIndex);
}
/**
* This will iterate all options panes and add register them with the configured categories
*

View File

@ -8,7 +8,7 @@ import {
// Mock the store module
jest.mock('app/core/store', () => ({
exists: jest.fn(),
getObject: jest.fn(),
getObject: jest.fn((_a, b) => b),
setObject: jest.fn(),
get: jest.fn(),
}));