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