From 2472555af0d7e488c424478e08131d4badbdfaa4 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Wed, 29 Oct 2025 10:15:20 -0400 Subject: [PATCH] Add to dashboard: expose add to dashboard form component for external apps (#112609) * add extension for drilldown to add to dashboard * reuse configure add to dashboard function callback * structure for drilldown add to dashboard * fix imports * fix tests * expose as a component * remove extension link * get component ready to extend * lazy load component * add component to exposed component registry * update folder structure to not work in explore folder * keep dependencies clean * nice structure to let folks know this is a drilldown integration * update code owners for new file * make exposed component more generic, step one, update component id * step 2, expose add to dashboard form component * add more explicit useAbsolutePath option to form * remove old implementation code for drilldown specific component * commit translation * add comments to avoid breaking changes * add e2e test for add to dashboard form component * fix flaky test * add exposed component id to e2e test app * remove gridPos in buildPanel fallback fn * add code comment for useAbsolutePath's purpose * remove gridPos from e2e test --- .../pages/ExposedComponents.tsx | 17 ++++++++ .../grafana-extensionstest-app/plugin.json | 2 +- .../tests/useExposedComponent.spec.ts | 30 ++++++++++++++ .../src/types/pluginExtensions.ts | 1 + .../addToDashboard/AddToDashboardForm.tsx | 14 ++++++- .../AddToDashboardFormExposedComponent.tsx | 41 +++++++++++++++++++ .../addToDashboard/addToDashboard.ts | 17 +++++++- .../plugins/extensions/registry/setup.ts | 7 ++++ public/locales/en-US/grafana.json | 5 +++ 9 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 public/app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent.tsx diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/pages/ExposedComponents.tsx b/e2e-playwright/test-plugins/grafana-extensionstest-app/pages/ExposedComponents.tsx index 28775391881..6b284e171bc 100644 --- a/e2e-playwright/test-plugins/grafana-extensionstest-app/pages/ExposedComponents.tsx +++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/pages/ExposedComponents.tsx @@ -10,6 +10,7 @@ export function ExposedComponents() { const { component: ReusableComponent } = usePluginComponent( 'grafana-extensionexample1-app/reusable-component/v1' ); + const { component: AddToDashboardForm } = usePluginComponent('grafana/add-to-dashboard-form/v1'); if (!ReusableComponent) { return null; @@ -20,6 +21,22 @@ export function ExposedComponents() {
+ {AddToDashboardForm && ( +
+

Save to dashboard (exposed form)

+ ({ + type: 'timeseries', + title: 'E2E Add to Dashboard Panel', + targets: [], + })} + // Ensure navigation works correctly from plugin page + options={{ useAbsolutePath: true }} + onClose={() => {}} + /> +
+ )} ); } diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json b/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json index 90978c2c8a2..57fa87dcde5 100644 --- a/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json +++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json @@ -80,7 +80,7 @@ "grafanaDependency": ">=10.4.0", "plugins": [], "extensions": { - "exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1"] + "exposedComponents": ["grafana-extensionexample1-app/reusable-component/v1", "grafana/add-to-dashboard-form/v1"] } } } diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/tests/useExposedComponent.spec.ts b/e2e-playwright/test-plugins/grafana-extensionstest-app/tests/useExposedComponent.spec.ts index 9f3d3f0bf35..501c929b612 100644 --- a/e2e-playwright/test-plugins/grafana-extensionstest-app/tests/useExposedComponent.spec.ts +++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/tests/useExposedComponent.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@grafana/plugin-e2e'; import { testIds } from '../testIds'; import pluginJson from '../plugin.json'; +import { ensureExtensionRegistryIsPopulated } from './utils'; test.describe( 'grafana-extensionstest-app', @@ -12,5 +13,34 @@ test.describe( await page.goto(`/a/${pluginJson.id}/exposed-components`); await expect(page.getByTestId(testIds.appB.exposedComponent)).toHaveText('Hello World!'); }); + + test('exposed add-to-dashboard form saves to a new dashboard', async ({ page }) => { + await page.goto(`/a/${pluginJson.id}/exposed-components`); + await ensureExtensionRegistryIsPopulated(page); + + // Wait for the exposed form section to be ready + await expect(page.getByRole('heading', { name: 'Save to dashboard (exposed form)' })).toBeVisible(); + + // Wait for any of the form buttons to render (lazy load) before clicking + const openInNewTab = page.getByRole('button', { name: 'Open in new tab' }); + const cancelBtn = page.getByRole('button', { name: 'Cancel' }); + await Promise.race([expect(openInNewTab).toBeVisible(), expect(cancelBtn).toBeVisible()]); + + // Now wait for the submit button to be visible, then click (role or text) + const openDashboardByRole = page.getByRole('button', { name: 'Open dashboard' }); + const openDashboardByText = page.getByText('Open dashboard'); + if (await openDashboardByRole.isVisible().catch(() => false)) { + await openDashboardByRole.click(); + } else { + await expect(openDashboardByText.first()).toBeVisible(); + await openDashboardByText.first().click(); + } + + // Navigates to /dashboard/new and prepopulates a panel from local storage + await expect(page).toHaveURL(/\/dashboard\/new/); + + // Panel should be created with our custom title + await expect(page.getByText('E2E Add to Dashboard Panel').first()).toBeVisible(); + }); } ); diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 47e130afcce..4b988c49a0a 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -230,6 +230,7 @@ export enum PluginExtensionPointPatterns { // Extension Points available in plugins export enum PluginExtensionExposedComponents { CentralAlertHistorySceneV1 = 'grafana/central-alert-history-scene/v1', + AddToDashboardFormV1 = 'grafana/add-to-dashboard-form/v1', } export type PluginExtensionPanelContext = { diff --git a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx index 17ecf87eb8f..85f3e47ae03 100644 --- a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx +++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardForm.tsx @@ -41,7 +41,11 @@ export interface Props { children?: React.ReactNode; } -export function AddToDashboardForm({ +/** + * Internal implementation used by the exposed versioned wrapper. + * For stability/versioning guidance, refer to AddToDashboardFormExposedComponent. + */ +export function AddToDashboardForm({ onClose, buildPanel, timeRange, @@ -91,7 +95,7 @@ export function AddToDashboardForm({ queries: panel.targets, }); - const error = addToDashboard({ dashboardUid, panel, openInNewTab, timeRange }); + const error = addToDashboard({ dashboardUid, panel, openInNewTab, timeRange, options }); if (error) { setSubmissionError(error); return; @@ -202,3 +206,9 @@ function assertIsSaveToExistingDashboardError( // explicitly assert its type so that TS can narrow down FormDTO to SaveToExistingDashboard // when we use it in the form. } + +export interface AbsolutePathOptions { + useAbsolutePath: boolean; +} + +export default AddToDashboardForm; diff --git a/public/app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent.tsx b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent.tsx new file mode 100644 index 00000000000..3111700e1ee --- /dev/null +++ b/public/app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent.tsx @@ -0,0 +1,41 @@ +import { lazy, Suspense } from 'react'; + +import { t } from '@grafana/i18n'; + +import { AbsolutePathOptions, Props } from './AddToDashboardForm'; + +// Lazy load the component +const AddToDashboardFormLazy = lazy(() => import('./AddToDashboardForm')); + +/** + * EXPOSED COMPONENT (stable): grafana/add-to-dashboard-form/v1 + * + * This component is exposed to plugins via the Plugin Extensions system. + * Treat its props and user-visible behavior as a stable contract. Do not make + * breaking changes in-place. If you need to change the API or behavior in a + * breaking way, create a new versioned component (e.g. AddToDashboardFormV2) + * and register it under a new ID: "grafana/add-to-dashboard-form/v2". + * + * Consumers should import it using the exposed component ID and pass only the + * supported props. The default buildPanel creates a time series panel; callers + * can supply a custom builder via "buildPanel". + */ +export const AddToDashboardFormExposedComponent = (props: Partial>) => ( + + {})} + buildPanel={ + props.buildPanel ?? + (() => ({ + type: 'timeseries', + title: t('dashboard-scene.add-to-dashboard-form-exposed.title.new-panel', 'New panel'), + targets: [], + })) + } + timeRange={props.timeRange} + options={props.options} + > + {props.children} + + +); diff --git a/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts b/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts index e22feb9a502..dfb375fced0 100644 --- a/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts +++ b/public/app/features/dashboard-scene/addToDashboard/addToDashboard.ts @@ -26,6 +26,9 @@ interface AddPanelToDashboardOptions { dashboardUid?: string; openInNewTab?: boolean; timeRange?: TimeRange; + options?: { + useAbsolutePath?: boolean; + }; } export function addToDashboard({ @@ -33,6 +36,7 @@ export function addToDashboard({ dashboardUid, openInNewTab, timeRange, + options, }: AddPanelToDashboardOptions): SubmissionError | undefined { let dto: DashboardDTO = { meta: {}, @@ -83,7 +87,18 @@ export function addToDashboard({ return; } - locationService.push(locationUtil.stripBaseFromUrl(dashboardURL)); + let navigateToDashboardUrl = locationUtil.stripBaseFromUrl(dashboardURL); + + // External apps need absolute paths to navigate to dashboards correctly. + // Without the leading '/', paths like "dashboard/new" are treated as relative to the current location. + // For example, from "/a/grafana-metricsdrilldown-app", this would incorrectly navigate to + // "/a/grafana-metricsdrilldown-app/dashboard/new" instead of "/dashboard/new". + if (options?.useAbsolutePath) { + navigateToDashboardUrl = '/' + navigateToDashboardUrl; + } + + locationService.push(navigateToDashboardUrl); + return; } diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index c4057e22c4b..bb25c487dc3 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -1,5 +1,6 @@ import { PluginExtensionExposedComponents } from '@grafana/data'; import CentralAlertHistorySceneExposedComponent from 'app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistorySceneExposedComponent'; +import { AddToDashboardFormExposedComponent } from 'app/features/dashboard-scene/addToDashboard/AddToDashboardFormExposedComponent'; import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations'; @@ -36,5 +37,11 @@ exposedComponentsRegistry.register({ description: 'Central alert history scene', component: CentralAlertHistorySceneExposedComponent, }, + { + id: PluginExtensionExposedComponents.AddToDashboardFormV1, + title: 'Add to dashboard form', + description: 'Add to dashboard form', + component: AddToDashboardFormExposedComponent, + }, ], }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e8c29e9ffe7..3c0c17c7af2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5702,6 +5702,11 @@ "open-in-new-tab": "Open in new tab", "title-error-adding-the-panel": "Error adding the panel" }, + "add-to-dashboard-form-exposed": { + "title": { + "new-panel": "New panel" + } + }, "annotation-settings-edit": { "back-to-list": "Back to list", "delete": "Delete",