DashboardScene: Show plugin extensions in panel menu (#78702)

* DashboardScene: Show plugin extensions in panel menu

* FIx test

* Nits

* Nit

* Review nits
pull/78733/head
Dominik Prokop 2 years ago committed by GitHub
parent 777d119a80
commit 6e4418ffd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
  2. 2
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  3. 428
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.test.tsx
  4. 106
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx
  5. 1
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  6. 11
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
  7. 53
      public/app/features/dashboard/utils/getPanelMenu.ts
  8. 52
      public/app/features/plugins/extensions/utils.tsx

@ -6,7 +6,7 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { PanelProps } from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config, locationService, setPluginImportUtils } from '@grafana/runtime';
import { config, getPluginLinkExtensions, locationService, setPluginImportUtils } from '@grafana/runtime';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { setupLoadDashboardMock } from '../utils/test-utils';
@ -15,6 +15,8 @@ import { DashboardScenePage, Props } from './DashboardScenePage';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
getDataSourceSrv: () => {
return {
get: jest.fn().mockResolvedValue({}),
@ -23,6 +25,8 @@ jest.mock('@grafana/runtime', () => ({
},
}));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
function setup() {
const context = getGrafanaContextMock();
const props: Props = {
@ -97,6 +101,8 @@ describe('DashboardScenePage', () => {
// hacky way because mocking autosizer does not work
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 1000 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 1000 });
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
it('Can render dashboard', async () => {
@ -123,6 +129,7 @@ describe('DashboardScenePage', () => {
await userEvent.click(screen.getByLabelText('Menu for panel with title Panel B'));
const inspectMenuItem = await screen.findAllByText('Inspect');
act(() => fireEvent.click(inspectMenuItem[0]));
expect(await screen.findByText('Inspect: Panel B')).toBeInTheDocument();

@ -35,6 +35,8 @@ import { setupKeyboardShortcuts } from './keyboardShortcuts';
export interface DashboardSceneState extends SceneObjectState {
/** The title */
title: string;
/** Tags */
tags?: string[];
/** A uid when saved */
uid?: string;
/** @deprecated */

@ -1,6 +1,24 @@
import {
FieldType,
LoadingState,
PanelData,
PluginExtensionPanelContext,
PluginExtensionTypes,
getDefaultTimeRange,
toDataFrame,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { locationService } from '@grafana/runtime';
import { SceneGridItem, SceneGridLayout, SceneQueryRunner, VizPanel, VizPanelMenu } from '@grafana/scenes';
import { getPluginLinkExtensions, locationService } from '@grafana/runtime';
import {
LocalValueVariable,
SceneGridItem,
SceneGridLayout,
SceneQueryRunner,
SceneTimeRange,
SceneVariableSet,
VizPanel,
VizPanelMenu,
} from '@grafana/scenes';
import { contextSrv } from 'app/core/services/context_srv';
import { GetExploreUrlArguments } from 'app/core/utils/explore';
@ -21,7 +39,20 @@ jest.mock('app/core/utils/explore', () => ({
jest.mock('app/core/services/context_srv');
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
}));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
describe('panelMenuBehavior', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
beforeAll(() => {
locationService.push('/scenes/dashboard/dash-1?from=now-5m&to=now');
});
@ -51,7 +82,7 @@ describe('panelMenuBehavior', () => {
// Verify explore url is called with correct arguments
const getExploreArgs: GetExploreUrlArguments = mocks.getExploreUrl.mock.calls[0][0];
expect(getExploreArgs.dsRef).toEqual({ uid: 'my-uid' });
expect(getExploreArgs.queries).toEqual([{ query: 'buu', refId: 'A' }]);
expect(getExploreArgs.queries).toEqual([{ query: 'QueryA', refId: 'A' }]);
expect(getExploreArgs.scopedVars?.__sceneObject?.value).toBe(panel);
// verify inspect url keeps url params and adds inspect=<panel-key>
@ -60,6 +91,384 @@ describe('panelMenuBehavior', () => {
expect(menu.state.items?.[4].subMenu?.length).toBe(3);
});
describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
],
});
const { menu, panel } = await buildTestScene({});
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should truncate menu item title to 25 chars', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
],
});
const { menu, panel } = await buildTestScene({});
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident when...',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should pass onClick from plugin extension link to menu item', async () => {
const expectedOnClick = jest.fn();
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
onClick: expectedOnClick,
},
],
});
const { menu, panel } = await buildTestScene({});
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
const menuItem = extensionsSubMenu?.find((i) => (i.text = 'Declare incident when...'));
menuItem?.onClick?.({} as React.MouseEvent);
expect(expectedOnClick).toBeCalledTimes(1);
});
it('should pass context with correct values when configuring extension', async () => {
const data: PanelData = {
series: [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time },
{ name: 'score', type: FieldType.number },
],
}),
],
timeRange: getDefaultTimeRange(),
state: LoadingState.Done,
};
const { menu, panel } = await buildTestScene({});
panel.state.$data?.setState({ data });
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const context: PluginExtensionPanelContext = {
id: 12,
pluginId: 'table',
title: 'Panel A',
timeZone: 'Africa/Abidjan',
timeRange: {
from: 'now-5m',
to: 'now',
},
targets: [
{
refId: 'A',
// @ts-expect-error
query: 'QueryA',
},
],
dashboard: {
tags: ['database', 'panel'],
uid: 'dash-1',
title: 'My dashboard',
},
scopedVars: {
a: {
text: 'a',
value: 'a',
},
},
data,
};
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
});
it('should pass context with default time zone values when configuring extension', async () => {
const data: PanelData = {
series: [
toDataFrame({
fields: [
{ name: 'time', type: FieldType.time },
{ name: 'score', type: FieldType.number },
],
}),
],
timeRange: getDefaultTimeRange(),
state: LoadingState.Done,
};
const { menu, panel, scene } = await buildTestScene({});
panel.state.$data?.setState({ data });
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
scene.state.$timeRange?.setState({ timeZone: undefined });
menu.activate();
await new Promise((r) => setTimeout(r, 1));
const context: PluginExtensionPanelContext = {
id: 12,
pluginId: 'table',
title: 'Panel A',
timeZone: 'browser',
timeRange: {
from: 'now-5m',
to: 'now',
},
targets: [
{
refId: 'A',
// @ts-expect-error
query: 'QueryA',
},
],
dashboard: {
tags: ['database', 'panel'],
uid: 'dash-1',
title: 'My dashboard',
},
scopedVars: {
a: {
text: 'a',
value: 'a',
},
},
data,
};
expect(getPluginLinkExtensionsMock).toBeCalledWith(expect.objectContaining({ context }));
});
it('should contain menu item with category', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
],
});
const { menu, panel } = await buildTestScene({});
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Incident',
subMenu: expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident',
href: '/a/grafana-basic-app/declare-incident',
}),
]),
}),
])
);
});
it('should truncate category to 25 chars', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Declare incident when pressing this amazing menu item',
},
],
});
const { menu, panel } = await buildTestScene({});
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident when...',
subMenu: expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident',
href: '/a/grafana-basic-app/declare-incident',
}),
]),
}),
])
);
});
it('should contain menu item with category and append items without category after divider', async () => {
getPluginLinkExtensionsMock.mockReturnValue({
extensions: [
{
id: '1',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
category: 'Incident',
},
{
id: '2',
pluginId: '...',
type: PluginExtensionTypes.link,
title: 'Create forecast',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
},
],
});
const { menu, panel } = await buildTestScene({});
panel.getPlugin = () => getPanelPlugin({ skipDataQuery: false });
mocks.contextSrv.hasAccessToExplore.mockReturnValue(true);
mocks.getExploreUrl.mockReturnValue(Promise.resolve('/explore'));
menu.activate();
await new Promise((r) => setTimeout(r, 1));
expect(menu.state.items?.length).toBe(7);
const extensionsSubMenu = menu.state.items?.find((i) => i.text === 'Extensions')?.subMenu;
expect(extensionsSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Incident',
subMenu: expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident',
href: '/a/grafana-basic-app/declare-incident',
}),
]),
}),
expect.objectContaining({
type: 'divider',
}),
expect.objectContaining({
text: 'Create forecast',
}),
])
);
});
});
});
interface SceneOptions {}
@ -74,15 +483,24 @@ async function buildTestScene(options: SceneOptions) {
pluginId: 'table',
key: 'panel-12',
menu,
$variables: new SceneVariableSet({
variables: [new LocalValueVariable({ name: 'a', value: 'a', text: 'a' })],
}),
$data: new SceneQueryRunner({
datasource: { uid: 'my-uid' },
queries: [{ query: 'buu', refId: 'A' }],
queries: [{ query: 'QueryA', refId: 'A' }],
}),
});
const scene = new DashboardScene({
title: 'hello',
title: 'My dashboard',
uid: 'dash-1',
tags: ['database', 'panel'],
$timeRange: new SceneTimeRange({
from: 'now-5m',
to: 'now',
timeZone: 'Africa/Abidjan',
}),
meta: {
canEdit: true,
},

@ -1,10 +1,26 @@
import { InterpolateFunction, PanelMenuItem } from '@grafana/data';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes';
import {
InterpolateFunction,
PanelMenuItem,
PluginExtensionPanelContext,
PluginExtensionPoints,
getTimeZone,
} from '@grafana/data';
import { config, getPluginLinkExtensions, locationService, reportInteraction } from '@grafana/runtime';
import {
LocalValueVariable,
SceneDataTransformer,
SceneGridRow,
SceneQueryRunner,
VizPanel,
VizPanelMenu,
sceneGraph,
} from '@grafana/scenes';
import { DataQuery } from '@grafana/schema';
import { t } from 'app/core/internationalization';
import { PanelModel } from 'app/features/dashboard/state';
import { InspectTab } from 'app/features/inspector/types';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration';
import { ShareModal } from '../sharing/ShareModal';
@ -14,6 +30,7 @@ import { getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { LibraryVizPanel } from './LibraryVizPanel';
import { VizPanelLinks } from './PanelLinks';
import { ShareQueryDataProvider } from './ShareQueryDataProvider';
/**
* Behavior is called when VizPanelMenu is activated (ie when it's opened).
@ -151,6 +168,23 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
subMenu: inspectSubMenu.length > 0 ? inspectSubMenu : undefined,
});
if (dashboard instanceof DashboardScene) {
const { extensions } = getPluginLinkExtensions({
extensionPointId: PluginExtensionPoints.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
limitPerPlugin: 3,
});
if (extensions.length > 0 && !dashboard.state.isEditing) {
items.push({
text: 'Extensions',
iconClassName: 'plug',
type: 'submenu',
subMenu: createExtensionSubMenu(extensions),
});
}
}
if (moreSubMenu.length) {
items.push({
type: 'submenu',
@ -196,3 +230,69 @@ export function getPanelLinksBehavior(panel: PanelModel) {
panelLinksMenu.setState({ links });
};
}
function createExtensionContext(panel: VizPanel, dashboard: DashboardScene): PluginExtensionPanelContext {
const timeRange = sceneGraph.getTimeRange(panel);
let queryRunner = panel.state.$data;
let targets: DataQuery[] = [];
const id = getPanelIdForVizPanel(panel);
if (queryRunner instanceof SceneDataTransformer) {
queryRunner = queryRunner.state.$data;
}
if (queryRunner instanceof SceneQueryRunner) {
targets = queryRunner.state.queries;
}
if (queryRunner instanceof ShareQueryDataProvider) {
targets = [queryRunner.state.query];
}
let scopedVars = {};
// Handle panel repeats scenario
if (panel.state.$variables) {
panel.state.$variables.state.variables.forEach((variable) => {
if (variable instanceof LocalValueVariable) {
scopedVars = {
...scopedVars,
[variable.state.name]: { value: variable.getValue(), text: variable.getValueText() },
};
}
});
}
// Handle row repeats scenario
if (panel.parent?.parent instanceof SceneGridRow) {
const row = panel.parent.parent;
if (row.state.$variables) {
row.state.$variables.state.variables.forEach((variable) => {
if (variable instanceof LocalValueVariable) {
scopedVars = {
...scopedVars,
[variable.state.name]: { value: variable.getValue(), text: variable.getValueText() },
};
}
});
}
}
return {
id,
pluginId: panel.state.pluginId,
title: panel.state.title,
timeRange: timeRange.state.value.raw,
timeZone: getTimeZone({
timeZone: timeRange.getTimeZone(),
}),
dashboard: {
uid: dashboard.state.uid!,
title: dashboard.state.title,
tags: dashboard.state.tags || [],
},
targets,
scopedVars,
data: queryRunner?.state.data,
};
}

@ -225,6 +225,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
return new DashboardScene({
title: oldModel.title,
tags: oldModel.tags || [],
uid: oldModel.uid,
id: oldModel.id,
meta: oldModel.meta,

@ -14,7 +14,7 @@ import {
VariableSupportType,
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { setPluginImportUtils } from '@grafana/runtime';
import { getPluginLinkExtensions, setPluginImportUtils } from '@grafana/runtime';
import {
MultiValueVariable,
SceneDataLayers,
@ -148,8 +148,12 @@ jest.mock('@grafana/runtime', () => ({
},
},
},
setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(),
}));
const getPluginLinkExtensionsMock = jest.mocked(getPluginLinkExtensions);
jest.mock('@grafana/scenes', () => ({
...jest.requireActual('@grafana/scenes'),
sceneUtils: {
@ -159,6 +163,11 @@ jest.mock('@grafana/scenes', () => ({
}));
describe('transformSceneToSaveModel', () => {
beforeEach(() => {
getPluginLinkExtensionsMock.mockRestore();
getPluginLinkExtensionsMock.mockReturnValue({ extensions: [] });
});
describe('Given a simple scene with variables', () => {
it('Should transform back to persisted model', () => {
const scene = transformSaveModelToScene({ dashboard: dashboard_to_load1 as any, meta: {} });

@ -1,7 +1,6 @@
import {
getTimeZone,
PanelMenuItem,
PluginExtensionLink,
PluginExtensionPoints,
urlUtil,
type PluginExtensionPanelContext,
@ -26,7 +25,7 @@ import {
} from 'app/features/dashboard/utils/panel';
import { InspectTab } from 'app/features/inspector/types';
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
import { truncateTitle } from 'app/features/plugins/extensions/utils';
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { store } from 'app/store/store';
@ -375,53 +374,3 @@ function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): P
data: panel.getQueryRunner().getLastResult(),
};
}
function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] {
const categorized: Record<string, PanelMenuItem[]> = {};
const uncategorized: PanelMenuItem[] = [];
for (const extension of extensions) {
const category = extension.category;
if (!category) {
uncategorized.push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
continue;
}
if (!Array.isArray(categorized[category])) {
categorized[category] = [];
}
categorized[category].push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
}
const subMenu = Object.keys(categorized).reduce((subMenu: PanelMenuItem[], category) => {
subMenu.push({
text: truncateTitle(category, 25),
type: 'group',
subMenu: categorized[category],
});
return subMenu;
}, []);
if (uncategorized.length > 0) {
if (subMenu.length > 0) {
subMenu.push({
text: 'divider',
type: 'divider',
});
}
Array.prototype.push.apply(subMenu, uncategorized);
}
return subMenu;
}

@ -13,6 +13,8 @@ import {
isDateTime,
dateTime,
PluginContextProvider,
PluginExtensionLink,
PanelMenuItem,
} from '@grafana/data';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
@ -249,3 +251,53 @@ export function truncateTitle(title: string, length: number): string {
const part = title.slice(0, length - 3);
return `${part.trimEnd()}...`;
}
export function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] {
const categorized: Record<string, PanelMenuItem[]> = {};
const uncategorized: PanelMenuItem[] = [];
for (const extension of extensions) {
const category = extension.category;
if (!category) {
uncategorized.push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
continue;
}
if (!Array.isArray(categorized[category])) {
categorized[category] = [];
}
categorized[category].push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
}
const subMenu = Object.keys(categorized).reduce((subMenu: PanelMenuItem[], category) => {
subMenu.push({
text: truncateTitle(category, 25),
type: 'group',
subMenu: categorized[category],
});
return subMenu;
}, []);
if (uncategorized.length > 0) {
if (subMenu.length > 0) {
subMenu.push({
text: 'divider',
type: 'divider',
});
}
Array.prototype.push.apply(subMenu, uncategorized);
}
return subMenu;
}

Loading…
Cancel
Save