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:
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
+41
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user