Skip to content

Commit

Permalink
[Embeddable Rebuild] [Controls] Add drag and drop to control group (#…
Browse files Browse the repository at this point in the history
…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.

![Jul-18-2024
16-24-32](https://github.com/user-attachments/assets/c8080af7-4176-473f-92ea-b13f8b1e5def)


### 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)
  • Loading branch information
Heenawter authored Jul 22, 2024
1 parent f19af22 commit fa0ef37
Show file tree
Hide file tree
Showing 10 changed files with 195 additions and 37 deletions.
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,25 +28,24 @@ 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;
hideEmptyDragHandle: boolean;
}) =>
isEditable ? (
<button
{...rest}
aria-label={i18n.translate('controls.controlGroup.ariaActions.moveControlButtonAction', {
defaultMessage: 'Move control {controlTitle}',
values: { controlTitle: controlTitle ?? '' },
Expand All @@ -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 (
Expand Down Expand Up @@ -102,19 +122,20 @@ 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', {
'controlFrameWrapper--grow': grow,
'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
Expand All @@ -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 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -33,7 +49,7 @@ import {
PublishesDataViews,
PublishesFilters,
PublishesTimeslice,
useStateFromPublishingSubject,
useBatchedPublishingSubjects,
} from '@kbn/presentation-publishing';

import { ControlRenderer } from '../control_renderer';
Expand All @@ -47,6 +63,7 @@ import {
ControlGroupSerializedState,
ControlGroupUnsavedChanges,
} from './types';
import { ControlClone } from '../components/control_clone';

export const getControlGroupEmbeddableFactory = (services: {
core: CoreStart;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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>
);
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@ 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,
order: initialControlPanelsState[key].order,
type: initialControlPanelsState[key].type,
}))
.sort((a, b) => (a.order > b.order ? 1 : -1))
.map(({ id, type }) => ({ id, type })) // filter out `order`
);

function untilControlLoaded(
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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} />;
});
})(),
Expand All @@ -79,5 +80,5 @@ export const ControlRenderer = <
[type]
);

return <ControlPanel<ApiType> Component={component} />;
return <ControlPanel<ApiType> Component={component} uuid={uuid} />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions examples/controls_example/public/react_controls/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,6 @@ export interface ControlPanelProps<
ApiType extends DefaultControlApi = DefaultControlApi,
PropsType extends {} = { className: string }
> {
uuid: string;
Component: PanelCompatibleComponent<ApiType, PropsType>;
}
Loading

0 comments on commit fa0ef37

Please sign in to comment.