mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Embeddable Rebuild] [Controls] Add drag and drop to control group (#188687)
## Summary > [!NOTE] > This PR has **no** user-facing changes - minus one small style change (which is a small selector simplification and doesn't actually change anything), all work is contained in the `examples` plugin. This PR adds drag and drop to the refactored control group in the `examples` plugin.  ### Checklist - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
parent
f19af22be6
commit
fa0ef37edf
10 changed files with 195 additions and 37 deletions
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiIcon } from '@elastic/eui';
|
||||
import {
|
||||
useBatchedOptionalPublishingSubjects,
|
||||
useStateFromPublishingSubject,
|
||||
} from '@kbn/presentation-publishing';
|
||||
|
||||
import { DefaultControlApi } from '../types';
|
||||
|
||||
/**
|
||||
* 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 = ({
|
||||
controlStyle,
|
||||
controlApi,
|
||||
}: {
|
||||
controlStyle: string;
|
||||
controlApi: DefaultControlApi;
|
||||
}) => {
|
||||
const width = useStateFromPublishingSubject(controlApi.width);
|
||||
const [panelTitle, defaultPanelTitle] = useBatchedOptionalPublishingSubjects(
|
||||
controlApi.panelTitle,
|
||||
controlApi.defaultPanelTitle
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexItem
|
||||
className={classNames('controlFrameCloneWrapper', {
|
||||
'controlFrameCloneWrapper--small': width === 'small',
|
||||
'controlFrameCloneWrapper--medium': width === 'medium',
|
||||
'controlFrameCloneWrapper--large': width === 'large',
|
||||
'controlFrameCloneWrapper--twoLine': controlStyle === 'twoLine',
|
||||
})}
|
||||
>
|
||||
{controlStyle === 'twoLine' ? (
|
||||
<EuiFormLabel>{panelTitle ?? defaultPanelTitle}</EuiFormLabel>
|
||||
) : undefined}
|
||||
<EuiFlexGroup responsive={false} gutterSize="none" className={'controlFrame__draggable'}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="grabHorizontal" className="controlFrame__dragHandle" />
|
||||
</EuiFlexItem>
|
||||
{controlStyle === 'oneLine' ? (
|
||||
<EuiFlexItem>
|
||||
<label className="controlFrameCloneWrapper__label">
|
||||
{panelTitle ?? defaultPanelTitle}
|
||||
</label>
|
||||
</EuiFlexItem>
|
||||
) : undefined}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
|
@ -9,6 +9,8 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
EuiFlexItem,
|
||||
EuiFormControlLayout,
|
||||
|
@ -26,18 +28,16 @@ import {
|
|||
} from '@kbn/presentation-publishing';
|
||||
import { FloatingActions } from '@kbn/presentation-util-plugin/public';
|
||||
|
||||
import { ControlError } from './control_error_component';
|
||||
import { ControlPanelProps, DefaultControlApi } from './types';
|
||||
import { ControlPanelProps, DefaultControlApi } from '../types';
|
||||
import { ControlError } from './control_error';
|
||||
|
||||
import './control_panel.scss';
|
||||
|
||||
/**
|
||||
* TODO: Handle dragging
|
||||
*/
|
||||
const DragHandle = ({
|
||||
isEditable,
|
||||
controlTitle,
|
||||
hideEmptyDragHandle,
|
||||
...rest // drag info is contained here
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
controlTitle?: string;
|
||||
|
@ -45,6 +45,7 @@ const DragHandle = ({
|
|||
}) =>
|
||||
isEditable ? (
|
||||
<button
|
||||
{...rest}
|
||||
aria-label={i18n.translate('controls.controlGroup.ariaActions.moveControlButtonAction', {
|
||||
defaultMessage: 'Move control {controlTitle}',
|
||||
values: { controlTitle: controlTitle ?? '' },
|
||||
|
@ -59,8 +60,27 @@ const DragHandle = ({
|
|||
|
||||
export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlApi>({
|
||||
Component,
|
||||
uuid,
|
||||
}: ControlPanelProps<ApiType>) => {
|
||||
const [api, setApi] = useState<ApiType | null>(null);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isOver,
|
||||
isDragging,
|
||||
index,
|
||||
isSorting,
|
||||
activeIndex,
|
||||
} = useSortable({
|
||||
id: uuid,
|
||||
});
|
||||
const style = {
|
||||
transition,
|
||||
transform: isSorting ? undefined : CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
const viewModeSubject = (() => {
|
||||
if (
|
||||
|
@ -102,8 +122,10 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
|
|||
|
||||
return (
|
||||
<EuiFlexItem
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
grow={grow}
|
||||
data-control-id={api?.uuid}
|
||||
data-control-id={uuid}
|
||||
data-test-subj={`control-frame`}
|
||||
data-render-complete="true"
|
||||
className={classNames('controlFrameWrapper', {
|
||||
|
@ -111,10 +133,9 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
|
|||
'controlFrameWrapper--small': width === 'small',
|
||||
'controlFrameWrapper--medium': width === 'medium',
|
||||
'controlFrameWrapper--large': width === 'large',
|
||||
// TODO: Add the following classes back once drag and drop logic is added
|
||||
// 'controlFrameWrapper-isDragging': isDragging,
|
||||
// 'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1),
|
||||
// 'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1),
|
||||
'controlFrameWrapper-isDragging': isDragging,
|
||||
'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (activeIndex ?? -1),
|
||||
'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (activeIndex ?? -1),
|
||||
})}
|
||||
>
|
||||
<FloatingActions
|
||||
|
@ -135,12 +156,15 @@ export const ControlPanel = <ApiType extends DefaultControlApi = DefaultControlA
|
|||
<EuiFormControlLayout
|
||||
fullWidth
|
||||
isLoading={Boolean(dataLoading)}
|
||||
className="controlFrame__formControlLayout"
|
||||
prepend={
|
||||
<>
|
||||
<DragHandle
|
||||
isEditable={isEditable}
|
||||
controlTitle={panelTitle || defaultPanelTitle}
|
||||
hideEmptyDragHandle={usingTwoLineLayout || Boolean(api?.CustomPrependComponent)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
|
||||
{api?.CustomPrependComponent ? (
|
|
@ -6,10 +6,26 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { EuiFlexGroup } from '@elastic/eui';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { EuiFlexGroup, EuiPanel } from '@elastic/eui';
|
||||
import {
|
||||
ControlGroupChainingSystem,
|
||||
ControlWidth,
|
||||
|
@ -33,7 +49,7 @@ import {
|
|||
PublishesDataViews,
|
||||
PublishesFilters,
|
||||
PublishesTimeslice,
|
||||
useStateFromPublishingSubject,
|
||||
useBatchedPublishingSubjects,
|
||||
} from '@kbn/presentation-publishing';
|
||||
|
||||
import { ControlRenderer } from '../control_renderer';
|
||||
|
@ -47,6 +63,7 @@ import {
|
|||
ControlGroupSerializedState,
|
||||
ControlGroupUnsavedChanges,
|
||||
} from './types';
|
||||
import { ControlClone } from '../components/control_clone';
|
||||
|
||||
export const getControlGroupEmbeddableFactory = (services: {
|
||||
core: CoreStart;
|
||||
|
@ -212,7 +229,31 @@ export const getControlGroupEmbeddableFactory = (services: {
|
|||
return {
|
||||
api,
|
||||
Component: () => {
|
||||
const controlsInOrder = useStateFromPublishingSubject(controlsManager.controlsInOrder$);
|
||||
const [controlsInOrder, controlStyle] = useBatchedPublishingSubjects(
|
||||
controlsManager.controlsInOrder$,
|
||||
labelPosition$
|
||||
);
|
||||
|
||||
/** Handle drag and drop */
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const onDragEnd = useCallback(
|
||||
({ over, active }: DragEndEvent) => {
|
||||
const oldIndex = active?.data.current?.sortable.index;
|
||||
const newIndex = over?.data.current?.sortable.index;
|
||||
if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) {
|
||||
controlsManager.controlsInOrder$.next(
|
||||
arrayMove([...controlsInOrder], oldIndex, newIndex)
|
||||
);
|
||||
}
|
||||
(document.activeElement as HTMLElement)?.blur(); // hide hover actions on drop; otherwise, they get stuck
|
||||
setDraggingId(null);
|
||||
},
|
||||
[controlsInOrder]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
@ -223,19 +264,47 @@ export const getControlGroupEmbeddableFactory = (services: {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup className={'controlGroup'} alignItems="center" gutterSize="s" wrap={true}>
|
||||
{controlsInOrder.map(({ id, type }) => (
|
||||
<ControlRenderer
|
||||
key={id}
|
||||
uuid={id}
|
||||
type={type}
|
||||
getParentApi={() => api}
|
||||
onApiAvailable={(controlApi) => {
|
||||
controlsManager.setControlApi(id, controlApi);
|
||||
<EuiPanel
|
||||
borderRadius="m"
|
||||
paddingSize="none"
|
||||
color={draggingId ? 'success' : 'transparent'}
|
||||
>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" wrap={true}>
|
||||
<DndContext
|
||||
onDragStart={({ active }) => setDraggingId(`${active.id}`)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={() => setDraggingId(null)}
|
||||
sensors={sensors}
|
||||
measuring={{
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.BeforeDragging,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
>
|
||||
<SortableContext items={controlsInOrder} strategy={rectSortingStrategy}>
|
||||
{controlsInOrder.map(({ id, type }) => (
|
||||
<ControlRenderer
|
||||
key={id}
|
||||
uuid={id}
|
||||
type={type}
|
||||
getParentApi={() => api}
|
||||
onApiAvailable={(controlApi) => {
|
||||
controlsManager.setControlApi(id, controlApi);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{draggingId ? (
|
||||
<ControlClone
|
||||
controlStyle={controlStyle}
|
||||
controlApi={controlsManager.getControlApi(draggingId)}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -19,12 +19,14 @@ import { omit } from 'lodash';
|
|||
import { ControlPanelsState, ControlPanelState } from './types';
|
||||
import { DefaultControlApi, DefaultControlState } from '../types';
|
||||
|
||||
type ControlOrder = Array<{ id: string; type: string }>;
|
||||
|
||||
export function initControlsManager(initialControlPanelsState: ControlPanelsState) {
|
||||
const children$ = new BehaviorSubject<{ [key: string]: DefaultControlApi }>({});
|
||||
const controlsPanelState: { [panelId: string]: DefaultControlState } = {
|
||||
...initialControlPanelsState,
|
||||
};
|
||||
const controlsInOrder$ = new BehaviorSubject<Array<{ id: string; type: string }>>(
|
||||
const controlsInOrder$ = new BehaviorSubject<ControlOrder>(
|
||||
Object.keys(initialControlPanelsState)
|
||||
.map((key) => ({
|
||||
id: key,
|
||||
|
@ -32,6 +34,7 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat
|
|||
type: initialControlPanelsState[key].type,
|
||||
}))
|
||||
.sort((a, b) => (a.order > b.order ? 1 : -1))
|
||||
.map(({ id, type }) => ({ id, type })) // filter out `order`
|
||||
);
|
||||
|
||||
function untilControlLoaded(
|
||||
|
@ -85,7 +88,7 @@ export function initControlsManager(initialControlPanelsState: ControlPanelsStat
|
|||
}
|
||||
|
||||
return {
|
||||
controlsInOrder$: controlsInOrder$ as PublishingSubject<Array<{ id: string; type: string }>>,
|
||||
controlsInOrder$,
|
||||
getControlApi,
|
||||
setControlApi: (uuid: string, controlApi: DefaultControlApi) => {
|
||||
children$.next({
|
||||
|
|
|
@ -13,7 +13,7 @@ import { StateComparators } from '@kbn/presentation-publishing';
|
|||
|
||||
import { getControlFactory } from './control_factory_registry';
|
||||
import { ControlGroupApi } from './control_group/types';
|
||||
import { ControlPanel } from './control_panel';
|
||||
import { ControlPanel } from './components/control_panel';
|
||||
import { ControlApiRegistration, DefaultControlApi, DefaultControlState } from './types';
|
||||
|
||||
/**
|
||||
|
@ -68,6 +68,7 @@ export const ControlRenderer = <
|
|||
return React.forwardRef<typeof api, { className: string }>((props, ref) => {
|
||||
// expose the api into the imperative handle
|
||||
useImperativeHandle(ref, () => api, []);
|
||||
|
||||
return <Component {...props} />;
|
||||
});
|
||||
})(),
|
||||
|
@ -79,5 +80,5 @@ export const ControlRenderer = <
|
|||
[type]
|
||||
);
|
||||
|
||||
return <ControlPanel<ApiType> Component={component} />;
|
||||
return <ControlPanel<ApiType> Component={component} uuid={uuid} />;
|
||||
};
|
||||
|
|
|
@ -185,7 +185,8 @@ export const getTimesliderControlFactory = (
|
|||
const viewModeSubject =
|
||||
getViewModeSubject(controlGroupApi) ?? new BehaviorSubject('view' as ViewMode);
|
||||
|
||||
const defaultControl = initializeDefaultControlApi(initialState);
|
||||
// overwrite the `width` attribute because time slider should always have a width of large
|
||||
const defaultControl = initializeDefaultControlApi({ ...initialState, width: 'large' });
|
||||
|
||||
const dashboardDataLoading$ =
|
||||
apiHasParentApi(controlGroupApi) && apiPublishesDataLoading(controlGroupApi.parentApi)
|
||||
|
|
|
@ -96,5 +96,6 @@ export interface ControlPanelProps<
|
|||
ApiType extends DefaultControlApi = DefaultControlApi,
|
||||
PropsType extends {} = { className: string }
|
||||
> {
|
||||
uuid: string;
|
||||
Component: PanelCompatibleComponent<ApiType, PropsType>;
|
||||
}
|
||||
|
|
|
@ -182,12 +182,7 @@ $controlMinWidth: $euiSize * 14;
|
|||
}
|
||||
|
||||
&-isDragging {
|
||||
.euiFormRow__labelWrapper {
|
||||
opacity: 0;
|
||||
}
|
||||
.controlFrame__formControlLayout {
|
||||
opacity: 0; // hide dragged control, while control is dragged its replaced with ControlClone component
|
||||
}
|
||||
opacity: 0; // hide dragged control, while control is dragged its replaced with ControlClone component
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue