mirror of https://github.com/grafana/grafana
Sidebar: Create a sidebar that can render an extension (#102626)
* Extension Sidebar: Add missing `web-section` icon * Extension Sidebar: Add core extension sidebar components * Extension Sidebar: Integrate extension sidebar into Grafana * Extension Sidebar: Change extension point to alpha * Extension Sidebar: Fix saved state of docked extensions * Extension Sidebar: Delete from local storage if undocked * Extension Sidebar: Move main scrollbar from body to pane * Extension Sidebar: Expose `ExtensionInfo` * Extension Sidebar: Move `useComponents` into ExtensionSidebar * Extension Sidebar: Store selection in `localStorage` * Extension Sidebar: Simplify return of extension point meta * Extension Sidebar: Ensure `body` is scrollable when sidebar is closed * Extension Sidebar: Add missing `GlobalStyles` change * Extension Sidebar: Don't render `ExtensionSidebar` unless it should be open * Extension Sidebar: Better toggle handling * Extension Sidebar: Fix wrong header height * Extension Sidebar: Change `getExtensionPointPluginMeta` to use `addedComponents` and add documentation * Extension Sidebar: Add tests for `getExtensionPointPluginMeta` * Extension Sidebar: Add tests for `ExtensionSidebarProvider` * Extension Sidebar: Fix tests `ExtensionSidebarProvider` * Extension Sidebar: Add tests `ExtensionToolbarItem` * Extension Sidebar: Add `extensionSidebar` feature toggle * Extension Sidebar: Put implementation behind `extensionSidebar` feature toggle * update feature toggles * fix lint * Extension Sidebar: Only toggle if clicking the same button * Extension Sidebar: Hide sidebar if chromeless * Update feature toggles doc * Sidebar: Add `isEnabled` to ExtensionSidebarProvider * Extension Sidebar: Use conditional CSS classes * Extension Sidebar: Move header height to GrafanaContext * Sidebar: Adapt to feature toggle change * Sidebar: Remove unused import * Sidebar: Keep featuretoggles in ExtensionSidebar tests * ProviderConfig: Keep `config` import in tests * FeatureToggles: adapt to docs review * fix typopull/103255/head
parent
b97b1cc730
commit
f277902682
|
@ -0,0 +1,64 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { usePluginComponents } from '@grafana/runtime'; |
||||||
|
import { useTheme2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { |
||||||
|
EXTENSION_SIDEBAR_EXTENSION_POINT_ID, |
||||||
|
getComponentMetaFromComponentId, |
||||||
|
useExtensionSidebarContext, |
||||||
|
} from './ExtensionSidebarProvider'; |
||||||
|
|
||||||
|
export const EXTENSION_SIDEBAR_WIDTH = '300px'; |
||||||
|
|
||||||
|
export function ExtensionSidebar() { |
||||||
|
const styles = getStyles(useTheme2()); |
||||||
|
const { dockedComponentId, isEnabled } = useExtensionSidebarContext(); |
||||||
|
const { components, isLoading } = usePluginComponents({ extensionPointId: EXTENSION_SIDEBAR_EXTENSION_POINT_ID }); |
||||||
|
|
||||||
|
if (isLoading || !dockedComponentId || !isEnabled) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const dockedMeta = getComponentMetaFromComponentId(dockedComponentId); |
||||||
|
if (!dockedMeta) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const ExtensionComponent = components.find( |
||||||
|
(c) => c.meta.pluginId === dockedMeta.pluginId && c.meta.title === dockedMeta.componentTitle |
||||||
|
); |
||||||
|
|
||||||
|
if (!ExtensionComponent) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.sidebarWrapper}> |
||||||
|
<div className={styles.content}> |
||||||
|
<ExtensionComponent /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
sidebarWrapper: css({ |
||||||
|
backgroundColor: theme.colors.background.primary, |
||||||
|
borderLeft: `1px solid ${theme.colors.border.weak}`, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(1), |
||||||
|
padding: theme.spacing(1), |
||||||
|
width: EXTENSION_SIDEBAR_WIDTH, |
||||||
|
height: '100%', |
||||||
|
}), |
||||||
|
content: css({ |
||||||
|
flex: 1, |
||||||
|
minHeight: 0, |
||||||
|
overflow: 'auto', |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
@ -0,0 +1,241 @@ |
|||||||
|
import { render, screen, act } from '@testing-library/react'; |
||||||
|
// import { render } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { store } from '@grafana/data'; |
||||||
|
import { config } from '@grafana/runtime'; |
||||||
|
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; |
||||||
|
|
||||||
|
import { |
||||||
|
ExtensionSidebarContextProvider, |
||||||
|
useExtensionSidebarContext, |
||||||
|
getComponentIdFromComponentMeta, |
||||||
|
getComponentMetaFromComponentId, |
||||||
|
EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY, |
||||||
|
} from './ExtensionSidebarProvider'; |
||||||
|
|
||||||
|
// Mock the store
|
||||||
|
jest.mock('@grafana/data', () => ({ |
||||||
|
...jest.requireActual('@grafana/data'), |
||||||
|
store: { |
||||||
|
get: jest.fn(), |
||||||
|
set: jest.fn(), |
||||||
|
delete: jest.fn(), |
||||||
|
}, |
||||||
|
})); |
||||||
|
|
||||||
|
// Mock the extension point plugin meta
|
||||||
|
jest.mock('app/features/plugins/extensions/utils', () => ({ |
||||||
|
...jest.requireActual('app/features/plugins/extensions/utils'), |
||||||
|
getExtensionPointPluginMeta: jest.fn(), |
||||||
|
})); |
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({ |
||||||
|
...jest.requireActual('@grafana/runtime'), |
||||||
|
config: { |
||||||
|
...jest.requireActual('@grafana/runtime').config, |
||||||
|
featureToggles: { |
||||||
|
...jest.requireActual('@grafana/runtime').config.featureToggles, |
||||||
|
extensionSidebar: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
})); |
||||||
|
|
||||||
|
const mockComponent = { |
||||||
|
title: 'Test Component', |
||||||
|
description: 'Test Description', |
||||||
|
targets: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const mockPluginMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [mockComponent], |
||||||
|
}; |
||||||
|
|
||||||
|
describe('ExtensionSidebarProvider', () => { |
||||||
|
beforeEach(() => { |
||||||
|
jest.clearAllMocks(); |
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]])); |
||||||
|
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true); |
||||||
|
}); |
||||||
|
|
||||||
|
const TestComponent = () => { |
||||||
|
const context = useExtensionSidebarContext(); |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<div data-testid="is-open">{context.isOpen.toString()}</div> |
||||||
|
<div data-testid="docked-component-id">{context.dockedComponentId || 'undefined'}</div> |
||||||
|
<div data-testid="available-components-size">{context.availableComponents.size}</div> |
||||||
|
<div data-testid="plugin-ids">{Array.from(context.availableComponents.keys()).join(', ')}</div> |
||||||
|
<div data-testid="is-enabled">{context.isEnabled.toString()}</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
it('should provide default context values', () => { |
||||||
|
render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<TestComponent /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
|
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('false'); |
||||||
|
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('undefined'); |
||||||
|
expect(screen.getByTestId('available-components-size')).toHaveTextContent('1'); |
||||||
|
expect(screen.getByTestId('is-enabled')).toHaveTextContent('true'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should have empty available components when feature toggle is disabled', () => { |
||||||
|
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false); |
||||||
|
|
||||||
|
render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<TestComponent /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
|
||||||
|
expect(screen.getByTestId('is-enabled')).toHaveTextContent('false'); |
||||||
|
expect(screen.getByTestId('available-components-size')).toHaveTextContent('0'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should load docked component from storage if available', () => { |
||||||
|
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); |
||||||
|
(store.get as jest.Mock).mockReturnValue(componentId); |
||||||
|
|
||||||
|
render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<TestComponent /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
|
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('true'); |
||||||
|
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(componentId); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not load docked component from storage if feature toggle is disabled', () => { |
||||||
|
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false); |
||||||
|
|
||||||
|
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); |
||||||
|
(store.get as jest.Mock).mockReturnValue(componentId); |
||||||
|
|
||||||
|
render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<TestComponent /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
|
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('false'); |
||||||
|
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('undefined'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should update storage when docked component changes', () => { |
||||||
|
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); |
||||||
|
|
||||||
|
const TestComponentWithActions = () => { |
||||||
|
const context = useExtensionSidebarContext(); |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
context.setDockedComponentId(componentId); |
||||||
|
}} |
||||||
|
> |
||||||
|
Set Component |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
context.setDockedComponentId(undefined); |
||||||
|
}} |
||||||
|
> |
||||||
|
Clear Component |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<TestComponentWithActions /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
|
||||||
|
act(() => { |
||||||
|
screen.getByText('Set Component').click(); |
||||||
|
}); |
||||||
|
|
||||||
|
expect(store.set).toHaveBeenCalledWith(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY, componentId); |
||||||
|
|
||||||
|
act(() => { |
||||||
|
screen.getByText('Clear Component').click(); |
||||||
|
}); |
||||||
|
|
||||||
|
expect(store.delete).toHaveBeenCalledWith(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should only include permitted plugins in available components', () => { |
||||||
|
const permittedPluginMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [mockComponent], |
||||||
|
}; |
||||||
|
|
||||||
|
const prohibitedPluginMeta = { |
||||||
|
pluginId: 'disabled-plugin', |
||||||
|
addedComponents: [mockComponent], |
||||||
|
}; |
||||||
|
|
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue( |
||||||
|
new Map([ |
||||||
|
[permittedPluginMeta.pluginId, permittedPluginMeta], |
||||||
|
[prohibitedPluginMeta.pluginId, prohibitedPluginMeta], |
||||||
|
]) |
||||||
|
); |
||||||
|
|
||||||
|
render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<TestComponent /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
|
||||||
|
// Should only include the enabled plugin
|
||||||
|
expect(screen.getByTestId('available-components-size')).toHaveTextContent('1'); |
||||||
|
expect(screen.getByTestId('plugin-ids')).toHaveTextContent(permittedPluginMeta.pluginId); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Utility Functions', () => { |
||||||
|
describe('getComponentIdFromComponentMeta', () => { |
||||||
|
it('should create a valid component ID', () => { |
||||||
|
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); |
||||||
|
|
||||||
|
expect(componentId).toBe( |
||||||
|
JSON.stringify({ pluginId: mockPluginMeta.pluginId, componentTitle: mockComponent.title }) |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('getComponentMetaFromComponentId', () => { |
||||||
|
it('should parse a valid component ID', () => { |
||||||
|
const componentId = getComponentIdFromComponentMeta(mockPluginMeta.pluginId, mockComponent); |
||||||
|
|
||||||
|
const meta = getComponentMetaFromComponentId(componentId); |
||||||
|
expect(meta).toEqual({ |
||||||
|
pluginId: mockPluginMeta.pluginId, |
||||||
|
componentTitle: mockComponent.title, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return undefined for invalid JSON', () => { |
||||||
|
const meta = getComponentMetaFromComponentId('invalid-json'); |
||||||
|
expect(meta).toBeUndefined(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return undefined for missing required fields', () => { |
||||||
|
const meta = getComponentMetaFromComponentId(JSON.stringify({ pluginId: mockPluginMeta.pluginId })); |
||||||
|
expect(meta).toBeUndefined(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return undefined for wrong field types', () => { |
||||||
|
const meta = getComponentMetaFromComponentId(JSON.stringify({ pluginId: 123, componentTitle: 'Test Component' })); |
||||||
|
expect(meta).toBeUndefined(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,132 @@ |
|||||||
|
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'; |
||||||
|
|
||||||
|
import { store, type ExtensionInfo } from '@grafana/data'; |
||||||
|
import { config } from '@grafana/runtime'; |
||||||
|
import { ExtensionPointPluginMeta, getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; |
||||||
|
|
||||||
|
export const EXTENSION_SIDEBAR_EXTENSION_POINT_ID = 'grafana/extension-sidebar/v0-alpha'; |
||||||
|
export const EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarDocked'; |
||||||
|
const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = ['grafana-investigations-app']; |
||||||
|
|
||||||
|
type ExtensionSidebarContextType = { |
||||||
|
/** |
||||||
|
* Whether the extension sidebar is enabled. |
||||||
|
*/ |
||||||
|
isEnabled: boolean; |
||||||
|
/** |
||||||
|
* Whether the extension sidebar is open. |
||||||
|
*/ |
||||||
|
isOpen: boolean; |
||||||
|
/** |
||||||
|
* The id of the component that is currently docked in the sidebar. If the id is undefined, nothing will be rendered. |
||||||
|
*/ |
||||||
|
dockedComponentId: string | undefined; |
||||||
|
/** |
||||||
|
* Sest the id of the component that will be rendered in the extension sidebar. |
||||||
|
*/ |
||||||
|
setDockedComponentId: (componentId: string | undefined) => void; |
||||||
|
/** |
||||||
|
* A map of all components that are available for the extension point. |
||||||
|
*/ |
||||||
|
availableComponents: ExtensionPointPluginMeta; |
||||||
|
}; |
||||||
|
|
||||||
|
export const ExtensionSidebarContext = createContext<ExtensionSidebarContextType>({ |
||||||
|
isEnabled: !!config.featureToggles.extensionSidebar, |
||||||
|
isOpen: false, |
||||||
|
dockedComponentId: undefined, |
||||||
|
setDockedComponentId: () => {}, |
||||||
|
availableComponents: new Map(), |
||||||
|
}); |
||||||
|
|
||||||
|
export function useExtensionSidebarContext() { |
||||||
|
return useContext(ExtensionSidebarContext); |
||||||
|
} |
||||||
|
|
||||||
|
interface ExtensionSidebarContextProps { |
||||||
|
children: ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
export const ExtensionSidebarContextProvider = ({ children }: ExtensionSidebarContextProps) => { |
||||||
|
const storedDockedPluginId = store.get(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY); |
||||||
|
const isEnabled = !!config.featureToggles.extensionSidebar; |
||||||
|
// get all components for this extension point, but only for the permitted plugins
|
||||||
|
// if the extension sidebar is not enabled, we will return an empty map
|
||||||
|
const availableComponents = isEnabled |
||||||
|
? new Map( |
||||||
|
Array.from(getExtensionPointPluginMeta(EXTENSION_SIDEBAR_EXTENSION_POINT_ID).entries()).filter(([pluginId]) => |
||||||
|
PERMITTED_EXTENSION_SIDEBAR_PLUGINS.includes(pluginId) |
||||||
|
) |
||||||
|
) |
||||||
|
: new Map< |
||||||
|
string, |
||||||
|
{ |
||||||
|
readonly addedComponents: ExtensionInfo[]; |
||||||
|
readonly addedLinks: ExtensionInfo[]; |
||||||
|
} |
||||||
|
>(); |
||||||
|
|
||||||
|
// check if the stored docked component is still available
|
||||||
|
let defaultDockedComponentId: string | undefined; |
||||||
|
if (storedDockedPluginId) { |
||||||
|
const dockedMeta = getComponentMetaFromComponentId(storedDockedPluginId); |
||||||
|
if (dockedMeta) { |
||||||
|
const plugin = availableComponents.get(dockedMeta.pluginId); |
||||||
|
if (plugin) { |
||||||
|
const component = plugin.addedComponents.find((c) => c.title === dockedMeta.componentTitle); |
||||||
|
if (component) { |
||||||
|
defaultDockedComponentId = storedDockedPluginId; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
const [dockedComponentId, setDockedComponentId] = useState<string | undefined>(defaultDockedComponentId); |
||||||
|
|
||||||
|
// update the stored docked component id when it changes
|
||||||
|
useEffect(() => { |
||||||
|
if (dockedComponentId) { |
||||||
|
store.set(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY, dockedComponentId); |
||||||
|
} else { |
||||||
|
store.delete(EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY); |
||||||
|
} |
||||||
|
}, [dockedComponentId]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<ExtensionSidebarContext.Provider |
||||||
|
value={{ |
||||||
|
isEnabled, |
||||||
|
isOpen: isEnabled && dockedComponentId !== undefined, |
||||||
|
dockedComponentId, |
||||||
|
setDockedComponentId, |
||||||
|
availableComponents, |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</ExtensionSidebarContext.Provider> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export function getComponentIdFromComponentMeta(pluginId: string, component: ExtensionInfo) { |
||||||
|
return JSON.stringify({ pluginId, componentTitle: component.title }); |
||||||
|
} |
||||||
|
|
||||||
|
export function getComponentMetaFromComponentId( |
||||||
|
componentId: string |
||||||
|
): { pluginId: string; componentTitle: string } | undefined { |
||||||
|
try { |
||||||
|
const parsed = JSON.parse(componentId); |
||||||
|
if ( |
||||||
|
typeof parsed === 'object' && |
||||||
|
parsed !== null && |
||||||
|
'pluginId' in parsed && |
||||||
|
'componentTitle' in parsed && |
||||||
|
typeof parsed.pluginId === 'string' && |
||||||
|
typeof parsed.componentTitle === 'string' |
||||||
|
) { |
||||||
|
return parsed; |
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} catch (error) { |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,209 @@ |
|||||||
|
import { render, screen } from '@testing-library/react'; |
||||||
|
import userEvent from '@testing-library/user-event'; |
||||||
|
|
||||||
|
import { store } from '@grafana/data'; |
||||||
|
import { config } from '@grafana/runtime'; |
||||||
|
import { getExtensionPointPluginMeta } from 'app/features/plugins/extensions/utils'; |
||||||
|
|
||||||
|
import { ExtensionSidebarContextProvider, useExtensionSidebarContext } from './ExtensionSidebarProvider'; |
||||||
|
import { ExtensionToolbarItem } from './ExtensionToolbarItem'; |
||||||
|
|
||||||
|
// Mock the extension point plugin meta
|
||||||
|
jest.mock('app/features/plugins/extensions/utils', () => ({ |
||||||
|
...jest.requireActual('app/features/plugins/extensions/utils'), |
||||||
|
getExtensionPointPluginMeta: jest.fn(), |
||||||
|
})); |
||||||
|
|
||||||
|
// Mock store
|
||||||
|
jest.mock('@grafana/data', () => ({ |
||||||
|
...jest.requireActual('@grafana/data'), |
||||||
|
store: { |
||||||
|
get: jest.fn(), |
||||||
|
set: jest.fn(), |
||||||
|
delete: jest.fn(), |
||||||
|
}, |
||||||
|
})); |
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({ |
||||||
|
...jest.requireActual('@grafana/runtime'), |
||||||
|
config: { |
||||||
|
...jest.requireActual('@grafana/runtime').config, |
||||||
|
featureToggles: { |
||||||
|
...jest.requireActual('@grafana/runtime').config.featureToggles, |
||||||
|
extensionSidebar: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
})); |
||||||
|
|
||||||
|
const mockComponent = { |
||||||
|
title: 'Test Component', |
||||||
|
description: 'Test Description', |
||||||
|
targets: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const mockPluginMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [mockComponent], |
||||||
|
}; |
||||||
|
|
||||||
|
const TestComponent = () => { |
||||||
|
const { isOpen, dockedComponentId } = useExtensionSidebarContext(); |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<div data-testid="is-open">{isOpen.toString()}</div> |
||||||
|
<div data-testid="docked-component-id">{dockedComponentId}</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const setup = () => { |
||||||
|
return render( |
||||||
|
<ExtensionSidebarContextProvider> |
||||||
|
<ExtensionToolbarItem /> |
||||||
|
<TestComponent /> |
||||||
|
</ExtensionSidebarContextProvider> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
describe('ExtensionToolbarItem', () => { |
||||||
|
beforeEach(() => { |
||||||
|
jest.clearAllMocks(); |
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map([[mockPluginMeta.pluginId, mockPluginMeta]])); |
||||||
|
(store.get as jest.Mock).mockClear(); |
||||||
|
(store.set as jest.Mock).mockClear(); |
||||||
|
(store.delete as jest.Mock).mockClear(); |
||||||
|
jest.replaceProperty(config.featureToggles, 'extensionSidebar', true); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not render when feature toggle is disabled', () => { |
||||||
|
jest.replaceProperty(config.featureToggles, 'extensionSidebar', false); |
||||||
|
setup(); |
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should not render when no components are available', () => { |
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue(new Map()); |
||||||
|
setup(); |
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should render a single button when only one component is available', () => { |
||||||
|
setup(); |
||||||
|
|
||||||
|
const button = screen.getByTestId('extension-toolbar-button'); |
||||||
|
expect(button).toBeInTheDocument(); |
||||||
|
expect(button).toHaveAttribute('aria-label', mockComponent.description); |
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('false'); |
||||||
|
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(''); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should toggle the sidebar when clicking a single component button', async () => { |
||||||
|
setup(); |
||||||
|
|
||||||
|
const button = screen.getByTestId('extension-toolbar-button'); |
||||||
|
await userEvent.click(button); |
||||||
|
|
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('true'); |
||||||
|
expect(screen.getByTestId('docked-component-id')).toHaveTextContent(mockComponent.title); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should render a dropdown menu when multiple components are available', async () => { |
||||||
|
const multipleComponentsMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [ |
||||||
|
{ ...mockComponent, title: 'Component 1' }, |
||||||
|
{ ...mockComponent, title: 'Component 2' }, |
||||||
|
], |
||||||
|
}; |
||||||
|
|
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue( |
||||||
|
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]]) |
||||||
|
); |
||||||
|
|
||||||
|
setup(); |
||||||
|
|
||||||
|
const button = screen.getByTestId('extension-toolbar-button'); |
||||||
|
expect(button).toBeInTheDocument(); |
||||||
|
|
||||||
|
await userEvent.click(button); |
||||||
|
expect(screen.getAllByRole('menuitem')).toHaveLength(multipleComponentsMeta.addedComponents.length); |
||||||
|
expect(screen.getByText(multipleComponentsMeta.addedComponents[0].title)).toBeInTheDocument(); |
||||||
|
expect(screen.getByText(multipleComponentsMeta.addedComponents[1].title)).toBeInTheDocument(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should show menu items when clicking the dropdown button', async () => { |
||||||
|
const multipleComponentsMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [ |
||||||
|
{ ...mockComponent, title: 'Component 1' }, |
||||||
|
{ ...mockComponent, title: 'Component 2' }, |
||||||
|
], |
||||||
|
}; |
||||||
|
|
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue( |
||||||
|
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]]) |
||||||
|
); |
||||||
|
|
||||||
|
setup(); |
||||||
|
|
||||||
|
const button = screen.getByTestId('extension-toolbar-button'); |
||||||
|
await userEvent.click(button); |
||||||
|
|
||||||
|
// Menu items should be visible
|
||||||
|
expect(screen.getByText('Component 1')).toBeInTheDocument(); |
||||||
|
expect(screen.getByText('Component 2')).toBeInTheDocument(); |
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('false'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should toggle the sidebar when clicking a menu item', async () => { |
||||||
|
const multipleComponentsMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [ |
||||||
|
{ ...mockComponent, title: 'Component 1' }, |
||||||
|
{ ...mockComponent, title: 'Component 2' }, |
||||||
|
], |
||||||
|
}; |
||||||
|
|
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue( |
||||||
|
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]]) |
||||||
|
); |
||||||
|
|
||||||
|
setup(); |
||||||
|
|
||||||
|
// Open the dropdown
|
||||||
|
const button = screen.getByTestId('extension-toolbar-button'); |
||||||
|
await userEvent.click(button); |
||||||
|
|
||||||
|
// Click a menu item
|
||||||
|
await userEvent.click(screen.getByText('Component 1')); |
||||||
|
|
||||||
|
// The button should now be active
|
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('true'); |
||||||
|
expect(screen.getByTestId('docked-component-id')).toHaveTextContent('Component 1'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should close the sidebar when clicking an active menu item', async () => { |
||||||
|
const multipleComponentsMeta = { |
||||||
|
pluginId: 'grafana-investigations-app', |
||||||
|
addedComponents: [ |
||||||
|
{ ...mockComponent, title: 'Component 1' }, |
||||||
|
{ ...mockComponent, title: 'Component 2' }, |
||||||
|
], |
||||||
|
}; |
||||||
|
|
||||||
|
(getExtensionPointPluginMeta as jest.Mock).mockReturnValue( |
||||||
|
new Map([[multipleComponentsMeta.pluginId, multipleComponentsMeta]]) |
||||||
|
); |
||||||
|
|
||||||
|
setup(); |
||||||
|
|
||||||
|
const button = screen.getByTestId('extension-toolbar-button'); |
||||||
|
await userEvent.click(button); |
||||||
|
await userEvent.click(screen.getByText('Component 1')); |
||||||
|
|
||||||
|
await userEvent.click(button); |
||||||
|
await userEvent.click(screen.getByText('Component 1')); |
||||||
|
|
||||||
|
expect(screen.getByTestId('is-open')).toHaveTextContent('false'); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,74 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
|
||||||
|
import { Dropdown, Menu, ToolbarButton } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { getComponentIdFromComponentMeta, useExtensionSidebarContext } from './ExtensionSidebarProvider'; |
||||||
|
|
||||||
|
export function ExtensionToolbarItem() { |
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false); |
||||||
|
const { availableComponents, dockedComponentId, setDockedComponentId, isOpen, isEnabled } = |
||||||
|
useExtensionSidebarContext(); |
||||||
|
|
||||||
|
if (!isEnabled || availableComponents.size === 0) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// get a flat list of all components with their pluginId
|
||||||
|
const components = Array.from(availableComponents.entries()).flatMap(([pluginId, { addedComponents }]) => |
||||||
|
addedComponents.map((c) => ({ ...c, pluginId })) |
||||||
|
); |
||||||
|
|
||||||
|
if (components.length === 0) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (components.length === 1) { |
||||||
|
return ( |
||||||
|
<ToolbarButton |
||||||
|
icon="web-section" |
||||||
|
data-testid="extension-toolbar-button" |
||||||
|
variant={isOpen ? 'active' : 'default'} |
||||||
|
tooltip={components[0].description} |
||||||
|
onClick={() => { |
||||||
|
if (isOpen) { |
||||||
|
setDockedComponentId(undefined); |
||||||
|
} else { |
||||||
|
setDockedComponentId(getComponentIdFromComponentMeta(components[0].pluginId, components[0])); |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const MenuItems = ( |
||||||
|
<Menu> |
||||||
|
{components.map((c) => { |
||||||
|
const id = getComponentIdFromComponentMeta(c.pluginId, c); |
||||||
|
return ( |
||||||
|
<Menu.Item |
||||||
|
key={id} |
||||||
|
active={dockedComponentId === id} |
||||||
|
label={c.title} |
||||||
|
onClick={() => { |
||||||
|
if (isOpen && dockedComponentId === id) { |
||||||
|
setDockedComponentId(undefined); |
||||||
|
} else { |
||||||
|
setDockedComponentId(id); |
||||||
|
} |
||||||
|
}} |
||||||
|
/> |
||||||
|
); |
||||||
|
})} |
||||||
|
</Menu> |
||||||
|
); |
||||||
|
return ( |
||||||
|
<Dropdown overlay={MenuItems} onVisibleChange={setIsMenuOpen} placement="bottom-end"> |
||||||
|
<ToolbarButton |
||||||
|
data-testid="extension-toolbar-button" |
||||||
|
icon="web-section" |
||||||
|
isOpen={isMenuOpen} |
||||||
|
variant={isOpen ? 'active' : 'default'} |
||||||
|
/> |
||||||
|
</Dropdown> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
import { config } from '@grafana/runtime'; |
||||||
|
import { GlobalStyles } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { useExtensionSidebarContext } from './ExtensionSidebarProvider'; |
||||||
|
|
||||||
|
/** |
||||||
|
* This component is used to wrap the GlobalStyles component and pass the isExtensionSidebarOpen prop to it. |
||||||
|
* Since GlobalStyles is imported from @grafana/ui, we need to wrap it in a component to use the useExtensionSidebarContext hook. |
||||||
|
*/ |
||||||
|
export const GlobalStylesWrapper = () => { |
||||||
|
const { isOpen } = useExtensionSidebarContext(); |
||||||
|
|
||||||
|
return <GlobalStyles hackNoBackdropBlur={config.featureToggles.noBackdropBlur} isExtensionSidebarOpen={isOpen} />; |
||||||
|
}; |
Loading…
Reference in new issue