diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 32507cbc5e5f4..41335069461fa 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -23,6 +23,7 @@ "requiredBundles": [ "home", "kibanaReact", - "kibanaUtils" + "kibanaUtils", + "presentationUtil" ] } diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 6501f92689d17..fc6e41538f753 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -41,7 +41,7 @@ export class DashboardContainerFactoryDefinition public readonly getDisplayName = () => { return i18n.translate('dashboard.factory.displayName', { - defaultMessage: 'dashboard', + defaultMessage: 'Dashboard', }); }; diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index a82aa78b815ec..bf8f957188a4c 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -11,9 +11,18 @@ import angular from 'angular'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import UseUnmount from 'react-use/lib/useUnmount'; +import { EuiContextMenu } from '@elastic/eui'; +import { VisualizeInput } from '../../../../visualizations/public'; +import { + AddFromLibraryButton, + PrimaryActionButton, + QuickButtonGroup, + SolutionToolbarPopover, +} from '../../../../presentation_util/public'; import { useKibana } from '../../services/kibana_react'; import { IndexPattern, SavedQuery, TimefilterContract } from '../../services/data'; import { + EmbeddableFactoryDefinition, EmbeddableFactoryNotFoundError, EmbeddableInput, isErrorEmbeddable, @@ -43,11 +52,11 @@ import { showCloneModal } from './show_clone_modal'; import { showOptionsPopover } from './show_options_popover'; import { TopNavIds } from './top_nav_ids'; import { ShowShareModal } from './show_share_modal'; -import { PanelToolbar } from './panel_toolbar'; import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; +import { SolutionToolbar } from '../../../../presentation_util/public'; import { DashboardContainer } from '..'; export interface DashboardTopNavState { @@ -103,6 +112,35 @@ export function DashboardTopNav({ const [state, setState] = useState({ chromeIsVisible: false }); const [isSaveInProgress, setIsSaveInProgress] = useState(false); + const factories = embeddable + ? Array.from(embeddable.getEmbeddableFactories()).filter( + ({ isEditable, canCreateNew, isContainerType }) => + isEditable() && !isContainerType && canCreateNew() + ) + : []; + + const editorMenuPanels = [ + { + id: 0, + items: factories.map((factory: EmbeddableFactoryDefinition) => { + const onClick = async () => { + if (factory.getExplicitInput) { + const explicitInput = await factory.getExplicitInput(); + await dashboardContainer.addNewEmbeddable(factory.type, explicitInput); + } else { + await factory.create({} as EmbeddableInput, dashboardContainer); + } + }; + + return { + name: factory.getDisplayName(), + icon: 'empty', + onClick, + }; + }), + }, + ]; + useEffect(() => { const visibleSubscription = chrome.getIsVisible$().subscribe((chromeIsVisible) => { setState((s) => ({ ...s, chromeIsVisible })); @@ -147,12 +185,28 @@ export function DashboardTopNav({ const createNew = useCallback(async () => { const type = 'visualization'; const factory = embeddable.getEmbeddableFactory(type); + if (!factory) { throw new EmbeddableFactoryNotFoundError(type); } + await factory.create({} as EmbeddableInput, dashboardContainer); }, [dashboardContainer, embeddable]); + const createNewVisType = useCallback( + (newVisType: string) => async () => { + const type = 'visualization'; + const factory = embeddable.getEmbeddableFactory(type); + + if (!factory) { + throw new EmbeddableFactoryNotFoundError(type); + } + + await factory.create({ newVisType, id: newVisType } as VisualizeInput, dashboardContainer); + }, + [dashboardContainer, embeddable] + ); + const clearAddPanel = useCallback(() => { if (state.addPanelOverlay) { state.addPanelOverlay.close(); @@ -540,11 +594,63 @@ export function DashboardTopNav({ }; const { TopNavMenu } = navigation.ui; + + const quickButtons = [ + { + iconType: 'visText', + createType: i18n.translate('dashboard.solutionToolbar.markdownQuickButtonLabel', { + defaultMessage: 'Markdown', + }), + onClick: createNewVisType('markdown'), + }, + { + iconType: 'controlsHorizontal', + createType: i18n.translate('dashboard.solutionToolbar.inputControlsQuickButtonLabel', { + defaultMessage: 'Input control', + }), + onClick: createNewVisType('input_control_vis'), + }, + ]; + return ( <> {viewMode !== ViewMode.VIEW ? ( - + + {{ + primaryActionButton: ( + + ), + quickButtonGroup: , + addFromLibraryButton: ( + + ), + extraButtons: factories.length + ? [ + + + , + ] + : undefined, + }} + ) : null} ); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot deleted file mode 100644 index afbbecb3935e0..0000000000000 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/__snapshots__/panel_toolbar.stories.storyshot +++ /dev/null @@ -1,71 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Storyshots components/PanelToolbar default 1`] = ` -
-
- -
-
- -
-
-`; diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx index 0449fae80186d..a017396d59c7d 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx +++ b/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.tsx @@ -7,7 +7,7 @@ */ import './panel_toolbar.scss'; -import React, { FC } from 'react'; +import React, { FC, ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; @@ -16,10 +16,17 @@ interface Props { onAddPanelClick: () => void; /** The click handler for the Library button for adding existing visualizations/embeddables */ onLibraryClick: () => void; + /** Additional content for toolbar */ + children: ReactNode; } -export const PanelToolbar: FC = ({ onAddPanelClick, onLibraryClick }) => ( - +export const PanelToolbar: FC = ({ onAddPanelClick, onLibraryClick, children }) => ( + = ({ onAddPanelClick, onLibraryClick }) => })} + {children} testTime.getTime()); + +// Mock React Portal for components that use modals, tooltips, etc +// @ts-expect-error Portal mocks are notoriously difficult to type +ReactDOM.createPortal = jest.fn((element) => element); + +// Mock EUI generated ids to be consistently predictable for snapshots. +jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); + +// Mock react-datepicker dep used by eui to avoid rendering the entire large component +jest.mock('@elastic/eui/packages/react-datepicker', () => { + return { + __esModule: true, + default: 'ReactDatePicker', + }; +}); + +// Mock the EUI HTML ID Generator so elements have a predictable ID in snapshots +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { + return { + htmlIdGenerator: () => () => `generated-id`, + }; +}); + +// To be resolved by EUI team. +// https://github.com/elastic/eui/issues/3712 +jest.mock('@elastic/eui/lib/components/overlay_mask/overlay_mask', () => { + return { + EuiOverlayMask: ({ children }: { children: ReactChildren }) => children, + }; +}); + +// @ts-ignore +import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; +jest.mock('@elastic/eui/test-env/components/observer/observer'); +EuiObserver.mockImplementation(() => 'EuiObserver'); + +// Some of the code requires that this directory exists, but the tests don't actually require any css to be present +const cssDir = path.resolve(__dirname, '../../../../built_assets/css'); +if (!fs.existsSync(cssDir)) { + fs.mkdirSync(cssDir, { recursive: true }); +} + +addSerializer(styleSheetSerializer); + +// Initialize Storyshots and build the Jest Snapshots +// initStoryshots({ +// configPath: path.resolve(__dirname, './../storybook'), +// framework: 'react', +// test: multiSnapshotWithOptions({}), +// }); diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index dd99119cfb457..12820fc08310d 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -32,5 +32,8 @@ { "path": "../saved_objects/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../spaces_oss/tsconfig.json" }, + { "path": "../charts/tsconfig.json" }, + { "path": "../discover/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, ] } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index 8caec4a4428c3..fc5d66f9a9027 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -8,15 +8,14 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { ReactElement } from 'react'; +import React from 'react'; import { CoreSetup } from 'src/core/public'; -import { EuiContextMenuItem, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import { EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; import { EmbeddableStart } from 'src/plugins/embeddable/public'; import { IContainer } from '../../../../containers'; import { EmbeddableFactoryNotFoundError } from '../../../../errors'; -import { SavedObjectFinderCreateNew } from './saved_object_finder_create_new'; import { SavedObjectEmbeddableInput } from '../../../../embeddables'; interface Props { @@ -32,10 +31,6 @@ interface State { isCreateMenuOpen: boolean; } -function capitalize([first, ...letters]: string) { - return `${first.toUpperCase()}${letters.join('')}`; -} - export class AddPanelFlyout extends React.Component { private lastToast: any; @@ -100,23 +95,6 @@ export class AddPanelFlyout extends React.Component { this.showToast(name); }; - private getCreateMenuItems(): ReactElement[] { - return [...this.props.getAllFactories()] - .filter( - (factory) => factory.isEditable() && !factory.isContainerType && factory.canCreateNew() - ) - .map((factory) => ( - this.createNewEmbeddable(factory.type)} - className="embPanel__addItem" - > - {capitalize(factory.getDisplayName())} - - )); - } - public render() { const SavedObjectFinder = this.props.SavedObjectFinder; const metaData = [...this.props.getAllFactories()] @@ -133,9 +111,7 @@ export class AddPanelFlyout extends React.Component { noItemsMessage={i18n.translate('embeddableApi.addPanel.noMatchingObjectsMessage', { defaultMessage: 'No matching objects found.', })} - > - - + /> ); return ( diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts new file mode 100644 index 0000000000000..332d60787b8cb --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/index.ts @@ -0,0 +1,10 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export { SolutionToolbar } from './solution_toolbar'; +export * from './items'; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx new file mode 100644 index 0000000000000..0550de1d069fa --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/add_from_library.tsx @@ -0,0 +1,24 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ComponentStrings } from '../../../i18n/components'; +import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; + +const { SolutionToolbar: strings } = ComponentStrings; + +export type Props = Omit; + +export const AddFromLibraryButton = ({ onClick, ...rest }: Props) => ( + +); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss similarity index 73% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss rename to src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss index 9ad6a5257df3e..79c3d4cca7ace 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.scss +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.scss @@ -1,9 +1,5 @@ -.panelToolbar { - padding: 0 $euiSizeS $euiSizeS; - flex-grow: 0; -} -.panelToolbarButton { +.solutionToolbarButton { line-height: $euiButtonHeight; // Keeps alignment of text and chart icon background-color: $euiColorEmptyShade; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx new file mode 100644 index 0000000000000..5de8e24ef5f0d --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/button.tsx @@ -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 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 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { EuiButtonPropsForButton } from '@elastic/eui/src/components/button/button'; + +import './button.scss'; + +export interface Props extends Pick { + label: string; + primary?: boolean; +} + +export const SolutionToolbarButton = ({ label, primary, ...rest }: Props) => ( + + {label} + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts new file mode 100644 index 0000000000000..1910dd48bff30 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/index.ts @@ -0,0 +1,13 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export { SolutionToolbarButton } from './button'; +export { SolutionToolbarPopover } from './popover'; +export { AddFromLibraryButton } from './add_from_library'; +export { QuickButton, QuickButtonGroup } from './quick_group'; +export { PrimaryActionButton, PrimaryActionPopover } from './primary'; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx new file mode 100644 index 0000000000000..fbb34e165190d --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/popover.tsx @@ -0,0 +1,36 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; +import { EuiPopover } from '@elastic/eui'; +import { Props as EuiPopoverProps } from '@elastic/eui/src/components/popover/popover'; + +import { SolutionToolbarButton, Props as ButtonProps } from './button'; + +type AllowedButtonProps = Omit; +type AllowedPopoverProps = Omit< + EuiPopoverProps, + 'button' | 'isOpen' | 'closePopover' | 'anchorPosition' +>; + +export type Props = AllowedButtonProps & AllowedPopoverProps; + +export const SolutionToolbarPopover = ({ label, iconType, primary, ...popover }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + const onButtonClick = () => setIsOpen((status) => !status); + const closePopover = () => setIsOpen(false); + + const button = ( + + ); + + return ( + + ); +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/primary.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary.tsx new file mode 100644 index 0000000000000..825f198ee7079 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary.tsx @@ -0,0 +1,20 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { SolutionToolbarButton, Props as SolutionToolbarButtonProps } from './button'; +import { SolutionToolbarPopover, Props as SolutionToolbarPopoverProps } from './popover'; + +export const PrimaryActionPopover = (props: Omit) => ( + +); + +export const PrimaryActionButton = (props: Omit) => ( + +); diff --git a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx similarity index 53% rename from src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx rename to src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx index 5525b1cd069ad..22d8363c4b146 100644 --- a/src/plugins/dashboard/public/application/top_nav/panel_toolbar/panel_toolbar.stories.tsx +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/primary_popover.tsx @@ -6,14 +6,12 @@ * Side Public License, v 1. */ -import { storiesOf } from '@storybook/react'; -import { action } from '@storybook/addon-actions'; import React from 'react'; -import { PanelToolbar } from './panel_toolbar'; -storiesOf('components/PanelToolbar', module).add('default', () => ( - -)); +import { SolutionToolbarPopover, Props as SolutionToolbarPopoverProps } from './popover'; + +export type Props = Omit; + +export const PrimaryActionPopover = (props: Props) => ( + +); diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss new file mode 100644 index 0000000000000..2c8e138502770 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.scss @@ -0,0 +1,8 @@ +.quickButtonGroup { + .euiButtonGroup__buttons { + border-radius: $euiSizeXS; + } + .quickButtonGroup__button { + background-color: $euiColorEmptyShade; + } +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx new file mode 100644 index 0000000000000..61a7cb8031455 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/items/quick_group.tsx @@ -0,0 +1,58 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiButtonGroup, htmlIdGenerator, EuiButtonGroupOptionProps } from '@elastic/eui'; +import { ComponentStrings } from '../../../i18n/components'; + +const { QuickButtonGroup: strings } = ComponentStrings; + +import './quick_group.scss'; + +export interface QuickButton extends Pick { + createType: string; + onClick: () => void; +} + +export interface Props { + buttons: QuickButton[]; +} + +type Option = EuiButtonGroupOptionProps & Omit; + +export const QuickButtonGroup = ({ buttons }: Props) => { + const buttonGroupOptions: Option[] = buttons.map((button: QuickButton, index) => { + const { createType: label, ...rest } = button; + const title = strings.getAriaButtonLabel(label); + + return { + ...rest, + 'aria-label': title, + className: 'quickButtonGroup__button', + id: `${htmlIdGenerator()()}${index}`, + label, + title, + }; + }); + + const onChangeIconsMulti = (optionId: string) => { + buttonGroupOptions.find((x) => x.id === optionId)?.onClick(); + }; + + return ( + + ); +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss new file mode 100644 index 0000000000000..18160acf191e4 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.scss @@ -0,0 +1,4 @@ +.solutionToolbar { + padding: 0 $euiSizeS $euiSizeS; + flex-grow: 0; +} diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx new file mode 100644 index 0000000000000..fa33f53f9ae4f --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.stories.tsx @@ -0,0 +1,205 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Story } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { EuiContextMenu } from '@elastic/eui'; + +import { SolutionToolbar } from './solution_toolbar'; +import { SolutionToolbarPopover } from './items'; +import { AddFromLibraryButton, PrimaryActionButton, QuickButtonGroup } from './items'; + +const quickButtons = [ + { + createType: 'Text', + onClick: action('onTextClick'), + iconType: 'visText', + }, + { + createType: 'Control', + onClick: action('onControlClick'), + iconType: 'controlsHorizontal', + }, + { + createType: 'Link', + onClick: action('onLinkClick'), + iconType: 'link', + }, + { + createType: 'Image', + onClick: action('onImageClick'), + iconType: 'image', + }, + { + createType: 'Markup', + onClick: action('onMarkupClick'), + iconType: 'visVega', + }, +]; + +const primaryButtonConfigs = { + Generic: ( + + ), + Canvas: ( + + + + ), + Dashboard: ( + + ), +}; + +const extraButtonConfigs = { + Generic: undefined, + Canvas: undefined, + Dashboard: [ + + + , + ], +}; + +export default { + title: 'Solution Toolbar', + description: 'A universal toolbar for solutions maintained by the Presentation Team.', + component: SolutionToolbar, + argTypes: { + quickButtonCount: { + defaultValue: 2, + control: { + type: 'number', + min: 0, + max: 5, + step: 1, + }, + }, + showAddFromLibraryButton: { + defaultValue: true, + control: { + type: 'boolean', + }, + }, + solution: { + table: { + disable: true, + }, + }, + }, + // https://github.com/storybookjs/storybook/issues/11543#issuecomment-684130442 + parameters: { + docs: { + source: { + type: 'code', + }, + }, + }, +}; + +const Template: Story<{ + solution: 'Generic' | 'Canvas' | 'Dashboard'; + quickButtonCount: number; + showAddFromLibraryButton: boolean; +}> = ({ quickButtonCount, solution, showAddFromLibraryButton }) => { + const primaryActionButton = primaryButtonConfigs[solution]; + const extraButtons = extraButtonConfigs[solution]; + let quickButtonGroup; + let addFromLibraryButton; + + if (quickButtonCount > 0) { + quickButtonGroup = ; + } + + if (showAddFromLibraryButton) { + addFromLibraryButton = ; + } + + return ( + + {{ + primaryActionButton, + quickButtonGroup, + extraButtons, + addFromLibraryButton, + }} + + ); +}; + +export const Generic = Template.bind({}); +Generic.args = { + ...Template.args, + solution: 'Generic', +}; + +export const Canvas = Template.bind({}); +Canvas.args = { + ...Template.args, + solution: 'Canvas', +}; + +export const Dashboard = Template.bind({}); +Dashboard.args = { + ...Template.args, + solution: 'Dashboard', +}; diff --git a/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx new file mode 100644 index 0000000000000..bb8b04e8b8f09 --- /dev/null +++ b/src/plugins/presentation_util/public/components/solution_toolbar/solution_toolbar.tsx @@ -0,0 +1,62 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import React, { ReactElement } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { + AddFromLibraryButton, + QuickButtonGroup, + PrimaryActionButton, + SolutionToolbarButton, + PrimaryActionPopover, + SolutionToolbarPopover, +} from './items'; + +import './solution_toolbar.scss'; + +interface NamedSlots { + primaryActionButton: ReactElement; + quickButtonGroup?: ReactElement; + addFromLibraryButton?: ReactElement; + extraButtons?: Array>; +} + +export interface Props { + children: NamedSlots; +} + +export const SolutionToolbar = ({ children }: Props) => { + const { + primaryActionButton, + quickButtonGroup, + addFromLibraryButton, + extraButtons = [], + } = children; + + const extra = extraButtons.map((button, index) => + button ? ( + + {button} + + ) : null + ); + + return ( + + {primaryActionButton} + {quickButtonGroup ? {quickButtonGroup} : null} + {extra} + {addFromLibraryButton ? {addFromLibraryButton} : null} + + ); +}; diff --git a/src/plugins/presentation_util/public/i18n/components.ts b/src/plugins/presentation_util/public/i18n/components.ts new file mode 100644 index 0000000000000..ab0e6d1bdbda0 --- /dev/null +++ b/src/plugins/presentation_util/public/i18n/components.ts @@ -0,0 +1,35 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +export const ComponentStrings = { + SolutionToolbar: { + getEditorMenuButtonLabel: () => + i18n.translate('presentationUtil.solutionToolbar.editorMenuButtonLabel', { + defaultMessage: 'All editors', + }), + getLibraryButtonLabel: () => + i18n.translate('presentationUtil.solutionToolbar.libraryButtonLabel', { + defaultMessage: 'Add from library', + }), + }, + QuickButtonGroup: { + getAriaButtonLabel: (createType: string) => + i18n.translate('presentationUtil.solutionToolbar.quickButton.ariaButtonLabel', { + defaultMessage: `Create new {createType}`, + values: { + createType, + }, + }), + getLegend: () => + i18n.translate('presentationUtil.solutionToolbar.quickButton.legendLabel', { + defaultMessage: 'Quick create', + }), + }, +}; diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index f13807032db3e..740ac7d33231f 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -14,6 +14,16 @@ export { } from './components/saved_object_save_modal_dashboard'; export { DashboardPicker } from './components/dashboard_picker'; +export { + AddFromLibraryButton, + PrimaryActionButton, + PrimaryActionPopover, + QuickButton, + QuickButtonGroup, + SolutionToolbar, + SolutionToolbarButton, + SolutionToolbarPopover, +} from './components/solution_toolbar'; export function plugin() { return new PresentationUtilPlugin(); diff --git a/src/plugins/presentation_util/storybook/storyshots.test.tsx b/src/plugins/presentation_util/storybook/storyshots.test.tsx new file mode 100644 index 0000000000000..047f802f9babf --- /dev/null +++ b/src/plugins/presentation_util/storybook/storyshots.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs'; +import { ReactChildren } from 'react'; +import path from 'path'; +import moment from 'moment'; +import 'moment-timezone'; +import ReactDOM from 'react-dom'; + +import initStoryshots, { multiSnapshotWithOptions } from '@storybook/addon-storyshots'; +// @ts-expect-error untyped library +import styleSheetSerializer from 'jest-styled-components/src/styleSheetSerializer'; +import { addSerializer } from 'jest-specific-snapshot'; + +// Set our default timezone to UTC for tests so we can generate predictable snapshots +moment.tz.setDefault('UTC'); + +// Freeze time for the tests for predictable snapshots +const testTime = new Date(Date.UTC(2019, 5, 1)); // June 1 2019 +Date.now = jest.fn(() => testTime.getTime()); + +// Mock EUI generated ids to be consistently predictable for snapshots. +jest.mock(`@elastic/eui/lib/components/form/form_row/make_id`, () => () => `generated-id`); + +// Mock react-datepicker dep used by eui to avoid rendering the entire large component +jest.mock('@elastic/eui/packages/react-datepicker', () => { + return { + __esModule: true, + default: 'ReactDatePicker', + }; +}); + +// Mock React Portal for components that use modals, tooltips, etc +// @ts-expect-error Portal mocks are notoriously difficult to type +ReactDOM.createPortal = jest.fn((element) => element); + +// Mock the EUI HTML ID Generator so elements have a predictable ID in snapshots +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { + return { + htmlIdGenerator: () => () => `generated-id`, + }; +}); + +// To be resolved by EUI team. +// https://github.com/elastic/eui/issues/3712 +jest.mock('@elastic/eui/lib/components/overlay_mask/overlay_mask', () => { + return { + EuiOverlayMask: ({ children }: { children: ReactChildren }) => children, + }; +}); + +// @ts-expect-error untyped library +import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; +jest.mock('@elastic/eui/test-env/components/observer/observer'); +EuiObserver.mockImplementation(() => 'EuiObserver'); + +// Some of the code requires that this directory exists, but the tests don't actually require any css to be present +const cssDir = path.resolve(__dirname, '../../../../built_assets/css'); +if (!fs.existsSync(cssDir)) { + fs.mkdirSync(cssDir, { recursive: true }); +} + +addSerializer(styleSheetSerializer); + +// Initialize Storyshots and build the Jest Snapshots +initStoryshots({ + configPath: path.resolve(__dirname, './../storybook'), + framework: 'react', + test: multiSnapshotWithOptions({}), + // Don't snapshot tests that start with 'redux' + storyNameRegex: /^((?!.*?redux).)*$/, +}); diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 37b9380f6f2b9..91108fa2dbe39 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -11,5 +11,6 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../saved_objects/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, ] } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 580ffef548fe1..bd2eb682aeed0 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -63,6 +63,7 @@ export interface VisualizeInput extends EmbeddableInput { query?: Query; filters?: Filter[]; timeRange?: TimeRange; + newVisType?: string; } export interface VisualizeOutput extends EmbeddableOutput { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index c2b9fcd77757a..96af09fe7d538 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -58,7 +58,7 @@ interface VisualizationAttributes extends SavedObjectAttributes { export interface VisualizeEmbeddableFactoryDeps { start: StartServicesGetter< - Pick + Pick >; } @@ -113,7 +113,7 @@ export class VisualizeEmbeddableFactory public getDisplayName() { return i18n.translate('visualizations.displayName', { - defaultMessage: 'visualization', + defaultMessage: 'Visualization', }); } @@ -162,7 +162,10 @@ export class VisualizeEmbeddableFactory } } - public async create(input: VisualizeInput & { savedVis?: SerializedVis }, parent?: IContainer) { + public async create( + input: VisualizeInput & { savedVis?: SerializedVis; newVisType?: string }, + parent?: IContainer + ) { // TODO: This is a bit of a hack to preserve the original functionality. Ideally we will clean this up // to allow for in place creation of visualizations without having to navigate away to a new URL. if (input.savedVis) { @@ -181,6 +184,7 @@ export class VisualizeEmbeddableFactory showNewVisModal({ originatingApp: await this.getCurrentAppId(), outsideVisualizeApp: true, + newVisType: input?.newVisType, }); return undefined; } diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 8f1ebe25b5059..901593626a945 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -17,7 +17,6 @@ import { dataPluginMock } from '../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; -import { dashboardPluginMock } from '../../../plugins/dashboard/public/mocks'; import { savedObjectsPluginMock } from '../../../plugins/saved_objects/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ @@ -62,7 +61,6 @@ const createInstance = async () => { uiActions: uiActionsPluginMock.createStartContract(), application: applicationServiceMock.createStartContract(), embeddable: embeddablePluginMock.createStartContract(), - dashboard: dashboardPluginMock.createStartContract(), getAttributeService: jest.fn(), savedObjectsClient: coreMock.createStart().savedObjects.client, savedObjects: savedObjectsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index d4e7132a1a21e..081f5d65103c2 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -62,7 +62,6 @@ import { convertToSerializedVis, } from './saved_visualizations/_saved_vis'; import { createSavedSearchesLoader } from '../../discover/public'; -import { DashboardStart } from '../../dashboard/public'; import { SavedObjectsStart } from '../../saved_objects/public'; /** @@ -97,7 +96,6 @@ export interface VisualizationsStartDeps { inspector: InspectorStart; uiActions: UiActionsStart; application: ApplicationStart; - dashboard: DashboardStart; getAttributeService: EmbeddableStart['getAttributeService']; savedObjects: SavedObjectsStart; savedObjectsClient: SavedObjectsClientContract; @@ -145,7 +143,7 @@ export class VisualizationsPlugin public start( core: CoreStart, - { data, expressions, uiActions, embeddable, dashboard, savedObjects }: VisualizationsStartDeps + { data, expressions, uiActions, embeddable, savedObjects }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); setTypes(types); diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index d36b734f75be2..6d5540b25a177 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -41,6 +41,7 @@ interface TypeSelectionProps { outsideVisualizeApp?: boolean; stateTransfer?: EmbeddableStateTransfer; originatingApp?: string; + newVisType?: string; } interface TypeSelectionState { @@ -84,6 +85,19 @@ class NewVisModal extends React.Component this.props.newVisType === alias.name) as VisTypeAlias); + + if (newVisType) { + this.onVisTypeSelected(newVisType); + return null; + } + } + const visNewVisDialogAriaLabel = i18n.translate( 'visualizations.newVisWizard.helpTextAriaLabel', { diff --git a/src/plugins/visualizations/public/wizard/show_new_vis.tsx b/src/plugins/visualizations/public/wizard/show_new_vis.tsx index e8d61cdd2cc07..b42499b2ee06b 100644 --- a/src/plugins/visualizations/public/wizard/show_new_vis.tsx +++ b/src/plugins/visualizations/public/wizard/show_new_vis.tsx @@ -29,6 +29,7 @@ export interface ShowNewVisModalParams { originatingApp?: string; outsideVisualizeApp?: boolean; createByValue?: boolean; + newVisType?: string; } /** @@ -41,6 +42,7 @@ export function showNewVisModal({ onClose, originatingApp, outsideVisualizeApp, + newVisType, }: ShowNewVisModalParams = {}) { const container = document.createElement('div'); let isClosed = false; @@ -78,6 +80,7 @@ export function showNewVisModal({ usageCollection={getUsageCollector()} application={getApplication()} docLinks={getDocLinks()} + newVisType={newVisType} /> diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index d7c5e6a4b4366..356448aa59771 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -15,7 +15,6 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, - { "path": "../dashboard/tsconfig.json" }, { "path": "../expressions/tsconfig.json" }, { "path": "../ui_actions/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7f257d37cd36d..60a2daad186b4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -670,8 +670,7 @@ "dashboard.panelStorageError.clearError": "保存されていない変更の消去中にエラーが発生しました。{message}", "dashboard.panelStorageError.getError": "保存されていない変更の取得中にエラーが発生しました。{message}", "dashboard.panelStorageError.setError": "保存されていない変更の設定中にエラーが発生しました。{message}", - "dashboard.panelToolbar.addPanelButtonLabel": "パネルの作成", - "dashboard.panelToolbar.libraryButtonLabel": "ライブラリから追加", + "dashboard.solutionToolbar.addPanelButtonLabel": "パネルの作成", "dashboard.placeholder.factory.displayName": "プレースホルダー", "dashboard.savedDashboard.newDashboardTitle": "新規ダッシュボード", "dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "このダッシュボードに時刻が保存されていないため、同期できません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 04962e1a56330..10b312aa710b1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -673,8 +673,7 @@ "dashboard.panelStorageError.clearError": "清除未保存更改时遇到错误:{message}", "dashboard.panelStorageError.getError": "获取未保存更改时遇到错误:{message}", "dashboard.panelStorageError.setError": "设置未保存更改时遇到错误:{message}", - "dashboard.panelToolbar.addPanelButtonLabel": "创建面板", - "dashboard.panelToolbar.libraryButtonLabel": "从库中添加", + "dashboard.solutionToolbar.addPanelButtonLabel": "创建面板", "dashboard.placeholder.factory.displayName": "占位符", "dashboard.savedDashboard.newDashboardTitle": "新建仪表板", "dashboard.stateManager.timeNotSavedWithDashboardErrorMessage": "时间未随此仪表板保存,因此无法同步。",