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