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
This commit is contained in:
Brendan O'Handley
2025-10-29 09:15:20 -05:00
committed by GitHub
parent 7dbacddb18
commit 2472555af0
9 changed files with 130 additions and 4 deletions
@@ -10,6 +10,7 @@ export function ExposedComponents() {
const { component: ReusableComponent } = usePluginComponent<ReusableComponentProps>(
'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() {
<div data-testid={testIds.exposedComponentsPage.container}>
<ReusableComponent name={'World'} />
</div>
{AddToDashboardForm && (
<section>
<h3>Save to dashboard (exposed form)</h3>
<AddToDashboardForm
// Create a recognizable panel for assertion
buildPanel={() => ({
type: 'timeseries',
title: 'E2E Add to Dashboard Panel',
targets: [],
})}
// Ensure navigation works correctly from plugin page
options={{ useAbsolutePath: true }}
onClose={() => {}}
/>
</section>
)}
</PluginPage>
);
}
@@ -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"]
}
}
}
@@ -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();
});
}
);
@@ -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 = {
@@ -41,7 +41,11 @@ export interface Props<TOptions = undefined> {
children?: React.ReactNode;
}
export function AddToDashboardForm<TOptions = undefined>({
/**
* Internal implementation used by the exposed versioned wrapper.
* For stability/versioning guidance, refer to AddToDashboardFormExposedComponent.
*/
export function AddToDashboardForm<TOptions extends AbsolutePathOptions | undefined = undefined>({
onClose,
buildPanel,
timeRange,
@@ -91,7 +95,7 @@ export function AddToDashboardForm<TOptions = undefined>({
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;
@@ -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<Props<AbsolutePathOptions | undefined>>) => (
<Suspense fallback={null}>
<AddToDashboardFormLazy
onClose={props.onClose ?? (() => {})}
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}
</AddToDashboardFormLazy>
</Suspense>
);
@@ -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;
}
@@ -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,
},
],
});
+5
View File
@@ -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",