[Controls] Improve controls empty state (#125728)

* Add controls button to toolbar

* Add dismiss button

* Add style to toolbar controls button

* Clean up unnecessary isControlsEnabled check

* Make toolbar controls button conditional once callout dismissed

* Move add and edit controls to toolbar dropdown

* Remove icon buttons

* Add each control seperately to toolbar dropdown

* Remove unused code

* Fix close popover on click

* Remove unnecessary dark theme check

* Make closePopover optional for creating controls

* Fix control group strings

* Fix alignment of toolbar popover items

* Functional tests - create controls from new menu button

* Hide controls callout for empty dashboards

* Add tooltips to control types + i18n support.

* Move callout render logic to dashboard viewport

* Add controls callout functional tests

* Fix bundle size by lazy importing controls callout

* Get create control button in callout via passed function

* Fix mobile view of callout

* Add documentation and cleaned code based on Devon's feedback

* Moved the 'add to library' and 'controls' buttons in to extra
This commit is contained in:
Hannah Mudge 2022-03-09 12:04:27 -07:00 committed by GitHub
parent ca447c66ce
commit b2cd94df7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 619 additions and 395 deletions

View file

@ -8,14 +8,7 @@
import '../control_group.scss';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
EuiPanel,
EuiText,
} from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import React, { useMemo, useState } from 'react';
import classNames from 'classnames';
import {
@ -35,24 +28,13 @@ import {
useSensors,
LayoutMeasuringStrategy,
} from '@dnd-kit/core';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { ViewMode } from '../../../../embeddable/public';
import { ControlGroupStrings } from '../control_group_strings';
import { CreateControlButton } from '../editor/create_control';
import { EditControlGroup } from '../editor/edit_control_group';
import { forwardAllContext } from '../editor/forward_all_context';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlClone, SortableControl } from './control_group_sortable_item';
import { useReduxContainerContext } from '../../../../presentation_util/public';
import { ControlsIllustration } from './controls_illustration';
export const ControlGroup = () => {
// Controls Services Context
const { overlays } = pluginServices.getHooks();
const { openFlyout } = overlays.useService();
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
@ -114,115 +96,69 @@ export const ControlGroup = () => {
if (draggingId) panelBg = 'success';
return (
<EuiPanel
borderRadius="m"
color={panelBg}
paddingSize={emptyState ? 's' : 'none'}
className={classNames('controlsWrapper', {
'controlsWrapper--empty': emptyState,
'controlsWrapper--twoLine': controlStyle === 'twoLine',
})}
>
<>
{idsInOrder.length > 0 ? (
<EuiFlexGroup
wrap={false}
gutterSize="m"
direction="row"
responsive={false}
alignItems="center"
data-test-subj="controls-group"
data-shared-items-count={idsInOrder.length}
<EuiPanel
borderRadius="m"
color={panelBg}
paddingSize={emptyState ? 's' : 'none'}
className={classNames('controlsWrapper', {
'controlsWrapper--empty': emptyState,
'controlsWrapper--twoLine': controlStyle === 'twoLine',
})}
>
<EuiFlexItem>
<DndContext
onDragStart={({ active }) => setDraggingId(active.id)}
onDragEnd={onDragEnd}
onDragCancel={() => setDraggingId(null)}
sensors={sensors}
collisionDetection={closestCenter}
layoutMeasuring={{
strategy: LayoutMeasuringStrategy.Always,
}}
>
<SortableContext items={idsInOrder} strategy={rectSortingStrategy}>
<EuiFlexGroup
className={classNames('controlGroup', { 'controlGroup-isDragging': draggingId })}
alignItems="center"
gutterSize="s"
wrap={true}
>
{idsInOrder.map(
(controlId, index) =>
panels[controlId] && (
<SortableControl
isEditable={isEditable}
dragInfo={{ index, draggingIndex }}
embeddableId={controlId}
key={controlId}
/>
)
)}
</EuiFlexGroup>
</SortableContext>
<DragOverlay>
{draggingId ? <ControlClone draggingId={draggingId} /> : null}
</DragOverlay>
</DndContext>
</EuiFlexItem>
{isEditable && (
<EuiFlexItem grow={false}>
<EuiFlexGroup responsive={false} className="groupEditActions" gutterSize="xs">
<EuiFlexItem>
<EuiToolTip content={ControlGroupStrings.management.getManageButtonTitle()}>
<EuiButtonIcon
aria-label={ControlGroupStrings.management.getManageButtonTitle()}
iconType="gear"
color="text"
data-test-subj="controls-sorting-button"
onClick={() => {
const flyoutInstance = openFlyout(
forwardAllContext(
<EditControlGroup closeFlyout={() => flyoutInstance.close()} />,
reduxContainerContext
)
);
}}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip content={ControlGroupStrings.management.getAddControlTitle()}>
<CreateControlButton isIconButton={true} />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
) : (
<>
<EuiFlexGroup alignItems="center" gutterSize="xs" data-test-subj="controls-empty">
<EuiFlexItem grow={1} className="controlsIllustration__container">
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<ControlsIllustration />
</EuiFlexItem>
<EuiFlexItem>
{' '}
<EuiText className="emptyStateText" size="s">
<p>{ControlGroupStrings.emptyState.getCallToAction()}</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div className="addControlButton">
<CreateControlButton isIconButton={false} />
</div>
<EuiFlexGroup
wrap={false}
gutterSize="m"
direction="row"
responsive={false}
alignItems="center"
data-test-subj="controls-group"
data-shared-items-count={idsInOrder.length}
>
<EuiFlexItem>
<DndContext
onDragStart={({ active }) => setDraggingId(active.id)}
onDragEnd={onDragEnd}
onDragCancel={() => setDraggingId(null)}
sensors={sensors}
collisionDetection={closestCenter}
layoutMeasuring={{
strategy: LayoutMeasuringStrategy.Always,
}}
>
<SortableContext items={idsInOrder} strategy={rectSortingStrategy}>
<EuiFlexGroup
className={classNames('controlGroup', {
'controlGroup-isDragging': draggingId,
})}
alignItems="center"
gutterSize="s"
wrap={true}
>
{idsInOrder.map(
(controlId, index) =>
panels[controlId] && (
<SortableControl
isEditable={isEditable}
dragInfo={{ index, draggingIndex }}
embeddableId={controlId}
key={controlId}
/>
)
)}
</EuiFlexGroup>
</SortableContext>
<DragOverlay>
{draggingId ? <ControlClone draggingId={draggingId} /> : null}
</DragOverlay>
</DndContext>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiPanel>
) : (
<></>
)}
</EuiPanel>
</>
);
};

View file

@ -13,6 +13,10 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.title', {
defaultMessage: 'Control group',
}),
getControlButtonTitle: () =>
i18n.translate('controls.controlGroup.toolbarButtonTitle', {
defaultMessage: 'Controls',
}),
emptyState: {
getCallToAction: () =>
i18n.translate('controls.controlGroup.emptyState.callToAction', {
@ -26,6 +30,10 @@ export const ControlGroupStrings = {
i18n.translate('controls.controlGroup.emptyState.twoLineLoadingTitle', {
defaultMessage: '...',
}),
getDismissButton: () =>
i18n.translate('controls.controlGroup.emptyState.dismissButton', {
defaultMessage: 'Dismiss',
}),
},
manageControl: {
getFlyoutCreateTitle: () =>
@ -60,11 +68,11 @@ export const ControlGroupStrings = {
}),
getManageButtonTitle: () =>
i18n.translate('controls.controlGroup.management.buttonTitle', {
defaultMessage: 'Configure controls',
defaultMessage: 'Settings',
}),
getFlyoutTitle: () =>
i18n.translate('controls.controlGroup.management.flyoutTitle', {
defaultMessage: 'Configure controls',
defaultMessage: 'Control settings',
}),
getDefaultWidthTitle: () =>
i18n.translate('controls.controlGroup.management.defaultWidthTitle', {

View file

@ -0,0 +1,153 @@
/*
* 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.
*/
/*
* 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 {
EuiFlyoutHeader,
EuiButtonGroup,
EuiFlyoutBody,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiFlyoutFooter,
EuiButton,
EuiFormRow,
EuiButtonEmpty,
EuiSpacer,
EuiCheckbox,
} from '@elastic/eui';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlStyle, ControlWidth } from '../../types';
import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from './editor_constants';
interface EditControlGroupProps {
width: ControlWidth;
controlStyle: ControlStyle;
setAllWidths: boolean;
updateControlStyle: (controlStyle: ControlStyle) => void;
updateWidth: (newWidth: ControlWidth) => void;
updateAllControlWidths: (newWidth: ControlWidth) => void;
onCancel: () => void;
onClose: () => void;
}
export const ControlGroupEditor = ({
width,
controlStyle,
setAllWidths,
updateControlStyle,
updateWidth,
updateAllControlWidths,
onCancel,
onClose,
}: EditControlGroupProps) => {
const [currentControlStyle, setCurrentControlStyle] = useState(controlStyle);
const [currentWidth, setCurrentWidth] = useState(width);
const [applyToAll, setApplyToAll] = useState(setAllWidths);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ControlGroupStrings.management.getFlyoutTitle()}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFormRow label={ControlGroupStrings.management.getLayoutTitle()}>
<EuiButtonGroup
color="primary"
idSelected={currentControlStyle}
legend={ControlGroupStrings.management.controlStyle.getDesignSwitchLegend()}
options={CONTROL_LAYOUT_OPTIONS}
onChange={(newControlStyle: string) => {
setCurrentControlStyle(newControlStyle as ControlStyle);
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow label={ControlGroupStrings.management.getDefaultWidthTitle()}>
<EuiButtonGroup
color="primary"
idSelected={currentWidth}
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
onChange={(newWidth: string) => {
setCurrentWidth(newWidth as ControlWidth);
}}
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiCheckbox
id="editControls_setAllSizesCheckbox"
label={ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()}
checked={applyToAll}
onChange={(e) => {
setApplyToAll(e.target.checked);
}}
/>
<EuiSpacer size="l" />
<EuiButtonEmpty
onClick={onCancel}
aria-label={'delete-all'}
iconType="trash"
color="danger"
flush="left"
size="s"
>
{ControlGroupStrings.management.getDeleteAllButtonTitle()}
</EuiButtonEmpty>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`cancel-editing-group`}
iconType="cross"
onClick={() => {
onClose();
}}
>
{ControlGroupStrings.manageControl.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label={`save-group`}
iconType="check"
color="primary"
onClick={() => {
if (currentControlStyle && currentControlStyle !== controlStyle) {
updateControlStyle(currentControlStyle);
}
if (currentWidth && currentWidth !== width) {
updateWidth(currentWidth);
}
if (applyToAll) {
updateAllControlWidths(currentWidth);
}
onClose();
}}
>
{ControlGroupStrings.manageControl.getSaveChangesTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -8,7 +8,6 @@
import {
EuiButton,
EuiButtonIcon,
EuiButtonIconColor,
EuiContextMenuItem,
EuiContextMenuPanel,
@ -16,42 +15,39 @@ import {
} from '@elastic/eui';
import React, { useState, ReactElement } from 'react';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { OverlayRef } from '../../../../../core/public';
import { forwardAllContext } from './forward_all_context';
import { DEFAULT_CONTROL_WIDTH } from './editor_constants';
import { ControlGroupStrings } from '../control_group_strings';
import { controlGroupReducers } from '../state/control_group_reducers';
import { EmbeddableFactoryNotFoundError } from '../../../../embeddable/public';
import { useReduxContainerContext } from '../../../../presentation_util/public';
import { ControlWidth, IEditableControlFactory, ControlInput } from '../../types';
import { toMountPoint } from '../../../../kibana_react/public';
export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) => {
export type CreateControlButtonTypes = 'toolbar' | 'callout';
export interface CreateControlButtonProps {
defaultControlWidth?: ControlWidth;
updateDefaultWidth: (defaultControlWidth: ControlWidth) => void;
addNewEmbeddable: (type: string, input: Omit<ControlInput, 'id'>) => void;
buttonType: CreateControlButtonTypes;
closePopover?: () => void;
}
export const CreateControlButton = ({
defaultControlWidth,
updateDefaultWidth,
addNewEmbeddable,
buttonType,
closePopover,
}: CreateControlButtonProps) => {
// Controls Services Context
const { overlays, controls } = pluginServices.getHooks();
const { getControlTypes, getControlFactory } = controls.useService();
const { openFlyout, openConfirm } = overlays.useService();
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
typeof controlGroupReducers
>();
const {
containerActions: { addNewEmbeddable },
actions: { setDefaultControlWidth },
useEmbeddableSelector,
useEmbeddableDispatch,
} = reduxContainerContext;
const dispatch = useEmbeddableDispatch();
// current state
const { defaultControlWidth } = useEmbeddableSelector((state) => state);
const { overlays, controls } = pluginServices.getServices();
const { getControlTypes, getControlFactory } = controls;
const { openFlyout, openConfirm } = overlays;
const [isControlTypePopoverOpen, setIsControlTypePopoverOpen] = useState(false);
const createNewControl = async (type: string) => {
const PresentationUtilProvider = pluginServices.getContextProvider();
const factory = getControlFactory(type);
if (!factory) throw new EmbeddableFactoryNotFoundError(type);
@ -80,26 +76,27 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean })
const editableFactory = factory as IEditableControlFactory;
const flyoutInstance = openFlyout(
forwardAllContext(
<ControlEditor
isCreate={true}
factory={editableFactory}
width={defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))}
onTypeEditorChange={(partialInput) =>
(inputToReturn = { ...inputToReturn, ...partialInput })
}
onSave={() => {
if (editableFactory.presaveTransformFunction) {
inputToReturn = editableFactory.presaveTransformFunction(inputToReturn);
toMountPoint(
<PresentationUtilProvider>
<ControlEditor
isCreate={true}
factory={editableFactory}
width={defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
updateWidth={updateDefaultWidth}
onTypeEditorChange={(partialInput) =>
(inputToReturn = { ...inputToReturn, ...partialInput })
}
resolve(inputToReturn);
flyoutInstance.close();
}}
onCancel={() => onCancel(flyoutInstance)}
/>,
reduxContainerContext
onSave={() => {
if (editableFactory.presaveTransformFunction) {
inputToReturn = editableFactory.presaveTransformFunction(inputToReturn);
}
resolve(inputToReturn);
flyoutInstance.close();
}}
onCancel={() => onCancel(flyoutInstance)}
/>
</PresentationUtilProvider>
),
{
onClose: (flyout) => onCancel(flyout),
@ -117,48 +114,49 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean })
if (getControlTypes().length === 0) return null;
const onCreateButtonClick = () => {
if (getControlTypes().length > 1) {
setIsControlTypePopoverOpen(!isControlTypePopoverOpen);
return;
}
createNewControl(getControlTypes()[0]);
};
const commonButtonProps = {
onClick: onCreateButtonClick,
color: 'primary' as EuiButtonIconColor,
'data-test-subj': 'controls-create-button',
'aria-label': ControlGroupStrings.management.getManageButtonTitle(),
};
const createControlButton = isIconButton ? (
<EuiButtonIcon {...commonButtonProps} iconType={'plusInCircle'} />
) : (
<EuiButton {...commonButtonProps} size="s">
{ControlGroupStrings.emptyState.getAddControlButtonTitle()}
</EuiButton>
);
if (getControlTypes().length > 1) {
const items: ReactElement[] = [];
getControlTypes().forEach((type) => {
const factory = getControlFactory(type);
items.push(
<EuiContextMenuItem
key={type}
icon={factory.getIconType?.()}
data-test-subj={`create-${type}-control`}
onClick={() => {
const items: ReactElement[] = [];
getControlTypes().forEach((type) => {
const factory = getControlFactory(type);
items.push(
<EuiContextMenuItem
key={type}
icon={factory.getIconType?.()}
data-test-subj={`create-${type}-control`}
onClick={() => {
if (buttonType === 'callout' && isControlTypePopoverOpen) {
setIsControlTypePopoverOpen(false);
createNewControl(type);
}}
>
{factory.getDisplayName()}
</EuiContextMenuItem>
);
});
} else if (closePopover) {
closePopover();
}
createNewControl(type);
}}
toolTipContent={factory.getDescription()}
>
{factory.getDisplayName()}
</EuiContextMenuItem>
);
});
if (buttonType === 'callout') {
const onCreateButtonClick = () => {
if (getControlTypes().length > 1) {
setIsControlTypePopoverOpen(!isControlTypePopoverOpen);
return;
}
createNewControl(getControlTypes()[0]);
};
const createControlButton = (
<EuiButton {...commonButtonProps} onClick={onCreateButtonClick} size="s">
{ControlGroupStrings.emptyState.getAddControlButtonTitle()}
</EuiButton>
);
return (
<EuiPopover
button={createControlButton}
@ -168,9 +166,9 @@ export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean })
data-test-subj="control-type-picker"
closePopover={() => setIsControlTypePopoverOpen(false)}
>
<EuiContextMenuPanel size="s" items={items} />
<EuiContextMenuPanel items={items} />
</EuiPopover>
);
}
return createControlButton;
return <EuiContextMenuPanel items={items} />;
};

View file

@ -6,165 +6,93 @@
* Side Public License, v 1.
*/
import React, { useState } from 'react';
import {
EuiTitle,
EuiSpacer,
EuiFormRow,
EuiFlexItem,
EuiFlexGroup,
EuiFlyoutBody,
EuiButtonGroup,
EuiButtonEmpty,
EuiFlyoutHeader,
EuiCheckbox,
EuiFlyoutFooter,
EuiButton,
} from '@elastic/eui';
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import {
CONTROL_LAYOUT_OPTIONS,
CONTROL_WIDTH_OPTIONS,
DEFAULT_CONTROL_WIDTH,
} from './editor_constants';
import { ControlGroupInput } from '../types';
import { DEFAULT_CONTROL_WIDTH, DEFAULT_CONTROL_STYLE } from './editor_constants';
import { ControlsPanels } from '../types';
import { pluginServices } from '../../services';
import { ControlStyle, ControlWidth } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { controlGroupReducers } from '../state/control_group_reducers';
import { useReduxContainerContext } from '../../../../presentation_util/public';
import { toMountPoint } from '../../../../kibana_react/public';
import { OverlayRef } from '../../../../../core/public';
import { ControlGroupEditor } from './control_group_editor';
interface EditControlGroupState {
newControlStyle: ControlGroupInput['controlStyle'];
newDefaultWidth: ControlGroupInput['defaultControlWidth'];
setAllWidths: boolean;
export interface EditControlGroupButtonProps {
controlStyle: ControlStyle;
panels?: ControlsPanels;
defaultControlWidth?: ControlWidth;
setControlStyle: (setControlStyle: ControlStyle) => void;
setDefaultControlWidth: (defaultControlWidth: ControlWidth) => void;
setAllControlWidths: (defaultControlWidth: ControlWidth) => void;
removeEmbeddable?: (panelId: string) => void;
closePopover: () => void;
}
export const EditControlGroup = ({ closeFlyout }: { closeFlyout: () => void }) => {
const { overlays } = pluginServices.getHooks();
const { openConfirm } = overlays.useService();
export const EditControlGroup = ({
panels,
defaultControlWidth,
controlStyle,
setControlStyle,
setDefaultControlWidth,
setAllControlWidths,
removeEmbeddable,
closePopover,
}: EditControlGroupButtonProps) => {
const { overlays } = pluginServices.getServices();
const { openConfirm, openFlyout } = overlays;
const {
containerActions,
useEmbeddableSelector,
useEmbeddableDispatch,
actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth },
} = useReduxContainerContext<ControlGroupInput, typeof controlGroupReducers>();
const dispatch = useEmbeddableDispatch();
const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state);
const editControlGroup = () => {
const PresentationUtilProvider = pluginServices.getContextProvider();
const [state, setState] = useState<EditControlGroupState>({
newControlStyle: controlStyle,
newDefaultWidth: defaultControlWidth,
setAllWidths: false,
});
const onCancel = (ref: OverlayRef) => {
if (!removeEmbeddable || !panels) return;
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) Object.keys(panels).forEach((panelId) => removeEmbeddable(panelId));
ref.close();
});
};
const onSave = () => {
const { newControlStyle, newDefaultWidth, setAllWidths } = state;
if (newControlStyle && newControlStyle !== controlStyle) {
dispatch(setControlStyle(newControlStyle));
}
if (newDefaultWidth && newDefaultWidth !== defaultControlWidth) {
dispatch(setDefaultControlWidth(newDefaultWidth));
}
if (setAllWidths && newDefaultWidth) {
dispatch(setAllControlWidths(newDefaultWidth));
}
closeFlyout();
const flyoutInstance = openFlyout(
toMountPoint(
<PresentationUtilProvider>
<ControlGroupEditor
width={defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
controlStyle={controlStyle ?? DEFAULT_CONTROL_STYLE}
setAllWidths={false}
updateControlStyle={setControlStyle}
updateWidth={setDefaultControlWidth}
updateAllControlWidths={setAllControlWidths}
onCancel={() => onCancel(flyoutInstance)}
onClose={() => flyoutInstance.close()}
/>
</PresentationUtilProvider>
),
{
onClose: () => flyoutInstance.close(),
}
);
};
const commonButtonProps = {
key: 'manageControls',
onClick: () => {
editControlGroup();
closePopover();
},
icon: 'gear',
'data-test-subj': 'controls-sorting-button',
'aria-label': ControlGroupStrings.management.getManageButtonTitle(),
};
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ControlGroupStrings.management.getFlyoutTitle()}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFormRow label={ControlGroupStrings.management.getLayoutTitle()}>
<EuiButtonGroup
color="primary"
legend={ControlGroupStrings.management.controlStyle.getDesignSwitchLegend()}
options={CONTROL_LAYOUT_OPTIONS}
idSelected={state.newControlStyle}
onChange={(newControlStyle) =>
setState((s) => ({ ...s, newControlStyle: newControlStyle as ControlStyle }))
}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow label={ControlGroupStrings.management.getDefaultWidthTitle()}>
<EuiButtonGroup
color="primary"
idSelected={state.newDefaultWidth ?? DEFAULT_CONTROL_WIDTH}
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
onChange={(newDefaultWidth: string) =>
setState((s) => ({ ...s, newDefaultWidth: newDefaultWidth as ControlWidth }))
}
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiCheckbox
id="editControls_setAllSizesCheckbox"
label={ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()}
checked={state.setAllWidths}
onChange={(e) => setState((s) => ({ ...s, setAllWidths: e.target.checked }))}
/>
<EuiSpacer size="l" />
<EuiButtonEmpty
onClick={() => {
if (!containerActions?.removeEmbeddable) return;
closeFlyout();
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed)
Object.keys(panels).forEach((panelId) =>
containerActions.removeEmbeddable(panelId)
);
});
}}
aria-label={'delete-all'}
iconType="trash"
color="danger"
flush="left"
size="s"
>
{ControlGroupStrings.management.getDeleteAllButtonTitle()}
</EuiButtonEmpty>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup responsive={false} justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`cancel-editing-group`}
iconType="cross"
onClick={() => {
closeFlyout();
}}
>
{ControlGroupStrings.manageControl.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label={`save-group`}
iconType="check"
color="primary"
onClick={() => {
onSave();
}}
>
{ControlGroupStrings.manageControl.getSaveChangesTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
<EuiContextMenuItem {...commonButtonProps}>
{ControlGroupStrings.management.getManageButtonTitle()}
</EuiContextMenuItem>
);
};

View file

@ -6,10 +6,11 @@
* Side Public License, v 1.
*/
import { ControlWidth } from '../../types';
import { ControlStyle, ControlWidth } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto';
export const DEFAULT_CONTROL_STYLE: ControlStyle = 'oneLine';
export const CONTROL_WIDTH_OPTIONS = [
{

View file

@ -13,6 +13,7 @@ import deepEqual from 'fast-deep-equal';
import { Filter, uniqFilters } from '@kbn/es-query';
import { EMPTY, merge, pipe, Subscription } from 'rxjs';
import { distinctUntilChanged, debounceTime, catchError, switchMap, map } from 'rxjs/operators';
import { EuiContextMenuPanel, EuiHorizontalRule } from '@elastic/eui';
import {
ControlGroupInput,
@ -24,6 +25,7 @@ import {
withSuspense,
LazyReduxEmbeddableWrapper,
ReduxEmbeddableWrapperPropsWithChildren,
SolutionToolbarPopover,
} from '../../../../presentation_util/public';
import { pluginServices } from '../../services';
import { DataView } from '../../../../data_views/public';
@ -32,6 +34,9 @@ import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { Container, EmbeddableFactory } from '../../../../embeddable/public';
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
import { EditControlGroup } from '../editor/edit_control_group';
import { ControlGroupStrings } from '../control_group_strings';
const ControlGroupReduxWrapper = withSuspense<
ReduxEmbeddableWrapperPropsWithChildren<ControlGroupInput>
@ -63,6 +68,74 @@ export class ControlGroupContainer extends Container<
return Promise.resolve();
};
/**
* Returns a button that allows controls to be created externally using the embeddable
* @param buttonType Controls the button styling
* @param closePopover Closes the create control menu popover when flyout opens - only necessary if `buttonType === 'toolbar'`
* @return If `buttonType == 'toolbar'`, returns `EuiContextMenuPanel` with input control types as items.
* Otherwise, if `buttonType == 'callout'` returns `EuiButton` with popover containing input control types.
*/
public getCreateControlButton = (
buttonType: CreateControlButtonTypes,
closePopover?: () => void
) => {
return (
<CreateControlButton
buttonType={buttonType}
defaultControlWidth={this.getInput().defaultControlWidth}
updateDefaultWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
addNewEmbeddable={(type, input) => this.addNewEmbeddable(type, input)}
closePopover={closePopover}
/>
);
};
private getEditControlGroupButton = (closePopover: () => void) => {
return (
<EditControlGroup
controlStyle={this.getInput().controlStyle}
panels={this.getInput().panels}
defaultControlWidth={this.getInput().defaultControlWidth}
setControlStyle={(controlStyle) => this.updateInput({ controlStyle })}
setDefaultControlWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
setAllControlWidths={(defaultControlWidth) => {
Object.keys(this.getInput().panels).forEach(
(panelId) => (this.getInput().panels[panelId].width = defaultControlWidth)
);
}}
removeEmbeddable={(id) => this.removeEmbeddable(id)}
closePopover={closePopover}
/>
);
};
/**
* Returns the toolbar button that is used for creating controls and managing control settings
* @return `SolutionToolbarPopover` button for input controls
*/
public getToolbarButtons = () => {
return (
<SolutionToolbarPopover
ownFocus
label={ControlGroupStrings.getControlButtonTitle()}
iconType="arrowDown"
iconSide="right"
panelPaddingSize="none"
data-test-subj="dashboardControlsMenuButton"
>
{({ closePopover }: { closePopover: () => void }) => (
<EuiContextMenuPanel
items={[
this.getCreateControlButton('toolbar', closePopover),
<EuiHorizontalRule margin="none" />,
this.getEditControlGroupButton(closePopover),
]}
/>
)}
</SolutionToolbarPopover>
);
};
constructor(initialInput: ControlGroupInput, parent?: Container) {
super(
initialInput,
@ -100,6 +173,10 @@ export class ControlGroupContainer extends Container<
});
}
public getPanelCount = () => {
return Object.keys(this.getInput().panels).length;
};
private recalculateOutput = () => {
const allFilters: Filter[] = [];
const allDataViews: DataView[] = [];

View file

@ -25,12 +25,6 @@ export const controlGroupReducers = {
) => {
state.defaultControlWidth = action.payload;
},
setAllControlWidths: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<ControlWidth>
) => {
Object.keys(state.panels).forEach((panelId) => (state.panels[panelId].width = action.payload));
},
setControlWidth: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<{ width: ControlWidth; embeddableId: string }>

View file

@ -16,6 +16,7 @@ import {
createOptionsListExtract,
createOptionsListInject,
} from '../../../common/control_types/options_list/options_list_persistable_state';
import { OptionsListStrings } from './options_list_strings';
export class OptionsListEmbeddableFactory
implements EmbeddableFactoryDefinition, IEditableControlFactory<OptionsListEmbeddableInput>
@ -49,7 +50,9 @@ export class OptionsListEmbeddableFactory
public isEditable = () => Promise.resolve(false);
public getDisplayName = () => 'Options List Control';
public getDisplayName = () => OptionsListStrings.getDisplayName();
public getIconType = () => 'list';
public getDescription = () => OptionsListStrings.getDescription();
public inject = createOptionsListInject();
public extract = createOptionsListExtract();

View file

@ -9,6 +9,14 @@
import { i18n } from '@kbn/i18n';
export const OptionsListStrings = {
getDisplayName: () =>
i18n.translate('controls.optionsList.displayName', {
defaultMessage: 'Options list',
}),
getDescription: () =>
i18n.translate('controls.optionsList.description', {
defaultMessage: 'Add control that allows options to be selected from a dropdown.',
}),
summary: {
getSeparator: () =>
i18n.translate('controls.optionsList.summary.separator', {

View file

@ -0,0 +1,69 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonEmpty, EuiPanel } from '@elastic/eui';
import React from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import classNames from 'classnames';
import { ControlGroupStrings } from '../control_group/control_group_strings';
import { ControlsIllustration } from './controls_illustration';
const CONTROLS_CALLOUT_STATE_KEY = 'dashboard:controlsCalloutDismissed';
export interface CalloutProps {
getCreateControlButton?: () => JSX.Element;
}
export const ControlsCallout = ({ getCreateControlButton }: CalloutProps) => {
const [controlsCalloutDismissed, setControlsCalloutDismissed] = useLocalStorage(
CONTROLS_CALLOUT_STATE_KEY,
false
);
const dismissControls = () => {
setControlsCalloutDismissed(true);
};
if (controlsCalloutDismissed) return null;
return (
<EuiPanel
borderRadius="m"
color="plain"
paddingSize={'s'}
className={classNames('controlsWrapper--empty', 'dshDashboardViewport-controls')}
>
<EuiFlexGroup alignItems="center" gutterSize="xs" data-test-subj="controls-empty">
<EuiFlexItem grow={1} className="controlsIllustration__container">
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<ControlsIllustration />
</EuiFlexItem>
<EuiFlexItem>
<EuiText className="emptyStateText" size="s">
<p>{ControlGroupStrings.emptyState.getCallToAction()}</p>
</EuiText>
</EuiFlexItem>
{getCreateControlButton ? (
<EuiFlexItem grow={false}>{getCreateControlButton()}</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<EuiButtonEmpty size="s" onClick={dismissControls}>
{ControlGroupStrings.emptyState.getDismissButton()}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ControlsCallout;

View file

@ -0,0 +1,11 @@
/*
* 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';
export const LazyControlsCallout = React.lazy(() => import('./controls_callout'));
export type { CalloutProps } from './controls_callout';

View file

@ -39,6 +39,8 @@ export {
type OptionsListEmbeddableInput,
} from './control_types';
export { LazyControlsCallout, type CalloutProps } from './controls_callout';
export function plugin() {
return new ControlsPlugin();
}

View file

@ -6,8 +6,8 @@
width: 100%;
}
.dshDashboardViewport-controlGroup {
margin: 0 $euiSizeS 0 $euiSizeS;
.dshDashboardViewport-controls {
margin: 0 $euiSizeS 0 $euiSizeS;
padding-bottom: $euiSizeXS;
}

View file

@ -13,7 +13,12 @@ import { DashboardContainer, DashboardReactContextValue } from '../dashboard_con
import { DashboardGrid } from '../grid';
import { context } from '../../../services/kibana_react';
import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
import { ControlGroupContainer } from '../../../../../controls/public';
import {
CalloutProps,
ControlGroupContainer,
LazyControlsCallout,
} from '../../../../../controls/public';
import { withSuspense } from '../../../services/presentation_util';
export interface DashboardViewportProps {
container: DashboardContainer;
@ -31,6 +36,8 @@ interface State {
isEmbeddedExternally?: boolean;
}
const ControlsCallout = withSuspense<CalloutProps>(LazyControlsCallout);
export class DashboardViewport extends React.Component<DashboardViewportProps, State> {
static contextType = context;
public declare readonly context: DashboardReactContextValue;
@ -94,14 +101,24 @@ export class DashboardViewport extends React.Component<DashboardViewportProps, S
};
public render() {
const { container, controlsEnabled } = this.props;
const { container, controlsEnabled, controlGroup } = this.props;
const isEditMode = container.getInput().viewMode !== ViewMode.VIEW;
const { isEmbeddedExternally, isFullScreenMode, panelCount, title, description, useMargins } =
this.state;
return (
<>
{controlsEnabled ? (
<div className="dshDashboardViewport-controlGroup" ref={this.controlsRoot} />
<>
{isEditMode && panelCount !== 0 && controlGroup?.getPanelCount() === 0 ? (
<ControlsCallout
getCreateControlButton={() => {
return controlGroup?.getCreateControlButton('callout');
}}
/>
) : null}
<div className="dshDashboardViewport-controls" ref={this.controlsRoot} />
</>
) : null}
<div
data-shared-items-count={panelCount}

View file

@ -598,17 +598,16 @@ export function DashboardTopNav({
/>
),
quickButtonGroup: <QuickButtonGroup buttons={quickButtons} />,
addFromLibraryButton: (
<AddFromLibraryButton
onClick={addFromLibrary}
data-test-subj="dashboardAddPanelButton"
/>
),
extraButtons: [
<EditorMenu
createNewVisType={createNewVisType}
dashboardContainer={dashboardAppState.dashboardContainer}
/>,
<AddFromLibraryButton
onClick={addFromLibrary}
data-test-subj="dashboardAddPanelButton"
/>,
dashboardAppState.dashboardContainer.controlGroup?.getToolbarButtons(),
],
}}
</SolutionToolbar>

View file

@ -10,7 +10,6 @@ import React, { ReactElement } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import {
AddFromLibraryButton,
QuickButtonGroup,
PrimaryActionButton,
SolutionToolbarButton,
@ -23,8 +22,9 @@ import './solution_toolbar.scss';
interface NamedSlots {
primaryActionButton: ReactElement<typeof PrimaryActionButton | typeof PrimaryActionPopover>;
quickButtonGroup?: ReactElement<typeof QuickButtonGroup>;
addFromLibraryButton?: ReactElement<typeof AddFromLibraryButton>;
extraButtons?: Array<ReactElement<typeof SolutionToolbarButton | typeof SolutionToolbarPopover>>;
extraButtons?: Array<
ReactElement<typeof SolutionToolbarButton | typeof SolutionToolbarPopover> | undefined
>;
}
export interface Props {
@ -33,12 +33,7 @@ export interface Props {
}
export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => {
const {
primaryActionButton,
quickButtonGroup,
addFromLibraryButton,
extraButtons = [],
} = children;
const { primaryActionButton, quickButtonGroup, extraButtons = [] } = children;
const extra = extraButtons.map((button, index) =>
button ? (
@ -61,9 +56,6 @@ export const SolutionToolbar = ({ isDarkModeEnabled, children }: Props) => {
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="xs">
{quickButtonGroup ? <EuiFlexItem grow={false}>{quickButtonGroup}</EuiFlexItem> : null}
{extra}
{addFromLibraryButton ? (
<EuiFlexItem grow={false}>{addFromLibraryButton}</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -51,12 +51,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.savedObjects.cleanStandardList();
});
it('shows the empty control callout on a new dashboard', async () => {
await testSubjects.existOrFail('controls-empty');
describe('Controls callout visibility', async () => {
describe('does not show the empty control callout on an empty dashboard', async () => {
it('in view mode', async () => {
await dashboard.saveDashboard('Test Controls Callout');
await dashboard.clickCancelOutOfEditMode();
await testSubjects.missingOrFail('controls-empty');
});
it('in edit mode', async () => {
await dashboard.switchToEditMode();
await testSubjects.missingOrFail('controls-empty');
});
});
it('show the empty control callout on a dashboard with panels', async () => {
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
await testSubjects.existOrFail('controls-empty');
});
it('adding control hides the empty control callout', async () => {
await dashboardControls.createOptionsListControl({
dataViewTitle: 'animals-*',
fieldName: 'sound.keyword',
});
await testSubjects.missingOrFail('controls-empty');
});
after(async () => {
await dashboard.clickCancelOutOfEditMode();
await dashboard.gotoDashboardLandingPage();
});
});
describe('Options List Control creation and editing experience', async () => {
it('can add a new options list control from a blank state', async () => {
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboardControls.createOptionsListControl({ fieldName: 'machine.os.raw' });
expect(await dashboardControls.getControlsCount()).to.be(1);
});
@ -115,7 +146,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
let controlId: string;
before(async () => {
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
await dashboardControls.createOptionsListControl({
dataViewTitle: 'animals-*',
fieldName: 'sound.keyword',

View file

@ -64,10 +64,8 @@ export class DashboardPageControls extends FtrService {
public async openCreateControlFlyout(type: string) {
this.log.debug(`Opening flyout for ${type} control`);
await this.testSubjects.click('controls-create-button');
if (await this.testSubjects.exists('control-type-picker')) {
await this.testSubjects.click(`create-${type}-control`);
}
await this.testSubjects.click('dashboardControlsMenuButton');
await this.testSubjects.click(`create-${type}-control`);
await this.retry.try(async () => {
await this.testSubjects.existOrFail('control-editor-flyout');
});

View file

@ -169,13 +169,13 @@ export const WorkpadHeader: FC<Props> = ({
{{
primaryActionButton: <ElementMenu addElement={addElement} elements={elements} />,
quickButtonGroup: <QuickButtonGroup buttons={quickButtons} />,
addFromLibraryButton: (
extraButtons: [
<AddFromLibraryButton
onClick={showEmbedPanel}
data-test-subj="canvas-add-from-library-button"
/>
),
extraButtons: [<EditorMenu addElement={addElement} />],
/>,
<EditorMenu addElement={addElement} />,
],
}}
</SolutionToolbar>
</EuiFlexItem>