[Lens] Add new drag and drop capabilities (#89745) (#90461)

This commit is contained in:
Marta Bondyra 2021-02-05 21:12:40 +01:00 committed by GitHub
parent f9c405e7b6
commit d0c5726730
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 2064 additions and 1117 deletions

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DragDrop droppable is reflected in the className 1`] = `
exports[`DragDrop defined dropType is reflected in the className 1`] = `
<button
class="lnsDragDrop lnsDragDrop-isDroppable lnsDragDrop-isDropTarget"
data-test-subj="lnsDragDrop"
@ -9,7 +9,7 @@ exports[`DragDrop droppable is reflected in the className 1`] = `
</button>
`;
exports[`DragDrop items that have droppable=false get special styling when another item is dragged 1`] = `
exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = `
<button
className="lnsDragDrop lnsDragDrop-isDroppable lnsDragDrop-isNotDroppable"
data-test-subj="lnsDragDrop"
@ -22,10 +22,12 @@ exports[`DragDrop items that have droppable=false get special styling when anoth
`;
exports[`DragDrop renders if nothing is being dragged 1`] = `
<div>
<div
data-test-subj="lnsDragDrop_draggable-hello"
>
<button
aria-describedby="lnsDragDrop-keyboardInstructions"
aria-label="dragging"
aria-label="hello"
class="euiScreenReaderOnly--showOnFocus lnsDragDrop__keyboardHandler"
data-test-subj="lnsDragDrop-keyboardHandler"
/>

View file

@ -0,0 +1,180 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { DropType } from '../types';
export interface HumanData {
label: string;
groupLabel?: string;
position?: number;
}
type AnnouncementFunction = (draggedElement: HumanData, dropElement: HumanData) => string;
interface CustomAnnouncementsType {
dropped: Partial<{ [dropType in DropType]: AnnouncementFunction }>;
selectedTarget: Partial<{ [dropType in DropType]: AnnouncementFunction }>;
}
const selectedTargetReplace = (
{ label }: HumanData,
{ label: dropLabel, groupLabel, position }: HumanData
) =>
i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.replace', {
defaultMessage: `Selected {dropLabel} in {groupLabel} group at position {position}. Press space or enter to replace {dropLabel} with {label}.`,
values: {
label,
dropLabel,
groupLabel,
position,
},
});
const droppedReplace = ({ label }: HumanData, { label: dropLabel, groupLabel }: HumanData) =>
i18n.translate('xpack.lens.dragDrop.announce.duplicated.replace', {
defaultMessage:
'You have dropped the item. You have replaced {dropLabel} with {label} in {groupLabel} group.',
values: {
label,
dropLabel,
groupLabel,
},
});
export const announcements: CustomAnnouncementsType = {
selectedTarget: {
reorder: ({ label, position: prevPosition }, { position }) =>
prevPosition === position
? i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reorderedBack', {
defaultMessage: `You have moved the item {label} back to position {prevPosition}`,
values: {
label,
prevPosition,
},
})
: i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.reordered', {
defaultMessage: `You have moved the item {label} from position {prevPosition} to position {position}`,
values: {
label,
position,
prevPosition,
},
}),
duplicate_in_group: ({ label }, { label: dropLabel, groupLabel, position }) =>
i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.duplicated', {
defaultMessage: `Selected {dropLabel} in {groupLabel} group at position {position}. Press space or enter to duplicate {label}.`,
values: {
label,
dropLabel,
groupLabel,
position,
},
}),
field_replace: selectedTargetReplace,
replace_compatible: selectedTargetReplace,
replace_incompatible: selectedTargetReplace,
},
dropped: {
reorder: ({ label, position: prevPosition }, { position }) =>
i18n.translate('xpack.lens.dragDrop.announce.dropped.reordered', {
defaultMessage:
'You have dropped the item {label}. You have moved the item from position {prevPosition} to positon {position}',
values: {
label,
position,
prevPosition,
},
}),
duplicate_in_group: ({ label }, { groupLabel, position }) =>
i18n.translate('xpack.lens.dragDrop.announce.dropped.duplicated', {
defaultMessage:
'You have dropped the item. You have duplicated {label} in {groupLabel} group at position {position}',
values: {
label,
groupLabel,
position,
},
}),
field_replace: droppedReplace,
replace_compatible: droppedReplace,
replace_incompatible: droppedReplace,
},
};
const defaultAnnouncements = {
lifted: ({ label }: HumanData) =>
i18n.translate('xpack.lens.dragDrop.announce.lifted', {
defaultMessage: `Lifted {label}`,
values: {
label,
},
}),
cancelled: () =>
i18n.translate('xpack.lens.dragDrop.announce.cancelled', {
defaultMessage: 'Movement cancelled',
}),
noTarget: () => {
return i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.noSelected', {
defaultMessage: `No target selected. Use arrow keys to select a target.`,
});
},
dropped: (
{ label }: HumanData,
{ groupLabel: dropGroupLabel, position, label: dropLabel }: HumanData
) =>
dropGroupLabel && position
? i18n.translate('xpack.lens.dragDrop.announce.droppedDefault', {
defaultMessage:
'You have dropped {label} to {dropLabel} in {dropGroupLabel} group at position {position}',
values: {
label,
dropGroupLabel,
position,
dropLabel,
},
})
: i18n.translate('xpack.lens.dragDrop.announce.droppedNoPosition', {
defaultMessage: 'You have dropped {label} to {dropLabel}',
values: {
label,
dropLabel,
},
}),
selectedTarget: (
{ label }: HumanData,
{ label: dropLabel, groupLabel: dropGroupLabel, position }: HumanData
) => {
return dropGroupLabel && position
? i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.default', {
defaultMessage: `Selected {dropLabel} in {dropGroupLabel} group at position {position}. Press space or enter to drop {label}`,
values: {
dropLabel,
label,
dropGroupLabel,
position,
},
})
: i18n.translate('xpack.lens.dragDrop.announce.selectedTarget.defaultNoPosition', {
defaultMessage: `Selected {dropLabel}. Press space or enter to drop {label}`,
values: {
dropLabel,
label,
},
});
},
};
export const announce = {
...defaultAnnouncements,
dropped: (draggedElement: HumanData, dropElement: HumanData, type?: DropType) =>
(type && announcements.dropped?.[type]?.(draggedElement, dropElement)) ||
defaultAnnouncements.dropped(draggedElement, dropElement),
selectedTarget: (draggedElement: HumanData, dropElement: HumanData, type?: DropType) =>
(type && announcements.selectedTarget?.[type]?.(draggedElement, dropElement)) ||
defaultAnnouncements.selectedTarget(draggedElement, dropElement),
};

View file

@ -2,6 +2,7 @@
@import '../mixins';
.lnsDragDrop {
user-select: none;
transition: background-color $euiAnimSpeedFast ease-in-out, border-color $euiAnimSpeedFast ease-in-out;
}
@ -27,6 +28,7 @@
// Drop area when there's an item being dragged
.lnsDragDrop-isDropTarget {
@include lnsDroppable;
@include lnsDroppableActive;
}
@ -52,6 +54,15 @@
}
}
.lnsDragDrop-notCompatible {
background-color: $euiColorHighlight;
border: $euiBorderWidthThin dashed $euiBorderColor;
&.lnsDragDrop-isActiveDropTarget {
background-color: rgba(251, 208, 17, .25);
border-color: $euiColorVis5;
}
}
.lnsDragDrop__container {
position: relative;
}
@ -73,6 +84,7 @@
transform: translateY(0);
transition: transform $euiAnimSpeedFast ease-in-out;
position: relative;
z-index: $euiZLevel1;
}
// Draggable item when it is moving

View file

@ -7,38 +7,40 @@
import React from 'react';
import { render, mount } from 'enzyme';
import { DragDrop, DropHandler } from './drag_drop';
import { DragDrop } from './drag_drop';
import {
ChildDragDropProvider,
DragContextState,
ReorderProvider,
DragDropIdentifier,
ActiveDropTarget,
DropTargets,
} from './providers';
import { act } from 'react-dom/test-utils';
import { DropType } from '../types';
jest.useFakeTimers();
const defaultContext = {
dragging: undefined,
setDragging: jest.fn(),
setActiveDropTarget: () => {},
activeDropTarget: undefined,
keyboardMode: false,
setKeyboardMode: () => {},
setA11yMessage: jest.fn(),
};
const dataTransfer = {
setData: jest.fn(),
getData: jest.fn(),
};
describe('DragDrop', () => {
const value = { id: '1', label: 'hello' };
const defaultContext = {
dragging: undefined,
setDragging: jest.fn(),
setActiveDropTarget: jest.fn(),
activeDropTarget: undefined,
keyboardMode: false,
setKeyboardMode: () => {},
setA11yMessage: jest.fn(),
registerDropTarget: jest.fn(),
};
const value = { id: '1', humanData: { label: 'hello' } };
test('renders if nothing is being dragged', () => {
const component = render(
<DragDrop value={value} draggable label="dragging">
<DragDrop value={value} draggable order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
);
@ -46,10 +48,10 @@ describe('DragDrop', () => {
expect(component).toMatchSnapshot();
});
test('dragover calls preventDefault if droppable is true', () => {
test('dragover calls preventDefault if dropType is defined', () => {
const preventDefault = jest.fn();
const component = mount(
<DragDrop droppable value={value}>
<DragDrop dropType="field_add" value={value} order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
);
@ -59,10 +61,10 @@ describe('DragDrop', () => {
expect(preventDefault).toBeCalled();
});
test('dragover does not call preventDefault if droppable is false', () => {
test('dragover does not call preventDefault if dropType is undefined', () => {
const preventDefault = jest.fn();
const component = mount(
<DragDrop value={value}>
<DragDrop value={value} order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
);
@ -75,9 +77,15 @@ describe('DragDrop', () => {
test('dragstart sets dragging in the context', async () => {
const setDragging = jest.fn();
const setA11yMessage = jest.fn();
const component = mount(
<ChildDragDropProvider {...defaultContext} dragging={value} setDragging={setDragging}>
<DragDrop value={value} draggable={true} label="drag label">
<ChildDragDropProvider
{...defaultContext}
dragging={value}
setDragging={setDragging}
setA11yMessage={setA11yMessage}
>
<DragDrop value={value} draggable={true} order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
</ChildDragDropProvider>
@ -87,8 +95,9 @@ describe('DragDrop', () => {
jest.runAllTimers();
expect(dataTransfer.setData).toBeCalledWith('text', 'drag label');
expect(dataTransfer.setData).toBeCalledWith('text', 'hello');
expect(setDragging).toBeCalledWith(value);
expect(setA11yMessage).toBeCalledWith('Lifted hello');
});
test('drop resets all the things', async () => {
@ -100,10 +109,10 @@ describe('DragDrop', () => {
const component = mount(
<ChildDragDropProvider
{...defaultContext}
dragging={{ id: '2', label: 'hi' }}
dragging={{ id: '2', humanData: { label: 'label1' } }}
setDragging={setDragging}
>
<DragDrop onDrop={onDrop} droppable={true} value={value}>
<DragDrop onDrop={onDrop} dropType="field_add" value={value} order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
</ChildDragDropProvider>
@ -116,18 +125,22 @@ describe('DragDrop', () => {
expect(preventDefault).toBeCalled();
expect(stopPropagation).toBeCalled();
expect(setDragging).toBeCalledWith(undefined);
expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }, { id: '1', label: 'hello' });
expect(onDrop).toBeCalledWith({ id: '2', humanData: { label: 'label1' } }, 'field_add');
});
test('drop function is not called on droppable=false', async () => {
test('drop function is not called on dropType undefined', async () => {
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const setDragging = jest.fn();
const onDrop = jest.fn();
const component = mount(
<ChildDragDropProvider {...defaultContext} dragging={{ id: 'hi' }} setDragging={setDragging}>
<DragDrop onDrop={onDrop} droppable={false} value={value}>
<ChildDragDropProvider
{...defaultContext}
dragging={{ id: 'hi', humanData: { label: 'label1' } }}
setDragging={setDragging}
>
<DragDrop onDrop={onDrop} dropType={undefined} value={value} order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
</ChildDragDropProvider>
@ -143,14 +156,15 @@ describe('DragDrop', () => {
expect(onDrop).not.toHaveBeenCalled();
});
test('droppable is reflected in the className', () => {
test('defined dropType is reflected in the className', () => {
const component = render(
<DragDrop
onDrop={(x: unknown) => {
throw x;
}}
droppable
dropType="field_add"
value={value}
order={[2, 0, 1, 0]}
>
<button>Hello!</button>
</DragDrop>
@ -159,13 +173,18 @@ describe('DragDrop', () => {
expect(component).toMatchSnapshot();
});
test('items that have droppable=false get special styling when another item is dragged', () => {
test('items that has dropType=undefined get special styling when another item is dragged', () => {
const component = mount(
<ChildDragDropProvider {...defaultContext} dragging={value}>
<DragDrop value={value} draggable={true} label="a">
<DragDrop value={value} draggable={true} order={[2, 0, 1, 0]}>
<button>Hello!</button>
</DragDrop>
<DragDrop onDrop={(x: unknown) => {}} droppable={false} value={{ id: '2' }}>
<DragDrop
order={[2, 0, 1, 0]}
onDrop={(x: unknown) => {}}
dropType={undefined}
value={{ id: '2', humanData: { label: 'label2' } }}
>
<button>Hello!</button>
</DragDrop>
</ChildDragDropProvider>
@ -175,30 +194,39 @@ describe('DragDrop', () => {
});
test('additional styles are reflected in the className until drop', () => {
let dragging: { id: '1' } | undefined;
const getAdditionalClasses = jest.fn().mockReturnValue('additional');
let dragging: { id: '1'; humanData: { label: 'label1' } } | undefined;
const getAdditionalClassesOnEnter = jest.fn().mockReturnValue('additional');
const getAdditionalClassesOnDroppable = jest.fn().mockReturnValue('droppable');
const setA11yMessage = jest.fn();
let activeDropTarget;
const component = mount(
<ChildDragDropProvider
{...defaultContext}
dragging={dragging}
setA11yMessage={setA11yMessage}
setDragging={() => {
dragging = { id: '1' };
dragging = { id: '1', humanData: { label: 'label1' } };
}}
setActiveDropTarget={(val) => {
activeDropTarget = { activeDropTarget: val };
}}
activeDropTarget={activeDropTarget}
>
<DragDrop value={{ label: 'ignored', id: '3' }} draggable={true} label="a">
<DragDrop
value={{ id: '3', humanData: { label: 'ignored' } }}
draggable={true}
order={[2, 0, 1, 0]}
>
<button>Hello!</button>
</DragDrop>
<DragDrop
order={[2, 0, 1, 0]}
value={value}
onDrop={(x: unknown) => {}}
droppable
getAdditionalClassesOnEnter={getAdditionalClasses}
dropType="field_add"
getAdditionalClassesOnEnter={getAdditionalClassesOnEnter}
getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable}
>
<button>Hello!</button>
</DragDrop>
@ -210,6 +238,7 @@ describe('DragDrop', () => {
.first()
.simulate('dragstart', { dataTransfer });
jest.runAllTimers();
expect(setA11yMessage).toBeCalledWith('Lifted ignored');
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover');
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop');
@ -217,8 +246,9 @@ describe('DragDrop', () => {
});
test('additional enter styles are reflected in the className until dragleave', () => {
let dragging: { id: '1' } | undefined;
let dragging: { id: '1'; humanData: { label: 'label1' } } | undefined;
const getAdditionalClasses = jest.fn().mockReturnValue('additional');
const getAdditionalClassesOnDroppable = jest.fn().mockReturnValue('droppable');
const setActiveDropTarget = jest.fn();
const component = mount(
@ -226,7 +256,7 @@ describe('DragDrop', () => {
setA11yMessage={jest.fn()}
dragging={dragging}
setDragging={() => {
dragging = { id: '1' };
dragging = { id: '1', humanData: { label: 'label1' } };
}}
setActiveDropTarget={setActiveDropTarget}
activeDropTarget={
@ -234,15 +264,22 @@ describe('DragDrop', () => {
}
keyboardMode={false}
setKeyboardMode={(keyboardMode) => true}
registerDropTarget={jest.fn()}
>
<DragDrop value={{ label: 'ignored', id: '3' }} draggable={true} label="a">
<DragDrop
value={{ id: '3', humanData: { label: 'ignored' } }}
draggable={true}
order={[2, 0, 1, 0]}
>
<button>Hello!</button>
</DragDrop>
<DragDrop
order={[2, 0, 1, 0]}
value={value}
onDrop={(x: unknown) => {}}
droppable
dropType="field_add"
getAdditionalClassesOnEnter={getAdditionalClasses}
getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable}
>
<button>Hello!</button>
</DragDrop>
@ -257,19 +294,137 @@ describe('DragDrop', () => {
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover');
expect(component.find('.additional')).toHaveLength(1);
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave');
expect(setActiveDropTarget).toBeCalledWith(undefined);
});
test('Keyboard navigation: User receives proper drop Targets highlighted when pressing arrow keys', () => {
const onDrop = jest.fn();
const setActiveDropTarget = jest.fn();
const setA11yMessage = jest.fn();
const items = [
{
draggable: true,
value: {
id: '1',
humanData: { label: 'label1', position: 1 },
},
children: '1',
order: [2, 0, 0, 0],
},
{
draggable: true,
dragType: 'move' as 'copy' | 'move',
value: {
id: '2',
humanData: { label: 'label2', position: 1 },
},
onDrop,
dropType: 'move_compatible' as DropType,
order: [2, 0, 1, 0],
},
{
draggable: true,
dragType: 'move' as 'copy' | 'move',
value: {
id: '3',
humanData: { label: 'label3', position: 1 },
},
onDrop,
dropType: 'replace_compatible' as DropType,
order: [2, 0, 2, 0],
},
{
draggable: true,
dragType: 'move' as 'copy' | 'move',
value: {
id: '4',
humanData: { label: 'label4', position: 2 },
},
order: [2, 0, 2, 1],
},
];
const component = mount(
<ChildDragDropProvider
{...{
...defaultContext,
dragging: items[0].value,
setActiveDropTarget,
setA11yMessage,
activeDropTarget: {
activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' },
dropTargetsByOrder: {
'2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' },
'2,0,2,0': { ...items[2].value, onDrop, dropType: 'replace_compatible' },
},
},
keyboardMode: true,
}}
>
{items.map((props) => (
<DragDrop {...props} key={props.value.id}>
<div />
</DragDrop>
))}
</ChildDragDropProvider>
);
const keyboardHandler = component
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]')
.first()
.simulate('focus');
act(() => {
keyboardHandler.simulate('keydown', { key: 'ArrowRight' });
expect(setActiveDropTarget).toBeCalledWith({
...items[2].value,
onDrop,
dropType: items[2].dropType,
});
keyboardHandler.simulate('keydown', { key: 'Enter' });
expect(setA11yMessage).toBeCalledWith(
'Selected label3 in group at position 1. Press space or enter to replace label3 with label1.'
);
expect(setActiveDropTarget).toBeCalledWith(undefined);
expect(onDrop).toBeCalledWith(
{ humanData: { label: 'label1', position: 1 }, id: '1' },
'move_compatible'
);
});
});
describe('reordering', () => {
const onDrop = jest.fn();
const items = [
{
id: '1',
humanData: { label: 'label1', position: 1 },
onDrop,
dropType: 'reorder' as DropType,
},
{
id: '2',
humanData: { label: 'label2', position: 2 },
onDrop,
dropType: 'reorder' as DropType,
},
{
id: '3',
humanData: { label: 'label3', position: 3 },
onDrop,
dropType: 'reorder' as DropType,
},
];
const mountComponent = (
dragContext: Partial<DragContextState> | undefined,
onDrop: DropHandler = jest.fn()
onDropHandler?: () => void
) => {
let dragging = dragContext?.dragging;
let keyboardMode = !!dragContext?.keyboardMode;
let activeDropTarget = dragContext?.activeDropTarget;
const setA11yMessage = jest.fn();
const registerDropTarget = jest.fn();
const baseContext = {
dragging,
setDragging: (val?: DragDropIdentifier) => {
@ -280,70 +435,51 @@ describe('DragDrop', () => {
keyboardMode = mode;
}),
setActiveDropTarget: (target?: DragDropIdentifier) => {
activeDropTarget = { activeDropTarget: target } as ActiveDropTarget;
activeDropTarget = { activeDropTarget: target } as DropTargets;
},
activeDropTarget,
setA11yMessage: jest.fn(),
setA11yMessage,
registerDropTarget,
};
const dragDropSharedProps = {
draggable: true,
dragType: 'move' as 'copy' | 'move',
dropType: 'reorder' as DropType,
reorderableGroup: items.map(({ id }) => ({ id })),
onDrop: onDropHandler || onDrop,
};
return mount(
<ChildDragDropProvider {...baseContext} {...dragContext}>
<ReorderProvider id="groupId">
<DragDrop
label="1"
draggable
droppable={false}
dragType="reorder"
dropType="reorder"
reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]}
value={{ id: '1' }}
onDrop={onDrop}
{...dragDropSharedProps}
value={items[0]}
dropType={undefined}
order={[2, 0, 0]}
>
<span>1</span>
</DragDrop>
<DragDrop
label="2"
draggable
droppable
dragType="reorder"
dropType="reorder"
reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]}
value={{
id: '2',
}}
onDrop={onDrop}
>
<DragDrop {...dragDropSharedProps} value={items[1]} order={[2, 0, 1]}>
<span>2</span>
</DragDrop>
<DragDrop
label="3"
draggable
droppable
dragType="reorder"
dropType="reorder"
reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]}
value={{
id: '3',
}}
onDrop={onDrop}
>
<DragDrop {...dragDropSharedProps} value={items[2]} order={[2, 0, 2]}>
<span>3</span>
</DragDrop>
</ReorderProvider>
</ChildDragDropProvider>
);
};
test(`Inactive reorderable group renders properly`, () => {
const component = mountComponent(undefined, jest.fn());
expect(component.find('.lnsDragDrop-reorderable')).toHaveLength(3);
test(`Inactive group renders properly`, () => {
const component = mountComponent(undefined);
expect(component.find('[data-test-subj="lnsDragDrop"]')).toHaveLength(3);
});
test(`Reorderable group with lifted element renders properly`, () => {
const setDragging = jest.fn();
const setA11yMessage = jest.fn();
const component = mountComponent(
{ dragging: { id: '1' }, setA11yMessage, setDragging },
jest.fn()
);
const setDragging = jest.fn();
const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage });
act(() => {
component
.find('[data-test-subj="lnsDragDrop"]')
@ -352,8 +488,8 @@ describe('DragDrop', () => {
jest.runAllTimers();
});
expect(setDragging).toBeCalledWith({ id: '1' });
expect(setA11yMessage).toBeCalledWith('You have lifted an item 1 in position 1');
expect(setDragging).toBeCalledWith(items[0]);
expect(setA11yMessage).toBeCalledWith('Lifted label1');
expect(
component
.find('[data-test-subj="lnsDragDrop-reorderableGroup"]')
@ -362,7 +498,7 @@ describe('DragDrop', () => {
});
test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => {
const component = mountComponent({ dragging: { id: '1' } }, jest.fn());
const component = mountComponent({ dragging: items[0] });
act(() => {
component
@ -403,16 +539,13 @@ describe('DragDrop', () => {
});
test(`Dropping an item runs onDrop function`, () => {
const setDragging = jest.fn();
const setA11yMessage = jest.fn();
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const onDrop = jest.fn();
const component = mountComponent(
{ dragging: { id: '1' }, setA11yMessage, setDragging },
onDrop
);
const setA11yMessage = jest.fn();
const setDragging = jest.fn();
const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage });
component
.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]')
@ -421,23 +554,58 @@ describe('DragDrop', () => {
jest.runAllTimers();
expect(setA11yMessage).toBeCalledWith(
'You have dropped the item. You have moved the item from position 1 to positon 3'
'You have dropped the item label1. You have moved the item from position 1 to positon 3'
);
expect(preventDefault).toBeCalled();
expect(stopPropagation).toBeCalled();
expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' });
expect(onDrop).toBeCalledWith(items[0], 'reorder');
});
test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => {
const onDrop = jest.fn();
const component = mountComponent(
{
dragging: { id: '1' },
activeDropTarget: { activeDropTarget: { id: '3' } } as ActiveDropTarget,
keyboardMode: true,
test(`Keyboard Navigation: User cannot move an element outside of the group`, () => {
const setA11yMessage = jest.fn();
const setActiveDropTarget = jest.fn();
const component = mountComponent({
dragging: items[0],
keyboardMode: true,
activeDropTarget: {
activeDropTarget: undefined,
dropTargetsByOrder: {
'2,0,0': undefined,
'2,0,1': { ...items[1], onDrop, dropType: 'reorder' },
'2,0,2': { ...items[2], onDrop, dropType: 'reorder' },
},
},
onDrop
setActiveDropTarget,
setA11yMessage,
});
const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowUp' });
expect(setActiveDropTarget).not.toHaveBeenCalled();
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
expect(setActiveDropTarget).toBeCalledWith(items[1]);
expect(setA11yMessage).toBeCalledWith(
'You have moved the item label1 from position 1 to position 2'
);
});
test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => {
const component = mountComponent({
dragging: items[0],
activeDropTarget: {
activeDropTarget: { ...items[2], dropType: 'reorder', onDrop },
dropTargetsByOrder: {
'2,0,0': { ...items[0], onDrop, dropType: 'reorder' },
'2,0,1': { ...items[1], onDrop, dropType: 'reorder' },
'2,0,2': { ...items[2], onDrop, dropType: 'reorder' },
},
},
keyboardMode: true,
});
const keyboardHandler = component
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]')
.simulate('focus');
@ -447,15 +615,43 @@ describe('DragDrop', () => {
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
keyboardHandler.simulate('keydown', { key: 'Enter' });
});
expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' });
expect(onDrop).toBeCalledWith(items[0], 'reorder');
});
test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => {
const setA11yMessage = jest.fn();
const onDropHandler = jest.fn();
const component = mountComponent({ dragging: items[0], setA11yMessage }, onDropHandler);
const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'Escape' });
jest.runAllTimers();
expect(onDropHandler).not.toHaveBeenCalled();
expect(setA11yMessage).toBeCalledWith('Movement cancelled');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
keyboardHandler.simulate('blur');
expect(onDropHandler).not.toHaveBeenCalled();
expect(setA11yMessage).toBeCalledWith('Movement cancelled');
});
test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => {
const setA11yMessage = jest.fn();
const component = mountComponent(
{ dragging: { id: '1' }, keyboardMode: true, setA11yMessage },
jest.fn()
);
const component = mountComponent({
dragging: items[0],
keyboardMode: true,
activeDropTarget: {
activeDropTarget: undefined,
dropTargetsByOrder: {
'2,0,0': undefined,
'2,0,1': { ...items[1], onDrop, dropType: 'reorder' },
'2,0,2': { ...items[2], onDrop, dropType: 'reorder' },
},
},
setA11yMessage,
});
const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
@ -475,7 +671,7 @@ describe('DragDrop', () => {
component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style')
).toEqual(undefined);
expect(setA11yMessage).toBeCalledWith(
'You have moved the item 1 from position 1 to position 2'
'You have moved the item label1 from position 1 to position 2'
);
component
@ -490,63 +686,45 @@ describe('DragDrop', () => {
).toEqual(undefined);
});
test(`Keyboard Navigation: User cannot move an element outside of the group`, () => {
const onDrop = jest.fn();
const setActiveDropTarget = jest.fn();
const setA11yMessage = jest.fn();
const component = mountComponent(
{ dragging: { id: '1' }, keyboardMode: true, setActiveDropTarget, setA11yMessage },
onDrop
);
const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowUp' });
expect(setActiveDropTarget).not.toHaveBeenCalled();
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
expect(setActiveDropTarget).toBeCalledWith({ id: '2' });
expect(setA11yMessage).toBeCalledWith(
'You have moved the item 1 from position 1 to position 2'
);
});
test(`Keyboard Navigation: User cannot drop element to itself`, () => {
const setActiveDropTarget = jest.fn();
const setA11yMessage = jest.fn();
const setActiveDropTarget = jest.fn();
const component = mount(
<ChildDragDropProvider
{...defaultContext}
keyboardMode={true}
activeDropTarget={{
activeDropTarget: { id: '2' },
activeDropTarget: {
...items[1],
onDrop,
dropType: 'reorder',
},
dropTargetsByOrder: {
'2,0,1,0': undefined,
'2,0,1,1': { ...items[1], onDrop, dropType: 'reorder' },
},
}}
dragging={{ id: '1' }}
dragging={items[0]}
setActiveDropTarget={setActiveDropTarget}
setA11yMessage={setA11yMessage}
>
<ReorderProvider id="groupId">
<DragDrop
label="1"
draggable
droppable={false}
dragType="reorder"
dropType="reorder"
reorderableGroup={[{ id: '1' }, { id: '2' }]}
value={{ id: '1' }}
dragType="move"
reorderableGroup={[items[0], items[1]]}
value={items[0]}
order={[2, 0, 1, 0]}
>
<span>1</span>
</DragDrop>
<DragDrop
label="2"
draggable
droppable
dragType="reorder"
dragType="move"
dropType="reorder"
reorderableGroup={[{ id: '1' }, { id: '2' }]}
value={{ id: '2' }}
reorderableGroup={[items[0], items[1]]}
value={items[1]}
order={[2, 0, 1, 1]}
>
<span>2</span>
</DragDrop>
@ -557,33 +735,8 @@ describe('DragDrop', () => {
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowUp' });
expect(setActiveDropTarget).toBeCalledWith({ id: '1' });
expect(setA11yMessage).toBeCalledWith('You have moved back the item 1 to position 1');
});
test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => {
const setA11yMessage = jest.fn();
const onDrop = jest.fn();
const component = mountComponent({ dragging: { id: '1' }, setA11yMessage }, onDrop);
const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'Escape' });
jest.runAllTimers();
expect(onDrop).not.toHaveBeenCalled();
expect(setA11yMessage).toBeCalledWith(
'Movement cancelled. The item has returned to its starting position 1'
);
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
keyboardHandler.simulate('blur');
expect(onDrop).not.toHaveBeenCalled();
expect(setA11yMessage).toBeCalledWith(
'Movement cancelled. The item has returned to its starting position 1'
);
expect(setActiveDropTarget).toBeCalledWith(undefined);
expect(setA11yMessage).toBeCalledWith('You have moved the item label1 back to position 1');
});
});
});

View file

@ -9,23 +9,23 @@ import './drag_drop.scss';
import React, { useContext, useEffect, memo } from 'react';
import classNames from 'classnames';
import { keys, EuiScreenReaderOnly } from '@elastic/eui';
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
import {
DragDropIdentifier,
DropIdentifier,
DragContext,
DragContextState,
nextValidDropTarget,
ReorderContext,
ReorderState,
reorderAnnouncements,
DropHandler,
} from './providers';
import { announce } from './announcements';
import { trackUiEvent } from '../lens_ui_telemetry';
import { DropType } from '../types';
export type DroppableEvent = React.DragEvent<HTMLElement>;
/**
* A function that handles a drop event.
*/
export type DropHandler = (dropped: DragDropIdentifier, dropTarget: DragDropIdentifier) => void;
/**
* The base props to the DragDrop component.
*/
@ -34,10 +34,6 @@ interface BaseProps {
* The CSS class(es) for the root element.
*/
className?: string;
/**
* The label for accessibility
*/
label?: string;
/**
* The event handler that fires when an item
@ -62,16 +58,15 @@ interface BaseProps {
* Indicates whether or not this component is draggable.
*/
draggable?: boolean;
/**
* 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;
getAdditionalClassesOnEnter?: (dropType?: DropType) => string | undefined;
/**
* Additional class names to apply when another element is droppable for a currently dragged item
*/
getAdditionalClassesOnDroppable?: (dropType?: DropType) => string | undefined;
/**
* The optional test subject associated with this DOM element.
@ -81,35 +76,29 @@ interface BaseProps {
/**
* items belonging to the same group that can be reordered
*/
reorderableGroup?: DragDropIdentifier[];
reorderableGroup?: Array<{ id: string }>;
/**
* Indicates to the user whether the currently dragged item
* will be moved or copied
*/
dragType?: 'copy' | 'move' | 'reorder';
dragType?: 'copy' | 'move';
/**
* Indicates to the user whether the drop action will
* replace something that is existing or add a new one
* Indicates the type of a drop - when undefined, the currently dragged item
* cannot be dropped onto this component.
*/
dropType?: 'add' | 'replace' | 'reorder';
dropType?: DropType;
/**
* temporary flag to exclude the draggable elements that don't have keyboard nav yet. To be removed along with the feature development
* Order for keyboard dragging. This takes an array of numbers which will be used to order hierarchically
*/
noKeyboardSupportYet?: boolean;
order: number[];
}
/**
* The props for a draggable instance of that component.
*/
interface DragInnerProps extends BaseProps {
/**
* 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;
isDragging: boolean;
keyboardMode: boolean;
setKeyboardMode: DragContextState['setKeyboardMode'];
@ -124,6 +113,7 @@ interface DragInnerProps extends BaseProps {
) => void;
onDragEnd?: () => void;
extraKeyboardHandler?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
ariaDescribedBy?: string;
}
/**
@ -131,23 +121,16 @@ interface DragInnerProps extends BaseProps {
*/
interface DropInnerProps extends BaseProps, DragContextState {
isDragging: boolean;
isNotDroppable: boolean;
}
/**
* A draggable / droppable item. Items can be both draggable and droppable at
* the same time.
*
* @param props
*/
const lnsLayerPanelDimensionMargin = 8;
export const DragDrop = (props: BaseProps) => {
const {
dragging,
setDragging,
registerDropTarget,
keyboardMode,
setKeyboardMode,
activeDropTarget,
@ -155,8 +138,7 @@ export const DragDrop = (props: BaseProps) => {
setA11yMessage,
} = useContext(DragContext);
const { value, draggable, droppable, reorderableGroup } = props;
const { value, draggable, dropType, reorderableGroup } = props;
const isDragging = !!(draggable && value.id === dragging?.id);
const dragProps = {
@ -178,16 +160,17 @@ export const DragDrop = (props: BaseProps) => {
setDragging,
activeDropTarget,
setActiveDropTarget,
registerDropTarget,
isDragging,
setA11yMessage,
isNotDroppable:
// If the configuration has provided a droppable flag, but this particular item is not
// droppable, then it should be less prominent. Ignores items that are both
// draggable and drop targets
!!(droppable === false && dragging && value.id !== dragging.id),
!!(!dropType && dragging && value.id !== dragging.id),
};
if (draggable && !droppable) {
if (draggable && !dropType) {
if (reorderableGroup && reorderableGroup.length > 1) {
return (
<ReorderableDrag
@ -204,14 +187,14 @@ export const DragDrop = (props: BaseProps) => {
if (
reorderableGroup &&
reorderableGroup.length > 1 &&
reorderableGroup?.some((i) => i.id === value.id)
reorderableGroup?.some((i) => i.id === dragging?.id)
) {
return <ReorderableDrop reorderableGroup={reorderableGroup} {...dropProps} />;
return <ReorderableDrop {...dropProps} reorderableGroup={reorderableGroup} />;
}
return <DropInner {...dropProps} />;
};
const DragInner = memo(function DragDropInner({
const DragInner = memo(function DragInner({
dataTestSubj,
className,
value,
@ -219,16 +202,16 @@ const DragInner = memo(function DragDropInner({
setDragging,
setKeyboardMode,
setActiveDropTarget,
label = '',
order,
keyboardMode,
isDragging,
activeDropTarget,
onDrop,
dragType,
onDragStart,
onDragEnd,
extraKeyboardHandler,
noKeyboardSupportYet,
ariaDescribedBy,
setA11yMessage,
}: DragInnerProps) {
const dragStart = (e?: DroppableEvent | React.KeyboardEvent<HTMLButtonElement>) => {
// Setting stopPropgagation causes Chrome failures, so
@ -241,7 +224,7 @@ const DragInner = memo(function DragDropInner({
// We only can reach the dragStart method if the element is draggable,
// so we know we have DraggableProps if we reach this code.
if (e && 'dataTransfer' in e) {
e.dataTransfer.setData('text', label);
e.dataTransfer.setData('text', value.humanData.label);
}
// Chrome causes issues if you try to render from within a
@ -250,6 +233,7 @@ const DragInner = memo(function DragDropInner({
const currentTarget = e?.currentTarget;
setTimeout(() => {
setDragging(value);
setA11yMessage(announce.lifted(value.humanData));
if (onDragStart) {
onDragStart(currentTarget);
}
@ -261,53 +245,78 @@ const DragInner = memo(function DragDropInner({
setDragging(undefined);
setActiveDropTarget(undefined);
setKeyboardMode(false);
setA11yMessage(announce.cancelled());
if (onDragEnd) {
onDragEnd();
}
};
const dropToActiveDropTarget = () => {
if (isDragging && activeDropTarget?.activeDropTarget) {
trackUiEvent('drop_total');
if (onDrop) {
onDrop(value, activeDropTarget.activeDropTarget);
}
const { dropType, humanData, onDrop: onTargetDrop } = activeDropTarget.activeDropTarget;
setTimeout(() => setA11yMessage(announce.dropped(value.humanData, humanData, dropType)));
onTargetDrop(value, dropType);
}
};
const setNextTarget = (reversed = false) => {
if (!order) {
return;
}
const nextTarget = nextValidDropTarget(
activeDropTarget,
[order.join(',')],
(el) => el?.dropType !== 'reorder',
reversed
);
setActiveDropTarget(nextTarget);
setA11yMessage(
nextTarget
? announce.selectedTarget(value.humanData, nextTarget?.humanData, nextTarget?.dropType)
: announce.noTarget()
);
};
return (
<div className={className}>
{!noKeyboardSupportYet && (
<EuiScreenReaderOnly showOnFocus>
<button
aria-label={label}
aria-describedby={`lnsDragDrop-keyboardInstructions`}
className="lnsDragDrop__keyboardHandler"
data-test-subj="lnsDragDrop-keyboardHandler"
onBlur={() => {
<div className={className} data-test-subj={`lnsDragDrop_draggable-${value.humanData.label}`}>
<EuiScreenReaderOnly showOnFocus>
<button
aria-label={value.humanData.label}
aria-describedby={ariaDescribedBy || `lnsDragDrop-keyboardInstructions`}
className="lnsDragDrop__keyboardHandler"
data-test-subj="lnsDragDrop-keyboardHandler"
onBlur={() => {
if (isDragging) {
dragEnd();
}}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === keys.ENTER || e.key === keys.SPACE) {
if (activeDropTarget) {
dropToActiveDropTarget();
}
if (isDragging) {
dragEnd();
} else {
dragStart(e);
setKeyboardMode(true);
}
} else if (e.key === keys.ESCAPE) {
}
}}
onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => {
const { key } = e;
if (key === keys.ENTER || key === keys.SPACE) {
if (activeDropTarget) {
dropToActiveDropTarget();
}
if (isDragging) {
dragEnd();
} else {
dragStart(e);
setKeyboardMode(true);
}
} else if (key === keys.ESCAPE) {
if (isDragging) {
dragEnd();
}
if (extraKeyboardHandler) {
extraKeyboardHandler(e);
}
}}
/>
</EuiScreenReaderOnly>
)}
}
if (extraKeyboardHandler) {
extraKeyboardHandler(e);
}
if (keyboardMode && (keys.ARROW_LEFT === key || keys.ARROW_RIGHT === key)) {
setNextTarget(!!(keys.ARROW_LEFT === key));
}
}}
/>
</EuiScreenReaderOnly>
{React.cloneElement(children, {
'data-test-subj': dataTestSubj || 'lnsDragDrop',
@ -329,26 +338,41 @@ const DropInner = memo(function DropInner(props: DropInnerProps) {
onDrop,
value,
children,
droppable,
draggable,
dragging,
setDragging,
isDragging,
isNotDroppable,
dragType = 'copy',
dropType = 'add',
dropType,
keyboardMode,
setKeyboardMode,
activeDropTarget,
registerDropTarget,
setActiveDropTarget,
getAdditionalClassesOnEnter,
getAdditionalClassesOnDroppable,
setKeyboardMode,
setDragging,
order,
setA11yMessage,
} = props;
useShallowCompareEffect(() => {
if (dropType && value && onDrop) {
registerDropTarget(order, { ...value, onDrop, dropType });
return () => {
registerDropTarget(order, undefined);
};
}
}, [order, value, registerDropTarget, dropType]);
const activeDropTargetMatches =
activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id;
const isMoveDragging = isDragging && dragType === 'move';
const classesOnEnter = getAdditionalClassesOnEnter?.(dropType);
const classesOnDroppable = getAdditionalClassesOnDroppable?.(dropType);
const classes = classNames(
'lnsDragDrop',
{
@ -356,27 +380,25 @@ const DropInner = memo(function DropInner(props: DropInnerProps) {
'lnsDragDrop-isDragging': isDragging,
'lnsDragDrop-isHidden': isMoveDragging && !keyboardMode,
'lnsDragDrop-isDroppable': !draggable,
'lnsDragDrop-isDropTarget': droppable && dragType !== 'reorder',
'lnsDragDrop-isDropTarget': dropType && dropType !== 'reorder',
'lnsDragDrop-isActiveDropTarget':
droppable && activeDropTargetMatches && dragType !== 'reorder',
dropType && activeDropTargetMatches && dropType !== 'reorder',
'lnsDragDrop-isNotDroppable': !isMoveDragging && isNotDroppable,
'lnsDragDrop-isReplacing': droppable && activeDropTargetMatches && dropType === 'replace',
},
getAdditionalClassesOnEnter && {
[getAdditionalClassesOnEnter()]: activeDropTargetMatches,
}
classesOnEnter && { [classesOnEnter]: activeDropTargetMatches },
classesOnDroppable && { [classesOnDroppable]: dropType }
);
const dragOver = (e: DroppableEvent) => {
if (!droppable) {
if (!dropType) {
return;
}
e.preventDefault();
// An optimization to prevent a bunch of React churn.
// todo: replace with custom function ?
if (!activeDropTargetMatches) {
setActiveDropTarget(value);
if (!activeDropTargetMatches && dragging && onDrop) {
setActiveDropTarget({ ...value, dropType, onDrop });
setA11yMessage(announce.selectedTarget(dragging.humanData, value.humanData, dropType));
}
};
@ -388,12 +410,15 @@ const DropInner = memo(function DropInner(props: DropInnerProps) {
e.preventDefault();
e.stopPropagation();
if (onDrop && droppable && dragging) {
if (onDrop && dropType && dragging) {
trackUiEvent('drop_total');
onDrop(dragging, value);
onDrop(dragging, dropType);
setTimeout(() =>
setA11yMessage(announce.dropped(dragging.humanData, value.humanData, dropType))
);
}
setActiveDropTarget(undefined);
setDragging(undefined);
setActiveDropTarget(undefined);
setKeyboardMode(false);
};
return (
@ -411,7 +436,7 @@ const DropInner = memo(function DropInner(props: DropInnerProps) {
});
const ReorderableDrag = memo(function ReorderableDrag(
props: DragInnerProps & { reorderableGroup: DragDropIdentifier[]; dragging?: DragDropIdentifier }
props: DragInnerProps & { reorderableGroup: Array<{ id: string }>; dragging?: DragDropIdentifier }
) {
const {
reorderState: { isReorderOn, reorderedItems, direction },
@ -421,17 +446,13 @@ const ReorderableDrag = memo(function ReorderableDrag(
const {
value,
setActiveDropTarget,
label = '',
keyboardMode,
isDragging,
activeDropTarget,
reorderableGroup,
onDrop,
setA11yMessage,
} = props;
const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id);
const isFocusInGroup = keyboardMode
? isDragging &&
(!activeDropTarget?.activeDropTarget ||
@ -457,25 +478,10 @@ const ReorderableDrag = memo(function ReorderableDrag(
draggingHeight: height,
}));
}
setA11yMessage(reorderAnnouncements.lifted(label, currentIndex + 1));
};
const onReorderableDragEnd = () => {
resetReorderState();
setA11yMessage(reorderAnnouncements.cancelled(currentIndex + 1));
};
const onReorderableDrop = (dragging: DragDropIdentifier, target: DragDropIdentifier) => {
if (onDrop) {
onDrop(dragging, target);
const targetIndex = reorderableGroup.findIndex(
(i) => i.id === activeDropTarget?.activeDropTarget?.id
);
resetReorderState();
setA11yMessage(reorderAnnouncements.dropped(targetIndex + 1, currentIndex + 1));
}
};
const resetReorderState = () =>
@ -495,42 +501,50 @@ const ReorderableDrag = memo(function ReorderableDrag(
);
if (index !== -1) activeDropTargetIndex = index;
}
if (keys.ARROW_DOWN === e.key) {
if (e.key === keys.ARROW_LEFT || e.key === keys.ARROW_RIGHT) {
resetReorderState();
setActiveDropTarget(undefined);
} else if (keys.ARROW_DOWN === e.key) {
if (activeDropTargetIndex < reorderableGroup.length - 1) {
setA11yMessage(
reorderAnnouncements.moved(label, activeDropTargetIndex + 2, currentIndex + 1)
const nextTarget = nextValidDropTarget(
activeDropTarget,
[props.order.join(',')],
(el) => el?.dropType === 'reorder'
);
onReorderableDragOver(reorderableGroup[activeDropTargetIndex + 1]);
onReorderableDragOver(nextTarget);
}
} else if (keys.ARROW_UP === e.key) {
if (activeDropTargetIndex > 0) {
setA11yMessage(
reorderAnnouncements.moved(label, activeDropTargetIndex, currentIndex + 1)
const nextTarget = nextValidDropTarget(
activeDropTarget,
[props.order.join(',')],
(el) => el?.dropType === 'reorder',
true
);
onReorderableDragOver(reorderableGroup[activeDropTargetIndex - 1]);
onReorderableDragOver(nextTarget);
}
}
}
};
const onReorderableDragOver = (target: DragDropIdentifier) => {
let droppingIndex = currentIndex;
if (keyboardMode && 'id' in target) {
setActiveDropTarget(target);
droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id);
}
const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id);
if (draggingIndex === -1) {
return;
}
if (draggingIndex === droppingIndex) {
const onReorderableDragOver = (target?: DropIdentifier) => {
if (!target) {
setReorderState((s: ReorderState) => ({
...s,
reorderedItems: [],
}));
setA11yMessage(announce.selectedTarget(value.humanData, value.humanData, 'reorder'));
setActiveDropTarget(target);
return;
}
const droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id);
const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id);
if (draggingIndex === -1) {
return;
}
setActiveDropTarget(target);
setA11yMessage(announce.selectedTarget(value.humanData, target.humanData, 'reorder'));
setReorderState((s: ReorderState) =>
draggingIndex < droppingIndex
@ -561,9 +575,7 @@ const ReorderableDrag = memo(function ReorderableDrag(
areItemsReordered
? {
transform: `translateY(${direction === '+' ? '-' : '+'}${reorderedItems.reduce(
(acc, cur) => {
return acc + Number(cur.height || 0) + lnsLayerPanelDimensionMargin;
},
(acc, cur) => acc + Number(cur.height || 0) + lnsLayerPanelDimensionMargin,
0
)}px)`,
}
@ -572,22 +584,21 @@ const ReorderableDrag = memo(function ReorderableDrag(
>
<DragInner
{...props}
ariaDescribedBy="lnsDragDrop-keyboardInstructionsWithReorder"
extraKeyboardHandler={extraKeyboardHandler}
onDragStart={onReorderableDragStart}
onDragEnd={onReorderableDragEnd}
onDrop={onReorderableDrop}
/>
</div>
);
});
const ReorderableDrop = memo(function ReorderableDrop(
props: DropInnerProps & { reorderableGroup: DragDropIdentifier[] }
props: DropInnerProps & { reorderableGroup: Array<{ id: string }> }
) {
const {
onDrop,
value,
droppable,
dragging,
setDragging,
setKeyboardMode,
@ -595,6 +606,7 @@ const ReorderableDrop = memo(function ReorderableDrop(
setActiveDropTarget,
reorderableGroup,
setA11yMessage,
dropType,
} = props;
const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id);
@ -628,15 +640,14 @@ const ReorderableDrop = memo(function ReorderableDrop(
}, [isReordered, setReorderState, value.id]);
const onReorderableDragOver = (e: DroppableEvent) => {
if (!droppable) {
if (!dropType) {
return;
}
e.preventDefault();
// An optimization to prevent a bunch of React churn.
// todo: replace with custom function ?
if (!activeDropTargetMatches) {
setActiveDropTarget(value);
if (!activeDropTargetMatches && dropType && onDrop) {
setActiveDropTarget({ ...value, dropType, onDrop });
}
const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id);
@ -675,14 +686,12 @@ const ReorderableDrop = memo(function ReorderableDrop(
setDragging(undefined);
setKeyboardMode(false);
if (onDrop && droppable && dragging) {
if (onDrop && dropType && dragging) {
trackUiEvent('drop_total');
onDrop(dragging, value);
const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging.id);
onDrop(dragging, 'reorder');
// setTimeout ensures it will run after dragEnd messaging
setTimeout(() =>
setA11yMessage(reorderAnnouncements.dropped(currentIndex + 1, draggingIndex + 1))
setA11yMessage(announce.dropped(dragging.humanData, value.humanData, 'reorder'))
);
}
};
@ -707,7 +716,7 @@ const ReorderableDrop = memo(function ReorderableDrop(
<div
data-test-subj="lnsDragDrop-reorderableDropLayer"
className={classNames('lnsDragDrop', {
['lnsDragDrop__reorderableDrop']: dragging && droppable,
['lnsDragDrop__reorderableDrop']: dragging && dropType,
})}
onDrop={onReorderableDrop}
onDragOver={onReorderableDragOver}

View file

@ -9,13 +9,30 @@ import React, { useState, useMemo } from 'react';
import classNames from 'classnames';
import { EuiScreenReaderOnly, EuiPortal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { HumanData } from './announcements';
import { DropType } from '../types';
/**
* A function that handles a drop event.
*/
export type DropHandler = (dropped: DragDropIdentifier, dropType?: DropType) => void;
export type DragDropIdentifier = Record<string, unknown> & {
id: string;
/**
* The data for accessibility, consists of required label and not required groupLabel and position in group
*/
humanData: HumanData;
};
export interface ActiveDropTarget {
activeDropTarget?: DragDropIdentifier;
export type DropIdentifier = DragDropIdentifier & {
dropType: DropType;
onDrop: DropHandler;
};
export interface DropTargets {
activeDropTarget?: DropIdentifier;
dropTargetsByOrder: Record<string, DropIdentifier | undefined>;
}
/**
* The shape of the drag / drop context.
@ -39,11 +56,12 @@ export interface DragContextState {
*/
setDragging: (dragging?: DragDropIdentifier) => void;
activeDropTarget?: ActiveDropTarget;
activeDropTarget?: DropTargets;
setActiveDropTarget: (newTarget?: DragDropIdentifier) => void;
setActiveDropTarget: (newTarget?: DropIdentifier) => void;
setA11yMessage: (message: string) => void;
registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void;
}
/**
@ -59,6 +77,7 @@ export const DragContext = React.createContext<DragContextState>({
activeDropTarget: undefined,
setActiveDropTarget: () => {},
setA11yMessage: () => {},
registerDropTarget: () => {},
});
/**
@ -89,10 +108,13 @@ export interface ProviderProps {
setDragging: (dragging?: DragDropIdentifier) => void;
activeDropTarget?: {
activeDropTarget?: DragDropIdentifier;
activeDropTarget?: DropIdentifier;
dropTargetsByOrder: Record<string, DropIdentifier | undefined>;
};
setActiveDropTarget: (newTarget?: DragDropIdentifier) => void;
setActiveDropTarget: (newTarget?: DropIdentifier) => void;
registerDropTarget: (order: number[], dropTarget?: DropIdentifier) => void;
/**
* The React children.
@ -116,9 +138,11 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }
const [keyboardModeState, setKeyboardModeState] = useState(false);
const [a11yMessageState, setA11yMessageState] = useState('');
const [activeDropTargetState, setActiveDropTargetState] = useState<{
activeDropTarget?: DragDropIdentifier;
activeDropTarget?: DropIdentifier;
dropTargetsByOrder: Record<string, DropIdentifier | undefined>;
}>({
activeDropTarget: undefined,
dropTargetsByOrder: {},
});
const setDragging = useMemo(
@ -131,11 +155,26 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }
]);
const setActiveDropTarget = useMemo(
() => (activeDropTarget?: DragDropIdentifier) =>
() => (activeDropTarget?: DropIdentifier) =>
setActiveDropTargetState((s) => ({ ...s, activeDropTarget })),
[setActiveDropTargetState]
);
const registerDropTarget = useMemo(
() => (order: number[], dropTarget?: DropIdentifier) => {
return setActiveDropTargetState((s) => {
return {
...s,
dropTargetsByOrder: {
...s.dropTargetsByOrder,
[order.join(',')]: dropTarget,
},
};
});
},
[setActiveDropTargetState]
);
return (
<div>
<ChildDragDropProvider
@ -146,6 +185,7 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }
setDragging={setDragging}
activeDropTarget={activeDropTargetState}
setActiveDropTarget={setActiveDropTarget}
registerDropTarget={registerDropTarget}
>
{children}
</ChildDragDropProvider>
@ -155,9 +195,14 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }
<p aria-live="assertive" aria-atomic={true}>
{a11yMessageState}
</p>
<p id={`lnsDragDrop-keyboardInstructionsWithReorder`}>
{i18n.translate('xpack.lens.dragDrop.keyboardInstructionsReorder', {
defaultMessage: `Press enter or space to dragging. When dragging, use the up/down arrow keys to reorder items in the group and left/right arrow keys to choose drop targets outside of the group. Press enter or space again to finish.`,
})}
</p>
<p id={`lnsDragDrop-keyboardInstructions`}>
{i18n.translate('xpack.lens.dragDrop.keyboardInstructions', {
defaultMessage: `Press enter or space to start reordering the dimension group. When dragging, use arrow keys to reorder. Press enter or space again to finish.`,
defaultMessage: `Press enter or space to start dragging. When dragging, use the left/right arrow keys to move between drop targets. Press enter or space again to finish.`,
})}
</p>
</div>
@ -167,6 +212,45 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }
);
}
export function nextValidDropTarget(
activeDropTarget: DropTargets | undefined,
draggingOrder: [string],
filterElements: (el: DragDropIdentifier) => boolean = () => true,
reverse = false
) {
if (!activeDropTarget) {
return;
}
const filteredTargets = [...Object.entries(activeDropTarget.dropTargetsByOrder)].filter(
([, dropTarget]) => dropTarget && filterElements(dropTarget)
);
const nextDropTargets = [...filteredTargets, draggingOrder].sort(([orderA], [orderB]) => {
const parsedOrderA = orderA.split(',').map((v) => Number(v));
const parsedOrderB = orderB.split(',').map((v) => Number(v));
const relevantLevel = parsedOrderA.findIndex((v, i) => parsedOrderA[i] !== parsedOrderB[i]);
return parsedOrderA[relevantLevel] - parsedOrderB[relevantLevel];
});
let currentActiveDropIndex = nextDropTargets.findIndex(
([_, dropTarget]) => dropTarget?.id === activeDropTarget?.activeDropTarget?.id
);
if (currentActiveDropIndex === -1) {
currentActiveDropIndex = nextDropTargets.findIndex(
([targetOrder]) => targetOrder === draggingOrder[0]
);
}
const previousElement =
(nextDropTargets.length + currentActiveDropIndex - 1) % nextDropTargets.length;
const nextElement = (currentActiveDropIndex + 1) % nextDropTargets.length;
return nextDropTargets[reverse ? previousElement : nextElement][1];
}
/**
* A React drag / drop provider that derives its state from a RootDragDropProvider. If
* part of a React application is rendered separately from the root, this provider can
@ -182,6 +266,7 @@ export function ChildDragDropProvider({
activeDropTarget,
setActiveDropTarget,
setA11yMessage,
registerDropTarget,
children,
}: ProviderProps) {
const value = useMemo(
@ -193,6 +278,7 @@ export function ChildDragDropProvider({
activeDropTarget,
setActiveDropTarget,
setA11yMessage,
registerDropTarget,
}),
[
setDragging,
@ -202,6 +288,7 @@ export function ChildDragDropProvider({
setKeyboardMode,
keyboardMode,
setA11yMessage,
registerDropTarget,
]
);
return <DragContext.Provider value={value}>{children}</DragContext.Provider>;
@ -211,7 +298,7 @@ export interface ReorderState {
/**
* Ids of the elements that are translated up or down
*/
reorderedItems: DragDropIdentifier[];
reorderedItems: Array<{ id: string; height?: number }>;
/**
* Direction of the move of dragged element in the reordered list
@ -282,51 +369,3 @@ export function ReorderProvider({
</div>
);
}
export const reorderAnnouncements = {
moved: (itemLabel: string, position: number, prevPosition: number) => {
return prevPosition === position
? i18n.translate('xpack.lens.dragDrop.elementMovedBack', {
defaultMessage: `You have moved back the item {itemLabel} to position {prevPosition}`,
values: {
itemLabel,
prevPosition,
},
})
: i18n.translate('xpack.lens.dragDrop.elementMoved', {
defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`,
values: {
itemLabel,
position,
prevPosition,
},
});
},
lifted: (itemLabel: string, position: number) =>
i18n.translate('xpack.lens.dragDrop.elementLifted', {
defaultMessage: `You have lifted an item {itemLabel} in position {position}`,
values: {
itemLabel,
position,
},
}),
cancelled: (position: number) =>
i18n.translate('xpack.lens.dragDrop.abortMessageReorder', {
defaultMessage:
'Movement cancelled. The item has returned to its starting position {position}',
values: {
position,
},
}),
dropped: (position: number, prevPosition: number) =>
i18n.translate('xpack.lens.dragDrop.dropMessageReorder', {
defaultMessage:
'You have dropped the item. You have moved the item from position {prevPosition} to positon {position}',
values: {
position,
prevPosition,
},
}),
};

View file

@ -56,7 +56,7 @@ const { dragging } = useContext(DragContext);
return (
<DragDrop
className="axis"
droppable={dragging && canHandleDrop(dragging)}
dropType={getDropTypes(dragging)}
onDrop={(item) => onChange([...items, item])}
>
{items.map((x) => (
@ -86,11 +86,14 @@ The children `DragDrop` components must have props defined as in the example:
key={f.id}
draggable
droppable
dragType="reorder"
dragType="move"
dropType="reorder"
reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}]
value={{
id: f.id,
humanData: {
label: 'Label'
}
}}
onDrop={/*handler*/}
>

View file

@ -7,14 +7,29 @@
import React, { useMemo } from 'react';
import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop';
import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types';
import {
Datasource,
VisualizationDimensionGroupConfig,
isDraggedOperation,
DropType,
} from '../../../types';
import { LayerDatasourceDropProps } from './types';
const isFromTheSameGroup = (el1: DragDropIdentifier, el2?: DragDropIdentifier) =>
el2 && isDraggedOperation(el2) && el1.groupId === el2.groupId && el1.columnId !== el2.columnId;
const getAdditionalClassesOnEnter = (dropType?: string) => {
if (
dropType === 'field_replace' ||
dropType === 'replace_compatible' ||
dropType === 'replace_incompatible'
) {
return 'lnsDragDrop-isReplacing';
}
};
const isSelf = (el1: DragDropIdentifier, el2?: DragDropIdentifier) =>
isDraggedOperation(el2) && el1.columnId === el2.columnId;
const getAdditionalClassesOnDroppable = (dropType?: string) => {
if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') {
return 'lnsDragDrop-notCompatible';
}
};
export function DraggableDimensionButton({
layerId,
@ -34,7 +49,11 @@ export function DraggableDimensionButton({
layerId: string;
groupIndex: number;
layerIndex: number;
onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void;
onDrop: (
droppedItem: DragDropIdentifier,
dropTarget: DragDropIdentifier,
dropType?: DropType
) => void;
group: VisualizationDimensionGroupConfig;
label: string;
children: React.ReactElement;
@ -43,66 +62,52 @@ export function DraggableDimensionButton({
accessorIndex: number;
columnId: string;
}) {
const value = useMemo(() => {
return {
const dropType = layerDatasource.getDropTypes({
...layerDatasourceDropProps,
columnId,
filterOperations: group.filterOperations,
groupId: group.groupId,
});
const value = useMemo(
() => ({
columnId,
groupId: group.groupId,
layerId,
id: columnId,
};
}, [columnId, group.groupId, layerId]);
const { dragging } = dragDropContext;
const isCurrentGroup = group.groupId === dragging?.groupId;
const isOperationDragged = isDraggedOperation(dragging);
const canHandleDrop =
Boolean(dragDropContext.dragging) &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId,
filterOperations: group.filterOperations,
});
const dragType = isSelf(value, dragging)
? 'move'
: isOperationDragged && isCurrentGroup
? 'reorder'
: 'copy';
const dropType = isOperationDragged ? (!isCurrentGroup ? 'replace' : 'reorder') : 'add';
const isCompatibleFromOtherGroup = !isCurrentGroup && canHandleDrop;
const isDroppable = isOperationDragged
? dragType === 'reorder'
? isFromTheSameGroup(value, dragging)
: isCompatibleFromOtherGroup
: canHandleDrop;
dropType,
humanData: {
label,
groupLabel: group.groupLabel,
position: accessorIndex + 1,
},
}),
[columnId, group.groupId, accessorIndex, layerId, dropType, label, group.groupLabel]
);
// todo: simplify by id and use drop targets?
const reorderableGroup = useMemo(
() =>
group.accessors.map((a) => ({
columnId: a.columnId,
id: a.columnId,
groupId: group.groupId,
layerId,
group.accessors.map((g) => ({
id: g.columnId,
})),
[group, layerId]
[group.accessors]
);
return (
<div className="lnsLayerPanel__dimensionContainer" data-test-subj={group.dataTestSubj}>
<DragDrop
noKeyboardSupportYet={reorderableGroup.length < 2} // to be removed when navigating outside of groups is added
getAdditionalClassesOnEnter={getAdditionalClassesOnEnter}
getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable}
order={[2, layerIndex, groupIndex, accessorIndex]}
draggable
dragType={dragType}
dragType={isDraggedOperation(dragDropContext.dragging) ? 'move' : 'copy'}
dropType={dropType}
reorderableGroup={reorderableGroup.length > 1 ? reorderableGroup : undefined}
value={value}
label={label}
droppable={dragging && isDroppable}
onDrop={onDrop}
onDrop={(drag: DragDropIdentifier, selectedDropType?: DropType) =>
onDrop(drag, value, selectedDropType)
}
>
{children}
</DragDrop>

View file

@ -5,17 +5,26 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState, useEffect } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { generateId } from '../../../id_generator';
import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop';
import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types';
import { DragDrop, DragDropIdentifier } from '../../../drag_drop';
import { Datasource, VisualizationDimensionGroupConfig, DropType } from '../../../types';
import { LayerDatasourceDropProps } from './types';
const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', {
defaultMessage: 'Empty dimension',
});
const getAdditionalClassesOnDroppable = (dropType?: string) => {
if (dropType === 'move_incompatible' || dropType === 'replace_incompatible') {
return 'lnsDragDrop-notCompatible';
}
};
export function EmptyDimensionButton({
dragDropContext,
group,
layerDatasource,
layerDatasourceDropProps,
@ -25,48 +34,58 @@ export function EmptyDimensionButton({
onClick,
onDrop,
}: {
dragDropContext: DragContextState;
layerId: string;
groupIndex: number;
layerIndex: number;
onClick: (id: string) => void;
onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void;
onDrop: (
droppedItem: DragDropIdentifier,
dropTarget: DragDropIdentifier,
dropType?: DropType
) => void;
group: VisualizationDimensionGroupConfig;
layerDatasource: Datasource<unknown, unknown>;
layerDatasourceDropProps: LayerDatasourceDropProps;
}) {
const handleDrop = (droppedItem: DragDropIdentifier) => onDrop(droppedItem, value);
const itemIndex = group.accessors.length;
const value = useMemo(() => {
const newId = generateId();
return {
columnId: newId,
const [newColumnId, setNewColumnId] = useState<string>(generateId());
useEffect(() => {
setNewColumnId(generateId());
}, [itemIndex]);
const dropType = layerDatasource.getDropTypes({
...layerDatasourceDropProps,
columnId: newColumnId,
filterOperations: group.filterOperations,
groupId: group.groupId,
});
const value = useMemo(
() => ({
columnId: newColumnId,
groupId: group.groupId,
layerId,
isNew: true,
id: newId,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group.accessors.length, group.groupId, layerId]);
id: newColumnId,
dropType,
humanData: {
label,
groupLabel: group.groupLabel,
position: itemIndex + 1,
},
}),
[dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex]
);
return (
<div className="lnsLayerPanel__dimensionContainer" data-test-subj={group.dataTestSubj}>
<DragDrop
getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable}
value={value}
onDrop={handleDrop}
droppable={
Boolean(dragDropContext.dragging) &&
// Verify that the dragged item is not coming from the same group
// since this would be a duplicate
(!isDraggedOperation(dragDropContext.dragging) ||
dragDropContext.dragging.groupId !== group.groupId) &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: value.columnId,
filterOperations: group.filterOperations,
})
}
/* 2 to leave room for data panel and workspace, then go by layer index, then by group index */
order={[2, layerIndex, groupIndex, itemIndex]}
onDrop={(droppedItem, selectedDropType) => onDrop(droppedItem, value, selectedDropType)}
dropType={dropType}
>
<div className="lnsLayerPanel__dimension lnsLayerPanel__dimension--empty">
<EuiButtonEmpty

View file

@ -13,7 +13,7 @@ import {
createMockDatasource,
DatasourceMock,
} from '../../mocks';
import { ChildDragDropProvider, DroppableEvent, DragDrop } from '../../../drag_drop';
import { ChildDragDropProvider, DragDrop } from '../../../drag_drop';
import { EuiFormRow } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test/jest';
import { Visualization } from '../../../types';
@ -31,6 +31,7 @@ const defaultContext = {
keyboardMode: false,
setKeyboardMode: () => {},
setA11yMessage: jest.fn(),
registerDropTarget: jest.fn(),
};
describe('LayerPanel', () => {
@ -224,7 +225,7 @@ describe('LayerPanel', () => {
});
it('should not update the visualization if the datasource is incomplete', () => {
(generateId as jest.Mock).mockReturnValueOnce(`newid`);
(generateId as jest.Mock).mockReturnValue(`newid`);
const updateAll = jest.fn();
const updateDatasource = jest.fn();
@ -439,9 +440,14 @@ describe('LayerPanel', () => {
],
});
mockDatasource.canHandleDrop.mockReturnValue(true);
mockDatasource.getDropTypes.mockReturnValue('field_add');
const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' };
const draggingField = {
field: { name: 'dragged' },
indexPatternId: 'a',
id: '1',
humanData: { label: 'Label' },
};
const component = mountWithIntl(
<ChildDragDropProvider {...defaultContext} dragging={draggingField}>
@ -449,7 +455,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith(
expect(mockDatasource.getDropTypes).toHaveBeenCalledWith(
expect.objectContaining({
dragDropContext: expect.objectContaining({
dragging: draggingField,
@ -482,9 +488,16 @@ describe('LayerPanel', () => {
],
});
mockDatasource.canHandleDrop.mockImplementation(({ columnId }) => columnId !== 'a');
mockDatasource.getDropTypes.mockImplementation(({ columnId }) =>
columnId !== 'a' ? 'field_replace' : undefined
);
const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' };
const draggingField = {
field: { name: 'dragged' },
indexPatternId: 'a',
id: '1',
humanData: { label: 'Label' },
};
const component = mountWithIntl(
<ChildDragDropProvider {...defaultContext} dragging={draggingField}>
@ -492,13 +505,13 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith(
expect(mockDatasource.getDropTypes).toHaveBeenCalledWith(
expect.objectContaining({ columnId: 'a' })
);
expect(
component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('droppable')
).toEqual(false);
component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('dropType')
).toEqual(undefined);
component
.find('[data-test-subj="lnsGroup"] DragDrop')
@ -533,9 +546,15 @@ describe('LayerPanel', () => {
],
});
mockDatasource.canHandleDrop.mockReturnValue(true);
mockDatasource.getDropTypes.mockReturnValue('replace_compatible');
const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' };
const draggingOperation = {
layerId: 'first',
columnId: 'a',
groupId: 'a',
id: 'a',
humanData: { label: 'Label' },
};
const component = mountWithIntl(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
@ -543,7 +562,7 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith(
expect(mockDatasource.getDropTypes).toHaveBeenCalledWith(
expect.objectContaining({
dragDropContext: expect.objectContaining({
dragging: draggingOperation,
@ -588,7 +607,13 @@ describe('LayerPanel', () => {
],
});
const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' };
const draggingOperation = {
layerId: 'first',
columnId: 'a',
groupId: 'a',
id: 'a',
humanData: { label: 'Label' },
};
const component = mountWithIntl(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
@ -596,15 +621,10 @@ describe('LayerPanel', () => {
</ChildDragDropProvider>
);
component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, {
layerId: 'first',
columnId: 'b',
groupId: 'a',
id: 'b',
});
component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder');
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
groupId: 'a',
dropType: 'reorder',
droppedItem: draggingOperation,
})
);
@ -624,22 +644,24 @@ describe('LayerPanel', () => {
],
});
const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' };
const draggingOperation = {
layerId: 'first',
columnId: 'a',
groupId: 'a',
id: 'a',
humanData: { label: 'Label' },
};
const component = mountWithIntl(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
);
component.find('[data-test-subj="lnsGroup"] DragDrop').at(2).prop('onDrop')!(
(draggingOperation as unknown) as DroppableEvent
);
component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_in_group');
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
groupId: 'a',
dropType: 'duplicate_in_group',
droppedItem: draggingOperation,
isNew: true,
})
);
});

View file

@ -11,7 +11,7 @@ import React, { useContext, useState, useEffect, useMemo, useCallback } from 're
import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { NativeRenderer } from '../../../native_renderer';
import { StateSetter, Visualization } from '../../../types';
import { StateSetter, Visualization, DraggedOperation, DropType } from '../../../types';
import {
DragContext,
DragDropIdentifier,
@ -115,13 +115,19 @@ export function LayerPanel(
const layerDatasourceOnDrop = layerDatasource.onDrop;
const onDrop = useMemo(() => {
return (droppedItem: DragDropIdentifier, targetItem: DragDropIdentifier) => {
const { columnId, groupId, layerId: targetLayerId, isNew } = (targetItem as unknown) as {
groupId: string;
columnId: string;
layerId: string;
isNew?: boolean;
};
return (
droppedItem: DragDropIdentifier,
targetItem: DragDropIdentifier,
dropType?: DropType
) => {
if (!dropType) {
return;
}
const {
columnId,
groupId,
layerId: targetLayerId,
} = (targetItem as unknown) as DraggedOperation; // TODO: correct misleading name
const filterOperations =
groups.find(({ groupId: gId }) => gId === targetItem.groupId)?.filterOperations ||
@ -131,10 +137,9 @@ export function LayerPanel(
...layerDatasourceDropProps,
droppedItem,
columnId,
groupId,
layerId: targetLayerId,
isNew,
filterOperations,
dropType,
});
if (dropResult) {
updateVisualization(
@ -317,7 +322,6 @@ export function LayerPanel(
</ReorderProvider>
{group.supportsMoreColumns ? (
<EmptyDimensionButton
dragDropContext={dragDropContext}
group={group}
groupIndex={groupIndex}
layerId={layerId}

View file

@ -1323,7 +1323,7 @@ describe('editor_frame', () => {
getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => {
if (!dragging || dragging.id !== 'draggedField') {
setDragging({ id: 'draggedField' });
setDragging({ id: 'draggedField', humanData: { label: 'draggedField' } });
}
},
},
@ -1344,8 +1344,9 @@ describe('editor_frame', () => {
indexPatternId: '1',
field: {},
id: '1',
humanData: { label: 'draggedField' },
},
{ id: 'lnsWorkspace' }
'field_replace'
);
});
@ -1424,7 +1425,7 @@ describe('editor_frame', () => {
getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()],
renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => {
if (!dragging || dragging.id !== 'draggedField') {
setDragging({ id: 'draggedField' });
setDragging({ id: 'draggedField', humanData: { label: '1' } });
}
},
},
@ -1445,8 +1446,11 @@ describe('editor_frame', () => {
indexPatternId: '1',
field: {},
id: '1',
humanData: {
label: 'label',
},
},
{ id: 'lnsWorkspace' }
'field_replace'
);
});

View file

@ -532,7 +532,7 @@ describe('suggestion helpers', () => {
{
mockindexpattern: { state: mockDatasourceState, isLoading: false },
},
{ id: 'myfield' },
{ id: 'myfield', humanData: { label: 'myfieldLabel' } },
];
});
@ -543,6 +543,9 @@ describe('suggestion helpers', () => {
mockDatasourceState,
{
id: 'myfield',
humanData: {
label: 'myfieldLabel',
},
}
);
});

View file

@ -775,7 +775,7 @@ describe('workspace_panel', () => {
let mockGetSuggestionForField: jest.Mock;
let frame: jest.Mocked<FramePublicAPI>;
const draggedField = { id: 'field' };
const draggedField = { id: 'field', humanData: { label: 'Label' } };
beforeEach(() => {
frame = createMockFramePublicAPI();
@ -793,6 +793,7 @@ describe('workspace_panel', () => {
keyboardMode={false}
setKeyboardMode={() => {}}
setA11yMessage={() => {}}
registerDropTarget={jest.fn()}
>
<WorkspacePanel
activeDatasourceId={'mock'}
@ -831,7 +832,7 @@ describe('workspace_panel', () => {
});
initComponent();
instance.find(DragDrop).prop('onDrop')!(draggedField, { id: 'lnsWorkspace' });
instance.find(DragDrop).prop('onDrop')!(draggedField, 'field_replace');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SWITCH_VISUALIZATION',
@ -850,12 +851,12 @@ describe('workspace_panel', () => {
visualizationState: {},
});
initComponent();
expect(instance.find(DragDrop).prop('droppable')).toBeTruthy();
expect(instance.find(DragDrop).prop('dropType')).toBeTruthy();
});
it('should refuse to drop if there are no suggestions', () => {
initComponent();
expect(instance.find(DragDrop).prop('droppable')).toBeFalsy();
expect(instance.find(DragDrop).prop('dropType')).toBeFalsy();
});
});
});

View file

@ -84,7 +84,17 @@ interface WorkspaceState {
expandError: boolean;
}
const workspaceDropValue = { id: 'lnsWorkspace' };
const dropProps = {
value: {
id: 'lnsWorkspace',
humanData: {
label: i18n.translate('xpack.lens.editorFrame.workspaceLabel', {
defaultMessage: 'Workspace',
}),
},
},
order: [1, 0, 0, 0],
};
// Exported for testing purposes only.
export const WorkspacePanel = React.memo(function WorkspacePanel({
@ -302,9 +312,10 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({
className="lnsWorkspacePanel__dragDrop"
dataTestSubj="lnsWorkspace"
draggable={false}
droppable={Boolean(suggestionForDraggedField)}
dropType={suggestionForDraggedField ? 'field_add' : undefined}
onDrop={onDrop}
value={workspaceDropValue}
value={dropProps.value}
order={dropProps.order}
>
<div>
{renderVisualization()}

View file

@ -88,7 +88,7 @@ export function createMockDatasource(id: string): DatasourceMock {
uniqueLabels: jest.fn((_state) => ({})),
renderDimensionTrigger: jest.fn(),
renderDimensionEditor: jest.fn(),
canHandleDrop: jest.fn(),
getDropTypes: jest.fn(),
onDrop: jest.fn(),
// this is an additional property which doesn't exist on real datasources

View file

@ -281,7 +281,7 @@ describe('IndexPattern Data Panel', () => {
setState={setStateSpy}
dragDropContext={{
...createMockedDragDropContext(),
dragging: { id: '1' },
dragging: { id: '1', humanData: { label: 'Label' } },
}}
/>
);
@ -303,7 +303,7 @@ describe('IndexPattern Data Panel', () => {
setState={jest.fn()}
dragDropContext={{
...createMockedDragDropContext(),
dragging: { id: '1' },
dragging: { id: '1', humanData: { label: 'Label' } },
}}
changeIndexPattern={jest.fn()}
/>
@ -338,7 +338,7 @@ describe('IndexPattern Data Panel', () => {
setState,
dragDropContext: {
...createMockedDragDropContext(),
dragging: { id: '1' },
dragging: { id: '1', humanData: { label: 'Label' } },
},
dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' },
state: {

View file

@ -106,9 +106,6 @@ const bytesColumn: IndexPatternColumn = {
*
* - Dimension trigger: Not tested here
* - Dimension editor component: First half of the tests
*
* - canHandleDrop: Tests for dropping of fields or other dimensions
* - onDrop: Correct application of drop logic
*/
describe('IndexPatternDimensionEditorPanel', () => {
let state: IndexPatternPrivateState;

View file

@ -12,39 +12,46 @@ import {
DraggedOperation,
} from '../../types';
import { IndexPatternColumn } from '../indexpattern';
import { insertOrReplaceColumn } from '../operations';
import { insertOrReplaceColumn, deleteColumn } from '../operations';
import { mergeLayer } from '../state_helpers';
import { hasField, isDraggedField } from '../utils';
import { IndexPatternPrivateState, IndexPatternField } from '../types';
import { IndexPatternPrivateState, IndexPatternField, DraggedField } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { getOperationSupportMatrix, OperationSupportMatrix } from './operation_support';
import { getOperationSupportMatrix } from './operation_support';
type DropHandlerProps<T = DraggedOperation> = Pick<
DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>,
'columnId' | 'setState' | 'state' | 'layerId' | 'droppedItem'
> & {
type DropHandlerProps<T> = DatasourceDimensionDropHandlerProps<IndexPatternPrivateState> & {
droppedItem: T;
operationSupportMatrix: OperationSupportMatrix;
};
export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
export function getDropTypes(
props: DatasourceDimensionDropProps<IndexPatternPrivateState> & { groupId: string }
) {
const { dragging } = props.dragDropContext;
if (!dragging) {
return;
}
const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId;
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
return !!getOperationSupportMatrix(props).operationByField[field.name];
}
const currentColumn = props.state.layers[props.layerId].columns[props.columnId];
if (isDraggedField(dragging)) {
const currentColumn = props.state.layers[props.layerId].columns[props.columnId];
return Boolean(
layerIndexPatternId === dragging.indexPatternId &&
Boolean(hasOperationForField(dragging.field)) &&
(!currentColumn ||
(hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name))
);
if (
!!(layerIndexPatternId === dragging.indexPatternId && hasOperationForField(dragging.field))
) {
if (!currentColumn) {
return 'field_add';
} else if (
(hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name) ||
!hasField(currentColumn)
) {
return 'field_replace';
}
}
return;
}
if (
@ -52,12 +59,72 @@ export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPr
dragging.layerId === props.layerId &&
props.columnId !== dragging.columnId
) {
const op = props.state.layers[props.layerId].columns[dragging.columnId];
return props.filterOperations(op);
// same group
if (props.groupId === dragging.groupId) {
if (currentColumn) {
return 'reorder';
}
return 'duplicate_in_group';
}
// compatible group
const op = props.state.layers[dragging.layerId].columns[dragging.columnId];
if (
!op ||
(currentColumn &&
hasField(currentColumn) &&
hasField(op) &&
currentColumn.sourceField === op.sourceField)
) {
return;
}
if (props.filterOperations(op)) {
if (currentColumn) {
return 'replace_compatible'; // in the future also 'swap_compatible' and 'duplicate_compatible'
} else {
return 'move_compatible'; // in the future also 'duplicate_compatible'
}
}
// suggest
const field =
hasField(op) && props.state.indexPatterns[layerIndexPatternId].getFieldByName(op.sourceField);
if (field && hasOperationForField(field)) {
if (currentColumn) {
return 'replace_incompatible'; // in the future also 'swap_incompatible', 'duplicate_incompatible'
} else {
return 'move_incompatible'; // in the future also 'duplicate_incompatible'
}
}
}
return false;
}
export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) {
const { droppedItem, dropType } = props;
if (dropType === 'field_add' || dropType === 'field_replace') {
return operationOnDropMap[dropType]({
...props,
droppedItem: droppedItem as DraggedField,
});
}
return operationOnDropMap[dropType]({
...props,
droppedItem: droppedItem as DraggedOperation,
});
}
const operationOnDropMap = {
field_add: onFieldDrop,
field_replace: onFieldDrop,
reorder: onReorderDrop,
duplicate_in_group: onSameGroupDuplicateDrop,
move_compatible: onMoveDropToCompatibleGroup,
replace_compatible: onMoveDropToCompatibleGroup,
move_incompatible: onMoveDropToNonCompatibleGroup,
replace_incompatible: onMoveDropToNonCompatibleGroup,
};
function reorderElements(items: string[], dest: string, src: string) {
const result = items.filter((c) => c !== src);
const destIndex = items.findIndex((c) => c === src);
@ -69,7 +136,13 @@ function reorderElements(items: string[], dest: string, src: string) {
return result;
}
const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: DropHandlerProps) => {
function onReorderDrop({
columnId,
setState,
state,
layerId,
droppedItem,
}: DropHandlerProps<DraggedOperation>) {
setState(
mergeLayer({
state,
@ -85,15 +158,98 @@ const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: Drop
);
return true;
};
}
const onMoveDropToCompatibleGroup = ({
function onMoveDropToNonCompatibleGroup(props: DropHandlerProps<DraggedOperation>) {
const { columnId, setState, state, layerId, droppedItem } = props;
const layer = state.layers[layerId];
const op = { ...layer.columns[droppedItem.columnId] };
const field =
hasField(op) && state.indexPatterns[layer.indexPatternId].getFieldByName(op.sourceField);
if (!field) {
return false;
}
const operationSupportMatrix = getOperationSupportMatrix(props);
const operationsForNewField = operationSupportMatrix.operationByField[field.name];
if (!operationsForNewField || operationsForNewField.size === 0) {
return false;
}
const currentIndexPattern = state.indexPatterns[layer.indexPatternId];
const newLayer = insertOrReplaceColumn({
layer: deleteColumn({
layer,
columnId: droppedItem.columnId,
indexPattern: currentIndexPattern,
}),
columnId,
indexPattern: currentIndexPattern,
op: operationsForNewField.values().next().value,
field,
});
trackUiEvent('drop_onto_dimension');
setState(
mergeLayer({
state,
layerId,
newLayer: {
...newLayer,
},
})
);
return { deleted: droppedItem.columnId };
}
function onSameGroupDuplicateDrop({
columnId,
setState,
state,
layerId,
droppedItem,
}: DropHandlerProps) => {
}: DropHandlerProps<DraggedOperation>) {
const layer = state.layers[layerId];
const op = { ...layer.columns[droppedItem.columnId] };
const newColumns = {
...layer.columns,
[columnId]: op,
};
const newColumnOrder = [...layer.columnOrder];
// put a new bucketed dimension just in front of the metric dimensions, a metric dimension in the back of the array
// TODO this logic does not take into account groups - we probably need to pass the current
// group config to this position to place the column right
const insertionIndex = op.isBucketed
? newColumnOrder.findIndex((id) => !newColumns[id].isBucketed)
: newColumnOrder.length;
newColumnOrder.splice(insertionIndex, 0, columnId);
// Time to replace
setState(
mergeLayer({
state,
layerId,
newLayer: {
columnOrder: newColumnOrder,
columns: newColumns,
},
})
);
return true;
}
function onMoveDropToCompatibleGroup({
columnId,
setState,
state,
layerId,
droppedItem,
}: DropHandlerProps<DraggedOperation>) {
const layer = state.layers[layerId];
const op = { ...layer.columns[droppedItem.columnId] };
const newColumns = { ...layer.columns };
@ -122,18 +278,14 @@ const onMoveDropToCompatibleGroup = ({
})
);
return { deleted: droppedItem.columnId };
};
}
function onFieldDrop(props: DropHandlerProps<DraggedField>) {
const { columnId, setState, state, layerId, droppedItem } = props;
const operationSupportMatrix = getOperationSupportMatrix(props);
const onFieldDrop = ({
columnId,
setState,
state,
layerId,
droppedItem,
operationSupportMatrix,
}: DropHandlerProps<unknown>) => {
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
return !!operationSupportMatrix.operationByField[field.name];
}
if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) {
@ -176,55 +328,4 @@ const onFieldDrop = ({
trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty');
setState(mergeLayer({ state, layerId, newLayer }));
return true;
};
export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
const { setState, state, droppedItem, columnId, layerId, groupId, isNew } = props;
if (!isDraggedOperation(droppedItem)) {
return onFieldDrop({
columnId,
setState,
state,
layerId,
droppedItem,
operationSupportMatrix,
});
}
const isExistingFromSameGroup =
droppedItem.groupId === groupId && droppedItem.columnId !== columnId && !isNew;
// reorder in the same group
if (isExistingFromSameGroup) {
return onReorderDrop({
columnId,
setState,
state,
layerId,
droppedItem,
operationSupportMatrix,
});
}
// replace or move to compatible group
const isFromOtherGroup = droppedItem.groupId !== groupId && droppedItem.layerId === layerId;
if (isFromOtherGroup) {
const layer = state.layers[layerId];
const op = { ...layer.columns[droppedItem.columnId] };
if (props.filterOperations(op)) {
return onMoveDropToCompatibleGroup({
columnId,
setState,
state,
layerId,
droppedItem,
operationSupportMatrix,
});
}
}
return false;
}

View file

@ -1,4 +1,5 @@
.lnsFieldItem {
width: 100%;
.lnsFieldItem__infoIcon {
visibility: hidden;
opacity: 0;
@ -13,6 +14,23 @@
transition: opacity $euiAnimSpeedFast ease-in-out 1s;
}
}
&:focus,
&:focus-within,
&.kbnFieldButton-isActive {
animation: none !important; // sass-lint:disable-line no-important
}
&:focus .kbnFieldButton__name span,
&:focus-within .kbnFieldButton__name span,
&.kbnFieldButton-isActive .kbnFieldButton__name span {
background-color: transparentize($euiColorVis1, .9) !important;
text-decoration: underline !important;
}
}
.kbnFieldButton__name {
transition: background-color $euiAnimSpeedFast ease-in-out;
}
.lnsFieldItem--missing {

View file

@ -48,11 +48,10 @@ import {
} from '../../../../../src/plugins/data/public';
import { FieldButton } from '../../../../../src/plugins/kibana_react/public';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { DraggedField } from './indexpattern';
import { DragDrop, DragDropIdentifier } from '../drag_drop';
import { DatasourceDataPanelProps, DataType } from '../types';
import { BucketedAggregation, FieldStatsResponse } from '../../common';
import { IndexPattern, IndexPatternField } from './types';
import { IndexPattern, IndexPatternField, DraggedField } from './types';
import { LensFieldIcon } from './lens_field_icon';
import { trackUiEvent } from '../lens_ui_telemetry';
@ -103,6 +102,8 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
dateRange,
filters,
hideDetails,
itemIndex,
groupIndex,
dropOntoWorkspace,
} = props;
@ -167,9 +168,18 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
}
const value = useMemo(
() => ({ field, indexPatternId: indexPattern.id, id: field.name } as DraggedField),
[field, indexPattern.id]
() => ({
field,
indexPatternId: indexPattern.id,
id: field.name,
humanData: {
label: field.displayName,
position: itemIndex + 1,
},
}),
[field, indexPattern.id, itemIndex]
);
const order = useMemo(() => [0, groupIndex, itemIndex], [groupIndex, itemIndex]);
const lensFieldIcon = <LensFieldIcon type={field.type as DataType} />;
const lensInfoIcon = (
@ -204,9 +214,8 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
container={document.querySelector<HTMLElement>('.application') || undefined}
button={
<DragDrop
noKeyboardSupportYet
draggable
label={field.displayName}
order={order}
value={value}
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
>
@ -271,6 +280,9 @@ function FieldPanelHeader({
indexPatternId,
id: field.name,
field,
humanData: {
label: field.displayName,
},
};
return (
@ -641,11 +653,7 @@ const DragToWorkspaceButton = ({
dropOntoWorkspace,
isEnabled,
}: {
field: {
indexPatternId: string;
id: string;
field: IndexPatternField;
};
field: DraggedField;
dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace'];
isEnabled: boolean;
}) => {

View file

@ -31,7 +31,7 @@ import { toExpression } from './to_expression';
import {
IndexPatternDimensionTrigger,
IndexPatternDimensionEditor,
canHandleDrop,
getDropTypes,
onDrop,
} from './dimension_panel';
import { IndexPatternDataPanel } from './datapanel';
@ -44,7 +44,7 @@ import {
import { isDraggedField, normalizeOperationDataType } from './utils';
import { LayerPanel } from './layerpanel';
import { IndexPatternColumn, getErrorMessages, IncompleteColumn } from './operations';
import { IndexPatternField, IndexPatternPrivateState, IndexPatternPersistedState } from './types';
import { IndexPatternPrivateState, IndexPatternPersistedState } from './types';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public';
@ -52,15 +52,9 @@ import { mergeLayer } from './state_helpers';
import { Datasource, StateSetter } from '../types';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { deleteColumn, isReferenced } from './operations';
import { DragDropIdentifier } from '../drag_drop/providers';
export { OperationType, IndexPatternColumn, deleteColumn } from './operations';
export type DraggedField = DragDropIdentifier & {
field: IndexPatternField;
indexPatternId: string;
};
export function columnToOperation(column: IndexPatternColumn, uniqueLabel?: string): Operation {
const { dataType, label, isBucketed, scale } = column;
return {
@ -314,8 +308,7 @@ export function getIndexPatternDatasource({
domElement
);
},
canHandleDrop,
getDropTypes,
onDrop,
// Reset the temporary invalid state when closing the editor, but don't

View file

@ -253,5 +253,6 @@ export function createMockedDragDropContext(): jest.Mocked<DragContextState> {
keyboardMode: false,
setKeyboardMode: jest.fn(),
setA11yMessage: jest.fn(),
registerDropTarget: jest.fn(),
};
}

View file

@ -8,6 +8,7 @@
import { IFieldType } from 'src/plugins/data/common';
import { IndexPatternColumn, IncompleteColumn } from './operations';
import { IndexPatternAggRestrictions } from '../../../../../src/plugins/data/public';
import { DragDropIdentifier } from '../drag_drop/providers';
export {
IndexPatternColumn,
@ -32,6 +33,10 @@ export {
MovingAverageIndexPatternColumn,
} from './operations';
export type DraggedField = DragDropIdentifier & {
field: IndexPatternField;
indexPatternId: string;
};
export interface IndexPattern {
id: string;
fields: IndexPatternField[];

View file

@ -6,8 +6,7 @@
*/
import { DataType } from '../types';
import { IndexPattern, IndexPatternLayer } from './types';
import { DraggedField } from './indexpattern';
import { IndexPattern, IndexPatternLayer, DraggedField } from './types';
import type {
BaseIndexPatternColumn,
FieldBasedIndexPatternColumn,

View file

@ -138,6 +138,16 @@ export type TableChangeType =
| 'reorder'
| 'layers';
export type DropType =
| 'field_add'
| 'field_replace'
| 'reorder'
| 'duplicate_in_group'
| 'move_compatible'
| 'replace_compatible'
| 'move_incompatible'
| 'replace_incompatible';
export interface DatasourceSuggestion<T = unknown> {
state: T;
table: TableSuggestion;
@ -179,7 +189,9 @@ export interface Datasource<T = unknown, P = unknown> {
renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps<T>) => void;
renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps<T>) => void;
renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps<T>) => void;
canHandleDrop: (props: DatasourceDimensionDropProps<T>) => boolean;
getDropTypes: (
props: DatasourceDimensionDropProps<T> & { groupId: string }
) => DropType | undefined;
onDrop: (props: DatasourceDimensionDropHandlerProps<T>) => false | true | { deleted: string };
updateStateOnCloseDimension?: (props: {
layerId: string;
@ -299,13 +311,11 @@ export type DatasourceDimensionDropProps<T> = SharedDimensionProps & {
state: T;
setState: StateSetter<T>;
dragDropContext: DragContextState;
isReorder?: boolean;
};
export type DatasourceDimensionDropHandlerProps<T> = DatasourceDimensionDropProps<T> & {
droppedItem: unknown;
groupId: string;
isNew?: boolean;
dropType: DropType;
};
export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip';

View file

@ -11190,8 +11190,6 @@
"xpack.lens.dimensionContainer.close": "閉じる",
"xpack.lens.dimensionContainer.closeConfiguration": "構成を閉じる",
"xpack.lens.discover.visualizeFieldLegend": "Visualize フィールド",
"xpack.lens.dragDrop.elementLifted": "位置 {position} のアイテム {itemLabel} を持ち上げました。",
"xpack.lens.dragDrop.elementMoved": "位置 {prevPosition} から位置 {position} までアイテム {itemLabel} を移動しました",
"xpack.lens.editLayerSettings": "レイヤー設定を編集",
"xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}",
"xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました",

View file

@ -11219,8 +11219,6 @@
"xpack.lens.dimensionContainer.close": "关闭",
"xpack.lens.dimensionContainer.closeConfiguration": "关闭配置",
"xpack.lens.discover.visualizeFieldLegend": "可视化字段",
"xpack.lens.dragDrop.elementLifted": "您已将项目 {itemLabel} 提升到位置 {position}",
"xpack.lens.dragDrop.elementMoved": "您已将项目 {itemLabel} 从位置 {prevPosition} 移到位置 {position}",
"xpack.lens.editLayerSettings": "编辑图层设置",
"xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}",
"xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误",

View file

@ -53,7 +53,7 @@ export default function ({ getPageObjects }: FtrProviderContext) {
});
it('should reorder the elements for the table', async () => {
await PageObjects.lens.reorderDimensions('lnsDatatable_column', 2, 0);
await PageObjects.lens.reorderDimensions('lnsDatatable_column', 3, 1);
await PageObjects.header.waitUntilLoadingHasFinished();
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsDatatable_column')).to.eql([
'Top values of @message.raw',
@ -83,6 +83,129 @@ export default function ({ getPageObjects }: FtrProviderContext) {
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql(['Top values of @message.raw']);
});
it('should move the column to non-compatible dimension group', async () => {
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql(['Top values of @message.raw']);
await PageObjects.lens.dragDimensionToDimension(
'lnsXY_splitDimensionPanel > lns-dimensionTrigger',
'lnsXY_yDimensionPanel > lns-dimensionTrigger'
);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql([]);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql([]);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Unique count of @message.raw',
]);
});
it('should duplicate the column when dragging to empty dimension in the same group', async () => {
await PageObjects.lens.dragDimensionToDimension(
'lnsXY_yDimensionPanel > lns-dimensionTrigger',
'lnsXY_yDimensionPanel > lns-empty-dimension'
);
await PageObjects.lens.dragDimensionToDimension(
'lnsXY_yDimensionPanel > lns-dimensionTrigger',
'lnsXY_yDimensionPanel > lns-empty-dimension'
);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Unique count of @message.raw',
'Unique count of @message.raw [1]',
'Unique count of @message.raw [2]',
]);
});
it('should duplicate the column when dragging to empty dimension in the same group', async () => {
await PageObjects.lens.dragDimensionToDimension(
'lnsXY_yDimensionPanel > lns-dimensionTrigger',
'lnsXY_xDimensionPanel > lns-empty-dimension'
);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Unique count of @message.raw',
'Unique count of @message.raw [1]',
]);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([
'Top values of @message.raw',
]);
});
});
describe('keyboard drag and drop', () => {
it('should drop a field to workspace', async () => {
await PageObjects.visualize.navigateToNewVisualization();
await PageObjects.visualize.clickVisType('lens');
await PageObjects.lens.goToTimeRange();
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.lens.dragFieldWithKeyboard('@timestamp');
expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel')).to.eql(
'@timestamp'
);
});
it('should drop a field to empty dimension', async () => {
await PageObjects.lens.dragFieldWithKeyboard('bytes', 4);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Count of records',
'Average of bytes',
]);
await PageObjects.lens.dragFieldWithKeyboard('@message.raw', 1, true);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql(['Top values of @message.raw']);
});
it('should drop a field to an existing dimension replacing the old one', async () => {
await PageObjects.lens.dragFieldWithKeyboard('clientip', 1, true);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql(['Top values of clientip']);
});
it('should duplicate an element in a group', async () => {
await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 0, 1);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Count of records',
'Average of bytes',
'Count of records [1]',
]);
});
it('should move dimension to compatible dimension', async () => {
await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_xDimensionPanel', 0, 5);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql(
[]
);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql(['@timestamp']);
await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_splitDimensionPanel', 0, 5, true);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_xDimensionPanel')).to.eql([
'@timestamp',
]);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql([]);
});
it('should move dimension to incompatible dimension', async () => {
await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 1, 2);
expect(
await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel')
).to.eql(['bytes']);
await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_xDimensionPanel', 0, 2);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Count of records',
'Unique count of @timestamp',
]);
});
it('should reorder elements with keyboard', async () => {
await PageObjects.lens.dimensionKeyboardReorder('lnsXY_yDimensionPanel', 0, 1);
expect(await PageObjects.lens.getDimensionTriggersTexts('lnsXY_yDimensionPanel')).to.eql([
'Unique count of @timestamp',
'Count of records',
]);
});
});
describe('workspace drop', () => {

View file

@ -163,6 +163,73 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
await PageObjects.header.waitUntilLoadingHasFinished();
},
/**
* Copies field to chosen destination that is defined by distance of `steps`
* (right arrow presses) from it
*
* @param fieldName - the desired field for the dimension
* @param steps - number of steps user has to press right
* @param reverse - defines the direction of going through drops
* */
async dragFieldWithKeyboard(fieldName: string, steps = 1, reverse = false) {
const field = await find.byCssSelector(
`[data-test-subj="lnsDragDrop_draggable-${fieldName}"] [data-test-subj="lnsDragDrop-keyboardHandler"]`
);
await field.focus();
await browser.pressKeys(browser.keys.ENTER);
for (let i = 0; i < steps; i++) {
await browser.pressKeys(reverse ? browser.keys.LEFT : browser.keys.RIGHT);
}
await browser.pressKeys(browser.keys.ENTER);
await PageObjects.header.waitUntilLoadingHasFinished();
},
/**
* Selects draggable element and moves it by number of `steps`
*
* @param group - the group of the element
* @param index - the index of the element in the group
* @param steps - number of steps of presses right or left
* @param reverse - defines the direction of going through drops
* */
async dimensionKeyboardDragDrop(group: string, index = 0, steps = 1, reverse = false) {
const elements = await find.allByCssSelector(
`[data-test-subj="${group}"] [data-test-subj="lnsDragDrop-keyboardHandler"]`
);
const el = elements[index];
await el.focus();
await browser.pressKeys(browser.keys.ENTER);
for (let i = 0; i < steps; i++) {
await browser.pressKeys(reverse ? browser.keys.LEFT : browser.keys.RIGHT);
}
await browser.pressKeys(browser.keys.ENTER);
await PageObjects.header.waitUntilLoadingHasFinished();
},
/**
* Selects draggable element and reorders it by number of `steps`
*
* @param group - the group of the element
* @param index - the index of the element in the group
* @param steps - number of steps of presses right or left
* @param reverse - defines the direction of going through drops
* */
async dimensionKeyboardReorder(group: string, index = 0, steps = 1, reverse = false) {
const elements = await find.allByCssSelector(
`[data-test-subj="${group}"] [data-test-subj="lnsDragDrop-keyboardHandler"]`
);
const el = elements[index];
await el.focus();
await browser.pressKeys(browser.keys.ENTER);
for (let i = 0; i < steps; i++) {
await browser.pressKeys(reverse ? browser.keys.ARROW_UP : browser.keys.ARROW_DOWN);
}
await browser.pressKeys(browser.keys.ENTER);
await PageObjects.header.waitUntilLoadingHasFinished();
},
/**
* Drags field to dimension trigger
*
@ -194,16 +261,12 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
/**
* Reorder elements within the group
*
* @param startIndex - the index of dragging element
* @param endIndex - the index of drop
* @param startIndex - the index of dragging element starting from 1
* @param endIndex - the index of drop starting from 1
* */
async reorderDimensions(dimension: string, startIndex: number, endIndex: number) {
const dragging = `[data-test-subj='${dimension}']:nth-of-type(${
startIndex + 1
}) .lnsDragDrop`;
const dropping = `[data-test-subj='${dimension}']:nth-of-type(${
endIndex + 1
}) [data-test-subj='lnsDragDrop-reorderableDropLayer'`;
const dragging = `[data-test-subj='${dimension}']:nth-of-type(${startIndex}) .lnsDragDrop`;
const dropping = `[data-test-subj='${dimension}']:nth-of-type(${endIndex}) [data-test-subj='lnsDragDrop-reorderableDropLayer'`;
await browser.html5DragAndDrop(dragging, dropping);
await PageObjects.header.waitUntilLoadingHasFinished();
},