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
This commit is contained in:
Eric Hilse
2025-11-19 11:19:14 -07:00
committed by GitHub
parent e9c959a0e5
commit f074431a36
6 changed files with 108 additions and 6 deletions
@@ -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 {
@@ -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": [
@@ -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
@@ -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';
@@ -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) => (
<NavLandingPageCard
key={link.id}
description={link.description}
text={link.title}
url={link.path ?? ''}
category={link.category}
onClick={link.onClick}
/>
))}
</section>
)}
</div>
@@ -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 (
<Card noMargin className={styles.card} href={url}>
<Card noMargin className={cx(styles.card, categoryClass)} href={url} onClick={onClick}>
<Card.Heading>{text}</Card.Heading>
<Card.Description className={styles.description}>{description}</Card.Description>
</Card>
@@ -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,
},
}),
});