[Controls] Redux Toolkit and Embeddable Redux Wrapper (#114371)

Use new redux wrapper for control group management and upgrade all control group methods to use redux wrapper. Get order of controls from embeddable input, set up preconfigured story.

Co-authored-by: andreadelrio <delrio.andre@gmail.com>
This commit is contained in:
Devon Thomson 2021-10-13 20:11:53 -04:00 committed by GitHub
parent e5576d688d
commit f8cbbbb99f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1308 additions and 625 deletions

View file

@ -91,6 +91,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",
"@dnd-kit/core": "^3.1.1",
"@dnd-kit/sortable": "^4.0.0",

View file

@ -6,8 +6,8 @@
* Side Public License, v 1.
*/
import { InputControlFactory } from '../types';
import { ControlsService } from '../controls_service';
import { InputControlFactory } from '../../../services/controls';
import { flightFields, getEuiSelectableOptions } from './flights';
import { OptionsListEmbeddableFactory } from '../control_types/options_list';

View file

@ -10,9 +10,14 @@ 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';
import { pluginServices, registry } from '../../../services/storybook';
import { populateStorybookControlFactories } from './storybook_control_factories';
import { ControlGroupContainerFactory } from '../control_group/embeddable/control_group_container_factory';
import { ControlsPanels } from '../control_group/types';
import {
OptionsListEmbeddableInput,
OPTIONS_LIST_CONTROL,
} from '../control_types/options_list/options_list_embeddable';
export default {
title: 'Controls',
@ -20,17 +25,15 @@ export default {
decorators,
};
const ControlGroupStoryComponent = () => {
const EmptyControlGroupStoryComponent = ({ panels }: { panels?: ControlsPanels }) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
providers.overlays.start({});
const overlays = providers.overlays.getService();
const controlsServiceStub = getControlsServiceStub();
pluginServices.setRegistry(registry.start({}));
populateStorybookControlFactories(pluginServices.getServices().controls);
useEffect(() => {
(async () => {
const factory = new ControlGroupContainerFactory(controlsServiceStub, overlays);
const factory = new ControlGroupContainerFactory();
const controlGroupContainerEmbeddable = await factory.create({
inheritParentState: {
useQuery: false,
@ -38,16 +41,57 @@ const ControlGroupStoryComponent = () => {
useTimerange: false,
},
controlStyle: 'oneLine',
panels: panels ?? {},
id: uuid.v4(),
panels: {},
});
if (controlGroupContainerEmbeddable && embeddableRoot.current) {
controlGroupContainerEmbeddable.render(embeddableRoot.current);
}
})();
}, [embeddableRoot, controlsServiceStub, overlays]);
}, [embeddableRoot, panels]);
return <div ref={embeddableRoot} />;
};
export const ControlGroupStory = () => <ControlGroupStoryComponent />;
export const EmptyControlGroupStory = () => <EmptyControlGroupStoryComponent />;
export const ConfiguredControlGroupStory = () => (
<EmptyControlGroupStoryComponent
panels={{
optionsList1: {
type: OPTIONS_LIST_CONTROL,
order: 1,
width: 'auto',
explicitInput: {
title: 'Origin City',
id: 'optionsList1',
indexPattern: 'demo data flights',
field: 'OriginCityName',
defaultSelections: ['Toronto'],
} as OptionsListEmbeddableInput,
},
optionsList2: {
type: OPTIONS_LIST_CONTROL,
order: 2,
width: 'auto',
explicitInput: {
title: 'Destination City',
id: 'optionsList2',
indexPattern: 'demo data flights',
field: 'DestCityName',
defaultSelections: ['London'],
} as OptionsListEmbeddableInput,
},
optionsList3: {
type: OPTIONS_LIST_CONTROL,
order: 3,
width: 'auto',
explicitInput: {
title: 'Carrier',
id: 'optionsList3',
indexPattern: 'demo data flights',
field: 'Carrier',
} as OptionsListEmbeddableInput,
},
}}
/>
);

View file

@ -0,0 +1,27 @@
/*
* 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 { flightFields, getEuiSelectableOptions } from './flights';
import { OptionsListEmbeddableFactory } from '../control_types/options_list';
import { InputControlFactory, PresentationControlsService } from '../../../services/controls';
export const populateStorybookControlFactories = (
controlsServiceStub: PresentationControlsService
) => {
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);
};

View file

@ -1,22 +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';
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

@ -15,32 +15,28 @@ import {
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';
import { ControlGroupInput } from '../types';
import { EditControlButton } from '../editor/edit_control';
import { useChildEmbeddable } from '../../hooks/use_child_embeddable';
import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context';
import { ControlGroupStrings } from '../control_group_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) => {
export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: ControlFrameProps) => {
const embeddableRoot: React.RefObject<HTMLDivElement> = useMemo(() => React.createRef(), []);
const embeddable = useChildEmbeddable({ container, embeddableId });
const {
useEmbeddableSelector,
containerActions: { untilEmbeddableLoaded, removeEmbeddable },
} = useReduxContainerContext<ControlGroupInput>();
const { controlStyle } = useEmbeddableSelector((state) => state);
const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId });
const [title, setTitle] = useState<string>();
@ -61,18 +57,13 @@ export const ControlFrame = ({
'controlFrame--floatingActions-oneLine': !usingTwoLineLayout,
})}
>
<EuiToolTip content={ControlFrameStrings.floatingActions.getEditButtonTitle()}>
<EuiButtonIcon
aria-label={ControlFrameStrings.floatingActions.getEditButtonTitle()}
iconType="pencil"
onClick={onEdit}
color="text"
/>
<EuiToolTip content={ControlGroupStrings.floatingActions.getEditButtonTitle()}>
<EditControlButton embeddableId={embeddableId} />
</EuiToolTip>
<EuiToolTip content={ControlFrameStrings.floatingActions.getRemoveButtonTitle()}>
<EuiToolTip content={ControlGroupStrings.floatingActions.getRemoveButtonTitle()}>
<EuiButtonIcon
aria-label={ControlFrameStrings.floatingActions.getRemoveButtonTitle()}
onClick={onRemove}
aria-label={ControlGroupStrings.floatingActions.getRemoveButtonTitle()}
onClick={() => removeEmbeddable(embeddableId)}
iconType="cross"
color="danger"
/>

View file

@ -9,7 +9,7 @@
import '../control_group.scss';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import classNames from 'classnames';
import {
arrayMove,
@ -29,46 +29,51 @@ import {
LayoutMeasuringStrategy,
} from '@dnd-kit/core';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../../../services';
import { ControlGroupStrings } from '../control_group_strings';
import { ControlGroupContainer } from '../control_group_container';
import { CreateControlButton } from '../editor/create_control';
import { EditControlGroup } from '../editor/edit_control_group';
import { forwardAllContext } from '../editor/forward_all_context';
import { ControlClone, SortableControl } from './control_group_sortable_item';
import { OPTIONS_LIST_CONTROL } from '../../control_types/options_list/options_list_embeddable';
import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context';
import { controlGroupReducers } from '../state/control_group_reducers';
interface ControlGroupProps {
controlGroupContainer: ControlGroupContainer;
}
export const ControlGroup = () => {
// Presentation Services Context
const { overlays } = pluginServices.getHooks();
const { openFlyout } = overlays.useService();
export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => {
const [controlIds, setControlIds] = useState<string[]>([]);
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
typeof controlGroupReducers
>();
const {
useEmbeddableSelector,
useEmbeddableDispatch,
actions: { setControlOrders },
} = reduxContainerContext;
const dispatch = useEmbeddableDispatch();
// 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]);
// current state
const { panels } = useEmbeddableSelector((state) => state);
const idsInOrder = useMemo(
() =>
Object.values(panels)
.sort((a, b) => (a.order > b.order ? 1 : -1))
.reduce((acc, panel) => {
acc.push(panel.explicitInput.id);
return acc;
}, [] as string[]),
[panels]
);
const [draggingId, setDraggingId] = useState<string | null>(null);
const draggingIndex = useMemo(
() => (draggingId ? controlIds.indexOf(draggingId) : -1),
[controlIds, draggingId]
() => (draggingId ? idsInOrder.indexOf(draggingId) : -1),
[idsInOrder, draggingId]
);
const sensors = useSensors(
@ -78,10 +83,10 @@ export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => {
const onDragEnd = ({ over }: DragEndEvent) => {
if (over) {
const overIndex = controlIds.indexOf(over.id);
const overIndex = idsInOrder.indexOf(over.id);
if (draggingIndex !== overIndex) {
const newIndex = overIndex;
setControlIds((currentControlIds) => arrayMove(currentControlIds, draggingIndex, newIndex));
dispatch(setControlOrders({ ids: arrayMove([...idsInOrder], draggingIndex, newIndex) }));
}
}
setDraggingId(null);
@ -100,36 +105,26 @@ export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => {
strategy: LayoutMeasuringStrategy.Always,
}}
>
<SortableContext items={controlIds} strategy={rectSortingStrategy}>
<SortableContext items={idsInOrder} strategy={rectSortingStrategy}>
<EuiFlexGroup
className={classNames('controlGroup', { 'controlGroup-isDragging': draggingId })}
alignItems="center"
gutterSize={'m'}
wrap={true}
>
{controlIds.map((controlId, index) => (
{idsInOrder.map(
(controlId, index) =>
panels[controlId] && (
<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>
<DragOverlay>{draggingId ? <ControlClone draggingId={draggingId} /> : null}</DragOverlay>
</DndContext>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -141,19 +136,15 @@ export const ControlGroup = ({ controlGroupContainer }: ControlGroupProps) => {
iconType="gear"
color="text"
data-test-subj="inputControlsSortingButton"
onClick={controlGroupContainer.editControlGroup}
onClick={() =>
openFlyout(forwardAllContext(<EditControlGroup />, reduxContainerContext))
}
/>
</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
/>
<CreateControlButton />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -12,10 +12,9 @@ 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';
import { ControlGroupInput } from '../types';
import { ControlFrame, ControlFrameProps } from './control_frame_component';
import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context';
interface DragInfo {
isOver?: boolean;
@ -26,7 +25,6 @@ interface DragInfo {
export type SortableControlProps = ControlFrameProps & {
dragInfo: DragInfo;
width: ControlWidth;
};
/**
@ -60,22 +58,12 @@ export const SortableControl = (frameProps: SortableControlProps) => {
const SortableControlInner = forwardRef<
HTMLButtonElement,
SortableControlProps & { style: HTMLAttributes<HTMLButtonElement>['style'] }
>(
(
{
embeddableId,
controlStyle,
container,
dragInfo,
onRemove,
onEdit,
style,
width,
...dragHandleProps
},
dragHandleRef
) => {
>(({ embeddableId, dragInfo, style, ...dragHandleProps }, dragHandleRef) => {
const { isOver, isDragging, draggingIndex, index } = dragInfo;
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupInput>();
const { panels } = useEmbeddableSelector((state) => state);
const width = panels[embeddableId].width;
const dragHandle = (
<button ref={dragHandleRef} {...dragHandleProps} className="controlFrame--dragHandle">
@ -98,53 +86,39 @@ const SortableControlInner = forwardRef<
>
<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;
export const ControlClone = ({ draggingId }: { draggingId: string }) => {
const { useEmbeddableSelector } = useReduxContainerContext<ControlGroupInput>();
const { panels, controlStyle } = useEmbeddableSelector((state) => state);
const width = panels[draggingId].width;
const title = panels[draggingId].explicitInput.title;
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',
'controlFrame--cloneWrapper-twoLine': controlStyle === 'twoLine',
})}
>
{layout === 'twoLine' ? (
<EuiFormLabel>{embeddable?.getInput().title}</EuiFormLabel>
) : undefined}
{controlStyle === 'twoLine' ? <EuiFormLabel>{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}
{controlStyle === 'oneLine' ? <EuiFlexItem>{title}</EuiFlexItem> : undefined}
</EuiFlexGroup>
</EuiFlexItem>
);

View file

@ -1,224 +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 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

@ -48,13 +48,9 @@ export const ControlGroupStrings = {
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',
getDefaultWidthTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', {
defaultMessage: 'Default width',
}),
getLayoutTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', {
@ -64,23 +60,20 @@ export const ControlGroupStrings = {
i18n.translate('presentationUtil.inputControls.controlGroup.management.delete', {
defaultMessage: 'Delete control',
}),
getSetAllWidthsToDefaultTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', {
defaultMessage: 'Set all widths to default',
}),
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',
defaultMessage: 'Change control width',
}
),
getAutoWidthTitle: () =>
@ -117,21 +110,31 @@ export const ControlGroupStrings = {
defaultMessage: 'Two line layout',
}),
},
deleteAllControls: {
getTitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.title', {
defaultMessage: 'Delete all?',
}),
deleteControls: {
getDeleteAllTitle: () =>
i18n.translate(
'presentationUtil.inputControls.controlGroup.management.delete.deleteAllTitle',
{
defaultMessage: 'Delete all controls?',
}
),
getDeleteTitle: () =>
i18n.translate(
'presentationUtil.inputControls.controlGroup.management.delete.deleteTitle',
{
defaultMessage: 'Delete control?',
}
),
getSubtitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.sub', {
i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.sub', {
defaultMessage: 'Controls are not recoverable once removed.',
}),
getConfirm: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.confirm', {
i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.confirm', {
defaultMessage: 'Delete',
}),
getCancel: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll.cancel', {
i18n.translate('presentationUtil.inputControls.controlGroup.management.delete.cancel', {
defaultMessage: 'Cancel',
}),
},
@ -143,7 +146,7 @@ export const ControlGroupStrings = {
getSubtitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', {
defaultMessage:
'Discard changes to this control? Controls are not recoverable once removed.',
'Discard changes to this control? Changes are not recoverable once discardsd.',
}),
getConfirm: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', {
@ -161,7 +164,7 @@ export const ControlGroupStrings = {
}),
getSubtitle: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', {
defaultMessage: 'Discard new control? Controls are not recoverable once removed.',
defaultMessage: 'Discard new control? Controls are not recoverable once discarded.',
}),
getConfirm: () =>
i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', {
@ -173,4 +176,14 @@ export const ControlGroupStrings = {
}),
},
},
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

@ -46,7 +46,7 @@ interface ManageControlProps {
updateWidth: (newWidth: ControlWidth) => void;
}
export const ManageControlComponent = ({
export const ControlEditor = ({
controlEditorComponent,
removeControl,
updateTitle,

View file

@ -0,0 +1,158 @@
/*
* 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 {
EuiButtonIcon,
EuiButtonIconColor,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
} from '@elastic/eui';
import React, { useState, ReactElement } from 'react';
import { ControlGroupInput } from '../types';
import { ControlEditor } from './control_editor';
import { pluginServices } from '../../../../services';
import { forwardAllContext } from './forward_all_context';
import { OverlayRef } from '../../../../../../../core/public';
import { ControlGroupStrings } from '../control_group_strings';
import { InputControlInput } from '../../../../services/controls';
import { DEFAULT_CONTROL_WIDTH } from '../control_group_constants';
import { ControlWidth, IEditableControlFactory } from '../../types';
import { controlGroupReducers } from '../state/control_group_reducers';
import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public';
import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context';
export const CreateControlButton = () => {
// Presentation Services Context
const { overlays, controls } = pluginServices.getHooks();
const { getInputControlTypes, getControlFactory } = controls.useService();
const { openFlyout, openConfirm } = overlays.useService();
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
typeof controlGroupReducers
>();
const {
containerActions: { addNewEmbeddable },
actions: { setDefaultControlWidth },
useEmbeddableSelector,
useEmbeddableDispatch,
} = reduxContainerContext;
const dispatch = useEmbeddableDispatch();
// current state
const { defaultControlWidth } = useEmbeddableSelector((state) => state);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const createNewControl = async (type: string) => {
const factory = 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) => {
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 flyoutInstance = openFlyout(
forwardAllContext(
<ControlEditor
width={defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
updateTitle={(newTitle) => (inputToReturn.title = newTitle)}
updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))}
controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({
onChange: (partialInput) => {
inputToReturn = { ...inputToReturn, ...partialInput };
},
})}
onSave={() => {
resolve(inputToReturn);
flyoutInstance.close();
}}
onCancel={() => onCancel(flyoutInstance)}
/>,
reduxContainerContext
),
{
onClose: (flyout) => onCancel(flyout),
}
);
});
initialInputPromise.then(
async (explicitInput) => {
await addNewEmbeddable(type, explicitInput);
},
() => {} // swallow promise rejection because it can be part of normal flow
);
};
if (getInputControlTypes().length === 0) return null;
const commonButtonProps = {
iconType: 'plus',
color: 'text' as EuiButtonIconColor,
'data-test-subj': 'inputControlsSortingButton',
'aria-label': ControlGroupStrings.management.getManageButtonTitle(),
};
if (getInputControlTypes().length > 1) {
const items: ReactElement[] = [];
getInputControlTypes().forEach((type) => {
const factory = getControlFactory(type);
items.push(
<EuiContextMenuItem
key={type}
icon={factory.getIconType?.()}
onClick={() => {
setIsPopoverOpen(false);
createNewControl(type);
}}
>
{factory.getDisplayName()}
</EuiContextMenuItem>
);
});
const button = <EuiButtonIcon {...commonButtonProps} onClick={() => setIsPopoverOpen(true)} />;
return (
<EuiPopover
button={button}
isOpen={isPopoverOpen}
panelPaddingSize="none"
anchorPosition="downLeft"
closePopover={() => setIsPopoverOpen(false)}
>
<EuiContextMenuPanel size="s" items={items} />
</EuiPopover>
);
}
return (
<EuiButtonIcon
{...commonButtonProps}
onClick={() => createNewControl(getInputControlTypes()[0])}
/>
);
};

View file

@ -0,0 +1,127 @@
/*
* 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 { isEqual } from 'lodash';
import { EuiButtonIcon } from '@elastic/eui';
import React, { useEffect, useRef } from 'react';
import { ControlGroupInput } from '../types';
import { ControlEditor } from './control_editor';
import { IEditableControlFactory } from '../../types';
import { pluginServices } from '../../../../services';
import { forwardAllContext } from './forward_all_context';
import { OverlayRef } from '../../../../../../../core/public';
import { ControlGroupStrings } from '../control_group_strings';
import { controlGroupReducers } from '../state/control_group_reducers';
import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public';
import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context';
export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => {
// Presentation Services Context
const { overlays, controls } = pluginServices.getHooks();
const { getControlFactory } = controls.useService();
const { openFlyout, openConfirm } = overlays.useService();
// Redux embeddable container Context
const reduxContainerContext = useReduxContainerContext<
ControlGroupInput,
typeof controlGroupReducers
>();
const {
containerActions: { untilEmbeddableLoaded, removeEmbeddable, updateInputForChild },
actions: { setControlWidth },
useEmbeddableSelector,
useEmbeddableDispatch,
} = reduxContainerContext;
const dispatch = useEmbeddableDispatch();
// current state
const { panels } = useEmbeddableSelector((state) => state);
// keep up to date ref of latest panel state for comparison when closing editor.
const latestPanelState = useRef(panels[embeddableId]);
useEffect(() => {
latestPanelState.current = panels[embeddableId];
}, [panels, embeddableId]);
const editControl = async () => {
const panel = panels[embeddableId];
const factory = getControlFactory(panel.type);
const embeddable = await untilEmbeddableLoaded(embeddableId);
if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type);
let removed = false;
const onCancel = (ref: OverlayRef) => {
if (
removed ||
(isEqual(latestPanelState.current.explicitInput, panel.explicitInput) &&
isEqual(latestPanelState.current.width, panel.width))
) {
ref.close();
return;
}
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) {
updateInputForChild(embeddableId, panel.explicitInput);
dispatch(setControlWidth({ width: panel.width, embeddableId }));
ref.close();
}
});
};
const flyoutInstance = openFlyout(
forwardAllContext(
<ControlEditor
width={panel.width}
title={embeddable.getTitle()}
removeControl={() => {
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed) {
removeEmbeddable(embeddableId);
removed = true;
flyoutInstance.close();
}
});
}}
updateTitle={(newTitle) => updateInputForChild(embeddableId, { title: newTitle })}
controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({
onChange: (partialInput) => updateInputForChild(embeddableId, partialInput),
initialInput: embeddable.getInput(),
})}
onCancel={() => onCancel(flyoutInstance)}
onSave={() => flyoutInstance.close()}
updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))}
/>,
reduxContainerContext
),
{
onClose: (flyout) => onCancel(flyout),
}
);
};
return (
<EuiButtonIcon
aria-label={ControlGroupStrings.floatingActions.getEditButtonTitle()}
iconType="pencil"
onClick={() => editControl()}
color="text"
/>
);
};

View file

@ -0,0 +1,124 @@
/*
* 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 {
EuiTitle,
EuiSpacer,
EuiFormRow,
EuiFlexItem,
EuiFlexGroup,
EuiFlyoutBody,
EuiButtonGroup,
EuiButtonEmpty,
EuiFlyoutHeader,
} from '@elastic/eui';
import {
CONTROL_LAYOUT_OPTIONS,
CONTROL_WIDTH_OPTIONS,
DEFAULT_CONTROL_WIDTH,
} from '../control_group_constants';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../../../services';
import { ControlStyle, ControlWidth } from '../../types';
import { ControlGroupStrings } from '../control_group_strings';
import { controlGroupReducers } from '../state/control_group_reducers';
import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context';
export const EditControlGroup = () => {
const { overlays } = pluginServices.getHooks();
const { openConfirm } = overlays.useService();
const {
containerActions,
useEmbeddableSelector,
useEmbeddableDispatch,
actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth },
} = useReduxContainerContext<ControlGroupInput, typeof controlGroupReducers>();
const dispatch = useEmbeddableDispatch();
const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state);
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={controlStyle}
onChange={(newControlStyle) =>
dispatch(setControlStyle(newControlStyle as ControlStyle))
}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow label={ControlGroupStrings.management.getDefaultWidthTitle()}>
<EuiFlexGroup direction={'row'}>
<EuiFlexItem>
<EuiButtonGroup
color="primary"
idSelected={defaultControlWidth ?? DEFAULT_CONTROL_WIDTH}
legend={ControlGroupStrings.management.controlWidth.getWidthSwitchLegend()}
options={CONTROL_WIDTH_OPTIONS}
onChange={(newWidth: string) =>
dispatch(setDefaultControlWidth(newWidth as ControlWidth))
}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiButtonEmpty
onClick={() =>
dispatch(setAllControlWidths(defaultControlWidth ?? DEFAULT_CONTROL_WIDTH))
}
aria-label={'delete-all'}
iconType="returnKey"
size="s"
>
{ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer size="xl" />
<EuiButtonEmpty
onClick={() => {
if (!containerActions?.removeEmbeddable) return;
openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), {
confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(),
cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(),
title: ControlGroupStrings.management.deleteControls.getDeleteAllTitle(),
buttonColor: 'danger',
}).then((confirmed) => {
if (confirmed)
Object.keys(panels).forEach((panelId) =>
containerActions.removeEmbeddable(panelId)
);
});
}}
aria-label={'delete-all'}
iconType="trash"
color="danger"
flush="left"
size="s"
>
{ControlGroupStrings.management.getDeleteAllButtonTitle()}
</EuiButtonEmpty>
</EuiFlyoutBody>
</>
);
};

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 { Provider } from 'react-redux';
import { ReactElement } from 'react';
import React from 'react';
import { ControlGroupInput } from '../types';
import { pluginServices } from '../../../../services';
import { toMountPoint } from '../../../../../../kibana_react/public';
import { ReduxContainerContextServices } from '../../../redux_embeddables/types';
import { ReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context';
import { getManagedEmbeddablesStore } from '../../../redux_embeddables/generic_embeddable_store';
/**
* The overlays service creates its divs outside the flow of the component. This necessitates
* passing all context from the component to the flyout.
*/
export const forwardAllContext = (
component: ReactElement,
reduxContainerContext: ReduxContainerContextServices<ControlGroupInput>
) => {
const PresentationUtilProvider = pluginServices.getContextProvider();
return toMountPoint(
<Provider store={getManagedEmbeddablesStore()}>
<ReduxEmbeddableContext.Provider value={reduxContainerContext}>
<PresentationUtilProvider>{component}</PresentationUtilProvider>
</ReduxEmbeddableContext.Provider>
</Provider>
);
};

View file

@ -1,113 +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 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,77 @@
/*
* 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 {
InputControlEmbeddable,
InputControlInput,
InputControlOutput,
} from '../../../../services/controls';
import { pluginServices } from '../../../../services';
import { ControlGroupInput, ControlPanelState } from '../types';
import { ControlGroup } from '../component/control_group_component';
import { controlGroupReducers } from '../state/control_group_reducers';
import { Container, EmbeddableFactory } from '../../../../../../embeddable/public';
import { CONTROL_GROUP_TYPE, DEFAULT_CONTROL_WIDTH } from '../control_group_constants';
import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper';
export class ControlGroupContainer extends Container<InputControlInput, ControlGroupInput> {
public readonly type = CONTROL_GROUP_TYPE;
constructor(initialInput: ControlGroupInput, parent?: Container) {
super(
initialInput,
{ embeddableLoaded: {} },
pluginServices.getServices().controls.getControlFactory,
parent
);
}
protected createNewPanelState<TEmbeddableInput extends InputControlInput = InputControlInput>(
factory: EmbeddableFactory<InputControlInput, InputControlOutput, InputControlEmbeddable>,
partial: Partial<TEmbeddableInput> = {}
): ControlPanelState<TEmbeddableInput> {
const panelState = super.createNewPanelState(factory, partial);
const highestOrder = Object.values(this.getInput().panels).reduce((highestSoFar, panel) => {
if (panel.order > highestSoFar) highestSoFar = panel.order;
return highestSoFar;
}, 0);
return {
order: highestOrder + 1,
width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH,
...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 render(dom: HTMLElement) {
const PresentationUtilProvider = pluginServices.getContextProvider();
ReactDOM.render(
<PresentationUtilProvider>
<ReduxEmbeddableWrapper<ControlGroupInput>
embeddable={this}
reducers={controlGroupReducers}
>
<ControlGroup />
</ReduxEmbeddableWrapper>
</PresentationUtilProvider>,
dom
);
}
}

View file

@ -20,13 +20,11 @@ import {
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';
} from '../../../../../../embeddable/public';
import { ControlGroupInput } from '../types';
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,
@ -38,13 +36,6 @@ export class ControlGroupContainerFactory
{
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;
@ -67,6 +58,6 @@ export class ControlGroupContainerFactory
initialInput: ControlGroupInput,
parent?: Container
): Promise<ControlGroupContainer | ErrorEmbeddable> => {
return new ControlGroupContainer(initialInput, this.controlsService, this.overlays, parent);
return new ControlGroupContainer(initialInput, parent);
};
}

View file

@ -0,0 +1,48 @@
/*
* 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 { PayloadAction } from '@reduxjs/toolkit';
import { WritableDraft } from 'immer/dist/types/types-external';
import { ControlWidth } from '../../types';
import { ControlGroupInput } from '../types';
export const controlGroupReducers = {
setControlStyle: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<ControlGroupInput['controlStyle']>
) => {
state.controlStyle = action.payload;
},
setDefaultControlWidth: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<ControlGroupInput['defaultControlWidth']>
) => {
state.defaultControlWidth = action.payload;
},
setAllControlWidths: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<ControlWidth>
) => {
Object.keys(state.panels).forEach((panelId) => (state.panels[panelId].width = action.payload));
},
setControlWidth: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<{ width: ControlWidth; embeddableId: string }>
) => {
state.panels[action.payload.embeddableId].width = action.payload.width;
},
setControlOrders: (
state: WritableDraft<ControlGroupInput>,
action: PayloadAction<{ ids: string[] }>
) => {
action.payload.ids.forEach((id, index) => {
state.panels[id].order = index;
});
},
};

View file

@ -7,7 +7,8 @@
*/
import { PanelState, EmbeddableInput } from '../../../../../embeddable/public';
import { ControlStyle, ControlWidth, InputControlInput } from '../types';
import { InputControlInput } from '../../../services/controls';
import { ControlStyle, ControlWidth } from '../types';
export interface ControlGroupInput
extends EmbeddableInput,
@ -17,6 +18,7 @@ export interface ControlGroupInput
useQuery: boolean;
useTimerange: boolean;
};
defaultControlWidth?: ControlWidth;
controlStyle: ControlStyle;
panels: ControlsPanels;
}

View file

@ -16,7 +16,7 @@ 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 { InputControlInput, InputControlOutput } from '../../../../services/controls';
import { OptionsListComponent, OptionsListComponentState } from './options_list_component';
const toggleAvailableOptions = (

View file

@ -8,12 +8,12 @@
import { EmbeddableFactory } from '../../../../embeddable/public';
import {
ControlTypeRegistry,
InputControlEmbeddable,
ControlTypeRegistry,
InputControlFactory,
InputControlInput,
InputControlOutput,
} from './types';
InputControlInput,
} from '../../services/controls';
export class ControlsService {
private controlsFactoriesMap: ControlTypeRegistry = {};

View file

@ -6,14 +6,13 @@
* Side Public License, v 1.
*/
import { useEffect, useState } from 'react';
import { InputControlEmbeddable } from '../types';
import { IContainer } from '../../../../../embeddable/public';
import { InputControlEmbeddable } from '../../../services/controls';
export const useChildEmbeddable = ({
container,
untilEmbeddableLoaded,
embeddableId,
}: {
container: IContainer;
untilEmbeddableLoaded: (embeddableId: string) => Promise<InputControlEmbeddable>;
embeddableId: string;
}) => {
const [embeddable, setEmbeddable] = useState<InputControlEmbeddable>();
@ -21,14 +20,14 @@ export const useChildEmbeddable = ({
useEffect(() => {
let mounted = true;
(async () => {
const newEmbeddable = await container.untilEmbeddableLoaded(embeddableId);
const newEmbeddable = await untilEmbeddableLoaded(embeddableId);
if (!mounted) return;
setEmbeddable(newEmbeddable);
})();
return () => {
mounted = false;
};
}, [container, embeddableId]);
}, [untilEmbeddableLoaded, embeddableId]);
return embeddable;
};

View file

@ -6,47 +6,11 @@
* 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';
import { InputControlInput } from '../../services/controls';
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
*/

View file

@ -0,0 +1,40 @@
/*
* 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 { configureStore, EnhancedStore } from '@reduxjs/toolkit';
import { combineReducers, Reducer } from 'redux';
export interface InjectReducerProps<StateShape> {
key: string;
asyncReducer: Reducer<StateShape>;
}
type ManagedEmbeddableReduxStore = EnhancedStore & {
asyncReducers: { [key: string]: Reducer<unknown> };
injectReducer: <StateShape>(props: InjectReducerProps<StateShape>) => void;
};
const embeddablesStore = configureStore({ reducer: {} as { [key: string]: Reducer } });
const managedEmbeddablesStore = embeddablesStore as ManagedEmbeddableReduxStore;
managedEmbeddablesStore.asyncReducers = {};
managedEmbeddablesStore.injectReducer = <StateShape>({
key,
asyncReducer,
}: InjectReducerProps<StateShape>) => {
managedEmbeddablesStore.asyncReducers[key] = asyncReducer as Reducer<unknown>;
managedEmbeddablesStore.replaceReducer(
combineReducers({ ...managedEmbeddablesStore.asyncReducers })
);
};
/**
* A managed Redux store which can be used with multiple embeddables at once. When a new embeddable is created at runtime,
* all passed in reducers will be made into a slice, then combined into the store using combineReducers.
*/
export const getManagedEmbeddablesStore = () => managedEmbeddablesStore;

View file

@ -0,0 +1,73 @@
/*
* 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 { createContext, useContext } from 'react';
import {
GenericEmbeddableReducers,
ReduxContainerContextServices,
ReduxEmbeddableContextServices,
} from './types';
import { ContainerInput, EmbeddableInput } from '../../../../embeddable/public';
/**
* When creating the context, a generic EmbeddableInput as placeholder is used. This will later be cast to
* the generic type passed in by the useReduxEmbeddableContext or useReduxContainerContext hooks
**/
export const ReduxEmbeddableContext = createContext<
| ReduxEmbeddableContextServices<EmbeddableInput>
| ReduxContainerContextServices<EmbeddableInput>
| null
>(null);
/**
* A typed use context hook for embeddables that are not containers. it @returns an
* ReduxEmbeddableContextServices object typed to the generic inputTypes and ReducerTypes you pass in.
* Note that the reducer type is optional, but will be required to correctly infer the keys and payload
* types of your reducers. use `typeof MyReducers` here to retain them.
*/
export const useReduxEmbeddableContext = <
InputType extends EmbeddableInput = EmbeddableInput,
ReducerType extends GenericEmbeddableReducers<InputType> = GenericEmbeddableReducers<InputType>
>(): ReduxEmbeddableContextServices<InputType, ReducerType> => {
const context = useContext<ReduxEmbeddableContextServices<InputType, ReducerType>>(
ReduxEmbeddableContext as unknown as React.Context<
ReduxEmbeddableContextServices<InputType, ReducerType>
>
);
if (context == null) {
throw new Error(
'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.'
);
}
return context!;
};
/**
* A typed use context hook for embeddable containers. it @returns an
* ReduxContainerContextServices object typed to the generic inputTypes and ReducerTypes you pass in.
* Note that the reducer type is optional, but will be required to correctly infer the keys and payload
* types of your reducers. use `typeof MyReducers` here to retain them. It also includes a containerActions
* key which contains most of the commonly used container operations
*/
export const useReduxContainerContext = <
InputType extends ContainerInput = ContainerInput,
ReducerType extends GenericEmbeddableReducers<InputType> = GenericEmbeddableReducers<InputType>
>(): ReduxContainerContextServices<InputType, ReducerType> => {
const context = useContext<ReduxContainerContextServices<InputType, ReducerType>>(
ReduxEmbeddableContext as unknown as React.Context<
ReduxContainerContextServices<InputType, ReducerType>
>
);
if (context == null) {
throw new Error(
'useReduxEmbeddableContext must be used inside the useReduxEmbeddableContextProvider.'
);
}
return context!;
};

View file

@ -0,0 +1,162 @@
/*
* 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, { PropsWithChildren, useEffect, useMemo, useRef } from 'react';
import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { Draft } from 'immer/dist/types/types-external';
import { isEqual } from 'lodash';
import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit';
import {
IEmbeddable,
EmbeddableInput,
EmbeddableOutput,
IContainer,
} from '../../../../embeddable/public';
import { getManagedEmbeddablesStore } from './generic_embeddable_store';
import {
ReduxContainerContextServices,
ReduxEmbeddableContextServices,
ReduxEmbeddableWrapperProps,
} from './types';
import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context';
const getDefaultProps = <InputType extends EmbeddableInput = EmbeddableInput>(): Required<
Pick<ReduxEmbeddableWrapperProps<InputType>, 'diffInput'>
> => ({
diffInput: (a, b) => {
const differences: Partial<InputType> = {};
const allKeys = [...Object.keys(a), ...Object.keys(b)] as Array<keyof InputType>;
allKeys.forEach((key) => {
if (!isEqual(a[key], b[key])) differences[key] = a[key];
});
return differences;
},
});
const embeddableIsContainer = (
embeddable: IEmbeddable<EmbeddableInput, EmbeddableOutput>
): embeddable is IContainer => embeddable.isContainer;
/**
* Place this wrapper around the react component when rendering an embeddable to automatically set up
* redux for use with the embeddable via the supplied reducers. Any child components can then use ReduxEmbeddableContext
* or ReduxContainerContext to interface with the state of the embeddable.
*/
export const ReduxEmbeddableWrapper = <InputType extends EmbeddableInput = EmbeddableInput>(
props: PropsWithChildren<ReduxEmbeddableWrapperProps<InputType>>
) => {
const { embeddable, reducers, diffInput } = useMemo(
() => ({ ...getDefaultProps<InputType>(), ...props }),
[props]
);
const containerActions: ReduxContainerContextServices['containerActions'] | undefined =
useMemo(() => {
if (embeddableIsContainer(embeddable)) {
return {
untilEmbeddableLoaded: embeddable.untilEmbeddableLoaded.bind(embeddable),
updateInputForChild: embeddable.updateInputForChild.bind(embeddable),
removeEmbeddable: embeddable.removeEmbeddable.bind(embeddable),
addNewEmbeddable: embeddable.addNewEmbeddable.bind(embeddable),
};
}
return;
}, [embeddable]);
const reduxEmbeddableContext: ReduxEmbeddableContextServices | ReduxContainerContextServices =
useMemo(() => {
const key = `${embeddable.type}_${embeddable.id}`;
// A generic reducer used to update redux state when the embeddable input changes
const updateEmbeddableReduxState = (
state: Draft<InputType>,
action: PayloadAction<Partial<InputType>>
) => {
return { ...state, ...action.payload };
};
const slice = createSlice<InputType, SliceCaseReducers<InputType>>({
initialState: embeddable.getInput(),
name: key,
reducers: { ...reducers, updateEmbeddableReduxState },
});
const store = getManagedEmbeddablesStore();
store.injectReducer({
key,
asyncReducer: slice.reducer,
});
const useEmbeddableSelector: TypedUseSelectorHook<InputType> = () =>
useSelector((state: ReturnType<typeof store.getState>) => state[key]);
return {
useEmbeddableDispatch: () => useDispatch<typeof store.dispatch>(),
useEmbeddableSelector,
actions: slice.actions as ReduxEmbeddableContextServices['actions'],
containerActions,
};
}, [reducers, embeddable, containerActions]);
return (
<Provider store={getManagedEmbeddablesStore()}>
<ReduxEmbeddableContext.Provider value={reduxEmbeddableContext}>
<ReduxEmbeddableSync diffInput={diffInput} embeddable={embeddable}>
{props.children}
</ReduxEmbeddableSync>
</ReduxEmbeddableContext.Provider>
</Provider>
);
};
interface ReduxEmbeddableSyncProps<InputType extends EmbeddableInput = EmbeddableInput> {
diffInput: (a: InputType, b: InputType) => Partial<InputType>;
embeddable: IEmbeddable<InputType, EmbeddableOutput>;
}
/**
* This component uses the context from the embeddable wrapper to set up a generic two-way binding between the embeddable input and
* the redux store. a custom diffInput function can be provided, this function should always prioritize input A over input B.
*/
const ReduxEmbeddableSync = <InputType extends EmbeddableInput = EmbeddableInput>({
embeddable,
diffInput,
children,
}: PropsWithChildren<ReduxEmbeddableSyncProps<InputType>>) => {
const {
useEmbeddableSelector,
useEmbeddableDispatch,
actions: { updateEmbeddableReduxState },
} = useReduxEmbeddableContext<InputType>();
const dispatch = useEmbeddableDispatch();
const currentState = useEmbeddableSelector((state) => state);
const stateRef = useRef(currentState);
// When Embeddable Input changes, push differences to redux.
useEffect(() => {
embeddable.getInput$().subscribe(() => {
const differences = diffInput(embeddable.getInput(), stateRef.current);
if (differences && Object.keys(differences).length > 0) {
dispatch(updateEmbeddableReduxState(differences));
}
});
}, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]);
// When redux state changes, push differences to Embeddable Input.
useEffect(() => {
stateRef.current = currentState;
const differences = diffInput(currentState, embeddable.getInput());
if (differences && Object.keys(differences).length > 0) {
embeddable.updateInput(differences);
}
}, [currentState, diffInput, embeddable]);
return <>{children}</>;
};

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
ActionCreatorWithPayload,
AnyAction,
CaseReducer,
Dispatch,
PayloadAction,
} from '@reduxjs/toolkit';
import { TypedUseSelectorHook } from 'react-redux';
import {
EmbeddableInput,
EmbeddableOutput,
IContainer,
IEmbeddable,
} from '../../../../embeddable/public';
export interface GenericEmbeddableReducers<InputType> {
/**
* PayloadAction of type any is strategic here because we want to allow payloads of any shape in generic reducers.
* This type will be overridden to remove any and be type safe when returned by ReduxEmbeddableContextServices.
*/
[key: string]: CaseReducer<InputType, PayloadAction<any>>;
}
export interface ReduxEmbeddableWrapperProps<InputType extends EmbeddableInput = EmbeddableInput> {
embeddable: IEmbeddable<InputType, EmbeddableOutput>;
reducers: GenericEmbeddableReducers<InputType>;
diffInput?: (a: InputType, b: InputType) => Partial<InputType>;
}
/**
* This context allows components underneath the redux embeddable wrapper to get access to the actions, selector, dispatch, and containerActions.
*/
export interface ReduxEmbeddableContextServices<
InputType extends EmbeddableInput = EmbeddableInput,
ReducerType extends GenericEmbeddableReducers<InputType> = GenericEmbeddableReducers<InputType>
> {
actions: {
[Property in keyof ReducerType]: ActionCreatorWithPayload<
Parameters<ReducerType[Property]>[1]['payload']
>;
} & { updateEmbeddableReduxState: ActionCreatorWithPayload<Partial<InputType>> };
useEmbeddableSelector: TypedUseSelectorHook<InputType>;
useEmbeddableDispatch: () => Dispatch<AnyAction>;
}
export type ReduxContainerContextServices<
InputType extends EmbeddableInput = EmbeddableInput,
ReducerType extends GenericEmbeddableReducers<InputType> = GenericEmbeddableReducers<InputType>
> = ReduxEmbeddableContextServices<InputType, ReducerType> & {
containerActions: Pick<
IContainer,
'untilEmbeddableLoaded' | 'removeEmbeddable' | 'addNewEmbeddable' | 'updateInputForChild'
>;
};

View file

@ -17,6 +17,7 @@ const createStartContract = (coreStart: CoreStart): PresentationUtilPluginStart
const startContract: PresentationUtilPluginStart = {
ContextProvider: pluginServices.getContextProvider(),
labsService: pluginServices.getServices().labs,
controlsService: pluginServices.getServices().controls,
};
return startContract;
};

View file

@ -39,6 +39,7 @@ export class PresentationUtilPlugin
pluginServices.setRegistry(registry.start({ coreStart, startPlugins }));
return {
ContextProvider: pluginServices.getContextProvider(),
controlsService: pluginServices.getServices().controls,
labsService: pluginServices.getServices().labs,
};
}

View file

@ -0,0 +1,85 @@
/*
* 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';
/**
* Control embeddable types
*/
export type InputControlFactory = EmbeddableFactory<
InputControlInput,
InputControlOutput,
InputControlEmbeddable
>;
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>;
export interface ControlTypeRegistry {
[key: string]: InputControlFactory;
}
export interface PresentationControlsService {
registerInputControlType: (factory: InputControlFactory) => void;
getControlFactory: <
I extends InputControlInput = InputControlInput,
O extends InputControlOutput = InputControlOutput,
E extends InputControlEmbeddable<I, O> = InputControlEmbeddable<I, O>
>(
type: string
) => EmbeddableFactory<I, O, E>;
getInputControlTypes: () => string[];
}
export const getCommonControlsService = () => {
const controlsFactoriesMap: ControlTypeRegistry = {};
const registerInputControlType = (factory: InputControlFactory) => {
controlsFactoriesMap[factory.type] = factory;
};
const getControlFactory = <
I extends InputControlInput = InputControlInput,
O extends InputControlOutput = InputControlOutput,
E extends InputControlEmbeddable<I, O> = InputControlEmbeddable<I, O>
>(
type: string
) => {
return controlsFactoriesMap[type] as EmbeddableFactory<I, O, E>;
};
const getInputControlTypes = () => Object.keys(controlsFactoriesMap);
return {
registerInputControlType,
getControlFactory,
getInputControlTypes,
};
};

View file

@ -13,6 +13,7 @@ import { PresentationDashboardsService } from './dashboards';
import { PresentationLabsService } from './labs';
import { registry as stubRegistry } from './stub';
import { PresentationOverlaysService } from './overlays';
import { PresentationControlsService } from './controls';
export { PresentationCapabilitiesService } from './capabilities';
export { PresentationDashboardsService } from './dashboards';
@ -21,6 +22,7 @@ export interface PresentationUtilServices {
dashboards: PresentationDashboardsService;
capabilities: PresentationCapabilitiesService;
overlays: PresentationOverlaysService;
controls: PresentationControlsService;
labs: PresentationLabsService;
}
@ -31,5 +33,6 @@ export const getStubPluginServices = (): PresentationUtilPluginStart => {
return {
ContextProvider: pluginServices.getContextProvider(),
labsService: pluginServices.getServices().labs,
controlsService: pluginServices.getServices().controls,
};
};

View file

@ -5,3 +5,9 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginServiceFactory } from '../create';
import { getCommonControlsService, PresentationControlsService } from '../controls';
export type ControlsServiceFactory = PluginServiceFactory<PresentationControlsService>;
export const controlsServiceFactory = () => getCommonControlsService();

View file

@ -18,6 +18,7 @@ import {
} from '../create';
import { PresentationUtilPluginStartDeps } from '../../types';
import { PresentationUtilServices } from '..';
import { controlsServiceFactory } from './controls';
export { capabilitiesServiceFactory } from './capabilities';
export { dashboardsServiceFactory } from './dashboards';
@ -32,6 +33,7 @@ export const providers: PluginServiceProviders<
labs: new PluginServiceProvider(labsServiceFactory),
dashboards: new PluginServiceProvider(dashboardsServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
controls: new PluginServiceProvider(controlsServiceFactory),
};
export const registry = new PluginServiceRegistry<

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginServiceFactory } from '../create';
import { getCommonControlsService, PresentationControlsService } from '../controls';
export type ControlsServiceFactory = PluginServiceFactory<PresentationControlsService>;
export const controlsServiceFactory = () => getCommonControlsService();

View file

@ -6,12 +6,18 @@
* Side Public License, v 1.
*/
import { PluginServices, PluginServiceProviders, PluginServiceProvider } from '../create';
import {
PluginServices,
PluginServiceProviders,
PluginServiceProvider,
PluginServiceRegistry,
} from '../create';
import { dashboardsServiceFactory } from '../stub/dashboards';
import { labsServiceFactory } from './labs';
import { capabilitiesServiceFactory } from './capabilities';
import { PresentationUtilServices } from '..';
import { overlaysServiceFactory } from './overlays';
import { controlsServiceFactory } from './controls';
export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create';
export { PresentationUtilServices } from '..';
@ -27,7 +33,10 @@ export const providers: PluginServiceProviders<PresentationUtilServices, Storybo
capabilities: new PluginServiceProvider(capabilitiesServiceFactory),
dashboards: new PluginServiceProvider(dashboardsServiceFactory),
overlays: new PluginServiceProvider(overlaysServiceFactory),
controls: new PluginServiceProvider(controlsServiceFactory),
labs: new PluginServiceProvider(labsServiceFactory),
};
export const pluginServices = new PluginServices<PresentationUtilServices>();
export const registry = new PluginServiceRegistry<PresentationUtilServices>(providers);

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginServiceFactory } from '../create';
import { getCommonControlsService, PresentationControlsService } from '../controls';
export type ControlsServiceFactory = PluginServiceFactory<PresentationControlsService>;
export const controlsServiceFactory = () => getCommonControlsService();

View file

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

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { PresentationControlsService } from './services/controls';
import { PresentationLabsService } from './services/labs';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -14,6 +15,7 @@ export interface PresentationUtilPluginSetup {}
export interface PresentationUtilPluginStart {
ContextProvider: React.FC;
labsService: PresentationLabsService;
controlsService: PresentationControlsService;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface