[Discover] Drag & drop for adding columns to the table (#153538)

Closes https://github.com/elastic/kibana/issues/151703

## Summary

This PR:
- changes design of field list item in Lens and Discover
- changes design of dimension triggers in Lens panels
- changes design "Add layers" button
- adds "+" action to field items in Lens
- unifies the logic of rendering Lens field items for text-based and
form-based views
- adds Drag&Drop functionality to Discover to add columns to the table
by dragging a field from the sidebar
- adds functional tests for drag&drop in Discover
- shows fields popover in text-based mode too so users can copy field
names


[Figma](https://www.figma.com/file/SvpfCqaZPb2iAYnPtd0Gnr/KUI-Library?node-id=674%3A198901&t=OnQH2EQ4fdBjsRLp-0)

<details>
<summary>Gifs</summary>

![Apr-04-2023
14-38-47](https://user-images.githubusercontent.com/1415710/229795117-712267ba-f5e0-42ca-a2e5-e23759d5ddda.gif)
![Apr-04-2023
14-40-59](https://user-images.githubusercontent.com/1415710/229795133-7b618566-e73a-4303-97d7-b2840d1fc006.gif)

</details>


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Julia Rechkunova 2023-04-19 19:17:54 +02:00 committed by GitHub
parent 0225747610
commit cac928b956
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 1628 additions and 748 deletions

View file

@ -16,6 +16,8 @@ export {
RootDragDropProvider,
ChildDragDropProvider,
ReorderProvider,
DropOverlayWrapper,
type DropOverlayWrapperProps,
} from './src';
export { DropTargetSwapDuplicateCombine } from './src/drop_targets';

View file

@ -7,3 +7,5 @@
*/
export const DEFAULT_DATA_TEST_SUBJ = 'domDragDrop';
export const REORDER_ITEM_HEIGHT = 32;
export const REORDER_ITEM_MARGIN = 8;

View file

@ -468,7 +468,7 @@ describe('DragDrop', () => {
ghost: {
children: <button>Hi!</button>,
style: {
height: 0,
minHeight: 0,
width: 0,
},
},
@ -1100,12 +1100,12 @@ describe('DragDrop', () => {
expect(
component.find('[data-test-subj="testDragDrop-translatableDrop"]').at(0).prop('style')
).toEqual({
transform: 'translateY(-8px)',
transform: 'translateY(-4px)',
});
expect(
component.find('[data-test-subj="testDragDrop-translatableDrop"]').at(1).prop('style')
).toEqual({
transform: 'translateY(-8px)',
transform: 'translateY(-4px)',
});
component
@ -1258,12 +1258,12 @@ describe('DragDrop', () => {
expect(
component.find('[data-test-subj="testDragDrop-reorderableDrag"]').at(0).prop('style')
).toEqual({
transform: 'translateY(+8px)',
transform: 'translateY(+4px)',
});
expect(
component.find('[data-test-subj="testDragDrop-translatableDrop"]').at(0).prop('style')
).toEqual({
transform: 'translateY(-40px)',
transform: 'translateY(-32px)',
});
expect(
component.find('[data-test-subj="testDragDrop-translatableDrop"]').at(1).prop('style')

View file

@ -24,6 +24,7 @@ import {
Ghost,
} from './providers';
import { DropType } from './types';
import { REORDER_ITEM_MARGIN } from './constants';
import './sass/drag_drop.scss';
/**
@ -71,11 +72,15 @@ interface BaseProps {
* The React element which will be passed the draggable handlers
*/
children: ReactElement;
/**
* Disable any drag & drop behaviour
*/
isDisabled?: boolean;
/**
* Indicates whether or not this component is draggable.
*/
draggable?: boolean;
/**
* Additional class names to apply when another element is over the drop target
*/
@ -152,7 +157,7 @@ interface DropsInnerProps extends BaseProps {
isNotDroppable: boolean;
}
const lnsLayerPanelDimensionMargin = 8;
const REORDER_OFFSET = REORDER_ITEM_MARGIN / 2;
/**
* DragDrop component
@ -174,6 +179,10 @@ export const DragDrop = (props: BaseProps) => {
onTrackUICounterEvent,
} = useContext(DragContext);
if (props.isDisabled) {
return props.children;
}
const { value, draggable, dropTypes, reorderableGroup } = props;
const isDragging = !!(draggable && value.id === dragging?.id);
@ -358,7 +367,7 @@ const DragInner = memo(function DragInner({
ghost: keyboardModeOn
? {
children,
style: { width: currentTarget.offsetWidth, height: currentTarget.offsetHeight },
style: { width: currentTarget.offsetWidth, minHeight: currentTarget?.offsetHeight },
}
: undefined,
});
@ -417,12 +426,14 @@ const DragInner = memo(function DragInner({
keyboardMode &&
activeDropTarget &&
activeDropTarget.dropType !== 'reorder';
return (
<div
className={classNames(className, {
'domDragDrop-isHidden':
(activeDraggingProps && dragType === 'move' && !keyboardMode) ||
shouldShowGhostImageInstead,
'domDragDrop--isDragStarted': activeDraggingProps,
})}
data-test-subj={`${dataTestSubjPrefix}_draggable-${value.humanData.label}`}
>
@ -772,7 +783,7 @@ const ReorderableDrag = memo(function ReorderableDrag(
| KeyboardEvent<HTMLButtonElement>['currentTarget']
) => {
if (currentTarget) {
const height = currentTarget.offsetHeight + lnsLayerPanelDimensionMargin;
const height = currentTarget.offsetHeight + REORDER_OFFSET;
setReorderState((s: ReorderState) => ({
...s,
draggingHeight: height,
@ -875,7 +886,7 @@ const ReorderableDrag = memo(function ReorderableDrag(
areItemsReordered
? {
transform: `translateY(${direction === '+' ? '-' : '+'}${reorderedItems.reduce(
(acc, cur) => acc + Number(cur.height || 0) + lnsLayerPanelDimensionMargin,
(acc, cur) => acc + Number(cur.height || 0) + REORDER_OFFSET,
0
)}px)`,
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import classnames from 'classnames';
/**
* DropOverlayWrapper Props
*/
export interface DropOverlayWrapperProps {
isVisible: boolean;
className?: string;
overlayProps?: object;
}
/**
* This prevents the in-place droppable styles (under children) and allows to rather show an overlay with droppable styles (on top of children)
* @param isVisible
* @param children
* @param overlayProps
* @param className
* @param otherProps
* @constructor
*/
export const DropOverlayWrapper: React.FC<DropOverlayWrapperProps> = ({
isVisible,
children,
overlayProps,
className,
...otherProps
}) => {
return (
<div
className={classnames('domDragDrop__dropOverlayWrapper', className)}
{...(otherProps || {})}
>
{children}
{isVisible && (
<div
className="domDragDrop__dropOverlay"
data-test-subj="domDragDrop__dropOverlay"
{...(overlayProps || {})}
/>
)}
</div>
);
};

View file

@ -9,3 +9,4 @@
export * from './types';
export * from './providers';
export * from './drag_drop';
export { DropOverlayWrapper, type DropOverlayWrapperProps } from './drop_overlay_wrapper';

View file

@ -8,7 +8,7 @@
import React, { useState, useMemo } from 'react';
import classNames from 'classnames';
import { DEFAULT_DATA_TEST_SUBJ } from '../constants';
import { DEFAULT_DATA_TEST_SUBJ, REORDER_ITEM_HEIGHT } from '../constants';
/**
* Reorder state
@ -54,7 +54,7 @@ export const ReorderContext = React.createContext<ReorderContextState>({
reorderState: {
reorderedItems: [],
direction: '-',
draggingHeight: 40,
draggingHeight: REORDER_ITEM_HEIGHT,
isReorderOn: false,
groupId: '',
},
@ -66,6 +66,7 @@ export const ReorderContext = React.createContext<ReorderContextState>({
* @param id
* @param children
* @param className
* @param draggingHeight
* @param dataTestSubj
* @constructor
*/
@ -73,17 +74,19 @@ export function ReorderProvider({
id,
children,
className,
draggingHeight = REORDER_ITEM_HEIGHT,
dataTestSubj = DEFAULT_DATA_TEST_SUBJ,
}: {
id: string;
children: React.ReactNode;
className?: string;
draggingHeight?: number;
dataTestSubj?: string;
}) {
const [state, setState] = useState<ReorderContextState['reorderState']>({
reorderedItems: [],
direction: '-',
draggingHeight: 40,
draggingHeight,
isReorderOn: false,
groupId: id,
});

View file

@ -1,7 +1,6 @@
@import './drag_drop_mixins';
.domDragDrop {
user-select: none;
transition: $euiAnimSpeedFast ease-in-out;
transition-property: background-color, border-color, opacity;
z-index: $domDragDropZLevel1;
@ -12,11 +11,11 @@
border: $euiBorderWidthThin dashed $euiBorderColor;
position: absolute !important; // sass-lint:disable-line no-important
margin: 0 !important; // sass-lint:disable-line no-important
bottom: 100%;
top: 0;
width: 100%;
left: 0;
opacity: .9;
transform: translate(-12px, 8px);
transform: translate($euiSizeXS, $euiSizeXS);
z-index: $domDragDropZLevel3;
pointer-events: none;
outline: $euiFocusRingSize solid currentColor; // Safari & Firefox
@ -29,7 +28,8 @@
@include mixinDomDragDropHover;
// Include a possible nested button like when using FieldButton
> .kbnFieldButton__button {
& .kbnFieldButton__button,
& .euiLink {
cursor: grab;
}
@ -39,14 +39,17 @@
}
// Drop area
.domDragDrop-isDroppable {
.domDragDrop-isDroppable:not(.domDragDrop__dropOverlayWrapper) {
@include mixinDomDroppable;
}
// Drop area when there's an item being dragged
.domDragDrop-isDropTarget {
@include mixinDomDroppable;
@include mixinDomDroppableActive;
&:not(.domDragDrop__dropOverlayWrapper) {
@include mixinDomDroppable;
@include mixinDomDroppableActive;
}
> * {
pointer-events: none;
}
@ -57,7 +60,7 @@
}
// Drop area while hovering with item
.domDragDrop-isActiveDropTarget {
.domDragDrop-isActiveDropTarget:not(.domDragDrop__dropOverlayWrapper) {
z-index: $domDragDropZLevel3;
@include mixinDomDroppableActiveHover;
}
@ -72,9 +75,9 @@
text-decoration: line-through;
}
.domDragDrop-notCompatible {
.domDragDrop-notCompatible:not(.domDragDrop__dropOverlayWrapper) {
background-color: $euiColorHighlight !important;
border: $euiBorderWidthThin dashed $euiBorderColor !important;
border: $euiBorderWidthThin dashed $euiColorVis5 !important;
&.domDragDrop-isActiveDropTarget {
background-color: rgba(251, 208, 17, .25) !important;
border-color: $euiColorVis5 !important;
@ -91,12 +94,12 @@
}
}
$lnsLayerPanelDimensionMargin: 8px;
$reorderItemMargin: $euiSizeS;
.domDragDrop__reorderableDrop {
position: absolute;
width: 100%;
top: 0;
height: calc(100% + #{$lnsLayerPanelDimensionMargin});
height: calc(100% + #{$reorderItemMargin / 2});
}
.domDragDrop-translatableDrop {
@ -146,6 +149,10 @@ $lnsLayerPanelDimensionMargin: 8px;
}
}
.domDragDrop--isDragStarted {
opacity: .5;
}
.domDragDrop__extraDrops {
opacity: 0;
visibility: hidden;
@ -193,7 +200,7 @@ $lnsLayerPanelDimensionMargin: 8px;
.domDragDrop__extraDrop {
position: relative;
height: $euiSizeXS * 10;
height: $euiSizeXS * 8;
min-width: $euiSize * 7;
color: $euiColorSuccessText;
padding: $euiSizeXS;
@ -201,3 +208,28 @@ $lnsLayerPanelDimensionMargin: 8px;
color: $euiColorWarningText;
}
}
.domDragDrop__dropOverlayWrapper {
position: relative;
height: 100%;
}
.domDragDrop__dropOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: $domDragDropZLevel3;
transition: $euiAnimSpeedFast ease-in-out;
transition-property: background-color, border-color, opacity;
.domDragDrop-isDropTarget & {
@include mixinDomDroppable($euiBorderWidthThick);
@include mixinDomDroppableActive($euiBorderWidthThick);
}
.domDragDrop-isActiveDropTarget & {
@include mixinDomDroppableActiveHover($euiBorderWidthThick);
}
}

View file

@ -13,31 +13,32 @@ $domDragDropZLevel3: 3;
@mixin mixinDomDraggable {
@include euiSlightShadow;
background: $euiColorEmptyShade;
border: $euiBorderWidthThin dashed transparent;
cursor: grab;
}
// Static styles for a drop area
@mixin mixinDomDroppable {
border: $euiBorderWidthThin dashed $euiBorderColor !important;
@mixin mixinDomDroppable($borderWidth: $euiBorderWidthThin) {
border: $borderWidth dashed transparent;
}
// Hovering state for drag item and drop area
@mixin mixinDomDragDropHover {
&:hover {
border: $euiBorderWidthThin dashed $euiColorMediumShade !important;
transform: translateX($euiSizeXS);
transition: transform $euiAnimSpeedSlow ease-out;
}
}
// Style for drop area when there's an item being dragged
@mixin mixinDomDroppableActive {
@mixin mixinDomDroppableActive($borderWidth: $euiBorderWidthThin) {
background-color: transparentize($euiColorVis0, .9) !important;
border: $borderWidth dashed $euiColorVis0 !important;
}
// Style for drop area while hovering with item
@mixin mixinDomDroppableActiveHover {
@mixin mixinDomDroppableActiveHover($borderWidth: $euiBorderWidthThin) {
background-color: transparentize($euiColorVis0, .75) !important;
border: $euiBorderWidthThin dashed $euiColorVis0 !important;
border: $borderWidth dashed $euiColorVis0 !important;
}
// Style for drop area that is not allowed for current item

View file

@ -2,7 +2,7 @@
exports[`fieldAction is rendered 1`] = `
<div
className="kbnFieldButton"
className="kbnFieldButton kbnFieldButton--s"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
@ -11,7 +11,11 @@ exports[`fieldAction is rendered 1`] = `
<span
className="kbnFieldButton__name"
>
name
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
<div
@ -26,7 +30,7 @@ exports[`fieldAction is rendered 1`] = `
exports[`fieldIcon is rendered 1`] = `
<div
className="kbnFieldButton"
className="kbnFieldButton kbnFieldButton--s"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
@ -42,7 +46,11 @@ exports[`fieldIcon is rendered 1`] = `
<span
className="kbnFieldButton__name"
>
name
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
</div>
@ -50,7 +58,7 @@ exports[`fieldIcon is rendered 1`] = `
exports[`isActive defaults to false 1`] = `
<div
className="kbnFieldButton"
className="kbnFieldButton kbnFieldButton--s"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
@ -59,7 +67,11 @@ exports[`isActive defaults to false 1`] = `
<span
className="kbnFieldButton__name"
>
name
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
</div>
@ -67,7 +79,7 @@ exports[`isActive defaults to false 1`] = `
exports[`isActive renders true 1`] = `
<div
className="kbnFieldButton kbnFieldButton-isActive"
className="kbnFieldButton kbnFieldButton--s kbnFieldButton-isActive"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
@ -76,41 +88,11 @@ exports[`isActive renders true 1`] = `
<span
className="kbnFieldButton__name"
>
name
</span>
</button>
</div>
`;
exports[`isDraggable is rendered 1`] = `
<div
className="kbnFieldButton kbnFieldButton--isDraggable"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
onClick={[Function]}
>
<span
className="kbnFieldButton__name"
>
name
</span>
</button>
</div>
`;
exports[`sizes m is applied 1`] = `
<div
className="kbnFieldButton"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
onClick={[Function]}
>
<span
className="kbnFieldButton__name"
>
name
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
</div>
@ -118,7 +100,7 @@ exports[`sizes m is applied 1`] = `
exports[`sizes s is applied 1`] = `
<div
className="kbnFieldButton kbnFieldButton--small"
className="kbnFieldButton kbnFieldButton--s"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
@ -127,7 +109,60 @@ exports[`sizes s is applied 1`] = `
<span
className="kbnFieldButton__name"
>
name
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
</div>
`;
exports[`sizes xs is applied 1`] = `
<div
className="kbnFieldButton kbnFieldButton--xs"
>
<button
className="kbn-resetFocusState kbnFieldButton__button"
onClick={[Function]}
>
<span
className="kbnFieldButton__name"
>
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
</div>
`;
exports[`with drag handle is rendered 1`] = `
<div
className="kbnFieldButton kbnFieldButton--s"
>
<div
className="kbnFieldButton__dragHandle"
>
<span>
drag
</span>
</div>
<button
className="kbn-resetFocusState kbnFieldButton__button"
onClick={[Function]}
>
<span
className="kbnFieldButton__name"
>
<span
className="kbnFieldButton__nameInner"
>
name
</span>
</span>
</button>
</div>

View file

@ -2,8 +2,9 @@
@include euiFontSizeS;
border-radius: $euiBorderRadius;
margin-bottom: $euiSizeXS;
padding: 0 $euiSizeS;
display: flex;
align-items: center;
align-items: flex-start;
transition: box-shadow $euiAnimSpeedFast $euiAnimSlightResistance,
background-color $euiAnimSpeedFast $euiAnimSlightResistance; // sass-lint:disable-line indentation
@ -13,29 +14,10 @@
}
}
.kbnFieldButton--isDraggable {
background: lightOrDarkTheme($euiColorEmptyShade, $euiColorLightestShade);
&:hover,
&:focus,
&:focus-within {
@include euiBottomShadowMedium;
border-radius: $euiBorderRadius;
z-index: 2;
}
.kbnFieldButton__button {
&:hover,
&:focus {
cursor: grab;
}
}
}
.kbnFieldButton__button {
flex-grow: 1;
text-align: left;
padding: $euiSizeS;
padding: $euiSizeS 0;
display: flex;
align-items: flex-start;
line-height: normal;
@ -44,33 +26,55 @@
.kbnFieldButton__fieldIcon {
flex-shrink: 0;
line-height: 0;
margin-right: $euiSizeS;
}
.kbnFieldButton__name {
flex-grow: 1;
word-break: break-word;
padding: 0 $euiSizeS;
}
.kbnFieldButton__infoIcon {
display: flex;
align-items: center;
justify-content: center;
min-height: $euiSize;
flex-shrink: 0;
margin-left: $euiSizeXS;
line-height: 0;
}
.kbnFieldButton__fieldAction {
margin-left: $euiSizeS;
}
.kbnFieldButton__dragHandle {
margin-right: $euiSizeS;
}
.kbnFieldButton__fieldAction,
.kbnFieldButton__dragHandle {
line-height: $euiSizeXL;
}
// Reduce text size and spacing for the small size
.kbnFieldButton--small {
.kbnFieldButton--xs {
font-size: $euiFontSizeXS;
.kbnFieldButton__button {
padding: $euiSizeXS;
padding: $euiSizeXS 0;
}
.kbnFieldButton__fieldIcon,
.kbnFieldButton__fieldAction {
margin-right: $euiSizeXS;
margin-left: $euiSizeXS;
}
.kbnFieldButton__fieldAction,
.kbnFieldButton__dragHandle {
line-height: $euiSizeL;
}
}
.kbnFieldButton--flushBoth {
padding-left: 0;
padding-right: 0;
}

View file

@ -21,9 +21,11 @@ describe('sizes', () => {
});
});
describe('isDraggable', () => {
describe('with drag handle', () => {
it('is rendered', () => {
const component = shallow(<FieldButton onClick={noop} fieldName="name" isDraggable />);
const component = shallow(
<FieldButton size="s" onClick={noop} fieldName="name" dragHandle={<span>drag</span>} />
);
expect(component).toMatchSnapshot();
});
});
@ -31,7 +33,7 @@ describe('isDraggable', () => {
describe('fieldIcon', () => {
it('is rendered', () => {
const component = shallow(
<FieldButton onClick={noop} fieldName="name" fieldIcon={<span>fieldIcon</span>} />
<FieldButton size="s" onClick={noop} fieldName="name" fieldIcon={<span>fieldIcon</span>} />
);
expect(component).toMatchSnapshot();
});
@ -40,7 +42,12 @@ describe('fieldIcon', () => {
describe('fieldAction', () => {
it('is rendered', () => {
const component = shallow(
<FieldButton onClick={noop} fieldName="name" fieldAction={<span>fieldAction</span>} />
<FieldButton
size="s"
onClick={noop}
fieldName="name"
fieldAction={<span>fieldAction</span>}
/>
);
expect(component).toMatchSnapshot();
});
@ -48,11 +55,11 @@ describe('fieldAction', () => {
describe('isActive', () => {
it('defaults to false', () => {
const component = shallow(<FieldButton onClick={noop} fieldName="name" />);
const component = shallow(<FieldButton size="s" onClick={noop} fieldName="name" />);
expect(component).toMatchSnapshot();
});
it('renders true', () => {
const component = shallow(<FieldButton onClick={noop} fieldName="name" isActive />);
const component = shallow(<FieldButton size="s" onClick={noop} fieldName="name" isActive />);
expect(component).toMatchSnapshot();
});
});

View file

@ -6,10 +6,10 @@
* Side Public License, v 1.
*/
import './field_button.scss';
import classNames from 'classnames';
import React, { ReactNode, HTMLAttributes, ButtonHTMLAttributes } from 'react';
import { CommonProps } from '@elastic/eui';
import './field_button.scss';
export interface FieldButtonProps extends HTMLAttributes<HTMLDivElement> {
/**
@ -34,13 +34,20 @@ export interface FieldButtonProps extends HTMLAttributes<HTMLDivElement> {
*/
isActive?: boolean;
/**
* Styles the component differently to indicate it is draggable
* Custom drag handle element
*/
isDraggable?: boolean;
dragHandle?: React.ReactElement;
/**
* Use the small size in condensed areas
* Use the xs size in condensed areas
*/
size: ButtonSize;
/**
* Whether to skip side paddings
*/
flush?: 'both';
/**
* Custom class name
*/
size?: ButtonSize;
className?: string;
/**
* The component will render a `<button>` when provided an `onClick`
@ -57,8 +64,8 @@ export interface FieldButtonProps extends HTMLAttributes<HTMLDivElement> {
}
const sizeToClassNameMap = {
s: 'kbnFieldButton--small',
m: null,
xs: 'kbnFieldButton--xs',
s: 'kbnFieldButton--s',
} as const;
export type ButtonSize = keyof typeof sizeToClassNameMap;
@ -66,14 +73,15 @@ export type ButtonSize = keyof typeof sizeToClassNameMap;
export const SIZES = Object.keys(sizeToClassNameMap) as ButtonSize[];
export function FieldButton({
size = 'm',
size,
isActive = false,
fieldIcon,
fieldName,
fieldInfoIcon,
fieldAction,
flush,
className,
isDraggable = false,
dragHandle,
onClick,
dataTestSubj,
buttonProps,
@ -82,8 +90,10 @@ export function FieldButton({
const classes = classNames(
'kbnFieldButton',
size ? sizeToClassNameMap[size] : null,
{ 'kbnFieldButton-isActive': isActive },
{ 'kbnFieldButton--isDraggable': isDraggable },
{
'kbnFieldButton-isActive': isActive,
'kbnFieldButton--flushBoth': flush === 'both',
},
className
);
@ -92,13 +102,18 @@ export function FieldButton({
const innerContent = (
<>
{fieldIcon && <span className="kbnFieldButton__fieldIcon">{fieldIcon}</span>}
{fieldName && <span className="kbnFieldButton__name">{fieldName}</span>}
{fieldName && (
<span className="kbnFieldButton__name">
<span className="kbnFieldButton__nameInner">{fieldName}</span>
</span>
)}
{fieldInfoIcon && <div className="kbnFieldButton__infoIcon">{fieldInfoIcon}</div>}
</>
);
return (
<div className={classes} {...rest}>
{dragHandle && <div className="kbnFieldButton__dragHandle">{dragHandle}</div>}
{onClick ? (
<button
onClick={(e) => {

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import './discover_layout.scss';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
import classNames from 'classnames';
import { generateFilters } from '@kbn/data-plugin/public';
import { DragContext } from '@kbn/dom-drag-drop';
import { DataView, DataViewField, DataViewType } from '@kbn/data-views-plugin/public';
import { VIEW_MODE } from '../../../../../common/constants';
import { useInternalStateSelector } from '../../services/discover_internal_state_container';
@ -207,6 +208,17 @@ export function DiscoverLayout({
const resizeRef = useRef<HTMLDivElement>(null);
const dragDropContext = useContext(DragContext);
const draggingFieldName = dragDropContext.dragging?.id;
const onDropFieldToTable = useMemo(() => {
if (!draggingFieldName || currentColumns.includes(draggingFieldName)) {
return undefined;
}
return () => onAddColumn(draggingFieldName);
}, [onAddColumn, draggingFieldName, currentColumns]);
const mainDisplay = useMemo(() => {
if (resultState === 'none') {
const globalQueryState = data.query.getState();
@ -245,6 +257,7 @@ export function DiscoverLayout({
onFieldEdited={onFieldEdited}
resizeRef={resizeRef}
inspectorAdapters={inspectorAdapters}
onDropFieldToTable={onDropFieldToTable}
/>
{resultState === 'loading' && <LoadingSpinner />}
</>
@ -265,7 +278,9 @@ export function DiscoverLayout({
savedSearch,
stateContainer,
viewMode,
onDropFieldToTable,
]);
return (
<EuiPage className="dscPage" data-fetch-counter={fetchCounter.current}>
<h1

View file

@ -8,6 +8,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { DragDrop, type DropType, DropOverlayWrapper } from '@kbn/dom-drag-drop';
import React, { useCallback } from 'react';
import { DataView } from '@kbn/data-views-plugin/common';
import { METRIC_TYPE } from '@kbn/analytics';
@ -23,6 +24,19 @@ import { DOCUMENTS_VIEW_CLICK, FIELD_STATISTICS_VIEW_CLICK } from '../field_stat
import { ErrorCallout } from '../../../../components/common/error_callout';
import { useDataState } from '../../hooks/use_data_state';
const DROP_PROPS = {
value: {
id: 'dscDropZoneTable',
humanData: {
label: i18n.translate('discover.dropZoneTableLabel', {
defaultMessage: 'Drop zone to add field as a column to the table',
}),
},
},
order: [1, 0, 0, 0],
types: ['field_add'] as DropType[],
};
export interface DiscoverMainContentProps {
dataView: DataView;
savedSearch: SavedSearch;
@ -32,6 +46,7 @@ export interface DiscoverMainContentProps {
viewMode: VIEW_MODE;
onAddFilter: DocViewFilterFn | undefined;
onFieldEdited: () => Promise<void>;
onDropFieldToTable?: () => void;
columns: string[];
}
@ -45,6 +60,7 @@ export const DiscoverMainContent = ({
columns,
stateContainer,
savedSearch,
onDropFieldToTable,
}: DiscoverMainContentProps) => {
const { trackUiMetric } = useDiscoverServices();
@ -64,49 +80,65 @@ export const DiscoverMainContent = ({
);
const dataState = useDataState(stateContainer.dataState.data$.main$);
const isDropAllowed = Boolean(onDropFieldToTable);
return (
<EuiFlexGroup
className="eui-fullHeight"
direction="column"
gutterSize="none"
responsive={false}
<DragDrop
draggable={false}
dropTypes={isDropAllowed ? DROP_PROPS.types : undefined}
value={DROP_PROPS.value}
order={DROP_PROPS.order}
onDrop={onDropFieldToTable}
>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
{!isPlainRecord && (
<DocumentViewModeToggle viewMode={viewMode} setDiscoverViewMode={setDiscoverViewMode} />
)}
</EuiFlexItem>
{dataState.error && (
<ErrorCallout
title={i18n.translate('discover.documentsErrorTitle', {
defaultMessage: 'Search error',
})}
error={dataState.error}
inline
data-test-subj="discoverMainError"
/>
)}
{viewMode === VIEW_MODE.DOCUMENT_LEVEL ? (
<DiscoverDocuments
dataView={dataView}
navigateTo={navigateTo}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
savedSearch={savedSearch}
stateContainer={stateContainer}
onFieldEdited={!isPlainRecord ? onFieldEdited : undefined}
/>
) : (
<FieldStatisticsTab
savedSearch={savedSearch}
dataView={dataView}
columns={columns}
stateContainer={stateContainer}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
trackUiMetric={trackUiMetric}
/>
)}
</EuiFlexGroup>
<DropOverlayWrapper isVisible={isDropAllowed}>
<EuiFlexGroup
className="eui-fullHeight"
direction="column"
gutterSize="none"
responsive={false}
data-test-subj="dscMainContent"
>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="none" />
{!isPlainRecord && (
<DocumentViewModeToggle
viewMode={viewMode}
setDiscoverViewMode={setDiscoverViewMode}
/>
)}
</EuiFlexItem>
{dataState.error && (
<ErrorCallout
title={i18n.translate('discover.documentsErrorTitle', {
defaultMessage: 'Search error',
})}
error={dataState.error}
inline
data-test-subj="discoverMainError"
/>
)}
{viewMode === VIEW_MODE.DOCUMENT_LEVEL ? (
<DiscoverDocuments
dataView={dataView}
navigateTo={navigateTo}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
savedSearch={savedSearch}
stateContainer={stateContainer}
onFieldEdited={!isPlainRecord ? onFieldEdited : undefined}
/>
) : (
<FieldStatisticsTab
savedSearch={savedSearch}
dataView={dataView}
columns={columns}
stateContainer={stateContainer}
onAddFilter={!isPlainRecord ? onAddFilter : undefined}
trackUiMetric={trackUiMetric}
/>
)}
</EuiFlexGroup>
</DropOverlayWrapper>
</DragDrop>
);
};

View file

@ -7,7 +7,7 @@
*/
import { act } from 'react-dom/test-utils';
import { EuiPopover, EuiProgress, EuiButtonIcon } from '@elastic/eui';
import { EuiButtonIcon, EuiPopover, EuiProgress } from '@elastic/eui';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { findTestSubject } from '@elastic/eui/lib/test';
@ -23,6 +23,7 @@ import { DataDocuments$ } from '../../services/discover_data_state_container';
import { getDataTableRecords } from '../../../../__fixtures__/real_hits';
import * as DetailsUtil from './deprecated_stats/get_details';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FieldItemButton } from '@kbn/unified-field-list-plugin/public';
jest.spyOn(DetailsUtil, 'getDetails');
@ -97,7 +98,10 @@ async function getComponent({
onEditField: jest.fn(),
onRemoveField: jest.fn(),
showFieldStats,
selected,
isSelected: selected,
isEmpty: false,
groupIndex: 1,
itemIndex: 0,
contextualFields: [],
};
const services = {
@ -206,7 +210,7 @@ describe('discover sidebar field', function () {
findTestSubject(comp, 'field-bytes-showDetails').simulate('click');
expect(DetailsUtil.getDetails).toHaveBeenCalledTimes(1);
});
it('should not return the popover if onAddFilter is not provided', async function () {
it('should not enable the popover if onAddFilter is not provided', async function () {
const field = new DataViewField({
name: '_source',
type: '_source',
@ -220,8 +224,8 @@ describe('discover sidebar field', function () {
field,
onAddFilterExists: false,
});
const popover = findTestSubject(comp, 'discoverFieldListPanelPopover');
expect(popover.length).toBe(0);
expect(comp.find(FieldItemButton).prop('onClick')).toBeUndefined();
});
it('should request field stats', async function () {
const field = new DataViewField({

View file

@ -8,162 +8,69 @@
import './discover_field.scss';
import React, { useState, useCallback, memo, useMemo } from 'react';
import {
EuiButtonIcon,
EuiToolTip,
EuiTitle,
EuiIcon,
EuiSpacer,
EuiHighlight,
} from '@elastic/eui';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
import classNames from 'classnames';
import { FieldButton } from '@kbn/react-field';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import {
FieldIcon,
FieldItemButton,
type FieldItemButtonProps,
FieldPopover,
FieldPopoverHeader,
FieldPopoverHeaderProps,
FieldPopoverVisualize,
getFieldIconProps,
} from '@kbn/unified-field-list-plugin/public';
import { DragDrop } from '@kbn/dom-drag-drop';
import { DiscoverFieldStats } from './discover_field_stats';
import { DiscoverFieldDetails } from './deprecated_stats/discover_field_details';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { SHOW_LEGACY_FIELD_TOP_VALUES, PLUGIN_ID } from '../../../../../common';
import { PLUGIN_ID, SHOW_LEGACY_FIELD_TOP_VALUES } from '../../../../../common';
import { getUiActions } from '../../../../kibana_services';
import { type DataDocuments$ } from '../../services/discover_data_state_container';
const FieldInfoIcon: React.FC = memo(() => (
<EuiToolTip
position="bottom"
content={i18n.translate('discover.field.mappingConflict', {
defaultMessage:
'This field is defined as several types (string, integer, etc) across the indices that match this pattern.' +
'You may still be able to use this conflicting field, but it will be unavailable for functions that require Kibana to know their type. Correcting this issue will require reindexing your data.',
})}
>
<EuiIcon
tabIndex={0}
type="warning"
title={i18n.translate('discover.field.mappingConflict.title', {
defaultMessage: 'Mapping Conflict',
})}
size="s"
/>
</EuiToolTip>
));
const DiscoverFieldTypeIcon: React.FC<{ field: DataViewField }> = memo(({ field }) => {
return <FieldIcon {...getFieldIconProps(field)} />;
});
const FieldName: React.FC<{ field: DataViewField; highlight?: string }> = memo(
({ field, highlight }) => {
const title =
field.displayName !== field.name
? i18n.translate('discover.field.title', {
defaultMessage: '{fieldName} ({fieldDisplayName})',
values: {
fieldName: field.name,
fieldDisplayName: field.displayName,
},
})
: field.displayName;
return (
<EuiHighlight
search={highlight || ''}
data-test-subj={`field-${field.name}`}
title={title}
className="dscSidebarField__name"
>
{field.displayName}
</EuiHighlight>
);
}
);
interface ActionButtonProps {
interface GetCommonFieldItemButtonPropsParams {
field: DataViewField;
isSelected?: boolean;
alwaysShow: boolean;
isSelected: boolean;
toggleDisplay: (field: DataViewField, isSelected?: boolean) => void;
}
const ActionButton: React.FC<ActionButtonProps> = memo(
({ field, isSelected, alwaysShow, toggleDisplay }) => {
const actionBtnClassName = classNames('dscSidebarItem__action', {
['dscSidebarItem__mobile']: alwaysShow,
});
if (field.name === '_source') {
return null;
}
if (!isSelected) {
return (
<EuiToolTip
delay="long"
content={i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
defaultMessage: 'Add field as column',
})}
>
<EuiButtonIcon
iconType="plusInCircleFilled"
className={actionBtnClassName}
onClick={(ev: React.MouseEvent<HTMLButtonElement>) => {
if (ev.type === 'click') {
ev.currentTarget.focus();
}
ev.preventDefault();
ev.stopPropagation();
toggleDisplay(field, isSelected);
}}
data-test-subj={`fieldToggle-${field.name}`}
aria-label={i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', {
defaultMessage: 'Add {field} to table',
values: { field: field.name },
})}
/>
</EuiToolTip>
);
} else {
return (
<EuiToolTip
key={`tooltip-${field.name}-${field.count || 0}-${isSelected}`}
delay="long"
content={i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
defaultMessage: 'Remove field from table',
})}
>
<EuiButtonIcon
color="danger"
iconType="cross"
className={actionBtnClassName}
onClick={(ev: React.MouseEvent<HTMLButtonElement>) => {
if (ev.type === 'click') {
ev.currentTarget.focus();
}
ev.preventDefault();
ev.stopPropagation();
toggleDisplay(field, isSelected);
}}
data-test-subj={`fieldToggle-${field.name}`}
aria-label={i18n.translate(
'discover.fieldChooser.discoverField.removeButtonAriaLabel',
{
defaultMessage: 'Remove {field} from table',
values: { field: field.name },
}
)}
/>
</EuiToolTip>
);
}
}
);
function getCommonFieldItemButtonProps({
field,
isSelected,
toggleDisplay,
}: GetCommonFieldItemButtonPropsParams): {
field: FieldItemButtonProps<DataViewField>['field'];
isSelected: FieldItemButtonProps<DataViewField>['isSelected'];
dataTestSubj: FieldItemButtonProps<DataViewField>['dataTestSubj'];
buttonAddFieldToWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps'];
buttonRemoveFieldFromWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps'];
onAddFieldToWorkspace: FieldItemButtonProps<DataViewField>['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: FieldItemButtonProps<DataViewField>['onRemoveFieldFromWorkspace'];
} {
const dataTestSubj = `fieldToggle-${field.name}`;
const handler =
field.name === '_source' ? undefined : (f: DataViewField) => toggleDisplay(f, isSelected);
return {
field,
isSelected,
dataTestSubj: `field-${field.name}-showDetails`,
buttonAddFieldToWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
defaultMessage: 'Add field as column',
}),
'data-test-subj': dataTestSubj,
},
buttonRemoveFieldFromWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
defaultMessage: 'Remove field from table',
}),
'data-test-subj': dataTestSubj,
},
onAddFieldToWorkspace: handler,
onRemoveFieldFromWorkspace: handler,
};
}
interface MultiFieldsProps {
multiFields: NonNullable<DiscoverFieldProps['multiFields']>;
@ -183,22 +90,20 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
</EuiTitle>
<EuiSpacer size="xs" />
{multiFields.map((entry) => (
<FieldButton
size="s"
className="dscSidebarItem dscSidebarItem--multi"
isActive={false}
dataTestSubj={`field-${entry.field.name}-showDetails`}
fieldIcon={<DiscoverFieldTypeIcon field={entry.field} />}
fieldAction={
<ActionButton
field={entry.field}
isSelected={entry.isSelected}
alwaysShow={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
/>
}
fieldName={<FieldName field={entry.field} />}
<FieldItemButton
key={entry.field.name}
size="xs"
className="dscSidebarItem dscSidebarItem--multi"
flush="both"
isEmpty={false}
isActive={false}
shouldAlwaysShowAction={alwaysShowActionButton}
onClick={undefined}
{...getCommonFieldItemButtonProps({
field: entry.field,
isSelected: entry.isSelected,
toggleDisplay,
})}
/>
))}
</React.Fragment>
@ -235,10 +140,14 @@ export interface DiscoverFieldProps {
* @param fieldName
*/
onRemoveField: (fieldName: string) => void;
/**
* Determines whether the field is empty
*/
isEmpty: boolean;
/**
* Determines whether the field is selected
*/
selected?: boolean;
isSelected: boolean;
/**
* Metric tracking function
* @param metricType
@ -273,6 +182,16 @@ export interface DiscoverFieldProps {
* Search by field name
*/
highlight?: string;
/**
* Group index in the field list
*/
groupIndex: number;
/**
* Item index in the field list
*/
itemIndex: number;
}
function DiscoverFieldComponent({
@ -284,13 +203,16 @@ function DiscoverFieldComponent({
onAddField,
onRemoveField,
onAddFilter,
selected,
isEmpty,
isSelected,
trackUiMetric,
multiFields,
onEditField,
onDeleteField,
showFieldStats,
contextualFields,
groupIndex,
itemIndex,
}: DiscoverFieldProps) {
const services = useDiscoverServices();
const [infoIsOpen, setOpen] = useState(false);
@ -315,7 +237,7 @@ function DiscoverFieldComponent({
setOpen(false);
}, [setOpen]);
const toggleDisplay: ActionButtonProps['toggleDisplay'] = useCallback(
const toggleDisplay: GetCommonFieldItemButtonPropsParams['toggleDisplay'] = useCallback(
(f, isCurrentlySelected) => {
closePopover();
if (isCurrentlySelected) {
@ -349,51 +271,6 @@ function DiscoverFieldComponent({
[field.name]
);
if (field.type === '_source') {
return (
<FieldButton
size="s"
className="dscSidebarItem"
dataTestSubj={`field-${field.name}-showDetails`}
fieldIcon={<DiscoverFieldTypeIcon field={field} />}
fieldAction={
<ActionButton
field={field}
isSelected={selected}
alwaysShow={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
/>
}
fieldName={<FieldName field={field} />}
/>
);
}
const button = (
<FieldButton
size="s"
className="dscSidebarItem"
isActive={infoIsOpen}
onClick={isDocumentRecord ? togglePopover : undefined}
dataTestSubj={`field-${field.name}-showDetails`}
fieldIcon={<DiscoverFieldTypeIcon field={field} />}
fieldAction={
<ActionButton
field={field}
isSelected={selected}
alwaysShow={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
/>
}
fieldName={<FieldName field={field} highlight={highlight} />}
fieldInfoIcon={field.type === 'conflict' && <FieldInfoIcon />}
/>
);
if (!isDocumentRecord) {
return button;
}
const renderPopover = () => {
const showLegacyFieldStats = services.uiSettings.get(SHOW_LEGACY_FIELD_TOP_VALUES);
@ -443,24 +320,57 @@ function DiscoverFieldComponent({
);
};
const value = useMemo(
() => ({
id: field.name,
humanData: {
label: field.displayName,
position: itemIndex + 1,
},
}),
[field, itemIndex]
);
const order = useMemo(() => [0, groupIndex, itemIndex], [groupIndex, itemIndex]);
return (
<FieldPopover
isOpen={infoIsOpen}
button={button}
button={
<DragDrop
draggable
order={order}
value={value}
onDragStart={closePopover}
isDisabled={alwaysShowActionButton}
dataTestSubj={`dscFieldListPanelField-${field.name}`}
>
<FieldItemButton
size="xs"
fieldSearchHighlight={highlight}
className="dscSidebarItem"
isEmpty={isEmpty}
isActive={infoIsOpen}
flush={alwaysShowActionButton ? 'both' : undefined}
shouldAlwaysShowAction={alwaysShowActionButton}
onClick={field.type !== '_source' ? togglePopover : undefined}
{...getCommonFieldItemButtonProps({ field, isSelected, toggleDisplay })}
/>
</DragDrop>
}
closePopover={closePopover}
data-test-subj="discoverFieldListPanelPopover"
renderHeader={() => (
<FieldPopoverHeader
field={field}
closePopover={closePopover}
onAddFieldToWorkspace={!selected ? toggleDisplay : undefined}
onAddFieldToWorkspace={!isSelected ? toggleDisplay : undefined}
onAddFilter={onAddFilter}
onEditField={onEditField}
onDeleteField={onDeleteField}
{...customPopoverHeaderProps}
/>
)}
renderContent={renderPopover}
renderContent={isDocumentRecord ? renderPopover : undefined}
/>
);
}

View file

@ -38,29 +38,3 @@
.dscSidebar__flyoutHeader {
align-items: center;
}
.dscSidebarItem {
&:hover,
&:focus-within,
&[class*='-isActive'] {
.dscSidebarItem__action {
opacity: 1;
}
}
}
/**
* 1. Only visually hide the action, so that it's still accessible to screen readers.
* 2. When tabbed to, this element needs to be visible for keyboard accessibility.
*/
.dscSidebarItem__action {
opacity: 0; /* 1 */
&.dscSidebarItem__mobile {
opacity: 1;
}
&:focus {
opacity: 1; /* 2 */
}
}

View file

@ -223,7 +223,7 @@ export function DiscoverSidebarComponent({
});
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
({ field, groupName, fieldSearchHighlight }) => (
({ field, groupName, groupIndex, itemIndex, fieldSearchHighlight }) => (
<li key={`field${field.name}`} data-attr-field={field.name}>
<DiscoverField
alwaysShowActionButton={alwaysShowActionButtons}
@ -240,7 +240,10 @@ export function DiscoverSidebarComponent({
onDeleteField={deleteField}
showFieldStats={showFieldStats}
contextualFields={columns}
selected={
groupIndex={groupIndex}
itemIndex={itemIndex}
isEmpty={groupName === FieldsGroupNames.EmptyFields}
isSelected={
groupName === FieldsGroupNames.SelectedFields ||
Boolean(selectedFieldsState.selectedFieldsMap[field.name])
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback, useEffect } from 'react';
import { RootDragDropProvider } from '@kbn/dom-drag-drop';
import { useHistory } from 'react-router-dom';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { DataViewListItem } from '@kbn/data-views-plugin/public';
@ -102,19 +103,21 @@ export function DiscoverMainApp(props: DiscoverMainProps) {
return (
<DiscoverMainProvider value={stateContainer}>
<DiscoverLayoutMemoized
inspectorAdapters={inspectorAdapters}
onChangeDataView={onChangeDataView}
onUpdateQuery={onUpdateQuery}
resetSavedSearch={resetCurrentSavedSearch}
navigateTo={navigateTo}
savedSearch={savedSearch}
searchSource={searchSource}
stateContainer={stateContainer}
persistDataView={persistDataView}
updateAdHocDataViewId={updateAdHocDataViewId}
updateDataViewList={updateDataViewList}
/>
<RootDragDropProvider>
<DiscoverLayoutMemoized
inspectorAdapters={inspectorAdapters}
onChangeDataView={onChangeDataView}
onUpdateQuery={onUpdateQuery}
resetSavedSearch={resetCurrentSavedSearch}
navigateTo={navigateTo}
savedSearch={savedSearch}
searchSource={searchSource}
stateContainer={stateContainer}
persistDataView={persistDataView}
updateAdHocDataViewId={updateAdHocDataViewId}
updateDataViewList={updateDataViewList}
/>
</RootDragDropProvider>
</DiscoverMainProvider>
);
}

View file

@ -52,6 +52,7 @@
"@kbn/config-schema",
"@kbn/storybook",
"@kbn/shared-ux-router",
"@kbn/dom-drag-drop",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,278 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UnifiedFieldList <FieldItemButton /> renders properly 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview bytes: number",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--number unifiedFieldListItemButton--exists"
fieldIcon={
<WrappedFieldIcon
scripted={false}
type="number"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-bytes"
search="by"
title="bytes"
>
bytes
</EuiHighlight>
}
isActive={false}
key="field-item-button-bytes"
onClick={[MockFunction]}
size="s"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly for Records (Lens field) 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview Records: document",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--document unifiedFieldListItemButton--exists"
fieldIcon={
<WrappedFieldIcon
scripted={false}
type="document"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-___records___"
search="re"
title="Records"
>
Records
</EuiHighlight>
}
isActive={true}
key="field-item-button-___records___"
onClick={[MockFunction]}
size="s"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly for text-based column field 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview agent: string",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--string unifiedFieldListItemButton--exists"
fieldIcon={
<WrappedFieldIcon
type="string"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-agent"
search="ag"
title="agent"
>
agent
</EuiHighlight>
}
isActive={false}
key="field-item-button-agent"
size="s"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly when a conflict field 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview custom_user_field: conflict",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--conflict unifiedFieldListItemButton--exists"
fieldIcon={
<WrappedFieldIcon
scripted={false}
type="conflict"
/>
}
fieldInfoIcon={<FieldConflictInfoIcon />}
fieldName={
<EuiHighlight
data-test-subj="field-custom_user_field"
search=""
title="custom_user_field"
>
custom_user_field
</EuiHighlight>
}
isActive={true}
key="field-item-button-custom_user_field"
onClick={[MockFunction]}
size="s"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly when empty 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview script date: date",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--date unifiedFieldListItemButton--missing"
fieldIcon={
<WrappedFieldIcon
scripted={true}
type="date"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-script date"
search=""
title="script date"
>
script date
</EuiHighlight>
}
isActive={false}
key="field-item-button-script date"
onClick={[MockFunction]}
size="s"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly with a drag handle 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview bytes: number",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--number unifiedFieldListItemButton--exists custom"
dataTestSubj="test-subj"
dragHandle={
<span>
dragHandle
</span>
}
fieldIcon={
<WrappedFieldIcon
scripted={false}
type="number"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-bytes"
search=""
title="bytes"
>
bytes
</EuiHighlight>
}
isActive={false}
key="field-item-button-bytes"
size="xs"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly with an action when deselected 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview bytes: number",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--number unifiedFieldListItemButton--exists"
fieldAction={
<EuiToolTip
content="Add \\"bytes\\" field"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiButtonIcon
aria-label="Add \\"bytes\\" field"
className="unifiedFieldListItemButton__action unifiedFieldListItemButton__action--always"
color="text"
data-test-subj="unifiedFieldListItem_addField-bytes"
iconType="plusInCircle"
onClick={[Function]}
/>
</EuiToolTip>
}
fieldIcon={
<WrappedFieldIcon
scripted={false}
type="number"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-bytes"
search=""
title="bytes"
>
bytes
</EuiHighlight>
}
isActive={false}
key="field-item-button-bytes"
size="s"
/>
`;
exports[`UnifiedFieldList <FieldItemButton /> renders properly with an action when selected 1`] = `
<FieldButton
buttonProps={
Object {
"aria-label": "Preview bytes: number",
}
}
className="unifiedFieldListItemButton unifiedFieldListItemButton--number unifiedFieldListItemButton--exists"
fieldAction={
<EuiToolTip
content="Remove \\"bytes\\" field"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiButtonIcon
aria-label="Remove \\"bytes\\" field"
className="unifiedFieldListItemButton__action"
color="danger"
data-test-subj="unifiedFieldListItem_removeField-bytes"
iconType="cross"
onClick={[Function]}
/>
</EuiToolTip>
}
fieldIcon={
<WrappedFieldIcon
scripted={false}
type="number"
/>
}
fieldName={
<EuiHighlight
data-test-subj="field-bytes"
search=""
title="bytes"
>
bytes
</EuiHighlight>
}
isActive={false}
key="field-item-button-bytes"
onClick={[MockFunction click]}
size="s"
/>
`;

View file

@ -24,13 +24,25 @@
}
}
.unifiedFieldItemButton {
width: 100%;
/**
* 1. Only visually hide the action, so that it's still accessible to screen readers.
* 2. When tabbed to, this element needs to be visible for keyboard accessibility.
*/
.unifiedFieldListItemButton__action {
opacity: 0; /* 1 */
&:hover:not([class*='isActive']) {
cursor: grab;
&--always {
opacity: 1;
}
&:focus {
opacity: 1; /* 2 */
}
}
.unifiedFieldListItemButton {
width: 100%;
&.kbnFieldButton {
&:focus-within,
&-isActive {
@ -39,15 +51,22 @@
}
.kbnFieldButton__button:focus {
@include passDownFocusRing('.kbnFieldButton__name > span');
@include passDownFocusRing('.kbnFieldButton__nameInner');
}
.kbnFieldButton__name > span {
text-decoration: underline;
& button .kbnFieldButton__nameInner:hover {
text-decoration: underline;
}
&:hover,
&[class*='-isActive'] {
.unifiedFieldListItemButton__action {
opacity: 1;
}
}
}
.unifiedFieldItemButton--missing {
.unifiedFieldListItemButton--missing {
background: lightOrDarkTheme(transparentize($euiColorMediumShade, .9), $euiColorEmptyShade);
color: $euiColorDarkShade;
}

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { DataViewField } from '@kbn/data-views-plugin/common';
import { DatatableColumn } from '@kbn/expressions-plugin/common';
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { FieldItemButton } from './field_item_button';
const bytesField = dataView.getFieldByName('bytes')!;
const scriptedField = dataView.getFieldByName('script date')!;
const conflictField = dataView.getFieldByName('custom_user_field')!;
describe('UnifiedFieldList <FieldItemButton />', () => {
test('renders properly', () => {
const component = shallow(
<FieldItemButton
field={bytesField}
fieldSearchHighlight="by"
isEmpty={false}
isSelected={false}
isActive={false}
onClick={jest.fn()}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly when empty', () => {
const component = shallow(
<FieldItemButton
field={scriptedField}
fieldSearchHighlight={undefined}
isEmpty={true}
isSelected={true}
isActive={false}
onClick={jest.fn()}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly when a conflict field', () => {
const component = shallow(
<FieldItemButton
field={conflictField}
fieldSearchHighlight={undefined}
isEmpty={false}
isSelected={false}
isActive={true}
onClick={jest.fn()}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly for Records (Lens field)', () => {
const component = shallow(
<FieldItemButton
field={
new DataViewField({
name: '___records___',
customLabel: 'Records',
type: 'document',
searchable: false,
aggregatable: true,
})
}
fieldSearchHighlight="re"
isEmpty={false}
isSelected={false}
isActive={true}
onClick={jest.fn()}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly with an action when selected', () => {
const component = shallow(
<FieldItemButton
field={bytesField}
fieldSearchHighlight={undefined}
isEmpty={false}
isSelected={true}
isActive={false}
onClick={jest.fn().mockName('click')}
onAddFieldToWorkspace={jest.fn().mockName('add')}
onRemoveFieldFromWorkspace={jest.fn().mockName('remove')}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly with an action when deselected', () => {
const component = shallow(
<FieldItemButton
field={bytesField}
fieldSearchHighlight={undefined}
isEmpty={false}
isSelected={false}
isActive={false}
onClick={undefined}
shouldAlwaysShowAction
onAddFieldToWorkspace={jest.fn().mockName('add')}
onRemoveFieldFromWorkspace={jest.fn().mockName('remove')}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly with a drag handle', () => {
const component = shallow(
<FieldItemButton
size="xs"
className="custom"
dataTestSubj="test-subj"
dragHandle={<span>dragHandle</span>}
field={bytesField}
fieldSearchHighlight={undefined}
isEmpty={false}
isSelected={false}
isActive={false}
onClick={undefined}
/>
);
expect(component).toMatchSnapshot();
});
test('renders properly for text-based column field', () => {
const component = shallow(
<FieldItemButton<DatatableColumn>
field={{ id: 'test', name: 'agent', meta: { type: 'string' } }}
fieldSearchHighlight="ag"
getCustomFieldType={(f) => f.meta.type}
isEmpty={false}
isSelected={false}
isActive={false}
onClick={undefined}
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -10,7 +10,7 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import classnames from 'classnames';
import { FieldButton, type FieldButtonProps } from '@kbn/react-field';
import { EuiHighlight } from '@elastic/eui';
import { EuiButtonIcon, EuiButtonIconProps, EuiHighlight, EuiIcon, EuiToolTip } from '@elastic/eui';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { type FieldListItem, type GetCustomFieldType } from '../../types';
import { FieldIcon, getFieldIconProps } from '../field_icon';
@ -22,72 +22,211 @@ import './field_item_button.scss';
export interface FieldItemButtonProps<T extends FieldListItem> {
field: T;
fieldSearchHighlight?: string;
isActive?: FieldButtonProps['isActive'];
isEmpty?: boolean; // whether the field has data or not
isSelected: boolean; // whether a field is under Selected section
isActive: FieldButtonProps['isActive']; // whether a popover is open
isEmpty: boolean; // whether the field has data or not
infoIcon?: FieldButtonProps['fieldInfoIcon'];
className?: FieldButtonProps['className'];
flush?: FieldButtonProps['flush'];
dragHandle?: FieldButtonProps['dragHandle'];
getCustomFieldType?: GetCustomFieldType<T>;
dataTestSubj?: string;
size?: FieldButtonProps['size'];
onClick: FieldButtonProps['onClick'];
shouldAlwaysShowAction?: boolean; // should the field action be visible on hover or always
buttonAddFieldToWorkspaceProps?: Partial<EuiButtonIconProps>;
buttonRemoveFieldFromWorkspaceProps?: Partial<EuiButtonIconProps>;
onAddFieldToWorkspace?: (field: T) => unknown;
onRemoveFieldFromWorkspace?: (field: T) => unknown;
}
/**
* Inner part of field list item
* Field list item component
* @param field
* @param fieldSearchHighlight
* @param isSelected
* @param isActive
* @param isEmpty
* @param infoIcon
* @param className
* @param getCustomFieldType
* @param dataTestSubj
* @param size
* @param onClick
* @param shouldAlwaysShowAction
* @param buttonAddFieldToWorkspaceProps
* @param buttonRemoveFieldFromWorkspaceProps
* @param onAddFieldToWorkspace
* @param onRemoveFieldFromWorkspace
* @param otherProps
* @constructor
*/
export function FieldItemButton<T extends FieldListItem = DataViewField>({
field,
fieldSearchHighlight,
isSelected,
isActive,
isEmpty,
infoIcon,
className,
getCustomFieldType,
dataTestSubj,
size,
onClick,
shouldAlwaysShowAction,
buttonAddFieldToWorkspaceProps,
buttonRemoveFieldFromWorkspaceProps,
onAddFieldToWorkspace,
onRemoveFieldFromWorkspace,
...otherProps
}: FieldItemButtonProps<T>) {
const displayName = field.displayName || field.name;
const title =
displayName !== field.name && field.name !== '___records___'
? i18n.translate('unifiedFieldList.fieldItemButton.fieldTitle', {
defaultMessage: '{fieldDisplayName} ({fieldName})',
values: {
fieldName: field.name,
fieldDisplayName: displayName,
},
})
: displayName;
const iconProps = getCustomFieldType
? { type: getCustomFieldType(field) }
: getFieldIconProps(field);
const type = iconProps.type;
const classes = classnames(
'unifiedFieldItemButton',
'unifiedFieldListItemButton',
{
[`unifiedFieldItemButton--${type}`]: type,
[`unifiedFieldItemButton--exists`]: !isEmpty,
[`unifiedFieldItemButton--missing`]: isEmpty,
[`unifiedFieldListItemButton--${type}`]: type,
[`unifiedFieldListItemButton--exists`]: !isEmpty,
[`unifiedFieldListItemButton--missing`]: isEmpty,
},
className
);
const addFieldToWorkspaceTooltip =
buttonAddFieldToWorkspaceProps?.['aria-label'] ??
i18n.translate('unifiedFieldList.fieldItemButton.addFieldToWorkspaceLabel', {
defaultMessage: 'Add "{field}" field',
values: {
field: field.displayName,
},
});
const removeFieldFromWorkspaceTooltip =
buttonRemoveFieldFromWorkspaceProps?.['aria-label'] ??
i18n.translate('unifiedFieldList.fieldItemButton.removeFieldToWorkspaceLabel', {
defaultMessage: 'Remove "{field}" field',
values: {
field: field.displayName,
},
});
const fieldActionClassName = classnames('unifiedFieldListItemButton__action', {
'unifiedFieldListItemButton__action--always': shouldAlwaysShowAction,
});
const fieldAction = isSelected
? onRemoveFieldFromWorkspace && (
<EuiToolTip
key={`selected-to-remove-${field.name}-${removeFieldFromWorkspaceTooltip}`}
content={removeFieldFromWorkspaceTooltip}
>
<EuiButtonIcon
data-test-subj={`unifiedFieldListItem_removeField-${field.name}`}
aria-label={removeFieldFromWorkspaceTooltip}
{...(buttonRemoveFieldFromWorkspaceProps || {})}
className={classnames(
fieldActionClassName,
buttonRemoveFieldFromWorkspaceProps?.className
)}
color="danger"
iconType="cross"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onRemoveFieldFromWorkspace(field);
}}
/>
</EuiToolTip>
)
: onAddFieldToWorkspace && (
<EuiToolTip
key={`deselected-to-add-${field.name}-${addFieldToWorkspaceTooltip}`}
content={addFieldToWorkspaceTooltip}
>
<EuiButtonIcon
data-test-subj={`unifiedFieldListItem_addField-${field.name}`}
aria-label={addFieldToWorkspaceTooltip}
{...(buttonAddFieldToWorkspaceProps || {})}
className={classnames(fieldActionClassName, buttonAddFieldToWorkspaceProps?.className)}
color="text"
iconType="plusInCircle"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onAddFieldToWorkspace(field);
}}
/>
</EuiToolTip>
);
const conflictInfoIcon = field.type === 'conflict' ? <FieldConflictInfoIcon /> : null;
return (
<FieldButton
key={`field-item-button-${field.name}`}
dataTestSubj={dataTestSubj}
size={size || 's'}
className={classes}
isActive={isActive}
buttonProps={{
['aria-label']: i18n.translate('unifiedFieldList.fieldItemButtonAriaLabel', {
defaultMessage: 'Preview {fieldName}: {fieldType}',
['aria-label']: i18n.translate('unifiedFieldList.fieldItemButton.ariaLabel', {
defaultMessage: 'Preview {fieldDisplayName}: {fieldType}',
values: {
fieldName: displayName,
fieldDisplayName: displayName,
fieldType: getCustomFieldType ? getCustomFieldType(field) : field.type,
},
}),
}}
fieldIcon={<FieldIcon {...iconProps} />}
fieldName={<EuiHighlight search={fieldSearchHighlight || ''}>{displayName}</EuiHighlight>}
fieldInfoIcon={infoIcon}
fieldName={
<EuiHighlight
search={fieldSearchHighlight || ''}
title={title}
data-test-subj={`field-${field.name}`}
>
{displayName}
</EuiHighlight>
}
fieldAction={fieldAction}
fieldInfoIcon={conflictInfoIcon || infoIcon}
onClick={onClick}
{...otherProps}
/>
);
}
function FieldConflictInfoIcon() {
return (
<EuiToolTip
position="bottom"
content={i18n.translate('unifiedFieldList.fieldItemButton.mappingConflictDescription', {
defaultMessage:
'This field is defined as several types (string, integer, etc) across the indices that match this pattern.' +
'You may still be able to use this conflicting field, but it will be unavailable for functions that require Kibana to know their type. Correcting this issue will require reindexing your data.',
})}
>
<EuiIcon
tabIndex={0}
type="warning"
title={i18n.translate('unifiedFieldList.fieldItemButton.mappingConflictTitle', {
defaultMessage: 'Mapping Conflict',
})}
size="s"
/>
</EuiToolTip>
);
}

View file

@ -60,6 +60,7 @@ export type {
FieldListGroups,
FieldsGroupDetails,
FieldTypeKnown,
FieldListItem,
GetCustomFieldType,
} from './types';
export { ExistenceFetchStatus, FieldsGroupNames } from './types';

View file

@ -28,6 +28,7 @@
"@kbn/core-lifecycle-browser",
"@kbn/react-field",
"@kbn/field-types",
"@kbn/expressions-plugin",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'header']);
describe('discover drag and drop', function describeIndexTests() {
before(async function () {
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
});
beforeEach(async () => {
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
await kibanaServer.uiSettings.replace({
defaultIndex: 'logstash-*',
});
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.waitUntilSearchingHasFinished();
});
afterEach(async () => {
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.uiSettings.replace({});
await PageObjects.discover.cleanSidebarLocalStorage();
});
describe('should add fields as columns via drag and drop', function () {
it('should support dragging and dropping a field onto the grid', async function () {
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSidebarHasLoaded();
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
'53 available fields. 0 empty fields. 3 meta fields.'
);
expect((await PageObjects.discover.getColumnHeaders()).join(', ')).to.be(
'@timestamp, Document'
);
await PageObjects.discover.dragFieldToTable('extension');
expect((await PageObjects.discover.getColumnHeaders()).join(', ')).to.be(
'@timestamp, extension'
);
await PageObjects.discover.dragFieldWithKeyboardToTable('@message');
expect((await PageObjects.discover.getColumnHeaders()).join(', ')).to.be(
'@timestamp, extension, @message'
);
await PageObjects.discover.waitUntilSearchingHasFinished();
expect(
(await PageObjects.discover.getSidebarSectionFieldNames('selected')).join(', ')
).to.be('extension, @message');
});
});
});
}

View file

@ -20,6 +20,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
});
loadTestFile(require.resolve('./_drag_drop'));
loadTestFile(require.resolve('./_sidebar'));
});
}

View file

@ -394,7 +394,7 @@ export class DiscoverPageObject extends FtrService {
public async getAllFieldNames() {
const sidebar = await this.testSubjects.find('discover-sidebar');
const $ = await sidebar.parseDomContent();
return $('.dscSidebarField__name')
return $('.kbnFieldButton__name')
.toArray()
.map((field) => $(field).text());
}
@ -868,4 +868,51 @@ export class DiscoverPageObject extends FtrService {
await this.fieldEditor.save();
await this.header.waitUntilLoadingHasFinished();
}
private async waitForDropToFinish() {
await this.retry.try(async () => {
const exists = await this.find.existsByCssSelector('.domDragDrop-isActiveGroup');
if (exists) {
throw new Error('UI still in drag/drop mode');
}
});
await this.header.waitUntilLoadingHasFinished();
await this.waitUntilSearchingHasFinished();
}
/**
* Drags field to add as a column
*
* @param fieldName
* */
public async dragFieldToTable(fieldName: string) {
await this.waitUntilSidebarHasLoaded();
const from = `dscFieldListPanelField-${fieldName}`;
await this.find.existsByCssSelector(from);
await this.browser.html5DragAndDrop(
this.testSubjects.getCssSelector(from),
this.testSubjects.getCssSelector('dscMainContent')
);
await this.waitForDropToFinish();
}
/**
* Drags field with keyboard actions to add as a column
*
* @param fieldName
* */
public async dragFieldWithKeyboardToTable(fieldName: string) {
const field = await this.find.byCssSelector(
`[data-test-subj="domDragDrop_draggable-${fieldName}"] [data-test-subj="domDragDrop-keyboardHandler"]`
);
await field.focus();
await this.retry.try(async () => {
await this.browser.pressKeys(this.browser.keys.ENTER);
await this.testSubjects.exists('.domDragDrop-isDropTarget'); // checks if we're in dnd mode and there's any drop target active
});
await this.browser.pressKeys(this.browser.keys.RIGHT);
await this.browser.pressKeys(this.browser.keys.ENTER);
await this.waitForDropToFinish();
}
}

View file

@ -0,0 +1,4 @@
.lnsFieldItem__fieldPanel {
min-width: 260px;
max-width: 300px;
}

View file

@ -9,16 +9,15 @@ import React, { ReactElement } from 'react';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui';
import { InnerFieldItem, FieldItemProps } from './field_item';
import { InnerFieldItem, FieldItemIndexPatternFieldProps } from './field_item';
import { coreMock } from '@kbn/core/public/mocks';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
import { IndexPattern } from '../../types';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { documentField } from './document_field';
import { documentField } from '../form_based/document_field';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
@ -32,12 +31,10 @@ jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({}),
}));
const chartsThemeService = chartPluginMock.createSetupContract().theme;
const clickField = async (wrapper: ReactWrapper, field: string) => {
await act(async () => {
await wrapper
.find(`[data-test-subj="lnsFieldListPanelField-${field}"] button`)
.find(`[data-test-subj="lnsFieldListPanelField-${field}"] .kbnFieldButton__button`)
.simulate('click');
});
};
@ -47,6 +44,7 @@ const mockedServices = {
dataViews: dataViewPluginMocks.createStartContract(),
fieldFormats: fieldFormatsServiceMock.createStartContract(),
charts: chartPluginMock.createSetupContract(),
uiActions: uiActionsPluginMock.createStartContract(),
uiSettings: coreMock.createStart().uiSettings,
share: {
url: {
@ -64,7 +62,7 @@ const mockedServices = {
},
};
const InnerFieldItemWrapper: React.FC<FieldItemProps> = (props) => {
const InnerFieldItemWrapper: React.FC<FieldItemIndexPatternFieldProps> = (props) => {
return (
<KibanaContextProvider services={mockedServices}>
<InnerFieldItem {...props} />
@ -72,7 +70,7 @@ const InnerFieldItemWrapper: React.FC<FieldItemProps> = (props) => {
);
};
async function getComponent(props: FieldItemProps) {
async function getComponent(props: FieldItemIndexPatternFieldProps) {
const instance = await mountWithIntl(<InnerFieldItemWrapper {...props} />);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
@ -80,8 +78,8 @@ async function getComponent(props: FieldItemProps) {
return instance;
}
describe('IndexPattern Field Item', () => {
let defaultProps: FieldItemProps;
describe('Lens Field Item', () => {
let defaultProps: FieldItemIndexPatternFieldProps;
let indexPattern: IndexPattern;
let dataView: DataView;
@ -146,13 +144,6 @@ describe('IndexPattern Field Item', () => {
defaultProps = {
indexPattern,
fieldFormats: {
...fieldFormatsServiceMock.createStartContract(),
getDefaultInstance: jest.fn(() => ({
convert: jest.fn((s: unknown) => JSON.stringify(s)),
})),
} as unknown as FieldFormatsStart,
core: coreMock.createStart(),
highlight: '',
dateRange: {
fromDate: 'now-7d',
@ -168,17 +159,17 @@ describe('IndexPattern Field Item', () => {
searchable: true,
},
exists: true,
chartsThemeService,
groupIndex: 0,
itemIndex: 0,
dropOntoWorkspace: () => {},
hasSuggestionForField: () => false,
uiActions: uiActionsPluginMock.createStartContract(),
};
dataView = {
...indexPattern,
getFormatterForField: defaultProps.fieldFormats.getDefaultInstance,
getFormatterForField: jest.fn(() => ({
convert: jest.fn((s: unknown) => JSON.stringify(s)),
})),
} as unknown as DataView;
(mockedServices.dataViews.get as jest.Mock).mockImplementation(() => {

View file

@ -8,14 +8,11 @@
import './field_item.scss';
import React, { useCallback, useState, useMemo } from 'react';
import { EuiText, EuiButton, EuiPopoverFooter, EuiIconTip } from '@elastic/eui';
import { EuiText, EuiButton, EuiPopoverFooter } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { Filter, Query } from '@kbn/es-query';
import { DataViewField, type DataView } from '@kbn/data-views-plugin/common';
import { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import {
AddFieldFilterHandler,
FieldStats,
@ -23,37 +20,59 @@ import {
FieldPopoverHeader,
FieldPopoverVisualize,
FieldItemButton,
type GetCustomFieldType,
} from '@kbn/unified-field-list-plugin/public';
import { DragDrop } from '@kbn/dom-drag-drop';
import { generateFilters, getEsQueryConfig } from '@kbn/data-plugin/public';
import { type DatatableColumn } from '@kbn/expressions-plugin/common';
import { DatasourceDataPanelProps } from '../../types';
import type { IndexPattern, IndexPatternField } from '../../types';
import type { LensAppServices } from '../../app_plugin/types';
import { APP_ID, DOCUMENT_FIELD_NAME } from '../../../common/constants';
import { combineQueryAndFilters } from '../../app_plugin/show_underlying_data';
import { getFieldItemActions } from './get_field_item_actions';
export interface FieldItemProps {
core: DatasourceDataPanelProps['core'];
fieldFormats: FieldFormatsStart;
field: IndexPatternField;
indexPattern: IndexPattern;
type LensFieldListItem = IndexPatternField | DatatableColumn | DataViewField;
function isTextBasedColumnField(field: LensFieldListItem): field is DatatableColumn {
return !('type' in field) && Boolean(field?.meta.type);
}
interface FieldItemBaseProps {
highlight?: string;
exists: boolean;
query: Query;
dateRange: DatasourceDataPanelProps['dateRange'];
chartsThemeService: ChartsPluginSetup['theme'];
filters: Filter[];
hideDetails?: boolean;
itemIndex: number;
groupIndex: number;
dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace'];
editField?: (name: string) => void;
removeField?: (name: string) => void;
hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField'];
uiActions: UiActionsStart;
}
export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
export interface FieldItemIndexPatternFieldProps extends FieldItemBaseProps {
field: IndexPatternField;
indexPattern: IndexPattern;
query: Query;
dateRange: DatasourceDataPanelProps['dateRange'];
filters: Filter[];
editField?: (name: string) => void;
removeField?: (name: string) => void;
getCustomFieldType?: never;
}
export interface FieldItemDatatableColumnProps extends FieldItemBaseProps {
field: DatatableColumn;
indexPattern?: never;
query?: never;
dateRange?: never;
filters?: never;
editField?: never;
removeField?: never;
getCustomFieldType: GetCustomFieldType<DatatableColumn>;
}
export type FieldItemProps = FieldItemIndexPatternFieldProps | FieldItemDatatableColumnProps;
export function InnerFieldItem(props: FieldItemProps) {
const {
field,
indexPattern,
@ -66,9 +85,21 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
hasSuggestionForField,
editField,
removeField,
getCustomFieldType,
} = props;
const dataViewField = useMemo(() => new DataViewField(field), [field]);
const dataViewField = useMemo(() => {
// DatatableColumn type
if (isTextBasedColumnField(field)) {
return new DataViewField({
name: field.name,
type: field.meta?.type ?? 'unknown',
searchable: true,
aggregatable: true,
});
}
// IndexPatternField type
return new DataViewField(field);
}, [field]);
const services = useKibana<LensAppServices>().services;
const filterManager = services?.data?.query?.filterManager;
const [infoIsOpen, setOpen] = useState(false);
@ -83,7 +114,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
const addFilterAndClose: AddFieldFilterHandler | undefined = useMemo(
() =>
filterManager
filterManager && indexPattern
? (clickedField, values, operation) => {
closePopover();
const newFilters = generateFilters(
@ -121,52 +152,45 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
[removeField, closePopover]
);
const indexPatternId = indexPattern?.id;
const value = useMemo(
() => ({
field,
indexPatternId: indexPattern.id,
id: field.name,
humanData: {
label: field.displayName,
position: itemIndex + 1,
},
}),
[field, indexPattern.id, itemIndex]
() =>
isTextBasedColumnField(field)
? {
field: field.name,
id: field.id,
humanData: { label: field.name },
}
: {
field,
indexPatternId,
id: field.name,
humanData: {
label: field.displayName,
position: itemIndex + 1,
},
},
[field, indexPatternId, itemIndex]
);
const dropOntoWorkspaceAndClose = useCallback(() => {
closePopover();
dropOntoWorkspace(value);
}, [dropOntoWorkspace, closePopover, value]);
const onDragStart = useCallback(() => {
setOpen(false);
}, [setOpen]);
const order = useMemo(() => [0, groupIndex, itemIndex], [groupIndex, itemIndex]);
const lensInfoIcon = (
<EuiIconTip
anchorClassName="lnsFieldItem__infoIcon"
content={
hideDetails
? i18n.translate('xpack.lens.indexPattern.fieldItemTooltip', {
defaultMessage: 'Drag and drop to visualize.',
})
: exists
? i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', {
defaultMessage: 'Click for a field preview, or drag and drop to visualize.',
})
: i18n.translate('xpack.lens.indexPattern.fieldStatsButtonEmptyLabel', {
defaultMessage:
'This field doesnt have any data but you can still drag and drop to visualize.',
})
}
type="iInCircle"
color="subdued"
size="s"
/>
);
const { buttonAddFieldToWorkspaceProps, onAddFieldToWorkspace } = getFieldItemActions({
value,
hasSuggestionForField,
dropOntoWorkspace,
closeFieldPopover: closePopover,
});
const commonFieldItemButtonProps = {
isSelected: false, // multiple selections are allowed
isEmpty: !exists,
isActive: infoIsOpen,
fieldSearchHighlight: highlight,
onClick: togglePopover,
buttonAddFieldToWorkspaceProps,
onAddFieldToWorkspace,
};
return (
<li>
@ -187,41 +211,26 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
order={order}
value={value}
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
onDragStart={onDragStart}
onDragStart={closePopover}
>
<FieldItemButton<IndexPatternField>
isEmpty={!exists}
isActive={infoIsOpen}
infoIcon={lensInfoIcon}
field={field}
fieldSearchHighlight={highlight}
onClick={togglePopover}
/>
{isTextBasedColumnField(field) ? (
<FieldItemButton<DatatableColumn>
field={field}
getCustomFieldType={getCustomFieldType}
{...commonFieldItemButtonProps}
/>
) : (
<FieldItemButton field={field} {...commonFieldItemButtonProps} />
)}
</DragDrop>
}
renderHeader={() => {
const canAddToWorkspace = hasSuggestionForField(value);
const buttonTitle = canAddToWorkspace
? i18n.translate('xpack.lens.indexPattern.moveToWorkspace', {
defaultMessage: 'Add {field} to workspace',
values: {
field: value.field.name,
},
})
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceNotAvailable', {
defaultMessage:
'To visualize this field, please add it directly to the desired layer. Adding this field to the workspace is not supported based on your current configuration.',
});
return (
<FieldPopoverHeader
field={dataViewField}
closePopover={closePopover}
buttonAddFieldToWorkspaceProps={{
isDisabled: !canAddToWorkspace,
'aria-label': buttonTitle,
}}
onAddFieldToWorkspace={dropOntoWorkspaceAndClose}
buttonAddFieldToWorkspaceProps={buttonAddFieldToWorkspaceProps}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onAddFilter={addFilterAndClose}
onEditField={editFieldAndClose}
onDeleteField={removeFieldAndClose}
@ -242,9 +251,9 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
/>
</li>
);
};
}
export const FieldItem = React.memo(InnerFieldItem);
export const FieldItem = React.memo(InnerFieldItem) as typeof InnerFieldItem;
function FieldItemPopoverContents(
props: FieldItemProps & {
@ -252,10 +261,13 @@ function FieldItemPopoverContents(
onAddFilter: AddFieldFilterHandler | undefined;
}
) {
const { query, filters, indexPattern, dataViewField, dateRange, onAddFilter, uiActions } = props;
const { query, filters, indexPattern, dataViewField, dateRange, onAddFilter } = props;
const services = useKibana<LensAppServices>().services;
const exploreInDiscover = useMemo(() => {
if (!indexPattern) {
return null;
}
const meta = {
id: indexPattern.id,
columns: [dataViewField.name],
@ -290,6 +302,10 @@ function FieldItemPopoverContents(
});
}, [dataViewField.name, filters, indexPattern, query, services]);
if (!indexPattern) {
return null;
}
return (
<>
<FieldStats
@ -330,7 +346,7 @@ function FieldItemPopoverContents(
field={dataViewField}
dataView={{ ...indexPattern, toSpec: () => indexPattern.spec } as unknown as DataView}
originatingApp={APP_ID}
uiActions={uiActions}
uiActions={services.uiActions}
buttonProps={{
'data-test-subj': `lensVisualize-GeoField-${dataViewField.name}`,
}}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { type DragDropIdentifier } from '@kbn/dom-drag-drop';
import type { FieldItemButtonProps, FieldListItem } from '@kbn/unified-field-list-plugin/public';
import { i18n } from '@kbn/i18n';
import { type DataViewField } from '@kbn/data-views-plugin/common';
interface GetFieldItemActionsParams<T extends FieldListItem> {
value: DragDropIdentifier;
dropOntoWorkspace: (value: DragDropIdentifier) => void;
hasSuggestionForField: (value: DragDropIdentifier) => boolean;
closeFieldPopover?: () => void;
}
interface GetFieldItemActionsResult<T extends FieldListItem> {
buttonAddFieldToWorkspaceProps: FieldItemButtonProps<T>['buttonAddFieldToWorkspaceProps'];
onAddFieldToWorkspace: FieldItemButtonProps<T | DataViewField>['onAddFieldToWorkspace'];
}
export function getFieldItemActions<T extends FieldListItem>({
value,
hasSuggestionForField,
dropOntoWorkspace,
closeFieldPopover,
}: GetFieldItemActionsParams<T>): GetFieldItemActionsResult<T> {
const canAddToWorkspace = hasSuggestionForField(value);
const addToWorkplaceButtonTitle = canAddToWorkspace
? i18n.translate('xpack.lens.indexPattern.moveToWorkspace', {
defaultMessage: 'Add {field} to workspace',
values: {
field: value.humanData.label,
},
})
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceNotAvailable', {
defaultMessage:
'To visualize this field, please add it directly to the desired layer. Adding this field to the workspace is not supported based on your current configuration.',
});
const dropOntoWorkspaceAndClose = () => {
closeFieldPopover?.();
dropOntoWorkspace(value);
};
return {
buttonAddFieldToWorkspaceProps: {
isDisabled: !canAddToWorkspace,
'aria-label': addToWorkplaceButtonTitle,
},
onAddFieldToWorkspace: dropOntoWorkspaceAndClose,
};
}

View file

@ -17,7 +17,7 @@ import { InnerFormBasedDataPanel, FormBasedDataPanel } from './datapanel';
import { FieldListGrouped } from '@kbn/unified-field-list-plugin/public';
import * as UseExistingFieldsApi from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields';
import * as ExistingFieldsServiceApi from '@kbn/unified-field-list-plugin/public/services/field_existing/load_field_existing';
import { FieldItem } from './field_item';
import { FieldItem } from '../common/field_item';
import { act } from 'react-dom/test-utils';
import { coreMock } from '@kbn/core/public/mocks';
import { FormBasedPrivateState } from './types';
@ -37,6 +37,7 @@ import { DataViewsState } from '../../state_management';
import { DataView } from '@kbn/data-views-plugin/public';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { ReactWrapper } from 'enzyme';
import { IndexPatternField } from '../../types';
const fieldsOne = [
{
@ -787,7 +788,11 @@ describe('FormBased Data Panel', () => {
it('should list all supported fields in the pattern sorted alphabetically in groups', async () => {
const wrapper = await mountAndWaitForLazyModules(<InnerFormBasedDataPanel {...props} />);
expect(wrapper.find(FieldItem).first().prop('field').displayName).toEqual('Records');
expect(wrapper.find(FieldItem).first().prop('field')).toEqual(
expect.objectContaining({
displayName: 'Records',
})
);
const availableAccordion = wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]');
expect(
availableAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name)
@ -803,7 +808,9 @@ describe('FormBased Data Panel', () => {
emptyAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name)
).toEqual(['client', 'source', 'timestamp']);
expect(
emptyAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').displayName)
emptyAccordion
.find(FieldItem)
.map((fieldItem) => (fieldItem.prop('field') as IndexPatternField).displayName)
).toEqual(['client', 'source', 'timestampLabel']);
expect(emptyAccordion.find(FieldItem).at(1).prop('exists')).toEqual(false);
});
@ -872,7 +879,7 @@ describe('FormBased Data Panel', () => {
wrapper
.find('[data-test-subj="lnsIndexPatternEmptyFields"]')
.find(FieldItem)
.map((fieldItem) => fieldItem.prop('field').displayName)
.map((fieldItem) => (fieldItem.prop('field') as IndexPatternField).displayName)
).toEqual(['amemory', 'bytes', 'client', 'source', 'timestampLabel']);
});
@ -974,7 +981,9 @@ describe('FormBased Data Panel', () => {
wrapper.find('[data-test-subj="typeFilter-number"]').first().simulate('click');
expect(
wrapper.find(FieldItem).map((fieldItem) => fieldItem.prop('field').displayName)
wrapper
.find(FieldItem)
.map((fieldItem) => (fieldItem.prop('field') as IndexPatternField).displayName)
).toEqual(['amemory', 'bytes']);
});
@ -1010,7 +1019,9 @@ describe('FormBased Data Panel', () => {
.first()
.simulate('click');
expect(
wrapper.find(FieldItem).map((fieldItem) => fieldItem.prop('field').displayName)
wrapper
.find(FieldItem)
.map((fieldItem) => (fieldItem.prop('field') as IndexPatternField).displayName)
).toEqual(['Records', 'amemory', 'bytes', 'client', 'source', 'timestampLabel']);
});

View file

@ -37,7 +37,7 @@ import type {
} from '../../types';
import type { FormBasedPrivateState } from './types';
import { IndexPatternServiceAPI } from '../../data_views_service/service';
import { FieldItem } from './field_item';
import { FieldItem } from '../common/field_item';
export type Props = Omit<
DatasourceDataPanelProps<FormBasedPrivateState>,
@ -175,9 +175,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
core,
data,
dataViews,
fieldFormats,
indexPatternFieldEditor,
charts,
dropOntoWorkspace,
hasSuggestionForField,
uiActions,
@ -380,30 +378,22 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
hasSuggestionForField={hasSuggestionForField}
editField={editField}
removeField={removeField}
uiActions={uiActions}
core={core}
fieldFormats={fieldFormats}
indexPattern={currentIndexPattern}
highlight={fieldSearchHighlight}
dateRange={dateRange}
query={query}
filters={filters}
chartsThemeService={charts.theme}
/>
),
[
core,
fieldFormats,
currentIndexPattern,
dateRange,
query,
filters,
charts.theme,
dropOntoWorkspace,
hasSuggestionForField,
editField,
removeField,
uiActions,
]
);

View file

@ -1,26 +0,0 @@
.kbnFieldButton {
.lnsFieldItem__infoIcon {
visibility: hidden;
opacity: 0;
}
&:hover:not([class*='isActive']) {
.lnsFieldItem__infoIcon {
visibility: visible;
opacity: 1;
transition: opacity $euiAnimSpeedFast ease-in-out 1s;
}
}
}
.kbnFieldButton.domDragDrop_ghost {
.lnsFieldItem__infoIcon {
visibility: hidden;
opacity: 0;
}
}
.lnsFieldItem__fieldPanel {
min-width: 260px;
max-width: 300px;
}

View file

@ -18,7 +18,7 @@ import {
Start as DataViewPublicStart,
} from '@kbn/data-views-plugin/public/mocks';
import type { DatatableColumn } from '@kbn/expressions-plugin/public';
import { EuiHighlight } from '@elastic/eui';
import { EuiHighlight, EuiToken } from '@elastic/eui';
import { type TextBasedDataPanelProps, TextBasedDataPanel } from './datapanel';
@ -274,4 +274,15 @@ describe('TextBased Query Languages Data Panel', () => {
.map((item) => item.prop('children'))
).toEqual(['memory']);
});
it('should render correct field type icons', async () => {
const wrapper = await mountAndWaitForLazyModules(<TextBasedDataPanel {...defaultProps} />);
expect(
wrapper
.find('[data-test-subj="lnsTextBasedLanguagesAvailableFields"]')
.find(EuiToken)
.map((item) => item.prop('iconType'))
).toEqual(['tokenNumber', 'tokenNumber', 'tokenDate']);
});
});

View file

@ -18,17 +18,17 @@ import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
FieldList,
FieldListFilters,
FieldItemButton,
GetCustomFieldType,
FieldListGrouped,
FieldListGroupedProps,
FieldsGroupNames,
GetCustomFieldType,
useGroupedFields,
} from '@kbn/unified-field-list-plugin/public';
import { ChildDragDropProvider, DragDrop } from '@kbn/dom-drag-drop';
import { ChildDragDropProvider } from '@kbn/dom-drag-drop';
import type { DatasourceDataPanelProps } from '../../types';
import type { TextBasedPrivateState } from './types';
import { getStateFromAggregateQuery } from './utils';
import { FieldItem } from '../common/field_item';
const getCustomFieldType: GetCustomFieldType<DatatableColumn> = (field) => field?.meta.type;
@ -52,6 +52,8 @@ export function TextBasedDataPanel({
expressions,
dataViews,
layerFields,
hasSuggestionForField,
dropOntoWorkspace,
}: TextBasedDataPanelProps) {
const prevQuery = usePrevious(query);
const [dataHasLoaded, setDataHasLoaded] = useState(false);
@ -108,33 +110,26 @@ export function TextBasedDataPanel({
});
const renderFieldItem: FieldListGroupedProps<DatatableColumn>['renderFieldItem'] = useCallback(
({ field, itemIndex, fieldSearchHighlight }) => {
({ field, groupIndex, itemIndex, fieldSearchHighlight, groupName }) => {
if (!field) {
return <></>;
}
return (
<DragDrop
draggable
order={[itemIndex]}
value={{
field: field.name,
id: field.id,
humanData: { label: field.name },
}}
dataTestSubj={`lnsFieldListPanelField-${field.name}`}
>
<FieldItemButton<DatatableColumn>
isEmpty={false}
isActive={false}
field={field}
fieldSearchHighlight={fieldSearchHighlight}
getCustomFieldType={getCustomFieldType}
onClick={() => {}}
/>
</DragDrop>
<FieldItem
field={field}
exists
hideDetails
itemIndex={itemIndex}
groupIndex={groupIndex}
dropOntoWorkspace={dropOntoWorkspace}
hasSuggestionForField={hasSuggestionForField}
highlight={fieldSearchHighlight}
getCustomFieldType={getCustomFieldType}
/>
);
},
[]
[hasSuggestionForField, dropOntoWorkspace]
);
return (

View file

@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n';
import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { AggregateQuery } from '@kbn/es-query';
import type { SavedObjectReference } from '@kbn/core/public';
import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui';
import { EuiFormRow } from '@elastic/eui';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import type { ExpressionsStart, DatatableColumnType } from '@kbn/expressions-plugin/public';
import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public';
@ -42,6 +42,7 @@ import type {
import { FieldSelect } from './field_select';
import type { Datasource, IndexPatternMap } from '../../types';
import { LayerPanel } from './layerpanel';
import { DimensionTrigger } from '../../shared_components/dimension_trigger';
function getLayerReferenceName(layerId: string) {
return `textBasedLanguages-datasource-layer-${layerId}`;
@ -377,16 +378,17 @@ export function getTextBasedDatasource({
}
render(
<EuiButtonEmpty
<DimensionTrigger
id={props.columnId}
color={customLabel && selectedField ? 'primary' : 'danger'}
onClick={() => {}}
data-test-subj="lns-dimensionTrigger-textBased"
>
{customLabel ??
dataTestSubj="lns-dimensionTrigger-textBased"
label={
customLabel ??
i18n.translate('xpack.lens.textBasedLanguages.missingField', {
defaultMessage: 'Missing field',
})}
</EuiButtonEmpty>,
})
}
/>,
domElement
);
},

View file

@ -66,13 +66,12 @@ export function AddLayerButton({
position="bottom"
>
<EuiButton
size="s"
fullWidth
data-test-subj="lnsLayerAddButton"
aria-label={i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
fill
color="text"
onClick={() => onAddLayerClick(supportedLayers[0].type)}
iconType="layers"
>
@ -89,13 +88,12 @@ export function AddLayerButton({
data-test-subj="lnsConfigPanel__addLayerPopover"
button={
<EuiButton
size="s"
fullWidth
data-test-subj="lnsLayerAddButton"
aria-label={i18n.translate('xpack.lens.configPanel.addLayerButton', {
defaultMessage: 'Add layer',
})}
fill
color="text"
onClick={() => toggleLayersChoice(!showLayersChoice)}
iconType="layers"
>

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { EuiButtonIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { EuiButtonIcon, EuiLink, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
@ -28,7 +28,9 @@ export function DimensionButton({
accessorConfig,
label,
message,
...otherProps // from Drag&Drop integration
}: {
className?: string;
group: VisualizationDimensionGroupConfig;
children: React.ReactElement;
onClick: (id: string) => void;
@ -38,32 +40,34 @@ export function DimensionButton({
message: UserMessage | undefined;
}) {
return (
<>
<div>
<EuiToolTip
content={message?.shortMessage || message?.longMessage || undefined}
position="left"
>
<EuiLink
className="lnsLayerPanel__dimensionLink"
data-test-subj="lnsLayerPanel-dimensionLink"
onClick={() => onClick(accessorConfig.columnId)}
aria-label={triggerLinkA11yText(label)}
title={triggerLinkA11yText(label)}
color={
message?.severity === 'error'
? 'danger'
: message?.severity === 'warning'
? 'warning'
: undefined
}
<div {...otherProps}>
<EuiFlexGroup direction="row" alignItems="center" gutterSize="none" responsive={false}>
<EuiFlexItem>
<EuiToolTip
content={message?.shortMessage || message?.longMessage || undefined}
position="left"
>
<DimensionButtonIcon message={message} accessorConfig={accessorConfig}>
{children}
</DimensionButtonIcon>
</EuiLink>
</EuiToolTip>
</div>
<EuiLink
className="lnsLayerPanel__dimensionLink"
data-test-subj="lnsLayerPanel-dimensionLink"
onClick={() => onClick(accessorConfig.columnId)}
aria-label={triggerLinkA11yText(label)}
title={triggerLinkA11yText(label)}
color={
message?.severity === 'error'
? 'danger'
: message?.severity === 'warning'
? 'warning'
: 'text'
}
>
<DimensionButtonIcon message={message} accessorConfig={accessorConfig}>
{children}
</DimensionButtonIcon>
</EuiLink>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
<EuiButtonIcon
className="lnsLayerPanel__dimensionRemove"
data-test-subj="indexPattern-dimension-remove"
@ -87,6 +91,6 @@ export function DimensionButton({
`}
/>
<PaletteIndicator accessorConfig={accessorConfig} />
</>
</div>
);
}

View file

@ -60,7 +60,7 @@ const DefaultEmptyButton = ({ columnId, group, onClick }: EmptyButtonProps) => {
className="lnsLayerPanel__triggerText"
color="text"
size="s"
iconType="plusInCircleFilled"
iconType="plus"
contentProps={{
className: 'lnsLayerPanel__triggerTextContent',
}}

View file

@ -54,7 +54,7 @@
&,
.euiFormRow__fieldWrapper {
& > * + * {
margin-top: $euiSize;
margin-top: $euiSizeS;
}
}
}
@ -64,22 +64,39 @@
padding: $euiSizeS $euiSize;
}
.lnsLayerPanel__dimensionRemove {
margin-right: $euiSizeS;
visibility: hidden;
opacity: 0;
}
.lnsLayerPanel__dimension {
@include euiFontSizeS;
border-radius: $euiBorderRadius;
display: flex;
align-items: center;
overflow: hidden;
min-height: $euiSizeXXL;
min-height: $euiSizeXL;
position: relative;
// NativeRenderer is messing this up
> div {
flex-grow: 1;
}
&:hover,
&:focus {
.lnsLayerPanel__dimensionRemove {
visibility: visible;
opacity: 1;
transition: opacity $euiAnimSpeedFast ease-in-out;
}
}
}
.lnsLayerPanel__dimension--empty {
border: $euiBorderWidthThin dashed $euiBorderColor !important;
&:focus,
&:focus-within {
@include euiFocusRing;
@ -94,28 +111,33 @@
}
}
.lnsLayerPanel__dimensionRemove {
margin-right: $euiSizeS;
}
.lnsLayerPanel__triggerText {
width: 100%;
padding: $euiSizeS;
min-height: $euiSizeXXL - 2;
padding: $euiSizeXS $euiSizeS;
word-break: break-word;
font-weight: $euiFontWeightRegular;
}
.lnsLayerPanel__dimensionLink {
&:hover {
text-decoration: none;
}
}
.lnsLayerPanel__triggerTextLabel {
transition: background-color $euiAnimSpeedFast ease-in-out;
&:hover {
text-decoration: underline;
}
}
.domDragDrop-isReplacing {
.lnsLayerPanel__triggerText {
text-decoration: line-through;
}
}
.lnsLayerPanel__triggerTextLabel {
transition: background-color $euiAnimSpeedFast ease-in-out;
}
.lnsLayerPanel__triggerTextContent {
// Make EUI button content not centered
justify-content: flex-start;
@ -139,6 +161,7 @@
}
.lnsLayerPanel__palette {
height: $euiSizeXS / 2;
border-radius: 0 0 ($euiBorderRadius - 1px) ($euiBorderRadius - 1px);
&::after {
@ -153,5 +176,6 @@
&:focus {
@include passDownFocusRing('.lnsLayerPanel__triggerTextLabel');
background-color: transparent;
text-decoration-thickness: $euiBorderWidthThin !important;
}
}

View file

@ -826,7 +826,8 @@ describe('LayerPanel', () => {
const dragDropElement = instance
.find('[data-test-subj="lnsGroup"] DragDrop')
.first()
.find('.lnsLayerPanel__dimension');
.find('.lnsLayerPanel__dimension')
.first();
dragDropElement.simulate('dragOver');
dragDropElement.simulate('drop');
@ -906,7 +907,7 @@ describe('LayerPanel', () => {
const updatedDragDropElement = instance
.find('[data-test-subj="lnsGroupB"] DragDrop .domDragDrop')
.at(2);
.last();
updatedDragDropElement.simulate('dragOver');
updatedDragDropElement.simulate('drop');

View file

@ -557,46 +557,45 @@ export function LayerPanel(
onDrop={onDrop}
indexPatterns={dataViews.indexPatterns}
>
<div className="lnsLayerPanel__dimension">
<DimensionButton
accessorConfig={accessorConfig}
label={columnLabelMap?.[accessorConfig.columnId] ?? ''}
group={group}
onClick={(id: string) => {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: id,
});
}}
onRemoveClick={(id: string) => {
props.onRemoveDimension({ columnId: id, layerId });
removeButtonRef(id);
}}
message={messages[0]}
>
{layerDatasource ? (
<NativeRenderer
render={layerDatasource.renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
indexPatterns: dataViews.indexPatterns,
}}
/>
) : (
<>
{activeVisualization?.renderDimensionTrigger?.({
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
})}
</>
)}
</DimensionButton>
</div>
<DimensionButton
className="lnsLayerPanel__dimension"
accessorConfig={accessorConfig}
label={columnLabelMap?.[accessorConfig.columnId] ?? ''}
group={group}
onClick={(id: string) => {
setActiveDimension({
isNew: false,
activeGroup: group,
activeId: id,
});
}}
onRemoveClick={(id: string) => {
props.onRemoveDimension({ columnId: id, layerId });
removeButtonRef(id);
}}
message={messages[0]}
>
{layerDatasource ? (
<NativeRenderer
render={layerDatasource.renderDimensionTrigger}
nativeProps={{
...layerDatasourceConfigProps,
columnId: accessorConfig.columnId,
groupId: group.groupId,
filterOperations: group.filterOperations,
indexPatterns: dataViews.indexPatterns,
}}
/>
) : (
<>
{activeVisualization?.renderDimensionTrigger?.({
columnId,
label: columnLabelMap?.[columnId] ?? '',
hideTooltip,
})}
</>
)}
</DimensionButton>
</DraggableDimensionButton>
);
})}

View file

@ -7,6 +7,7 @@
import { EuiText, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiTextProps } from '@elastic/eui/src/components/text/text';
export const defaultDimensionTriggerTooltip = (
<p>
@ -20,13 +21,24 @@ export const defaultDimensionTriggerTooltip = (
</p>
);
export const DimensionTrigger = ({ id, label }: { label: string; id: string }) => {
export const DimensionTrigger = ({
id,
label,
color,
dataTestSubj,
}: {
label: string;
id: string;
color?: EuiTextProps['color'];
dataTestSubj?: string;
}) => {
return (
<EuiText
size="s"
id={id}
color={color}
className="lnsLayerPanel__triggerText"
data-test-subj="lns-dimensionTrigger"
data-test-subj={dataTestSubj || 'lns-dimensionTrigger'}
>
<EuiFlexItem grow={true}>
<span>

View file

@ -2147,13 +2147,10 @@
"discover.docTable.totalDocuments": "{totalDocuments} documents",
"discover.dscTour.stepAddFields.description": "Cliquez sur {plusIcon} pour ajouter les champs qui vous intéressent.",
"discover.dscTour.stepExpand.description": "Cliquez sur {expandIcon} pour afficher, comparer et filtrer les documents.",
"discover.field.title": "{fieldName} ({fieldDisplayName})",
"discover.fieldChooser.detailViews.existsInRecordsText": "Existe dans {value} / {totalValue} enregistrements",
"discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "Exclure {field} : \"{value}\"",
"discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "Filtrer sur {field} : \"{value}\"",
"discover.fieldChooser.detailViews.valueOfRecordsText": "{value} / {totalValue} enregistrements",
"discover.fieldChooser.discoverField.addButtonAriaLabel": "Ajouter {field} au tableau",
"discover.fieldChooser.discoverField.removeButtonAriaLabel": "Retirer {field} du tableau",
"discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "Ce champ est présent dans votre mapping Elasticsearch, mais pas dans les {hitsLength} documents affichés dans le tableau des documents. Cependant, vous pouvez toujours le consulter ou effectuer une recherche dessus.",
"discover.grid.copyClipboardButtonTitle": "Copier la valeur de {column}",
"discover.grid.copyColumnValuesToClipboard.toastTitle": "Valeurs de la colonne \"{column}\" copiées dans le presse-papiers",
@ -2337,8 +2334,6 @@
"discover.embeddable.inspectorRequestDataTitle": "Données",
"discover.embeddable.inspectorRequestDescription": "Cette requête interroge Elasticsearch afin de récupérer les données pour la recherche.",
"discover.embeddable.search.displayName": "rechercher",
"discover.field.mappingConflict": "Ce champ est défini avec plusieurs types (chaîne, entier, etc.) dans les différents index qui correspondent à ce modèle. Vous pouvez toujours utiliser ce champ conflictuel, mais il sera indisponible pour les fonctions qui nécessitent que Kibana en connaisse le type. Pour corriger ce problème, vous devrez réindexer vos données.",
"discover.field.mappingConflict.title": "Conflit de mapping",
"discover.fieldChooser.addField.label": "Ajouter un champ",
"discover.fieldChooser.availableFieldsTooltip": "Champs disponibles pour l'affichage dans le tableau.",
"discover.fieldChooser.detailViews.emptyStringText": "Chaîne vide",
@ -19529,10 +19524,7 @@
"xpack.lens.indexPattern.emptyFieldsLabel": "Champs vides",
"xpack.lens.indexPattern.enableAccuracyMode": "Activer le mode de précision",
"xpack.lens.indexPattern.fieldExploreInDiscover": "Explorer dans Discover",
"xpack.lens.indexPattern.fieldItemTooltip": "Effectuez un glisser-déposer pour visualiser.",
"xpack.lens.indexPattern.fieldPlaceholder": "Champ",
"xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "Ce champ ne comporte aucune donnée mais vous pouvez toujours effectuer un glisser-déposer pour visualiser.",
"xpack.lens.indexPattern.fieldStatsButtonLabel": "Cliquez pour obtenir un aperçu du champ, ou effectuez un glisser-déposer pour visualiser.",
"xpack.lens.indexPattern.fieldStatsNoData": "Lens ne peut pas créer de visualisation avec ce champ, car il ne contient pas de données. Pour créer une visualisation, glissez-déposez un autre champ.",
"xpack.lens.indexPattern.filterBy.clickToEdit": "Cliquer pour modifier",
"xpack.lens.indexPattern.filterBy.emptyFilterQuery": "(vide)",

View file

@ -2147,13 +2147,10 @@
"discover.docTable.totalDocuments": "{totalDocuments}ドキュメント",
"discover.dscTour.stepAddFields.description": "{plusIcon}をクリックして、関心があるフィールドを追加します。",
"discover.dscTour.stepExpand.description": "{expandIcon}をクリックすると、ドキュメントを表示、比較、フィルタリングできます。",
"discover.field.title": "{fieldName}{fieldDisplayName}",
"discover.fieldChooser.detailViews.existsInRecordsText": "{value} / {totalValue}レコードに存在します",
"discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "{field}を除外:\"{value}\"",
"discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "{field}のフィルター:\"{value}\"",
"discover.fieldChooser.detailViews.valueOfRecordsText": "{value} / {totalValue}レコード",
"discover.fieldChooser.discoverField.addButtonAriaLabel": "{field}をテーブルに追加",
"discover.fieldChooser.discoverField.removeButtonAriaLabel": "{field}をテーブルから削除",
"discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "このフィールドはElasticsearchマッピングに表示されますが、ドキュメントテーブルの{hitsLength}件のドキュメントには含まれません。可視化や検索は可能な場合があります。",
"discover.grid.copyClipboardButtonTitle": "{column}の値をコピー",
"discover.grid.copyColumnValuesToClipboard.toastTitle": "\"{column}\"列の値がクリップボードにコピーされました",
@ -2337,8 +2334,6 @@
"discover.embeddable.inspectorRequestDataTitle": "データ",
"discover.embeddable.inspectorRequestDescription": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。",
"discover.embeddable.search.displayName": "検索",
"discover.field.mappingConflict": "このフィールドは、このパターンと一致するインデックス全体に対して複数の型文字列、整数などとして定義されています。この競合フィールドを使用することはできますが、Kibana で型を認識する必要がある関数では使用できません。この問題を修正するにはデータのレンダリングが必要です。",
"discover.field.mappingConflict.title": "マッピングの矛盾",
"discover.fieldChooser.addField.label": "フィールドを追加",
"discover.fieldChooser.availableFieldsTooltip": "フィールドをテーブルに表示できます。",
"discover.fieldChooser.detailViews.emptyStringText": "空の文字列",
@ -19529,10 +19524,7 @@
"xpack.lens.indexPattern.emptyFieldsLabel": "空のフィールド",
"xpack.lens.indexPattern.enableAccuracyMode": "精度モードを有効にする",
"xpack.lens.indexPattern.fieldExploreInDiscover": "Discoverで探索",
"xpack.lens.indexPattern.fieldItemTooltip": "可視化するには、ドラッグアンドドロップします。",
"xpack.lens.indexPattern.fieldPlaceholder": "フィールド",
"xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "このフィールドにはデータがありませんが、ドラッグアンドドロップで可視化できます。",
"xpack.lens.indexPattern.fieldStatsButtonLabel": "フィールドプレビューを表示するには、クリックします。可視化するには、ドラッグアンドドロップします。",
"xpack.lens.indexPattern.fieldStatsNoData": "Lensはこのフィールドのビジュアライゼーションを作成できません。フィールドにデータがありません。ビジュアライゼーションを作成するには、別のフィールドをドラッグします。",
"xpack.lens.indexPattern.filterBy.clickToEdit": "クリックして編集",
"xpack.lens.indexPattern.filterBy.emptyFilterQuery": "(空)",

View file

@ -2147,13 +2147,10 @@
"discover.docTable.totalDocuments": "{totalDocuments} 个文档",
"discover.dscTour.stepAddFields.description": "单击 {plusIcon} 以添加您感兴趣的字段。",
"discover.dscTour.stepExpand.description": "单击 {expandIcon} 以查看、比较和筛选文档。",
"discover.field.title": "{fieldName} ({fieldDisplayName})",
"discover.fieldChooser.detailViews.existsInRecordsText": "存在于 {value}/{totalValue} 条记录中",
"discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel": "筛除 {field}“{value}”",
"discover.fieldChooser.detailViews.filterValueButtonAriaLabel": "筛留 {field}“{value}”",
"discover.fieldChooser.detailViews.valueOfRecordsText": "{value}/{totalValue} 条记录",
"discover.fieldChooser.discoverField.addButtonAriaLabel": "将 {field} 添加到表中",
"discover.fieldChooser.discoverField.removeButtonAriaLabel": "从表中移除 {field}",
"discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage": "此字段在您的 Elasticsearch 映射中,但不在文档表中显示的 {hitsLength} 个文档中。您可能仍能够基于它可视化或搜索。",
"discover.grid.copyClipboardButtonTitle": "复制 {column} 的值",
"discover.grid.copyColumnValuesToClipboard.toastTitle": "“{column}”列的值已复制到剪贴板",
@ -2337,8 +2334,6 @@
"discover.embeddable.inspectorRequestDataTitle": "数据",
"discover.embeddable.inspectorRequestDescription": "此请求将查询 Elasticsearch 以获取搜索的数据。",
"discover.embeddable.search.displayName": "搜索",
"discover.field.mappingConflict": "此字段在匹配此模式的各个索引中已定义为若干类型(字符串、整数等)。您可能仍可以使用此冲突字段,但它无法用于需要 Kibana 知道其类型的函数。要解决此问题,需要重新索引您的数据。",
"discover.field.mappingConflict.title": "映射冲突",
"discover.fieldChooser.addField.label": "添加字段",
"discover.fieldChooser.availableFieldsTooltip": "适用于在表中显示的字段。",
"discover.fieldChooser.detailViews.emptyStringText": "空字符串",
@ -19530,10 +19525,7 @@
"xpack.lens.indexPattern.emptyFieldsLabel": "空字段",
"xpack.lens.indexPattern.enableAccuracyMode": "启用准确性模式",
"xpack.lens.indexPattern.fieldExploreInDiscover": "在 Discover 中浏览",
"xpack.lens.indexPattern.fieldItemTooltip": "拖放以可视化。",
"xpack.lens.indexPattern.fieldPlaceholder": "字段",
"xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "此字段不包含任何数据,但您仍然可以拖放以进行可视化。",
"xpack.lens.indexPattern.fieldStatsButtonLabel": "单击以进行字段预览,或拖放以进行可视化。",
"xpack.lens.indexPattern.fieldStatsNoData": "Lens 无法使用此字段创建可视化,因为其中未包含数据。要创建可视化,请拖放其他字段。",
"xpack.lens.indexPattern.filterBy.clickToEdit": "单击以编辑",
"xpack.lens.indexPattern.filterBy.emptyFilterQuery": "(空)",

View file

@ -148,7 +148,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await listingTable.searchForItemWithName('lnsXYvis');
await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis');
await PageObjects.lens.goToTimeRange();
await testSubjects.click('lnsXY_splitDimensionPanel > indexPattern-dimension-remove');
await PageObjects.lens.removeDimension('lnsXY_splitDimensionPanel');
await PageObjects.lens.switchToVisualization('line');
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger',
@ -200,9 +200,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql(
longLabel
);
expect(
await testSubjects.isDisplayed('lnsXY_yDimensionPanel > indexPattern-dimension-remove')
).to.equal(true);
expect(await PageObjects.lens.canRemoveDimension('lnsXY_yDimensionPanel')).to.equal(true);
await PageObjects.lens.removeDimension('lnsXY_yDimensionPanel');
await testSubjects.missingOrFail('lnsXY_yDimensionPanel > lns-dimensionTrigger');
});

View file

@ -626,10 +626,18 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
async isTopLevelAggregation() {
return await testSubjects.isEuiSwitchChecked('indexPattern-nesting-switch');
},
/**
* Cen remove the dimension matching a specific test subject?
*/
async canRemoveDimension(dimensionTestSubj: string) {
await testSubjects.moveMouseTo(`${dimensionTestSubj} > indexPattern-dimension-remove`);
return await testSubjects.isDisplayed(`${dimensionTestSubj} > indexPattern-dimension-remove`);
},
/**
* Removes the dimension matching a specific test subject
*/
async removeDimension(dimensionTestSubj: string) {
await testSubjects.moveMouseTo(`${dimensionTestSubj} > indexPattern-dimension-remove`);
await testSubjects.click(`${dimensionTestSubj} > indexPattern-dimension-remove`);
},
/**
@ -1663,7 +1671,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
) {
const groupCapitalized = `${group[0].toUpperCase()}${group.slice(1).toLowerCase()}`;
const allFieldsForType = await find.allByCssSelector(
`[data-test-subj="lnsIndexPattern${groupCapitalized}Fields"] .unifiedFieldItemButton--${type}`
`[data-test-subj="lnsIndexPattern${groupCapitalized}Fields"] .unifiedFieldListItemButton--${type}`
);
// map to testSubjId
return Promise.all(allFieldsForType.map((el) => el.getAttribute('data-test-subj')));

View file

@ -1052,9 +1052,7 @@ export function TransformWizardProvider({ getService, getPageObjects }: FtrProvi
await retry.tryForTime(60 * 1000, async () => {
const allFields = await pageObjects.discover.getAllFieldNames();
if (Array.isArray(allFields)) {
// For some reasons, Discover returns fields with dot (e.g '.avg') with extra space
const fields = allFields.map((n) => n.replace('.', '.'));
expect(fields).to.contain(
expect(allFields).to.contain(
field,
`Expected Discover to contain field ${field}, got ${allFields.join()}`
);

View file

@ -292,7 +292,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await listingTable.searchForItemWithName('lnsXYvis');
await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis');
await PageObjects.lens.goToTimeRange();
await testSubjects.click('lnsXY_splitDimensionPanel > indexPattern-dimension-remove');
await PageObjects.lens.removeDimension('lnsXY_splitDimensionPanel');
await PageObjects.lens.switchToVisualization('line', termTranslator('line'));
await PageObjects.lens.configureDimension({
dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger',
@ -344,9 +344,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql(
longLabel
);
expect(
await testSubjects.isDisplayed('lnsXY_yDimensionPanel > indexPattern-dimension-remove')
).to.equal(true);
expect(await PageObjects.lens.canRemoveDimension('lnsXY_yDimensionPanel')).to.equal(true);
await PageObjects.lens.removeDimension('lnsXY_yDimensionPanel');
await testSubjects.missingOrFail('lnsXY_yDimensionPanel > lns-dimensionTrigger');
});