[Controls] Control Group Embeddable and Management Experience (#111065)

* built control group embeddable featuring inline control creation and editing, and DndKit based drag and drop.

Co-authored-by: andreadelrio <delrio.andre@gmail.com>
This commit is contained in:
Devon Thomson 2021-10-05 12:34:13 -04:00 committed by GitHub
parent b26968dbe8
commit ac39f94b75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2137 additions and 227 deletions

View file

@ -11,6 +11,12 @@ const aliases = require('../../src/dev/storybook/aliases.ts').storybookAliases;
config.refs = {};
// Required due to https://github.com/storybookjs/storybook/issues/13834
config.babel = async (options) => ({
...options,
plugins: ['@babel/plugin-transform-typescript', ...options.plugins],
});
for (const alias of Object.keys(aliases).filter((a) => a !== 'ci_composite')) {
// snake_case -> Title Case
const title = alias

View file

@ -92,6 +92,9 @@
"yarn": "^1.21.1"
},
"dependencies": {
"@dnd-kit/core": "^3.1.1",
"@dnd-kit/sortable": "^4.0.0",
"@dnd-kit/utilities": "^2.0.0",
"@babel/runtime": "^7.15.4",
"@elastic/apm-rum": "^5.9.1",
"@elastic/apm-rum-react": "^1.3.1",

View file

@ -46,6 +46,7 @@ export abstract class Container<
parent?: Container
) {
super(input, output, parent);
this.getFactory = getFactory; // Currently required for using in storybook due to https://github.com/storybookjs/storybook/issues/13834
this.subscription = this.getInput$()
// At each update event, get both the previous and current state
.pipe(startWith(input), pairwise())

View file

@ -0,0 +1,29 @@
/*
* 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 { InputControlFactory } from '../types';
import { ControlsService } from '../controls_service';
import { flightFields, getEuiSelectableOptions } from './flights';
import { OptionsListEmbeddableFactory } from '../control_types/options_list';
export const getControlsServiceStub = () => {
const controlsServiceStub = new ControlsService();
const optionsListFactoryStub = new OptionsListEmbeddableFactory(
({ field, search }) =>
new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)),
() => Promise.resolve(['demo data flights']),
() => Promise.resolve(flightFields)
);
// cast to unknown because the stub cannot use the embeddable start contract to transform the EmbeddableFactoryDefinition into an EmbeddableFactory
const optionsListControlFactory = optionsListFactoryStub as unknown as InputControlFactory;
optionsListControlFactory.getDefaultInput = () => ({});
controlsServiceStub.registerInputControlType(optionsListControlFactory);
return controlsServiceStub;
};

View file

@ -23,7 +23,7 @@ const panelStyle = {
const kqlBarStyle = { background: bar, padding: 16, minHeight, fontStyle: 'italic' };
const inputBarStyle = { background: '#fff', padding: 4, minHeight };
const inputBarStyle = { background: '#fff', padding: 4 };
const layout = (OptionStory: Story) => (
<EuiFlexGroup style={{ background }} direction="column">

View file

@ -0,0 +1,53 @@
/*
* 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, useMemo } from 'react';
import uuid from 'uuid';
import { decorators } from './decorators';
import { providers } from '../../../services/storybook';
import { getControlsServiceStub } from './controls_service_stub';
import { ControlGroupContainerFactory } from '../control_group/control_group_container_factory';
export default {
title: 'Controls',
description: '',
decorators,
};
const ControlGroupStoryComponent = () => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
providers.overlays.start({});
const overlays = providers.overlays.getService();
const controlsServiceStub = getControlsServiceStub();
useEffect(() => {
(async () => {
const factory = new ControlGroupContainerFactory(controlsServiceStub, overlays);
const controlGroupContainerEmbeddable = await factory.create({
inheritParentState: {
useQuery: false,
useFilters: false,
useTimerange: false,
},
controlStyle: 'oneLine',
id: uuid.v4(),
panels: {},
});
if (controlGroupContainerEmbeddable && embeddableRoot.current) {
controlGroupContainerEmbeddable.render(embeddableRoot.current);
}
})();
}, [embeddableRoot, controlsServiceStub, overlays]);
return <div ref={embeddableRoot} />;
};
export const ControlGroupStory = () => <ControlGroupStoryComponent />;

View file

@ -0,0 +1,117 @@
/*
* 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, useMemo, useState } from 'react';
import classNames from 'classnames';
import {
EuiButtonIcon,
EuiFormControlLayout,
EuiFormLabel,
EuiFormRow,
EuiToolTip,
} from '@elastic/eui';
import { ControlGroupContainer } from '../control_group/control_group_container';
import { useChildEmbeddable } from '../hooks/use_child_embeddable';
import { ControlStyle } from '../types';
import { ControlFrameStrings } from './control_frame_strings';
export interface ControlFrameProps {
container: ControlGroupContainer;
customPrepend?: JSX.Element;
controlStyle: ControlStyle;
enableActions?: boolean;
onRemove?: () => void;
embeddableId: string;
onEdit?: () => void;
}
export const ControlFrame = ({
customPrepend,
enableActions,
embeddableId,
controlStyle,
container,
onRemove,
onEdit,
}: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const embeddable = useChildEmbeddable({ container, embeddableId });
const [title, setTitle] = useState<string>();
const usingTwoLineLayout = controlStyle === 'twoLine';
useEffect(() => {
if (embeddableRoot.current && embeddable) {
embeddable.render(embeddableRoot.current);
}
const subscription = embeddable?.getInput$().subscribe((newInput) => setTitle(newInput.title));
return () => subscription?.unsubscribe();
}, [embeddable, embeddableRoot]);
const floatingActions = (
<div
className={classNames('controlFrame--floatingActions', {
'controlFrame--floatingActions-twoLine': usingTwoLineLayout,
'controlFrame--floatingActions-oneLine': !usingTwoLineLayout,
})}
>
<EuiToolTip content={ControlFrameStrings.floatingActions.getEditButtonTitle()}>
<EuiButtonIcon
aria-label={ControlFrameStrings.floatingActions.getEditButtonTitle()}
iconType="pencil"
onClick={onEdit}
color="text"
/>
</EuiToolTip>
<EuiToolTip content={ControlFrameStrings.floatingActions.getRemoveButtonTitle()}>
<EuiButtonIcon
aria-label={ControlFrameStrings.floatingActions.getRemoveButtonTitle()}
onClick={onRemove}
iconType="cross"
color="danger"
/>
</EuiToolTip>
</div>
);
const form = (
<EuiFormControlLayout
className={'controlFrame--formControlLayout'}
fullWidth
prepend={
<>
{customPrepend ?? null}
{usingTwoLineLayout ? undefined : (
<EuiFormLabel className="controlFrame--formControlLayout__label" htmlFor={embeddableId}>
{title}
</EuiFormLabel>
)}
</>
}
>
<div
className={classNames('controlFrame--control', {
'controlFrame--twoLine': controlStyle === 'twoLine',
'controlFrame--oneLine': controlStyle === 'oneLine',
})}
id={`controlFrame--${embeddableId}`}
ref={embeddableRoot}
/>
</EuiFormControlLayout>
);
return (
<>
{enableActions && floatingActions}
<EuiFormRow fullWidth label={usingTwoLineLayout ? title : undefined}>
{form}
</EuiFormRow>
</>
);
};

View file

@ -0,0 +1,22 @@
/*
* 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 ControlFrameStrings = {
floatingActions: {
getEditButtonTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', {
defaultMessage: 'Manage control',
}),
getRemoveButtonTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', {
defaultMessage: 'Remove control',
}),
},
};

View file

@ -0,0 +1,163 @@
/*
* 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 '../control_group.scss';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import React, { useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import {
arrayMove,
SortableContext,
rectSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import {
closestCenter,
DndContext,
DragEndEvent,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
LayoutMeasuringStrategy,
} from '@dnd-kit/core';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupContainer } from '../control_group_container';
import { ControlClone, SortableControl } from './control_group_sortable_item';
import { OPTIONS_LIST_CONTROL } from '../../control_types/options_list/options_list_embeddable';
interface ControlGroupProps {
controlGroupContainer: ControlGroupContainer;
}
export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => {
const [controlIds, setControlIds] = useState<string[]>([]);
// sync controlIds every time input panels change
useEffect(() => {
const subscription = controlGroupContainer.getInput$().subscribe(() => {
setControlIds((currentIds) => {
// sync control Ids with panels from container input.
const { panels } = controlGroupContainer.getInput();
const newIds: string[] = [];
const allIds = [...currentIds, ...Object.keys(panels)];
allIds.forEach((id) => {
const currentIndex = currentIds.indexOf(id);
if (!panels[id] && currentIndex !== -1) {
currentIds.splice(currentIndex, 1);
}
if (currentIndex === -1 && Boolean(panels[id])) {
newIds.push(id);
}
});
return [...currentIds, ...newIds];
});
});
return () => subscription.unsubscribe();
}, [controlGroupContainer]);
const [draggingId, setDraggingId] = useState<string | null>(null);
const draggingIndex = useMemo(
() => (draggingId ? controlIds.indexOf(draggingId) : -1),
[controlIds, draggingId]
);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const onDragEnd = ({ over }: DragEndEvent) => {
if (over) {
const overIndex = controlIds.indexOf(over.id);
if (draggingIndex !== overIndex) {
const newIndex = overIndex;
setControlIds((currentControlIds) => arrayMove(currentControlIds, draggingIndex, newIndex));
}
}
setDraggingId(null);
};
return (
<EuiFlexGroup wrap={false} direction="row" alignItems="center" className="superWrapper">
<EuiFlexItem>
<DndContext
onDragStart={({ active }) => setDraggingId(active.id)}
onDragEnd={onDragEnd}
onDragCancel={() => setDraggingId(null)}
sensors={sensors}
collisionDetection={closestCenter}
layoutMeasuring={{
strategy: LayoutMeasuringStrategy.Always,
}}
>
<SortableContext items={controlIds} strategy={rectSortingStrategy}>
<EuiFlexGroup
className={classNames('controlGroup', { 'controlGroup-isDragging': draggingId })}
alignItems="center"
gutterSize={'m'}
wrap={true}
>
{controlIds.map((controlId, index) => (
<SortableControl
onEdit={() => controlGroupContainer.editControl(controlId)}
onRemove={() => controlGroupContainer.removeEmbeddable(controlId)}
dragInfo={{ index, draggingIndex }}
container={controlGroupContainer}
controlStyle={controlGroupContainer.getInput().controlStyle}
embeddableId={controlId}
width={controlGroupContainer.getInput().panels[controlId].width}
key={controlId}
/>
))}
</EuiFlexGroup>
</SortableContext>
<DragOverlay>
{draggingId ? (
<ControlClone
width={controlGroupContainer.getInput().panels[draggingId].width}
embeddableId={draggingId}
container={controlGroupContainer}
/>
) : null}
</DragOverlay>
</DndContext>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center" direction="row" gutterSize="xs">
<EuiFlexItem>
<EuiToolTip content={ControlGroupStrings.management.getManageButtonTitle()}>
<EuiButtonIcon
aria-label={ControlGroupStrings.management.getManageButtonTitle()}
iconType="gear"
color="text"
data-test-subj="inputControlsSortingButton"
onClick={controlGroupContainer.editControlGroup}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip content={ControlGroupStrings.management.getAddControlTitle()}>
<EuiButtonIcon
aria-label={ControlGroupStrings.management.getManageButtonTitle()}
iconType="plus"
color="text"
data-test-subj="inputControlsSortingButton"
onClick={() => controlGroupContainer.createNewControl(OPTIONS_LIST_CONTROL)} // use popover when there are multiple types of control
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,151 @@
/*
* 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 { EuiFlexItem, EuiFormLabel, EuiIcon, EuiFlexGroup } from '@elastic/eui';
import React, { forwardRef, HTMLAttributes } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import classNames from 'classnames';
import { ControlWidth } from '../../types';
import { ControlGroupContainer } from '../control_group_container';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
import { ControlFrame, ControlFrameProps } from '../../control_frame/control_frame_component';
interface DragInfo {
isOver?: boolean;
isDragging?: boolean;
draggingIndex?: number;
index?: number;
}
export type SortableControlProps = ControlFrameProps & {
dragInfo: DragInfo;
width: ControlWidth;
};
/**
* A sortable wrapper around the generic control frame.
*/
export const SortableControl = (frameProps: SortableControlProps) => {
const { embeddableId } = frameProps;
const { over, listeners, isSorting, transform, transition, attributes, isDragging, setNodeRef } =
useSortable({
id: embeddableId,
animateLayoutChanges: () => true,
});
frameProps.dragInfo = { ...frameProps.dragInfo, isOver: over?.id === embeddableId, isDragging };
return (
<SortableControlInner
key={embeddableId}
ref={setNodeRef}
{...frameProps}
{...attributes}
{...listeners}
style={{
transition: transition ?? undefined,
transform: isSorting ? undefined : CSS.Translate.toString(transform),
}}
/>
);
};
const SortableControlInner = forwardRef<
HTMLButtonElement,
SortableControlProps & { style: HTMLAttributes<HTMLButtonElement>['style'] }
>(
(
{
embeddableId,
controlStyle,
container,
dragInfo,
onRemove,
onEdit,
style,
width,
...dragHandleProps
},
dragHandleRef
) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const dragHandle = (
<button ref={dragHandleRef} {...dragHandleProps} className="controlFrame--dragHandle">
<EuiIcon type="grabHorizontal" />
</button>
);
return (
<EuiFlexItem
grow={width === 'auto'}
className={classNames('controlFrame--wrapper', {
'controlFrame--wrapper-isDragging': isDragging,
'controlFrame--wrapper-small': width === 'small',
'controlFrame--wrapper-medium': width === 'medium',
'controlFrame--wrapper-large': width === 'large',
'controlFrame--wrapper-insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1),
'controlFrame--wrapper-insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1),
})}
style={style}
>
<ControlFrame
enableActions={draggingIndex === -1}
controlStyle={controlStyle}
embeddableId={embeddableId}
customPrepend={dragHandle}
container={container}
onRemove={onRemove}
onEdit={onEdit}
/>
</EuiFlexItem>
);
}
);
/**
* A simplified clone version of the control which is dragged. This version only shows
* the title, because individual controls can be any size, and dragging a wide item
* can be quite cumbersome.
*/
export const ControlClone = ({
embeddableId,
container,
width,
}: {
embeddableId: string;
container: ControlGroupContainer;
width: ControlWidth;
}) => {
const embeddable = useChildEmbeddable({ embeddableId, container });
const layout = container.getInput().controlStyle;
return (
<EuiFlexItem
className={classNames('controlFrame--cloneWrapper', {
'controlFrame--cloneWrapper-small': width === 'small',
'controlFrame--cloneWrapper-medium': width === 'medium',
'controlFrame--cloneWrapper-large': width === 'large',
'controlFrame--cloneWrapper-twoLine': layout === 'twoLine',
})}
>
{layout === 'twoLine' ? (
<EuiFormLabel>{embeddable?.getInput().title}</EuiFormLabel>
) : undefined}
<EuiFlexGroup gutterSize="none" className={'controlFrame--draggable'}>
<EuiFlexItem grow={false}>
<EuiIcon type="grabHorizontal" className="controlFrame--dragHandle" />
</EuiFlexItem>
{container.getInput().controlStyle === 'oneLine' ? (
<EuiFlexItem>{embeddable?.getInput().title}</EuiFlexItem>
) : undefined}
</EuiFlexGroup>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,184 @@
$smallControl: $euiSize * 14;
$mediumControl: $euiSize * 25;
$largeControl: $euiSize * 50;
$controlMinWidth: $euiSize * 14;
.controlGroup {
margin-left: $euiSizeXS;
overflow-x: clip; // sometimes when using auto width, removing a control can cause a horizontal scrollbar to appear.
min-height: $euiSize * 4;
padding: $euiSize 0;
}
.controlFrame--cloneWrapper {
width: max-content;
.euiFormLabel {
padding-bottom: $euiSizeXS;
}
&-small {
width: $smallControl;
}
&-medium {
width: $mediumControl;
}
&-large {
width: $largeControl;
}
&-twoLine {
margin-top: -$euiSize * 1.25;
}
.euiFormLabel, div {
cursor: grabbing !important; // prevents cursor flickering while dragging the clone
}
.controlFrame--draggable {
cursor: grabbing;
height: $euiButtonHeight;
align-items: center;
border-radius: $euiBorderRadius;
@include euiFontSizeS;
font-weight: $euiFontWeightSemiBold;
@include euiFormControlDefaultShadow;
background-color: $euiFormInputGroupLabelBackground;
min-width: $controlMinWidth;
}
.controlFrame--formControlLayout, .controlFrame--draggable {
&-clone {
box-shadow: 0 0 0 1px $euiShadowColor,
0 1px 6px 0 $euiShadowColor;
cursor: grabbing !important;
}
.controlFrame--dragHandle {
cursor: grabbing;
}
}
}
.controlFrame--wrapper {
flex-basis: auto;
position: relative;
display: block;
.controlFrame--formControlLayout {
width: 100%;
min-width: $controlMinWidth;
transition:background-color .1s, color .1s;
&__label {
@include euiTextTruncate;
max-width: 50%;
}
&:not(.controlFrame--formControlLayout-clone) {
.controlFrame--dragHandle {
cursor: grab;
}
}
.controlFrame--control {
height: 100%;
transition: opacity .1s;
&.controlFrame--twoLine {
width: 100%;
}
}
}
&-small {
width: $smallControl;
}
&-medium {
width: $mediumControl;
}
&-large {
width: $largeControl;
}
&-insertBefore,
&-insertAfter {
.controlFrame--formControlLayout:after {
content: '';
position: absolute;
background-color: transparentize($euiColorPrimary, .5);
border-radius: $euiBorderRadius;
top: 0;
bottom: 0;
width: 2px;
}
}
&-insertBefore {
.controlFrame--formControlLayout:after {
left: -$euiSizeS;
}
}
&-insertAfter {
.controlFrame--formControlLayout:after {
right: -$euiSizeS;
}
}
.controlFrame--floatingActions {
visibility: hidden;
opacity: 0;
// slower transition on hover leave in case the user accidentally stops hover
transition: visibility .3s, opacity .3s;
z-index: 1;
position: absolute;
&-oneLine {
right:$euiSizeXS;
top: -$euiSizeL;
padding: $euiSizeXS;
border-radius: $euiBorderRadius;
background-color: $euiColorEmptyShade;
box-shadow: 0 0 0 1pt $euiColorLightShade;
}
&-twoLine {
right:$euiSizeXS;
top: -$euiSizeXS;
}
}
&:hover {
.controlFrame--floatingActions {
transition:visibility .1s, opacity .1s;
visibility: visible;
opacity: 1;
}
}
&-isDragging {
.euiFormRow__labelWrapper {
opacity: 0;
}
.controlFrame--formControlLayout {
background-color: $euiColorEmptyShade !important;
color: transparent !important;
box-shadow: none;
.euiFormLabel {
opacity: 0;
}
.controlFrame--control {
opacity: 0;
}
}
}
}

View file

@ -0,0 +1,44 @@
/*
* 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 { ControlWidth } from '../types';
import { ControlGroupStrings } from './control_group_strings';
export const CONTROL_GROUP_TYPE = 'control_group';
export const DEFAULT_CONTROL_WIDTH: ControlWidth = 'auto';
export const CONTROL_WIDTH_OPTIONS = [
{
id: `auto`,
label: ControlGroupStrings.management.controlWidth.getAutoWidthTitle(),
},
{
id: `small`,
label: ControlGroupStrings.management.controlWidth.getSmallWidthTitle(),
},
{
id: `medium`,
label: ControlGroupStrings.management.controlWidth.getMediumWidthTitle(),
},
{
id: `large`,
label: ControlGroupStrings.management.controlWidth.getLargeWidthTitle(),
},
];
export const CONTROL_LAYOUT_OPTIONS = [
{
id: `oneLine`,
label: ControlGroupStrings.management.controlStyle.getSingleLineTitle(),
},
{
id: `twoLine`,
label: ControlGroupStrings.management.controlStyle.getTwoLineTitle(),
},
];

View file

@ -0,0 +1,224 @@
/*
* 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 ReactDOM from 'react-dom';
import { cloneDeep } from 'lodash';
import {
Container,
EmbeddableFactory,
EmbeddableFactoryNotFoundError,
} from '../../../../../embeddable/public';
import {
InputControlEmbeddable,
InputControlInput,
InputControlOutput,
IEditableControlFactory,
ControlWidth,
} from '../types';
import { ControlsService } from '../controls_service';
import { ControlGroupInput, ControlPanelState } from './types';
import { ManageControlComponent } from './editor/manage_control';
import { toMountPoint } from '../../../../../kibana_react/public';
import { ControlGroup } from './component/control_group_component';
import { PresentationOverlaysService } from '../../../services/overlays';
import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from './control_group_constants';
import { ManageControlGroup } from './editor/manage_control_group_component';
import { OverlayRef } from '../../../../../../core/public';
import { ControlGroupStrings } from './control_group_strings';
export class ControlGroupContainer extends Container<InputControlInput, ControlGroupInput> {
public readonly type = CONTROL_GROUP_TYPE;
private nextControlWidth: ControlWidth = DEFAULT_CONTROL_WIDTH;
constructor(
initialInput: ControlGroupInput,
private readonly controlsService: ControlsService,
private readonly overlays: PresentationOverlaysService,
parent?: Container
) {
super(initialInput, { embeddableLoaded: {} }, controlsService.getControlFactory, parent);
this.overlays = overlays;
this.controlsService = controlsService;
}
protected createNewPanelState<TEmbeddableInput extends InputControlInput = InputControlInput>(
factory: EmbeddableFactory<InputControlInput, InputControlOutput, InputControlEmbeddable>,
partial: Partial<TEmbeddableInput> = {}
): ControlPanelState<TEmbeddableInput> {
const panelState = super.createNewPanelState(factory, partial);
return {
order: 1,
width: this.nextControlWidth,
...panelState,
} as ControlPanelState<TEmbeddableInput>;
}
protected getInheritedInput(id: string): InputControlInput {
const { filters, query, timeRange, inheritParentState } = this.getInput();
return {
filters: inheritParentState.useFilters ? filters : undefined,
query: inheritParentState.useQuery ? query : undefined,
timeRange: inheritParentState.useTimerange ? timeRange : undefined,
id,
};
}
public createNewControl = async (type: string) => {
const factory = this.controlsService.getControlFactory(type);
if (!factory) throw new EmbeddableFactoryNotFoundError(type);
const initialInputPromise = new Promise<Omit<InputControlInput, 'id'>>((resolve, reject) => {
let inputToReturn: Partial<InputControlInput> = {};
const onCancel = (ref: OverlayRef) => {
this.overlays
.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 flyoutInstance = this.overlays.openFlyout(
toMountPoint(
<ManageControlComponent
width={this.nextControlWidth}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
updateWidth={(newWidth) => (this.nextControlWidth = newWidth)}
controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({
onChange: (partialInput) => {
inputToReturn = { ...inputToReturn, ...partialInput };
},
})}
onSave={() => {
resolve(inputToReturn);
flyoutInstance.close();
}}
onCancel={() => onCancel(flyoutInstance)}
/>
),
{
onClose: (flyout) => onCancel(flyout),
}
);
});
initialInputPromise.then(
async (explicitInput) => {
await this.addNewEmbeddable(type, explicitInput);
},
() => {} // swallow promise rejection because it can be part of normal flow
);
};
public editControl = async (embeddableId: string) => {
const panel = this.getInput().panels[embeddableId];
const factory = this.getFactory(panel.type);
const embeddable = await this.untilEmbeddableLoaded(embeddableId);
if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);
const initialExplicitInput = cloneDeep(panel.explicitInput);
const initialWidth = panel.width;
const onCancel = (ref: OverlayRef) => {
this.overlays
.openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(),
cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(),
title: ControlGroupStrings.management.discardChanges.getTitle(),
buttonColor: 'danger',
})
.then((confirmed) => {
if (confirmed) {
embeddable.updateInput(initialExplicitInput);
this.updateInput({
panels: {
...this.getInput().panels,
[embeddableId]: { ...this.getInput().panels[embeddableId], width: initialWidth },
},
});
ref.close();
}
});
};
const flyoutInstance = this.overlays.openFlyout(
toMountPoint(
<ManageControlComponent
width={panel.width}
title={embeddable.getTitle()}
removeControl={() => this.removeEmbeddable(embeddableId)}
updateTitle={(newTitle) => embeddable.updateInput({ title: newTitle })}
controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({
onChange: (partialInput) => embeddable.updateInput(partialInput),
initialInput: embeddable.getInput(),
})}
onCancel={() => onCancel(flyoutInstance)}
onSave={() => flyoutInstance.close()}
updateWidth={(newWidth) =>
this.updateInput({
panels: {
...this.getInput().panels,
[embeddableId]: { ...this.getInput().panels[embeddableId], width: newWidth },
},
})
}
/>
),
{
onClose: (flyout) => onCancel(flyout),
}
);
};
public editControlGroup = () => {
const flyoutInstance = this.overlays.openFlyout(
toMountPoint(
<ManageControlGroup
controlStyle={this.getInput().controlStyle}
setControlStyle={(newStyle) => this.updateInput({ controlStyle: newStyle })}
deleteAllEmbeddables={() => {
this.overlays
.openConfirm(ControlGroupStrings.management.deleteAllControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteAllControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteAllControls.getCancel(),
title: ControlGroupStrings.management.deleteAllControls.getTitle(),
buttonColor: 'danger',
})
.then((confirmed) => {
if (confirmed) {
Object.keys(this.getInput().panels).forEach((id) => this.removeEmbeddable(id));
flyoutInstance.close();
}
});
}}
setAllPanelWidths={(newWidth) => {
const newPanels = cloneDeep(this.getInput().panels);
Object.values(newPanels).forEach((panel) => (panel.width = newWidth));
this.updateInput({ panels: { ...newPanels, ...newPanels } });
}}
panels={this.getInput().panels}
/>
)
);
};
public render(dom: HTMLElement) {
ReactDOM.render(<ControlGroup controlGroupContainer={this} />, dom);
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 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 {
Container,
ContainerOutput,
EmbeddableFactory,
EmbeddableFactoryDefinition,
ErrorEmbeddable,
} from '../../../../../embeddable/public';
import { ControlGroupInput } from './types';
import { ControlsService } from '../controls_service';
import { ControlGroupStrings } from './control_group_strings';
import { CONTROL_GROUP_TYPE } from './control_group_constants';
import { ControlGroupContainer } from './control_group_container';
import { PresentationOverlaysService } from '../../../services/overlays';
export type DashboardContainerFactory = EmbeddableFactory<
ControlGroupInput,
ContainerOutput,
ControlGroupContainer
>;
export class ControlGroupContainerFactory
implements EmbeddableFactoryDefinition<ControlGroupInput, ContainerOutput, ControlGroupContainer>
{
public readonly isContainerType = true;
public readonly type = CONTROL_GROUP_TYPE;
public readonly controlsService: ControlsService;
private readonly overlays: PresentationOverlaysService;
constructor(controlsService: ControlsService, overlays: PresentationOverlaysService) {
this.overlays = overlays;
this.controlsService = controlsService;
}
public isEditable = async () => false;
public readonly getDisplayName = () => {
return ControlGroupStrings.getEmbeddableTitle();
};
public getDefaultInput(): Partial<ControlGroupInput> {
return {
panels: {},
inheritParentState: {
useFilters: true,
useQuery: true,
useTimerange: true,
},
};
}
public create = async (
initialInput: ControlGroupInput,
parent?: Container
): Promise<ControlGroupContainer | ErrorEmbeddable> => {
return new ControlGroupContainer(initialInput, this.controlsService, this.overlays, parent);
};
}

View file

@ -0,0 +1,176 @@
/*
* 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 ControlGroupStrings = {
getEmbeddableTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.title', {
defaultMessage: 'Control group',
}),
manageControl: {
getFlyoutTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', {
defaultMessage: 'Manage control',
}),
getTitleInputTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', {
defaultMessage: 'Title',
}),
getWidthInputTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', {
defaultMessage: 'Control width',
}),
getSaveChangesTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.saveChangesTitle', {
defaultMessage: 'Save and close',
}),
getCancelTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.cancelTitle', {
defaultMessage: 'Cancel',
}),
},
management: {
getAddControlTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.addControl', {
defaultMessage: 'Add control',
}),
getManageButtonTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', {
defaultMessage: 'Manage controls',
}),
getFlyoutTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', {
defaultMessage: 'Manage controls',
}),
getDesignTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.designTitle', {
defaultMessage: 'Design',
}),
getWidthTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.widthTitle', {
defaultMessage: 'Width',
}),
getLayoutTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', {
defaultMessage: 'Layout',
}),
getDeleteButtonTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.delete', {
defaultMessage: 'Delete control',
}),
getDeleteAllButtonTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', {
defaultMessage: 'Delete all',
}),
controlWidth: {
getChangeAllControlWidthsTitle: () =>
i18n.translate(
'presentationUtil.inputControls.controlGroup.management.layout.changeAllControlWidths',
{
defaultMessage: 'Set width for all controls',
}
),
getWidthSwitchLegend: () =>
i18n.translate(
'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend',
{
defaultMessage: 'Change individual control width',
}
),
getAutoWidthTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.auto', {
defaultMessage: 'Auto',
}),
getSmallWidthTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.small', {
defaultMessage: 'Small',
}),
getMediumWidthTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.medium', {
defaultMessage: 'Medium',
}),
getLargeWidthTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.large', {
defaultMessage: 'Large',
}),
},
controlStyle: {
getDesignSwitchLegend: () =>
i18n.translate(
'presentationUtil.inputControls.controlGroup.management.layout.designSwitchLegend',
{
defaultMessage: 'Switch control designs',
}
),
getSingleLineTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', {
defaultMessage: 'Single line layout',
}),
getTwoLineTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', {
defaultMessage: 'Two line layout',
}),
},
deleteAllControls: {
getTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.title', {
defaultMessage: 'Delete all?',
}),
getSubtitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.sub', {
defaultMessage: 'Controls are not recoverable once removed.',
}),
getConfirm: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.confirm', {
defaultMessage: 'Delete',
}),
getCancel: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.cancel', {
defaultMessage: 'Cancel',
}),
},
discardChanges: {
getTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', {
defaultMessage: 'Discard?',
}),
getSubtitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', {
defaultMessage:
'Discard changes to this control? Controls are not recoverable once removed.',
}),
getConfirm: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', {
defaultMessage: 'Discard',
}),
getCancel: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', {
defaultMessage: 'Cancel',
}),
},
discardNewControl: {
getTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', {
defaultMessage: 'Discard?',
}),
getSubtitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', {
defaultMessage: 'Discard new control? Controls are not recoverable once removed.',
}),
getConfirm: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', {
defaultMessage: 'Discard',
}),
getCancel: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', {
defaultMessage: 'Cancel',
}),
},
},
};

View file

@ -0,0 +1,150 @@
/*
* 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, { useEffect, useState } from 'react';
import {
EuiFlyoutHeader,
EuiButtonGroup,
EuiFlyoutBody,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiFieldText,
EuiFlyoutFooter,
EuiButton,
EuiFormRow,
EuiForm,
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlEditorComponent, ControlWidth } from '../../types';
import { CONTROL_WIDTH_OPTIONS } from '../control_group_constants';
interface ManageControlProps {
title?: string;
onSave: () => void;
width: ControlWidth;
onCancel: () => void;
removeControl?: () => void;
controlEditorComponent?: ControlEditorComponent;
updateTitle: (title: string) => void;
updateWidth: (newWidth: ControlWidth) => void;
}
export const ManageControlComponent = ({
controlEditorComponent,
removeControl,
updateTitle,
updateWidth,
onCancel,
onSave,
title,
width,
}: ManageControlProps) => {
const [currentTitle, setCurrentTitle] = useState(title);
const [currentWidth, setCurrentWidth] = useState(width);
const [controlEditorValid, setControlEditorValid] = useState(false);
const [editorValid, setEditorValid] = useState(false);
useEffect(() => setEditorValid(Boolean(currentTitle)), [currentTitle]);
return (
<>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>{ControlGroupStrings.manageControl.getFlyoutTitle()}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiForm>
<EuiFormRow label={ControlGroupStrings.manageControl.getTitleInputTitle()}>
<EuiFieldText
placeholder="Placeholder text"
value={currentTitle}
onChange={(e) => {
updateTitle(e.target.value);
setCurrentTitle(e.target.value);
}}
aria-label="Use aria labels when no actual label is in use"
/>
</EuiFormRow>
<EuiFormRow label={ControlGroupStrings.manageControl.getWidthInputTitle()}>
<EuiButtonGroup
color="primary"
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
idSelected={currentWidth}
onChange={(newWidth: string) => {
setCurrentWidth(newWidth as ControlWidth);
updateWidth(newWidth as ControlWidth);
}}
/>
</EuiFormRow>
<EuiSpacer size="l" />
{controlEditorComponent &&
controlEditorComponent({ setValidState: setControlEditorValid })}
<EuiSpacer size="l" />
{removeControl && (
<EuiButtonEmpty
aria-label={`delete-${title}`}
iconType="trash"
flush="left"
color="danger"
onClick={() => {
onCancel();
removeControl();
}}
>
{ControlGroupStrings.management.getDeleteButtonTitle()}
</EuiButtonEmpty>
)}
</EuiForm>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-label={`delete-${title}`}
iconType="cross"
onClick={() => {
onCancel();
}}
>
{ControlGroupStrings.manageControl.getCancelTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label={`delete-${title}`}
iconType="check"
color="primary"
disabled={!editorValid || !controlEditorValid}
onClick={() => {
onSave();
}}
>
{ControlGroupStrings.manageControl.getSaveChangesTitle()}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -0,0 +1,113 @@
/*
* 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 useMount from 'react-use/lib/useMount';
import React, { useState } from 'react';
import {
EuiFlyoutHeader,
EuiButtonEmpty,
EuiButtonGroup,
EuiFlyoutBody,
EuiFormRow,
EuiSpacer,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { ControlsPanels } from '../types';
import { ControlStyle, ControlWidth } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { CONTROL_LAYOUT_OPTIONS, CONTROL_WIDTH_OPTIONS } from '../control_group_constants';
interface ManageControlGroupProps {
panels: ControlsPanels;
controlStyle: ControlStyle;
deleteAllEmbeddables: () => void;
setControlStyle: (style: ControlStyle) => void;
setAllPanelWidths: (newWidth: ControlWidth) => void;
}
export const ManageControlGroup = ({
panels,
controlStyle,
setControlStyle,
setAllPanelWidths,
deleteAllEmbeddables,
}: ManageControlGroupProps) => {
const [currentControlStyle, setCurrentControlStyle] = useState<ControlStyle>(controlStyle);
const [selectedWidth, setSelectedWidth] = useState<ControlWidth>();
const [selectionDisplay, setSelectionDisplay] = useState(false);
useMount(() => {
if (!panels || Object.keys(panels).length === 0) return;
const firstWidth = panels[Object.keys(panels)[0]].width;
if (Object.values(panels).every((panel) => panel.width === firstWidth)) {
setSelectedWidth(firstWidth);
}
});
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={currentControlStyle}
onChange={(newControlStyle) => {
setControlStyle(newControlStyle as ControlStyle);
setCurrentControlStyle(newControlStyle as ControlStyle);
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow label={ControlGroupStrings.management.getWidthTitle()}>
<EuiSwitch
label={ControlGroupStrings.management.controlWidth.getChangeAllControlWidthsTitle()}
checked={selectionDisplay}
onChange={() => setSelectionDisplay(!selectionDisplay)}
/>
</EuiFormRow>
{selectionDisplay ? (
<>
<EuiSpacer size="s" />
<EuiButtonGroup
color="primary"
idSelected={selectedWidth ?? ''}
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
onChange={(newWidth: string) => {
setAllPanelWidths(newWidth as ControlWidth);
setSelectedWidth(newWidth as ControlWidth);
}}
/>
</>
) : undefined}
<EuiSpacer size="xl" />
<EuiButtonEmpty
onClick={deleteAllEmbeddables}
aria-label={'delete-all'}
iconType="trash"
color="danger"
flush="left"
size="s"
>
{ControlGroupStrings.management.getDeleteAllButtonTitle()}
</EuiButtonEmpty>
</EuiFlyoutBody>
</>
);
};

View file

@ -0,0 +1,32 @@
/*
* 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 { PanelState, EmbeddableInput } from '../../../../../embeddable/public';
import { ControlStyle, ControlWidth, InputControlInput } from '../types';
export interface ControlGroupInput
extends EmbeddableInput,
Omit<InputControlInput, 'twoLineLayout'> {
inheritParentState: {
useFilters: boolean;
useQuery: boolean;
useTimerange: boolean;
};
controlStyle: ControlStyle;
panels: ControlsPanels;
}
export interface ControlPanelState<TEmbeddableInput extends InputControlInput = InputControlInput>
extends PanelState<TEmbeddableInput> {
order: number;
width: ControlWidth;
}
export interface ControlsPanels {
[panelId: string]: ControlPanelState;
}

View file

@ -15,7 +15,7 @@ import { OptionsListStrings } from './options_list_strings';
import { OptionsListPopover } from './options_list_popover_component';
import './options_list.scss';
import { useStateObservable } from '../../use_state_observable';
import { useStateObservable } from '../../hooks/use_state_observable';
export interface OptionsListComponentState {
availableOptions?: EuiSelectableOption[];

View file

@ -0,0 +1,108 @@
/*
* 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 { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import useMount from 'react-use/lib/useMount';
import { ControlEditorProps, GetControlEditorComponentProps } from '../../types';
import {
OptionsListEmbeddableInput,
OptionsListFieldFetcher,
OptionsListIndexPatternFetcher,
} from './options_list_embeddable';
import { OptionsListStrings } from './options_list_strings';
interface OptionsListEditorProps extends ControlEditorProps {
onChange: GetControlEditorComponentProps<OptionsListEmbeddableInput>['onChange'];
fetchIndexPatterns: OptionsListIndexPatternFetcher;
initialInput?: Partial<OptionsListEmbeddableInput>;
fetchFields: OptionsListFieldFetcher;
}
interface OptionsListEditorState {
availableIndexPatterns: Array<EuiSuperSelectOption<string>>;
indexPattern?: string;
availableFields: Array<EuiSuperSelectOption<string>>;
field?: string;
}
export const OptionsListEditor = ({
onChange,
fetchFields,
initialInput,
setValidState,
fetchIndexPatterns,
}: OptionsListEditorProps) => {
const [state, setState] = useState<OptionsListEditorState>({
indexPattern: initialInput?.indexPattern,
field: initialInput?.field,
availableIndexPatterns: [],
availableFields: [],
});
const applySelection = ({ field, indexPattern }: { field?: string; indexPattern?: string }) => {
const newState = { ...(field ? { field } : {}), ...(indexPattern ? { indexPattern } : {}) };
/**
* apply state and run onChange concurrently. State is copied here rather than by subscribing to embeddable
* input so that the same editor component can cover the 'create' use case.
*/
setState((currentState) => {
return { ...currentState, ...newState };
});
onChange(newState);
};
useMount(() => {
(async () => {
const indexPatterns = (await fetchIndexPatterns()).map((indexPattern) => ({
value: indexPattern,
inputDisplay: indexPattern,
}));
setState((currentState) => ({ ...currentState, availableIndexPatterns: indexPatterns }));
})();
});
useEffect(() => {
(async () => {
let availableFields: Array<EuiSuperSelectOption<string>> = [];
if (state.indexPattern) {
availableFields = (await fetchFields(state.indexPattern)).map((field) => ({
value: field,
inputDisplay: field,
}));
}
setState((currentState) => ({ ...currentState, availableFields }));
})();
}, [state.indexPattern, fetchFields]);
useEffect(
() => setValidState(Boolean(state.field) && Boolean(state.indexPattern)),
[state.field, setValidState, state.indexPattern]
);
return (
<>
<EuiFormRow label={OptionsListStrings.editor.getIndexPatternTitle()}>
<EuiSuperSelect
options={state.availableIndexPatterns}
onChange={(indexPattern) => applySelection({ indexPattern })}
valueOfSelected={state.indexPattern}
/>
</EuiFormRow>
<EuiFormRow label={OptionsListStrings.editor.getFieldTitle()}>
<EuiSuperSelect
disabled={!state.indexPattern}
options={state.availableFields}
onChange={(field) => applySelection({ field })}
valueOfSelected={state.field}
/>
</EuiFormRow>
</>
);
};

View file

@ -15,9 +15,9 @@ import { tap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators';
import { esFilters } from '../../../../../../data/public';
import { OptionsListStrings } from './options_list_strings';
import { Embeddable, IContainer } from '../../../../../../embeddable/public';
import { InputControlInput, InputControlOutput } from '../../types';
import { OptionsListComponent, OptionsListComponentState } from './options_list_component';
import { Embeddable } from '../../../../../../embeddable/public';
import { InputControlInput, InputControlOutput } from '../../embeddable/types';
const toggleAvailableOptions = (
indices: number[],
@ -50,6 +50,9 @@ interface OptionsListDataFetchProps {
timeRange?: InputControlInput['timeRange'];
}
export type OptionsListIndexPatternFetcher = () => Promise<string[]>; // TODO: use the proper types here.
export type OptionsListFieldFetcher = (indexPattern: string) => Promise<string[]>; // TODO: use the proper types here.
export type OptionsListDataFetcher = (
props: OptionsListDataFetchProps
) => Promise<EuiSelectableOption[]>;
@ -58,7 +61,7 @@ export const OPTIONS_LIST_CONTROL = 'optionsListControl';
export interface OptionsListEmbeddableInput extends InputControlInput {
field: string;
indexPattern: string;
multiSelect: boolean;
singleSelect?: boolean;
defaultSelections?: string[];
}
export class OptionsListEmbeddable extends Embeddable<
@ -66,14 +69,11 @@ export class OptionsListEmbeddable extends Embeddable<
InputControlOutput
> {
public readonly type = OPTIONS_LIST_CONTROL;
private node?: HTMLElement;
private fetchData: OptionsListDataFetcher;
// internal state for this input control.
private selectedOptions: Set<string>;
private typeaheadSubject: Subject<string> = new Subject<string>();
private searchString: string = '';
private componentState: OptionsListComponentState;
private componentStateSubject$ = new Subject<OptionsListComponentState>();
@ -88,9 +88,10 @@ export class OptionsListEmbeddable extends Embeddable<
constructor(
input: OptionsListEmbeddableInput,
output: InputControlOutput,
fetchData: OptionsListDataFetcher
private fetchData: OptionsListDataFetcher,
parent?: IContainer
) {
super(input, output);
super(input, output, parent);
this.fetchData = fetchData;
// populate default selections from input
@ -99,7 +100,7 @@ export class OptionsListEmbeddable extends Embeddable<
// fetch available options when input changes or when search string has changed
const typeaheadPipe = this.typeaheadSubject.pipe(
tap((newSearchString) => (this.searchString = newSearchString)),
tap((newSearchString) => this.updateComponentState({ searchString: newSearchString })),
debounceTime(100)
);
const inputPipe = this.getInput$().pipe(
@ -136,7 +137,7 @@ export class OptionsListEmbeddable extends Embeddable<
const { indexPattern, timeRange, filters, field, query } = this.getInput();
let newOptions = await this.fetchData({
search: this.searchString,
search: this.componentState.searchString,
indexPattern,
timeRange,
filters,

View file

@ -0,0 +1,63 @@
/*
* 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 { EmbeddableFactoryDefinition, IContainer } from '../../../../../../embeddable/public';
import {
ControlEditorProps,
GetControlEditorComponentProps,
IEditableControlFactory,
} from '../../types';
import { OptionsListEditor } from './options_list_editor';
import {
OptionsListDataFetcher,
OptionsListEmbeddable,
OptionsListEmbeddableInput,
OptionsListFieldFetcher,
OptionsListIndexPatternFetcher,
OPTIONS_LIST_CONTROL,
} from './options_list_embeddable';
export class OptionsListEmbeddableFactory
implements EmbeddableFactoryDefinition, IEditableControlFactory
{
public type = OPTIONS_LIST_CONTROL;
constructor(
private fetchData: OptionsListDataFetcher,
private fetchIndexPatterns: OptionsListIndexPatternFetcher,
private fetchFields: OptionsListFieldFetcher
) {
this.fetchIndexPatterns = fetchIndexPatterns;
this.fetchFields = fetchFields;
this.fetchData = fetchData;
}
public create(initialInput: OptionsListEmbeddableInput, parent?: IContainer) {
return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, this.fetchData, parent));
}
public getControlEditor = ({
onChange,
initialInput,
}: GetControlEditorComponentProps<OptionsListEmbeddableInput>) => {
return ({ setValidState }: ControlEditorProps) => (
<OptionsListEditor
fetchIndexPatterns={this.fetchIndexPatterns}
fetchFields={this.fetchFields}
setValidState={setValidState}
initialInput={initialInput}
onChange={onChange}
/>
);
};
public isEditable = () => Promise.resolve(false);
public getDisplayName = () => 'Options List Control';
}

View file

@ -19,6 +19,16 @@ export const OptionsListStrings = {
defaultMessage: 'Select...',
}),
},
editor: {
getIndexPatternTitle: () =>
i18n.translate('presentationUtil.inputControls.optionsList.editor.indexPatternTitle', {
defaultMessage: 'Index pattern',
}),
getFieldTitle: () =>
i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', {
defaultMessage: 'Field',
}),
},
popover: {
getLoadingMessage: () =>
i18n.translate('presentationUtil.inputControls.optionsList.popover.loading', {

View file

@ -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 { EmbeddableFactory } from '../../../../embeddable/public';
import {
ControlTypeRegistry,
InputControlEmbeddable,
InputControlFactory,
InputControlInput,
InputControlOutput,
} from './types';
export class ControlsService {
private controlsFactoriesMap: ControlTypeRegistry = {};
public registerInputControlType = (factory: InputControlFactory) => {
this.controlsFactoriesMap[factory.type] = factory;
};
public getControlFactory = <
I extends InputControlInput = InputControlInput,
O extends InputControlOutput = InputControlOutput,
E extends InputControlEmbeddable<I, O> = InputControlEmbeddable<I, O>
>(
type: string
) => {
return this.controlsFactoriesMap[type] as EmbeddableFactory<I, O, E>;
};
public getInputControlTypes = () => Object.keys(this.controlsFactoriesMap);
}

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 { useEffect, useState } from 'react';
import { InputControlEmbeddable } from '../types';
import { IContainer } from '../../../../../embeddable/public';
export const useChildEmbeddable = ({
container,
embeddableId,
}: {
container: IContainer;
embeddableId: string;
}) => {
const [embeddable, setEmbeddable] = useState<InputControlEmbeddable>();
useEffect(() => {
let mounted = true;
(async () => {
const newEmbeddable = await container.untilEmbeddableLoaded(embeddableId);
if (!mounted) return;
setEmbeddable(newEmbeddable);
})();
return () => {
mounted = false;
};
}, [container, embeddableId]);
return embeddable;
};

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 { Filter } from '@kbn/es-query';
import { Query, TimeRange } from '../../../../data/public';
import {
EmbeddableFactory,
EmbeddableInput,
EmbeddableOutput,
IEmbeddable,
} from '../../../../embeddable/public';
export type ControlWidth = 'auto' | 'small' | 'medium' | 'large';
export type ControlStyle = 'twoLine' | 'oneLine';
/**
* Control embeddable types
*/
export type InputControlFactory = EmbeddableFactory<
InputControlInput,
InputControlOutput,
InputControlEmbeddable
>;
export interface ControlTypeRegistry {
[key: string]: InputControlFactory;
}
export type InputControlInput = EmbeddableInput & {
query?: Query;
filters?: Filter[];
timeRange?: TimeRange;
twoLineLayout?: boolean;
};
export type InputControlOutput = EmbeddableOutput & {
filters?: Filter[];
};
export type InputControlEmbeddable<
TInputControlEmbeddableInput extends InputControlInput = InputControlInput,
TInputControlEmbeddableOutput extends InputControlOutput = InputControlOutput
> = IEmbeddable<TInputControlEmbeddableInput, TInputControlEmbeddableOutput>;
/**
* Control embeddable editor types
*/
export interface IEditableControlFactory<T extends InputControlInput = InputControlInput> {
getControlEditor?: GetControlEditorComponent<T>;
}
export type GetControlEditorComponent<T extends InputControlInput = InputControlInput> = (
props: GetControlEditorComponentProps<T>
) => ControlEditorComponent;
export interface GetControlEditorComponentProps<T extends InputControlInput = InputControlInput> {
onChange: (partial: Partial<T>) => void;
initialInput?: Partial<T>;
}
export type ControlEditorComponent = (props: ControlEditorProps) => JSX.Element;
export interface ControlEditorProps {
setValidState: (valid: boolean) => void;
}

View file

@ -1,88 +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 React, { useEffect, useMemo, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { decorators } from './decorators';
import { getEuiSelectableOptions, flightFields, flightFieldLabels, FlightField } from './flights';
import { OptionsListEmbeddableFactory, OptionsListEmbeddable } from '../control_types/options_list';
import { ControlFrame } from '../control_frame/control_frame';
export default {
title: 'Input Controls',
description: '',
decorators,
};
interface OptionsListStorybookArgs {
fields: string[];
twoLine: boolean;
}
const storybookArgs = {
twoLine: false,
fields: ['OriginCityName', 'OriginWeather', 'DestCityName', 'DestWeather'],
};
const storybookArgTypes = {
fields: {
twoLine: {
control: { type: 'bool' },
},
control: {
type: 'check',
options: flightFields,
},
},
};
const OptionsListStoryComponent = ({ fields, twoLine }: OptionsListStorybookArgs) => {
const [embeddables, setEmbeddables] = useState<OptionsListEmbeddable[]>([]);
const optionsListEmbeddableFactory = useMemo(
() =>
new OptionsListEmbeddableFactory(
({ field, search }) =>
new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500))
),
[]
);
useEffect(() => {
const embeddableCreatePromises = fields.map((field) => {
return optionsListEmbeddableFactory.create({
field,
id: '',
indexPattern: '',
multiSelect: true,
twoLineLayout: twoLine,
title: flightFieldLabels[field as FlightField],
});
});
Promise.all(embeddableCreatePromises).then((newEmbeddables) => setEmbeddables(newEmbeddables));
}, [fields, optionsListEmbeddableFactory, twoLine]);
return (
<EuiFlexGroup alignItems="center" wrap={true} gutterSize={'s'}>
{embeddables.map((embeddable) => (
<EuiFlexItem key={embeddable.getInput().field}>
<ControlFrame twoLine={twoLine} embeddable={embeddable} />
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};
export const OptionsListStory = ({ fields, twoLine }: OptionsListStorybookArgs) => (
<OptionsListStoryComponent fields={fields} twoLine={twoLine} />
);
OptionsListStory.args = storybookArgs;
OptionsListStory.argTypes = storybookArgTypes;

View file

@ -1,14 +0,0 @@
.controlFrame--formControlLayout {
width: 100%;
min-width: $euiSize * 12.5;
}
.controlFrame--control {
&.optionsList--filterBtnSingle {
height: 100%;
}
}
.optionsList--filterBtnTwoLine {
width: 100%;
}

View file

@ -1,58 +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 React, { useMemo } from 'react';
import useMount from 'react-use/lib/useMount';
import classNames from 'classnames';
import { EuiFormControlLayout, EuiFormLabel, EuiFormRow } from '@elastic/eui';
import { InputControlEmbeddable } from '../embeddable/types';
import './control_frame.scss';
interface ControlFrameProps {
embeddable: InputControlEmbeddable;
twoLine?: boolean;
}
export const ControlFrame = ({ twoLine, embeddable }: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
useMount(() => {
if (embeddableRoot.current && embeddable) embeddable.render(embeddableRoot.current);
});
const form = (
<EuiFormControlLayout
className="controlFrame--formControlLayout"
fullWidth
prepend={
twoLine ? undefined : (
<EuiFormLabel htmlFor={embeddable.id}>{embeddable.getInput().title}</EuiFormLabel>
)
}
>
<div
className={classNames('controlFrame--control', {
'optionsList--filterBtnTwoLine': twoLine,
'optionsList--filterBtnSingle': !twoLine,
})}
id={embeddable.id}
ref={embeddableRoot}
/>
</EuiFormControlLayout>
);
return twoLine ? (
<EuiFormRow fullWidth label={embeddable.getInput().title}>
{form}
</EuiFormRow>
) : (
form
);
};

View file

@ -1,32 +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 { EmbeddableFactoryDefinition } from '../../../../../../embeddable/public';
import {
OptionsListDataFetcher,
OptionsListEmbeddable,
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
} from './options_list_embeddable';
export class OptionsListEmbeddableFactory implements EmbeddableFactoryDefinition {
public type = OPTIONS_LIST_CONTROL;
private fetchData: OptionsListDataFetcher;
constructor(fetchData: OptionsListDataFetcher) {
this.fetchData = fetchData;
}
public create(initialInput: OptionsListEmbeddableInput) {
return Promise.resolve(new OptionsListEmbeddable(initialInput, {}, this.fetchData));
}
public isEditable = () => Promise.resolve(false);
public getDisplayName = () => 'Options List Control';
}

View file

@ -1,23 +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 { Filter, Query, TimeRange } from '../../../../../data/public';
import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from '../../../../../embeddable/public';
export type InputControlInput = EmbeddableInput & {
filters?: Filter[];
query?: Query;
timeRange?: TimeRange;
twoLineLayout?: boolean;
};
export type InputControlOutput = EmbeddableOutput & {
filters?: Filter[];
};
export type InputControlEmbeddable = IEmbeddable<InputControlInput, InputControlOutput>;

View file

@ -12,6 +12,7 @@ import { PresentationCapabilitiesService } from './capabilities';
import { PresentationDashboardsService } from './dashboards';
import { PresentationLabsService } from './labs';
import { registry as stubRegistry } from './stub';
import { PresentationOverlaysService } from './overlays';
export { PresentationCapabilitiesService } from './capabilities';
export { PresentationDashboardsService } from './dashboards';
@ -19,6 +20,7 @@ export { PresentationLabsService } from './labs';
export interface PresentationUtilServices {
dashboards: PresentationDashboardsService;
capabilities: PresentationCapabilitiesService;
overlays: PresentationOverlaysService;
labs: PresentationLabsService;
}

View file

@ -8,6 +8,7 @@
import { capabilitiesServiceFactory } from './capabilities';
import { dashboardsServiceFactory } from './dashboards';
import { overlaysServiceFactory } from './overlays';
import { labsServiceFactory } from './labs';
import {
PluginServiceProviders,
@ -20,6 +21,7 @@ import { PresentationUtilServices } from '..';
export { capabilitiesServiceFactory } from './capabilities';
export { dashboardsServiceFactory } from './dashboards';
export { overlaysServiceFactory } from './overlays';
export { labsServiceFactory } from './labs';
export const providers: PluginServiceProviders<
@ -29,6 +31,7 @@ export const providers: PluginServiceProviders<
capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
labs: new PluginServiceProvider(labsServiceFactory),
dashboards: new PluginServiceProvider(dashboardsServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
};
export const registry = new PluginServiceRegistry<

View file

@ -0,0 +1,26 @@
/*
* 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 { PresentationUtilPluginStartDeps } from '../../types';
import { KibanaPluginServiceFactory } from '../create';
import { PresentationOverlaysService } from '../overlays';
export type OverlaysServiceFactory = KibanaPluginServiceFactory<
PresentationOverlaysService,
PresentationUtilPluginStartDeps
>;
export const overlaysServiceFactory: OverlaysServiceFactory = ({ coreStart }) => {
const {
overlays: { openFlyout, openConfirm },
} = coreStart;
return {
openFlyout,
openConfirm,
};
};

View file

@ -0,0 +1,19 @@
/*
* 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 {
MountPoint,
OverlayFlyoutOpenOptions,
OverlayModalConfirmOptions,
OverlayRef,
} from '../../../../core/public';
export interface PresentationOverlaysService {
openFlyout(mount: MountPoint, options?: OverlayFlyoutOpenOptions): OverlayRef;
openConfirm(message: MountPoint | string, options?: OverlayModalConfirmOptions): Promise<boolean>;
}

View file

@ -11,6 +11,7 @@ import { dashboardsServiceFactory } from '../stub/dashboards';
import { labsServiceFactory } from './labs';
import { capabilitiesServiceFactory } from './capabilities';
import { PresentationUtilServices } from '..';
import { overlaysServiceFactory } from './overlays';
export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create';
export { PresentationUtilServices } from '..';
@ -25,6 +26,7 @@ export interface StorybookParams {
export const providers: PluginServiceProviders<PresentationUtilServices, StorybookParams> = {
capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
dashboards: new PluginServiceProvider(dashboardsServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
labs: new PluginServiceProvider(labsServiceFactory),
};

View file

@ -0,0 +1,147 @@
/*
* 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 { EuiConfirmModal, EuiFlyout } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Subject } from 'rxjs';
import {
MountPoint,
OverlayFlyoutOpenOptions,
OverlayModalConfirmOptions,
OverlayRef,
} from '../../../../../core/public';
import { MountWrapper } from '../../../../../core/public/utils';
import { PluginServiceFactory } from '../create';
import { PresentationOverlaysService } from '../overlays';
type OverlaysServiceFactory = PluginServiceFactory<PresentationOverlaysService>;
/**
* This code is a storybook stub version of src/core/public/overlays/overlay_service.ts
* Eventually, core services should have simple storybook representations, but until that happens
* it is necessary to recreate their functionality here.
*/
class GenericOverlayRef implements OverlayRef {
public readonly onClose: Promise<void>;
private closeSubject = new Subject<void>();
constructor() {
this.onClose = this.closeSubject.toPromise();
}
public close(): Promise<void> {
if (!this.closeSubject.closed) {
this.closeSubject.next();
this.closeSubject.complete();
}
return this.onClose;
}
}
export const overlaysServiceFactory: OverlaysServiceFactory = () => {
const flyoutDomElement = document.createElement('div');
const modalDomElement = document.createElement('div');
let activeFlyout: OverlayRef | null;
let activeModal: OverlayRef | null;
const cleanupModal = () => {
if (modalDomElement != null) {
unmountComponentAtNode(modalDomElement);
modalDomElement.innerHTML = '';
}
activeModal = null;
};
const cleanupFlyout = () => {
if (flyoutDomElement != null) {
unmountComponentAtNode(flyoutDomElement);
flyoutDomElement.innerHTML = '';
}
activeFlyout = null;
};
return {
openFlyout: (mount: MountPoint, options?: OverlayFlyoutOpenOptions) => {
if (activeFlyout) {
activeFlyout.close();
cleanupFlyout();
}
const flyout = new GenericOverlayRef();
flyout.onClose.then(() => {
if (activeFlyout === flyout) {
cleanupFlyout();
}
});
activeFlyout = flyout;
const onCloseFlyout = () => {
if (options?.onClose) {
options?.onClose(flyout);
return;
}
flyout.close();
};
render(
<EuiFlyout onClose={onCloseFlyout}>
<MountWrapper mount={mount} className="kbnOverlayMountWrapper" />
</EuiFlyout>,
flyoutDomElement
);
return flyout;
},
openConfirm: (message: MountPoint | string, options?: OverlayModalConfirmOptions) => {
if (activeModal) {
activeModal.close();
cleanupModal();
}
return new Promise((resolve, reject) => {
let resolved = false;
const closeModal = (confirmed: boolean) => {
resolved = true;
modal.close();
resolve(confirmed);
};
const modal = new GenericOverlayRef();
modal.onClose.then(() => {
if (activeModal === modal) {
cleanupModal();
}
// modal.close can be called when opening a new modal/confirm, so we need to resolve the promise in that case.
if (!resolved) {
closeModal(false);
}
});
activeModal = modal;
const props = {
...options,
children:
typeof message === 'string' ? (
message
) : (
<MountWrapper mount={message} className="kbnOverlayMountWrapper" />
),
onCancel: () => closeModal(false),
onConfirm: () => closeModal(true),
cancelButtonText: options?.cancelButtonText || '', // stub default cancel text
confirmButtonText: options?.confirmButtonText || '', // stub default confirm text
};
render(<EuiConfirmModal {...props} />, modalDomElement);
});
},
};
};

View file

@ -11,6 +11,7 @@ import { dashboardsServiceFactory } from './dashboards';
import { labsServiceFactory } from './labs';
import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create';
import { PresentationUtilServices } from '..';
import { overlaysServiceFactory } from './overlays';
export { dashboardsServiceFactory } from './dashboards';
export { capabilitiesServiceFactory } from './capabilities';
@ -18,6 +19,7 @@ export { capabilitiesServiceFactory } from './capabilities';
export const providers: PluginServiceProviders<PresentationUtilServices> = {
dashboards: new PluginServiceProvider(dashboardsServiceFactory),
capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
labs: new PluginServiceProvider(labsServiceFactory),
};

View file

@ -0,0 +1,32 @@
/*
* 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 {
MountPoint,
OverlayFlyoutOpenOptions,
OverlayModalConfirmOptions,
OverlayRef,
} from '../../../../../core/public';
import { PluginServiceFactory } from '../create';
import { PresentationOverlaysService } from '../overlays';
type OverlaysServiceFactory = PluginServiceFactory<PresentationOverlaysService>;
class StubRef implements OverlayRef {
public readonly onClose: Promise<void> = Promise.resolve();
public close(): Promise<void> {
return this.onClose;
}
}
export const overlaysServiceFactory: OverlaysServiceFactory = () => ({
openFlyout: (mount: MountPoint, options?: OverlayFlyoutOpenOptions) => new StubRef(),
openConfirm: (message: MountPoint | string, options?: OverlayModalConfirmOptions) =>
Promise.resolve(true),
});

View file

@ -2228,6 +2228,37 @@
enabled "2.0.x"
kuler "^2.0.0"
"@dnd-kit/accessibility@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde"
integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==
dependencies:
tslib "^2.0.0"
"@dnd-kit/core@^3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.1.1.tgz#c5ad6665931f5a51e74226220e58ac7514f3faf0"
integrity sha512-18YY5+1lTqJbGSg6JBSa/fjAOTUYAysFrQ5Ti8oppEPHFacQbC+owM51y2z2KN0LkDHBfGZKw2sFT7++ttwfpA==
dependencies:
"@dnd-kit/accessibility" "^3.0.0"
"@dnd-kit/utilities" "^2.0.0"
tslib "^2.0.0"
"@dnd-kit/sortable@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-4.0.0.tgz#81dd2b014a16527cf89602dc40060d9ee4dad352"
integrity sha512-teYVFy6mQG/u6F6CaGxAkzPfiNJvguFzWfJ/zonYQRxfANHX6QJ6GziMG9KON/Ae9Q2ODJP8vib+guWJrDXeGg==
dependencies:
"@dnd-kit/utilities" "^2.0.0"
tslib "^2.0.0"
"@dnd-kit/utilities@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97"
integrity sha512-bjs49yMNzMM+BYRsBUhTqhTk6HEvhuY3leFt6Em6NaYGgygaMbtGbbXof/UXBv7rqyyi0OkmBBnrCCcxqS2t/g==
dependencies:
tslib "^2.0.0"
"@dsherret/to-absolute-glob@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@dsherret/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz#1f6475dc8bd974cea07a2daf3864b317b1dd332c"