/* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ import './drag_drop.scss'; import React, { useState, useContext, useEffect } from 'react'; import classNames from 'classnames'; import { keys, EuiScreenReaderOnly } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DragContext, DragContextState, ReorderContext, ReorderState } from './providers'; import { trackUiEvent } from '../lens_ui_telemetry'; export type DroppableEvent = React.DragEvent; /** * A function that handles a drop event. */ export type DropHandler = (item: unknown) => void; /** * A function that handles a dropTo event. */ export type DropToHandler = (dropTargetId: string) => void; /** * The base props to the DragDrop component. */ interface BaseProps { /** * The CSS class(es) for the root element. */ className?: string; /** * The event handler that fires when this item * is dropped to the one with passed id * */ dropTo?: DropToHandler; /** * The event handler that fires when an item * is dropped onto this DragDrop component. */ onDrop?: DropHandler; /** * The value associated with this item, if it is draggable. * If this component is dragged, this will be the value of * "dragging" in the root drag/drop context. */ value?: DragContextState['dragging']; /** * Optional comparison function to check whether a value is the dragged one */ isValueEqual?: (value1: unknown, value2: unknown) => boolean; /** * The React element which will be passed the draggable handlers */ children: React.ReactElement; /** * Indicates whether or not the currently dragged item * can be dropped onto this component. */ droppable?: boolean; /** * Additional class names to apply when another element is over the drop target */ getAdditionalClassesOnEnter?: () => string; /** * The optional test subject associated with this DOM element. */ 'data-test-subj'?: string; /** * items belonging to the same group that can be reordered */ itemsInGroup?: string[]; /** * Indicates to the user whether the currently dragged item * will be moved or copied */ dragType?: 'copy' | 'move' | 'reorder'; /** * Indicates to the user whether the drop action will * replace something that is existing or add a new one */ dropType?: 'add' | 'replace' | 'reorder'; } /** * The props for a draggable instance of that component. */ interface DraggableProps extends BaseProps { /** * Indicates whether or not this component is draggable. */ draggable: true; /** * The label, which should be attached to the drag event, and which will e.g. * be used if the element will be dropped into a text field. */ label: string; } /** * The props for a non-draggable instance of that component. */ interface NonDraggableProps extends BaseProps { /** * Indicates whether or not this component is draggable. */ draggable?: false; } type Props = DraggableProps | NonDraggableProps; /** * A draggable / droppable item. Items can be both draggable and droppable at * the same time. * * @param props */ export const DragDrop = (props: Props) => { const { dragging, setDragging } = useContext(DragContext); const { value, draggable, droppable, isValueEqual } = props; return ( ); }; const DragDropInner = React.memo(function DragDropInner( props: Props & DragContextState & { isDragging: boolean; isNotDroppable: boolean; } ) { const [state, setState] = useState({ isActive: false, dragEnterClassNames: '', }); const { className, onDrop, value, children, droppable, draggable, dragging, setDragging, isDragging, isNotDroppable, dragType = 'copy', dropType = 'add', dropTo, itemsInGroup, } = props; const isMoveDragging = isDragging && dragType === 'move'; const classes = classNames( 'lnsDragDrop', { 'lnsDragDrop-isDraggable': draggable, 'lnsDragDrop-isDragging': isDragging, 'lnsDragDrop-isHidden': isMoveDragging, 'lnsDragDrop-isDroppable': !draggable, 'lnsDragDrop-isDropTarget': droppable && dragType !== 'reorder', 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive && dragType !== 'reorder', 'lnsDragDrop-isNotDroppable': !isMoveDragging && isNotDroppable, 'lnsDragDrop-isReplacing': droppable && state.isActive && dropType === 'replace', }, state.dragEnterClassNames ); const dragStart = (e: DroppableEvent) => { // Setting stopPropgagation causes Chrome failures, so // we are manually checking if we've already handled this // in a nested child, and doing nothing if so... if (e.dataTransfer.getData('text')) { return; } // We only can reach the dragStart method if the element is draggable, // so we know we have DraggableProps if we reach this code. e.dataTransfer.setData('text', (props as DraggableProps).label); // Chrome causes issues if you try to render from within a // dragStart event, so we drop a setTimeout to avoid that. setState({ ...state }); setTimeout(() => setDragging(value)); }; const dragEnd = (e: DroppableEvent) => { e.stopPropagation(); setDragging(undefined); }; const dragOver = (e: DroppableEvent) => { if (!droppable) { return; } e.preventDefault(); // An optimization to prevent a bunch of React churn. if (!state.isActive) { setState({ ...state, isActive: true, dragEnterClassNames: props.getAdditionalClassesOnEnter ? props.getAdditionalClassesOnEnter() : '', }); } }; const dragLeave = () => { setState({ ...state, isActive: false, dragEnterClassNames: '' }); }; const drop = (e: DroppableEvent) => { e.preventDefault(); e.stopPropagation(); setState({ ...state, isActive: false, dragEnterClassNames: '' }); setDragging(undefined); if (onDrop && droppable) { trackUiEvent('drop_total'); onDrop(dragging); } }; const isReorderDragging = !!(dragging && itemsInGroup?.includes(dragging.id)); if ( draggable && itemsInGroup?.length && itemsInGroup.length > 1 && value?.id && dropTo && (!dragging || isReorderDragging) ) { const { label } = props as DraggableProps; return ( {children} ); } return React.cloneElement(children, { 'data-test-subj': props['data-test-subj'] || 'lnsDragDrop', className: classNames(children.props.className, classes, className), onDragOver: dragOver, onDragLeave: dragLeave, onDrop: drop, draggable, onDragEnd: dragEnd, onDragStart: dragStart, }); }); const getKeyboardReorderMessageMoved = ( itemLabel: string, position: number, prevPosition: number ) => i18n.translate('xpack.lens.dragDrop.elementMoved', { defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`, values: { itemLabel, position, prevPosition, }, }); const getKeyboardReorderMessageLifted = (itemLabel: string, position: number) => i18n.translate('xpack.lens.dragDrop.elementLifted', { defaultMessage: `You have lifted an item {itemLabel} in position {position}`, values: { itemLabel, position, }, }); const lnsLayerPanelDimensionMargin = 8; export const ReorderableDragDrop = ({ draggingProps, dropProps, children, label, dropTo, className, dataTestSubj, }: { draggingProps: { className: string; draggable: Props['draggable']; onDragEnd: (e: DroppableEvent) => void; onDragStart: (e: DroppableEvent) => void; isReorderDragging: boolean; }; dropProps: { onDrop: (e: DroppableEvent) => void; onDragOver: (e: DroppableEvent) => void; onDragLeave: () => void; dragging: DragContextState['dragging']; droppable: DraggableProps['droppable']; itemsInGroup: string[]; id: string; isActive: boolean; }; children: React.ReactElement; label: string; dropTo: DropToHandler; className?: string; dataTestSubj: string; }) => { const { itemsInGroup, dragging, id, droppable } = dropProps; const { reorderState, setReorderState } = useContext(ReorderContext); const { isReorderOn, reorderedItems, draggingHeight, direction, groupId } = reorderState; const currentIndex = itemsInGroup.indexOf(id); useEffect( () => setReorderState((s: ReorderState) => ({ ...s, isReorderOn: draggingProps.isReorderDragging, })), [draggingProps.isReorderDragging, setReorderState] ); return (