[Lens] Refactor reorder drag and drop (#88578)

This commit is contained in:
Marta Bondyra 2021-02-01 11:54:16 +01:00 committed by GitHub
parent e31b6a8c91
commit 1b8c3c1dcc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 2098 additions and 1176 deletions

View file

@ -289,13 +289,13 @@ export async function BrowserProvider({ getService }: FtrProviderContext) {
}
const origin = document.querySelector(arguments[0]);
const target = document.querySelector(arguments[1]);
const dragStartEvent = createEvent('dragstart');
dispatchEvent(origin, dragStartEvent);
setTimeout(() => {
const dropEvent = createEvent('drop');
const target = document.querySelector(arguments[1]);
dispatchEvent(target, dropEvent, dragStartEvent.dataTransfer);
const dragEndEvent = createEvent('dragend');
dispatchEvent(origin, dragEndEvent, dropEvent.dataTransfer);

View file

@ -13,10 +13,8 @@ exports[`DragDrop items that have droppable=false get special styling when anoth
<button
className="lnsDragDrop lnsDragDrop-isDroppable lnsDragDrop-isNotDroppable"
data-test-subj="lnsDragDrop"
onDragEnd={[Function]}
onDragLeave={[Function]}
onDragOver={[Function]}
onDragStart={[Function]}
onDrop={[Function]}
>
Hello!
@ -24,11 +22,19 @@ exports[`DragDrop items that have droppable=false get special styling when anoth
`;
exports[`DragDrop renders if nothing is being dragged 1`] = `
<button
class="lnsDragDrop lnsDragDrop-isDraggable"
data-test-subj="lnsDragDrop"
draggable="true"
>
Hello!
</button>
<div>
<button
aria-describedby="lnsDragDrop-keyboardInstructions"
aria-label="dragging"
class="euiScreenReaderOnly--showOnFocus lnsDragDrop__keyboardHandler"
data-test-subj="lnsDragDrop-keyboardHandler"
/>
<button
class="lnsDragDrop lnsDragDrop-isDraggable"
data-test-subj="lnsDragDrop"
draggable="true"
>
Hello!
</button>
</div>
`;

View file

@ -52,7 +52,7 @@
}
}
.lnsDragDrop__reorderableContainer {
.lnsDragDrop__container {
position: relative;
}
@ -63,11 +63,18 @@
height: calc(100% + #{$lnsLayerPanelDimensionMargin});
}
.lnsDragDrop-isReorderable {
.lnsDragDrop-translatableDrop {
transform: translateY(0);
transition: transform $euiAnimSpeedFast ease-in-out;
pointer-events: none;
}
.lnsDragDrop-translatableDrag {
transform: translateY(0);
transition: transform $euiAnimSpeedFast ease-in-out;
position: relative;
}
// Draggable item when it is moving
.lnsDragDrop-isHidden {
opacity: 0;

View file

@ -6,11 +6,33 @@
import React from 'react';
import { render, mount } from 'enzyme';
import { DragDrop, ReorderableDragDrop, DropToHandler, DropHandler } from './drag_drop';
import { ChildDragDropProvider, ReorderProvider } from './providers';
import { DragDrop, DropHandler } from './drag_drop';
import {
ChildDragDropProvider,
DragContextState,
ReorderProvider,
DragDropIdentifier,
ActiveDropTarget,
} from './providers';
import { act } from 'react-dom/test-utils';
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' };
test('renders if nothing is being dragged', () => {
@ -26,7 +48,7 @@ describe('DragDrop', () => {
test('dragover calls preventDefault if droppable is true', () => {
const preventDefault = jest.fn();
const component = mount(
<DragDrop droppable>
<DragDrop droppable value={value}>
<button>Hello!</button>
</DragDrop>
);
@ -39,7 +61,7 @@ describe('DragDrop', () => {
test('dragover does not call preventDefault if droppable is false', () => {
const preventDefault = jest.fn();
const component = mount(
<DragDrop>
<DragDrop value={value}>
<button>Hello!</button>
</DragDrop>
);
@ -51,13 +73,9 @@ describe('DragDrop', () => {
test('dragstart sets dragging in the context', async () => {
const setDragging = jest.fn();
const dataTransfer = {
setData: jest.fn(),
getData: jest.fn(),
};
const component = mount(
<ChildDragDropProvider dragging={value} setDragging={setDragging}>
<ChildDragDropProvider {...defaultContext} dragging={value} setDragging={setDragging}>
<DragDrop value={value} draggable={true} label="drag label">
<button>Hello!</button>
</DragDrop>
@ -79,7 +97,11 @@ describe('DragDrop', () => {
const onDrop = jest.fn();
const component = mount(
<ChildDragDropProvider dragging={{ id: '2', label: 'hi' }} setDragging={setDragging}>
<ChildDragDropProvider
{...defaultContext}
dragging={{ id: '2', label: 'hi' }}
setDragging={setDragging}
>
<DragDrop onDrop={onDrop} droppable={true} value={value}>
<button>Hello!</button>
</DragDrop>
@ -93,7 +115,7 @@ describe('DragDrop', () => {
expect(preventDefault).toBeCalled();
expect(stopPropagation).toBeCalled();
expect(setDragging).toBeCalledWith(undefined);
expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' });
expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }, { id: '1', label: 'hello' });
});
test('drop function is not called on droppable=false', async () => {
@ -103,7 +125,7 @@ describe('DragDrop', () => {
const onDrop = jest.fn();
const component = mount(
<ChildDragDropProvider dragging={{ id: 'hi' }} setDragging={setDragging}>
<ChildDragDropProvider {...defaultContext} dragging={{ id: 'hi' }} setDragging={setDragging}>
<DragDrop onDrop={onDrop} droppable={false} value={value}>
<button>Hello!</button>
</DragDrop>
@ -127,6 +149,7 @@ describe('DragDrop', () => {
throw x;
}}
droppable
value={value}
>
<button>Hello!</button>
</DragDrop>
@ -137,11 +160,11 @@ describe('DragDrop', () => {
test('items that have droppable=false get special styling when another item is dragged', () => {
const component = mount(
<ChildDragDropProvider dragging={value} setDragging={() => {}}>
<ChildDragDropProvider {...defaultContext} dragging={value}>
<DragDrop value={value} draggable={true} label="a">
<button>Hello!</button>
</DragDrop>
<DragDrop onDrop={(x: unknown) => {}} droppable={false}>
<DragDrop onDrop={(x: unknown) => {}} droppable={false} value={{ id: '2' }}>
<button>Hello!</button>
</DragDrop>
</ChildDragDropProvider>
@ -153,17 +176,69 @@ describe('DragDrop', () => {
test('additional styles are reflected in the className until drop', () => {
let dragging: { id: '1' } | undefined;
const getAdditionalClasses = jest.fn().mockReturnValue('additional');
let activeDropTarget;
const component = mount(
<ChildDragDropProvider
{...defaultContext}
dragging={dragging}
setDragging={() => {
dragging = { id: '1' };
}}
setActiveDropTarget={(val) => {
activeDropTarget = { activeDropTarget: val };
}}
activeDropTarget={activeDropTarget}
>
<DragDrop value={{ label: 'ignored', id: '3' }} draggable={true} label="a">
<button>Hello!</button>
</DragDrop>
<DragDrop
value={value}
onDrop={(x: unknown) => {}}
droppable
getAdditionalClassesOnEnter={getAdditionalClasses}
>
<button>Hello!</button>
</DragDrop>
</ChildDragDropProvider>
);
component
.find('[data-test-subj="lnsDragDrop"]')
.first()
.simulate('dragstart', { dataTransfer });
jest.runAllTimers();
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover');
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop');
expect(component.find('.additional')).toHaveLength(0);
});
test('additional enter styles are reflected in the className until dragleave', () => {
let dragging: { id: '1' } | undefined;
const getAdditionalClasses = jest.fn().mockReturnValue('additional');
const setActiveDropTarget = jest.fn();
const component = mount(
<ChildDragDropProvider
setA11yMessage={jest.fn()}
dragging={dragging}
setDragging={() => {
dragging = { id: '1' };
}}
setActiveDropTarget={setActiveDropTarget}
activeDropTarget={
({ activeDropTarget: value } as unknown) as DragContextState['activeDropTarget']
}
keyboardMode={false}
setKeyboardMode={(keyboardMode) => true}
>
<DragDrop value={{ label: 'ignored', id: '3' }} draggable={true} label="a">
<button>Hello!</button>
</DragDrop>
<DragDrop
value={value}
onDrop={(x: unknown) => {}}
droppable
getAdditionalClassesOnEnter={getAdditionalClasses}
@ -173,10 +248,6 @@ describe('DragDrop', () => {
</ChildDragDropProvider>
);
const dataTransfer = {
setData: jest.fn(),
getData: jest.fn(),
};
component
.find('[data-test-subj="lnsDragDrop"]')
.first()
@ -187,37 +258,44 @@ describe('DragDrop', () => {
expect(component.find('.additional')).toHaveLength(1);
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave');
expect(component.find('.additional')).toHaveLength(0);
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover');
component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop');
expect(component.find('.additional')).toHaveLength(0);
expect(setActiveDropTarget).toBeCalledWith(undefined);
});
describe('reordering', () => {
const mountComponent = (
dragging: { id: '1' } | undefined,
onDrop: DropHandler = jest.fn(),
dropTo: DropToHandler = jest.fn()
) =>
mount(
<ChildDragDropProvider
dragging={{ id: '1' }}
setDragging={() => {
dragging = { id: '1' };
}}
>
dragContext: Partial<DragContextState> | undefined,
onDrop: DropHandler = jest.fn()
) => {
let dragging = dragContext?.dragging;
let keyboardMode = !!dragContext?.keyboardMode;
let activeDropTarget = dragContext?.activeDropTarget;
const baseContext = {
dragging,
setDragging: (val?: DragDropIdentifier) => {
dragging = val;
},
keyboardMode,
setKeyboardMode: jest.fn((mode) => {
keyboardMode = mode;
}),
setActiveDropTarget: (target?: DragDropIdentifier) => {
activeDropTarget = { activeDropTarget: target } as ActiveDropTarget;
},
activeDropTarget,
setA11yMessage: jest.fn(),
};
return mount(
<ChildDragDropProvider {...baseContext} {...dragContext}>
<ReorderProvider id="groupId">
<DragDrop
label="1"
draggable
droppable
droppable={false}
dragType="reorder"
dropType="reorder"
itemsInGroup={['1', '2', '3']}
reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]}
value={{ id: '1' }}
onDrop={onDrop}
dropTo={dropTo}
>
<span>1</span>
</DragDrop>
@ -227,12 +305,11 @@ describe('DragDrop', () => {
droppable
dragType="reorder"
dropType="reorder"
itemsInGroup={['1', '2', '3']}
reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]}
value={{
id: '2',
}}
onDrop={onDrop}
dropTo={dropTo}
>
<span>2</span>
</DragDrop>
@ -242,132 +319,270 @@ describe('DragDrop', () => {
droppable
dragType="reorder"
dropType="reorder"
itemsInGroup={['1', '2', '3']}
reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]}
value={{
id: '3',
}}
onDrop={onDrop}
dropTo={dropTo}
>
<span>3</span>
</DragDrop>
</ReorderProvider>
</ChildDragDropProvider>
);
test(`ReorderableDragDrop component doesn't appear for groups of 1 or less`, () => {
let dragging;
};
test(`Inactive reorderable group renders properly`, () => {
const component = mountComponent(undefined, jest.fn());
expect(component.find('.lnsDragDrop-reorderable')).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()
);
act(() => {
component
.find('[data-test-subj="lnsDragDrop"]')
.first()
.simulate('dragstart', { dataTransfer });
jest.runAllTimers();
});
expect(setDragging).toBeCalledWith({ id: '1' });
expect(setA11yMessage).toBeCalledWith('You have lifted an item 1 in position 1');
expect(
component
.find('[data-test-subj="lnsDragDrop-reorderableGroup"]')
.hasClass('lnsDragDrop-isActiveGroup')
).toEqual(true);
});
test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => {
const component = mountComponent({ dragging: { id: '1' } }, jest.fn());
act(() => {
component
.find('[data-test-subj="lnsDragDrop"]')
.first()
.simulate('dragstart', { dataTransfer });
jest.runAllTimers();
});
component
.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]')
.at(1)
.simulate('dragover');
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style')
).toEqual(undefined);
expect(
component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(0).prop('style')
).toEqual({
transform: 'translateY(-8px)',
});
expect(
component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style')
).toEqual({
transform: 'translateY(-8px)',
});
component
.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]')
.at(1)
.simulate('dragleave');
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style')
).toEqual(undefined);
expect(
component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style')
).toEqual(undefined);
});
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
);
component
.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]')
.at(1)
.simulate('drop', { preventDefault, stopPropagation });
jest.runAllTimers();
expect(setA11yMessage).toBeCalledWith(
'You have dropped the item. You have moved the item from position 1 to positon 3'
);
expect(preventDefault).toBeCalled();
expect(stopPropagation).toBeCalled();
expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' });
});
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,
},
onDrop
);
const keyboardHandler = component
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]')
.simulate('focus');
act(() => {
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
keyboardHandler.simulate('keydown', { key: 'Enter' });
});
expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' });
});
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 keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style')
).toEqual({
transform: 'translateY(+8px)',
});
expect(
component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(0).prop('style')
).toEqual({
transform: 'translateY(-40px)',
});
expect(
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'
);
component
.find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]')
.at(1)
.simulate('dragleave');
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style')
).toEqual(undefined);
expect(
component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style')
).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 component = mount(
<ChildDragDropProvider
dragging={dragging}
setDragging={() => {
dragging = { id: '1' };
{...defaultContext}
keyboardMode={true}
activeDropTarget={{
activeDropTarget: { id: '2' },
}}
dragging={{ id: '1' }}
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' }}
>
<span>1</span>
</DragDrop>
<DragDrop
label="2"
draggable
droppable
dragType="reorder"
dropType="reorder"
itemsInGroup={['1']}
value={{ id: '1' }}
onDrop={jest.fn()}
dropTo={jest.fn()}
reorderableGroup={[{ id: '1' }, { id: '2' }]}
value={{ id: '2' }}
>
<div />
<span>2</span>
</DragDrop>
</ReorderProvider>
</ChildDragDropProvider>
);
expect(component.find(ReorderableDragDrop)).toHaveLength(0);
const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
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(`Reorderable component renders properly`, () => {
const component = mountComponent(undefined, jest.fn());
expect(component.find(ReorderableDragDrop)).toHaveLength(3);
});
test(`Elements between dragged and drop get extra class to show the reorder effect when dragging`, () => {
const component = mountComponent({ id: '1' }, jest.fn());
const dataTransfer = {
setData: jest.fn(),
getData: jest.fn(),
};
component
.find(ReorderableDragDrop)
.first()
.find('[data-test-subj="lnsDragDrop"]')
.simulate('dragstart', { dataTransfer });
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();
component.find('[data-test-subj="lnsDragDrop-reorderableDrop"]').at(2).simulate('dragover');
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style')
).toEqual({});
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(1).prop('style')
).toEqual({
transform: 'translateY(-40px)',
});
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(2).prop('style')
).toEqual({
transform: 'translateY(-40px)',
});
component.find('[data-test-subj="lnsDragDrop-reorderableDrop"]').at(2).simulate('dragleave');
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(1).prop('style')
).toEqual({});
expect(
component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(2).prop('style')
).toEqual({});
});
test(`Dropping an item runs onDrop function`, () => {
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const onDrop = jest.fn();
const component = mountComponent({ id: '1' }, onDrop);
component
.find('[data-test-subj="lnsDragDrop-reorderableDrop"]')
.at(1)
.simulate('drop', { preventDefault, stopPropagation });
expect(preventDefault).toBeCalled();
expect(stopPropagation).toBeCalled();
expect(onDrop).toBeCalledWith({ id: '1' });
});
test(`Keyboard navigation: user can reorder an element`, () => {
const onDrop = jest.fn();
const dropTo = jest.fn();
const component = mountComponent({ id: '1' }, onDrop, dropTo);
const keyboardHandler = component
.find(ReorderableDragDrop)
.at(1)
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
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' });
expect(dropTo).toBeCalledWith('3');
keyboardHandler.simulate('blur');
keyboardHandler.simulate('keydown', { key: 'ArrowUp' });
expect(dropTo).toBeCalledWith('1');
});
test(`Keyboard Navigation: User cannot move an element outside of the group`, () => {
const onDrop = jest.fn();
const dropTo = jest.fn();
const component = mountComponent({ id: '1' }, onDrop, dropTo);
const keyboardHandler = component
.find(ReorderableDragDrop)
.first()
.find('[data-test-subj="lnsDragDrop-keyboardHandler"]');
keyboardHandler.simulate('keydown', { key: 'Space' });
keyboardHandler.simulate('keydown', { key: 'ArrowUp' });
expect(dropTo).not.toHaveBeenCalled();
keyboardHandler.simulate('keydown', { key: 'ArrowDown' });
expect(dropTo).toBeCalledWith('2');
expect(onDrop).not.toHaveBeenCalled();
expect(setA11yMessage).toBeCalledWith(
'Movement cancelled. The item has returned to its starting position 1'
);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -9,12 +9,13 @@ import classNames from 'classnames';
import { EuiScreenReaderOnly, EuiPortal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export type Dragging =
| (Record<string, unknown> & {
id: string;
})
| undefined;
export type DragDropIdentifier = Record<string, unknown> & {
id: string;
};
export interface ActiveDropTarget {
activeDropTarget?: DragDropIdentifier;
}
/**
* The shape of the drag / drop context.
*/
@ -22,12 +23,26 @@ export interface DragContextState {
/**
* The item being dragged or undefined.
*/
dragging: Dragging;
dragging?: DragDropIdentifier;
/**
* keyboard mode
*/
keyboardMode: boolean;
/**
* keyboard mode
*/
setKeyboardMode: (mode: boolean) => void;
/**
* Set the item being dragged.
*/
setDragging: (dragging: Dragging) => void;
setDragging: (dragging?: DragDropIdentifier) => void;
activeDropTarget?: ActiveDropTarget;
setActiveDropTarget: (newTarget?: DragDropIdentifier) => void;
setA11yMessage: (message: string) => void;
}
/**
@ -38,28 +53,52 @@ export interface DragContextState {
export const DragContext = React.createContext<DragContextState>({
dragging: undefined,
setDragging: () => {},
keyboardMode: false,
setKeyboardMode: () => {},
activeDropTarget: undefined,
setActiveDropTarget: () => {},
setA11yMessage: () => {},
});
/**
* The argument to DragDropProvider.
*/
export interface ProviderProps {
/**
* keyboard mode
*/
keyboardMode: boolean;
/**
* keyboard mode
*/
setKeyboardMode: (mode: boolean) => void;
/**
* Set the item being dragged.
*/
/**
* The item being dragged. If unspecified, the provider will
* behave as if it is the root provider.
*/
dragging: Dragging;
dragging?: DragDropIdentifier;
/**
* Sets the item being dragged. If unspecified, the provider
* will behave as if it is the root provider.
*/
setDragging: (dragging: Dragging) => void;
setDragging: (dragging?: DragDropIdentifier) => void;
activeDropTarget?: {
activeDropTarget?: DragDropIdentifier;
};
setActiveDropTarget: (newTarget?: DragDropIdentifier) => void;
/**
* The React children.
*/
children: React.ReactNode;
setA11yMessage: (message: string) => void;
}
/**
@ -70,15 +109,60 @@ export interface ProviderProps {
* @param props
*/
export function RootDragDropProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<{ dragging: Dragging }>({
const [draggingState, setDraggingState] = useState<{ dragging?: DragDropIdentifier }>({
dragging: undefined,
});
const setDragging = useMemo(() => (dragging: Dragging) => setState({ dragging }), [setState]);
const [keyboardModeState, setKeyboardModeState] = useState(false);
const [a11yMessageState, setA11yMessageState] = useState('');
const [activeDropTargetState, setActiveDropTargetState] = useState<{
activeDropTarget?: DragDropIdentifier;
}>({
activeDropTarget: undefined,
});
const setDragging = useMemo(
() => (dragging?: DragDropIdentifier) => setDraggingState({ dragging }),
[setDraggingState]
);
const setA11yMessage = useMemo(() => (message: string) => setA11yMessageState(message), [
setA11yMessageState,
]);
const setActiveDropTarget = useMemo(
() => (activeDropTarget?: DragDropIdentifier) =>
setActiveDropTargetState((s) => ({ ...s, activeDropTarget })),
[setActiveDropTargetState]
);
return (
<ChildDragDropProvider dragging={state.dragging} setDragging={setDragging}>
{children}
</ChildDragDropProvider>
<div>
<ChildDragDropProvider
keyboardMode={keyboardModeState}
setKeyboardMode={setKeyboardModeState}
dragging={draggingState.dragging}
setA11yMessage={setA11yMessage}
setDragging={setDragging}
activeDropTarget={activeDropTargetState}
setActiveDropTarget={setActiveDropTarget}
>
{children}
</ChildDragDropProvider>
<EuiPortal>
<EuiScreenReaderOnly>
<div>
<p aria-live="assertive" aria-atomic={true}>
{a11yMessageState}
</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.`,
})}
</p>
</div>
</EuiScreenReaderOnly>
</EuiPortal>
</div>
);
}
@ -89,8 +173,36 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode }
*
* @param props
*/
export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) {
const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]);
export function ChildDragDropProvider({
dragging,
setDragging,
setKeyboardMode,
keyboardMode,
activeDropTarget,
setActiveDropTarget,
setA11yMessage,
children,
}: ProviderProps) {
const value = useMemo(
() => ({
setKeyboardMode,
keyboardMode,
dragging,
setDragging,
activeDropTarget,
setActiveDropTarget,
setA11yMessage,
}),
[
setDragging,
dragging,
activeDropTarget,
setActiveDropTarget,
setKeyboardMode,
keyboardMode,
setA11yMessage,
]
);
return <DragContext.Provider value={value}>{children}</DragContext.Provider>;
}
@ -98,7 +210,7 @@ export interface ReorderState {
/**
* Ids of the elements that are translated up or down
*/
reorderedItems: string[];
reorderedItems: DragDropIdentifier[];
/**
* Direction of the move of dragged element in the reordered list
@ -112,10 +224,6 @@ export interface ReorderState {
* indicates that user is in keyboard mode
*/
isReorderOn: boolean;
/**
* aria-live message for changes in reordering
*/
keyboardReorderMessage: string;
/**
* reorder group needed for screen reader aria-described-by attribute
*/
@ -135,7 +243,6 @@ export const ReorderContext = React.createContext<ReorderContextState>({
direction: '-',
draggingHeight: 40,
isReorderOn: false,
keyboardReorderMessage: '',
groupId: '',
},
setReorderState: () => () => {},
@ -155,33 +262,70 @@ export function ReorderProvider({
direction: '-',
draggingHeight: 40,
isReorderOn: false,
keyboardReorderMessage: '',
groupId: id,
});
const setReorderState = useMemo(() => (dispatch: SetReorderStateDispatch) => setState(dispatch), [
setState,
]);
return (
<div className={classNames(className, { 'lnsDragDrop-isActiveGroup': state.isReorderOn })}>
<div
data-test-subj="lnsDragDrop-reorderableGroup"
className={classNames(className, {
'lnsDragDrop-isActiveGroup': state.isReorderOn && React.Children.count(children) > 1,
})}
>
<ReorderContext.Provider value={{ reorderState: state, setReorderState }}>
{children}
</ReorderContext.Provider>
<EuiPortal>
<EuiScreenReaderOnly>
<div>
<p aria-live="assertive" aria-atomic={true}>
{state.keyboardReorderMessage}
</p>
<p id={`lnsDragDrop-reorderInstructions-${id}`}>
{i18n.translate('xpack.lens.dragDrop.reorderInstructions', {
defaultMessage: `Press space bar to start a drag. When dragging, use arrow keys to reorder. Press space bar again to finish.`,
})}
</p>
</div>
</EuiScreenReaderOnly>
</EuiPortal>
</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

@ -30,7 +30,7 @@ In your child application, place a `ChildDragDropProvider` at the root of that,
This enables your child application to share the same drag / drop context as the root application.
## Dragging
## DragDropIdentifier
An item can be both draggable and droppable at the same time, but for simplicity's sake, we'll treat these two cases separately.
@ -88,7 +88,7 @@ The children `DragDrop` components must have props defined as in the example:
droppable
dragType="reorder"
dropType="reorder"
itemsInGroup={fields.map((f) => f.id)} // consists ids of all reorderable elements in the group, eg. ['3', '5', '1']
reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}]
value={{
id: f.id,
}}

View file

@ -92,6 +92,14 @@ describe('ConfigPanel', () => {
mockDatasource = createMockDatasource('ds1');
});
// in what case is this test needed?
it('should fail to render layerPanels if the public API is out of date', () => {
const props = getDefaultProps();
props.framePublicAPI.datasourceLayers = {};
const component = mountWithIntl(<LayerPanels {...props} />);
expect(component.find(LayerPanel).exists()).toBe(false);
});
describe('focus behavior when adding or removing layers', () => {
it('should focus the only layer when resetting the layer', () => {
const component = mountWithIntl(<LayerPanels {...getDefaultProps()} />);

View file

@ -134,37 +134,42 @@ export function LayerPanels(
[dispatch]
);
const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers;
return (
<EuiForm className="lnsConfigPanel">
{layerIds.map((layerId, index) => (
<LayerPanel
{...props}
setLayerRef={setLayerRef}
key={layerId}
layerId={layerId}
index={index}
visualizationState={visualizationState}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateAll={updateAll}
isOnlyLayer={layerIds.length === 1}
onRemoveLayer={() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'REMOVE_OR_CLEAR_LAYER',
updater: (state) =>
removeLayer({
activeVisualization,
layerId,
trackUiEvent,
datasourceMap,
state,
}),
});
removeLayerRef(layerId);
}}
/>
))}
{layerIds.map((layerId, layerIndex) =>
datasourcePublicAPIs[layerId] ? (
<LayerPanel
{...props}
activeVisualization={activeVisualization}
setLayerRef={setLayerRef}
key={layerId}
layerId={layerId}
layerIndex={layerIndex}
visualizationState={visualizationState}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateAll={updateAll}
isOnlyLayer={layerIds.length === 1}
onRemoveLayer={() => {
dispatch({
type: 'UPDATE_STATE',
subType: 'REMOVE_OR_CLEAR_LAYER',
updater: (state) =>
removeLayer({
activeVisualization,
layerId,
trackUiEvent,
datasourceMap,
state,
}),
});
removeLayerRef(layerId);
}}
/>
) : null
)}
{activeVisualization.appendLayer && visualizationState && (
<EuiFlexItem grow={true}>
<EuiToolTip

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiButtonIcon, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ColorIndicator } from './color_indicator';
import { PaletteIndicator } from './palette_indicator';
import { VisualizationDimensionGroupConfig, AccessorConfig } from '../../../types';
const triggerLinkA11yText = (label: string) =>
i18n.translate('xpack.lens.configure.editConfig', {
defaultMessage: 'Edit {label} configuration',
values: { label },
});
export function DimensionButton({
group,
children,
onClick,
onRemoveClick,
accessorConfig,
label,
}: {
group: VisualizationDimensionGroupConfig;
children: React.ReactElement;
onClick: (id: string) => void;
onRemoveClick: (id: string) => void;
accessorConfig: AccessorConfig;
label: string;
}) {
return (
<>
<EuiLink
className="lnsLayerPanel__dimensionLink"
data-test-subj="lnsLayerPanel-dimensionLink"
onClick={() => onClick(accessorConfig.columnId)}
aria-label={triggerLinkA11yText(label)}
title={triggerLinkA11yText(label)}
>
<ColorIndicator accessorConfig={accessorConfig}>{children}</ColorIndicator>
</EuiLink>
<EuiButtonIcon
className="lnsLayerPanel__dimensionRemove"
data-test-subj="indexPattern-dimension-remove"
iconType="cross"
iconSize="s"
size="s"
color="danger"
aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration from "{groupLabel}"',
values: { groupLabel: group.groupLabel },
})}
title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration from "{groupLabel}"',
values: { groupLabel: group.groupLabel },
})}
onClick={() => onRemoveClick(accessorConfig.columnId)}
/>
<PaletteIndicator accessorConfig={accessorConfig} />
</>
);
}

View file

@ -0,0 +1,110 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop';
import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types';
import { LayerDatasourceDropProps } from './types';
const isFromTheSameGroup = (el1: DragDropIdentifier, el2?: DragDropIdentifier) =>
el2 && isDraggedOperation(el2) && el1.groupId === el2.groupId && el1.columnId !== el2.columnId;
const isSelf = (el1: DragDropIdentifier, el2?: DragDropIdentifier) =>
isDraggedOperation(el2) && el1.columnId === el2.columnId;
export function DraggableDimensionButton({
layerId,
label,
accessorIndex,
groupIndex,
layerIndex,
columnId,
group,
onDrop,
children,
dragDropContext,
layerDatasourceDropProps,
layerDatasource,
}: {
dragDropContext: DragContextState;
layerId: string;
groupIndex: number;
layerIndex: number;
onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void;
group: VisualizationDimensionGroupConfig;
label: string;
children: React.ReactElement;
layerDatasource: Datasource<unknown, unknown>;
layerDatasourceDropProps: LayerDatasourceDropProps;
accessorIndex: number;
columnId: string;
}) {
const value = useMemo(() => {
return {
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;
const reorderableGroup = useMemo(
() =>
group.accessors.map((a) => ({
columnId: a.columnId,
id: a.columnId,
groupId: group.groupId,
layerId,
})),
[group, layerId]
);
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
draggable
dragType={dragType}
dropType={dropType}
reorderableGroup={reorderableGroup.length > 1 ? reorderableGroup : undefined}
value={value}
label={label}
droppable={dragging && isDroppable}
onDrop={onDrop}
>
{children}
</DragDrop>
</div>
);
}

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } 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 { LayerDatasourceDropProps } from './types';
export function EmptyDimensionButton({
dragDropContext,
group,
layerDatasource,
layerDatasourceDropProps,
layerId,
groupIndex,
layerIndex,
onClick,
onDrop,
}: {
dragDropContext: DragContextState;
layerId: string;
groupIndex: number;
layerIndex: number;
onClick: (id: string) => void;
onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void;
group: VisualizationDimensionGroupConfig;
layerDatasource: Datasource<unknown, unknown>;
layerDatasourceDropProps: LayerDatasourceDropProps;
}) {
const handleDrop = (droppedItem: DragDropIdentifier) => onDrop(droppedItem, value);
const value = useMemo(() => {
const newId = generateId();
return {
columnId: newId,
groupId: group.groupId,
layerId,
isNew: true,
id: newId,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [group.accessors.length, group.groupId, layerId]);
return (
<div className="lnsLayerPanel__dimensionContainer" data-test-subj={group.dataTestSubj}>
<DragDrop
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,
})
}
>
<div className="lnsLayerPanel__dimension lnsLayerPanel__dimension--empty">
<EuiButtonEmpty
className="lnsLayerPanel__triggerText"
color="text"
size="xs"
iconType="plusInCircleFilled"
contentProps={{
className: 'lnsLayerPanel__triggerTextContent',
}}
aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', {
defaultMessage: 'Drop a field or click to add to {groupLabel}',
values: { groupLabel: group.groupLabel },
})}
data-test-subj="lns-empty-dimension"
onClick={() => {
onClick(value.columnId);
}}
>
<FormattedMessage
id="xpack.lens.configure.emptyConfig"
defaultMessage="Drop a field or click to add"
/>
</EuiButtonEmpty>
</div>
</DragDrop>
</div>
);
}

View file

@ -76,6 +76,7 @@
.lnsLayerPanel__dimensionContainer {
margin: 0 $euiSizeS $euiSizeS;
position: relative;
&:last-child {
margin-bottom: 0;
@ -127,12 +128,13 @@
}
}
.lnsLayerPanel__dimensionLink {
// Added .lnsLayerPanel__dimension specificity required for animation style override
.lnsLayerPanel__dimension .lnsLayerPanel__dimensionLink {
width: 100%;
&:focus {
background-color: transparent !important; // sass-lint:disable-line no-important
outline: none !important; // sass-lint:disable-line no-important
background-color: transparent;
animation: none !important; // sass-lint:disable-line no-important
}
&:focus .lnsLayerPanel__triggerTextLabel,

View file

@ -12,7 +12,7 @@ import {
createMockDatasource,
DatasourceMock,
} from '../../mocks';
import { ChildDragDropProvider, DroppableEvent } from '../../../drag_drop';
import { ChildDragDropProvider, DroppableEvent, DragDrop } from '../../../drag_drop';
import { EuiFormRow } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test/jest';
import { Visualization } from '../../../types';
@ -22,9 +22,20 @@ import { generateId } from '../../../id_generator';
jest.mock('../../../id_generator');
const defaultContext = {
dragging: undefined,
setDragging: jest.fn(),
setActiveDropTarget: () => {},
activeDropTarget: undefined,
keyboardMode: false,
setKeyboardMode: () => {},
setA11yMessage: jest.fn(),
};
describe('LayerPanel', () => {
let mockVisualization: jest.Mocked<Visualization>;
let mockVisualization2: jest.Mocked<Visualization>;
let mockDatasource: DatasourceMock;
function getDefaultProps() {
@ -34,11 +45,7 @@ describe('LayerPanel', () => {
};
return {
layerId: 'first',
activeVisualizationId: 'vis1',
visualizationMap: {
vis1: mockVisualization,
vis2: mockVisualization2,
},
activeVisualization: mockVisualization,
activeDatasourceId: 'ds1',
datasourceMap: {
ds1: mockDatasource,
@ -58,7 +65,7 @@ describe('LayerPanel', () => {
onRemoveLayer: jest.fn(),
dispatch: jest.fn(),
core: coreMock.createStart(),
index: 0,
layerIndex: 0,
setLayerRef: jest.fn(),
};
}
@ -92,20 +99,6 @@ describe('LayerPanel', () => {
mockDatasource = createMockDatasource('ds1');
});
it('should fail to render if the public API is out of date', () => {
const props = getDefaultProps();
props.framePublicAPI.datasourceLayers = {};
const component = mountWithIntl(<LayerPanel {...props} />);
expect(component.isEmptyRender()).toBe(true);
});
it('should fail to render if the active visualization is missing', () => {
const component = mountWithIntl(
<LayerPanel {...getDefaultProps()} activeVisualizationId="missing" />
);
expect(component.isEmptyRender()).toBe(true);
});
describe('layer reset and remove', () => {
it('should show the reset button when single layer', () => {
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
@ -147,8 +140,7 @@ describe('LayerPanel', () => {
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('DragDrop[data-test-subj="lnsGroup"]');
const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
@ -167,8 +159,7 @@ describe('LayerPanel', () => {
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
const group = component.find('DragDrop[data-test-subj="lnsGroup"]');
const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]');
expect(group).toHaveLength(1);
});
@ -231,50 +222,6 @@ describe('LayerPanel', () => {
expect(panel.props.children).toHaveLength(2);
});
it('should keep the DimensionContainer open when configuring a new dimension', () => {
/**
* The ID generation system for new dimensions has been messy before, so
* this tests that the ID used in the first render is used to keep the container
* open in future renders
*/
(generateId as jest.Mock).mockReturnValueOnce(`newid`);
(generateId as jest.Mock).mockReturnValueOnce(`bad`);
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
// Normally the configuration would change in response to a state update,
// but this test is updating it directly
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'newid' }],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
},
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
act(() => {
component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(true);
});
it('should not update the visualization if the datasource is incomplete', () => {
(generateId as jest.Mock).mockReturnValueOnce(`newid`);
const updateAll = jest.fn();
@ -338,13 +285,12 @@ describe('LayerPanel', () => {
expect(updateAll).toHaveBeenCalled();
});
it('should close the DimensionContainer when the active visualization changes', () => {
it('should keep the DimensionContainer open when configuring a new dimension', () => {
/**
* The ID generation system for new dimensions has been messy before, so
* this tests that the ID used in the first render is used to keep the container
* open in future renders
*/
(generateId as jest.Mock).mockReturnValueOnce(`newid`);
(generateId as jest.Mock).mockReturnValueOnce(`bad`);
mockVisualization.getConfiguration.mockReturnValueOnce({
@ -374,6 +320,51 @@ describe('LayerPanel', () => {
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
act(() => {
component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click');
});
component.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(true);
});
it('should close the DimensionContainer when the active visualization changes', () => {
/**
* The ID generation system for new dimensions has been messy before, so
* this tests that the ID used in the first render is used to keep the container
* open in future renders
*/
(generateId as jest.Mock).mockReturnValueOnce(`newid`);
(generateId as jest.Mock).mockReturnValueOnce(`bad`);
mockVisualization.getConfiguration.mockReturnValueOnce({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
// Normally the configuration would change in response to a state update,
// but this test is updating it directly
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'newid' }],
filterOperations: () => true,
supportsMoreColumns: false,
dataTestSubj: 'lnsGroup',
},
],
});
const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />);
act(() => {
@ -382,7 +373,7 @@ describe('LayerPanel', () => {
component.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(true);
act(() => {
component.setProps({ activeVisualizationId: 'vis2' });
component.setProps({ activeVisualization: mockVisualization2 });
});
component.update();
expect(component.find('EuiFlyoutHeader').exists()).toBe(false);
@ -452,7 +443,7 @@ describe('LayerPanel', () => {
const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' };
const component = mountWithIntl(
<ChildDragDropProvider dragging={draggingField} setDragging={jest.fn()}>
<ChildDragDropProvider {...defaultContext} dragging={draggingField}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
);
@ -465,7 +456,7 @@ describe('LayerPanel', () => {
})
);
component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop');
component.find('[data-test-subj="lnsGroup"] DragDrop').first().simulate('drop');
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
@ -495,7 +486,7 @@ describe('LayerPanel', () => {
const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' };
const component = mountWithIntl(
<ChildDragDropProvider dragging={draggingField} setDragging={jest.fn()}>
<ChildDragDropProvider {...defaultContext} dragging={draggingField}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
);
@ -505,10 +496,14 @@ describe('LayerPanel', () => {
);
expect(
component.find('DragDrop[data-test-subj="lnsGroup"]').first().prop('droppable')
component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('droppable')
).toEqual(false);
component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop');
component
.find('[data-test-subj="lnsGroup"] DragDrop')
.first()
.find('.lnsLayerPanel__dimension')
.simulate('drop');
expect(mockDatasource.onDrop).not.toHaveBeenCalled();
});
@ -542,12 +537,11 @@ describe('LayerPanel', () => {
const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' };
const component = mountWithIntl(
<ChildDragDropProvider dragging={draggingOperation} setDragging={jest.fn()}>
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
);
expect(mockDatasource.canHandleDrop).toHaveBeenCalledTimes(2);
expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith(
expect.objectContaining({
dragDropContext: expect.objectContaining({
@ -557,7 +551,7 @@ describe('LayerPanel', () => {
);
// Simulate drop on the pre-populated dimension
component.find('DragDrop[data-test-subj="lnsGroupB"]').at(0).simulate('drop');
component.find('[data-test-subj="lnsGroupB"] DragDrop').at(0).simulate('drop');
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
columnId: 'b',
@ -568,7 +562,7 @@ describe('LayerPanel', () => {
);
// Simulate drop on the empty dimension
component.find('DragDrop[data-test-subj="lnsGroupB"]').at(1).simulate('drop');
component.find('[data-test-subj="lnsGroupB"] DragDrop').at(1).simulate('drop');
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
columnId: 'newid',
@ -596,18 +590,55 @@ describe('LayerPanel', () => {
const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' };
const component = mountWithIntl(
<ChildDragDropProvider dragging={draggingOperation} setDragging={jest.fn()}>
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel {...getDefaultProps()} />
</ChildDragDropProvider>
);
expect(mockDatasource.canHandleDrop).not.toHaveBeenCalled();
component.find('DragDrop[data-test-subj="lnsGroup"]').at(1).prop('onDrop')!(
component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, {
layerId: 'first',
columnId: 'b',
groupId: 'a',
id: 'b',
});
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
groupId: 'a',
droppedItem: draggingOperation,
})
);
});
it('should copy when dropping on empty slot in the same group', () => {
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'a' }, { columnId: 'b' }],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
],
});
const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' };
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
);
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
isReorder: true,
groupId: 'a',
droppedItem: draggingOperation,
isNew: true,
})
);
});

View file

@ -5,66 +5,35 @@
*/
import './layer_panel.scss';
import React, { useContext, useState, useEffect } from 'react';
import {
EuiPanel,
EuiSpacer,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiFormRow,
EuiLink,
} from '@elastic/eui';
import React, { useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { NativeRenderer } from '../../../native_renderer';
import { StateSetter, isDraggedOperation } from '../../../types';
import { DragContext, DragDrop, ChildDragDropProvider, ReorderProvider } from '../../../drag_drop';
import { StateSetter, Visualization } from '../../../types';
import {
DragContext,
DragDropIdentifier,
ChildDragDropProvider,
ReorderProvider,
} from '../../../drag_drop';
import { LayerSettings } from './layer_settings';
import { trackUiEvent } from '../../../lens_ui_telemetry';
import { generateId } from '../../../id_generator';
import { ConfigPanelWrapperProps, ActiveDimensionState } from './types';
import { LayerPanelProps, ActiveDimensionState } from './types';
import { DimensionContainer } from './dimension_container';
import { ColorIndicator } from './color_indicator';
import { PaletteIndicator } from './palette_indicator';
const triggerLinkA11yText = (label: string) =>
i18n.translate('xpack.lens.configure.editConfig', {
defaultMessage: 'Click to edit configuration for {label} or drag to move',
values: { label },
});
import { RemoveLayerButton } from './remove_layer_button';
import { EmptyDimensionButton } from './empty_dimension_button';
import { DimensionButton } from './dimension_button';
import { DraggableDimensionButton } from './draggable_dimension_button';
const initialActiveDimensionState = {
isNew: false,
};
function isConfiguration(
value: unknown
): value is { columnId: string; groupId: string; layerId: string } {
return (
Boolean(value) &&
typeof value === 'object' &&
'columnId' in value! &&
'groupId' in value &&
'layerId' in value
);
}
function isSameConfiguration(config1: unknown, config2: unknown) {
return (
isConfiguration(config1) &&
isConfiguration(config2) &&
config1.columnId === config2.columnId &&
config1.groupId === config2.groupId &&
config1.layerId === config2.layerId
);
}
export function LayerPanel(
props: Exclude<ConfigPanelWrapperProps, 'state' | 'setState'> & {
props: Exclude<LayerPanelProps, 'state' | 'setState'> & {
activeVisualization: Visualization;
layerId: string;
index: number;
layerIndex: number;
isOnlyLayer: boolean;
updateVisualization: StateSetter<unknown>;
updateDatasource: (datasourceId: string, newState: unknown) => void;
@ -82,26 +51,25 @@ export function LayerPanel(
initialActiveDimensionState
);
const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, setLayerRef, index } = props;
const {
framePublicAPI,
layerId,
isOnlyLayer,
onRemoveLayer,
setLayerRef,
layerIndex,
activeVisualization,
updateVisualization,
updateDatasource,
} = props;
const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId];
useEffect(() => {
setActiveDimension(initialActiveDimensionState);
}, [props.activeVisualizationId]);
}, [activeVisualization.id]);
const setLayerRefMemoized = React.useCallback((el) => setLayerRef(layerId, el), [
layerId,
setLayerRef,
]);
const setLayerRefMemoized = useCallback((el) => setLayerRef(layerId, el), [layerId, setLayerRef]);
if (
!datasourcePublicAPI ||
!props.activeVisualizationId ||
!props.visualizationMap[props.activeVisualizationId]
) {
return null;
}
const activeVisualization = props.visualizationMap[props.activeVisualizationId];
const layerVisualizationConfigProps = {
layerId,
dragDropContext,
@ -110,18 +78,23 @@ export function LayerPanel(
dateRange: props.framePublicAPI.dateRange,
activeData: props.framePublicAPI.activeData,
};
const datasourceId = datasourcePublicAPI.datasourceId;
const layerDatasourceState = props.datasourceStates[datasourceId].state;
const layerDatasource = props.datasourceMap[datasourceId];
const layerDatasourceDropProps = {
layerId,
dragDropContext,
state: layerDatasourceState,
setState: (newState: unknown) => {
props.updateDatasource(datasourceId, newState);
},
};
const layerDatasourceDropProps = useMemo(
() => ({
layerId,
dragDropContext,
state: layerDatasourceState,
setState: (newState: unknown) => {
updateDatasource(datasourceId, newState);
},
}),
[layerId, dragDropContext, layerDatasourceState, datasourceId, updateDatasource]
);
const layerDatasource = props.datasourceMap[datasourceId];
const layerDatasourceConfigProps = {
...layerDatasourceDropProps,
@ -135,10 +108,68 @@ export function LayerPanel(
const { activeId, activeGroup } = activeDimension;
const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state);
const { setDimension, removeDimension } = activeVisualization;
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;
};
const filterOperations =
groups.find(({ groupId: gId }) => gId === targetItem.groupId)?.filterOperations ||
(() => false);
const dropResult = layerDatasourceOnDrop({
...layerDatasourceDropProps,
droppedItem,
columnId,
groupId,
layerId: targetLayerId,
isNew,
filterOperations,
});
if (dropResult) {
updateVisualization(
setDimension({
columnId,
groupId,
layerId: targetLayerId,
prevState: props.visualizationState,
})
);
if (typeof dropResult === 'object') {
// When a column is moved, we delete the reference to the old
updateVisualization(
removeDimension({
columnId: dropResult.deleted,
layerId: targetLayerId,
prevState: props.visualizationState,
})
);
}
}
};
}, [
groups,
layerDatasourceOnDrop,
props.visualizationState,
updateVisualization,
setDimension,
removeDimension,
layerDatasourceDropProps,
]);
return (
<ChildDragDropProvider {...dragDropContext}>
<section tabIndex={-1} ref={setLayerRefMemoized} className="lnsLayerPanel">
<EuiPanel data-test-subj={`lns-layerPanel-${index}`} paddingSize="s">
<EuiPanel data-test-subj={`lns-layerPanel-${layerIndex}`} paddingSize="s">
<EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false} className="lnsLayerPanel__settingsFlexItem">
<LayerSettings
@ -193,10 +224,8 @@ export function LayerPanel(
<EuiSpacer size="m" />
{groups.map((group) => {
const newId = generateId();
{groups.map((group, groupIndex) => {
const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0;
return (
<EuiFormRow
className={
@ -222,261 +251,86 @@ export function LayerPanel(
}
>
<>
<ReorderProvider
id={`${layerId}-${group.groupId}`}
className={'lnsLayerPanel__group'}
>
{group.accessors.map((accessorConfig) => {
const accessor = accessorConfig.columnId;
const { dragging } = dragDropContext;
const dragType =
isDraggedOperation(dragging) && accessor === dragging.columnId
? 'move'
: isDraggedOperation(dragging) && group.groupId === dragging.groupId
? 'reorder'
: 'copy';
const dropType = isDraggedOperation(dragging)
? group.groupId !== dragging.groupId
? 'replace'
: 'reorder'
: 'add';
const isFromCompatibleGroup =
dragging?.groupId !== group.groupId &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: accessor,
filterOperations: group.filterOperations,
});
const isFromTheSameGroup =
isDraggedOperation(dragging) &&
dragging.groupId === group.groupId &&
dragging.columnId !== accessor;
const isDroppable = isDraggedOperation(dragging)
? dragType === 'reorder'
? isFromTheSameGroup
: isFromCompatibleGroup
: layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: accessor,
filterOperations: group.filterOperations,
});
<ReorderProvider id={group.groupId} className={'lnsLayerPanel__group'}>
{group.accessors.map((accessorConfig, accessorIndex) => {
const { columnId } = accessorConfig;
return (
<DragDrop
key={accessor}
draggable={!activeId}
dragType={dragType}
dropType={dropType}
data-test-subj={group.dataTestSubj}
itemsInGroup={group.accessors.map((a) =>
typeof a === 'string' ? a : a.columnId
)}
className={'lnsLayerPanel__dimensionContainer'}
value={{
columnId: accessor,
groupId: group.groupId,
layerId,
id: accessor,
}}
isValueEqual={isSameConfiguration}
label={columnLabelMap[accessor]}
droppable={dragging && isDroppable}
dropTo={(dropTargetId: string) => {
layerDatasource.onDrop({
isReorder: true,
...layerDatasourceDropProps,
droppedItem: {
columnId: accessor,
groupId: group.groupId,
layerId,
id: accessor,
},
columnId: dropTargetId,
filterOperations: group.filterOperations,
});
}}
onDrop={(droppedItem) => {
const isReorder =
isDraggedOperation(droppedItem) &&
droppedItem.groupId === group.groupId &&
droppedItem.columnId !== accessor;
const dropResult = layerDatasource.onDrop({
isReorder,
...layerDatasourceDropProps,
droppedItem,
columnId: accessor,
filterOperations: group.filterOperations,
});
if (typeof dropResult === 'object') {
// When a column is moved, we delete the reference to the old
props.updateVisualization(
activeVisualization.removeDimension({
layerId,
columnId: dropResult.deleted,
prevState: props.visualizationState,
})
);
}
}}
<DraggableDimensionButton
accessorIndex={accessorIndex}
columnId={columnId}
dragDropContext={dragDropContext}
group={group}
groupIndex={groupIndex}
key={columnId}
layerDatasourceDropProps={layerDatasourceDropProps}
label={columnLabelMap[columnId]}
layerDatasource={layerDatasource}
layerIndex={layerIndex}
layerId={layerId}
onDrop={onDrop}
>
<div className="lnsLayerPanel__dimension">
<EuiLink
className="lnsLayerPanel__dimensionLink"
data-test-subj="lnsLayerPanel-dimensionLink"
onClick={() => {
if (activeId) {
setActiveDimension(initialActiveDimensionState);
} else {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: accessor,
});
}
<DimensionButton
accessorConfig={accessorConfig}
label={columnLabelMap[accessorConfig.columnId]}
group={group}
onClick={(id: string) => {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: id,
});
}}
aria-label={triggerLinkA11yText(columnLabelMap[accessor])}
title={triggerLinkA11yText(columnLabelMap[accessor])}
>
<ColorIndicator accessorConfig={accessorConfig}>
<NativeRenderer
render={layerDatasource.renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessor,
filterOperations: group.filterOperations,
}}
/>
</ColorIndicator>
</EuiLink>
<EuiButtonIcon
className="lnsLayerPanel__dimensionRemove"
data-test-subj="indexPattern-dimension-remove"
iconType="cross"
iconSize="s"
size="s"
color="danger"
aria-label={i18n.translate(
'xpack.lens.indexPattern.removeColumnLabel',
{
defaultMessage: 'Remove configuration from "{groupLabel}"',
values: { groupLabel: group.groupLabel },
}
)}
title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', {
defaultMessage: 'Remove configuration from "{groupLabel}"',
values: { groupLabel: group.groupLabel },
})}
onClick={() => {
onRemoveClick={(id: string) => {
trackUiEvent('indexpattern_dimension_removed');
props.updateAll(
datasourceId,
layerDatasource.removeColumn({
layerId,
columnId: accessor,
columnId: id,
prevState: layerDatasourceState,
}),
activeVisualization.removeDimension({
layerId,
columnId: accessor,
columnId: id,
prevState: props.visualizationState,
})
);
}}
/>
<PaletteIndicator accessorConfig={accessorConfig} />
>
<NativeRenderer
render={layerDatasource.renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessorConfig.columnId,
filterOperations: group.filterOperations,
}}
/>
</DimensionButton>
</div>
</DragDrop>
</DraggableDimensionButton>
);
})}
</ReorderProvider>
{group.supportsMoreColumns ? (
<div className={'lnsLayerPanel__dimensionContainer'}>
<DragDrop
data-test-subj={group.dataTestSubj}
droppable={
Boolean(dragDropContext.dragging) &&
// Verify that the dragged item is not coming from the same group
// since this would be a reorder
(!isDraggedOperation(dragDropContext.dragging) ||
dragDropContext.dragging.groupId !== group.groupId) &&
layerDatasource.canHandleDrop({
...layerDatasourceDropProps,
columnId: newId,
filterOperations: group.filterOperations,
})
}
onDrop={(droppedItem) => {
const dropResult = layerDatasource.onDrop({
...layerDatasourceDropProps,
droppedItem,
columnId: newId,
filterOperations: group.filterOperations,
});
if (dropResult) {
props.updateVisualization(
activeVisualization.setDimension({
layerId,
groupId: group.groupId,
columnId: newId,
prevState: props.visualizationState,
})
);
if (typeof dropResult === 'object') {
// When a column is moved, we delete the reference to the old
props.updateVisualization(
activeVisualization.removeDimension({
layerId,
columnId: dropResult.deleted,
prevState: props.visualizationState,
})
);
}
}
}}
>
<div className="lnsLayerPanel__dimension lnsLayerPanel__dimension--empty">
<EuiButtonEmpty
className="lnsLayerPanel__triggerText"
color="text"
size="xs"
iconType="plusInCircleFilled"
contentProps={{
className: 'lnsLayerPanel__triggerTextContent',
}}
aria-label={i18n.translate(
'xpack.lens.indexPattern.removeColumnAriaLabel',
{
defaultMessage: 'Drop a field or click to add to {groupLabel}',
values: { groupLabel: group.groupLabel },
}
)}
data-test-subj="lns-empty-dimension"
onClick={() => {
if (activeId) {
setActiveDimension(initialActiveDimensionState);
} else {
setActiveDimension({
isNew: true,
activeGroup: group,
activeId: newId,
});
}
}}
>
<FormattedMessage
id="xpack.lens.configure.emptyConfig"
defaultMessage="Drop a field or click to add"
/>
</EuiButtonEmpty>
</div>
</DragDrop>
</div>
<EmptyDimensionButton
dragDropContext={dragDropContext}
group={group}
groupIndex={groupIndex}
layerId={layerId}
layerIndex={layerIndex}
layerDatasource={layerDatasource}
layerDatasourceDropProps={layerDatasourceDropProps}
onClick={(id) => {
setActiveDimension({
activeGroup: group,
activeId: id,
isNew: true,
});
}}
onDrop={onDrop}
/>
) : null}
</>
</EuiFormRow>
@ -572,44 +426,11 @@ export function LayerPanel(
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="xs"
iconType="trash"
color="danger"
data-test-subj="lnsLayerRemove"
aria-label={
isOnlyLayer
? i18n.translate('xpack.lens.resetLayerAriaLabel', {
defaultMessage: 'Reset layer {index}',
values: { index: index + 1 },
})
: i18n.translate('xpack.lens.deleteLayerAriaLabel', {
defaultMessage: `Delete layer {index}`,
values: { index: index + 1 },
})
}
onClick={() => {
// If we don't blur the remove / clear button, it remains focused
// which is a strange UX in this case. e.target.blur doesn't work
// due to who knows what, but probably event re-writing. Additionally,
// activeElement does not have blur so, we need to do some casting + safeguards.
const el = (document.activeElement as unknown) as { blur: () => void };
if (el?.blur) {
el.blur();
}
onRemoveLayer();
}}
>
{isOnlyLayer
? i18n.translate('xpack.lens.resetLayer', {
defaultMessage: 'Reset layer',
})
: i18n.translate('xpack.lens.deleteLayer', {
defaultMessage: `Delete layer`,
})}
</EuiButtonEmpty>
<RemoveLayerButton
onRemoveLayer={onRemoveLayer}
layerIndex={layerIndex}
isOnlyLayer={isOnlyLayer}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
export function RemoveLayerButton({
onRemoveLayer,
layerIndex,
isOnlyLayer,
}: {
onRemoveLayer: () => void;
layerIndex: number;
isOnlyLayer: boolean;
}) {
return (
<EuiButtonEmpty
size="xs"
iconType="trash"
color="danger"
data-test-subj="lnsLayerRemove"
aria-label={
isOnlyLayer
? i18n.translate('xpack.lens.resetLayerAriaLabel', {
defaultMessage: 'Reset layer {index}',
values: { index: layerIndex + 1 },
})
: i18n.translate('xpack.lens.deleteLayerAriaLabel', {
defaultMessage: `Delete layer {index}`,
values: { index: layerIndex + 1 },
})
}
onClick={() => {
// If we don't blur the remove / clear button, it remains focused
// which is a strange UX in this case. e.target.blur doesn't work
// due to who knows what, but probably event re-writing. Additionally,
// activeElement does not have blur so, we need to do some casting + safeguards.
const el = (document.activeElement as unknown) as { blur: () => void };
if (el?.blur) {
el.blur();
}
onRemoveLayer();
}}
>
{isOnlyLayer
? i18n.translate('xpack.lens.resetLayer', {
defaultMessage: 'Reset layer',
})
: i18n.translate('xpack.lens.deleteLayer', {
defaultMessage: `Delete layer`,
})}
</EuiButtonEmpty>
);
}

View file

@ -12,7 +12,7 @@ import {
DatasourceDimensionEditorProps,
VisualizationDimensionGroupConfig,
} from '../../../types';
import { DragContextState } from '../../../drag_drop';
export interface ConfigPanelWrapperProps {
activeDatasourceId: string;
visualizationState: unknown;
@ -31,6 +31,30 @@ export interface ConfigPanelWrapperProps {
core: DatasourceDimensionEditorProps['core'];
}
export interface LayerPanelProps {
activeDatasourceId: string;
visualizationState: unknown;
datasourceMap: Record<string, Datasource>;
activeVisualization: Visualization;
dispatch: (action: Action) => void;
framePublicAPI: FramePublicAPI;
datasourceStates: Record<
string,
{
isLoading: boolean;
state: unknown;
}
>;
core: DatasourceDimensionEditorProps['core'];
}
export interface LayerDatasourceDropProps {
layerId: string;
dragDropContext: DragContextState;
state: unknown;
setState: (newState: unknown) => void;
}
export interface ActiveDimensionState {
isNew: boolean;
activeId?: string;

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { NativeRenderer } from '../../native_renderer';
import { Action } from './state_management';
import { DragContext, Dragging } from '../../drag_drop';
import { DragContext, DragDropIdentifier } from '../../drag_drop';
import { StateSetter, FramePublicAPI, DatasourceDataPanelProps, Datasource } from '../../types';
import { Query, Filter } from '../../../../../../src/plugins/data/public';
@ -26,8 +26,8 @@ interface DataPanelWrapperProps {
query: Query;
dateRange: FramePublicAPI['dateRange'];
filters: Filter[];
dropOntoWorkspace: (field: Dragging) => void;
hasSuggestionForField: (field: Dragging) => boolean;
dropOntoWorkspace: (field: DragDropIdentifier) => void;
hasSuggestionForField: (field: DragDropIdentifier) => boolean;
}
export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => {

View file

@ -1338,10 +1338,14 @@ describe('editor_frame', () => {
instance.update();
act(() => {
instance.find(DragDrop).filter('[data-test-subj="mockVisA"]').prop('onDrop')!({
indexPatternId: '1',
field: {},
});
instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!(
{
indexPatternId: '1',
field: {},
id: '1',
},
{ id: 'lnsWorkspace' }
);
});
expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith(
@ -1435,10 +1439,14 @@ describe('editor_frame', () => {
instance.update();
act(() => {
instance.find(DragDrop).filter('[data-test-subj="lnsWorkspace"]').prop('onDrop')!({
indexPatternId: '1',
field: {},
});
instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!(
{
indexPatternId: '1',
field: {},
id: '1',
},
{ id: 'lnsWorkspace' }
);
});
expect(mockVisualization3.getConfiguration).toHaveBeenCalledWith(

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useReducer, useState } from 'react';
import React, { useEffect, useReducer, useState, useCallback } from 'react';
import { CoreSetup, CoreStart } from 'kibana/public';
import { PaletteRegistry } from 'src/plugins/charts/public';
import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public';
@ -16,7 +16,7 @@ import { FrameLayout } from './frame_layout';
import { SuggestionPanel } from './suggestion_panel';
import { WorkspacePanel } from './workspace_panel';
import { Document } from '../../persistence/saved_object_store';
import { Dragging, RootDragDropProvider } from '../../drag_drop';
import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop';
import { getSavedObjectFormat } from './save';
import { generateId } from '../../id_generator';
import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public';
@ -260,7 +260,7 @@ export function EditorFrame(props: EditorFrameProps) {
);
const getSuggestionForField = React.useCallback(
(field: Dragging) => {
(field: DragDropIdentifier) => {
const { activeDatasourceId, datasourceStates } = state;
const activeVisualizationId = state.visualization.activeId;
const visualizationState = state.visualization.state;
@ -290,12 +290,12 @@ export function EditorFrame(props: EditorFrameProps) {
]
);
const hasSuggestionForField = React.useCallback(
(field: Dragging) => getSuggestionForField(field) !== undefined,
const hasSuggestionForField = useCallback(
(field: DragDropIdentifier) => getSuggestionForField(field) !== undefined,
[getSuggestionForField]
);
const dropOntoWorkspace = React.useCallback(
const dropOntoWorkspace = useCallback(
(field) => {
const suggestion = getSuggestionForField(field);
if (suggestion) {

View file

@ -19,7 +19,7 @@ import {
DatasourcePublicAPI,
} from '../../types';
import { Action } from './state_management';
import { Dragging } from '../../drag_drop';
import { DragDropIdentifier } from '../../drag_drop';
export interface Suggestion {
visualizationId: string;
@ -231,7 +231,7 @@ export function getTopSuggestionForField(
visualizationState: unknown,
datasource: Datasource,
datasourceStates: Record<string, { state: unknown; isLoading: boolean }>,
field: Dragging
field: DragDropIdentifier
) {
const hasData = Object.values(datasourceLayers).some(
(datasourceLayer) => datasourceLayer.getTableSpec().length > 0

View file

@ -784,7 +784,15 @@ describe('workspace_panel', () => {
function initComponent(draggingContext = draggedField) {
instance = mount(
<ChildDragDropProvider dragging={draggingContext} setDragging={() => {}}>
<ChildDragDropProvider
dragging={draggingContext}
setDragging={() => {}}
setActiveDropTarget={() => {}}
activeDropTarget={undefined}
keyboardMode={false}
setKeyboardMode={() => {}}
setA11yMessage={() => {}}
>
<WorkspacePanel
activeDatasourceId={'mock'}
datasourceStates={{
@ -822,7 +830,7 @@ describe('workspace_panel', () => {
});
initComponent();
instance.find(DragDrop).prop('onDrop')!(draggedField);
instance.find(DragDrop).prop('onDrop')!(draggedField, { id: 'lnsWorkspace' });
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SWITCH_VISUALIZATION',

View file

@ -39,7 +39,7 @@ import {
isLensFilterEvent,
isLensEditEvent,
} from '../../../types';
import { DragDrop, DragContext, Dragging } from '../../../drag_drop';
import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop';
import { Suggestion, switchToSuggestion } from '../suggestion_helpers';
import { buildExpression } from '../expression_helpers';
import { debouncedComponent } from '../../../debounced_component';
@ -75,7 +75,7 @@ export interface WorkspacePanelProps {
plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart };
title?: string;
visualizeTriggerFieldContext?: VisualizeFieldContext;
getSuggestionForField: (field: Dragging) => Suggestion | undefined;
getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined;
}
interface WorkspaceState {
@ -83,8 +83,10 @@ interface WorkspaceState {
expandError: boolean;
}
const workspaceDropValue = { id: 'lnsWorkspace' };
// Exported for testing purposes only.
export function WorkspacePanel({
export const WorkspacePanel = React.memo(function WorkspacePanel({
activeDatasourceId,
activeVisualizationId,
visualizationMap,
@ -102,7 +104,8 @@ export function WorkspacePanel({
}: WorkspacePanelProps) {
const dragDropContext = useContext(DragContext);
const suggestionForDraggedField = getSuggestionForField(dragDropContext.dragging);
const suggestionForDraggedField =
dragDropContext.dragging && getSuggestionForField(dragDropContext.dragging);
const [localState, setLocalState] = useState<WorkspaceState>({
expressionBuildError: undefined,
@ -296,10 +299,11 @@ export function WorkspacePanel({
>
<DragDrop
className="lnsWorkspacePanel__dragDrop"
data-test-subj="lnsWorkspace"
dataTestSubj="lnsWorkspace"
draggable={false}
droppable={Boolean(suggestionForDraggedField)}
onDrop={onDrop}
value={workspaceDropValue}
>
<div>
{renderVisualization()}
@ -308,7 +312,7 @@ export function WorkspacePanel({
</DragDrop>
</WorkspacePanelWrapper>
);
}
});
export const InnerVisualizationWrapper = ({
expression,

View file

@ -278,7 +278,10 @@ describe('IndexPattern Data Panel', () => {
{...defaultProps}
state={state}
setState={setStateSpy}
dragDropContext={{ dragging: { id: '1' }, setDragging: () => {} }}
dragDropContext={{
...createMockedDragDropContext(),
dragging: { id: '1' },
}}
/>
);
@ -297,7 +300,10 @@ describe('IndexPattern Data Panel', () => {
indexPatterns: {},
}}
setState={jest.fn()}
dragDropContext={{ dragging: { id: '1' }, setDragging: () => {} }}
dragDropContext={{
...createMockedDragDropContext(),
dragging: { id: '1' },
}}
changeIndexPattern={jest.fn()}
/>
);
@ -329,7 +335,10 @@ describe('IndexPattern Data Panel', () => {
...defaultProps,
changeIndexPattern: jest.fn(),
setState,
dragDropContext: { dragging: { id: '1' }, setDragging: () => {} },
dragDropContext: {
...createMockedDragDropContext(),
dragging: { id: '1' },
},
dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' },
state: {
indexPatternRefs: [],

View file

@ -426,6 +426,23 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
);
}, [unfilteredFieldGroups, localState.nameFilter, localState.typeFilter]);
const checkFieldExists = useCallback(
(field) =>
field.type === 'document' ||
fieldExists(existingFields, currentIndexPattern.title, field.name),
[existingFields, currentIndexPattern.title]
);
const { nameFilter, typeFilter } = localState;
const filter = useMemo(
() => ({
nameFilter,
typeFilter,
}),
[nameFilter, typeFilter]
);
const fieldProps = useMemo(
() => ({
core,
@ -586,17 +603,11 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
</EuiScreenReaderOnly>
<EuiFlexItem>
<FieldList
exists={(field) =>
field.type === 'document' ||
fieldExists(existingFields, currentIndexPattern.title, field.name)
}
exists={checkFieldExists}
fieldProps={fieldProps}
fieldGroups={fieldGroups}
hasSyncedExistingFields={!!hasSyncedExistingFields}
filter={{
nameFilter: localState.nameFilter,
typeFilter: localState.typeFilter,
}}
filter={filter}
currentIndexPatternId={currentIndexPatternId}
existenceFetchFailed={existenceFetchFailed}
existFieldsInIndex={!!allFields.length}

View file

@ -316,6 +316,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
droppedItem: dragging,
columnId: 'col2',
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
groupId: '1',
});
expect(setState).toBeCalledTimes(1);
@ -352,6 +353,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
droppedItem: dragging,
columnId: 'col2',
filterOperations: (op: OperationMetadata) => op.isBucketed,
groupId: '1',
});
expect(setState).toBeCalledTimes(1);
@ -387,6 +389,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
droppedItem: dragging,
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
groupId: '1',
});
expect(setState).toBeCalledTimes(1);
@ -438,6 +441,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
},
},
groupId: '1',
});
expect(setState).toBeCalledTimes(1);
@ -473,6 +477,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
droppedItem: dragging,
columnId: 'col2',
groupId: '1',
});
expect(setState).toBeCalledTimes(1);
@ -538,6 +543,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
},
droppedItem: dragging,
state: testState,
groupId: '1',
});
expect(setState).toBeCalledTimes(1);
@ -600,6 +606,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
droppedItem: dragging,
state: testState,
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
groupId: 'a',
};
const stateWithColumnOrder = (columnOrder: string[]) => {

View file

@ -8,6 +8,7 @@ import {
DatasourceDimensionDropProps,
DatasourceDimensionDropHandlerProps,
isDraggedOperation,
DraggedOperation,
} from '../../types';
import { IndexPatternColumn } from '../indexpattern';
import { insertOrReplaceColumn } from '../operations';
@ -15,7 +16,15 @@ import { mergeLayer } from '../state_helpers';
import { hasField, isDraggedField } from '../utils';
import { IndexPatternPrivateState, IndexPatternField } from '../types';
import { trackUiEvent } from '../../lens_ui_telemetry';
import { getOperationSupportMatrix } from './operation_support';
import { getOperationSupportMatrix, OperationSupportMatrix } from './operation_support';
type DropHandlerProps<T = DraggedOperation> = Pick<
DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>,
'columnId' | 'setState' | 'state' | 'layerId' | 'droppedItem'
> & {
droppedItem: T;
operationSupportMatrix: OperationSupportMatrix;
};
export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
@ -29,11 +38,11 @@ export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPr
if (isDraggedField(dragging)) {
const currentColumn = props.state.layers[props.layerId].columns[props.columnId];
return (
return Boolean(
layerIndexPatternId === dragging.indexPatternId &&
Boolean(hasOperationForField(dragging.field)) &&
(!currentColumn ||
(hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name))
Boolean(hasOperationForField(dragging.field)) &&
(!currentColumn ||
(hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name))
);
}
@ -59,74 +68,80 @@ function reorderElements(items: string[], dest: string, src: string) {
return result;
}
export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) {
const operationSupportMatrix = getOperationSupportMatrix(props);
const { setState, state, layerId, columnId, droppedItem } = props;
const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: DropHandlerProps) => {
setState(
mergeLayer({
state,
layerId,
newLayer: {
columnOrder: reorderElements(
state.layers[layerId].columnOrder,
columnId,
droppedItem.columnId
),
},
})
);
if (isDraggedOperation(droppedItem) && props.isReorder) {
const dropEl = columnId;
return true;
};
setState(
mergeLayer({
state,
layerId,
newLayer: {
columnOrder: reorderElements(
state.layers[layerId].columnOrder,
dropEl,
droppedItem.columnId
),
},
})
);
const onMoveDropToCompatibleGroup = ({
columnId,
setState,
state,
layerId,
droppedItem,
}: DropHandlerProps) => {
const layer = state.layers[layerId];
const op = { ...layer.columns[droppedItem.columnId] };
const newColumns = { ...layer.columns };
delete newColumns[droppedItem.columnId];
newColumns[columnId] = op;
return true;
const newColumnOrder = [...layer.columnOrder];
const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId);
const newIndex = newColumnOrder.findIndex((c) => c === columnId);
if (newIndex === -1) {
newColumnOrder[oldIndex] = columnId;
} else {
newColumnOrder.splice(oldIndex, 1);
}
// Time to replace
setState(
mergeLayer({
state,
layerId,
newLayer: {
columnOrder: newColumnOrder,
columns: newColumns,
},
})
);
return { deleted: droppedItem.columnId };
};
const onFieldDrop = ({
columnId,
setState,
state,
layerId,
droppedItem,
operationSupportMatrix,
}: DropHandlerProps<unknown>) => {
function hasOperationForField(field: IndexPatternField) {
return Boolean(operationSupportMatrix.operationByField[field.name]);
}
if (isDraggedOperation(droppedItem) && droppedItem.layerId === layerId) {
const layer = state.layers[layerId];
const op = { ...layer.columns[droppedItem.columnId] };
if (!props.filterOperations(op)) {
return false;
}
const newColumns = { ...layer.columns };
delete newColumns[droppedItem.columnId];
newColumns[columnId] = op;
const newColumnOrder = [...layer.columnOrder];
const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId);
const newIndex = newColumnOrder.findIndex((c) => c === columnId);
if (newIndex === -1) {
newColumnOrder[oldIndex] = columnId;
} else {
newColumnOrder.splice(oldIndex, 1);
}
// Time to replace
setState(
mergeLayer({
state,
layerId,
newLayer: {
columnOrder: newColumnOrder,
columns: newColumns,
},
})
);
return { deleted: droppedItem.columnId };
}
if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) {
// TODO: What do we do if we couldn't find a column?
return false;
}
// dragged field, not operation
const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name];
if (!operationsForNewField || operationsForNewField.size === 0) {
@ -159,6 +174,56 @@ export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPr
const hasData = Object.values(state.layers).some(({ columns }) => columns.length);
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

@ -95,6 +95,8 @@ describe('IndexPattern Field Item', () => {
},
exists: true,
chartsThemeService,
groupIndex: 0,
itemIndex: 0,
dropOntoWorkspace: () => {},
hasSuggestionForField: () => false,
};

View file

@ -6,7 +6,7 @@
import './field_item.scss';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useState, useMemo } from 'react';
import DateMath from '@elastic/datemath';
import {
EuiButtonGroup,
@ -48,7 +48,7 @@ import {
import { FieldButton } from '../../../../../src/plugins/kibana_react/public';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { DraggedField } from './indexpattern';
import { DragDrop, Dragging } from '../drag_drop';
import { DragDrop, DragDropIdentifier } from '../drag_drop';
import { DatasourceDataPanelProps, DataType } from '../types';
import { BucketedAggregation, FieldStatsResponse } from '../../common';
import { IndexPattern, IndexPatternField } from './types';
@ -69,6 +69,8 @@ export interface FieldItemProps {
chartsThemeService: ChartsPluginSetup['theme'];
filters: Filter[];
hideDetails?: boolean;
itemIndex: number;
groupIndex: number;
dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace'];
hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField'];
}
@ -106,7 +108,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
const [infoIsOpen, setOpen] = useState(false);
const dropOntoWorkspaceAndClose = useCallback(
(droppedField: Dragging) => {
(droppedField: DragDropIdentifier) => {
dropOntoWorkspace(droppedField);
setOpen(false);
},
@ -163,10 +165,11 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
}
}
const value = React.useMemo(
const value = useMemo(
() => ({ field, indexPatternId: indexPattern.id, id: field.name } as DraggedField),
[field, indexPattern.id]
);
const lensFieldIcon = <LensFieldIcon type={field.type as DataType} />;
const lensInfoIcon = (
<EuiIconTip
@ -200,10 +203,11 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
container={document.querySelector<HTMLElement>('.application') || undefined}
button={
<DragDrop
noKeyboardSupportYet
draggable
label={field.displayName}
value={value}
data-test-subj={`lnsFieldListPanelField-${field.name}`}
draggable
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
>
<FieldButton
className={`lnsFieldItem lnsFieldItem--${field.type} lnsFieldItem--${
@ -215,7 +219,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
['aria-label']: i18n.translate(
'xpack.lens.indexPattern.fieldStatsButtonAriaLabel',
{
defaultMessage: '{fieldName}: {fieldType}. Hit enter for a field preview.',
defaultMessage: 'Preview {fieldName}: {fieldType}',
values: {
fieldName: field.displayName,
fieldType: field.type,

View file

@ -40,7 +40,7 @@ function getDisplayedFieldsLength(
.reduce((allFieldCount, [, { fields }]) => allFieldCount + fields.length, 0);
}
export function FieldList({
export const FieldList = React.memo(function FieldList({
exists,
fieldGroups,
existenceFetchFailed,
@ -135,13 +135,15 @@ export function FieldList({
{Object.entries(fieldGroups)
.filter(([, { showInAccordion }]) => !showInAccordion)
.flatMap(([, { fields }]) =>
fields.map((field) => (
fields.map((field, index) => (
<FieldItem
{...fieldProps}
exists={exists(field)}
field={field}
hideDetails={true}
key={field.name}
itemIndex={index}
groupIndex={0}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
/>
@ -151,7 +153,7 @@ export function FieldList({
<EuiSpacer size="s" />
{Object.entries(fieldGroups)
.filter(([, { showInAccordion }]) => showInAccordion)
.map(([key, fieldGroup]) => (
.map(([key, fieldGroup], index) => (
<Fragment key={key}>
<FieldsAccordion
dropOntoWorkspace={dropOntoWorkspace}
@ -168,6 +170,7 @@ export function FieldList({
isFiltered={fieldGroup.fieldCount !== fieldGroup.fields.length}
paginatedFields={paginatedFields[key]}
fieldProps={fieldProps}
groupIndex={index + 1}
onToggle={(open) => {
setAccordionState((s) => ({
...s,
@ -198,4 +201,4 @@ export function FieldList({
</div>
</div>
);
}
});

View file

@ -72,6 +72,7 @@ describe('Fields Accordion', () => {
fieldProps,
renderCallout: <div id="lens-test-callout">Callout</div>,
exists: () => true,
groupIndex: 0,
dropOntoWorkspace: () => {},
hasSuggestionForField: () => false,
};

View file

@ -5,7 +5,7 @@
*/
import './datapanel.scss';
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiText,
@ -50,11 +50,12 @@ export interface FieldsAccordionProps {
exists: (field: IndexPatternField) => boolean;
showExistenceFetchError?: boolean;
hideDetails?: boolean;
groupIndex: number;
dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace'];
hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField'];
}
export const InnerFieldsAccordion = function InnerFieldsAccordion({
export const FieldsAccordion = memo(function InnerFieldsAccordion({
initialIsOpen,
onToggle,
id,
@ -69,28 +70,72 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({
exists,
hideDetails,
showExistenceFetchError,
groupIndex,
dropOntoWorkspace,
hasSuggestionForField,
}: FieldsAccordionProps) {
const renderField = useCallback(
(field: IndexPatternField) => (
(field: IndexPatternField, index) => (
<FieldItem
{...fieldProps}
key={field.name}
field={field}
exists={exists(field)}
hideDetails={hideDetails}
itemIndex={index}
groupIndex={groupIndex}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
/>
),
[fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField]
[fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField, groupIndex]
);
const titleClassname = classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip,
});
const renderButton = useMemo(() => {
const titleClassname = classNames({
// eslint-disable-next-line @typescript-eslint/naming-convention
lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip,
});
return (
<EuiText size="xs">
<strong className={titleClassname}>{label}</strong>
{!!helpTooltip && (
<EuiIconTip
aria-label={helpTooltip}
type="questionInCircle"
color="subdued"
size="s"
position="right"
content={helpTooltip}
iconProps={{
className: 'eui-alignTop',
}}
/>
)}
</EuiText>
);
}, [label, helpTooltip]);
const extraAction = useMemo(() => {
return showExistenceFetchError ? (
<EuiIconTip
aria-label={i18n.translate('xpack.lens.indexPattern.existenceErrorAriaLabel', {
defaultMessage: 'Existence fetch failed',
})}
type="alert"
color="warning"
content={i18n.translate('xpack.lens.indexPattern.existenceErrorLabel', {
defaultMessage: "Field information can't be loaded",
})}
/>
) : hasLoaded ? (
<EuiNotificationBadge size="m" color={isFiltered ? 'accent' : 'subdued'}>
{fieldsCount}
</EuiNotificationBadge>
) : (
<EuiLoadingSpinner size="m" />
);
}, [showExistenceFetchError, hasLoaded, isFiltered, fieldsCount]);
return (
<EuiAccordion
@ -98,44 +143,8 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({
onToggle={onToggle}
data-test-subj={id}
id={id}
buttonContent={
<EuiText size="xs">
<strong className={titleClassname}>{label}</strong>
{!!helpTooltip && (
<EuiIconTip
aria-label={helpTooltip}
type="questionInCircle"
color="subdued"
size="s"
position="right"
content={helpTooltip}
iconProps={{
className: 'eui-alignTop',
}}
/>
)}
</EuiText>
}
extraAction={
showExistenceFetchError ? (
<EuiIconTip
aria-label={i18n.translate('xpack.lens.indexPattern.existenceErrorAriaLabel', {
defaultMessage: 'Existence fetch failed',
})}
type="alert"
color="warning"
content={i18n.translate('xpack.lens.indexPattern.existenceErrorLabel', {
defaultMessage: "Field information can't be loaded",
})}
/>
) : hasLoaded ? (
<EuiNotificationBadge size="m" color={isFiltered ? 'accent' : 'subdued'}>
{fieldsCount}
</EuiNotificationBadge>
) : (
<EuiLoadingSpinner size="m" />
)
}
buttonContent={renderButton}
extraAction={extraAction}
>
<EuiSpacer size="s" />
{hasLoaded &&
@ -148,6 +157,4 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({
))}
</EuiAccordion>
);
};
export const FieldsAccordion = memo(InnerFieldsAccordion);
});

View file

@ -51,11 +51,11 @@ import { mergeLayer } from './state_helpers';
import { Datasource, StateSetter } from '../types';
import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public';
import { deleteColumn, isReferenced } from './operations';
import { Dragging } from '../drag_drop/providers';
import { DragDropIdentifier } from '../drag_drop/providers';
export { OperationType, IndexPatternColumn, deleteColumn } from './operations';
export type DraggedField = Dragging & {
export type DraggedField = DragDropIdentifier & {
field: IndexPatternField;
indexPatternId: string;
};

View file

@ -247,5 +247,10 @@ export function createMockedDragDropContext(): jest.Mocked<DragContextState> {
return {
dragging: undefined,
setDragging: jest.fn(),
activeDropTarget: undefined,
setActiveDropTarget: jest.fn(),
keyboardMode: false,
setKeyboardMode: jest.fn(),
setA11yMessage: jest.fn(),
};
}

View file

@ -16,7 +16,7 @@ import {
Datatable,
SerializedFieldFormat,
} from '../../../../src/plugins/expressions/public';
import { DragContextState, Dragging } from './drag_drop';
import { DragContextState, DragDropIdentifier } from './drag_drop';
import { Document } from './persistence';
import { DateRange } from '../common';
import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public';
@ -226,8 +226,8 @@ export interface DatasourceDataPanelProps<T = unknown> {
query: Query;
dateRange: DateRange;
filters: Filter[];
dropOntoWorkspace: (field: Dragging) => void;
hasSuggestionForField: (field: Dragging) => boolean;
dropOntoWorkspace: (field: DragDropIdentifier) => void;
hasSuggestionForField: (field: DragDropIdentifier) => boolean;
}
interface SharedDimensionProps {
@ -301,6 +301,8 @@ export type DatasourceDimensionDropProps<T> = SharedDimensionProps & {
export type DatasourceDimensionDropHandlerProps<T> = DatasourceDimensionDropProps<T> & {
droppedItem: unknown;
groupId: string;
isNew?: boolean;
};
export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip';

View file

@ -5,7 +5,7 @@
*/
import './xy_config_panel.scss';
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, memo } from 'react';
import { i18n } from '@kbn/i18n';
import { Position } from '@elastic/charts';
import { debounce } from 'lodash';
@ -179,8 +179,7 @@ function getValueLabelDisableReason({
defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts',
});
}
export function XyToolbar(props: VisualizationToolbarProps<State>) {
export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps<State>) {
const { state, setState, frame } = props;
const hasNonBarSeries = state?.layers.some(({ seriesType }) =>
@ -485,7 +484,8 @@ export function XyToolbar(props: VisualizationToolbarProps<State>) {
</EuiFlexItem>
</EuiFlexGroup>
);
}
});
const idPrefix = htmlIdGenerator()();
export function DimensionEditor(
@ -653,7 +653,7 @@ const ColorPicker = ({
}
};
const updateColorInState: EuiColorPickerProps['onChange'] = React.useMemo(
const updateColorInState: EuiColorPickerProps['onChange'] = useMemo(
() =>
debounce((text, output) => {
const newYConfigs = [...(layer.yConfig || [])];

View file

@ -11188,7 +11188,6 @@
"xpack.lens.discover.visualizeFieldLegend": "Visualize フィールド",
"xpack.lens.dragDrop.elementLifted": "位置 {position} のアイテム {itemLabel} を持ち上げました。",
"xpack.lens.dragDrop.elementMoved": "位置 {prevPosition} から位置 {position} までアイテム {itemLabel} を移動しました",
"xpack.lens.dragDrop.reorderInstructions": "スペースバーを押すと、ドラッグを開始します。ドラッグするときには、矢印キーで並べ替えることができます。もう一度スペースバーを押すと終了します。",
"xpack.lens.editLayerSettings": "レイヤー設定を編集",
"xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}",
"xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました",

View file

@ -11217,7 +11217,6 @@
"xpack.lens.discover.visualizeFieldLegend": "可视化字段",
"xpack.lens.dragDrop.elementLifted": "您已将项目 {itemLabel} 提升到位置 {position}",
"xpack.lens.dragDrop.elementMoved": "您已将项目 {itemLabel} 从位置 {prevPosition} 移到位置 {position}",
"xpack.lens.dragDrop.reorderInstructions": "按空格键开始拖动。拖动时,使用方向键重新排序。再次按空格键结束操作。",
"xpack.lens.editLayerSettings": "编辑图层设置",
"xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}",
"xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误",

View file

@ -202,7 +202,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
}) .lnsDragDrop`;
const dropping = `[data-test-subj='${dimension}']:nth-of-type(${
endIndex + 1
}) [data-test-subj='lnsDragDrop-reorderableDrop'`;
}) [data-test-subj='lnsDragDrop-reorderableDropLayer'`;
await browser.html5DragAndDrop(dragging, dropping);
await PageObjects.header.waitUntilLoadingHasFinished();
},