mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
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  - Introduces a new `Add to timeline investigation` context menu action that automatically drags and drops any draggable to the timeline  - 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`)  ### Desk testing Desk tested in: - Chrome `81.0.4044.129` - Firefox `75.0` - Safari `13.1`
This commit is contained in:
parent
ee270c7c3f
commit
ecd16dcc71
31 changed files with 1584 additions and 1351 deletions
|
@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 }) => (
|
||||
<>
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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]}
|
||||
|
|
|
@ -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>
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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()}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
}
|
|
@ -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';
|
|
@ -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 }) => (
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -139,8 +139,6 @@ exports[`Header rendering renders correctly against snapshot 1`] = `
|
|||
]
|
||||
}
|
||||
id="foo"
|
||||
onChangeDataProviderKqlQuery={[MockFunction]}
|
||||
onChangeDroppableAndProvider={[MockFunction]}
|
||||
onDataProviderEdited={[MockFunction]}
|
||||
onDataProviderRemoved={[MockFunction]}
|
||||
onToggleDataProviderEnabled={[MockFunction]}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
165
x-pack/plugins/siem/public/hooks/use_add_to_timeline.tsx
Normal file
165
x-pack/plugins/siem/public/hooks/use_add_to_timeline.tsx
Normal 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;
|
||||
};
|
20
x-pack/plugins/siem/public/hooks/use_providers_portal.tsx
Normal file
20
x-pack/plugins/siem/public/hooks/use_providers_portal.tsx
Normal 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;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue