[dashboard] refactor EditorMenu to AddPanelButton (#208226)

PR refactors `EditorMenu` component into `AddPanelButton` to provide
better naming scheme and simplify logic.
* replace `COMMON_VISUALIZATION_GROUPING` with
`ADD_PANEL_VISUALIZATION_GROUP`. Moved from visualizations plugin to
embeddable plugin.
* rename `EditorMenu` => `AddPanelButton`
* rename `DashboardPanelSelectionListFlyout` => `AddPanelFlyout`
* remove unused style `dshSolutionToolbar__editorContextMenu` from
editor_menu.scss
* Simplify loading of `AddPanelFlyout` contents. Replaced
`useGetDashboardPanels` hook with `getMenuItemGroups` function.
* avoid loading `AddPanelFlyout` contents until `AddPanelButton` is
clicked. `DashboardEditingToolbar` component used to setup
`useGetDashboardPanels` hook. Now `AddPanelFlyout` loads contents by
directly calling `getMenuItemGroups`.
* Added loading state to `AddPanelFlyout`
<img width="600" alt="Screenshot 2025-01-24 at 9 21 05 AM"
src="https://github.com/user-attachments/assets/b2dfde5f-e347-4745-9ee0-b3268e2138c2"
/>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Hannah Mudge <Heenawter@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2025-01-28 14:58:23 -07:00 committed by GitHub
parent 8ba2179096
commit dcf64f2eb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 780 additions and 1288 deletions

View file

@ -7,5 +7,5 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { useGetDashboardPanels } from './use_get_dashboard_panels';
export { DashboardPanelSelectionListFlyout } from './dashboard_panel_selection_flyout';
export { getLinksEmbeddableFactory } from './links_embeddable';
export { deserializeLinksSavedObject } from '../lib/deserialize_from_library';

View file

@ -1,116 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getMockPresentationContainer } from '@kbn/presentation-containers/mocks';
import { getAddPanelActionMenuItemsGroup } from './add_panel_action_menu_items';
describe('getAddPanelActionMenuItems', () => {
it('returns the items correctly', async () => {
const registeredActions = [
{
id: 'ACTION_CREATE_ESQL_CHART',
type: 'ACTION_CREATE_ESQL_CHART',
getDisplayName: () => 'Action name',
getIconType: () => 'pencil',
getDisplayNameTooltip: () => 'Action tooltip',
isCompatible: () => Promise.resolve(true),
execute: jest.fn(),
},
{
id: 'TEST_ACTION_01',
type: 'TEST_ACTION_01',
getDisplayName: () => 'Action name 01',
getIconType: () => 'pencil',
getDisplayNameTooltip: () => 'Action tooltip',
isCompatible: () => Promise.resolve(true),
execute: jest.fn(),
grouping: [
{
id: 'groupedAddPanelAction',
getDisplayName: () => 'Custom group',
getIconType: () => 'logoElasticsearch',
},
],
},
{
id: 'TEST_ACTION_02',
type: 'TEST_ACTION_02',
getDisplayName: () => 'Action name',
getDisplayNameTooltip: () => 'Action tooltip',
getIconType: () => undefined,
isCompatible: () => Promise.resolve(true),
execute: jest.fn(),
grouping: [
{
id: 'groupedAddPanelAction',
getDisplayName: () => 'Custom group',
getIconType: () => 'logoElasticsearch',
},
],
},
];
const grouped = getAddPanelActionMenuItemsGroup(
getMockPresentationContainer(),
registeredActions,
jest.fn()
);
expect(grouped).toStrictEqual({
groupedAddPanelAction: {
id: 'groupedAddPanelAction',
title: 'Custom group',
order: 0,
'data-test-subj': 'dashboardEditorMenu-groupedAddPanelActionGroup',
items: [
{
'data-test-subj': 'create-action-Action name 01',
icon: 'pencil',
id: 'TEST_ACTION_01',
name: 'Action name 01',
onClick: expect.any(Function),
description: 'Action tooltip',
order: 0,
},
{
'data-test-subj': 'create-action-Action name',
icon: 'empty',
id: 'TEST_ACTION_02',
name: 'Action name',
onClick: expect.any(Function),
description: 'Action tooltip',
order: 0,
},
],
},
other: {
id: 'other',
title: 'Other',
order: -1,
'data-test-subj': 'dashboardEditorMenu-otherGroup',
items: [
{
id: 'ACTION_CREATE_ESQL_CHART',
name: 'Action name',
icon: 'pencil',
description: 'Action tooltip',
onClick: expect.any(Function),
'data-test-subj': 'create-action-Action name',
order: 0,
},
],
},
});
});
it('returns empty array if no actions have been registered', async () => {
const grouped = getAddPanelActionMenuItemsGroup(getMockPresentationContainer(), [], jest.fn());
expect(grouped).toStrictEqual({});
});
});

View file

@ -1,118 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import {
type ActionExecutionContext,
type Action,
addPanelMenuTrigger,
} from '@kbn/ui-actions-plugin/public';
import { PresentationContainer } from '@kbn/presentation-containers';
import { ADD_PANEL_OTHER_GROUP } from '@kbn/embeddable-plugin/public';
import type { IconType, CommonProps } from '@elastic/eui';
import React, { type MouseEventHandler } from 'react';
export interface PanelSelectionMenuItem extends Pick<CommonProps, 'data-test-subj'> {
id: string;
name: string;
icon: IconType;
onClick: MouseEventHandler;
description?: string;
isDisabled?: boolean;
isDeprecated?: boolean;
order: number;
}
export type GroupedAddPanelActions = Pick<
PanelSelectionMenuItem,
'id' | 'isDisabled' | 'data-test-subj' | 'order'
> & {
title: string;
items: PanelSelectionMenuItem[];
};
const onAddPanelActionClick =
(action: Action, context: ActionExecutionContext<object>, closePopover: () => void) =>
(event: React.MouseEvent) => {
closePopover();
if (event.currentTarget instanceof HTMLAnchorElement) {
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 &&
(!event.currentTarget.target || event.currentTarget.target === '_self') &&
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
) {
event.preventDefault();
action.execute(context);
}
} else action.execute(context);
};
export const getAddPanelActionMenuItemsGroup = (
api: PresentationContainer,
actions: Array<Action<object>> | undefined,
onPanelSelected: () => void
) => {
const grouped: Record<string, GroupedAddPanelActions> = {};
const context = {
embeddable: api,
trigger: addPanelMenuTrigger,
};
const getMenuItem = (item: Action<object>): PanelSelectionMenuItem => {
const actionName = item.getDisplayName(context);
return {
id: item.id,
name: actionName,
icon:
(typeof item.getIconType === 'function' ? item.getIconType(context) : undefined) ?? 'empty',
onClick: onAddPanelActionClick(item, context, onPanelSelected),
'data-test-subj': `create-action-${actionName}`,
description: item?.getDisplayNameTooltip?.(context),
order: item.order ?? 0,
};
};
actions?.forEach((item) => {
if (Array.isArray(item.grouping)) {
item.grouping.forEach((group) => {
const groupId = group.id;
if (!grouped[groupId]) {
grouped[groupId] = {
id: groupId,
title: group.getDisplayName ? group.getDisplayName(context) : '',
'data-test-subj': `dashboardEditorMenu-${groupId}Group`,
order: group.order ?? 0,
items: [],
};
}
grouped[group.id]!.items!.push(getMenuItem(item));
});
} else {
// use other group as the default for definitions that don't have a group
const fallbackGroup = ADD_PANEL_OTHER_GROUP;
if (!grouped[fallbackGroup.id]) {
grouped[fallbackGroup.id] = {
id: fallbackGroup.id,
title: fallbackGroup.getDisplayName?.() || '',
'data-test-subj': `dashboardEditorMenu-${fallbackGroup.id}Group`,
order: fallbackGroup.order || 0,
items: [],
};
}
grouped[fallbackGroup.id].items.push(getMenuItem(item));
}
});
return grouped;
};

View file

@ -1,109 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { type ComponentProps } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { DashboardPanelSelectionListFlyout } from './dashboard_panel_selection_flyout';
import type { GroupedAddPanelActions } from './add_panel_action_menu_items';
const defaultProps: Omit<
ComponentProps<typeof DashboardPanelSelectionListFlyout>,
'fetchDashboardPanels'
> = {
close: jest.fn(),
paddingSize: 's',
};
const renderComponent = ({
fetchDashboardPanels,
}: Pick<ComponentProps<typeof DashboardPanelSelectionListFlyout>, 'fetchDashboardPanels'>) =>
render(
<IntlProvider locale="en">
<DashboardPanelSelectionListFlyout
{...defaultProps}
fetchDashboardPanels={fetchDashboardPanels}
/>
</IntlProvider>
);
const panelConfiguration: GroupedAddPanelActions[] = [
{
id: 'panel1',
title: 'App 1',
items: [
{
icon: 'icon1',
id: 'mockFactory',
name: 'Factory 1',
description: 'Factory 1 description',
'data-test-subj': 'createNew-mockFactory',
onClick: jest.fn(),
order: 0,
},
],
order: 10,
'data-test-subj': 'dashboardEditorMenu-group1Group',
},
];
describe('DashboardPanelSelectionListFlyout', () => {
it('renders a loading indicator when fetchDashboardPanel has not yielded any value', async () => {
const promiseDelay = 5000;
renderComponent({
fetchDashboardPanels: jest.fn(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(panelConfiguration), promiseDelay);
})
),
});
expect(
await screen.findByTestId('dashboardPanelSelectionLoadingIndicator')
).toBeInTheDocument();
});
it('renders an error indicator when fetchDashboardPanel errors', async () => {
renderComponent({
fetchDashboardPanels: jest.fn().mockRejectedValue(new Error('simulated error')),
});
expect(await screen.findByTestId('dashboardPanelSelectionErrorIndicator')).toBeInTheDocument();
});
it('renders the list of available panels when fetchDashboardPanel resolves a value', async () => {
renderComponent({ fetchDashboardPanels: jest.fn().mockResolvedValue(panelConfiguration) });
expect(await screen.findByTestId(panelConfiguration[0]['data-test-subj']!)).toBeInTheDocument();
});
it('renders a not found message when a user searches for an item that is not in the selection list', async () => {
renderComponent({ fetchDashboardPanels: jest.fn().mockResolvedValue(panelConfiguration) });
expect(await screen.findByTestId(panelConfiguration[0]['data-test-subj']!)).toBeInTheDocument();
await userEvent.type(
screen.getByTestId('dashboardPanelSelectionFlyout__searchInput'),
'non existent panel'
);
expect(await screen.findByTestId('dashboardPanelSelectionNoPanelMessage')).toBeInTheDocument();
});
it('invokes the close method when the flyout close btn is clicked', async () => {
renderComponent({ fetchDashboardPanels: jest.fn().mockResolvedValue(panelConfiguration) });
fireEvent.click(await screen.findByTestId('dashboardPanelSelectionCloseBtn'));
expect(defaultProps.close).toHaveBeenCalled();
});
});

View file

@ -1,293 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useState } from 'react';
import { i18n as i18nFn } from '@kbn/i18n';
import { type EuiFlyoutProps, EuiLoadingChart } from '@elastic/eui';
import orderBy from 'lodash/orderBy';
import {
EuiEmptyPrompt,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiBadge,
EuiFormRow,
EuiTitle,
EuiFieldSearch,
useEuiTheme,
EuiListGroup,
EuiListGroupItem,
EuiToolTip,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
type PanelSelectionMenuItem,
type GroupedAddPanelActions,
} from './add_panel_action_menu_items';
export interface DashboardPanelSelectionListFlyoutProps {
/** Handler to close flyout */
close: () => void;
/** Padding for flyout */
paddingSize: Exclude<EuiFlyoutProps['paddingSize'], 'none' | undefined>;
/** Fetches the panels available for a dashboard */
fetchDashboardPanels: () => Promise<GroupedAddPanelActions[]>;
}
export const DashboardPanelSelectionListFlyout: React.FC<
DashboardPanelSelectionListFlyoutProps
> = ({ close, paddingSize, fetchDashboardPanels }) => {
const { euiTheme } = useEuiTheme();
const [{ data: panels, loading, error }, setPanelState] = useState<{
loading: boolean;
data: GroupedAddPanelActions[] | null;
error: unknown | null;
}>({ loading: true, data: null, error: null });
const [searchTerm, setSearchTerm] = useState<string>('');
const [panelsSearchResult, setPanelsSearchResult] = useState<GroupedAddPanelActions[] | null>(
panels
);
useEffect(() => {
const requestDashboardPanels = () => {
fetchDashboardPanels()
.then((_panels) =>
setPanelState((prevState) => ({
...prevState,
loading: false,
data: _panels,
}))
)
.catch((err) =>
setPanelState((prevState) => ({
...prevState,
loading: false,
error: err,
}))
);
};
requestDashboardPanels();
}, [fetchDashboardPanels]);
useEffect(() => {
const _panels = (panels ?? []).slice(0);
if (!searchTerm) {
return setPanelsSearchResult(_panels);
}
const q = searchTerm.toLowerCase();
setPanelsSearchResult(
orderBy(
_panels.map((panel) => {
const groupSearchMatch = panel.title.toLowerCase().includes(q);
const [groupSearchMatchAgg, items] = panel.items.reduce(
(acc, cur) => {
const searchMatch = cur.name.toLowerCase().includes(q);
acc[0] = acc[0] || searchMatch;
acc[1].push({
...cur,
isDisabled: !(groupSearchMatch || searchMatch),
});
return acc;
},
[groupSearchMatch, [] as PanelSelectionMenuItem[]]
);
return {
...panel,
isDisabled: !groupSearchMatchAgg,
items,
};
}),
['isDisabled']
)
);
}, [panels, searchTerm]);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h1 id="addPanelsFlyout">
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.headingText"
defaultMessage="Add panel"
/>
</h1>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup direction="column" responsive={false} gutterSize="m">
<EuiFlexItem
grow={false}
css={{
position: 'sticky',
top: euiTheme.size[paddingSize],
zIndex: 1,
boxShadow: `0 -${euiTheme.size[paddingSize]} 0 4px ${euiTheme.colors.emptyShade}`,
}}
>
<EuiForm component="form" fullWidth>
<EuiFormRow css={{ backgroundColor: euiTheme.colors.emptyShade }}>
<EuiFieldSearch
compressed
autoFocus
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
aria-label={i18nFn.translate(
'dashboard.editorMenu.addPanelFlyout.searchLabelText',
{ defaultMessage: 'search field for panels' }
)}
className="nsPanelSelectionFlyout__searchInput"
data-test-subj="dashboardPanelSelectionFlyout__searchInput"
/>
</EuiFormRow>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem
css={{
minHeight: '20vh',
...(loading || error
? {
justifyContent: 'center',
alignItems: 'center',
}
: {}),
}}
>
{loading ? (
<EuiEmptyPrompt
data-test-subj="dashboardPanelSelectionLoadingIndicator"
icon={<EuiLoadingChart size="l" mono />}
/>
) : (
<EuiFlexGroup
direction="column"
gutterSize="m"
data-test-subj="dashboardPanelSelectionList"
>
{panelsSearchResult?.some(({ isDisabled }) => !isDisabled) ? (
panelsSearchResult.map(
({ id, title, items, isDisabled, ['data-test-subj']: dataTestSubj, order }) =>
!isDisabled ? (
<EuiFlexItem
key={id}
data-test-subj={dataTestSubj}
data-group-sort-order={order}
>
<EuiTitle id={`${id}-group`} size="xxs">
{typeof title === 'string' ? <h3>{title}</h3> : title}
</EuiTitle>
<EuiListGroup
aria-labelledby={`${id}-group`}
size="s"
gutterSize="none"
maxWidth={false}
flush
>
{items?.map((item, idx) => {
return (
<EuiListGroupItem
key={`${id}.${idx}`}
label={
<EuiToolTip position="right" content={item.description}>
{!item.isDeprecated ? (
<EuiText size="s">{item.name}</EuiText>
) : (
<EuiFlexGroup wrap responsive={false} gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText size="s">{item.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="warning">
<FormattedMessage
id="dashboard.editorMenu.deprecatedTag"
defaultMessage="Deprecated"
/>
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiToolTip>
}
onClick={item?.onClick}
iconType={item.icon}
data-test-subj={item['data-test-subj']}
isDisabled={item.isDisabled}
/>
);
})}
</EuiListGroup>
</EuiFlexItem>
) : null
)
) : (
<>
{Boolean(error) ? (
<EuiEmptyPrompt
iconType="warning"
iconColor="danger"
body={
<EuiText size="s" textAlign="center">
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.loadingErrorDescription"
defaultMessage="An error occurred loading the available dashboard panels for selection"
/>
</EuiText>
}
data-test-subj="dashboardPanelSelectionErrorIndicator"
/>
) : (
<EuiText
size="s"
textAlign="center"
data-test-subj="dashboardPanelSelectionNoPanelMessage"
>
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.noResultsDescription"
defaultMessage="No panel types found"
/>
</EuiText>
)}
</>
)}
</EuiFlexGroup>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={close} data-test-subj="dashboardPanelSelectionCloseBtn">
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.cancelButtonText"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -1,218 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ADD_PANEL_ANNOTATION_GROUP, ADD_PANEL_LEGACY_GROUP } from '@kbn/embeddable-plugin/public';
import type { PresentationContainer } from '@kbn/presentation-containers';
import type { Action, UiActionsService } from '@kbn/ui-actions-plugin/public';
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
import {
VisGroups,
VisTypeAlias,
VisualizationsStart,
type BaseVisType,
} from '@kbn/visualizations-plugin/public';
import { renderHook } from '@testing-library/react';
import { uiActionsService, visualizationsService } from '../../../services/kibana_services';
import { useGetDashboardPanels } from './use_get_dashboard_panels';
const mockApi = { addNewPanel: jest.fn() } as unknown as jest.Mocked<PresentationContainer>;
describe('Get dashboard panels hook', () => {
const defaultHookProps: Parameters<typeof useGetDashboardPanels>[0] = {
api: mockApi,
createNewVisType: jest.fn(),
};
let compatibleTriggerActionsRequestSpy: jest.SpyInstance<
ReturnType<NonNullable<UiActionsService['getTriggerCompatibleActions']>>
>;
let dashboardVisualizationGroupGetterSpy: jest.SpyInstance<
ReturnType<VisualizationsStart['getByGroup']>
>;
let dashboardVisualizationAliasesGetterSpy: jest.SpyInstance<
ReturnType<VisualizationsStart['getAliases']>
>;
beforeAll(() => {
compatibleTriggerActionsRequestSpy = jest.spyOn(
uiActionsService,
'getTriggerCompatibleActions'
);
dashboardVisualizationGroupGetterSpy = jest.spyOn(visualizationsService, 'getByGroup');
dashboardVisualizationAliasesGetterSpy = jest.spyOn(visualizationsService, 'getAliases');
});
beforeEach(() => {
compatibleTriggerActionsRequestSpy.mockResolvedValue([]);
dashboardVisualizationGroupGetterSpy.mockReturnValue([]);
dashboardVisualizationAliasesGetterSpy.mockReturnValue([]);
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
jest.resetAllMocks();
});
describe('useGetDashboardPanels', () => {
it('hook return value is callable', () => {
const { result } = renderHook(() => useGetDashboardPanels(defaultHookProps));
expect(result.current).toBeInstanceOf(Function);
});
it('returns a callable method that yields a cached result if invoked after a prior resolution', async () => {
const { result } = renderHook(() => useGetDashboardPanels(defaultHookProps));
expect(result.current).toBeInstanceOf(Function);
const firstInvocationResult = await result.current(jest.fn());
expect(compatibleTriggerActionsRequestSpy).toHaveBeenCalledWith(ADD_PANEL_TRIGGER, {
embeddable: expect.objectContaining(mockApi),
});
const secondInvocationResult = await result.current(jest.fn());
expect(firstInvocationResult).toStrictEqual(secondInvocationResult);
expect(compatibleTriggerActionsRequestSpy).toHaveBeenCalledTimes(1);
});
});
describe('augmenting ui action group items with dashboard visualization types', () => {
it.each([
['visualizations', VisGroups.PROMOTED],
[ADD_PANEL_LEGACY_GROUP.id, VisGroups.LEGACY],
[ADD_PANEL_ANNOTATION_GROUP.id, VisGroups.TOOLS],
])(
'includes in the ui action %s group, %s dashboard visualization group types',
async (uiActionGroupId, dashboardVisualizationGroupId) => {
const mockVisualizationsUiAction: Action<object> = {
id: `some-${uiActionGroupId}-action`,
type: '',
order: 10,
grouping: [
{
id: uiActionGroupId,
order: 1000,
getDisplayName: jest.fn(),
getIconType: jest.fn(),
},
],
getDisplayName: jest.fn(() => `Some ${uiActionGroupId} visualization Action`),
getIconType: jest.fn(),
execute: jest.fn(),
isCompatible: jest.fn(() => Promise.resolve(true)),
};
const mockDashboardVisualizationType = {
name: dashboardVisualizationGroupId,
title: dashboardVisualizationGroupId,
order: 0,
description: `This is a dummy representation of a ${dashboardVisualizationGroupId} visualization.`,
icon: 'empty',
stage: 'production',
isDeprecated: false,
group: dashboardVisualizationGroupId,
titleInWizard: `Custom ${dashboardVisualizationGroupId} visualization`,
} as BaseVisType;
compatibleTriggerActionsRequestSpy.mockResolvedValue([mockVisualizationsUiAction]);
dashboardVisualizationGroupGetterSpy.mockImplementation((group) => {
if (group !== dashboardVisualizationGroupId) return [];
return [mockDashboardVisualizationType];
});
const { result } = renderHook(() => useGetDashboardPanels(defaultHookProps));
expect(result.current).toBeInstanceOf(Function);
expect(await result.current(jest.fn())).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: uiActionGroupId,
'data-test-subj': `dashboardEditorMenu-${uiActionGroupId}Group`,
items: expect.arrayContaining([
expect.objectContaining({
// @ts-expect-error ignore passing the required context in this test
'data-test-subj': `create-action-${mockVisualizationsUiAction.getDisplayName()}`,
}),
expect.objectContaining({
'data-test-subj': `visType-${mockDashboardVisualizationType.name}`,
}),
]),
}),
])
);
}
);
it('includes in the ui action visualization group dashboard visualization alias types', async () => {
const mockVisualizationsUiAction: Action<object> = {
id: 'some-vis-action',
type: '',
order: 10,
grouping: [
{
id: 'visualizations',
order: 1000,
getDisplayName: jest.fn(),
getIconType: jest.fn(),
},
],
getDisplayName: jest.fn(() => 'Some visualization Action'),
getIconType: jest.fn(),
execute: jest.fn(),
isCompatible: jest.fn(() => Promise.resolve(true)),
};
const mockedAliasVisualizationType: VisTypeAlias = {
name: 'alias visualization',
title: 'Alias Visualization',
order: 0,
description: 'This is a dummy representation of aan aliased visualization.',
icon: 'empty',
stage: 'production',
isDeprecated: false,
};
compatibleTriggerActionsRequestSpy.mockResolvedValue([mockVisualizationsUiAction]);
dashboardVisualizationAliasesGetterSpy.mockReturnValue([mockedAliasVisualizationType]);
const { result } = renderHook(() => useGetDashboardPanels(defaultHookProps));
expect(result.current).toBeInstanceOf(Function);
expect(await result.current(jest.fn())).toStrictEqual(
expect.arrayContaining([
expect.objectContaining({
id: mockVisualizationsUiAction.grouping![0].id,
'data-test-subj': `dashboardEditorMenu-${
mockVisualizationsUiAction.grouping![0].id
}Group`,
items: expect.arrayContaining([
expect.objectContaining({
// @ts-expect-error ignore passing the required context in this test
'data-test-subj': `create-action-${mockVisualizationsUiAction.getDisplayName()}`,
}),
expect.objectContaining({
'data-test-subj': `visType-${mockedAliasVisualizationType.name}`,
}),
]),
}),
])
);
});
});
});

View file

@ -1,210 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useCallback, useMemo, useRef } from 'react';
import { AsyncSubject, defer, from, lastValueFrom, map, type Subscription } from 'rxjs';
import { ADD_PANEL_ANNOTATION_GROUP, ADD_PANEL_LEGACY_GROUP } from '@kbn/embeddable-plugin/public';
import { PresentationContainer } from '@kbn/presentation-containers';
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
import { VisGroups, type BaseVisType, type VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { uiActionsService, visualizationsService } from '../../../services/kibana_services';
import {
getAddPanelActionMenuItemsGroup,
type GroupedAddPanelActions,
type PanelSelectionMenuItem,
} from './add_panel_action_menu_items';
interface UseGetDashboardPanelsArgs {
api: PresentationContainer;
createNewVisType: (visType: BaseVisType | VisTypeAlias) => () => void;
}
const sortGroupPanelsByOrder = <T extends { order: number }>(panelGroups: T[]): T[] => {
return panelGroups.sort(
// larger number sorted to the top
(panelGroupA, panelGroupB) => panelGroupB.order - panelGroupA.order
);
};
export const useGetDashboardPanels = ({ api, createNewVisType }: UseGetDashboardPanelsArgs) => {
const panelsComputeResultCache = useRef(new AsyncSubject<GroupedAddPanelActions[]>());
const panelsComputeSubscription = useRef<Subscription | null>(null);
const getSortedVisTypesByGroup = (group: VisGroups) =>
visualizationsService
.getByGroup(group)
.sort((a: BaseVisType | VisTypeAlias, b: BaseVisType | VisTypeAlias) => {
const labelA = 'titleInWizard' in a ? a.titleInWizard || a.title : a.title;
const labelB = 'titleInWizard' in b ? b.titleInWizard || a.title : a.title;
if (labelA < labelB) {
return -1;
}
if (labelA > labelB) {
return 1;
}
return 0;
})
.filter(({ disableCreate }: BaseVisType) => !disableCreate);
const promotedVisTypes = getSortedVisTypesByGroup(VisGroups.PROMOTED);
const toolVisTypes = getSortedVisTypesByGroup(VisGroups.TOOLS);
const legacyVisTypes = getSortedVisTypesByGroup(VisGroups.LEGACY);
const visTypeAliases = visualizationsService
.getAliases()
.sort(({ promotion: a = false }: VisTypeAlias, { promotion: b = false }: VisTypeAlias) =>
a === b ? 0 : a ? -1 : 1
)
.filter(({ disableCreate }: VisTypeAlias) => !disableCreate);
const augmentedCreateNewVisType = useCallback(
(visType: Parameters<typeof createNewVisType>[0], cb: () => void) => {
const visClickHandler = createNewVisType(visType);
return () => {
visClickHandler();
cb();
};
},
[createNewVisType]
);
const getVisTypeMenuItem = useCallback(
(onClickCb: () => void, visType: BaseVisType): PanelSelectionMenuItem => {
const {
name,
title,
titleInWizard,
description,
icon = 'empty',
isDeprecated,
order,
} = visType;
return {
id: name,
name: titleInWizard || title,
isDeprecated,
icon,
onClick: augmentedCreateNewVisType(visType, onClickCb),
'data-test-subj': `visType-${name}`,
description,
order,
};
},
[augmentedCreateNewVisType]
);
const getVisTypeAliasMenuItem = useCallback(
(onClickCb: () => void, visTypeAlias: VisTypeAlias): PanelSelectionMenuItem => {
const { name, title, description, icon = 'empty', order } = visTypeAlias;
return {
id: name,
name: title,
icon,
onClick: augmentedCreateNewVisType(visTypeAlias, onClickCb),
'data-test-subj': `visType-${name}`,
description,
order: order ?? 0,
};
},
[augmentedCreateNewVisType]
);
const addPanelAction$ = useMemo(
() =>
defer(() => {
return from(
uiActionsService.getTriggerCompatibleActions?.(ADD_PANEL_TRIGGER, {
embeddable: api,
}) ?? []
);
}),
[api]
);
const computeAvailablePanels = useCallback(
(onPanelSelected: () => void) => {
if (!panelsComputeSubscription.current) {
panelsComputeSubscription.current = addPanelAction$
.pipe(
map((addPanelActions) =>
getAddPanelActionMenuItemsGroup(api, addPanelActions, onPanelSelected)
),
map((groupedAddPanelAction) => {
return sortGroupPanelsByOrder<GroupedAddPanelActions>(
Object.values(groupedAddPanelAction)
).map((panelGroup) => {
switch (panelGroup.id) {
case 'visualizations': {
return {
...panelGroup,
items: sortGroupPanelsByOrder<PanelSelectionMenuItem>(
(panelGroup.items ?? []).concat(
// TODO: actually add grouping to vis type alias so we wouldn't randomly display an unintended item
visTypeAliases.map(getVisTypeAliasMenuItem.bind(null, onPanelSelected)),
promotedVisTypes.map(getVisTypeMenuItem.bind(null, onPanelSelected))
)
),
};
}
case ADD_PANEL_LEGACY_GROUP.id: {
return {
...panelGroup,
items: sortGroupPanelsByOrder<PanelSelectionMenuItem>(
(panelGroup.items ?? []).concat(
legacyVisTypes.map(getVisTypeMenuItem.bind(null, onPanelSelected))
)
),
};
}
case ADD_PANEL_ANNOTATION_GROUP.id: {
return {
...panelGroup,
items: sortGroupPanelsByOrder<PanelSelectionMenuItem>(
(panelGroup.items ?? []).concat(
toolVisTypes.map(getVisTypeMenuItem.bind(null, onPanelSelected))
)
),
};
}
default: {
return {
...panelGroup,
items: sortGroupPanelsByOrder(panelGroup.items),
};
}
}
});
})
)
.subscribe(panelsComputeResultCache.current);
}
},
[
api,
addPanelAction$,
getVisTypeMenuItem,
getVisTypeAliasMenuItem,
toolVisTypes,
legacyVisTypes,
promotedVisTypes,
visTypeAliases,
]
);
return useCallback(
(...args: Parameters<typeof computeAvailablePanels>) => {
computeAvailablePanels(...args);
return lastValueFrom(panelsComputeResultCache.current.asObservable());
},
[computeAvailablePanels]
);
};

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { AddPanelFlyout } from './add_panel_flyout';
import { useDashboardApi } from '../../../../dashboard_api/use_dashboard_api';
import { coreServices } from '../../../../services/kibana_services';
export const AddPanelButton = ({ isDisabled }: { isDisabled?: boolean }) => {
const dashboardApi = useDashboardApi();
useEffect(() => {
// ensure opened overlays are closed if a navigation event happens
return () => {
dashboardApi.clearOverlays();
};
}, [dashboardApi]);
const openFlyout = useCallback(() => {
const overlayRef = coreServices.overlays.openFlyout(
toMountPoint(
React.createElement(function () {
return <AddPanelFlyout dashboardApi={dashboardApi} />;
}),
coreServices
),
{
size: 'm',
maxWidth: 500,
paddingSize: 'm',
'aria-labelledby': 'addPanelsFlyout',
'data-test-subj': 'dashboardPanelSelectionFlyout',
onClose() {
dashboardApi.clearOverlays();
overlayRef.close();
},
}
);
dashboardApi.openOverlay(overlayRef);
}, [dashboardApi]);
return (
<ToolbarButton
data-test-subj="dashboardEditorMenuButton"
isDisabled={isDisabled}
iconType="plusInCircle"
label={i18n.translate('dashboard.solutionToolbar.editorMenuButtonLabel', {
defaultMessage: 'Add panel',
})}
onClick={openFlyout}
size="s"
/>
);
};

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { AddPanelFlyout } from './add_panel_flyout';
import { DashboardApi } from '../../../../dashboard_api/types';
jest.mock('../get_menu_item_groups', () => ({}));
const mockDashboardApi = {} as unknown as DashboardApi;
describe('AddPanelFlyout', () => {
describe('getMenuItemGroups throws', () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('../get_menu_item_groups').getMenuItemGroups = async () => {
throw new Error('simulated getMenuItemGroups error');
};
});
test('displays getMenuItemGroups error', async () => {
render(
<IntlProvider locale="en">
<AddPanelFlyout dashboardApi={mockDashboardApi} />
</IntlProvider>
);
await waitFor(() => {
screen.getByTestId('dashboardPanelSelectionErrorIndicator');
});
});
});
describe('getMenuItemGroups returns results', () => {
const onClickMock = jest.fn();
beforeEach(() => {
onClickMock.mockClear();
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('../get_menu_item_groups').getMenuItemGroups = async () => [
{
id: 'panel1',
title: 'App 1',
items: [
{
icon: 'icon1',
id: 'mockFactory',
name: 'Factory 1',
description: 'Factory 1 description',
'data-test-subj': 'myItem',
onClick: onClickMock,
order: 0,
},
],
order: 10,
'data-test-subj': 'dashboardEditorMenu-group1Group',
},
];
});
test('calls item onClick handler when item is clicked', async () => {
render(
<IntlProvider locale="en">
<AddPanelFlyout dashboardApi={mockDashboardApi} />
</IntlProvider>
);
await waitFor(async () => {
await userEvent.click(screen.getByTestId('myItem'));
expect(onClickMock).toBeCalled();
});
});
test('displays not found message when a user searches for an item that is not in the selection list', async () => {
render(
<IntlProvider locale="en">
<AddPanelFlyout dashboardApi={mockDashboardApi} />
</IntlProvider>
);
await waitFor(async () => {
await userEvent.type(
screen.getByTestId('dashboardPanelSelectionFlyout__searchInput'),
'non existent panel'
);
screen.getByTestId('dashboardPanelSelectionNoPanelMessage');
});
});
});
});

View file

@ -0,0 +1,177 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useEffect, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { i18n as i18nFn } from '@kbn/i18n';
import {
EuiEmptyPrompt,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiForm,
EuiFormRow,
EuiTitle,
EuiFieldSearch,
useEuiTheme,
EuiSkeletonText,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DashboardApi } from '../../../../dashboard_api/types';
import { MenuItem, MenuItemGroup } from '../types';
import { getMenuItemGroups } from '../get_menu_item_groups';
import { Groups } from './groups';
export function AddPanelFlyout({ dashboardApi }: { dashboardApi: DashboardApi }) {
const { euiTheme } = useEuiTheme();
const {
value: groups,
loading,
error,
} = useAsync(async () => {
return await getMenuItemGroups(dashboardApi);
}, [dashboardApi]);
const [searchTerm, setSearchTerm] = useState<string>('');
const [filteredGroups, setFilteredGroups] = useState<MenuItemGroup[]>([]);
useEffect(() => {
if (!searchTerm) {
return setFilteredGroups(groups ?? []);
}
const q = searchTerm.toLowerCase();
const currentGroups = groups ?? ([] as MenuItemGroup[]);
setFilteredGroups(
currentGroups
.map((group) => {
const groupMatch = group.title.toLowerCase().includes(q);
const [itemsMatch, items] = group.items.reduce(
(acc, item) => {
const itemMatch = item.name.toLowerCase().includes(q);
acc[0] = acc[0] || itemMatch;
acc[1].push({
...item,
isDisabled: !(groupMatch || itemMatch),
});
return acc;
},
[false, [] as MenuItem[]]
);
return {
...group,
isDisabled: !(groupMatch || itemsMatch),
items,
};
})
.filter((group) => !group.isDisabled)
);
}, [groups, searchTerm]);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h1 id="addPanelsFlyout">
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.headingText"
defaultMessage="Add panel"
/>
</h1>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiSkeletonText isLoading={loading}>
<EuiFlexGroup direction="column" responsive={false} gutterSize="m">
<EuiFlexItem
grow={false}
css={{
position: 'sticky',
top: euiTheme.size.m,
zIndex: 1,
boxShadow: `0 -${euiTheme.size.m} 0 4px ${euiTheme.colors.emptyShade}`,
}}
>
<EuiForm component="form" fullWidth>
<EuiFormRow css={{ backgroundColor: euiTheme.colors.emptyShade }}>
<EuiFieldSearch
compressed
autoFocus
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
aria-label={i18nFn.translate(
'dashboard.editorMenu.addPanelFlyout.searchLabelText',
{ defaultMessage: 'search field for panels' }
)}
data-test-subj="dashboardPanelSelectionFlyout__searchInput"
/>
</EuiFormRow>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem
css={{
minHeight: '20vh',
...(error
? {
justifyContent: 'center',
alignItems: 'center',
}
: {}),
}}
>
{error ? (
<EuiEmptyPrompt
iconType="warning"
iconColor="danger"
body={
<EuiText size="s" textAlign="center">
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.loadingErrorDescription"
defaultMessage="An error occurred loading the available dashboard panels for selection"
/>
</EuiText>
}
data-test-subj="dashboardPanelSelectionErrorIndicator"
/>
) : (
<Groups groups={filteredGroups} />
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiSkeletonText>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={dashboardApi.clearOverlays}
data-test-subj="dashboardPanelSelectionCloseBtn"
>
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.cancelButtonText"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
}

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiListGroup,
EuiListGroupItem,
EuiText,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { MenuItemGroup } from '../types';
export function Group({ group }: { group: MenuItemGroup }) {
return (
<>
<EuiTitle id={`${group.id}-group`} size="xxs">
<h3>{group.title}</h3>
</EuiTitle>
<EuiListGroup
aria-labelledby={`${group.id}-group`}
size="s"
gutterSize="none"
maxWidth={false}
flush
>
{group.items.map((item) => {
return (
<EuiListGroupItem
key={item.id}
label={
<EuiToolTip position="right" content={item.description}>
{!item.isDeprecated ? (
<EuiText size="s">{item.name}</EuiText>
) : (
<EuiFlexGroup wrap responsive={false} gutterSize="s">
<EuiFlexItem grow={false}>
<EuiText size="s">{item.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="warning">
<FormattedMessage
id="dashboard.editorMenu.deprecatedTag"
defaultMessage="Deprecated"
/>
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiToolTip>
}
onClick={item.onClick}
iconType={item.icon}
data-test-subj={item['data-test-subj']}
isDisabled={item.isDisabled}
/>
);
})}
</EuiListGroup>
</>
);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { Group } from './group';
import { MenuItemGroup } from '../types';
export function Groups({ groups }: { groups: MenuItemGroup[] }) {
return groups.length === 0 ? (
<EuiText size="s" textAlign="center" data-test-subj="dashboardPanelSelectionNoPanelMessage">
<FormattedMessage
id="dashboard.solutionToolbar.addPanelFlyout.noResultsDescription"
defaultMessage="No panel types found"
/>
</EuiText>
) : (
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="dashboardPanelSelectionList">
{groups.map((group) => (
<EuiFlexItem
key={group.id}
data-test-subj={group['data-test-subj']}
data-group-sort-order={group.order}
>
<Group group={group} />
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { getMenuItemGroups } from './get_menu_item_groups';
jest.mock('../../../services/kibana_services', () => ({
uiActionsService: {
getTriggerCompatibleActions: async () => [
{
id: 'mockAddPanelAction',
type: '',
order: 10,
grouping: [
{
id: 'myGroup',
order: 900,
getDisplayName: () => 'My group',
},
],
getDisplayName: () => 'mockAddPanelAction',
getIconType: () => 'empty',
execute: () => {},
isCompatible: async () => true,
},
],
},
visualizationsService: {
all: () => [
{
name: 'myPromotedVis',
title: 'myPromotedVis',
order: 0,
description: 'myPromotedVis description',
icon: 'empty',
stage: 'production',
isDeprecated: false,
group: 'promoted',
titleInWizard: 'myPromotedVis title',
},
],
getAliases: () => [
{
name: 'alias visualization',
title: 'Alias Visualization',
order: 0,
description: 'This is a dummy representation of aan aliased visualization.',
icon: 'empty',
stage: 'production',
isDeprecated: false,
},
],
},
}));
describe('getMenuItemGroups', () => {
test('gets sorted groups from visTypes, visTypeAliases, and add panel actions', async () => {
const api = {
getAppContext: () => ({
currentAppId: 'dashboards',
}),
openOverlay: () => {},
clearOverlays: () => {},
};
const groups = await getMenuItemGroups(api);
expect(groups.length).toBe(2);
expect(groups[0].title).toBe('Visualizations');
expect(groups[0].items.length).toBe(2); // promoted vis type and vis alias
expect(groups[1].title).toBe('My group');
expect(groups[1].items.length).toBe(1); // add panel action
});
});

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { VisGroups } from '@kbn/visualizations-plugin/public';
import { ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public';
import {
ADD_PANEL_ANNOTATION_GROUP,
ADD_PANEL_LEGACY_GROUP,
ADD_PANEL_OTHER_GROUP,
ADD_PANEL_VISUALIZATION_GROUP,
} from '@kbn/embeddable-plugin/public';
import type { TracksOverlays } from '@kbn/presentation-containers';
import { PresentableGroup } from '@kbn/ui-actions-browser/src/types';
import { addPanelMenuTrigger } from '@kbn/ui-actions-plugin/public';
import type { HasAppContext } from '@kbn/presentation-publishing';
import { uiActionsService, visualizationsService } from '../../../services/kibana_services';
import { navigateToVisEditor } from './navigate_to_vis_editor';
import type { MenuItem, MenuItemGroup } from './types';
const VIS_GROUP_TO_ADD_PANEL_GROUP: Record<VisGroups, undefined | PresentableGroup> = {
[VisGroups.AGGBASED]: undefined,
[VisGroups.PROMOTED]: ADD_PANEL_VISUALIZATION_GROUP,
[VisGroups.TOOLS]: ADD_PANEL_ANNOTATION_GROUP,
[VisGroups.LEGACY]: ADD_PANEL_LEGACY_GROUP,
};
export async function getMenuItemGroups(
api: HasAppContext & TracksOverlays
): Promise<MenuItemGroup[]> {
const groups: Record<string, MenuItemGroup> = {};
const addPanelContext = {
embeddable: api,
trigger: addPanelMenuTrigger,
};
function pushItem(group: PresentableGroup, item: MenuItem) {
if (!groups[group.id]) {
groups[group.id] = {
id: group.id,
title: group.getDisplayName?.(addPanelContext) ?? '',
'data-test-subj': `dashboardEditorMenu-${group.id}Group`,
order: group.order ?? 0,
items: [],
};
}
groups[group.id].items.push(item);
}
// add menu items from vis types
visualizationsService.all().forEach((visType) => {
if (visType.disableCreate) return;
const group = VIS_GROUP_TO_ADD_PANEL_GROUP[visType.group];
if (!group) return;
pushItem(group, {
id: visType.name,
name: visType.titleInWizard || visType.title,
isDeprecated: visType.isDeprecated,
icon: visType.icon ?? 'empty',
onClick: () => {
api.clearOverlays();
navigateToVisEditor(api, visType);
},
'data-test-subj': `visType-${visType.name}`,
description: visType.description,
order: visType.order,
});
});
// add menu items from vis alias
visualizationsService.getAliases().forEach((visTypeAlias) => {
if (visTypeAlias.disableCreate) return;
pushItem(ADD_PANEL_VISUALIZATION_GROUP, {
id: visTypeAlias.name,
name: visTypeAlias.title,
icon: visTypeAlias.icon ?? 'empty',
onClick: () => {
api.clearOverlays();
navigateToVisEditor(api, visTypeAlias);
},
'data-test-subj': `visType-${visTypeAlias.name}`,
description: visTypeAlias.description,
order: visTypeAlias.order ?? 0,
});
});
// add menu items from "add panel" actions
(
await uiActionsService.getTriggerCompatibleActions(ADD_PANEL_TRIGGER, { embeddable: api })
).forEach((action) => {
const actionGroups = Array.isArray(action.grouping) ? action.grouping : [ADD_PANEL_OTHER_GROUP];
actionGroups.forEach((group) => {
const actionName = action.getDisplayName(addPanelContext);
pushItem(group, {
id: action.id,
name: actionName,
icon: action.getIconType?.(addPanelContext) ?? 'empty',
onClick: (event: React.MouseEvent) => {
api.clearOverlays();
if (event.currentTarget instanceof HTMLAnchorElement) {
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 &&
(!event.currentTarget.target || event.currentTarget.target === '_self') &&
!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
) {
event.preventDefault();
}
}
action.execute(addPanelContext);
},
'data-test-subj': `create-action-${actionName}`,
description: action?.getDisplayNameTooltip?.(addPanelContext),
order: action.order ?? 0,
});
});
});
return Object.values(groups)
.map((group) => {
group.items.sort((itemA, itemB) => {
return itemA.order === itemB.order
? itemA.name.localeCompare(itemB.name)
: itemB.order - itemA.order;
});
return group;
})
.sort((groupA, groupB) => groupB.order - groupA.order);
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { HasAppContext } from '@kbn/presentation-publishing';
import type { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { METRIC_TYPE } from '@kbn/analytics';
import {
dataService,
embeddableService,
usageCollectionService,
} from '../../../services/kibana_services';
import { DASHBOARD_UI_METRIC_ID } from '../../../utils/telemetry_constants';
export function navigateToVisEditor(api: HasAppContext, visType?: BaseVisType | VisTypeAlias) {
let path = '';
let appId = '';
if (visType) {
const trackUiMetric = usageCollectionService?.reportUiCounter.bind(
usageCollectionService,
DASHBOARD_UI_METRIC_ID
);
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, `${visType.name}:create`);
}
if (!('alias' in visType)) {
// this visualization is not an alias
appId = 'visualize';
path = `#/create?type=${encodeURIComponent(visType.name)}`;
} else if (visType.alias && 'path' in visType.alias) {
// this visualization **is** an alias, and it has an app to redirect to for creation
appId = visType.alias.app;
path = visType.alias.path;
}
} else {
appId = 'visualize';
path = '#/create?';
}
const stateTransferService = embeddableService.getStateTransfer();
stateTransferService.navigateToEditor(appId, {
path,
state: {
originatingApp: api.getAppContext()?.currentAppId,
originatingPath: api.getAppContext()?.getCurrentPath?.(),
searchSessionId: dataService.search.session.getSessionId(),
},
});
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { MouseEventHandler } from 'react';
import type { IconType, CommonProps } from '@elastic/eui';
export interface MenuItem extends Pick<CommonProps, 'data-test-subj'> {
id: string;
name: string;
icon: IconType;
onClick: MouseEventHandler;
description?: string;
isDisabled?: boolean;
isDeprecated?: boolean;
order: number;
}
export interface MenuItemGroup extends Pick<CommonProps, 'data-test-subj'> {
id: string;
isDisabled?: boolean;
title: string;
order: number;
items: MenuItem[];
}

View file

@ -9,80 +9,32 @@
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { METRIC_TYPE } from '@kbn/analytics';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { AddFromLibraryButton, Toolbar, ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { BaseVisType, VisTypeAlias } from '@kbn/visualizations-plugin/public';
import { useStateFromPublishingSubject } from '@kbn/presentation-publishing';
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
import { DASHBOARD_UI_METRIC_ID } from '../../utils/telemetry_constants';
import {
dataService,
embeddableService,
usageCollectionService,
visualizationsService,
} from '../../services/kibana_services';
import { visualizationsService } from '../../services/kibana_services';
import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings';
import { ControlsToolbarButton } from './controls_toolbar_button';
import { EditorMenu } from './editor_menu';
import { AddPanelButton } from './add_panel_button/components/add_panel_button';
import { addFromLibrary } from '../../dashboard_container/embeddable/api';
import { navigateToVisEditor } from './add_panel_button/navigate_to_vis_editor';
export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) {
const { euiTheme } = useEuiTheme();
const dashboardApi = useDashboardApi();
const lensAlias = useMemo(
() => visualizationsService.getAliases().find(({ name }) => name === 'lens'),
[]
);
const createNewVisType = useCallback(
(visType?: BaseVisType | VisTypeAlias) => () => {
let path = '';
let appId = '';
if (visType) {
const trackUiMetric = usageCollectionService?.reportUiCounter.bind(
usageCollectionService,
DASHBOARD_UI_METRIC_ID
);
if (trackUiMetric) {
trackUiMetric(METRIC_TYPE.CLICK, `${visType.name}:create`);
}
if (!('alias' in visType)) {
// this visualization is not an alias
appId = 'visualize';
path = `#/create?type=${encodeURIComponent(visType.name)}`;
} else if (visType.alias && 'path' in visType.alias) {
// this visualization **is** an alias, and it has an app to redirect to for creation
appId = visType.alias.app;
path = visType.alias.path;
}
} else {
appId = 'visualize';
path = '#/create?';
}
const stateTransferService = embeddableService.getStateTransfer();
stateTransferService.navigateToEditor(appId, {
path,
state: {
originatingApp: dashboardApi.getAppContext()?.currentAppId,
originatingPath: dashboardApi.getAppContext()?.getCurrentPath?.(),
searchSessionId: dataService.search.session.getSessionId(),
},
});
},
[dashboardApi]
);
const navigateToDefaultEditor = useCallback(() => {
const lensAlias = visualizationsService.getAliases().find(({ name }) => name === 'lens');
navigateToVisEditor(dashboardApi, lensAlias);
}, [dashboardApi]);
const controlGroupApi = useStateFromPublishingSubject(dashboardApi.controlGroupApi$);
const extraButtons = [
<EditorMenu createNewVisType={createNewVisType} isDisabled={isDisabled} />,
<AddPanelButton isDisabled={isDisabled} />,
<AddFromLibraryButton
onClick={() => addFromLibrary(dashboardApi)}
size="s"
@ -106,7 +58,7 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }
isDisabled={isDisabled}
iconType="lensApp"
size="s"
onClick={createNewVisType(lensAlias)}
onClick={navigateToDefaultEditor}
label={getCreateVisualizationButtonTitle()}
data-test-subj="dashboardAddNewPanelButton"
/>

View file

@ -1,6 +0,0 @@
.dshSolutionToolbar__editorContextMenu {
max-height: 60vh;
overflow-y: scroll;
@include euiScrollBar;
@include euiOverflowShadow;
}

View file

@ -1,31 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { render } from '@testing-library/react';
import React from 'react';
import { buildMockDashboardApi } from '../../mocks';
import { EditorMenu } from './editor_menu';
import { DashboardContext } from '../../dashboard_api/use_dashboard_api';
import { uiActionsService, visualizationsService } from '../../services/kibana_services';
jest.spyOn(uiActionsService, 'getTriggerCompatibleActions').mockResolvedValue([]);
jest.spyOn(visualizationsService, 'getByGroup').mockReturnValue([]);
jest.spyOn(visualizationsService, 'getAliases').mockReturnValue([]);
describe('editor menu', () => {
it('renders without crashing', async () => {
const { api } = buildMockDashboardApi();
render(<EditorMenu createNewVisType={jest.fn()} />, {
wrapper: ({ children }) => {
return <DashboardContext.Provider value={api}>{children}</DashboardContext.Provider>;
},
});
});
});

View file

@ -1,92 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import './editor_menu.scss';
import React, { useEffect, useCallback, type ComponentProps } from 'react';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { ToolbarButton } from '@kbn/shared-ux-button-toolbar';
import { useGetDashboardPanels, DashboardPanelSelectionListFlyout } from './add_new_panel';
import { useDashboardApi } from '../../dashboard_api/use_dashboard_api';
import { coreServices } from '../../services/kibana_services';
interface EditorMenuProps
extends Pick<Parameters<typeof useGetDashboardPanels>[0], 'createNewVisType'> {
isDisabled?: boolean;
}
export const EditorMenu = ({ createNewVisType, isDisabled }: EditorMenuProps) => {
const dashboardApi = useDashboardApi();
const fetchDashboardPanels = useGetDashboardPanels({
api: dashboardApi,
createNewVisType,
});
useEffect(() => {
// ensure opened dashboard is closed if a navigation event happens;
return () => {
dashboardApi.clearOverlays();
};
}, [dashboardApi]);
const openDashboardPanelSelectionFlyout = useCallback(
function openDashboardPanelSelectionFlyout() {
const flyoutPanelPaddingSize: ComponentProps<
typeof DashboardPanelSelectionListFlyout
>['paddingSize'] = 'm';
const mount = toMountPoint(
React.createElement(function () {
return (
<DashboardPanelSelectionListFlyout
close={dashboardApi.clearOverlays}
{...{
paddingSize: flyoutPanelPaddingSize,
fetchDashboardPanels: fetchDashboardPanels.bind(null, dashboardApi.clearOverlays),
}}
/>
);
}),
coreServices
);
dashboardApi.openOverlay(
coreServices.overlays.openFlyout(mount, {
size: 'm',
maxWidth: 500,
paddingSize: flyoutPanelPaddingSize,
'aria-labelledby': 'addPanelsFlyout',
'data-test-subj': 'dashboardPanelSelectionFlyout',
onClose(overlayRef) {
dashboardApi.clearOverlays();
overlayRef.close();
},
})
);
},
[dashboardApi, fetchDashboardPanels]
);
return (
<ToolbarButton
data-test-subj="dashboardEditorMenuButton"
isDisabled={isDisabled}
iconType="plusInCircle"
label={i18n.translate('dashboard.solutionToolbar.editorMenuButtonLabel', {
defaultMessage: 'Add panel',
})}
onClick={openDashboardPanelSelectionFlyout}
size="s"
/>
);
};

View file

@ -85,7 +85,8 @@
"@kbn/core-rendering-browser",
"@kbn/esql-variables-types",
"@kbn/grid-layout",
"@kbn/esql-validation-autocomplete"
"@kbn/esql-validation-autocomplete",
"@kbn/ui-actions-browser"
],
"exclude": ["target/**/*"]
}

View file

@ -57,4 +57,5 @@ export {
ADD_PANEL_ANNOTATION_GROUP,
ADD_PANEL_OTHER_GROUP,
ADD_PANEL_LEGACY_GROUP,
ADD_PANEL_VISUALIZATION_GROUP,
} from './ui_actions/add_panel_groups';

View file

@ -9,6 +9,18 @@
import { i18n } from '@kbn/i18n';
export const ADD_PANEL_VISUALIZATION_GROUP = {
id: 'visualizations',
getDisplayName: () =>
i18n.translate('embeddableApi.common.constants.grouping.visualizations', {
defaultMessage: 'Visualizations',
}),
getIconType: () => {
return 'visGauge';
},
order: 1000,
};
export const ADD_PANEL_ANNOTATION_GROUP = {
id: 'annotation-and-navigation',
getDisplayName: () =>

View file

@ -20,7 +20,6 @@ export function plugin(initializerContext: PluginInitializerContext) {
export { TypesService } from './vis_types/types_service';
export { VIS_EVENT_TO_TRIGGER } from './embeddable/events';
export { apiHasVisualizeConfig } from './embeddable/interfaces/has_visualize_config';
export { COMMON_VISUALIZATION_GROUPING } from './legacy/embeddable/constants';
export { VisualizationContainer } from './components';
export { getVisSchemas } from './vis_schemas';
export { prepareLogTable } from '../common/utils/prepare_log_table';

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { i18n } from '@kbn/i18n';
export { VISUALIZE_EMBEDDABLE_TYPE } from '../../../common/constants';
export const COMMON_VISUALIZATION_GROUPING = [
{
id: 'visualizations',
getDisplayName: () =>
i18n.translate('visualizations.common.constants.grouping.legacy', {
defaultMessage: 'Visualizations',
}),
getIconType: () => {
return 'visGauge';
},
order: 1000,
},
];

View file

@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { VISUALIZE_EMBEDDABLE_TYPE, COMMON_VISUALIZATION_GROUPING } from './constants';
export { VISUALIZE_EMBEDDABLE_TYPE } from '../../../common/constants';
export { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object';
export type { VisualizeEmbeddable, VisualizeInput } from './visualize_embeddable';

View file

@ -38,7 +38,7 @@ import { StartServicesGetter } from '@kbn/kibana-utils-plugin/public';
import { isFallbackDataView } from '../../visualize_app/utils';
import { VisualizationMissedSavedObjectError } from '../../components/visualization_missed_saved_object_error';
import VisualizationError from '../../components/visualization_error';
import { VISUALIZE_EMBEDDABLE_TYPE } from './constants';
import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../common/constants';
import { SerializedVis, Vis } from '../../vis';
import { getApplication, getExpressions, getUiActions } from '../../services';
import { VIS_EVENT_TO_TRIGGER } from '../../embeddable/events';

View file

@ -9531,7 +9531,6 @@
"visualizations.badge.readOnly.text": "Lecture seule",
"visualizations.badge.readOnly.tooltip": "Impossible d'enregistrer les visualisations dans la bibliothèque",
"visualizations.byValue_pageHeading": "Visualisation de type {chartType} intégrée à l'application {originatingApp}",
"visualizations.common.constants.grouping.legacy": "Visualisations",
"visualizations.confirmModal.cancelButtonLabel": "Annuler",
"visualizations.confirmModal.confirmTextDescription": "Quitter l'éditeur Visualize sans enregistrer les modifications ?",
"visualizations.confirmModal.overwriteButtonLabel": "Écraser",

View file

@ -9406,7 +9406,6 @@
"visualizations.badge.readOnly.text": "読み取り専用",
"visualizations.badge.readOnly.tooltip": "ビジュアライゼーションをライブラリに保存できません",
"visualizations.byValue_pageHeading": "{originatingApp}アプリに埋め込まれた{chartType}タイプのビジュアライゼーション",
"visualizations.common.constants.grouping.legacy": "ビジュアライゼーション",
"visualizations.confirmModal.cancelButtonLabel": "キャンセル",
"visualizations.confirmModal.confirmTextDescription": "変更を保存せずにVisualizeエディターから移動しますか",
"visualizations.confirmModal.overwriteButtonLabel": "上書き",

View file

@ -9265,7 +9265,6 @@
"visualizations.badge.readOnly.text": "只读",
"visualizations.badge.readOnly.tooltip": "无法将可视化保存到库",
"visualizations.byValue_pageHeading": "已嵌入到 {originatingApp} 应用中的 {chartType} 类型可视化",
"visualizations.common.constants.grouping.legacy": "可视化",
"visualizations.confirmModal.cancelButtonLabel": "取消",
"visualizations.confirmModal.confirmTextDescription": "离开 Visualize 编辑器而不保存更改?",
"visualizations.confirmModal.overwriteButtonLabel": "覆盖",

View file

@ -9,7 +9,7 @@ import type { CoreStart } from '@kbn/core/public';
import { Action, IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { EmbeddableApiContext } from '@kbn/presentation-publishing';
import { apiIsPresentationContainer } from '@kbn/presentation-containers';
import { COMMON_VISUALIZATION_GROUPING } from '@kbn/visualizations-plugin/public';
import { ADD_PANEL_VISUALIZATION_GROUP } from '@kbn/embeddable-plugin/public';
import type { LensPluginStartDependencies } from '../../plugin';
import type { EditorFrameService } from '../../editor_frame_service';
@ -22,7 +22,7 @@ export class CreateESQLPanelAction implements Action<EmbeddableApiContext> {
public id = ACTION_CREATE_ESQL_CHART;
public order = 50;
public grouping = COMMON_VISUALIZATION_GROUPING;
public grouping = [ADD_PANEL_VISUALIZATION_GROUP];
constructor(
protected readonly startDependencies: LensPluginStartDependencies,