mirror of https://github.com/grafana/grafana.git
PluginExtensions: Add extension point for overriding Observability home page (#110500)
* feat/add_observability_landing * Add check for observability path * Fix existing tests * Test that we're rendering the component when in the correct path * Reset all mocks after testing * Check for extension only on observability route * Undo changes to tests * Extract strings to constants * Remove unused validator * Remove unnecesary ObservabilityLanding component * Update subtitle for Observability section * Use proper ' * Expose extension point, allow plugins to hook into it, and render received components * Fix and test * Remove no longer needed unit tests * Readd validation checks, allow for regex like paths * refactor(extensions): extract dynamic extension point ids to a separate enum * Undo unwanted const to let change * Update extension point id to better transmit intent and use --------- Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
parent
0d6827eb75
commit
646dd8de06
|
@ -571,6 +571,7 @@ export {
|
|||
PluginExtensionTypes,
|
||||
PluginExtensionPoints,
|
||||
PluginExtensionExposedComponents,
|
||||
PluginExtensionPointPatterns,
|
||||
type PluginExtension,
|
||||
type PluginExtensionLink,
|
||||
type PluginExtensionComponent,
|
||||
|
|
|
@ -208,6 +208,13 @@ export enum PluginExtensionPoints {
|
|||
ExtensionSidebar = 'grafana/extension-sidebar/v0-alpha',
|
||||
}
|
||||
|
||||
// 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.)
|
||||
export enum PluginExtensionPointPatterns {
|
||||
NavLandingPage = 'grafana/dynamic/nav-landing-page/nav-id-.*/v1',
|
||||
}
|
||||
|
||||
// Extension Points available in plugins
|
||||
export enum PluginExtensionExposedComponents {
|
||||
CentralAlertHistorySceneV1 = 'grafana/central-alert-history-scene/v1',
|
||||
|
|
|
@ -236,7 +236,7 @@ func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *n
|
|||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Observability",
|
||||
Id: navtree.NavIDObservability,
|
||||
SubTitle: "Opinionated observability across applications, services, and infrastructure",
|
||||
SubTitle: "Monitor infrastructure and applications in real time with Grafana Cloud's fully managed observability suite",
|
||||
Icon: "heart-rate",
|
||||
SortWeight: navtree.WeightObservability,
|
||||
Children: []*navtree.NavLink{appLink},
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { config, setPluginComponentsHook } from '@grafana/runtime';
|
||||
import { createComponentWithMeta } from 'app/features/plugins/extensions/usePluginComponents';
|
||||
|
||||
import { NavLandingPage } from './NavLandingPage';
|
||||
|
||||
describe('NavLandingPage', () => {
|
||||
beforeEach(() => {
|
||||
setPluginComponentsHook(() => ({
|
||||
components: [],
|
||||
isLoading: false,
|
||||
}));
|
||||
});
|
||||
|
||||
const mockSectionTitle = 'Section title';
|
||||
const mockId = 'section';
|
||||
const mockSectionUrl = 'mock-section-url';
|
||||
|
@ -83,4 +91,23 @@ describe('NavLandingPage', () => {
|
|||
setup(true);
|
||||
expect(screen.getByRole('heading', { name: 'Custom Header' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the ObservabilityLandingPage when the path is /observability', () => {
|
||||
setPluginComponentsHook(() => ({
|
||||
components: [
|
||||
createComponentWithMeta(
|
||||
{
|
||||
title: 'Landing Page',
|
||||
description: 'Landing Page description',
|
||||
component: () => <div>ObservabilityLandingPage</div>,
|
||||
pluginId: 'grafana-plugin-app',
|
||||
},
|
||||
'grafana/dynamic/nav-landing-page/nav-id-observability/v1'
|
||||
),
|
||||
],
|
||||
isLoading: false,
|
||||
}));
|
||||
setup();
|
||||
expect(screen.getByText('ObservabilityLandingPage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, NavModelItem } from '@grafana/data';
|
||||
import { usePluginComponents } from '@grafana/runtime';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { useNavModel } from 'app/core/hooks/useNavModel';
|
||||
|
@ -13,29 +14,45 @@ interface Props {
|
|||
header?: React.ReactNode;
|
||||
}
|
||||
|
||||
const EXTENSION_ID = (nodeId: string) => `grafana/dynamic/nav-landing-page/nav-id-${nodeId}/v1`;
|
||||
|
||||
export function NavLandingPage({ navId, header }: Props) {
|
||||
const { node } = useNavModel(navId);
|
||||
const styles = useStyles2(getStyles);
|
||||
const children = node.children?.filter((child) => !child.hideFromTabs);
|
||||
|
||||
const { components, isLoading } = usePluginComponents<{
|
||||
node: NavModelItem;
|
||||
}>({
|
||||
extensionPointId: EXTENSION_ID(node.id ?? ''),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page navId={node.id}>
|
||||
<Page.Contents>
|
||||
<div className={styles.content}>
|
||||
{header}
|
||||
{children && children.length > 0 && (
|
||||
<section className={styles.grid}>
|
||||
{children?.map((child) => (
|
||||
<NavLandingPageCard
|
||||
key={child.id}
|
||||
description={child.subTitle}
|
||||
text={child.text}
|
||||
url={child.url ?? ''}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
{components?.length > 0 ? (
|
||||
components.map((Component, idx) => <Component key={idx} node={node} />)
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
{header}
|
||||
{children && children.length > 0 && (
|
||||
<section className={styles.grid}>
|
||||
{children?.map((child) => (
|
||||
<NavLandingPageCard
|
||||
key={child.id}
|
||||
description={child.subTitle}
|
||||
text={child.text}
|
||||
url={child.url ?? ''}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
|
|
@ -211,6 +211,7 @@ describe('Plugin Extension Validators', () => {
|
|||
['plugins/grafana-oncall-app/alert-group/action', 'grafana-oncall-app'],
|
||||
['plugins/grafana-oncall-app/alert-group/action/v1', 'grafana-oncall-app'],
|
||||
['plugins/grafana-oncall-app/alert-group/action/v1.0.0', 'grafana-oncall-app'],
|
||||
['grafana/dynamic/nav-landing-page/nav-id-observability/v1', 'grafana'], // this a dynamic (runtime evaluated) extension point id
|
||||
])('should return TRUE if the extension point id is valid ("%s", "%s")', (extensionPointId, pluginId) => {
|
||||
expect(
|
||||
isExtensionPointIdValid({
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
type PluginExtensionExposedComponentConfig,
|
||||
type PluginExtensionAddedFunctionConfig,
|
||||
PluginExtensionPoints,
|
||||
PluginExtensionPointPatterns,
|
||||
} from '@grafana/data';
|
||||
import { PluginAddedLinksConfigureFunc } from '@grafana/data/internal';
|
||||
import { config, isPluginExtensionLink } from '@grafana/runtime';
|
||||
|
@ -91,7 +92,13 @@ export function isExtensionPointIdValid({
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!isInsidePlugin && !Object.values<string>(PluginExtensionPoints).includes(extensionPointId)) {
|
||||
if (
|
||||
!isInsidePlugin &&
|
||||
!Object.values<string>(PluginExtensionPoints).includes(extensionPointId) &&
|
||||
!Object.values<string>(PluginExtensionPointPatterns).some((extensionPointPattern) =>
|
||||
extensionPointId.match(extensionPointPattern)
|
||||
)
|
||||
) {
|
||||
log.error(errors.INVALID_EXTENSION_POINT_ID_GRAFANA_EXPOSED);
|
||||
return false;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue