Dashboard Scene: Improve Angular experience (#92847)

In Dashboard Scene: 

* Display a warning in the panel header when rendering an angular panel
* Display a warning in the dashboard when rendering one or more angular panels
* Display a button to migrate in the dashboard banner
* Display a button to migrate in the panel editor
* Display a button to "Edit options" when it is an Angular panel, to open the panel JSON inspector to be able to edit the options
* Add tests

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Alexa V
2024-10-01 15:02:13 +02:00
committed by GitHub
co-authored by Torkel Ödegaard
parent 6c91b65aca
commit 5cc11bd1a1
13 changed files with 402 additions and 14 deletions
@@ -8,8 +8,10 @@ import {
isStandardFieldProp,
PanelPluginMeta,
restoreCustomOverrideRules,
PluginType,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, locationService } from '@grafana/runtime';
import {
DeepPartial,
SceneComponentProps,
@@ -19,11 +21,15 @@ import {
VizPanel,
sceneGraph,
} from '@grafana/scenes';
import { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui';
import { Button, Card, FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
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';
import { getAllPanelPluginMeta } from 'app/features/panel/state/util';
import { AngularDeprecationPluginNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationPluginNotice';
import { isUsingAngularPanelPlugin } from '../scene/angular/AngularDeprecation';
import { PanelOptions } from './PanelOptions';
import { PanelVizTypePicker } from './PanelVizTypePicker';
@@ -82,13 +88,20 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
this.setState({ listMode });
};
onOpenPanelJSON = (vizPanel: VizPanel) => {
locationService.partial({
inspect: vizPanel.state.key,
inspectTab: 'json',
});
};
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => {
const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState();
const panel = panelRef.resolve();
const { pluginId } = panel.useState();
const { data } = sceneGraph.getData(panel).useState();
const styles = useStyles2(getStyles);
const isAngularPanel = isUsingAngularPanelPlugin(panel);
return (
<>
{!isVizPickerOpen && (
@@ -102,6 +115,39 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
onChange={model.onSetSearchQuery}
/>
</div>
{isAngularPanel && (
<div className={styles.angularDeprecationContainer}>
<AngularDeprecationPluginNotice
showPluginDetailsLink={true}
pluginId={pluginId}
pluginType={PluginType.panel}
angularSupportEnabled={config?.angularSupportEnabled}
interactionElementId="panel-options"
>
<Card.Heading>
<Trans i18nKey="dashboards.panel-edit.angular-deprecation-heading">Panel options</Trans>
</Card.Heading>
<Card.Description>
<Trans i18nKey="dashboards.panel-edit.angular-deprecation-description">
Angular panels options can only be edited using the JSON editor.
</Trans>
</Card.Description>
<Card.Actions>
<Button
variant="secondary"
fullWidth={false}
onClick={() => {
model.onOpenPanelJSON(panel);
}}
>
<Trans i18nKey="dashboards.panel-edit.angular-deprecation-button-open-panel-json">
Open JSON editor
</Trans>
</Button>
</Card.Actions>
</AngularDeprecationPluginNotice>
</div>
)}
<div className={styles.listOfOptions}>
<PanelOptions panel={panel} searchQuery={searchQuery} listMode={listMode} data={data} />
</div>
@@ -146,6 +192,13 @@ function getStyles(theme: GrafanaTheme2) {
rotateIcon: css({
rotate: '180deg',
}),
angularDeprecationContainer: css({
label: 'angular-deprecation-container',
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
}),
};
}
@@ -59,6 +59,17 @@ jest.mock('@grafana/runtime', () => ({
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
};
},
config: {
...jest.requireActual('@grafana/runtime').config,
angularSupportEnabled: true,
panels: {
'briangann-datatable-panel': {
id: 'briangann-datatable-panel',
state: 'deprecated',
angular: { detected: true, hideDeprecation: false },
},
},
},
}));
jest.mock('app/features/playlist/PlaylistSrv', () => ({
@@ -826,6 +837,75 @@ describe('DashboardScene', () => {
expect(restoredGrid.state.grid.state.children.length).toBe(1);
});
});
describe('When a dashboard contain angular panels', () => {
it('should return true if the dashboard contains angular panels', () => {
// create a scene with angular panels inside
const scene = buildTestScene({
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'briangann-datatable-panel',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
}),
}),
new DashboardGridItem({
key: 'griditem-2',
body: new VizPanel({
title: 'Panel B',
key: 'panel-2',
pluginId: 'table',
}),
}),
],
}),
}),
});
scene.activate();
expect(scene.hasDashboardAngularPlugins()).toBe(true);
});
it('should return true if the dashboard contains explicitControllerMigration panels', () => {
// create a scene with angular panels inside
const scene = buildTestScene({
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [
new DashboardGridItem({
key: 'griditem-1',
x: 0,
body: new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'graph',
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
}),
}),
new DashboardGridItem({
key: 'griditem-2',
body: new VizPanel({
title: 'Panel B',
key: 'panel-2',
pluginId: 'table',
}),
}),
],
}),
}),
});
scene.activate();
expect(scene.hasDashboardAngularPlugins()).toBe(true);
});
});
});
function buildTestScene(overrides?: Partial<DashboardSceneState>) {
@@ -67,6 +67,7 @@ import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
import { ViewPanelScene } from './ViewPanelScene';
import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './angular/AngularDeprecation';
import { setupKeyboardShortcuts } from './keyboardShortcuts';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { DashboardLayoutManager } from './types';
@@ -662,6 +663,27 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
locationService.replace('/');
}
public getDashboardPanels() {
return dashboardSceneGraph.getVizPanels(this);
}
public hasDashboardAngularPlugins() {
const sceneGridLayout = this.state.body;
if (!(sceneGridLayout instanceof DefaultGridLayoutManager)) {
return false;
}
const gridItems = sceneGridLayout.state.grid.state.children;
const dashboardWasAngular = gridItems.some((gridItem) => {
if (!(gridItem instanceof DashboardGridItem)) {
return false;
}
const isAngularPanel = isUsingAngularPanelPlugin(gridItem.state.body);
const isAngularDs = isUsingAngularDatasourcePlugin(gridItem.state.body);
return isAngularPanel || isAngularDs;
});
return dashboardWasAngular;
}
public onSetScrollRef = (scrollElement: ScrollRefElement): void => {
this._scrollRef = scrollElement;
};
@@ -1,13 +1,36 @@
import { screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { selectors } from '@grafana/e2e-selectors';
import { config, setPluginImportUtils } from '@grafana/runtime';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
getPanelPluginFromCache: (id: string) => undefined,
});
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getDataSourceSrv: () => {
return {
getInstanceSettings: jest.fn().mockResolvedValue({ uid: 'ds1' }),
};
},
config: {
...jest.requireActual('@grafana/runtime').config,
angularSupportEnabled: true,
panels: {
'briangann-datatable-panel': {
id: 'briangann-datatable-panel',
state: 'deprecated',
angular: { detected: true, hideDeprecation: false },
},
},
},
}));
describe('DashboardSceneRenderer', () => {
@@ -50,4 +73,54 @@ describe('DashboardSceneRenderer', () => {
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
});
it('should render angular deprecation notice when dashboard contains angular components', async () => {
const noticeText = /This dashboard depends on Angular/i;
//enable feature flag angularDeprecationUI
config.featureToggles.angularDeprecationUI = true;
const scene = transformSaveModelToScene({
meta: {},
dashboard: {
title: 'Angular dashboard',
uid: 'uid',
schemaVersion: 0,
// Disabling build in annotations to avoid mocking Grafana data source
annotations: {
list: [
{
builtIn: 1,
datasource: {
type: 'grafana',
uid: '-- Grafana --',
},
enable: false,
hide: true,
iconColor: 'rgba(0, 211, 255, 1)',
name: 'Annotations & Alerts',
type: 'dashboard',
},
],
},
panels: [
{
id: 1,
type: 'briangann-datatable-panel',
gridPos: { x: 0, y: 0, w: 12, h: 6 },
title: 'Angular component',
options: {
showHeader: true,
},
fieldConfig: { defaults: {}, overrides: [] },
datasource: { uid: 'abcdef' },
targets: [{ refId: 'A' }],
},
],
},
});
render(<scene.Component model={scene} />);
expect(await screen.findByText(noticeText)).toBeInTheDocument();
});
});
@@ -16,6 +16,7 @@ import { useSelector } from 'app/types';
import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions';
import { PanelSearchLayout } from './PanelSearchLayout';
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { controls, overlay, editview, editPanel, isEmpty, meta, viewPanelScene, panelSearch, panelsPerRow } =
@@ -65,7 +66,9 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const notFound = meta.dashboardNotFound && <EntityNotFound entity="Dashboard" key="dashboard-not-found" />;
let body: React.ReactNode = [withPanels];
const angularBanner = <DashboardAngularDeprecationBanner dashboard={model} key="angular-deprecation-banner" />;
let body: React.ReactNode = [angularBanner, withPanels];
if (notFound) {
body = [notFound];
@@ -0,0 +1,103 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { Icon, PanelChrome, Tooltip, useStyles2 } from '@grafana/ui';
import { explicitlyControlledMigrationPanels } from 'app/features/dashboard/state/PanelModel';
import { isAngularDatasourcePluginAndNotHidden } from 'app/features/plugins/angularDeprecation/utils';
import { getQueryRunnerFor } from '../../utils/utils';
export class AngularDeprecation extends SceneObjectBase {
static Component = AngularDeprecationRenderer;
constructor() {
super({});
this.addActivationHandler(this.onActivate);
}
private onActivate = () => {
const panel = this.parent;
if (!panel || !(panel instanceof VizPanel)) {
throw new Error('PanelNotices can be used only as title items for VizPanel');
}
};
public getPanel() {
const panel = this.parent;
if (panel && panel instanceof VizPanel) {
return panel;
}
return null;
}
}
function AngularDeprecationRenderer({ model }: SceneComponentProps<AngularDeprecation>) {
const panel = model.getPanel();
const styles = useStyles2(getStyles);
if (!panel) {
return null;
}
const showAngularNotice = shouldShowAngularNotice(panel);
if (showAngularNotice) {
const pluginTypeNotice = getPluginTypeNotice(
isUsingAngularDatasourcePlugin(panel),
isUsingAngularPanelPlugin(panel)
);
const message = `This ${pluginTypeNotice} requires Angular (deprecated).`;
const angularNoticeTooltip = (
<Tooltip content={message}>
<PanelChrome.TitleItem className={styles.angularNotice} data-testid="angular-deprecation-icon">
<Icon name="exclamation-triangle" size="md" />
</PanelChrome.TitleItem>
</Tooltip>
);
return angularNoticeTooltip;
}
return null;
}
export function getPluginTypeNotice(isAngularDatasource: boolean, isAngularPanel: boolean) {
if (isAngularPanel) {
return 'panel';
}
if (isAngularDatasource) {
return 'data source';
}
return 'panel or data source';
}
export function isUsingAngularPanelPlugin(panel: VizPanel) {
return (
(config.panels[panel.state.pluginId]?.angular?.detected ||
explicitlyControlledMigrationPanels.includes(panel.state.pluginId)) &&
!config.panels[panel.state.pluginId]?.angular?.hideDeprecation
);
}
export function isUsingAngularDatasourcePlugin(panel: VizPanel) {
const queryRunner = getQueryRunnerFor(panel);
const datasource = queryRunner?.state.datasource;
return datasource?.uid ? isAngularDatasourcePluginAndNotHidden(datasource?.uid) : false;
}
export function shouldShowAngularNotice(panel: VizPanel) {
return (
(config.featureToggles.angularDeprecationUI ?? false) &&
(isUsingAngularDatasourcePlugin(panel) || isUsingAngularPanelPlugin(panel))
);
}
function getStyles(theme: GrafanaTheme2) {
return {
angularNotice: css({
color: theme.colors.warning.text,
}),
};
}
@@ -0,0 +1,31 @@
import { config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { explicitlyControlledMigrationPanels } from 'app/features/dashboard/state/PanelModel';
import { AngularDeprecationNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationNotice';
import { DashboardScene } from '../DashboardScene';
interface Props {
dashboard: DashboardScene;
}
export const DashboardAngularDeprecationBanner = ({ dashboard }: Props) => {
const panels = dashboard.getDashboardPanels();
const shouldShowAutoMigrateLink = panels.some((panel) => {
if (panel instanceof VizPanel) {
return explicitlyControlledMigrationPanels.includes(panel.state.pluginId);
}
return false;
});
const isContainingAngularPanels =
config.featureToggles.angularDeprecationUI && dashboard.hasDashboardAngularPlugins();
return isContainingAngularPanels && dashboard.state.uid ? (
<AngularDeprecationNotice
dashboardUid={dashboard.state.uid}
showAutoMigrateLink={shouldShowAutoMigrateLink}
key={dashboard.state.uid}
/>
) : null;
};
@@ -18,7 +18,6 @@ export function getAngularPanelMigrationHandler(oldModel: PanelModel) {
const wasAngular = autoMigrateAngular[oldModel.autoMigrateFrom] != null;
const oldOptions = oldModel.getOptionsToRemember();
const prevPluginId = oldModel.autoMigrateFrom;
if (plugin.onPanelTypeChanged) {
const prevOptions = wasAngular ? { angular: oldOptions } : oldOptions.options;
Object.assign(panel.options, plugin.onPanelTypeChanged(panel, prevPluginId, prevOptions, panel.fieldConfig));
@@ -37,6 +37,7 @@ import { panelLinksBehavior, panelMenuBehavior } from '../scene/PanelMenuBehavio
import { PanelNotices } from '../scene/PanelNotices';
import { PanelTimeRange } from '../scene/PanelTimeRange';
import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { AngularDeprecation } from '../scene/angular/AngularDeprecation';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowActions } from '../scene/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
@@ -278,6 +279,9 @@ export function buildGridItemForPanel(panel: PanelModel): DashboardGridItem {
const titleItems: SceneObject[] = [];
if (config.featureToggles.angularDeprecationUI) {
titleItems.push(new AngularDeprecation());
}
titleItems.push(
new VizPanelLinks({
rawLinks: panel.links,
@@ -13,12 +13,10 @@ function getRefreshPicker(scene: DashboardScene) {
}
function getPanelLinks(panel: VizPanel) {
if (
panel.state.titleItems &&
Array.isArray(panel.state.titleItems) &&
panel.state.titleItems[0] instanceof VizPanelLinks
) {
return panel.state.titleItems[0];
if (panel.state.titleItems && Array.isArray(panel.state.titleItems)) {
// search panel.state.titleItems for VizPanelLinks
const panelLink = panel.state.titleItems.find((item) => item instanceof VizPanelLinks);
return panelLink ?? null;
}
return null;
@@ -7,14 +7,12 @@ import { Alert } from '@grafana/ui';
type Props = {
className?: string;
pluginId?: string;
pluginType?: PluginType;
angularSupportEnabled?: boolean;
showPluginDetailsLink?: boolean;
interactionElementId?: string;
children?: React.ReactNode;
};
function deprecationMessage(pluginType?: string, angularSupportEnabled?: boolean): string {
@@ -45,7 +43,15 @@ function deprecationMessage(pluginType?: string, angularSupportEnabled?: boolean
// An Alert showing information about Angular deprecation notice.
// If the plugin does not use Angular (!plugin.angularDetected), it returns null.
export function AngularDeprecationPluginNotice(props: Props): React.ReactElement | null {
const { className, angularSupportEnabled, pluginId, pluginType, showPluginDetailsLink, interactionElementId } = props;
const {
className,
angularSupportEnabled,
pluginId,
pluginType,
showPluginDetailsLink,
interactionElementId,
children,
} = props;
const [dismissed, setDismissed] = useState(false);
const interactionAttributes: Record<string, string> = {};
@@ -88,6 +94,12 @@ export function AngularDeprecationPluginNotice(props: Props): React.ReactElement
) : null}
</ul>
</div>
{children && (
<>
<hr />
{children}
</>
)}
</Alert>
);
}
+5
View File
@@ -751,6 +751,11 @@
}
},
"dashboards": {
"panel-edit": {
"angular-deprecation-button-open-panel-json": "Open JSON editor",
"angular-deprecation-description": "Angular panels options can only be edited using the JSON editor.",
"angular-deprecation-heading": "Panel options"
},
"settings": {
"variables": {
"dependencies": {
@@ -751,6 +751,11 @@
}
},
"dashboards": {
"panel-edit": {
"angular-deprecation-button-open-panel-json": "Øpęʼn ĴŜØŃ ęđįŧőř",
"angular-deprecation-description": "Åʼnģūľäř päʼnęľş őpŧįőʼnş čäʼn őʼnľy þę ęđįŧęđ ūşįʼnģ ŧĥę ĴŜØŃ ęđįŧőř.",
"angular-deprecation-heading": "Päʼnęľ őpŧįőʼnş"
},
"settings": {
"variables": {
"dependencies": {