Suggestions: improve styling for new version (#114381)

* Suggestions: hashes on suggestions, update logic to select first suggestion

* fix types

* Suggestions: New UI style updates

* update some styles

* getting styles just right

* remove grouping when not on flag

* adjust minimum width for sidebar

* CI cleanups

* updates from ad hoc review

* add loading and error states to suggestions

* remove unused import

* update header ui for panel editor

* restore back button to vizpicker

* fix e2e test

* fix e2e

* add i18n update

* use new util for setVisualization operation

* Apply suggestions from code review

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>

* comments from review

* updates from review

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Paul Marbach
2025-12-02 11:36:46 -08:00
committed by GitHub
co-authored by Torkel Ödegaard
parent 227b596a46
commit 021e0c6da0
16 changed files with 370 additions and 216 deletions
@@ -2,17 +2,18 @@ import { Locator } from '@playwright/test';
import { test, expect } from '@grafana/plugin-e2e';
import { setVisualization } from './vizpicker-utils';
test.use({
featureToggles: {
canvasPanelPanZoom: true,
},
});
test.describe('Canvas Panel - Scene Tests', () => {
test.beforeEach(async ({ page, gotoDashboardPage }) => {
test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({});
const panelEditPage = await dashboardPage.addPanel();
await panelEditPage.setVisualization('Canvas');
await setVisualization(panelEditPage, 'Canvas', selectors);
// Wait for canvas panel to load
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
@@ -58,7 +58,7 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Queries')).click();
// Check that Time series is chosen
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker)).toContainText(
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header)).toHaveText(
'Time series'
);
@@ -71,9 +71,10 @@ test.describe(
// Change to Text panel
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations')).click(); // <-- should only need to do this once thanks to the session storage
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item('Text')).click();
// Check current visualization shows Text
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker)).toContainText(
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header)).toHaveText(
'Text'
);
@@ -84,7 +85,7 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item('Table')).click();
// Check current visualization shows Table
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker)).toContainText(
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header)).toHaveText(
'Table'
);
@@ -0,0 +1,24 @@
import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e';
// this replaces the panelEditPage.setVisualization method used previously in tests, since it
// does not know how to use the updated 12.4 viz picker UI to set the visualization
export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => {
const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
await expect(vizPicker, '"Change" button should be visible').toBeVisible();
await vizPicker.click();
const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations'));
await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible();
await allVizTabBtn.click();
const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName));
await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible();
await vizItem.scrollIntoViewIfNeeded();
await vizItem.click();
await expect(vizPicker, '"Change" button should be visible again').toBeVisible();
await expect(
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
'Panel header should have the new viz type name'
).toHaveText(vizName);
};
@@ -1,5 +1,6 @@
import { expect, test } from '@grafana/plugin-e2e';
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
import { formatExpectError } from '../errors';
import { successfulDataQuery } from '../mocks/queries';
@@ -24,10 +25,10 @@ test.describe(
).toContainText(['Field', 'Max', 'Mean', 'Last']);
});
test('table panel data assertions', async ({ panelEditPage }) => {
test('table panel data assertions', async ({ panelEditPage, selectors }) => {
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
await panelEditPage.datasource.set('gdev-testdata');
await panelEditPage.setVisualization('Table');
await setVisualization(panelEditPage, 'Table', selectors);
await panelEditPage.refreshPanel();
await expect(
panelEditPage.panel.locator,
@@ -43,10 +44,10 @@ test.describe(
).toContainText(['val1', 'val2', 'val3', 'val4']);
});
test('timeseries panel - table view assertions', async ({ panelEditPage }) => {
test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => {
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
await panelEditPage.datasource.set('gdev-testdata');
await panelEditPage.setVisualization('Time series');
await setVisualization(panelEditPage, 'Time series', selectors);
await panelEditPage.refreshPanel();
await panelEditPage.toggleTableView();
await expect(
@@ -1,5 +1,6 @@
import { expect, test } from '@grafana/plugin-e2e';
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
import { formatExpectError } from '../errors';
import { successfulDataQuery } from '../mocks/queries';
import { scenarios } from '../mocks/resources';
@@ -53,10 +54,10 @@ test.describe(
).toHaveText(scenarios.map((s) => s.name));
});
test('mocked query data response', async ({ panelEditPage, page }) => {
test('mocked query data response', async ({ panelEditPage, page, selectors }) => {
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
await panelEditPage.datasource.set('gdev-testdata');
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
await panelEditPage.refreshPanel();
await expect(
panelEditPage.panel.getErrorIcon(),
@@ -75,9 +76,9 @@ test.describe(
selectors,
page,
}) => {
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
await expect(
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker),
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
formatExpectError('Expected panel visualization to be set to table')
).toHaveText(TABLE_VIZ_NAME);
await panelEditPage.setPanelTitle(PANEL_TITLE);
@@ -92,8 +93,8 @@ test.describe(
).toBeVisible();
});
test('Select time zone in timezone picker', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const axisOptions = await panelEditPage.getCustomOptions('Axis');
const timeZonePicker = axisOptions.getSelect('Time zone');
@@ -101,8 +102,8 @@ test.describe(
await expect(timeZonePicker).toHaveSelected('Europe/Stockholm');
});
test('select unit in unit picker', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('select unit in unit picker', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const standardOptions = panelEditPage.getStandardOptions();
const unitPicker = standardOptions.getUnitPicker('Unit');
@@ -111,8 +112,8 @@ test.describe(
await expect(unitPicker).toHaveSelected('Pixels');
});
test('enter value in number input', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('enter value in number input', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const axisOptions = panelEditPage.getCustomOptions('Axis');
const lineWith = axisOptions.getNumberInput('Soft min');
@@ -121,8 +122,8 @@ test.describe(
await expect(lineWith).toHaveValue('10');
});
test('enter value in slider', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('enter value in slider', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const graphOptions = panelEditPage.getCustomOptions('Graph styles');
const lineWidth = graphOptions.getSliderInput('Line width');
@@ -131,8 +132,8 @@ test.describe(
await expect(lineWidth).toHaveValue('10');
});
test('select value in single value select', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('select value in single value select', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const standardOptions = panelEditPage.getStandardOptions();
const colorSchemeSelect = standardOptions.getSelect('Color scheme');
@@ -140,8 +141,8 @@ test.describe(
await expect(colorSchemeSelect).toHaveSelected('Classic palette');
});
test('clear input', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('clear input', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const panelOptions = panelEditPage.getPanelOptions();
const title = panelOptions.getTextInput('Title');
@@ -150,8 +151,8 @@ test.describe(
await expect(title).toHaveValue('');
});
test('enter value in input', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('enter value in input', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const panelOptions = panelEditPage.getPanelOptions();
const description = panelOptions.getTextInput('Description');
@@ -160,8 +161,8 @@ test.describe(
await expect(description).toHaveValue('This is a panel');
});
test('unchecking switch', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('unchecking switch', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const axisOptions = panelEditPage.getCustomOptions('Axis');
const showBorder = axisOptions.getSwitch('Show border');
@@ -173,8 +174,8 @@ test.describe(
await expect(showBorder).toBeChecked({ checked: false });
});
test('checking switch', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('checking switch', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const axisOptions = panelEditPage.getCustomOptions('Axis');
const showBorder = axisOptions.getSwitch('Show border');
@@ -183,8 +184,8 @@ test.describe(
await expect(showBorder).toBeChecked();
});
test('re-selecting value in radio button group', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const axisOptions = panelEditPage.getCustomOptions('Axis');
const placement = axisOptions.getRadioGroup('Placement');
@@ -195,8 +196,8 @@ test.describe(
await expect(placement).toHaveChecked('Auto');
});
test('selecting value in radio button group', async ({ panelEditPage }) => {
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
test('selecting value in radio button group', async ({ panelEditPage, selectors }) => {
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
const axisOptions = panelEditPage.getCustomOptions('Axis');
const placement = axisOptions.getRadioGroup('Placement');
@@ -45,7 +45,9 @@ test.describe(
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click();
// Verify panel type is selected
await expect(vizPicker).toHaveText(panel.name, { timeout: 10000 });
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header)
).toHaveText(panel.name, { timeout: 10000 });
// Wait for panel to finish rendering
await expect(page.getByLabel('Panel loading bar')).toHaveCount(0, { timeout: 10000 });
@@ -30,7 +30,7 @@ describe('Panels smokescreen', () => {
e2e.components.PanelEditor.toggleVizPicker().click();
e2e.components.PluginVisualization.item(panel.name).scrollIntoView().should('be.visible').click();
e2e.components.PanelEditor.toggleVizPicker().should((e) => expect(e).to.contain(panel.name));
e2e.components.PanelEditor.OptionsPane.content().should((e) => expect(e).to.contain(panel.name));
// TODO: Come up with better check / better failure messaging to clearly indicate which panel failed
cy.contains('An unexpected error happened').should('not.exist');
}
@@ -583,6 +583,9 @@ export const versionedComponents = {
'11.1.0': 'data-testid Panel editor option pane content',
[MIN_GRAFANA_VERSION]: 'Panel editor option pane content',
},
header: {
'12.4.0': 'data-testid Panel editor OptionsPane header',
},
select: {
[MIN_GRAFANA_VERSION]: 'Panel editor option pane select',
},
@@ -257,6 +257,7 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
searchQuery: '',
listMode: OptionFilter.All,
isVizPickerOpen: isUnconfigured,
isNewPanel: this.state.isNewPanel,
}),
isInitializing: false,
});
@@ -1,11 +1,12 @@
import { css, cx } from '@emotion/css';
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { SceneComponentProps, VizPanel } from '@grafana/scenes';
import { Button, Spinner, ToolbarButton, useStyles2 } from '@grafana/ui';
import { Button, Spinner, ToolbarButton, useStyles2, useTheme2 } from '@grafana/ui';
import { MIN_SUGGESTIONS_PANE_WIDTH } from 'app/features/panel/suggestions/constants';
import { useEditPaneCollapsed } from '../edit-pane/shared';
import { NavToolbarActions } from '../scene/NavToolbarActions';
@@ -25,6 +26,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
const isScrollingLayout = useScrollReflowLimit();
const theme = useTheme2();
const panePadding = useMemo(() => +theme.spacing(2).replace(/px$/, ''), [theme]);
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
useSnappingSplitter({
direction: 'row',
@@ -32,7 +35,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
initialSize: 330,
usePixels: true,
collapsed: isInitiallyCollapsed,
collapseBelowPixels: 250,
collapseBelowPixels: MIN_SUGGESTIONS_PANE_WIDTH + panePadding,
disabled: isScrollingLayout,
});
@@ -12,8 +12,8 @@ import {
SelectableValue,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { locationService, reportInteraction } from '@grafana/runtime';
import { t, Trans } from '@grafana/i18n';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import {
DeepPartial,
SceneComponentProps,
@@ -23,7 +23,7 @@ import {
VizPanel,
sceneGraph,
} from '@grafana/scenes';
import { Button, FilterInput, ScrollContainer, Stack, ToolbarButton, useStyles2, Field } from '@grafana/ui';
import { Button, FilterInput, ScrollContainer, Stack, ToolbarButton, useStyles2, Text } from '@grafana/ui';
import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions';
import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError';
import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types';
@@ -39,6 +39,8 @@ export interface PanelOptionsPaneState extends SceneObjectState {
searchQuery: string;
listMode: OptionFilter;
panelRef: SceneObjectRef<VizPanel>;
isNewPanel?: boolean;
hasPickedViz?: boolean;
}
interface PluginOptionsCache {
@@ -50,11 +52,15 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
private _cachedPluginOptions: Record<string, PluginOptionsCache | undefined> = {};
onToggleVizPicker = () => {
const newState = !this.state.isVizPickerOpen;
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.TOGGLE_DROPDOWN,
open: !this.state.isVizPickerOpen,
open: newState,
});
this.setState({
isVizPickerOpen: newState,
hasPickedViz: this.state.hasPickedViz || newState === false,
});
this.setState({ isVizPickerOpen: !this.state.isVizPickerOpen });
};
onChangePanelPlugin = (options: VizTypeChangeDetails) => {
@@ -131,7 +137,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
}
function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPane>) {
const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState();
const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz } = model.useState();
const panel = panelRef.resolve();
const { pluginId } = panel.useState();
const { data } = sceneGraph.getData(panel).useState();
@@ -142,34 +148,65 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
const onlyOverrides = listMode === OptionFilter.Overrides;
const isScrollingLayout = useScrollReflowLimit();
const pluginMeta: PanelPluginMeta = useMemo(() => {
let meta = getAllPanelPluginMeta().filter((p) => p.id === pluginId)[0];
if (!meta) {
const notFound = getPanelPluginNotFound(`Panel plugin not found (${pluginId})`, true);
meta = notFound.meta;
}
return meta;
}, [pluginId]);
return (
<>
{!isVizPickerOpen && (
<>
<div className={styles.top}>
<Field label={t('dashboard.panel-edit.visualization-button-label', 'Visualization')} noMargin>
<Stack gap={1}>
<VisualizationButton pluginId={pluginId} onOpen={model.onToggleVizPicker} />
<Button
icon="search"
variant="secondary"
onClick={setIsSearchingOptions}
tooltip={t('dashboard.panel-edit.visualization-button-tooltip', 'Search options')}
/>
{hasFieldConfig && (
<ToolbarButton
icon="filter"
tooltip={t('dashboard.panel-edit.only-overrides-button-tooltip', 'Show only overrides')}
variant={onlyOverrides ? 'active' : 'canvas'}
onClick={() => {
model.onSetListMode(onlyOverrides ? OptionFilter.All : OptionFilter.Overrides);
}}
/>
<Stack gap={1}>
<img alt={pluginMeta.name} src={pluginMeta.info.logos.small} className={styles.pluginIcon} />
<Text
data-testid={selectors.components.PanelEditor.OptionsPane.header}
element="h3"
variant="body"
weight="medium"
truncate
>
{pluginMeta.name}
</Text>
<Button
size="sm"
fill="text"
onClick={model.onToggleVizPicker}
data-testid={selectors.components.PanelEditor.toggleVizPicker}
aria-label={t(
'dashboard-scene.visualization-button.aria-label-change-visualization',
'Change visualization'
)}
</Stack>
</Field>
{isSearchingOptions && (
>
<Trans i18nKey="dashboard-scene.visualization-button.text">Change</Trans>
</Button>
</Stack>
<Stack gap={1}>
{hasFieldConfig && (
<ToolbarButton
icon="sliders-v-alt"
tooltip={t('dashboard.panel-edit.only-overrides-button-tooltip', 'Show only overrides')}
variant={onlyOverrides ? 'active' : 'canvas'}
onClick={() => {
model.onSetListMode(onlyOverrides ? OptionFilter.All : OptionFilter.Overrides);
}}
/>
)}
<Button
icon="search"
variant="secondary"
onClick={setIsSearchingOptions}
tooltip={t('dashboard.panel-edit.visualization-button-tooltip', 'Search options')}
/>
</Stack>
</div>
{isSearchingOptions && (
<div className={styles.searchWrapper}>
<FilterInput
className={styles.searchOptions}
value={searchQuery}
@@ -182,8 +219,8 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
}
}}
/>
)}
</div>
</div>
)}
<ScrollContainer minHeight={isScrollingLayout ? 'max-content' : 0}>
<PanelOptions panel={panel} searchQuery={searchQuery} listMode={listMode} data={data} />
</ScrollContainer>
@@ -195,6 +232,7 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
onChange={model.onChangePanelPlugin}
onClose={model.onToggleVizPicker}
data={data}
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
/>
)}
</>
@@ -205,63 +243,24 @@ function getStyles(theme: GrafanaTheme2) {
return {
top: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(1, 2, 2, 2),
flexDirection: 'row',
padding: theme.spacing(1, 2),
gap: theme.spacing(2),
justifyContent: 'space-between',
alignItems: 'center',
}),
searchOptions: css({
minHeight: theme.spacing(4),
}),
searchWrapper: css({
padding: theme.spacing(2, 2, 2, 0),
padding: theme.spacing(1, 2, 2, 2),
}),
rotateIcon: css({
rotate: '180deg',
}),
};
}
interface VisualizationButtonProps {
pluginId: string;
onOpen: () => void;
}
export function VisualizationButton({ pluginId, onOpen }: VisualizationButtonProps) {
const styles = useStyles2(getVizButtonStyles);
let pluginMeta: PanelPluginMeta | undefined = useMemo(
() => getAllPanelPluginMeta().filter((p) => p.id === pluginId)[0],
[pluginId]
);
if (!pluginMeta) {
const notFound = getPanelPluginNotFound(`Panel plugin not found (${pluginId})`, true);
pluginMeta = notFound.meta;
}
return (
<ToolbarButton
className={styles.vizButton}
tooltip={t(
'dashboard-scene.visualization-button.tooltip-click-to-change-visualization',
'Click to change visualization'
)}
imgSrc={pluginMeta.info.logos.small}
onClick={onOpen}
data-testid={selectors.components.PanelEditor.toggleVizPicker}
aria-label={t('dashboard-scene.visualization-button.aria-label-change-visualization', 'Change visualization')}
variant="canvas"
isOpen={false}
fullWidth
>
{pluginMeta.name}
</ToolbarButton>
);
}
function getVizButtonStyles(theme: GrafanaTheme2) {
return {
vizButton: css({
textAlign: 'left',
pluginIcon: css({
height: '22px',
width: '22px',
}),
};
}
@@ -4,10 +4,11 @@ import { useCallback, useMemo, useState } from 'react';
import { useSessionStorage } from 'react-use';
import { GrafanaTheme2, PanelData } from '@grafana/data';
import { t } from '@grafana/i18n';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { FilterInput, ScrollContainer, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { Button, FilterInput, ScrollContainer, Stack, Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types';
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
@@ -20,6 +21,7 @@ import { INTERACTION_EVENT_NAME, INTERACTION_ITEM } from './interaction';
export interface Props {
data?: PanelData;
showBackButton?: boolean;
panel: VizPanel;
onChange: (options: VizTypeChangeDetails) => void;
onClose: () => void;
@@ -39,7 +41,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
: [allVisualizationsTab, suggestionsTab];
};
export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) {
export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) {
const styles = useStyles2(getStyles);
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
@@ -60,9 +62,6 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) {
}, 300),
[]
);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
};
/** TABS */
const tabs = useMemo(getTabs, []);
@@ -84,15 +83,6 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) {
return (
<div className={styles.wrapper}>
{/*@TODO: Re-enable/move close button*/}
{/*<Button*/}
{/* aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}*/}
{/* variant="secondary"*/}
{/* icon="angle-up"*/}
{/* className={styles.closeButton}*/}
{/* data-testid={selectors.components.PanelEditor.toggleVizPicker}*/}
{/* onClick={onClose}*/}
{/*/>*/}
<TabsBar className={styles.tabs} hideBorder={true}>
{tabs.map((tab) => (
<Tab
@@ -105,27 +95,39 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) {
))}
</TabsBar>
<ScrollContainer>
<TabContent>
<TabContent className={styles.tabContent}>
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions onChange={onChange} panel={panelModel} data={data} />
)}
{listMode === VisualizationSelectPaneTab.Visualizations && (
<>
<div className={styles.searchRow}>
<Stack gap={1} direction="column">
<Stack direction="row" gap={1}>
{showBackButton && (
<Button
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
fill="text"
variant="secondary"
icon="arrow-left"
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onClose}
>
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
</Button>
)}
<FilterInput
className={styles.filter}
value={searchQuery}
onChange={handleSearchChange}
onChange={setSearchQuery}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
</div>
</Stack>
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
trackSearch={trackSearch}
onChange={onChange}
/>
</>
</Stack>
)}
</TabContent>
</ScrollContainer>
@@ -138,7 +140,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
padding: theme.spacing(2, 1),
height: '100%',
gap: theme.spacing(2),
}),
@@ -154,6 +155,9 @@ const getStyles = (theme: GrafanaTheme2) => ({
justifyContent: 'center',
textAlign: 'center',
}),
tabContent: css({
paddingInline: theme.spacing(2),
}),
closeButton: css({
marginLeft: 'auto',
}),
@@ -1,8 +1,8 @@
import { css, cx } from '@emotion/css';
import { cloneDeep } from 'lodash';
import { CSSProperties, HTMLAttributes } from 'react';
import { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data';
import { colorManipulator, GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { Tooltip, useStyles2 } from '@grafana/ui';
@@ -16,7 +16,14 @@ export interface Props extends HTMLAttributes<HTMLButtonElement> {
isSelected?: boolean;
}
export function VisualizationSuggestionCard({ data, suggestion, width, isSelected = false, onClick }: Props) {
export function VisualizationSuggestionCard({
data,
suggestion,
width,
isSelected = false,
className,
...restProps
}: Props) {
const styles = useStyles2(getStyles);
const { innerStyles, outerStyles, renderWidth, renderHeight } = getPreviewDimensionsAndStyles(width);
const cardOptions = suggestion.cardOptions ?? {};
@@ -24,35 +31,30 @@ export function VisualizationSuggestionCard({ data, suggestion, width, isSelecte
const commonButtonProps = {
'aria-label': suggestion.name,
className: cx(styles.vizBox, isNewVizSuggestionsEnabled && isSelected && styles.selectedBox),
className: cx(className, styles.vizBox),
'data-testid': selectors.components.VisualizationPreview.card(suggestion.name),
style: outerStyles,
onClick,
...restProps,
};
let content: ReactNode;
if (cardOptions.imgSrc) {
return (
<Tooltip content={suggestion.description ?? suggestion.name}>
<button
{...commonButtonProps}
className={cx(styles.vizBox, styles.imgBox, isNewVizSuggestionsEnabled && isSelected && styles.selectedBox)}
>
<div className={styles.name}>{suggestion.name}</div>
<img className={styles.img} src={cardOptions.imgSrc} alt={suggestion.name} />
</button>
</Tooltip>
content = (
<button {...commonButtonProps} className={cx(commonButtonProps.className, styles.imgBox)}>
<div className={styles.name}>{suggestion.name}</div>
<img className={styles.img} src={cardOptions.imgSrc} alt={suggestion.name} />
</button>
);
}
} else {
let preview = suggestion;
if (suggestion.cardOptions?.previewModifier) {
preview = cloneDeep(suggestion);
suggestion.cardOptions.previewModifier(preview);
}
let preview = suggestion;
if (suggestion.cardOptions?.previewModifier) {
preview = cloneDeep(suggestion);
suggestion.cardOptions.previewModifier(preview);
}
return (
<button {...commonButtonProps}>
<Tooltip content={suggestion.name}>
content = (
<button {...commonButtonProps}>
<div style={innerStyles} className={styles.renderContainer}>
<PanelRenderer
title=""
@@ -63,22 +65,38 @@ export function VisualizationSuggestionCard({ data, suggestion, width, isSelecte
options={preview.options}
fieldConfig={preview.fieldConfig}
/>
<div className={styles.hoverPane} />
{/* this prevents interaction with the underlying panel. */}
<div className={cx(styles.hoverPane, isSelected && styles.hoverPaneSelected)} />
</div>
</Tooltip>
</button>
);
</button>
);
}
if (!isNewVizSuggestionsEnabled) {
return <Tooltip content={suggestion.description ?? suggestion.name}>{content}</Tooltip>;
}
return content;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
hoverPane: css({
position: 'absolute',
top: 0,
right: 0,
left: 0,
borderRadius: theme.spacing(2),
bottom: 0,
top: -4,
left: -4,
right: -2,
bottom: -2,
borderRadius: theme.spacing(0.5),
background: 'transparent',
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create(['background'], {
duration: theme.transitions.duration.short,
}),
},
}),
hoverPaneSelected: css({
background: colorManipulator.alpha(theme.colors.text.primary, 0.1),
}),
vizBox: css({
position: 'relative',
@@ -97,10 +115,6 @@ const getStyles = (theme: GrafanaTheme2) => {
background: theme.colors.background.secondary,
},
}),
selectedBox: css({
border: `2px solid ${theme.colors.primary.main}`,
boxShadow: `0 0 0 1px ${theme.colors.primary.main}`,
}),
imgBox: css({
display: 'flex',
flexDirection: 'column',
@@ -1,14 +1,21 @@
import { css } from '@emotion/css';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAsync, useMeasure } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data';
import {
GrafanaTheme2,
PanelData,
PanelModel,
PanelPluginMeta,
PanelPluginVisualizationSuggestion,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Button, Icon, Text, useStyles2 } from '@grafana/ui';
import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui';
import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel';
import { getAllPanelPluginMeta } from '../../state/util';
import { MIN_MULTI_COLUMN_SIZE } from '../../suggestions/constants';
import { getAllSuggestions } from '../../suggestions/getAllSuggestions';
import { hasData } from '../../suggestions/utils';
@@ -21,19 +28,35 @@ export interface Props {
panel?: PanelModel;
}
const MIN_COLUMN_SIZE = 260;
export function VisualizationSuggestions({ onChange, data, panel }: Props) {
const styles = useStyles2(getStyles);
const { value: suggestions } = useAsync(async () => await getAllSuggestions(data), [data]);
const { value: suggestions, loading, error } = useAsync(() => getAllSuggestions(data), [data]);
const [suggestionHash, setSuggestionHash] = useState<string | null>(null);
const [firstCardRef, { width }] = useMeasure<HTMLDivElement>();
const [firstCardHash, setFirstCardHash] = useState<string | null>(null);
const isNewVizSuggestionsEnabled = config.featureToggles.newVizSuggestions;
const isUnconfiguredPanel = panel?.type === UNCONFIGURED_PANEL_PLUGIN_ID;
const suggestionsByVizType = useMemo(() => {
const meta = getAllPanelPluginMeta();
const record: Record<string, PanelPluginMeta> = {};
for (const m of meta) {
record[m.id] = m;
}
const result: Array<[PanelPluginMeta | undefined, PanelPluginVisualizationSuggestion[]]> = [];
let currentVizType: PanelPluginMeta | undefined = undefined;
for (const suggestion of suggestions || []) {
const vizType = record[suggestion.pluginId];
if (!currentVizType || currentVizType.id !== vizType?.id) {
currentVizType = vizType;
result.push([vizType, []]);
}
result[result.length - 1][1].push(suggestion);
}
return result;
}, [suggestions]);
const applySuggestion = useCallback(
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
onChange({
@@ -66,19 +89,35 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
}
}, [suggestions, suggestionHash, firstCardHash, isNewVizSuggestionsEnabled, isUnconfiguredPanel, applySuggestion]);
const renderEmptyState = () => (
<div className={styles.emptyStateWrapper}>
<Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.new-panel.suggestions.empty-state-message">
Run a query to start seeing suggested visualizations
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spinner size="xxl" />
</div>
);
}
if (error) {
return (
<Alert title={t('panel.visualization-suggestions.error-loading-suggestions.title', 'Error')} severity="error">
<Trans i18nKey="panel.visualization-suggestions.error-loading-suggestions.message">
An error occurred when loading visualization suggestions.
</Trans>
</Text>
</div>
);
</Alert>
);
}
if (isNewVizSuggestionsEnabled && (!data || !hasData(data))) {
return renderEmptyState();
return (
<div className={styles.emptyStateWrapper}>
<Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.new-panel.suggestions.empty-state-message">
Run a query to start seeing suggested visualizations
</Trans>
</Text>
</div>
);
}
if (!data) {
@@ -86,28 +125,40 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
}
return (
// This div is needed in some places to make AutoSizer work
<div>
<AutoSizer disableHeight style={{ width: '100%', height: '100%' }}>
{() => (
<div>
<div className={styles.grid}>
{suggestions?.map((suggestion, index) => {
const isCardSelected = isNewVizSuggestionsEnabled && suggestionHash === suggestion.hash;
<div className={styles.grid}>
{isNewVizSuggestionsEnabled
? suggestionsByVizType.map(([vizType, vizTypeSuggestions]) => (
<>
<div className={styles.vizTypeHeader} key={vizType?.id || 'unknown-viz-type'}>
<Text variant="body" weight="medium">
{vizType?.info && <img className={styles.vizTypeLogo} src={vizType.info.logos.small} alt="" />}
{vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')}
</Text>
</div>
{vizTypeSuggestions?.map((suggestion, index) => {
const isCardSelected = suggestionHash === suggestion.hash;
return (
<div key={index} className={styles.cardContainer} ref={index === 0 ? firstCardRef : undefined}>
<div
key={suggestion.hash}
className={styles.cardContainer}
ref={index === 0 ? firstCardRef : undefined}
>
{isCardSelected && (
<Button
variant="primary"
size={'md'}
onClick={() => applySuggestion(suggestion)}
className={styles.applySuggestionButton}
aria-label={t(
'panel.visualization-suggestions.apply-suggestion-aria-label',
'Apply {{suggestionName}} visualization',
{ suggestionName: suggestion.name }
)}
onClick={() =>
onChange({
pluginId: suggestion.pluginId,
withModKey: false,
})
}
>
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
</Button>
@@ -117,21 +168,39 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
suggestion={suggestion}
width={width}
isSelected={isCardSelected}
onClick={() => applySuggestion(suggestion, isNewVizSuggestionsEnabled)}
tabIndex={index}
onClick={() => applySuggestion(suggestion, true)}
/>
</div>
);
})}
</>
))
: suggestions?.map((suggestion, index) => (
<div key={suggestion.hash} className={styles.cardContainer} ref={index === 0 ? firstCardRef : undefined}>
<VisualizationSuggestionCard
key={index}
data={data}
suggestion={suggestion}
width={width}
tabIndex={index}
onClick={() => applySuggestion(suggestion)}
/>
</div>
</div>
)}
</AutoSizer>
))}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
loadingContainer: css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
marginTop: theme.spacing(6),
}),
filterRow: css({
display: 'flex',
flexDirection: 'row',
@@ -147,7 +216,7 @@ const getStyles = (theme: GrafanaTheme2) => {
grid: css({
display: 'grid',
gridGap: theme.spacing(1),
gridTemplateColumns: `repeat(auto-fit, minmax(${MIN_COLUMN_SIZE}px, 1fr))`,
gridTemplateColumns: `repeat(auto-fit, minmax(${MIN_MULTI_COLUMN_SIZE}px, 1fr))`,
marginBottom: theme.spacing(1),
justifyContent: 'space-evenly',
}),
@@ -167,13 +236,29 @@ const getStyles = (theme: GrafanaTheme2) => {
cardContainer: css({
position: 'relative',
}),
vizTypeHeader: css({
gridColumn: '1 / -1',
marginBottom: theme.spacing(0.5),
marginTop: theme.spacing(2),
'&:first-of-type': {
marginTop: 0,
},
}),
vizTypeLogo: css({
filter: 'grayscale(100%)',
maxHeight: `${theme.typography.body.lineHeight}em`,
width: `${theme.typography.body.lineHeight}em`,
alignItems: 'center',
display: 'inline-block',
marginRight: theme.spacing(1),
}),
applySuggestionButton: css({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 10,
padding: '0 16px',
padding: theme.spacing(0, 2),
}),
};
};
@@ -0,0 +1,8 @@
// we overall want the suggestions pane to be at least 260px wide (plus padding, which we will
// get from the theme).
export const MIN_SUGGESTIONS_PANE_WIDTH = 260;
// when the layout expands to multi-column, this is the minimum width of each column.
// this value ensures that the columns are never larger than 440px - the suggestions look
// weird if they get too wide.
export const MIN_MULTI_COLUMN_SIZE = 220;
+9 -2
View File
@@ -5184,7 +5184,6 @@
},
"only-overrides-button-tooltip": "Show only overrides",
"placeholder-search-options": "Search options",
"visualization-button-label": "Visualization",
"visualization-button-tooltip": "Search options"
},
"panel-editor-table-view": {
@@ -6223,6 +6222,9 @@
}
},
"panel-viz-type-picker": {
"button": {
"close": "Back"
},
"placeholder-search-for": "Search for...",
"radio-options": {
"label": {
@@ -6534,7 +6536,7 @@
},
"visualization-button": {
"aria-label-change-visualization": "Change visualization",
"tooltip-click-to-change-visualization": "Click to change visualization"
"text": "Change"
},
"viz-and-data-pane": {
"aria-label-open-query-pane": "Open query pane",
@@ -11089,6 +11091,11 @@
},
"visualization-suggestions": {
"apply-suggestion-aria-label": "Apply {{suggestionName}} visualization",
"error-loading-suggestions": {
"message": "An error occurred when loading visualization suggestions.",
"title": "Error"
},
"unknown-viz-type": "Unknown visualization type",
"use-this-suggestion": "Use this suggestion"
},
"viz-type-picker": {