Drag between ANDs in timeline queries / add to timeline (#65228)

## Summary

This enhancement: 

- Enables drag and drop between `AND`s in a timeline query to narrow or widen a search

![drag-between-ANDs](https://user-images.githubusercontent.com/4459398/81045705-f457cf00-8e73-11ea-9df0-231a30e1bbd9.gif)

- Introduces a new `Add to timeline investigation` context menu action that automatically drags and drops any draggable to the timeline

![add-to-timeline](https://user-images.githubusercontent.com/4459398/81045745-08033580-8e74-11ea-9f3e-0e173f925aea.gif)

- When dragging while the timeline is closed, the timeline drop target automatically opens (and closes), making it possible to directly add to an existing `AND` (in addition to the original behavior, which added an `OR`)

![drag-directly-to-group](https://user-images.githubusercontent.com/4459398/81045882-5e707400-8e74-11ea-8c18-91399546214c.gif)

### Desk testing

Desk tested in:
- Chrome `81.0.4044.129`
- Firefox `75.0`
- Safari `13.1`
This commit is contained in:
Andrew Goldstein 2020-05-05 16:26:49 -06:00 committed by GitHub
parent ee270c7c3f
commit ecd16dcc71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1584 additions and 1351 deletions

View file

@ -49,7 +49,7 @@ describe('timeline data providers', () => {
.first()
.invoke('text')
.should(hostname => {
expect(dataProviderText).to.eq(`host.name: "${hostname}"`);
expect(dataProviderText).to.eq(`host.name: "${hostname}"AND`);
});
});
});

View file

@ -12,9 +12,15 @@ import { Dispatch } from 'redux';
import { BeforeCapture } from './drag_drop_context';
import { BrowserFields } from '../../containers/source';
import { dragAndDropModel, dragAndDropSelectors } from '../../store';
import { dragAndDropModel, dragAndDropSelectors, timelineSelectors } from '../../store';
import { IdToDataProvider } from '../../store/drag_and_drop/model';
import { State } from '../../store/reducer';
import { DataProvider } from '../timeline/data_providers/data_provider';
import { reArrangeProviders } from '../timeline/data_providers/helpers';
import { ACTIVE_TIMELINE_REDUX_ID } from '../top_n';
import { ADDED_TO_TIMELINE_MESSAGE } from '../../hooks/translations';
import { useAddToTimelineSensor } from '../../hooks/use_add_to_timeline';
import { displaySuccessToast, useStateToaster } from '../toasters';
import {
addFieldToTimelineColumns,
@ -23,8 +29,8 @@ import {
IS_DRAGGING_CLASS_NAME,
IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME,
providerWasDroppedOnTimeline,
providerWasDroppedOnTimelineButton,
draggableIsField,
userIsReArrangingProviders,
} from './helpers';
// @ts-ignore
@ -37,58 +43,92 @@ interface Props {
}
interface OnDragEndHandlerParams {
activeTimelineDataProviders: DataProvider[];
browserFields: BrowserFields;
dataProviders: IdToDataProvider;
dispatch: Dispatch;
onAddedToTimeline: (fieldOrValue: string) => void;
result: DropResult;
}
const onDragEndHandler = ({
activeTimelineDataProviders,
browserFields,
dataProviders,
dispatch,
onAddedToTimeline,
result,
}: OnDragEndHandlerParams) => {
if (providerWasDroppedOnTimeline(result)) {
addProviderToTimeline({ dataProviders, result, dispatch });
} else if (providerWasDroppedOnTimelineButton(result)) {
addProviderToTimeline({ dataProviders, result, dispatch });
if (userIsReArrangingProviders(result)) {
reArrangeProviders({
dataProviders: activeTimelineDataProviders,
destination: result.destination,
dispatch,
source: result.source,
timelineId: ACTIVE_TIMELINE_REDUX_ID,
});
} else if (providerWasDroppedOnTimeline(result)) {
addProviderToTimeline({
activeTimelineDataProviders,
dataProviders,
dispatch,
onAddedToTimeline,
result,
timelineId: ACTIVE_TIMELINE_REDUX_ID,
});
} else if (fieldWasDroppedOnTimelineColumns(result)) {
addFieldToTimelineColumns({ browserFields, dispatch, result });
addFieldToTimelineColumns({
browserFields,
dispatch,
result,
timelineId: ACTIVE_TIMELINE_REDUX_ID,
});
}
};
const sensors = [useAddToTimelineSensor];
/**
* DragDropContextWrapperComponent handles all drag end events
*/
export const DragDropContextWrapperComponent = React.memo<Props & PropsFromRedux>(
({ browserFields, children, dataProviders, dispatch }) => {
({ activeTimelineDataProviders, browserFields, children, dataProviders, dispatch }) => {
const [, dispatchToaster] = useStateToaster();
const onAddedToTimeline = useCallback(
(fieldOrValue: string) => {
displaySuccessToast(ADDED_TO_TIMELINE_MESSAGE(fieldOrValue), dispatchToaster);
},
[dispatchToaster]
);
const onDragEnd = useCallback(
(result: DropResult) => {
enableScrolling();
try {
enableScrolling();
if (dataProviders != null) {
onDragEndHandler({
browserFields,
result,
dataProviders,
dispatch,
});
}
if (!draggableIsField(result)) {
if (dataProviders != null) {
onDragEndHandler({
activeTimelineDataProviders,
browserFields,
dataProviders,
dispatch,
onAddedToTimeline,
result,
});
}
} finally {
document.body.classList.remove(IS_DRAGGING_CLASS_NAME);
}
if (draggableIsField(result)) {
document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME);
if (draggableIsField(result)) {
document.body.classList.remove(IS_TIMELINE_FIELD_DRAGGING_CLASS_NAME);
}
}
},
[browserFields, dataProviders]
[dataProviders, activeTimelineDataProviders, browserFields]
);
return (
// @ts-ignore
<DragDropContext onDragEnd={onDragEnd} onBeforeCapture={onBeforeCapture}>
<DragDropContext onDragEnd={onDragEnd} onBeforeCapture={onBeforeCapture} sensors={sensors}>
{children}
</DragDropContext>
);
@ -96,7 +136,8 @@ export const DragDropContextWrapperComponent = React.memo<Props & PropsFromRedux
(prevProps, nextProps) => {
return (
prevProps.children === nextProps.children &&
prevProps.dataProviders === nextProps.dataProviders
prevProps.dataProviders === nextProps.dataProviders &&
prevProps.activeTimelineDataProviders === nextProps.activeTimelineDataProviders
); // prevent re-renders when data providers are added or removed, but all other props are the same
}
);
@ -104,11 +145,15 @@ export const DragDropContextWrapperComponent = React.memo<Props & PropsFromRedux
DragDropContextWrapperComponent.displayName = 'DragDropContextWrapperComponent';
const emptyDataProviders: dragAndDropModel.IdToDataProvider = {}; // stable reference
const emptyActiveTimelineDataProviders: DataProvider[] = []; // stable reference
const mapStateToProps = (state: State) => {
const activeTimelineDataProviders =
timelineSelectors.getTimelineByIdSelector()(state, ACTIVE_TIMELINE_REDUX_ID)?.dataProviders ??
emptyActiveTimelineDataProviders;
const dataProviders = dragAndDropSelectors.dataProvidersSelector(state) ?? emptyDataProviders;
return { dataProviders };
return { activeTimelineDataProviders, dataProviders };
};
const connector = connect(mapStateToProps);

View file

@ -132,6 +132,7 @@ export const DraggableWrapper = React.memo<Props>(
const hoverContent = useMemo(
() => (
<DraggableWrapperHoverContent
draggableId={getDraggableId(dataProvider.id)}
field={dataProvider.queryMatch.field}
onFilterAdded={onFilterAdded}
showTopN={showTopN}

View file

@ -6,8 +6,10 @@
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { DraggableId } from 'react-beautiful-dnd';
import { getAllFieldsByName, WithSource } from '../../containers/source';
import { useAddToTimeline } from '../../hooks/use_add_to_timeline';
import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard';
import { useKibana } from '../../lib/kibana';
import { createFilter } from '../page/add_filter_to_global_search_bar';
@ -18,6 +20,7 @@ import { allowTopN } from './helpers';
import * as i18n from './translations';
interface Props {
draggableId?: DraggableId;
field: string;
onFilterAdded?: () => void;
showTopN: boolean;
@ -26,12 +29,14 @@ interface Props {
}
const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
draggableId,
field,
onFilterAdded,
showTopN,
toggleTopN,
value,
}) => {
const startDragToTimeline = useAddToTimeline({ draggableId, fieldName: field });
const kibana = useKibana();
const { filterManager: timelineFilterManager } = useTimelineContext();
const filterManager = useMemo(() => kibana.services.data.query.filterManager, [
@ -92,6 +97,18 @@ const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
</EuiToolTip>
)}
{!showTopN && value != null && draggableId != null && (
<EuiToolTip content={i18n.ADD_TO_TIMELINE}>
<EuiButtonIcon
aria-label={i18n.ADD_TO_TIMELINE}
color="text"
data-test-subj="add-to-timeline"
iconType="timeline"
onClick={startDragToTimeline}
/>
</EuiToolTip>
)}
<WithSource sourceId="default">
{({ browserFields }) => (
<>

View file

@ -28,7 +28,6 @@ import {
getDroppableId,
getFieldIdFromDraggable,
getProviderIdFromDraggable,
getTimelineIdFromDestination,
providerWasDroppedOnTimeline,
reasonIsDrop,
sourceIsContent,
@ -381,100 +380,6 @@ describe('helpers', () => {
});
});
describe('#getTimelineIdFromDestination', () => {
test('it returns returns the timeline id from the destination when it is a provider', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_PROVIDERS,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('timeline');
});
test('it returns returns the timeline id from the destination when the destination is timeline columns', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: DROPPABLE_ID_TIMELINE_COLUMNS,
index: 0,
},
draggableId: getDraggableFieldId({ contextId: 'test', fieldId: 'event.action' }),
reason: 'DROP',
source: {
droppableId: 'fake.source.droppable.id',
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('timeline-1');
});
test('it returns returns the timeline id from the destination when it is a button', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: `${droppableTimelineFlyoutButtonPrefix}timeline`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('timeline');
});
test('it returns returns an empty string when the destination is undefined', () => {
expect(
getTimelineIdFromDestination({
destination: undefined,
draggableId: `${draggableIdPrefix}.timeline.timeline.dataProvider.685260508808089`,
reason: 'DROP',
source: {
droppableId: `${droppableIdPrefix}.timelineProviders.timeline`,
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('');
});
test('it returns returns an empty string when the destination is not a timeline', () => {
expect(
getTimelineIdFromDestination({
destination: {
droppableId: `${droppableIdPrefix}.somewhere.else`,
index: 0,
},
draggableId: getDraggableId('685260508808089'),
reason: 'DROP',
source: {
droppableId: getDroppableId('685260508808089'),
index: 0,
},
type: 'DEFAULT',
mode: 'FLUID',
})
).toEqual('');
});
});
describe('#getProviderIdFromDraggable', () => {
test('it returns the expected id', () => {
const id = getProviderIdFromDraggable({

View file

@ -10,12 +10,12 @@ import { Dispatch } from 'redux';
import { ActionCreator } from 'typescript-fsa';
import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source';
import { dragAndDropActions, timelineActions } from '../../store/actions';
import { IdToDataProvider } from '../../store/drag_and_drop/model';
import { ColumnHeaderOptions } from '../../store/timeline/model';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants';
import { DataProvider } from '../timeline/data_providers/data_provider';
import { dragAndDropActions, timelineActions } from '../../store/actions';
import { addContentToTimeline } from '../timeline/data_providers/helpers';
export const draggableIdPrefix = 'draggableId';
@ -23,6 +23,8 @@ export const droppableIdPrefix = 'droppableId';
export const draggableContentPrefix = `${draggableIdPrefix}.content.`;
export const draggableTimelineProvidersPrefix = `${draggableIdPrefix}.timelineProviders.`;
export const draggableFieldPrefix = `${draggableIdPrefix}.field.`;
export const droppableContentPrefix = `${droppableIdPrefix}.content.`;
@ -46,12 +48,43 @@ export const getDraggableFieldId = ({
fieldId: string;
}): string => `${draggableFieldPrefix}${escapeContextId(contextId)}.${escapeFieldId(fieldId)}`;
export const getTimelineProviderDroppableId = ({
groupIndex,
timelineId,
}: {
groupIndex: number;
timelineId: string;
}): string => `${droppableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}`;
export const getTimelineProviderDraggableId = ({
dataProviderId,
groupIndex,
timelineId,
}: {
dataProviderId: string;
groupIndex: number;
timelineId: string;
}): string =>
`${draggableTimelineProvidersPrefix}${timelineId}.group.${groupIndex}.${dataProviderId}`;
export const getDroppableId = (visualizationPlaceholderId: string): string =>
`${droppableContentPrefix}${visualizationPlaceholderId}`;
export const sourceIsContent = (result: DropResult): boolean =>
result.source.droppableId.startsWith(droppableContentPrefix);
export const sourceAndDestinationAreSameTimelineProviders = (result: DropResult): boolean => {
const regex = /^droppableId\.timelineProviders\.(\S+)\./;
const sourceMatches = result.source.droppableId.match(regex) ?? [];
const destinationMatches = result.destination?.droppableId.match(regex) ?? [];
return (
sourceMatches.length >= 2 &&
destinationMatches.length >= 2 &&
sourceMatches[1] === destinationMatches[1]
);
};
export const draggableIsContent = (result: DropResult | { draggableId: string }): boolean =>
result.draggableId.startsWith(draggableContentPrefix);
@ -72,14 +105,6 @@ export const destinationIsTimelineButton = (result: DropResult): boolean =>
result.destination != null &&
result.destination.droppableId.startsWith(droppableTimelineFlyoutButtonPrefix);
export const getTimelineIdFromDestination = (result: DropResult): string =>
result.destination != null &&
(destinationIsTimelineProviders(result) ||
destinationIsTimelineButton(result) ||
destinationIsTimelineColumns(result))
? result.destination.droppableId.substring(result.destination.droppableId.lastIndexOf('.') + 1)
: '';
export const getProviderIdFromDraggable = (result: DropResult): string =>
result.draggableId.substring(result.draggableId.lastIndexOf('.') + 1);
@ -100,26 +125,22 @@ export const providerWasDroppedOnTimeline = (result: DropResult): boolean =>
sourceIsContent(result) &&
destinationIsTimelineProviders(result);
export const userIsReArrangingProviders = (result: DropResult): boolean =>
reasonIsDrop(result) && sourceAndDestinationAreSameTimelineProviders(result);
export const fieldWasDroppedOnTimelineColumns = (result: DropResult): boolean =>
reasonIsDrop(result) && draggableIsField(result) && destinationIsTimelineColumns(result);
export const providerWasDroppedOnTimelineButton = (result: DropResult): boolean =>
reasonIsDrop(result) &&
draggableIsContent(result) &&
sourceIsContent(result) &&
destinationIsTimelineButton(result);
interface AddProviderToTimelineParams {
activeTimelineDataProviders: DataProvider[];
dataProviders: IdToDataProvider;
result: DropResult;
dispatch: Dispatch;
addProvider?: ActionCreator<{
id: string;
provider: DataProvider;
}>;
noProviderFound?: ActionCreator<{
id: string;
}>;
onAddedToTimeline: (fieldOrValue: string) => void;
result: DropResult;
timelineId: string;
}
interface AddFieldToTimelineColumnsParams {
@ -131,21 +152,30 @@ interface AddFieldToTimelineColumnsParams {
browserFields: BrowserFields;
dispatch: Dispatch;
result: DropResult;
timelineId: string;
}
export const addProviderToTimeline = ({
activeTimelineDataProviders,
dataProviders,
result,
dispatch,
addProvider = timelineActions.addProvider,
result,
timelineId,
noProviderFound = dragAndDropActions.noProviderFound,
onAddedToTimeline,
}: AddProviderToTimelineParams): void => {
const timeline = getTimelineIdFromDestination(result);
const providerId = getProviderIdFromDraggable(result);
const provider = dataProviders[providerId];
const providerToAdd = dataProviders[providerId];
if (provider) {
dispatch(addProvider({ id: timeline, provider }));
if (providerToAdd) {
addContentToTimeline({
dataProviders: activeTimelineDataProviders,
destination: result.destination,
dispatch,
onAddedToTimeline,
providerToAdd,
timelineId,
});
} else {
dispatch(noProviderFound({ id: providerId }));
}
@ -156,8 +186,8 @@ export const addFieldToTimelineColumns = ({
browserFields,
dispatch,
result,
timelineId,
}: AddFieldToTimelineColumnsParams): void => {
const timeline = getTimelineIdFromDestination(result);
const fieldId = getFieldIdFromDraggable(result);
const allColumns = getAllFieldsByName(browserFields);
const column = allColumns[fieldId];
@ -175,7 +205,7 @@ export const addFieldToTimelineColumns = ({
aggregatable: column.aggregatable,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
id: timeline,
id: timelineId,
index: result.destination != null ? result.destination.index : 0,
})
);
@ -188,34 +218,13 @@ export const addFieldToTimelineColumns = ({
id: fieldId,
width: DEFAULT_COLUMN_MIN_WIDTH,
},
id: timeline,
id: timelineId,
index: result.destination != null ? result.destination.index : 0,
})
);
}
};
interface ShowTimelineParams {
result: DropResult;
show: boolean;
dispatch: Dispatch;
showTimeline?: ActionCreator<{
id: string;
show: boolean;
}>;
}
export const updateShowTimeline = ({
result,
show,
dispatch,
showTimeline = timelineActions.showTimeline,
}: ShowTimelineParams): void => {
const timeline = getTimelineIdFromDestination(result);
dispatch(showTimeline({ id: timeline, show }));
};
/**
* Prevents fields from being dragged or dropped to any area other than column
* header drop zone in the timeline

View file

@ -6,6 +6,10 @@
import { i18n } from '@kbn/i18n';
export const ADD_TO_TIMELINE = i18n.translate('xpack.siem.dragAndDrop.addToTimeline', {
defaultMessage: 'Add to timeline investigation',
});
export const COPY_TO_CLIPBOARD = i18n.translate('xpack.siem.dragAndDrop.copyToClipboardTooltip', {
defaultMessage: 'Copy to Clipboard',
});

View file

@ -4,67 +4,48 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiNotificationBadge, EuiIcon, EuiButton } from '@elastic/eui';
import { noop } from 'lodash/fp';
import { EuiButton, EuiNotificationBadge, EuiPanel } from '@elastic/eui';
import { rgba } from 'polished';
import React from 'react';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { DroppableWrapper } from '../../drag_and_drop/droppable_wrapper';
import {
droppableTimelineFlyoutButtonPrefix,
IS_DRAGGING_CLASS_NAME,
} from '../../drag_and_drop/helpers';
import { WithSource } from '../../../containers/source';
import { IS_DRAGGING_CLASS_NAME } from '../../drag_and_drop/helpers';
import { DataProviders } from '../../timeline/data_providers';
import { DataProvider } from '../../timeline/data_providers/data_provider';
import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers';
import * as i18n from './translations';
export const NOT_READY_TO_DROP_CLASS_NAME = 'not-ready-to-drop';
export const READY_TO_DROP_CLASS_NAME = 'ready-to-drop';
export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button';
export const getBadgeCount = (dataProviders: DataProvider[]): number =>
flattenIntoAndGroups(dataProviders).reduce((total, group) => total + group.length, 0);
const SHOW_HIDE_TRANSLATE_X = 497; // px
const Container = styled.div`
overflow-x: auto;
overflow-y: hidden;
padding-top: 8px;
position: fixed;
right: 0px;
top: 40%;
right: -51px;
z-index: ${({ theme }) => theme.eui.euiZLevel9};
transform: rotate(-90deg);
transform: translateX(${SHOW_HIDE_TRANSLATE_X}px);
user-select: none;
width: 500px;
z-index: ${({ theme }) => theme.eui.euiZLevel9};
button {
border-radius: 4px 4px 0 0;
box-shadow: none;
height: 46px;
margin: 1px 0 1px 1px;
width: 136px;
}
.euiButton:hover:not(:disabled) {
.${IS_DRAGGING_CLASS_NAME} & {
transform: none;
}
.euiButton--primary:enabled {
background: ${({ theme }) => theme.eui.euiColorEmptyShade};
.${FLYOUT_BUTTON_CLASS_NAME} {
border-radius: 4px 4px 0 0;
box-shadow: none;
height: 46px;
}
.euiButton--primary:enabled:hover,
.euiButton--primary:enabled:focus {
animation: none;
background: ${({ theme }) => theme.eui.euiColorEmptyShade};
box-shadow: none;
}
.${IS_DRAGGING_CLASS_NAME} & .${NOT_READY_TO_DROP_CLASS_NAME} {
color: ${({ theme }) => theme.eui.euiColorSuccess};
background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.1)} !important;
border: 1px solid ${({ theme }) => theme.eui.euiColorSuccess};
border-bottom: none;
text-decoration: none;
}
.${READY_TO_DROP_CLASS_NAME} {
.${IS_DRAGGING_CLASS_NAME} & .${FLYOUT_BUTTON_CLASS_NAME} {
color: ${({ theme }) => theme.eui.euiColorSuccess};
background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important;
border: 1px solid ${({ theme }) => theme.eui.euiColorSuccess};
@ -79,10 +60,21 @@ const BadgeButtonContainer = styled.div`
align-items: flex-start;
display: flex;
flex-direction: row;
left: -87px;
position: absolute;
top: 34px;
transform: rotate(-90deg);
`;
BadgeButtonContainer.displayName = 'BadgeButtonContainer';
const DataProvidersPanel = styled(EuiPanel)`
border-radius: 0;
padding: 0 4px 0 4px;
user-select: none;
z-index: ${({ theme }) => theme.eui.euiZLevel9};
`;
interface FlyoutButtonProps {
dataProviders: DataProvider[];
onOpen: () => void;
@ -91,57 +83,63 @@ interface FlyoutButtonProps {
}
export const FlyoutButton = React.memo<FlyoutButtonProps>(
({ onOpen, show, dataProviders, timelineId }) =>
show ? (
<Container onClick={onOpen}>
<DroppableWrapper
data-test-subj="flyout-droppable-wrapper"
droppableId={`${droppableTimelineFlyoutButtonPrefix}${timelineId}`}
render={({ isDraggingOver }) => (
<BadgeButtonContainer
className="flyout-overlay"
data-test-subj="flyoutOverlay"
onClick={onOpen}
>
{!isDraggingOver ? (
<EuiButton
className={NOT_READY_TO_DROP_CLASS_NAME}
data-test-subj="flyout-button-not-ready-to-drop"
fill={false}
iconSide="right"
iconType="arrowUp"
>
{i18n.FLYOUT_BUTTON}
</EuiButton>
) : (
<EuiButton
className={READY_TO_DROP_CLASS_NAME}
data-test-subj="flyout-button-ready-to-drop"
fill={false}
>
<EuiIcon data-test-subj="flyout-button-plus-icon" type="plusInCircleFilled" />
</EuiButton>
)}
({ onOpen, show, dataProviders, timelineId }) => {
const badgeCount = useMemo(() => getBadgeCount(dataProviders), [dataProviders]);
<EuiNotificationBadge
color="accent"
data-test-subj="badge"
style={{
left: '-9px',
position: 'relative',
top: '-6px',
transform: 'rotate(90deg)',
visibility: dataProviders.length !== 0 ? 'inherit' : 'hidden',
zIndex: 10,
}}
>
{dataProviders.length}
</EuiNotificationBadge>
</BadgeButtonContainer>
)}
/>
if (!show) {
return null;
}
return (
<Container>
<BadgeButtonContainer
className="flyout-overlay"
data-test-subj="flyoutOverlay"
onClick={onOpen}
>
<EuiButton
className={FLYOUT_BUTTON_CLASS_NAME}
data-test-subj="flyout-button-not-ready-to-drop"
fill={false}
iconSide="right"
iconType="arrowUp"
>
{i18n.FLYOUT_BUTTON}
</EuiButton>
<EuiNotificationBadge
color="accent"
data-test-subj="badge"
style={{
left: '-9px',
position: 'relative',
top: '-6px',
transform: 'rotate(90deg)',
visibility: dataProviders.length !== 0 ? 'inherit' : 'hidden',
zIndex: 10,
}}
>
{badgeCount}
</EuiNotificationBadge>
</BadgeButtonContainer>
<DataProvidersPanel paddingSize="none">
<WithSource sourceId="default">
{({ browserFields }) => (
<DataProviders
browserFields={browserFields}
id={timelineId}
dataProviders={dataProviders}
onDataProviderEdited={noop}
onDataProviderRemoved={noop}
onToggleDataProviderEnabled={noop}
onToggleDataProviderExcluded={noop}
show={show}
/>
)}
</WithSource>
</DataProvidersPanel>
</Container>
) : null,
);
},
(prevProps, nextProps) =>
prevProps.show === nextProps.show &&
prevProps.dataProviders === nextProps.dataProviders &&

View file

@ -722,8 +722,6 @@ exports[`Timeline rendering renders correctly against snapshot 1`] = `
"title": "filebeat-*,auditbeat-*,packetbeat-*",
}
}
onChangeDataProviderKqlQuery={[MockFunction]}
onChangeDroppableAndProvider={[MockFunction]}
onDataProviderEdited={[MockFunction]}
onDataProviderRemoved={[MockFunction]}
onToggleDataProviderEnabled={[MockFunction]}

View file

@ -14,7 +14,9 @@ exports[`Empty rendering renders correctly against snapshot 1`] = `
Drop anything
</Text>
<HighlightedBackground>
<BadgeHighlighted>
<BadgeHighlighted
className="highlighted-drop-target"
>
highlighted
</BadgeHighlighted>
</HighlightedBackground>

View file

@ -26,8 +26,6 @@ describe('DataProviders', () => {
browserFields={{}}
id="foo"
dataProviders={mockDataProviders}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -47,29 +45,6 @@ describe('DataProviders', () => {
browserFields={{}}
id="foo"
dataProviders={dataProviders}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
onToggleDataProviderExcluded={jest.fn()}
show={true}
/>
</TestProviders>
);
dropMessage.forEach(word => expect(wrapper.text()).toContain(word));
});
test('it should STILL render a placeholder given a non-empty collection of data providers', () => {
const wrapper = mount(
<TestProviders>
<DataProviders
browserFields={{}}
id="foo"
dataProviders={mockDataProviders}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -89,8 +64,6 @@ describe('DataProviders', () => {
browserFields={{}}
id="foo"
dataProviders={mockDataProviders}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}

View file

@ -12,6 +12,8 @@ import { AndOrBadge } from '../../and_or_badge';
import * as i18n from './translations';
export const HIGHLIGHTED_DROP_TARGET_CLASS_NAME = 'highlighted-drop-target';
const Text = styled(EuiText)`
overflow: hidden;
margin: 5px 0 5px 0;
@ -88,7 +90,9 @@ export const Empty = React.memo<Props>(({ showSmallMsg = false }) => (
{i18n.DROP_ANYTHING}
</Text>
<HighlightedBackground>
<BadgeHighlighted>{i18n.HIGHLIGHTED}</BadgeHighlighted>
<BadgeHighlighted className={HIGHLIGHTED_DROP_TARGET_CLASS_NAME}>
{i18n.HIGHLIGHTED}
</BadgeHighlighted>
</HighlightedBackground>
</NoWrap>

View file

@ -0,0 +1,334 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { omit } from 'lodash/fp';
import { DraggableLocation } from 'react-beautiful-dnd';
import { Dispatch } from 'redux';
import { updateProviders } from '../../../store/timeline/actions';
import { DataProvider, DataProvidersAnd } from './data_provider';
export const omitAnd = (provider: DataProvider): DataProvidersAnd => omit('and', provider);
export const reorder = (
group: DataProvidersAnd[],
startIndex: number,
endIndex: number
): DataProvidersAnd[] => {
const groupClone = [...group];
const [removed] = groupClone.splice(startIndex, 1); // ⚠️ mutation
groupClone.splice(endIndex, 0, removed); // ⚠️ mutation
return groupClone;
};
export const move = ({
destinationGroup,
moveProviderFromSourceIndex,
moveProviderToDestinationIndex,
sourceGroup,
}: {
destinationGroup: DataProvidersAnd[];
moveProviderFromSourceIndex: number;
moveProviderToDestinationIndex: number;
sourceGroup: DataProvidersAnd[];
}): {
updatedDestinationGroup: DataProvidersAnd[];
updatedSourceGroup: DataProvidersAnd[];
} => {
const sourceClone = [...sourceGroup];
const destinationClone = [...destinationGroup];
const [removed] = sourceClone.splice(moveProviderFromSourceIndex, 1); // ⚠️ mutation
destinationClone.splice(moveProviderToDestinationIndex, 0, removed); // ⚠️ mutation
const deDuplicatedDestinationGroup = destinationClone.filter((provider, i) =>
provider.id === removed.id && i !== moveProviderToDestinationIndex ? false : true
);
return {
updatedDestinationGroup: deDuplicatedDestinationGroup,
updatedSourceGroup: sourceClone,
};
};
export const isValidDestination = (
destination: DraggableLocation | undefined
): destination is DraggableLocation => destination != null;
export const sourceAndDestinationAreSameDroppable = ({
destination,
source,
}: {
destination: DraggableLocation;
source: DraggableLocation;
}): boolean => source.droppableId === destination.droppableId;
export const flattenIntoAndGroups = (dataProviders: DataProvider[]): DataProvidersAnd[][] =>
dataProviders.reduce<DataProvidersAnd[][]>(
(acc, provider) => [...acc, [omitAnd(provider), ...provider.and]],
[]
);
export const reArrangeProvidersInSameGroup = ({
dataProviderGroups,
destination,
dispatch,
source,
timelineId,
}: {
dataProviderGroups: DataProvidersAnd[][];
destination: DraggableLocation;
dispatch: Dispatch;
source: DraggableLocation;
timelineId: string;
}) => {
const groupIndex = getGroupIndexFromDroppableId(source.droppableId);
if (
indexIsValid({
index: groupIndex,
dataProviderGroups,
})
) {
const reorderedGroup = reorder(dataProviderGroups[groupIndex], source.index, destination.index);
const updatedGroups = dataProviderGroups.reduce<DataProvidersAnd[][]>(
(acc, group, i) => [...acc, i === groupIndex ? [...reorderedGroup] : [...group]],
[]
);
dispatch(
updateProviders({
id: timelineId,
providers: unFlattenGroups(updatedGroups.filter(g => g.length)),
})
);
}
};
export const getGroupIndexFromDroppableId = (droppableId: string): number =>
Number(droppableId.substring(droppableId.lastIndexOf('.') + 1));
export const indexIsValid = ({
index,
dataProviderGroups,
}: {
index: number;
dataProviderGroups: DataProvidersAnd[][];
}): boolean => index >= 0 && index < dataProviderGroups.length;
export const convertDataProviderAnd = (dataProvidersAnd: DataProvidersAnd): DataProvider => ({
...dataProvidersAnd,
and: [],
});
export const unFlattenGroups = (groups: DataProvidersAnd[][]): DataProvider[] =>
groups.reduce<DataProvider[]>((acc, group) => [...acc, { ...group[0], and: group.slice(1) }], []);
export const moveProvidersBetweenGroups = ({
dataProviderGroups,
destination,
dispatch,
source,
timelineId,
}: {
dataProviderGroups: DataProvidersAnd[][];
destination: DraggableLocation;
dispatch: Dispatch;
source: DraggableLocation;
timelineId: string;
}) => {
const sourceGroupIndex = getGroupIndexFromDroppableId(source.droppableId);
const destinationGroupIndex = getGroupIndexFromDroppableId(destination.droppableId);
if (
indexIsValid({
index: sourceGroupIndex,
dataProviderGroups,
}) &&
indexIsValid({
index: destinationGroupIndex,
dataProviderGroups,
})
) {
const sourceGroup = dataProviderGroups[sourceGroupIndex];
const destinationGroup = dataProviderGroups[destinationGroupIndex];
const moveProviderFromSourceIndex = source.index;
const moveProviderToDestinationIndex = destination.index;
const { updatedDestinationGroup, updatedSourceGroup } = move({
destinationGroup,
moveProviderFromSourceIndex,
moveProviderToDestinationIndex,
sourceGroup,
});
const updatedGroups = dataProviderGroups.reduce<DataProvidersAnd[][]>(
(acc, group, i) => [
...acc,
i === sourceGroupIndex
? [...updatedSourceGroup]
: i === destinationGroupIndex
? [...updatedDestinationGroup]
: [...group],
],
[]
);
dispatch(
updateProviders({
id: timelineId,
providers: unFlattenGroups(updatedGroups.filter(g => g.length)),
})
);
}
};
export const addProviderToEmptyTimeline = ({
dispatch,
onAddedToTimeline,
providerToAdd,
timelineId,
}: {
dispatch: Dispatch;
onAddedToTimeline: (fieldOrValue: string) => void;
providerToAdd: DataProvider;
timelineId: string;
}) => {
dispatch(
updateProviders({
id: timelineId,
providers: [providerToAdd],
})
);
onAddedToTimeline(providerToAdd.name);
};
/** Rendered as a constant drop target for creating a new OR group */
export const EMPTY_GROUP: DataProvidersAnd[][] = [[]];
export const reArrangeProviders = ({
dataProviders,
destination,
dispatch,
source,
timelineId,
}: {
dataProviders: DataProvider[];
destination: DraggableLocation | undefined;
dispatch: Dispatch;
source: DraggableLocation;
timelineId: string;
}) => {
if (!isValidDestination(destination)) {
return;
}
const dataProviderGroups = [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP];
if (sourceAndDestinationAreSameDroppable({ source, destination })) {
reArrangeProvidersInSameGroup({
dataProviderGroups,
destination,
dispatch,
source,
timelineId,
});
} else {
moveProvidersBetweenGroups({
dataProviderGroups,
destination,
dispatch,
source,
timelineId,
});
}
};
export const addProviderToGroup = ({
dataProviders,
destination,
dispatch,
onAddedToTimeline,
providerToAdd,
timelineId,
}: {
dataProviders: DataProvider[];
destination: DraggableLocation | undefined;
dispatch: Dispatch;
onAddedToTimeline: (fieldOrValue: string) => void;
providerToAdd: DataProvider;
timelineId: string;
}) => {
const dataProviderGroups = [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP];
if (!isValidDestination(destination)) {
return;
}
const destinationGroupIndex = getGroupIndexFromDroppableId(destination.droppableId);
if (
indexIsValid({
index: destinationGroupIndex,
dataProviderGroups,
})
) {
const destinationGroup = dataProviderGroups[destinationGroupIndex];
const destinationClone = [...destinationGroup];
destinationClone.splice(destination.index, 0, omitAnd(providerToAdd)); // ⚠️ mutation
const deDuplicatedGroup = destinationClone.filter((provider, i) =>
provider.id === providerToAdd.id && i !== destination.index ? false : true
);
const updatedGroups = dataProviderGroups.reduce<DataProvidersAnd[][]>(
(acc, group, i) => [
...acc,
i === destinationGroupIndex ? [...deDuplicatedGroup] : [...group],
],
[]
);
dispatch(
updateProviders({
id: timelineId,
providers: unFlattenGroups(updatedGroups.filter(g => g.length)),
})
);
onAddedToTimeline(providerToAdd.name);
}
};
export const addContentToTimeline = ({
dataProviders,
destination,
dispatch,
onAddedToTimeline,
providerToAdd,
timelineId,
}: {
dataProviders: DataProvider[];
destination: DraggableLocation | undefined;
dispatch: Dispatch;
onAddedToTimeline: (fieldOrValue: string) => void;
providerToAdd: DataProvider;
timelineId: string;
}) => {
if (dataProviders.length === 0) {
addProviderToEmptyTimeline({ dispatch, onAddedToTimeline, providerToAdd, timelineId });
} else {
addProviderToGroup({
dataProviders,
destination,
dispatch,
onAddedToTimeline,
providerToAdd,
timelineId,
});
}
};

View file

@ -15,8 +15,6 @@ import {
IS_DRAGGING_CLASS_NAME,
} from '../../drag_and_drop/helpers';
import {
OnChangeDataProviderKqlQuery,
OnChangeDroppableAndProvider,
OnDataProviderEdited,
OnDataProviderRemoved,
OnToggleDataProviderEnabled,
@ -32,8 +30,6 @@ interface Props {
browserFields: BrowserFields;
id: string;
dataProviders: DataProvider[];
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
onDataProviderEdited: OnDataProviderEdited;
onDataProviderRemoved: OnDataProviderRemoved;
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
@ -42,6 +38,8 @@ interface Props {
}
const DropTargetDataProvidersContainer = styled.div`
padding: 2px 0 4px 0;
.${IS_DRAGGING_CLASS_NAME} & .drop-target-data-providers {
background: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.1)};
border: 0.2rem dashed ${({ theme }) => theme.eui.euiColorSuccess};
@ -60,9 +58,6 @@ const DropTargetDataProviders = styled.div`
position: relative;
border: 0.2rem dashed ${props => props.theme.eui.euiColorMediumShade};
border-radius: 5px;
display: flex;
flex-direction: column;
justify-content: center;
margin: 5px 0 5px 0;
min-height: 100px;
overflow-y: auto;
@ -95,43 +90,46 @@ export const DataProviders = React.memo<Props>(
browserFields,
id,
dataProviders,
onChangeDataProviderKqlQuery,
onChangeDroppableAndProvider,
onDataProviderEdited,
onDataProviderRemoved,
onToggleDataProviderEnabled,
onToggleDataProviderExcluded,
show,
}) => (
<DropTargetDataProvidersContainer className="drop-target-data-providers-container">
<DropTargetDataProviders
className="drop-target-data-providers"
data-test-subj="dataProviders"
>
<TimelineContext.Consumer>
{({ isLoading }) => (
<DroppableWrapper isDropDisabled={!show || isLoading} droppableId={getDroppableId(id)}>
{dataProviders != null && dataProviders.length ? (
<Providers
browserFields={browserFields}
id={id}
dataProviders={dataProviders}
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
/>
) : (
<Empty />
)}
</DroppableWrapper>
)}
</TimelineContext.Consumer>
</DropTargetDataProviders>
</DropTargetDataProvidersContainer>
)
}) => {
return (
<DropTargetDataProvidersContainer className="drop-target-data-providers-container">
<DropTargetDataProviders
className="drop-target-data-providers"
data-test-subj="dataProviders"
>
<TimelineContext.Consumer>
{({ isLoading }) => (
<>
{dataProviders != null && dataProviders.length ? (
<Providers
browserFields={browserFields}
id={id}
dataProviders={dataProviders}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
/>
) : (
<DroppableWrapper
isDropDisabled={!show || isLoading}
droppableId={getDroppableId(id)}
>
<Empty />
</DroppableWrapper>
)}
</>
)}
</TimelineContext.Consumer>
</DropTargetDataProviders>
</DropTargetDataProvidersContainer>
);
}
);
DataProviders.displayName = 'DataProviders';

View file

@ -10,9 +10,8 @@ import { isString } from 'lodash/fp';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { ProviderContainer } from '../../drag_and_drop/provider_container';
import { getEmptyString } from '../../empty_value';
import { WithCopyToClipboard } from '../../../lib/clipboard/with_copy_to_clipboard';
import { WithHoverActions } from '../../with_hover_actions';
import { EXISTS_OPERATOR, QueryOperator } from './data_provider';
@ -94,26 +93,13 @@ export const ProviderBadge = React.memo<ProviderBadgeProps>(
const prefix = useMemo(() => (isExcluded ? <span>{i18n.NOT} </span> : null), [isExcluded]);
const title = useMemo(() => `${field}: "${formattedValue}"`, [field, formattedValue]);
const hoverContent = useMemo(
() => (
<WithCopyToClipboard
data-test-subj="copy-to-clipboard"
text={`${field} : ${typeof val === 'string' ? `"${val}"` : `${val}`}`}
titleSummary={i18n.FIELD}
/>
),
[field, val]
);
const badge = useCallback(
() => (
return (
<ProviderContainer>
<ProviderBadgeStyled
id={`${providerId}-${field}-${val}`}
className={classes}
color="hollow"
title={title}
title=""
iconOnClick={deleteFilter}
iconOnClickAriaLabel={i18n.REMOVE_DATA_PROVIDER}
iconType="cross"
@ -135,23 +121,8 @@ export const ProviderBadge = React.memo<ProviderBadgeProps>(
</span>
)}
</ProviderBadgeStyled>
),
[
providerId,
field,
val,
classes,
title,
deleteFilter,
togglePopover,
formattedValue,
closeButtonProps,
prefix,
operator,
]
</ProviderContainer>
);
return <WithHoverActions hoverContent={hoverContent} render={badge} />;
}
);

View file

@ -1,95 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { AndOrBadge } from '../../and_or_badge';
import { BrowserFields } from '../../../containers/source';
import {
OnChangeDataProviderKqlQuery,
OnDataProviderEdited,
OnDataProviderRemoved,
OnToggleDataProviderEnabled,
OnToggleDataProviderExcluded,
} from '../events';
import { DataProvidersAnd, IS_OPERATOR } from './data_provider';
import { ProviderItemBadge } from './provider_item_badge';
interface ProviderItemAndPopoverProps {
browserFields: BrowserFields;
dataProvidersAnd: DataProvidersAnd[];
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
onDataProviderEdited: OnDataProviderEdited;
onDataProviderRemoved: OnDataProviderRemoved;
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
providerId: string;
timelineId: string;
}
export class ProviderItemAnd extends React.PureComponent<ProviderItemAndPopoverProps> {
public render() {
const {
browserFields,
dataProvidersAnd,
onDataProviderEdited,
providerId,
timelineId,
} = this.props;
return dataProvidersAnd.map((providerAnd: DataProvidersAnd, index: number) => (
<React.Fragment key={`provider-item-and-${timelineId}-${providerId}-${providerAnd.id}`}>
<EuiFlexItem>
<AndOrBadge type="and" />
</EuiFlexItem>
<EuiFlexItem>
<ProviderItemBadge
andProviderId={providerAnd.id}
browserFields={browserFields}
deleteProvider={() => this.deleteAndProvider(providerId, providerAnd.id)}
field={providerAnd.queryMatch.displayField || providerAnd.queryMatch.field}
kqlQuery={providerAnd.kqlQuery}
isEnabled={providerAnd.enabled}
isExcluded={providerAnd.excluded}
onDataProviderEdited={onDataProviderEdited}
operator={providerAnd.queryMatch.operator || IS_OPERATOR}
providerId={providerId}
timelineId={timelineId}
toggleEnabledProvider={() =>
this.toggleEnabledAndProvider(providerId, !providerAnd.enabled, providerAnd.id)
}
toggleExcludedProvider={() =>
this.toggleExcludedAndProvider(providerId, !providerAnd.excluded, providerAnd.id)
}
val={providerAnd.queryMatch.displayValue || providerAnd.queryMatch.value}
/>
</EuiFlexItem>
</React.Fragment>
));
}
private deleteAndProvider = (providerId: string, andProviderId: string) => {
this.props.onDataProviderRemoved(providerId, andProviderId);
};
private toggleEnabledAndProvider = (
providerId: string,
enabled: boolean,
andProviderId: string
) => {
this.props.onToggleDataProviderEnabled({ providerId, enabled, andProviderId });
};
private toggleExcludedAndProvider = (
providerId: string,
excluded: boolean,
andProviderId: string
) => {
this.props.onToggleDataProviderExcluded({ providerId, excluded, andProviderId });
};
}

View file

@ -1,136 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { rgba } from 'polished';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import { AndOrBadge } from '../../and_or_badge';
import {
OnChangeDataProviderKqlQuery,
OnChangeDroppableAndProvider,
OnDataProviderEdited,
OnDataProviderRemoved,
OnToggleDataProviderEnabled,
OnToggleDataProviderExcluded,
} from '../events';
import { BrowserFields } from '../../../containers/source';
import { DataProvider } from './data_provider';
import { ProviderItemAnd } from './provider_item_and';
import * as i18n from './translations';
const DropAndTargetDataProvidersContainer = styled(EuiFlexItem)`
margin: 0px 8px;
`;
DropAndTargetDataProvidersContainer.displayName = 'DropAndTargetDataProvidersContainer';
const DropAndTargetDataProviders = styled.div<{ hasAndItem: boolean }>`
min-width: 230px;
width: auto;
border: 0.1rem dashed ${props => props.theme.eui.euiColorSuccess};
border-radius: 5px;
text-align: center;
padding: 3px 10px;
display: flex;
justify-content: center;
align-items: center;
${props =>
props.hasAndItem
? `&:hover {
transition: background-color 0.7s ease;
background-color: ${() => rgba(props.theme.eui.euiColorSuccess, 0.2)};
}`
: ''};
cursor: ${({ hasAndItem }) => (!hasAndItem ? `default` : 'inherit')};
`;
DropAndTargetDataProviders.displayName = 'DropAndTargetDataProviders';
const NumberProviderAndBadge = (styled(EuiBadge)`
margin: 0px 5px;
` as unknown) as typeof EuiBadge;
NumberProviderAndBadge.displayName = 'NumberProviderAndBadge';
interface ProviderItemDropProps {
browserFields: BrowserFields;
dataProvider: DataProvider;
mousePosition?: { x: number; y: number; boundLeft: number; boundTop: number };
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
onDataProviderEdited: OnDataProviderEdited;
onDataProviderRemoved: OnDataProviderRemoved;
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
timelineId: string;
}
export const ProviderItemAndDragDrop = React.memo<ProviderItemDropProps>(
({
browserFields,
dataProvider,
onChangeDataProviderKqlQuery,
onChangeDroppableAndProvider,
onDataProviderEdited,
onDataProviderRemoved,
onToggleDataProviderEnabled,
onToggleDataProviderExcluded,
timelineId,
}) => {
const onMouseEnter = useCallback(() => onChangeDroppableAndProvider(dataProvider.id), [
onChangeDroppableAndProvider,
dataProvider.id,
]);
const onMouseLeave = useCallback(() => onChangeDroppableAndProvider(''), [
onChangeDroppableAndProvider,
]);
const hasAndItem = dataProvider.and.length > 0;
return (
<EuiFlexGroup
direction="row"
gutterSize="none"
justifyContent="flexStart"
alignItems="center"
>
<DropAndTargetDataProvidersContainer className="drop-and-provider-timeline">
<DropAndTargetDataProviders
hasAndItem={hasAndItem}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{hasAndItem && (
<NumberProviderAndBadge color="primary">
{dataProvider.and.length}
</NumberProviderAndBadge>
)}
<EuiText color="subdued" size="xs">
{i18n.DROP_HERE_TO_ADD_AN}
</EuiText>
<AndOrBadge type="and" />
</DropAndTargetDataProviders>
</DropAndTargetDataProvidersContainer>
<ProviderItemAnd
browserFields={browserFields}
dataProvidersAnd={dataProvider.and}
providerId={dataProvider.id}
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
timelineId={timelineId}
/>
</EuiFlexGroup>
);
}
);
ProviderItemAndDragDrop.displayName = 'ProviderItemAndDragDrop';

View file

@ -5,14 +5,16 @@
*/
import { noop } from 'lodash/fp';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { BrowserFields } from '../../../containers/source';
import { OnDataProviderEdited } from '../events';
import { ProviderBadge } from './provider_badge';
import { ProviderItemActions } from './provider_item_actions';
import { QueryOperator } from './data_provider';
import { DataProvidersAnd, QueryOperator } from './data_provider';
import { dragAndDropActions } from '../../../store/drag_and_drop';
import { TimelineContext } from '../timeline_context';
interface ProviderItemBadgeProps {
@ -26,6 +28,7 @@ interface ProviderItemBadgeProps {
onDataProviderEdited?: OnDataProviderEdited;
operator: QueryOperator;
providerId: string;
register?: DataProvidersAnd;
timelineId?: string;
toggleEnabledProvider: () => void;
toggleExcludedProvider: () => void;
@ -44,6 +47,7 @@ export const ProviderItemBadge = React.memo<ProviderItemBadgeProps>(
onDataProviderEdited,
operator,
providerId,
register,
timelineId,
toggleEnabledProvider,
toggleExcludedProvider,
@ -69,6 +73,31 @@ export const ProviderItemBadge = React.memo<ProviderItemBadgeProps>(
closePopover();
}, [toggleExcludedProvider]);
const [providerRegistered, setProviderRegistered] = useState(false);
const dispatch = useDispatch();
useEffect(() => {
// optionally register the provider if provided
if (!providerRegistered && register != null) {
dispatch(dragAndDropActions.registerProvider({ provider: { ...register, and: [] } }));
setProviderRegistered(true);
}
}, [providerRegistered, dispatch, register, setProviderRegistered]);
const unRegisterProvider = useCallback(() => {
if (providerRegistered && register != null) {
dispatch(dragAndDropActions.unRegisterProvider({ id: register.id }));
}
}, [providerRegistered, dispatch, register]);
useEffect(
() => () => {
unRegisterProvider();
},
[]
);
return (
<TimelineContext.Consumer>
{({ isLoading }) => (

View file

@ -14,7 +14,7 @@ import { FilterManager } from '../../../../../../../src/plugins/data/public';
import { TimelineContext } from '../timeline_context';
import { mockDataProviders } from './mock/mock_data_providers';
import { getDraggableId, Providers } from './providers';
import { Providers } from './providers';
import { DELETE_CLASS_NAME, ENABLE_CLASS_NAME, EXCLUDE_CLASS_NAME } from './provider_item_actions';
import { useMountAppended } from '../../../utils/use_mount_appended';
@ -32,8 +32,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -51,8 +49,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -80,8 +76,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={mockOnDataProviderRemoved}
onToggleDataProviderEnabled={jest.fn()}
@ -107,8 +101,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={mockOnDataProviderRemoved}
onToggleDataProviderEnabled={jest.fn()}
@ -136,8 +128,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={mockOnDataProviderRemoved}
onToggleDataProviderEnabled={jest.fn()}
@ -170,8 +160,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={mockOnDataProviderRemoved}
onToggleDataProviderEnabled={jest.fn()}
@ -197,14 +185,6 @@ describe('Providers', () => {
});
});
describe('#getDraggableId', () => {
test('it returns the expected id', () => {
expect(getDraggableId({ id: 'timeline1', dataProviderId: 'abcd' })).toEqual(
'draggableId.timeline.timeline1.dataProvider.abcd'
);
});
});
describe('#onToggleDataProviderEnabled', () => {
test('it invokes the onToggleDataProviderEnabled callback when you click on the option "Temporary disable" in the provider menu', () => {
const mockOnToggleDataProviderEnabled = jest.fn();
@ -215,8 +195,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled}
@ -252,8 +230,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled}
@ -290,8 +266,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -330,8 +304,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -370,8 +342,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={dataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -403,8 +373,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={mockOnDataProviderRemoved}
onToggleDataProviderEnabled={jest.fn()}
@ -439,8 +407,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={mockDataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={mockOnDataProviderRemoved}
onToggleDataProviderEnabled={jest.fn()}
@ -475,8 +441,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={dataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled}
@ -520,8 +484,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={dataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={mockOnToggleDataProviderEnabled}
@ -561,8 +523,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={dataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -606,8 +566,6 @@ describe('Providers', () => {
browserFields={{}}
dataProviders={dataProviders}
id="foo"
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}

View file

@ -5,32 +5,35 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiFormHelpText } from '@elastic/eui';
import React from 'react';
import { Draggable } from 'react-beautiful-dnd';
import styled from 'styled-components';
import { rgba } from 'polished';
import React, { useMemo } from 'react';
import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd';
import styled, { css } from 'styled-components';
import { AndOrBadge } from '../../and_or_badge';
import { BrowserFields } from '../../../containers/source';
import {
getTimelineProviderDroppableId,
IS_DRAGGING_CLASS_NAME,
getTimelineProviderDraggableId,
} from '../../drag_and_drop/helpers';
import {
OnChangeDataProviderKqlQuery,
OnChangeDroppableAndProvider,
OnDataProviderEdited,
OnDataProviderRemoved,
OnToggleDataProviderEnabled,
OnToggleDataProviderExcluded,
} from '../events';
import { BrowserFields } from '../../../containers/source';
import { DataProvider, IS_OPERATOR } from './data_provider';
import { Empty } from './empty';
import { ProviderItemAndDragDrop } from './provider_item_and_drag_drop';
import { DataProvider, DataProvidersAnd, IS_OPERATOR } from './data_provider';
import { EMPTY_GROUP, flattenIntoAndGroups } from './helpers';
import { ProviderItemBadge } from './provider_item_badge';
import * as i18n from './translations';
export const EMPTY_PROVIDERS_GROUP_CLASS_NAME = 'empty-providers-group';
interface Props {
browserFields: BrowserFields;
id: string;
dataProviders: DataProvider[];
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
onDataProviderEdited: OnDataProviderEdited;
onDataProviderRemoved: OnDataProviderRemoved;
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
@ -42,68 +45,66 @@ interface Props {
* (growth causes layout thrashing) when the AND drop target in a row
* of data providers is revealed.
*/
const ROW_OF_DATA_PROVIDERS_HEIGHT = 43; // px
const ROW_OF_DATA_PROVIDERS_HEIGHT = 36; // px
const PanelProviders = styled.div`
position: relative;
display: flex;
flex-direction: row;
min-height: 100px;
padding: 5px 10px 15px 0px;
overflow-y: auto;
align-items: stretch;
justify-content: flex-start;
`;
const listStyle: React.CSSProperties = {
alignItems: 'center',
display: 'flex',
height: `${ROW_OF_DATA_PROVIDERS_HEIGHT}px`,
minWidth: '125px',
};
PanelProviders.displayName = 'PanelProviders';
const getItemStyle = (
draggableStyle: DraggingStyle | NotDraggingStyle | undefined
): React.CSSProperties => ({
...draggableStyle,
userSelect: 'none',
});
const PanelProvidersGroupContainer = styled(EuiFlexGroup)`
position: relative;
flex-grow: unset;
.euiFlexItem {
flex: 1 0 auto;
}
.euiFlexItem--flexGrowZero {
flex: 0 0 auto;
}
`;
PanelProvidersGroupContainer.displayName = 'PanelProvidersGroupContainer';
/** A row of data providers in the timeline drop zone */
const PanelProviderGroupContainer = styled(EuiFlexGroup)`
const DroppableContainer = styled.div`
height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px;
min-height: ${ROW_OF_DATA_PROVIDERS_HEIGHT}px;
margin: 5px 0px;
.${IS_DRAGGING_CLASS_NAME} &:hover {
background-color: ${({ theme }) => rgba(theme.eui.euiColorSuccess, 0.2)} !important;
}
`;
PanelProviderGroupContainer.displayName = 'PanelProviderGroupContainer';
const PanelProviderItemContainer = styled(EuiFlexItem)`
position: relative;
const Parens = styled.span`
${({ theme }) => css`
color: ${theme.eui.euiColorMediumShade};
font-size: 32px;
padding: 2px;
user-select: none;
`}
`;
PanelProviderItemContainer.displayName = 'PanelProviderItemContainer';
const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>`
span {
visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')};
}
`;
const LastAndOrBadgeInGroup = styled.div`
display: none;
.${IS_DRAGGING_CLASS_NAME} & {
display: initial;
}
`;
const OrFlexItem = styled(EuiFlexItem)`
padding-left: 9px;
`;
const TimelineEuiFormHelpText = styled(EuiFormHelpText)`
padding-top: 0px;
position: absolute;
bottom: 0px;
left: 5px;
left: 4px;
`;
TimelineEuiFormHelpText.displayName = 'TimelineEuiFormHelpText';
interface GetDraggableIdParams {
id: string;
dataProviderId: string;
}
export const getDraggableId = ({ id, dataProviderId }: GetDraggableIdParams): string =>
`draggableId.timeline.${id}.dataProvider.${dataProviderId}`;
/**
* Renders an interactive card representation of the data providers. It also
* affords uniform UI controls for the following actions:
@ -116,104 +117,151 @@ export const Providers = React.memo<Props>(
browserFields,
id,
dataProviders,
onChangeDataProviderKqlQuery,
onChangeDroppableAndProvider,
onDataProviderEdited,
onDataProviderRemoved,
onToggleDataProviderEnabled,
onToggleDataProviderExcluded,
}) => (
<PanelProviders className="timeline-drop-area" data-test-subj="providers">
<Empty showSmallMsg={dataProviders.length > 0} />
<PanelProvidersGroupContainer
direction="column"
className="provider-items-container"
alignItems="flexStart"
gutterSize="none"
>
<EuiFlexItem grow={true}>
{dataProviders.map((dataProvider, i) => {
const deleteProvider = () => onDataProviderRemoved(dataProvider.id);
const toggleEnabledProvider = () =>
onToggleDataProviderEnabled({
providerId: dataProvider.id,
enabled: !dataProvider.enabled,
});
const toggleExcludedProvider = () =>
onToggleDataProviderExcluded({
providerId: dataProvider.id,
excluded: !dataProvider.excluded,
});
return (
// Providers are a special drop target that can't be drag-and-dropped
// to another destination, so it doesn't use our DraggableWrapper
<PanelProviderGroupContainer
key={dataProvider.id}
direction="row"
gutterSize="none"
justifyContent="flexStart"
alignItems="center"
}) => {
// Transform the dataProviders into flattened groups, and append an empty group
const dataProviderGroups: DataProvidersAnd[][] = useMemo(
() => [...flattenIntoAndGroups(dataProviders), ...EMPTY_GROUP],
[dataProviders]
);
return (
<>
{dataProviderGroups.map((group, groupIndex) => (
<EuiFlexGroup alignItems="center" gutterSize="none" key={`droppable-${groupIndex}`}>
<OrFlexItem grow={false}>
<AndOrBadgeContainer hideBadge={groupIndex === 0}>
<AndOrBadge type="or" />
</AndOrBadgeContainer>
</OrFlexItem>
<EuiFlexItem grow={false}>
<Parens>{'('}</Parens>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Droppable
droppableId={getTimelineProviderDroppableId({ groupIndex, timelineId: id })}
direction="horizontal"
>
<PanelProviderItemContainer className="provider-item-filter-container" grow={false}>
<Draggable
draggableId={getDraggableId({ id, dataProviderId: dataProvider.id })}
index={i}
{droppableProvided => (
<DroppableContainer
className={
groupIndex === dataProviderGroups.length - 1
? EMPTY_PROVIDERS_GROUP_CLASS_NAME
: ''
}
ref={droppableProvided.innerRef}
style={listStyle}
{...droppableProvided.droppableProps}
>
{provided => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
data-test-subj="providerContainer"
{group.map((dataProvider, index) => (
<Draggable
disableInteractiveElementBlocking={true}
draggableId={getTimelineProviderDraggableId({
dataProviderId: dataProvider.id,
groupIndex,
timelineId: id,
})}
index={index}
key={dataProvider.id}
>
<ProviderItemBadge
browserFields={browserFields}
field={
dataProvider.queryMatch.displayField || dataProvider.queryMatch.field
}
kqlQuery={dataProvider.kqlQuery}
isEnabled={dataProvider.enabled}
isExcluded={dataProvider.excluded}
deleteProvider={deleteProvider}
operator={dataProvider.queryMatch.operator || IS_OPERATOR}
onDataProviderEdited={onDataProviderEdited}
timelineId={id}
toggleEnabledProvider={toggleEnabledProvider}
toggleExcludedProvider={toggleExcludedProvider}
providerId={dataProvider.id}
val={
dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value
}
/>
</div>
)}
</Draggable>
</PanelProviderItemContainer>
<EuiFlexItem grow={false}>
<ProviderItemAndDragDrop
browserFields={browserFields}
dataProvider={dataProvider}
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
timelineId={id}
/>
</EuiFlexItem>
</PanelProviderGroupContainer>
);
})}
</EuiFlexItem>
</PanelProvidersGroupContainer>
<TimelineEuiFormHelpText>
<span>
{i18n.DROP_HERE} {i18n.TO_BUILD_AN} {i18n.OR.toLocaleUpperCase()} {i18n.QUERY}
</span>
</TimelineEuiFormHelpText>
</PanelProviders>
)
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getItemStyle(provided.draggableProps.style)}
data-test-subj="providerContainer"
>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<ProviderItemBadge
andProviderId={index > 0 ? dataProvider.id : undefined}
browserFields={browserFields}
deleteProvider={() =>
index > 0
? onDataProviderRemoved(group[0].id, dataProvider.id)
: onDataProviderRemoved(dataProvider.id)
}
field={
index > 0
? dataProvider.queryMatch.displayField ??
dataProvider.queryMatch.field
: group[0].queryMatch.displayField ??
group[0].queryMatch.field
}
kqlQuery={index > 0 ? dataProvider.kqlQuery : group[0].kqlQuery}
isEnabled={index > 0 ? dataProvider.enabled : group[0].enabled}
isExcluded={index > 0 ? dataProvider.excluded : group[0].excluded}
onDataProviderEdited={onDataProviderEdited}
operator={
index > 0
? dataProvider.queryMatch.operator ?? IS_OPERATOR
: group[0].queryMatch.operator ?? IS_OPERATOR
}
register={dataProvider}
providerId={index > 0 ? group[0].id : dataProvider.id}
timelineId={id}
toggleEnabledProvider={() =>
index > 0
? onToggleDataProviderEnabled({
providerId: group[0].id,
enabled: !dataProvider.enabled,
andProviderId: dataProvider.id,
})
: onToggleDataProviderEnabled({
providerId: dataProvider.id,
enabled: !dataProvider.enabled,
})
}
toggleExcludedProvider={() =>
index > 0
? onToggleDataProviderExcluded({
providerId: group[0].id,
excluded: !dataProvider.excluded,
andProviderId: dataProvider.id,
})
: onToggleDataProviderExcluded({
providerId: dataProvider.id,
excluded: !dataProvider.excluded,
})
}
val={
dataProvider.queryMatch.displayValue ??
dataProvider.queryMatch.value
}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{!snapshot.isDragging &&
(index < group.length - 1 ? (
<AndOrBadge type="and" />
) : (
<LastAndOrBadgeInGroup>
<AndOrBadge type="and" />
</LastAndOrBadgeInGroup>
))}
</EuiFlexItem>
</EuiFlexGroup>
</div>
)}
</Draggable>
))}
{droppableProvided.placeholder}
</DroppableContainer>
)}
</Droppable>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Parens>{')'}</Parens>
</EuiFlexItem>
</EuiFlexGroup>
))}
</>
);
}
);
Providers.displayName = 'Providers';

View file

@ -83,7 +83,7 @@ export const INCLUDE_DATA_PROVIDER = i18n.translate(
);
export const NOT = i18n.translate('xpack.siem.dataProviders.not', {
defaultMessage: 'not',
defaultMessage: 'NOT',
});
export const OR = i18n.translate('xpack.siem.dataProviders.or', {

View file

@ -139,8 +139,6 @@ exports[`Header rendering renders correctly against snapshot 1`] = `
]
}
id="foo"
onChangeDataProviderKqlQuery={[MockFunction]}
onChangeDroppableAndProvider={[MockFunction]}
onDataProviderEdited={[MockFunction]}
onDataProviderRemoved={[MockFunction]}
onToggleDataProviderEnabled={[MockFunction]}

View file

@ -33,8 +33,6 @@ describe('Header', () => {
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -55,8 +53,6 @@ describe('Header', () => {
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}
@ -79,8 +75,6 @@ describe('Header', () => {
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
id="foo"
indexPattern={indexPattern}
onChangeDataProviderKqlQuery={jest.fn()}
onChangeDroppableAndProvider={jest.fn()}
onDataProviderEdited={jest.fn()}
onDataProviderRemoved={jest.fn()}
onToggleDataProviderEnabled={jest.fn()}

View file

@ -12,8 +12,6 @@ import deepEqual from 'fast-deep-equal';
import { DataProviders } from '../data_providers';
import { DataProvider } from '../data_providers/data_provider';
import {
OnChangeDataProviderKqlQuery,
OnChangeDroppableAndProvider,
OnDataProviderEdited,
OnDataProviderRemoved,
OnToggleDataProviderEnabled,
@ -30,8 +28,6 @@ interface Props {
filterManager: FilterManager;
id: string;
indexPattern: IIndexPattern;
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
onDataProviderEdited: OnDataProviderEdited;
onDataProviderRemoved: OnDataProviderRemoved;
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
@ -46,8 +42,6 @@ const TimelineHeaderComponent: React.FC<Props> = ({
indexPattern,
dataProviders,
filterManager,
onChangeDataProviderKqlQuery,
onChangeDroppableAndProvider,
onDataProviderEdited,
onDataProviderRemoved,
onToggleDataProviderEnabled,
@ -65,18 +59,19 @@ const TimelineHeaderComponent: React.FC<Props> = ({
size="s"
/>
)}
<DataProviders
browserFields={browserFields}
id={id}
dataProviders={dataProviders}
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
show={show}
/>
{show && (
<DataProviders
browserFields={browserFields}
id={id}
dataProviders={dataProviders}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
show={show}
/>
)}
<StatefulSearchOrFilter
browserFields={browserFields}
filterManager={filterManager}
@ -94,8 +89,6 @@ export const TimelineHeader = React.memo(
deepEqual(prevProps.indexPattern, nextProps.indexPattern) &&
deepEqual(prevProps.dataProviders, nextProps.dataProviders) &&
prevProps.filterManager === nextProps.filterManager &&
prevProps.onChangeDataProviderKqlQuery === nextProps.onChangeDataProviderKqlQuery &&
prevProps.onChangeDroppableAndProvider === nextProps.onChangeDroppableAndProvider &&
prevProps.onDataProviderEdited === nextProps.onDataProviderEdited &&
prevProps.onDataProviderRemoved === nextProps.onDataProviderRemoved &&
prevProps.onToggleDataProviderEnabled === nextProps.onToggleDataProviderEnabled &&

View file

@ -16,8 +16,6 @@ import { ColumnHeaderOptions, TimelineModel } from '../../store/timeline/model';
import { timelineDefaults } from '../../store/timeline/defaults';
import { defaultHeaders } from './body/column_headers/default_headers';
import {
OnChangeDataProviderKqlQuery,
OnChangeDroppableAndProvider,
OnChangeItemsPerPage,
OnDataProviderRemoved,
OnDataProviderEdited,
@ -58,8 +56,6 @@ const StatefulTimelineComponent = React.memo<Props>(
start,
updateDataProviderEnabled,
updateDataProviderExcluded,
updateDataProviderKqlQuery,
updateHighlightedDropAndProviderId,
updateItemsPerPage,
upsertColumn,
usersViewing,
@ -120,21 +116,11 @@ const StatefulTimelineComponent = React.memo<Props>(
[id]
);
const onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery = useCallback(
({ providerId, kqlQuery }) => updateDataProviderKqlQuery!({ id, kqlQuery, providerId }),
[id]
);
const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback(
itemsChangedPerPage => updateItemsPerPage!({ id, itemsPerPage: itemsChangedPerPage }),
[id]
);
const onChangeDroppableAndProvider: OnChangeDroppableAndProvider = useCallback(
providerId => updateHighlightedDropAndProviderId!({ id, providerId }),
[id]
);
const toggleColumn = useCallback(
(column: ColumnHeaderOptions) => {
const exists = columns.findIndex(c => c.id === column.id) !== -1;
@ -182,8 +168,6 @@ const StatefulTimelineComponent = React.memo<Props>(
kqlMode={kqlMode}
kqlQueryExpression={kqlQueryExpression}
loadingIndexName={loading}
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
onChangeItemsPerPage={onChangeItemsPerPage}
onClose={onClose}
onDataProviderEdited={onDataProviderEditedLocal}

View file

@ -65,8 +65,6 @@ describe('Timeline', () => {
kqlMode: 'search' as TimelineComponentProps['kqlMode'],
kqlQueryExpression: '',
loadingIndexName: false,
onChangeDataProviderKqlQuery: jest.fn(),
onChangeDroppableAndProvider: jest.fn(),
onChangeItemsPerPage: jest.fn(),
onClose: jest.fn(),
onDataProviderEdited: jest.fn(),

View file

@ -20,8 +20,6 @@ import { Sort } from './body/sort';
import { StatefulBody } from './body/stateful_body';
import { DataProvider } from './data_providers/data_provider';
import {
OnChangeDataProviderKqlQuery,
OnChangeDroppableAndProvider,
OnChangeItemsPerPage,
OnDataProviderRemoved,
OnDataProviderEdited,
@ -99,8 +97,6 @@ export interface Props {
kqlMode: KqlMode;
kqlQueryExpression: string;
loadingIndexName: boolean;
onChangeDataProviderKqlQuery: OnChangeDataProviderKqlQuery;
onChangeDroppableAndProvider: OnChangeDroppableAndProvider;
onChangeItemsPerPage: OnChangeItemsPerPage;
onClose: () => void;
onDataProviderEdited: OnDataProviderEdited;
@ -132,8 +128,6 @@ export const TimelineComponent: React.FC<Props> = ({
kqlMode,
kqlQueryExpression,
loadingIndexName,
onChangeDataProviderKqlQuery,
onChangeDroppableAndProvider,
onChangeItemsPerPage,
onClose,
onDataProviderEdited,
@ -185,8 +179,6 @@ export const TimelineComponent: React.FC<Props> = ({
indexPattern={indexPattern}
dataProviders={dataProviders}
filterManager={filterManager}
onChangeDataProviderKqlQuery={onChangeDataProviderKqlQuery}
onChangeDroppableAndProvider={onChangeDroppableAndProvider}
onDataProviderEdited={onDataProviderEdited}
onDataProviderRemoved={onDataProviderRemoved}
onToggleDataProviderEnabled={onToggleDataProviderEnabled}

View file

@ -6,6 +6,12 @@
import { i18n } from '@kbn/i18n';
export const ADDED_TO_TIMELINE_MESSAGE = (fieldOrValue: string) =>
i18n.translate('xpack.siem.hooks.useAddToTimeline.addedFieldMessage', {
values: { fieldOrValue },
defaultMessage: `Added {fieldOrValue} to timeline`,
});
export const STATUS_CODE = i18n.translate(
'xpack.siem.components.ml.api.errors.statusCodeFailureTitle',
{

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import d3 from 'd3';
import { useCallback } from 'react';
import { DraggableId, FluidDragActions, Position, SensorAPI } from 'react-beautiful-dnd';
import { IS_DRAGGING_CLASS_NAME } from '../components/drag_and_drop/helpers';
import { HIGHLIGHTED_DROP_TARGET_CLASS_NAME } from '../components/timeline/data_providers/empty';
import { EMPTY_PROVIDERS_GROUP_CLASS_NAME } from '../components/timeline/data_providers/providers';
let _sensorApiSingleton: SensorAPI;
/**
* This hook is passed (in an array) to the `sensors` prop of the
* `react-beautiful-dnd` `DragDropContext` component. Example:
*
* ```
<DragDropContext onDragEnd={onDragEnd} sensors={[useAddToTimelineSensor]}>
{children}
</DragDropContext>*
* ```
*
* As a side effect of registering this hook with the `DragDropContext`,
* the `SensorAPI` singleton is initialized. This singleton is used
* by the `useAddToTimeline` hook.
*/
export const useAddToTimelineSensor = (api: SensorAPI) => {
_sensorApiSingleton = api;
};
/**
* Returns the position of the specified element
*/
const getPosition = (element: Element): Position => {
const rect = element.getBoundingClientRect();
return { x: rect.left, y: rect.top };
};
/**
* Returns the position of one of the following timeline drop targets
* (in the following order of preference):
* 1) The "Drop anything highlighted..." drop target
* 2) The persistent "empty" data provider group drop target
* 3) `null`, because none of the above targets exist (an error state)
*/
export const getDropTargetCoordinate = (): Position | null => {
// The placeholder in the "Drop anything highlighted here to build an OR query":
const highlighted = document.querySelector(`.${HIGHLIGHTED_DROP_TARGET_CLASS_NAME}`);
if (highlighted != null) {
return getPosition(highlighted);
}
// If at least one provider has been added to the timeline, the "Drop anything
// highlighted..." drop target won't be visible, so we need to drop into the
// empty group instead:
const emptyGroup = document.querySelector(`.${EMPTY_PROVIDERS_GROUP_CLASS_NAME}`);
if (emptyGroup != null) {
return getPosition(emptyGroup);
}
return null;
};
/**
* Returns the coordinates of the specified draggable
*/
export const getDraggableCoordinate = (draggableId: DraggableId): Position | null => {
// The placeholder in the "Drop anything highlighted here to build an OR query":
const draggable = document.querySelector(`[data-rbd-draggable-id="${draggableId}"]`);
if (draggable != null) {
return getPosition(draggable);
}
return null;
};
/**
* Animates a draggable via `requestAnimationFrame`
*/
export const animate = ({
drag,
fieldName,
values,
}: {
drag: FluidDragActions;
fieldName: string;
values: Position[];
}) => {
requestAnimationFrame(() => {
if (values.length === 0) {
setTimeout(() => drag.drop(), 0); // schedule the drop the next time around
return;
}
drag.move(values[0]);
animate({
drag,
fieldName,
values: values.slice(1),
});
});
};
/**
* This hook animates a draggable data provider to the timeline
*/
export const useAddToTimeline = ({
draggableId,
fieldName,
}: {
draggableId: DraggableId | undefined;
fieldName: string;
}) => {
const startDragToTimeline = useCallback(() => {
if (_sensorApiSingleton == null) {
throw new TypeError(
'To use this hook, the companion `useAddToTimelineSensor` hook must be registered in the `sensors` prop of the `DragDropContext`.'
);
}
if (draggableId == null) {
// A request to start the animation should not have been made, because
// no draggableId was provided
return;
}
// add the dragging class, which will show the flyout data providers (if the flyout button is being displayed):
document.body.classList.add(IS_DRAGGING_CLASS_NAME);
// start the animation after the flyout data providers are visible:
setTimeout(() => {
const draggableCoordinate = getDraggableCoordinate(draggableId);
const dropTargetCoordinate = getDropTargetCoordinate();
const preDrag = _sensorApiSingleton.tryGetLock(draggableId);
if (draggableCoordinate != null && dropTargetCoordinate != null && preDrag != null) {
const steps = 10;
const points = d3.range(steps + 1).map(i => ({
x: d3.interpolate(draggableCoordinate.x, dropTargetCoordinate.x)(i * 0.1),
y: d3.interpolate(draggableCoordinate.y, dropTargetCoordinate.y)(i * 0.1),
}));
const drag = preDrag.fluidLift(draggableCoordinate);
animate({
drag,
fieldName,
values: points,
});
} else {
document.body.classList.remove(IS_DRAGGING_CLASS_NAME); // it was not possible to perform a drag and drop
}
}, 0);
}, [_sensorApiSingleton, draggableId]);
return startDragToTimeline;
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState } from 'react';
import { createPortalNode } from 'react-reverse-portal';
/**
* A singleton portal for rendering the draggable groups of providers in the
* header of the timeline, or in the animated flyout
*/
const proivdersPortalNodeSingleton = createPortalNode();
export const useProvidersPortal = () => {
const [proivdersPortalNode] = useState(proivdersPortalNodeSingleton);
return proivdersPortalNode;
};