[controls] Edit and save example (#147897)

Part of https://github.com/elastic/kibana/issues/145428

PR does the following:
* Removes ControlsCallout
* Cleans up ControlGroupContainer API to avoid leaking Dashboard
implementation details
    * Removes getCreateControlButton method
    * Removes getCreateTimeSliderControlButton
    * Removes getToolbarButtons
    * Adds openAddDataControlFlyout
* Add Edit and save example  

<img width="600" alt="Screen Shot 2022-12-21 at 9 29 21 AM"
src="https://user-images.githubusercontent.com/373691/208928858-94984880-3fdb-45f4-bb2a-a086bfc440d0.png">

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Nathan Reese 2023-01-03 16:47:42 -05:00 committed by GitHub
parent 750e5e0e95
commit 36a3d6915c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 403 additions and 826 deletions

View file

@ -14,6 +14,7 @@ import { AppMountParameters } from '@kbn/core/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { ControlsExampleStartDeps } from './plugin';
import { BasicReduxExample } from './basic_redux_example';
import { EditExample } from './edit_example';
import { SearchExample } from './search_example';
export const renderApp = async (
@ -26,6 +27,8 @@ export const renderApp = async (
<>
<SearchExample dataView={dataViews[0]} navigation={navigation} data={data} />
<EuiSpacer size="xl" />
<EditExample />
<EuiSpacer size="xl" />
<BasicReduxExample dataViewId={dataViews[0].id!} />
</>
) : (

View file

@ -0,0 +1,122 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { LazyControlGroupRenderer, ControlGroupContainer } from '@kbn/controls-plugin/public';
import { withSuspense } from '@kbn/presentation-util-plugin/public';
const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer);
const INPUT_KEY = 'kbnControls:saveExample:input';
export const EditExample = () => {
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [controlGroup, setControlGroup] = useState<ControlGroupContainer>();
async function onSave() {
setIsSaving(true);
localStorage.setItem(INPUT_KEY, JSON.stringify(controlGroup!.getInput()));
// simulated async save await
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsSaving(false);
}
async function onLoad() {
setIsLoading(true);
// simulated async load await
await new Promise((resolve) => setTimeout(resolve, 1000));
let input = {};
const inputAsString = localStorage.getItem(INPUT_KEY);
if (inputAsString) {
try {
input = JSON.parse(inputAsString);
} catch (e) {
// ignore parse errors
}
}
setIsLoading(false);
return input;
}
return (
<>
<EuiTitle>
<h2>Edit and save example</h2>
</EuiTitle>
<EuiText>
<p>Customize controls and persist state to local storage.</p>
</EuiText>
<EuiSpacer size="m" />
<EuiPanel hasBorder={true}>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
iconType="plusInCircle"
isDisabled={controlGroup === undefined}
onClick={() => {
controlGroup!.openAddDataControlFlyout();
}}
>
Add control
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
isDisabled={controlGroup === undefined || isSaving}
fill
onClick={onSave}
isLoading={isSaving}
>
Save
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isLoading ? (
<>
<EuiSpacer />
<EuiLoadingContent lines={1} />
</>
) : null}
<ControlGroupRenderer
getInitialInput={async (initialInput, builder) => {
const persistedInput = await onLoad();
return {
...initialInput,
...persistedInput,
viewMode: ViewMode.EDIT,
};
}}
onLoadComplete={async (newControlGroup) => {
setControlGroup(newControlGroup);
}}
/>
</EuiPanel>
</>
);
};

View file

@ -9,10 +9,6 @@
import { i18n } from '@kbn/i18n';
export const ControlGroupStrings = {
getControlButtonTitle: () =>
i18n.translate('controls.controlGroup.toolbarButtonTitle', {
defaultMessage: 'Controls',
}),
emptyState: {
getBadge: () =>
i18n.translate('controls.controlGroup.emptyState.badgeText', {

View file

@ -1,164 +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 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 { EuiButton, EuiContextMenuItem } from '@elastic/eui';
import React from 'react';
import { OverlayRef } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlWidth, ControlInput, IEditableControlFactory, DataControlInput } from '../../types';
import {
DEFAULT_CONTROL_WIDTH,
DEFAULT_CONTROL_GROW,
} from '../../../common/control_group/control_group_constants';
import { setFlyoutRef } from '../embeddable/control_group_container';
export type CreateControlButtonTypes = 'toolbar' | 'callout';
export interface CreateControlButtonProps {
defaultControlWidth?: ControlWidth;
defaultControlGrow?: boolean;
updateDefaultWidth: (defaultControlWidth: ControlWidth) => void;
updateDefaultGrow: (defaultControlGrow: boolean) => void;
addNewEmbeddable: (type: string, input: Omit<ControlInput, 'id'>) => void;
setLastUsedDataViewId?: (newDataViewId: string) => void;
getRelevantDataViewId?: () => string | undefined;
buttonType: CreateControlButtonTypes;
closePopover?: () => void;
}
interface CreateControlResult {
type: string;
controlInput: Omit<ControlInput, 'id'>;
}
export const CreateControlButton = ({
buttonType,
defaultControlWidth,
defaultControlGrow,
addNewEmbeddable,
closePopover,
getRelevantDataViewId,
setLastUsedDataViewId,
updateDefaultWidth,
updateDefaultGrow,
}: CreateControlButtonProps) => {
// Controls Services Context
const {
overlays: { openFlyout, openConfirm },
controls: { getControlTypes, getControlFactory },
theme: { theme$ },
} = pluginServices.getServices();
const createNewControl = async () => {
const ControlsServicesProvider = pluginServices.getContextProvider();
const initialInputPromise = new Promise<CreateControlResult>((resolve, reject) => {
let inputToReturn: Partial<DataControlInput> = {};
const onCancel = (ref: OverlayRef) => {
if (Object.keys(inputToReturn).length === 0) {
reject();
ref.close();
return;
}
openConfirm(ControlGroupStrings.management.discardNewControl.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardNewControl.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardNewControl.getCancel(),
title: ControlGroupStrings.management.discardNewControl.getTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
reject();
ref.close();
}
});
};
const onSave = (ref: OverlayRef, type?: string) => {
if (!type) {
reject();
ref.close();
return;
}
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
inputToReturn = factory.presaveTransformFunction(inputToReturn);
}
resolve({ type, controlInput: inputToReturn });
ref.close();
};
const flyoutInstance = openFlyout(
toMountPoint(
<ControlsServicesProvider>
<ControlEditor
setLastUsedDataViewId={setLastUsedDataViewId}
getRelevantDataViewId={getRelevantDataViewId}
isCreate={true}
width={defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
grow={defaultControlGrow ?? DEFAULT_CONTROL_GROW}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
updateWidth={updateDefaultWidth}
updateGrow={updateDefaultGrow}
onSave={(type) => onSave(flyoutInstance, type)}
onCancel={() => onCancel(flyoutInstance)}
onTypeEditorChange={(partialInput) =>
(inputToReturn = { ...inputToReturn, ...partialInput })
}
/>
</ControlsServicesProvider>,
{ theme$ }
),
{
'aria-label': ControlGroupStrings.manageControl.getFlyoutCreateTitle(),
outsideClickCloses: false,
onClose: (flyout) => {
onCancel(flyout);
setFlyoutRef(undefined);
},
}
);
setFlyoutRef(flyoutInstance);
});
initialInputPromise.then(
async (promise) => {
await addNewEmbeddable(promise.type, promise.controlInput);
},
() => {} // swallow promise rejection because it can be part of normal flow
);
};
if (getControlTypes().length === 0) return null;
const commonButtonProps = {
key: 'addControl',
onClick: () => {
createNewControl();
if (closePopover) {
closePopover();
}
},
'data-test-subj': 'controls-create-button',
'aria-label': ControlGroupStrings.management.getManageButtonTitle(),
};
return buttonType === 'callout' ? (
<EuiButton {...commonButtonProps} color="primary" size="s">
{ControlGroupStrings.emptyState.getAddControlButtonTitle()}
</EuiButton>
) : (
<EuiContextMenuItem {...commonButtonProps} icon="plusInCircle">
{ControlGroupStrings.emptyState.getAddControlButtonTitle()}
</EuiContextMenuItem>
);
};

View file

@ -1,48 +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 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';
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
interface Props {
onCreate: () => void;
closePopover?: () => void;
hasTimeSliderControl: boolean;
}
export const CreateTimeSliderControlButton = ({
onCreate,
closePopover,
hasTimeSliderControl,
}: Props) => {
return (
<EuiContextMenuItem
icon="plusInCircle"
onClick={() => {
onCreate();
if (closePopover) {
closePopover();
}
}}
data-test-subj="controls-create-timeslider-button"
disabled={hasTimeSliderControl}
toolTipContent={
hasTimeSliderControl
? i18n.translate('controls.controlGroup.onlyOneTimeSliderControlMsg', {
defaultMessage: 'Control group already contains time slider control.',
})
: null
}
>
{i18n.translate('controls.controlGroup.addTimeSliderControlButtonTitle', {
defaultMessage: 'Add time slider control',
})}
</EuiContextMenuItem>
);
};

View file

@ -0,0 +1,106 @@
/*
* 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 { toMountPoint } from '@kbn/kibana-react-plugin/public';
import type {
AddDataControlProps,
AddOptionsListControlProps,
AddRangeSliderControlProps,
} from '../control_group_input_builder';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container';
import { pluginServices } from '../../services';
import { ControlEditor } from './control_editor';
import { DataControlInput, OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL } from '../..';
import { IEditableControlFactory } from '../../types';
import {
DEFAULT_CONTROL_GROW,
DEFAULT_CONTROL_WIDTH,
} from '../../../common/control_group/control_group_constants';
export function openAddDataControlFlyout(this: ControlGroupContainer) {
const {
overlays: { openFlyout, openConfirm },
controls: { getControlFactory },
theme: { theme$ },
} = pluginServices.getServices();
const ControlsServicesProvider = pluginServices.getContextProvider();
let controlInput: Partial<DataControlInput> = {};
const onCancel = () => {
if (Object.keys(controlInput).length === 0) {
this.closeAllFlyouts();
return;
}
openConfirm(ControlGroupStrings.management.discardNewControl.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardNewControl.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardNewControl.getCancel(),
title: ControlGroupStrings.management.discardNewControl.getTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
this.closeAllFlyouts();
}
});
};
const flyoutInstance = openFlyout(
toMountPoint(
<ControlsServicesProvider>
<ControlEditor
setLastUsedDataViewId={(newId) => this.setLastUsedDataViewId(newId)}
getRelevantDataViewId={this.getMostRelevantDataViewId}
isCreate={true}
width={this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
grow={this.getInput().defaultControlGrow ?? DEFAULT_CONTROL_GROW}
updateTitle={(newTitle) => (controlInput.title = newTitle)}
updateWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
updateGrow={(defaultControlGrow: boolean) => this.updateInput({ defaultControlGrow })}
onSave={(type) => {
this.closeAllFlyouts();
if (!type) {
return;
}
const factory = getControlFactory(type) as IEditableControlFactory;
if (factory.presaveTransformFunction) {
controlInput = factory.presaveTransformFunction(controlInput);
}
if (type === OPTIONS_LIST_CONTROL) {
this.addOptionsListControl(controlInput as AddOptionsListControlProps);
return;
}
if (type === RANGE_SLIDER_CONTROL) {
this.addRangeSliderControl(controlInput as AddRangeSliderControlProps);
return;
}
this.addDataControlFromField(controlInput as AddDataControlProps);
}}
onCancel={onCancel}
onTypeEditorChange={(partialInput) =>
(controlInput = { ...controlInput, ...partialInput })
}
/>
</ControlsServicesProvider>,
{ theme$ }
),
{
'aria-label': ControlGroupStrings.manageControl.getFlyoutCreateTitle(),
outsideClickCloses: false,
onClose: () => {
onCancel();
},
}
);
setFlyoutRef(flyoutInstance);
}

View file

@ -12,13 +12,8 @@ import ReactDOM from 'react-dom';
import { compareFilters, COMPARE_ALL_OPTIONS, Filter, uniqFilters } from '@kbn/es-query';
import { BehaviorSubject, merge, Subject, Subscription } from 'rxjs';
import _ from 'lodash';
import { EuiContextMenuPanel } from '@elastic/eui';
import {
ReduxEmbeddablePackage,
ReduxEmbeddableTools,
SolutionToolbarPopover,
} from '@kbn/presentation-util-plugin/public';
import { ReduxEmbeddablePackage, ReduxEmbeddableTools } from '@kbn/presentation-util-plugin/public';
import { OverlayRef } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { Container, EmbeddableFactory } from '@kbn/embeddable-plugin/public';
@ -36,14 +31,11 @@ import {
controlOrdersAreEqual,
} from './control_group_chaining_system';
import { pluginServices } from '../../services';
import { ControlGroupStrings } from '../control_group_strings';
import { openAddDataControlFlyout } from '../editor/open_add_data_control_flyout';
import { EditControlGroup } from '../editor/edit_control_group';
import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { OPTIONS_LIST_CONTROL, RANGE_SLIDER_CONTROL, TIME_SLIDER_CONTROL } from '../..';
import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types';
import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control';
import { CreateTimeSliderControlButton } from '../editor/create_time_slider_control';
import { getNextPanelOrder } from './control_group_helpers';
import type {
AddDataControlProps,
@ -127,68 +119,9 @@ export class ControlGroupContainer extends Container<
return this.createAndSaveEmbeddable(panelState.type, panelState);
}
/**
* 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
) => {
const ControlsServicesProvider = pluginServices.getContextProvider();
public openAddDataControlFlyout = openAddDataControlFlyout;
return (
<ControlsServicesProvider>
<CreateControlButton
buttonType={buttonType}
defaultControlWidth={this.getInput().defaultControlWidth}
defaultControlGrow={this.getInput().defaultControlGrow}
updateDefaultWidth={(defaultControlWidth) => this.updateInput({ defaultControlWidth })}
updateDefaultGrow={(defaultControlGrow: boolean) =>
this.updateInput({ defaultControlGrow })
}
addNewEmbeddable={(type, input) => {
if (type === OPTIONS_LIST_CONTROL) {
this.addOptionsListControl(input as AddOptionsListControlProps);
return;
}
if (type === RANGE_SLIDER_CONTROL) {
this.addRangeSliderControl(input as AddRangeSliderControlProps);
return;
}
this.addDataControlFromField(input as AddDataControlProps);
}}
closePopover={closePopover}
getRelevantDataViewId={() => this.getMostRelevantDataViewId()}
setLastUsedDataViewId={(newId) => this.setLastUsedDataViewId(newId)}
/>
</ControlsServicesProvider>
);
};
public getCreateTimeSliderControlButton = (closePopover?: () => void) => {
const childIds = this.getChildIds();
const hasTimeSliderControl = childIds.some((id) => {
const child = this.getChild(id);
return child.type === TIME_SLIDER_CONTROL;
});
return (
<CreateTimeSliderControlButton
onCreate={() => {
this.addTimeSliderControl();
}}
closePopover={closePopover}
hasTimeSliderControl={hasTimeSliderControl}
/>
);
};
private getEditControlGroupButton = (closePopover: () => void) => {
public getEditControlGroupButton = (closePopover: () => void) => {
const ControlsServicesProvider = pluginServices.getContextProvider();
return (
@ -198,33 +131,6 @@ export class ControlGroupContainer extends Container<
);
};
/**
* 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="dashboard-controls-menu-button"
>
{({ closePopover }: { closePopover: () => void }) => (
<EuiContextMenuPanel
items={[
this.getCreateControlButton('toolbar', closePopover),
this.getCreateTimeSliderControlButton(closePopover),
this.getEditControlGroupButton(closePopover),
]}
/>
)}
</SolutionToolbarPopover>
);
};
constructor(
reduxEmbeddablePackage: ReduxEmbeddablePackage,
initialInput: ControlGroupInput,

View file

@ -1,37 +0,0 @@
@include euiBreakpoint('xs', 's') {
.controlsIllustration, .emptyStateBadge {
display: none;
}
}
.controlsWrapper {
&--empty {
display: flex;
overflow: hidden;
margin: 0 $euiSizeS 0 $euiSizeS;
.addControlButton {
text-align: center;
}
@include euiBreakpoint('m', 'l', 'xl') {
height: $euiSizeS * 6;
.emptyStateBadge {
padding-left: $euiSize * 2;
text-transform: uppercase;
}
}
@include euiBreakpoint('xs', 's') {
min-height: $euiSizeS * 6;
.emptyStateText {
padding-left: 0;
text-align: center;
}
.controlsIllustration__container {
margin-bottom: 0 !important;
}
}
}
}

View file

@ -1,76 +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 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 {
EuiBadge,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiButtonEmpty,
EuiPanel,
} from '@elastic/eui';
import React from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import './controls_callout.scss';
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="controlsWrapper--empty">
<EuiFlexGroup alignItems="center" gutterSize="xs" data-test-subj="controls-empty">
<EuiFlexItem grow={1} className="controlsIllustration__container">
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap>
<EuiFlexItem grow={false}>
<ControlsIllustration />
</EuiFlexItem>
<EuiFlexItem className="emptyStateBadge" grow={false}>
<EuiBadge color="accent">{ControlGroupStrings.emptyState.getBadge()}</EuiBadge>
</EuiFlexItem>
<EuiFlexItem>
<EuiText className="emptyStateText" size="s">
<p>{ControlGroupStrings.emptyState.getCallToAction()}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="spaceAround" responsive={false} gutterSize="xs">
{getCreateControlButton && <EuiFlexItem>{getCreateControlButton()}</EuiFlexItem>}
<EuiFlexItem>
<EuiButtonEmpty size="s" onClick={dismissControls}>
{ControlGroupStrings.emptyState.getDismissButton()}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default ControlsCallout;

File diff suppressed because one or more lines are too long

View file

@ -54,7 +54,6 @@ export {
type RangeSliderEmbeddableInput,
} from './range_slider';
export { LazyControlsCallout, type CalloutProps } from './controls_callout';
export {
LazyControlGroupRenderer,
useControlGroupContainerContext,

View file

@ -335,3 +335,23 @@ export const topNavStrings = {
}),
},
};
export const getControlButtonTitle = () =>
i18n.translate('dashboard.editingToolbar.controlsButtonTitle', {
defaultMessage: 'Controls',
});
export const getAddControlButtonTitle = () =>
i18n.translate('dashboard.editingToolbar.addControlButtonTitle', {
defaultMessage: 'Add control',
});
export const getOnlyOneTimeSliderControlMsg = () =>
i18n.translate('dashboard.editingToolbar.onlyOneTimeSliderControlMsg', {
defaultMessage: 'Control group already contains time slider control.',
});
export const getAddTimeSliderControlButtonTitle = () =>
i18n.translate('dashboard.editingToolbar.addTimeSliderControlButtonTitle', {
defaultMessage: 'Add time slider control',
});

View file

@ -0,0 +1,34 @@
/*
* 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 { EuiContextMenuItem } from '@elastic/eui';
import { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { getAddControlButtonTitle } from '../../_dashboard_app_strings';
interface Props {
closePopover: () => void;
controlGroup: ControlGroupContainer;
}
export const AddDataControlButton = ({ closePopover, controlGroup }: Props) => {
return (
<EuiContextMenuItem
key="addControl"
icon="plusInCircle"
data-test-subj="controls-create-button"
aria-label={getAddControlButtonTitle()}
onClick={() => {
controlGroup.openAddDataControlFlyout();
closePopover();
}}
>
{getAddControlButtonTitle()}
</EuiContextMenuItem>
);
};

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 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, { useEffect, useState } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { ControlGroupContainer, TIME_SLIDER_CONTROL } from '@kbn/controls-plugin/public';
import {
getAddTimeSliderControlButtonTitle,
getOnlyOneTimeSliderControlMsg,
} from '../../_dashboard_app_strings';
interface Props {
closePopover: () => void;
controlGroup: ControlGroupContainer;
}
export const AddTimeSliderControlButton = ({ closePopover, controlGroup }: Props) => {
const [hasTimeSliderControl, setHasTimeSliderControl] = useState(false);
useEffect(() => {
const subscription = controlGroup.getInput$().subscribe(() => {
const childIds = controlGroup.getChildIds();
const nextHasTimeSliderControl = childIds.some((id: string) => {
const child = controlGroup.getChild(id);
return child.type === TIME_SLIDER_CONTROL;
});
if (nextHasTimeSliderControl !== hasTimeSliderControl) {
setHasTimeSliderControl(nextHasTimeSliderControl);
}
});
return () => {
subscription.unsubscribe();
};
}, [controlGroup, hasTimeSliderControl, setHasTimeSliderControl]);
return (
<EuiContextMenuItem
key="addTimeSliderControl"
icon="plusInCircle"
onClick={() => {
controlGroup.addTimeSliderControl();
closePopover();
}}
data-test-subj="controls-create-timeslider-button"
disabled={hasTimeSliderControl}
toolTipContent={hasTimeSliderControl ? getOnlyOneTimeSliderControlMsg() : null}
>
{getAddTimeSliderControlButtonTitle()}
</EuiContextMenuItem>
);
};

View file

@ -0,0 +1,38 @@
/*
* 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 { EuiContextMenuPanel } from '@elastic/eui';
import { SolutionToolbarPopover } from '@kbn/presentation-util-plugin/public';
import type { ControlGroupContainer } from '@kbn/controls-plugin/public';
import { getControlButtonTitle } from '../../_dashboard_app_strings';
import { AddDataControlButton } from './add_data_control_button';
import { AddTimeSliderControlButton } from './add_time_slider_control_button';
export function ControlsToolbarButton({ controlGroup }: { controlGroup: ControlGroupContainer }) {
return (
<SolutionToolbarPopover
ownFocus
label={getControlButtonTitle()}
iconType="arrowDown"
iconSide="right"
panelPaddingSize="none"
data-test-subj="dashboard-controls-menu-button"
>
{({ closePopover }: { closePopover: () => void }) => (
<EuiContextMenuPanel
items={[
<AddDataControlButton controlGroup={controlGroup} closePopover={closePopover} />,
<AddTimeSliderControlButton controlGroup={controlGroup} closePopover={closePopover} />,
controlGroup.getEditControlGroupButton(closePopover),
]}
/>
)}
</SolutionToolbarPopover>
);
}

View file

@ -6,6 +6,4 @@
* Side Public License, v 1.
*/
import React from 'react';
export const LazyControlsCallout = React.lazy(() => import('./controls_callout'));
export type { CalloutProps } from './controls_callout';
export { ControlsToolbarButton } from './controls_toolbar_button';

View file

@ -25,6 +25,7 @@ import { useDashboardContainerContext } from '../../dashboard_container/dashboar
import { pluginServices } from '../../services/plugin_services';
import { getCreateVisualizationButtonTitle } from '../_dashboard_app_strings';
import { EditorMenu } from './editor_menu';
import { ControlsToolbarButton } from './controls_toolbar_button';
export function DashboardEditingToolbar() {
const {
@ -165,6 +166,17 @@ export function DashboardEditingToolbar() {
.map(getVisTypeQuickButton)
.filter((button) => button) as QuickButtonProps[];
const extraButtons = [
<EditorMenu createNewVisType={createNewVisType} createNewEmbeddable={createNewEmbeddable} />,
<AddFromLibraryButton
onClick={() => dashboardContainer.addFromLibrary()}
data-test-subj="dashboardAddPanelButton"
/>,
];
if (dashboardContainer.controlGroup) {
extraButtons.push(<ControlsToolbarButton controlGroup={dashboardContainer.controlGroup} />);
}
return (
<>
<EuiHorizontalRule margin="none" />
@ -180,17 +192,7 @@ export function DashboardEditingToolbar() {
/>
),
quickButtonGroup: <QuickButtonGroup buttons={quickButtons} />,
extraButtons: [
<EditorMenu
createNewVisType={createNewVisType}
createNewEmbeddable={createNewEmbeddable}
/>,
<AddFromLibraryButton
onClick={() => dashboardContainer.addFromLibrary()}
data-test-subj="dashboardAddPanelButton"
/>,
dashboardContainer.controlGroup?.getToolbarButtons(),
],
extraButtons,
}}
</SolutionToolbar>
</>

View file

@ -8,10 +8,8 @@
import React, { useEffect, useRef } from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { ExitFullScreenButton } from '@kbn/shared-ux-button-exit-full-screen';
import { CalloutProps, LazyControlsCallout } from '@kbn/controls-plugin/public';
import { DashboardGrid } from '../grid';
import { pluginServices } from '../../../services/plugin_services';
@ -19,15 +17,13 @@ import { DashboardEmptyScreen } from '../empty_screen/dashboard_empty_screen';
import { useDashboardContainerContext } from '../../dashboard_container_renderer';
import { DashboardLoadedInfo } from '../../embeddable/dashboard_container';
const ControlsCallout = withSuspense<CalloutProps>(LazyControlsCallout);
export const DashboardViewport = ({
onDataLoaded,
}: {
onDataLoaded?: (data: DashboardLoadedInfo) => void;
}) => {
const {
settings: { isProjectEnabledInLabs, uiSettings },
settings: { isProjectEnabledInLabs },
} = pluginServices.getServices();
const controlsRoot = useRef(null);
@ -60,30 +56,14 @@ export const DashboardViewport = ({
const isEmbeddedExternally = select((state) => state.componentState.isEmbeddedExternally);
const controlsEnabled = isProjectEnabledInLabs('labs:dashboard:dashboardControls');
const hideAnnouncements = Boolean(uiSettings.get('hideAnnouncements'));
return (
<>
{controlsEnabled && controlGroup ? (
<>
{!hideAnnouncements &&
viewMode === ViewMode.EDIT &&
panelCount !== 0 &&
controlCount === 0 ? (
<ControlsCallout
getCreateControlButton={() => {
return controlGroup && controlGroup.getCreateControlButton('callout');
}}
/>
) : null}
{viewMode !== ViewMode.PRINT && (
<div
className={controlCount > 0 ? 'dshDashboardViewport-controls' : ''}
ref={controlsRoot}
/>
)}
</>
{controlsEnabled && controlGroup && viewMode !== ViewMode.PRINT ? (
<div
className={controlCount > 0 ? 'dshDashboardViewport-controls' : ''}
ref={controlsRoot}
/>
) : null}
<div
data-shared-items-count={panelCount}

View file

@ -51,7 +51,6 @@
"@kbn/core-saved-objects-common",
"@kbn/task-manager-plugin",
"@kbn/core-execution-context-common",
"@kbn/shared-ux-utility",
],
"exclude": [
"target/**/*",

View file

@ -1,103 +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 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 { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const kibanaServer = getService('kibanaServer');
const browser = getService('browser');
const testSubjects = getService('testSubjects');
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardPanelActions = getService('dashboardPanelActions');
const { dashboardControls, timePicker, dashboard } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'common',
'header',
]);
describe('Controls callout', () => {
describe('callout visibility', async () => {
before(async () => {
await dashboard.gotoDashboardLandingPage();
await dashboard.clickNewDashboard();
await timePicker.setDefaultDataRange();
await dashboard.saveDashboard('Test Controls Callout');
});
describe('does not show the empty control callout on an empty dashboard', async () => {
before(async () => {
const panelCount = await dashboard.getPanelCount();
if (panelCount > 0) {
const panels = await dashboard.getDashboardPanels();
for (const panel of panels) {
await dashboardPanelActions.removePanel(panel);
}
await dashboard.clickQuickSave();
}
});
it('in view mode', async () => {
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 dashboard.switchToEditMode();
const panelCount = await dashboard.getPanelCount();
if (panelCount < 1) {
await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie');
}
await testSubjects.existOrFail('controls-empty');
});
it('adding control hides the empty control callout', async () => {
await dashboardControls.createControl({
controlType: OPTIONS_LIST_CONTROL,
dataViewTitle: 'animals-*',
fieldName: 'sound.keyword',
});
await testSubjects.missingOrFail('controls-empty');
});
it('deleting all controls shows the emoty control callout again', async () => {
await dashboardControls.deleteAllControls();
await testSubjects.existOrFail('controls-empty');
});
it('hide callout when hide announcement setting is true', async () => {
await dashboard.clickQuickSave();
await dashboard.gotoDashboardLandingPage();
await kibanaServer.uiSettings.update({ hideAnnouncements: true });
await browser.refresh();
await dashboard.loadSavedDashboard('Test Controls Callout');
await dashboard.switchToEditMode();
await testSubjects.missingOrFail('controls-empty');
await kibanaServer.uiSettings.update({ hideAnnouncements: false });
});
after(async () => {
await dashboard.clickCancelOutOfEditMode();
await dashboard.gotoDashboardLandingPage();
});
});
});
}

View file

@ -46,7 +46,6 @@ export default function ({ loadTestFile, getService, getPageObjects }: FtrProvid
describe('Controls', function () {
before(setup);
after(teardown);
loadTestFile(require.resolve('./controls_callout'));
loadTestFile(require.resolve('./control_group_settings'));
loadTestFile(require.resolve('./options_list'));
loadTestFile(require.resolve('./range_slider'));

View file

@ -368,7 +368,6 @@
"controls.optionsList.popover.invalidSelectionsTooltip": "{selectedOptions} {selectedOptions, plural, one {option sélectionnée} other {options sélectionnées}} {selectedOptions, plural, one {est ignorée} other {sont ignorées}}, car {selectedOptions, plural, one {elle n'est plus présente} other {elles ne sont plus présentes}} dans les données.",
"controls.rangeSlider.errors.dataViewNotFound": "Impossible de localiser la vue de données : {dataViewId}",
"controls.rangeSlider.errors.fieldNotFound": "Impossible de localiser le champ : {fieldName}",
"controls.controlGroup.addTimeSliderControlButtonTitle": "Ajouter un contrôle de curseur temporel",
"controls.controlGroup.emptyState.addControlButtonTitle": "Ajouter un contrôle",
"controls.controlGroup.emptyState.badgeText": "Nouveauté",
"controls.controlGroup.emptyState.callToAction": "Le filtrage des données s'est amélioré grâce aux contrôles, qui vous permettent d'afficher uniquement les données que vous souhaitez explorer.",
@ -426,10 +425,8 @@
"controls.controlGroup.management.query.useAllSearchSettingsTitle": "Assure la synchronisation entre le groupe de contrôle et la barre de requête, en appliquant une plage temporelle, des pilules de filtre et des requêtes de la barre de requête",
"controls.controlGroup.management.validate.subtitle": "Ignorez automatiquement toutes les sélections de contrôle qui ne donneraient aucune donnée.",
"controls.controlGroup.management.validate.title": "Valider les sélections utilisateur",
"controls.controlGroup.onlyOneTimeSliderControlMsg": "Le groupe de contrôle contient déjà un contrôle de curseur temporel.",
"controls.controlGroup.timeSlider.title": "Curseur temporel",
"controls.controlGroup.title": "Groupe de contrôle",
"controls.controlGroup.toolbarButtonTitle": "Contrôles",
"controls.frame.error.message": "Une erreur s'est produite. En savoir plus",
"controls.optionsList.control.excludeExists": "NE PAS",
"controls.optionsList.control.negate": "NON",

View file

@ -370,7 +370,6 @@
"controls.optionsList.popover.invalidSelectionsTooltip": "{selectedOptions}個の選択した{selectedOptions, plural, other {オプション}} {selectedOptions, plural, other {が}}無視されます。{selectedOptions, plural, other {オプションが}}データに存在しません。",
"controls.rangeSlider.errors.dataViewNotFound": "データビュー{dataViewId}が見つかりませんでした",
"controls.rangeSlider.errors.fieldNotFound": "フィールド{fieldName}が見つかりませんでした",
"controls.controlGroup.addTimeSliderControlButtonTitle": "時間スライダーコントロールを追加",
"controls.controlGroup.emptyState.addControlButtonTitle": "コントロールを追加",
"controls.controlGroup.emptyState.badgeText": "新規",
"controls.controlGroup.emptyState.callToAction": "データのフィルタリングはコントロールによって効果的になりました。探索するデータのみを表示できます。",
@ -428,10 +427,8 @@
"controls.controlGroup.management.query.useAllSearchSettingsTitle": "時間範囲、フィルターピル、クエリバーからのクエリを適用して、コントロールグループを常にクエリと同期します",
"controls.controlGroup.management.validate.subtitle": "データがないコントロール選択は自動的に無視されます。",
"controls.controlGroup.management.validate.title": "ユーザー選択を検証",
"controls.controlGroup.onlyOneTimeSliderControlMsg": "コントロールグループには、すでに時間スライダーコントロールがあります。",
"controls.controlGroup.timeSlider.title": "時間スライダー",
"controls.controlGroup.title": "コントロールグループ",
"controls.controlGroup.toolbarButtonTitle": "コントロール",
"controls.frame.error.message": "エラーが発生しました。続きを読む",
"controls.optionsList.control.excludeExists": "DOES NOT",
"controls.optionsList.control.negate": "NOT",

View file

@ -370,7 +370,6 @@
"controls.optionsList.popover.invalidSelectionsTooltip": "{selectedOptions} 个选定{selectedOptions, plural, other {选项}} {selectedOptions, plural, other {已}}忽略,因为{selectedOptions, plural, one {其} other {它们}}已不再在数据中。",
"controls.rangeSlider.errors.dataViewNotFound": "找不到数据视图:{dataViewId}",
"controls.rangeSlider.errors.fieldNotFound": "找不到字段:{fieldName}",
"controls.controlGroup.addTimeSliderControlButtonTitle": "添加时间滑块控件",
"controls.controlGroup.emptyState.addControlButtonTitle": "添加控件",
"controls.controlGroup.emptyState.badgeText": "新建",
"controls.controlGroup.emptyState.callToAction": "使用控件可以更有效地筛选数据,允许您仅显示要浏览的数据。",
@ -428,10 +427,8 @@
"controls.controlGroup.management.query.useAllSearchSettingsTitle": "通过从查询栏应用时间范围、筛选胶囊和查询,使控件组与查询栏保持同步",
"controls.controlGroup.management.validate.subtitle": "自动忽略所有不会生成数据的控件选择。",
"controls.controlGroup.management.validate.title": "验证用户选择",
"controls.controlGroup.onlyOneTimeSliderControlMsg": "控件组已包含时间滑块控件。",
"controls.controlGroup.timeSlider.title": "时间滑块",
"controls.controlGroup.title": "控件组",
"controls.controlGroup.toolbarButtonTitle": "控件",
"controls.frame.error.message": "发生错误。阅读更多内容",
"controls.optionsList.control.excludeExists": "不",
"controls.optionsList.control.negate": "非",