From f074431a360cde79aab7cc49f50749e5137ff804 Mon Sep 17 00:00:00 2001 From: Eric Hilse Date: Wed, 19 Nov 2025 11:19:14 -0700 Subject: [PATCH] Plugins: add card extension to nav-landing-page (#113768) * Plugins: add card extension to nav-landing-page * feat(nav-landing-page): update card extension to include category and onClick support * feat(NavLandingPage): add setPluginLinksHook to test setup * feat(NavLandingPageCard): refactor className handling using cx for better style management * feat(NavLandingPage): update card extension targets to use plural form for consistency * feat(NavLandingPage): add warning for mutually exclusive extension points --- .../grafana-extensionstest-app/module.tsx | 7 +++ .../grafana-extensionstest-app/plugin.json | 5 ++ .../src/types/pluginExtensions.ts | 5 ++ .../NavLandingPage/NavLandingPage.test.tsx | 6 +- .../NavLandingPage/NavLandingPage.tsx | 31 +++++++++- .../NavLandingPage/NavLandingPageCard.tsx | 60 ++++++++++++++++++- 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx b/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx index 25d69140a96..89ed1af12c4 100644 --- a/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx +++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/module.tsx @@ -78,6 +78,13 @@ export const plugin = new AppPlugin<{}>() return undefined; } }, + }) + .addLink({ + targets: 'grafana/dynamic/nav-landing-page/nav-id-cfg/cards/v1', + title: 'Extensions Test App', + description: 'This card tests the admin page card extension point', + path: `/a/${pluginJson.id}/`, + category: 'error', }); function isSupported(context?: PluginExtensionPanelContext): boolean { diff --git a/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json b/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json index 57fa87dcde5..3f5adfa215a 100644 --- a/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json +++ b/e2e-playwright/test-plugins/grafana-extensionstest-app/plugin.json @@ -55,6 +55,11 @@ "targets": ["grafana/dashboard/panel/menu"], "title": "Open from time series or pie charts (onClick)", "description": "This link will only be visible on time series and pie charts" + }, + { + "targets": ["grafana/dynamic/nav-landing-page/nav-id-cfg/cards/v1"], + "title": "Extensions Test App", + "description": "This card tests the admin page card extension point" } ], "extensionPoints": [ diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index 4b988c49a0a..05e7b2b144e 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -223,8 +223,13 @@ export enum PluginExtensionPoints { // Don't use directly in a plugin! // Extension point IDs that contain dynamic segments and are not valid as static values — they require runtime substitution of certain parts. // (They cannot be used as is. E.g. "grafana/nav-landing-page/.*/v1" becomes "grafana/nav-landing-page/observability/v1" during runtime.) +// +// IMPORTANT: NavLandingPage and NavLandingPageCards are mutually exclusive. +// If a plugin extends NavLandingPage, it will replace the entire page content and any NavLandingPageCards extensions will be ignored. +// Only use NavLandingPageCards if you want to add additional cards to the default landing page layout. export enum PluginExtensionPointPatterns { NavLandingPage = 'grafana/dynamic/nav-landing-page/nav-id-.*/v1', + NavLandingPageCards = 'grafana/dynamic/nav-landing-page/nav-id-.*/cards/v1', } // Extension Points available in plugins diff --git a/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx b/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx index 0c0ae6987a8..849cfc6d3c8 100644 --- a/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx +++ b/public/app/core/components/NavLandingPage/NavLandingPage.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { TestProvider } from 'test/helpers/TestProvider'; -import { config, setPluginComponentsHook } from '@grafana/runtime'; +import { config, setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents'; import { NavLandingPage } from './NavLandingPage'; @@ -12,6 +12,10 @@ describe('NavLandingPage', () => { components: [], isLoading: false, })); + setPluginLinksHook(() => ({ + links: [], + isLoading: false, + })); }); const mockSectionTitle = 'Section title'; diff --git a/public/app/core/components/NavLandingPage/NavLandingPage.tsx b/public/app/core/components/NavLandingPage/NavLandingPage.tsx index 4dd7a53d67a..769c37334bc 100644 --- a/public/app/core/components/NavLandingPage/NavLandingPage.tsx +++ b/public/app/core/components/NavLandingPage/NavLandingPage.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import * as React from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { usePluginComponents } from '@grafana/runtime'; +import { usePluginComponents, usePluginLinks } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; @@ -15,6 +15,7 @@ interface Props { } const EXTENSION_ID = (nodeId: string) => `grafana/dynamic/nav-landing-page/nav-id-${nodeId}/v1`; +const CARDS_EXTENSION_ID = (nodeId: string) => `grafana/dynamic/nav-landing-page/nav-id-${nodeId}/cards/v1`; export function NavLandingPage({ navId, header }: Props) { const { node } = useNavModel(navId); @@ -27,7 +28,23 @@ export function NavLandingPage({ navId, header }: Props) { extensionPointId: EXTENSION_ID(node.id ?? ''), }); - if (isLoading) { + const { links: additionalCards, isLoading: isLoadingCards } = usePluginLinks({ + extensionPointId: CARDS_EXTENSION_ID(node.id ?? ''), + context: { node }, + }); + + // Warn if both extension points are being used (they are mutually exclusive) + React.useEffect(() => { + if (components && components.length > 0 && additionalCards && additionalCards.length > 0) { + console.warn( + `[NavLandingPage] Both NavLandingPage and NavLandingPageCards extensions are registered for "${node.id}". ` + + `The NavLandingPage extension will take precedence and NavLandingPageCards will be ignored. ` + + `Please use only one extension point.` + ); + } + }, [components, additionalCards, node.id]); + + if (isLoading || isLoadingCards) { return null; } @@ -49,6 +66,16 @@ export function NavLandingPage({ navId, header }: Props) { url={child.url ?? ''} /> ))} + {additionalCards?.map((link) => ( + + ))} )} diff --git a/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx b/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx index 4eb223b4f59..3897066cbd7 100644 --- a/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx +++ b/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx @@ -1,4 +1,5 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; +import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Card, useStyles2 } from '@grafana/ui'; @@ -7,12 +8,24 @@ interface Props { description?: string; text: string; url: string; + category?: string; + onClick?: (event?: React.MouseEvent) => void; } -export function NavLandingPageCard({ description, text, url }: Props) { +const CATEGORY_STYLES = ['primary', 'secondary', 'success', 'warning', 'error'] as const; +type CategoryStyle = (typeof CATEGORY_STYLES)[number]; + +function isCategoryStyle(cat: string): cat is CategoryStyle { + return CATEGORY_STYLES.some((style) => style === cat); +} + +export function NavLandingPageCard({ description, text, url, category, onClick }: Props) { const styles = useStyles2(getStyles); + + const categoryClass = category && isCategoryStyle(category) ? styles[category] : undefined; + return ( - + {text} {description} @@ -31,4 +44,45 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: '-webkit-box', overflow: 'hidden', }), + // Category-based styling + primary: css({ + border: `1px solid ${theme.colors.primary.borderTransparent}`, + backgroundColor: theme.colors.primary.transparent, + '&:hover': { + backgroundColor: theme.colors.primary.transparent, + borderColor: theme.colors.primary.border, + }, + }), + secondary: css({ + border: `1px solid ${theme.colors.secondary.borderTransparent}`, + backgroundColor: theme.colors.secondary.transparent, + '&:hover': { + backgroundColor: theme.colors.secondary.transparent, + borderColor: theme.colors.secondary.border, + }, + }), + success: css({ + border: `1px solid ${theme.colors.success.borderTransparent}`, + backgroundColor: theme.colors.success.transparent, + '&:hover': { + backgroundColor: theme.colors.success.transparent, + borderColor: theme.colors.success.border, + }, + }), + warning: css({ + border: `1px solid ${theme.colors.warning.borderTransparent}`, + backgroundColor: theme.colors.warning.transparent, + '&:hover': { + backgroundColor: theme.colors.warning.transparent, + borderColor: theme.colors.warning.border, + }, + }), + error: css({ + border: `1px solid ${theme.colors.error.borderTransparent}`, + backgroundColor: theme.colors.error.transparent, + '&:hover': { + backgroundColor: theme.colors.error.transparent, + borderColor: theme.colors.error.border, + }, + }), });