From e5d1cd8ea5903cf8170d99ac7ac5c255b4c25f61 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Wed, 27 Mar 2024 15:24:24 -0600 Subject: [PATCH] AngularMigration: Allow dashboard by dashboard migration (#84100) --- .betterer.results | 3 +- packages/grafana-runtime/src/config.ts | 11 ++-- .../dashboard/containers/DashboardPage.tsx | 54 ++++++++++++++- .../dashboard/state/DashboardModel.ts | 6 +- .../state/getPanelPluginToMigrateTo.ts | 42 ++++++++++-- .../AngularDeprecationNotice.test.tsx | 22 ++++++- .../AngularDeprecationNotice.tsx | 43 +++++++----- .../AngularMigrationNotice.test.tsx | 50 ++++++++++++++ .../AngularMigrationNotice.tsx | 66 +++++++++++++++++++ .../plugins/angularDeprecation/utils.ts | 10 +++ 10 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 public/app/features/plugins/angularDeprecation/AngularMigrationNotice.test.tsx create mode 100644 public/app/features/plugins/angularDeprecation/AngularMigrationNotice.tsx diff --git a/.betterer.results b/.betterer.results index 4a83fb1e4a22..ab863af66ab2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2759,7 +2759,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "4"] + [0, 0, 0, "Do not use any type assertions.", "4"], + [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "5"] ], "public/app/features/dashboard/containers/DashboardPageProxy.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 3af5b15d1900..624174f13d91 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -214,7 +214,9 @@ export class GrafanaBootConfig implements GrafanaConfig { systemDateFormats.update(this.dateFormats); } - overrideFeatureTogglesFromUrl(this); + if (this.buildInfo.env === 'development') { + overrideFeatureTogglesFromUrl(this); + } overrideFeatureTogglesFromLocalStorage(this); if (this.featureToggles.disableAngular) { @@ -251,9 +253,7 @@ function overrideFeatureTogglesFromUrl(config: GrafanaBootConfig) { return; } - const isLocalDevEnv = config.buildInfo.env === 'development'; - - const prodUrlAllowedFeatureFlags = new Set([ + const migrationFeatureFlags = new Set([ 'autoMigrateOldPanels', 'autoMigrateGraphPanel', 'autoMigrateTablePanel', @@ -269,7 +269,8 @@ function overrideFeatureTogglesFromUrl(config: GrafanaBootConfig) { const featureToggles = config.featureToggles as Record; const featureName = key.substring(10); - if (!isLocalDevEnv && !prodUrlAllowedFeatureFlags.has(featureName)) { + // skip the migration feature flags + if (migrationFeatureFlags.has(featureName)) { return; } diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 188c10d9d46e..870be1837632 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -17,6 +17,7 @@ import { getNavModel } from 'app/core/selectors/navModel'; import { PanelModel } from 'app/features/dashboard/state'; import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher'; import { AngularDeprecationNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationNotice'; +import { AngularMigrationNotice } from 'app/features/plugins/angularDeprecation/AngularMigrationNotice'; import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage'; import { DashboardRoutes, KioskMode, StoreState } from 'app/types'; import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events'; @@ -36,6 +37,7 @@ import { SubMenu } from '../components/SubMenu/SubMenu'; import { DashboardGrid } from '../dashgrid/DashboardGrid'; import { liveTimer } from '../dashgrid/liveTimer'; import { getTimeSrv } from '../services/TimeSrv'; +import { explicitlyControlledMigrationPanels, autoMigrateAngular } from '../state/PanelModel'; import { cleanUpDashboardAndVariables } from '../state/actions'; import { initDashboard } from '../state/initDashboard'; @@ -319,6 +321,50 @@ export class UnthemedDashboardPage extends PureComponent { ); } + const migrationFeatureFlags = new Set([ + 'autoMigrateOldPanels', + 'autoMigrateGraphPanel', + 'autoMigrateTablePanel', + 'autoMigratePiechartPanel', + 'autoMigrateWorldmapPanel', + 'autoMigrateStatPanel', + 'disableAngular', + ]); + + const isAutoMigrationFlagSet = () => { + const urlParams = new URLSearchParams(window.location.search); + let isFeatureFlagSet = false; + + urlParams.forEach((value, key) => { + if (key.startsWith('__feature.')) { + const featureName = key.substring(10); + const toggleState = value === 'true' || value === ''; + const featureToggles = config.featureToggles as Record; + + if (featureToggles[featureName]) { + return; + } + + if (migrationFeatureFlags.has(featureName) && toggleState) { + isFeatureFlagSet = true; + return; + } + } + }); + + return isFeatureFlagSet; + }; + + const dashboardWasAngular = dashboard.panels.some( + (panel) => panel.autoMigrateFrom && autoMigrateAngular[panel.autoMigrateFrom] != null + ); + + const showDashboardMigrationNotice = + config.featureToggles.angularDeprecationUI && + dashboardWasAngular && + isAutoMigrationFlagSet() && + dashboard.uid !== null; + return ( <> { )} {config.featureToggles.angularDeprecationUI && dashboard.hasAngularPlugins() && dashboard.uid !== null && ( - + + explicitlyControlledMigrationPanels.includes(panel.type) + )} + /> )} + {showDashboardMigrationNotice && } { // Return false for plugins that are angular but have angular.hideDeprecation = false // We cannot use panel.plugin.isAngularPlugin() because panel.plugin may not be initialized at this stage. + // We also have to check for old core angular plugins (explicitlyControlledMigrationPanels). const isAngularPanel = - config.panels[panel.type]?.angular?.detected && !config.panels[panel.type]?.angular?.hideDeprecation; + (config.panels[panel.type]?.angular?.detected || explicitlyControlledMigrationPanels.includes(panel.type)) && + !config.panels[panel.type]?.angular?.hideDeprecation; let isAngularDs = false; if (panel.datasource?.uid) { isAngularDs = isAngularDatasourcePluginAndNotHidden(panel.datasource?.uid); diff --git a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts index bfc5c32b13bc..5eb7c201ee49 100644 --- a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts +++ b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts @@ -7,12 +7,30 @@ export function getPanelPluginToMigrateTo(panel: any, forceMigration?: boolean): return autoMigrateRemovedPanelPlugins[panel.type]; } + const isUrlFeatureFlagEnabled = (featureName: string) => { + const flag = '__feature.' + featureName; + + const urlParams = new URLSearchParams(window.location.search); + const featureFlagValue = urlParams.get(flag); + + return featureFlagValue === 'true' || featureFlagValue === ''; + }; + // Auto-migrate old angular panels const shouldMigrateAllAngularPanels = - forceMigration || !config.angularSupportEnabled || config.featureToggles.autoMigrateOldPanels; + forceMigration || + !config.angularSupportEnabled || + config.featureToggles.autoMigrateOldPanels || + isUrlFeatureFlagEnabled('autoMigrateOldPanels') || + isUrlFeatureFlagEnabled('disableAngular'); // Graph needs special logic as it can be migrated to multiple panels - if (panel.type === 'graph' && (shouldMigrateAllAngularPanels || config.featureToggles.autoMigrateGraphPanel)) { + if ( + panel.type === 'graph' && + (shouldMigrateAllAngularPanels || + config.featureToggles.autoMigrateGraphPanel || + isUrlFeatureFlagEnabled('autoMigrateGraphPanel')) + ) { if (panel.xaxis?.mode === 'series') { return 'barchart'; } @@ -28,21 +46,31 @@ export function getPanelPluginToMigrateTo(panel: any, forceMigration?: boolean): return autoMigrateAngular[panel.type]; } - if (panel.type === 'table-old' && config.featureToggles.autoMigrateTablePanel) { + if ( + panel.type === 'table-old' && + (config.featureToggles.autoMigrateTablePanel || isUrlFeatureFlagEnabled('autoMigrateTablePanel')) + ) { return 'table'; } - if (panel.type === 'grafana-piechart-panel' && config.featureToggles.autoMigratePiechartPanel) { + if ( + panel.type === 'grafana-piechart-panel' && + (config.featureToggles.autoMigratePiechartPanel || isUrlFeatureFlagEnabled('autoMigratePiechartPanel')) + ) { return 'piechart'; } - if (panel.type === 'grafana-worldmap-panel' && config.featureToggles.autoMigrateWorldmapPanel) { + if ( + panel.type === 'grafana-worldmap-panel' && + (config.featureToggles.autoMigrateWorldmapPanel || isUrlFeatureFlagEnabled('autoMigrateWorldmapPanel')) + ) { return 'geomap'; } if ( - (panel.type === 'singlestat' || panel.type === 'grafana-singlestat-panel') && - config.featureToggles.autoMigrateStatPanel + ((panel.type === 'singlestat' || panel.type === 'grafana-singlestat-panel') && + config.featureToggles.autoMigrateStatPanel) || + isUrlFeatureFlagEnabled('autoMigrateStatPanel') ) { return 'stat'; } diff --git a/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.test.tsx b/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.test.tsx index 9c1efcedbf80..40e566814571 100644 --- a/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.test.tsx +++ b/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.test.tsx @@ -12,7 +12,7 @@ jest.mock('@grafana/runtime', () => ({ })); function localStorageKey(dsUid: string) { - return `grafana.angularDeprecation.dashboardNotice.isDismissed.${dsUid}`; + return `grafana.angularDeprecation.dashboardNoticeAndMigration.isDismissed.${dsUid}`; } describe('AngularDeprecationNotice', () => { @@ -63,4 +63,24 @@ describe('AngularDeprecationNotice', () => { await userEvent.click(closeButton); expect(reportInteraction).toHaveBeenCalledWith('angular_deprecation_notice_dismissed'); }); + + describe('auto migrate button', () => { + const autoMigrateText = 'Try migration'; + + it('should display auto migrate button if showAutoMigrateLink is true', () => { + render(); + const autoMigrateButton = screen.getByRole('button', { name: /Try migration/i }); + expect(autoMigrateButton).toBeInTheDocument(); + }); + + it('should not display auto migrate button if showAutoMigrateLink is false', () => { + render(); + expect(screen.queryByText(autoMigrateText)).not.toBeInTheDocument(); + }); + + it('should not display auto migrate link if showAutoMigrateLink is not provided', () => { + render(); + expect(screen.queryByText(autoMigrateText)).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.tsx b/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.tsx index f697e183f9c8..affd3056bb13 100644 --- a/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.tsx +++ b/public/app/features/plugins/angularDeprecation/AngularDeprecationNotice.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { reportInteraction } from '@grafana/runtime'; -import { Alert } from '@grafana/ui'; +import { Alert, Button } from '@grafana/ui'; import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider'; -const LOCAL_STORAGE_KEY_PREFIX = 'grafana.angularDeprecation.dashboardNotice.isDismissed'; +const LOCAL_STORAGE_KEY_PREFIX = 'grafana.angularDeprecation.dashboardNoticeAndMigration.isDismissed'; function localStorageKey(dashboardUid: string): string { return LOCAL_STORAGE_KEY_PREFIX + '.' + dashboardUid; @@ -12,9 +12,19 @@ function localStorageKey(dashboardUid: string): string { export interface Props { dashboardUid: string; + showAutoMigrateLink?: boolean; } -export function AngularDeprecationNotice({ dashboardUid }: Props) { +function tryMigration() { + const autoMigrateParam = '__feature.autoMigrateOldPanels'; + const url = new URL(window.location.toString()); + if (!url.searchParams.has(autoMigrateParam)) { + url.searchParams.append(autoMigrateParam, 'true'); + } + window.open(url.toString(), '_self'); +} + +export function AngularDeprecationNotice({ dashboardUid, showAutoMigrateLink }: Props) { return ( storageKey={localStorageKey(dashboardUid)} defaultValue={false}> {(isDismissed, onDismiss) => { @@ -32,18 +42,21 @@ export function AngularDeprecationNotice({ dashboardUid }: Props) { }} > diff --git a/public/app/features/plugins/angularDeprecation/AngularMigrationNotice.test.tsx b/public/app/features/plugins/angularDeprecation/AngularMigrationNotice.test.tsx new file mode 100644 index 000000000000..07d59e6b8326 --- /dev/null +++ b/public/app/features/plugins/angularDeprecation/AngularMigrationNotice.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { AngularMigrationNotice } from './AngularMigrationNotice'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), +})); + +describe('AngularMigrationNotice', () => { + const noticeText = + /This dashboard was migrated from Angular. Please make sure everything is behaving as expected and save and refresh this dashboard to persist the migration./i; + const dsUid = 'abc'; + + afterAll(() => { + jest.resetAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render', () => { + render(); + expect(screen.getByText(noticeText)).toBeInTheDocument(); + }); + + it('should be dismissable', async () => { + render(); + const closeButton = screen.getByRole('button', { name: /Close alert/i }); + expect(closeButton).toBeInTheDocument(); + await userEvent.click(closeButton); + expect(screen.queryByText(noticeText)).not.toBeInTheDocument(); + }); + + describe('Migration alert buttons', () => { + it('should display the "Revert migration" button', () => { + render(); + const revertMigrationButton = screen.getByRole('button', { name: /Revert migration/i }); + expect(revertMigrationButton).toBeInTheDocument(); + }); + + it('should display the "Report issue" button', () => { + render(); + const reportIssueButton = screen.getByRole('button', { name: /Report issue/i }); + expect(reportIssueButton).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/plugins/angularDeprecation/AngularMigrationNotice.tsx b/public/app/features/plugins/angularDeprecation/AngularMigrationNotice.tsx new file mode 100644 index 000000000000..b131e2955411 --- /dev/null +++ b/public/app/features/plugins/angularDeprecation/AngularMigrationNotice.tsx @@ -0,0 +1,66 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, Button, useStyles2 } from '@grafana/ui'; + +import { migrationFeatureFlags } from './utils'; + +interface Props { + dashboardUid: string; +} + +const revertAutoMigrateUrlFlag = () => { + const url = new URL(window.location.toString()); + const urlParams = new URLSearchParams(url.search); + + urlParams.forEach((value, key) => { + if (key.startsWith('__feature.')) { + const featureName = key.substring(10); + if (migrationFeatureFlags.has(featureName)) { + urlParams.delete(key); + } + } + }); + + window.location.href = new URL(url.origin + url.pathname + '?' + urlParams.toString()).toString(); +}; + +const reportIssue = () => { + window.open( + 'https://github.com/grafana/grafana/issues/new?assignees=&labels=&projects=&template=0-bug-report.yaml&title=Product+Area%3A+Short+description+of+bug' + ); +}; + +export function AngularMigrationNotice({ dashboardUid }: Props) { + const styles = useStyles2(getStyles); + + const [showAlert, setShowAlert] = useState(true); + + if (showAlert) { + return ( + setShowAlert(false)} + > +
+ + +
+
+ ); + } + + return null; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + linkButton: css({ + marginRight: 10, + }), +}); diff --git a/public/app/features/plugins/angularDeprecation/utils.ts b/public/app/features/plugins/angularDeprecation/utils.ts index 3c2beb9d1a51..0f42e93a3a80 100644 --- a/public/app/features/plugins/angularDeprecation/utils.ts +++ b/public/app/features/plugins/angularDeprecation/utils.ts @@ -14,3 +14,13 @@ export function isAngularDatasourcePluginAndNotHidden(dsUid: string): boolean { const settings = getDsInstanceSettingsByUid(dsUid); return (settings?.meta.angular?.detected && !settings?.meta.angular.hideDeprecation) ?? false; } + +export const migrationFeatureFlags = new Set([ + 'autoMigrateOldPanels', + 'autoMigrateGraphPanel', + 'autoMigrateTablePanel', + 'autoMigratePiechartPanel', + 'autoMigrateWorldmapPanel', + 'autoMigrateStatPanel', + 'disableAngular', +]);