[SIEM] Drag between ANDs in timeline queries / add to timeline
test coverage / CSS Tweaks (#67150)
## Summary - Increases test coverage for the [[SIEM] Drag between ANDs in timeline queries / add to timeline](https://github.com/elastic/kibana/pull/65228) `7.8` feature - Removes the `show` prop from `DataProviders`, which added delay when closing the timeline - CSS tweaks (detailed below) - Removed files no longer in use ### CSS Tweaks - Providers in the timeline are now vertically centered, as shown in the _Before_ / _After_ screenshots below (containing two groups): #### Before <img width="1186" alt="providers-two-rows-before" src="https://user-images.githubusercontent.com/4459398/82508699-1e8bcc80-9ac3-11ea-8f44-b170d1528a12.png"> #### After <img width="1186" alt="providers-two-rows-after" src="https://user-images.githubusercontent.com/4459398/82508708-264b7100-9ac3-11ea-8532-a24612573991.png"> - Very slightly increased the padding between the bottom of the last group of data providers and the dashed border, as shown in the _Before_ / _After_ screenshots below (containing three groups): #### Before <img width="1200" alt="providers-three-rows-padding-bottom-before" src="https://user-images.githubusercontent.com/4459398/82508573-b63ceb00-9ac2-11ea-9f8b-c3c024a3c91e.png"> #### After <img width="1201" alt="providers-three-rows-padding-bottom-after" src="https://user-images.githubusercontent.com/4459398/82508578-b89f4500-9ac2-11ea-8560-a40eb10b22f1.png"> - Moved the timeline flyout button slightly to the right by a few pixels to avoid showing the flyout button's right border, as shown in the _Before_ / _After_ screenshots below: #### Before <img width="1179" alt="timeline-button-before" src="https://user-images.githubusercontent.com/4459398/82509015-f94b8e00-9ac3-11ea-9d2f-644721c0ec21.png"> #### After <img width="1178" alt="timeline-button-after" src="https://user-images.githubusercontent.com/4459398/82509026-00729c00-9ac4-11ea-9029-6a4f698daa36.png"> ### Desk testing Desk tested in: - Chrome `83.0.4103.61` - Firefox `76.0.1` - Safari `13.1`
This commit is contained in:
parent
90cabaef2d
commit
75d6682d86
|
@ -15,6 +15,7 @@ import { TestProviders } from '../../mock';
|
|||
import { createKibanaCoreStartMock } from '../../mock/kibana_core';
|
||||
import { FilterManager } from '../../../../../../../src/plugins/data/public';
|
||||
import { TimelineContext } from '../../../timelines/components/timeline/timeline_context';
|
||||
import { useAddToTimeline } from '../../hooks/use_add_to_timeline';
|
||||
|
||||
import { DraggableWrapperHoverContent } from './draggable_wrapper_hover_content';
|
||||
|
||||
|
@ -27,11 +28,18 @@ jest.mock('uuid', () => {
|
|||
};
|
||||
});
|
||||
|
||||
jest.mock('../../hooks/use_add_to_timeline');
|
||||
|
||||
const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings;
|
||||
const field = 'process.name';
|
||||
const value = 'nice';
|
||||
|
||||
describe('DraggableWrapperHoverContent', () => {
|
||||
beforeAll(() => {
|
||||
// our mock implementation of the useAddToTimeline hook returns a mock startDragToTimeline function:
|
||||
(useAddToTimeline as jest.Mock).mockReturnValue(jest.fn());
|
||||
});
|
||||
|
||||
// Suppress warnings about "react-beautiful-dnd"
|
||||
/* eslint-disable no-console */
|
||||
const originalError = console.error;
|
||||
|
@ -329,6 +337,75 @@ describe('DraggableWrapperHoverContent', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Add to timeline', () => {
|
||||
const aggregatableStringField = 'cloud.account.id';
|
||||
const draggableId = 'draggable.id';
|
||||
|
||||
[false, true].forEach(showTopN => {
|
||||
[value, null].forEach(maybeValue => {
|
||||
[draggableId, undefined].forEach(maybeDraggableId => {
|
||||
const shouldRender = !showTopN && maybeValue != null && maybeDraggableId != null;
|
||||
const assertion = shouldRender ? 'should render' : 'should NOT render';
|
||||
|
||||
test(`it ${assertion} the 'Add to timeline investigation' button when showTopN is ${showTopN}, value is ${maybeValue}, and a draggableId is ${maybeDraggableId}`, () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<MockedProvider mocks={mocksSource} addTypename={false}>
|
||||
<DraggableWrapperHoverContent
|
||||
draggableId={maybeDraggableId}
|
||||
field={aggregatableStringField}
|
||||
showTopN={showTopN}
|
||||
toggleTopN={jest.fn()}
|
||||
value={maybeValue}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="add-to-timeline"]')
|
||||
.first()
|
||||
.exists()
|
||||
).toBe(shouldRender);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('when clicked, it invokes the `startDragToTimeline` function returned by the `useAddToTimeline` hook', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<MockedProvider mocks={mocksSource} addTypename={false}>
|
||||
<DraggableWrapperHoverContent
|
||||
draggableId={draggableId}
|
||||
field={aggregatableStringField}
|
||||
showTopN={false}
|
||||
toggleTopN={jest.fn()}
|
||||
value={value}
|
||||
/>
|
||||
</MockedProvider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
// The following "startDragToTimeline" function returned by our mock
|
||||
// useAddToTimeline hook is called when the user clicks the
|
||||
// Add to timeline investigation action:
|
||||
const startDragToTimeline = useAddToTimeline({
|
||||
draggableId,
|
||||
fieldName: aggregatableStringField,
|
||||
});
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="add-to-timeline"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
wrapper.update();
|
||||
|
||||
expect(startDragToTimeline).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Top N', () => {
|
||||
test(`it renders the 'Show top field' button when showTopN is false and an aggregatable string field is provided`, async () => {
|
||||
const aggregatableStringField = 'cloud.account.id';
|
||||
|
|
|
@ -5,8 +5,12 @@
|
|||
*/
|
||||
|
||||
import { omit } from 'lodash/fp';
|
||||
import { DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { IdToDataProvider } from '../../store/drag_and_drop/model';
|
||||
|
||||
import {
|
||||
addProviderToTimeline,
|
||||
allowTopN,
|
||||
destinationIsTimelineButton,
|
||||
destinationIsTimelineColumns,
|
||||
|
@ -28,10 +32,14 @@ import {
|
|||
getDroppableId,
|
||||
getFieldIdFromDraggable,
|
||||
getProviderIdFromDraggable,
|
||||
getTimelineProviderDraggableId,
|
||||
getTimelineProviderDroppableId,
|
||||
providerWasDroppedOnTimeline,
|
||||
reasonIsDrop,
|
||||
sourceAndDestinationAreSameTimelineProviders,
|
||||
sourceIsContent,
|
||||
unEscapeFieldId,
|
||||
userIsReArrangingProviders,
|
||||
} from './helpers';
|
||||
|
||||
const DROPPABLE_ID_TIMELINE_PROVIDERS = `${droppableTimelineProvidersPrefix}timeline`;
|
||||
|
@ -717,4 +725,263 @@ describe('helpers', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimelineProviderDroppableId', () => {
|
||||
test('it returns the expected id', () => {
|
||||
expect(
|
||||
getTimelineProviderDroppableId({
|
||||
groupIndex: 1234,
|
||||
timelineId: 'i-hope-you-had-the-time-of-your-life',
|
||||
})
|
||||
).toEqual('droppableId.timelineProviders.i-hope-you-had-the-time-of-your-life.group.1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimelineProviderDraggableId', () => {
|
||||
test('it returns the expected id', () => {
|
||||
const dataProviderId =
|
||||
'port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828';
|
||||
|
||||
expect(
|
||||
getTimelineProviderDraggableId({
|
||||
dataProviderId,
|
||||
groupIndex: 1234,
|
||||
timelineId: 'time-to-make-the-doughnuts',
|
||||
})
|
||||
).toEqual(
|
||||
`draggableId.timelineProviders.time-to-make-the-doughnuts.group.1234.${dataProviderId}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceAndDestinationAreSameTimelineProviders', () => {
|
||||
test('it returns true when the source and destination droppable IDs exactly match', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(sourceAndDestinationAreSameTimelineProviders(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('it returns true when the source and destination droppable IDs are the same timeline, but different groups', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.1' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(sourceAndDestinationAreSameTimelineProviders(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('it returns false when destination is undefined', () => {
|
||||
const result: DropResult = {
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.1' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(sourceAndDestinationAreSameTimelineProviders(result)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when the source and destination droppable IDs are for different timelines', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-2.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(sourceAndDestinationAreSameTimelineProviders(result)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when the destination is NOT timeline providers', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.otherProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(sourceAndDestinationAreSameTimelineProviders(result)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when the source is NOT timeline providers', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.otherProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(sourceAndDestinationAreSameTimelineProviders(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userIsReArrangingProviders', () => {
|
||||
test('it returns true when reason IS DROP and source + destination are the SAME timeline providers', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(userIsReArrangingProviders(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('it returns false when reason IS DROP, but source + destination are NOT the same timeline providers', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.otherProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(userIsReArrangingProviders(result)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when the reason is NOT DROP and source + destination are the SAME timeline providers', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'CANCEL',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(userIsReArrangingProviders(result)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when the reason is NOT DROP and source + destination are NOT the same timeline providers', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.otherProviders.timeline-1.group.0', index: 1 },
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'CANCEL',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.0' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(userIsReArrangingProviders(result)).toBe(false);
|
||||
});
|
||||
|
||||
test('it returns false when reason IS DROP, but destination is undefined', () => {
|
||||
const result: DropResult = {
|
||||
draggableId:
|
||||
'draggableId.timelineProviders.timeline-1.group.0.port-default-draggable-netflow-renderer-timeline-1-Ib4zD3IBbNV0npT21btr-Ib4zD3IBbNV0npT21btr-source_port-57828',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.timelineProviders.timeline-1.group.1' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
expect(userIsReArrangingProviders(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addProviderToTimeline', () => {
|
||||
const result: DropResult = {
|
||||
destination: { droppableId: 'droppableId.timelineProviders.timeline-1', index: 0 },
|
||||
draggableId: 'draggableId.content.hosts-table-hostName-ENDPOINT-W-0-01',
|
||||
mode: 'FLUID',
|
||||
reason: 'DROP',
|
||||
source: { index: 0, droppableId: 'droppableId.content.hosts-table-hostName-ENDPOINT-W-0-01' },
|
||||
type: 'DEFAULT',
|
||||
};
|
||||
|
||||
test('it dispatches the expected UPDATE_PROVIDERS action when the provider to add exists in the `dataProviders` collection of `id -> `DataProvider`', () => {
|
||||
const dispatch = jest.fn();
|
||||
const onAddedToTimeline = jest.fn();
|
||||
const dataProviders: IdToDataProvider = {
|
||||
'hosts-table-hostName-ENDPOINT-W-0-01': {
|
||||
and: [],
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'hosts-table-hostName-ENDPOINT-W-0-01',
|
||||
kqlQuery: '',
|
||||
name: 'ENDPOINT-W-0-01',
|
||||
queryMatch: { field: 'host.name', value: 'ENDPOINT-W-0-01', operator: ':' },
|
||||
},
|
||||
};
|
||||
|
||||
addProviderToTimeline({
|
||||
activeTimelineDataProviders: [],
|
||||
dataProviders,
|
||||
dispatch,
|
||||
result,
|
||||
timelineId: 'timeline-1',
|
||||
onAddedToTimeline,
|
||||
});
|
||||
|
||||
expect(dispatch).toBeCalledWith({
|
||||
payload: {
|
||||
id: 'timeline-1',
|
||||
providers: [
|
||||
{
|
||||
and: [],
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'hosts-table-hostName-ENDPOINT-W-0-01',
|
||||
kqlQuery: '',
|
||||
name: 'ENDPOINT-W-0-01',
|
||||
queryMatch: { field: 'host.name', operator: ':', value: 'ENDPOINT-W-0-01' },
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'x-pack/siem/local/timeline/UPDATE_PROVIDERS',
|
||||
});
|
||||
});
|
||||
|
||||
test('it dispatches the expected NO_PROVIDER_FOUND action when the provider to add does NOT exist in the `dataProviders` collection of `id -> `DataProvider`', () => {
|
||||
const dispatch = jest.fn();
|
||||
const onAddedToTimeline = jest.fn();
|
||||
|
||||
addProviderToTimeline({
|
||||
activeTimelineDataProviders: [],
|
||||
dataProviders: {}, // <-- the specified data provider ID does not exist in this empty collection
|
||||
dispatch,
|
||||
result,
|
||||
timelineId: 'timeline-1',
|
||||
onAddedToTimeline,
|
||||
});
|
||||
|
||||
expect(dispatch).toBeCalledWith({
|
||||
payload: {
|
||||
id: 'hosts-table-hostName-ENDPOINT-W-0-01',
|
||||
},
|
||||
type: 'x-pack/siem/local/drag_and_drop/NO_PROVIDER_FOUND',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* 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 { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { TestProviders } from '../../../../common/mock/test_providers';
|
||||
import { twoGroups } from '../../timeline/data_providers/mock/mock_and_providers';
|
||||
|
||||
import { FlyoutButton, getBadgeCount } from '.';
|
||||
|
||||
describe('FlyoutButton', () => {
|
||||
describe('getBadgeCount', () => {
|
||||
test('it returns 0 when dataProviders is empty', () => {
|
||||
expect(getBadgeCount([])).toEqual(0);
|
||||
});
|
||||
|
||||
test('it returns a count that includes every provider in every group of ANDs', () => {
|
||||
expect(getBadgeCount(twoGroups)).toEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders the button when show is true', () => {
|
||||
const onOpen = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutButton dataProviders={[]} onOpen={onOpen} show={true} timelineId="test" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('it renders the expected button text', () => {
|
||||
const onOpen = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutButton dataProviders={[]} onOpen={onOpen} show={true} timelineId="test" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="flyout-button-not-ready-to-drop"]')
|
||||
.first()
|
||||
.text()
|
||||
).toEqual('Timeline');
|
||||
});
|
||||
|
||||
test('it renders the data providers drop target area', () => {
|
||||
const onOpen = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutButton dataProviders={[]} onOpen={onOpen} show={true} timelineId="test" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('it does NOT render the button when show is false', () => {
|
||||
const onOpen = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutButton dataProviders={[]} onOpen={onOpen} show={false} timelineId="test" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="flyout-button-not-ready-to-drop"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('it invokes `onOpen` when clicked', () => {
|
||||
const onOpen = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutButton dataProviders={[]} onOpen={onOpen} show={true} timelineId="test" />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="flyout-button-not-ready-to-drop"]')
|
||||
.first()
|
||||
.simulate('click');
|
||||
wrapper.update();
|
||||
|
||||
expect(onOpen).toBeCalled();
|
||||
});
|
||||
});
|
|
@ -22,7 +22,7 @@ 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 SHOW_HIDE_TRANSLATE_X = 501; // px
|
||||
|
||||
const Container = styled.div`
|
||||
padding-top: 8px;
|
||||
|
@ -131,7 +131,6 @@ export const FlyoutButton = React.memo<FlyoutButtonProps>(
|
|||
onDataProviderRemoved={noop}
|
||||
onToggleDataProviderEnabled={noop}
|
||||
onToggleDataProviderExcluded={noop}
|
||||
show={show}
|
||||
/>
|
||||
)}
|
||||
</WithSource>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Providers rendering renders correctly against snapshot 1`] = `
|
||||
<Fragment>
|
||||
<div>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="none"
|
||||
|
@ -530,5 +530,5 @@ exports[`Providers rendering renders correctly against snapshot 1`] = `
|
|||
</styled.span>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</Fragment>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -30,7 +30,6 @@ describe('DataProviders', () => {
|
|||
onDataProviderRemoved={jest.fn()}
|
||||
onToggleDataProviderEnabled={jest.fn()}
|
||||
onToggleDataProviderExcluded={jest.fn()}
|
||||
show={true}
|
||||
/>
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
@ -49,7 +48,6 @@ describe('DataProviders', () => {
|
|||
onDataProviderRemoved={jest.fn()}
|
||||
onToggleDataProviderEnabled={jest.fn()}
|
||||
onToggleDataProviderExcluded={jest.fn()}
|
||||
show={true}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
@ -68,7 +66,6 @@ describe('DataProviders', () => {
|
|||
onDataProviderRemoved={jest.fn()}
|
||||
onToggleDataProviderEnabled={jest.fn()}
|
||||
onToggleDataProviderExcluded={jest.fn()}
|
||||
show={true}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -14,11 +14,15 @@ 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[] => {
|
||||
export const reorder = ({
|
||||
endIndex,
|
||||
group,
|
||||
startIndex,
|
||||
}: {
|
||||
endIndex: number;
|
||||
group: DataProvidersAnd[];
|
||||
startIndex: number;
|
||||
}): DataProvidersAnd[] => {
|
||||
const groupClone = [...group];
|
||||
const [removed] = groupClone.splice(startIndex, 1); // ⚠️ mutation
|
||||
groupClone.splice(endIndex, 0, removed); // ⚠️ mutation
|
||||
|
@ -95,7 +99,11 @@ export const reArrangeProvidersInSameGroup = ({
|
|||
dataProviderGroups,
|
||||
})
|
||||
) {
|
||||
const reorderedGroup = reorder(dataProviderGroups[groupIndex], source.index, destination.index);
|
||||
const reorderedGroup = reorder({
|
||||
group: dataProviderGroups[groupIndex],
|
||||
startIndex: source.index,
|
||||
endIndex: destination.index,
|
||||
});
|
||||
|
||||
const updatedGroups = dataProviderGroups.reduce<DataProvidersAnd[][]>(
|
||||
(acc, group, i) => [...acc, i === groupIndex ? [...reorderedGroup] : [...group]],
|
||||
|
|
|
@ -34,7 +34,6 @@ interface Props {
|
|||
onDataProviderRemoved: OnDataProviderRemoved;
|
||||
onToggleDataProviderEnabled: OnToggleDataProviderEnabled;
|
||||
onToggleDataProviderExcluded: OnToggleDataProviderExcluded;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const DropTargetDataProvidersContainer = styled.div`
|
||||
|
@ -55,10 +54,14 @@ const DropTargetDataProvidersContainer = styled.div`
|
|||
`;
|
||||
|
||||
const DropTargetDataProviders = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-bottom: 2px;
|
||||
position: relative;
|
||||
border: 0.2rem dashed ${props => props.theme.eui.euiColorMediumShade};
|
||||
border-radius: 5px;
|
||||
margin: 5px 0 5px 0;
|
||||
margin: 2px 0 2px 0;
|
||||
min-height: 100px;
|
||||
overflow-y: auto;
|
||||
background-color: ${props => props.theme.eui.euiFormBackgroundColor};
|
||||
|
@ -94,7 +97,6 @@ export const DataProviders = React.memo<Props>(
|
|||
onDataProviderRemoved,
|
||||
onToggleDataProviderEnabled,
|
||||
onToggleDataProviderExcluded,
|
||||
show,
|
||||
}) => {
|
||||
return (
|
||||
<DropTargetDataProvidersContainer className="drop-target-data-providers-container">
|
||||
|
@ -116,10 +118,7 @@ export const DataProviders = React.memo<Props>(
|
|||
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
|
||||
/>
|
||||
) : (
|
||||
<DroppableWrapper
|
||||
isDropDisabled={!show || isLoading}
|
||||
droppableId={getDroppableId(id)}
|
||||
>
|
||||
<DroppableWrapper isDropDisabled={isLoading} droppableId={getDroppableId(id)}>
|
||||
<Empty />
|
||||
</DroppableWrapper>
|
||||
)}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* 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 { DataProvider, DataProvidersAnd, IS_OPERATOR } from '../data_provider';
|
||||
|
||||
export const providerA: DataProvidersAnd = {
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'context-field.name-a',
|
||||
kqlQuery: '',
|
||||
name: 'a',
|
||||
queryMatch: {
|
||||
field: 'field.name',
|
||||
value: 'a',
|
||||
operator: IS_OPERATOR,
|
||||
},
|
||||
};
|
||||
|
||||
export const providerB: DataProvidersAnd = {
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'context-field.name-b',
|
||||
kqlQuery: '',
|
||||
name: 'b',
|
||||
queryMatch: {
|
||||
field: 'field.name',
|
||||
value: 'b',
|
||||
operator: IS_OPERATOR,
|
||||
},
|
||||
};
|
||||
|
||||
export const providerC: DataProvidersAnd = {
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'context-field.name-c',
|
||||
kqlQuery: '',
|
||||
name: 'c',
|
||||
queryMatch: {
|
||||
field: 'field.name',
|
||||
value: 'c',
|
||||
operator: IS_OPERATOR,
|
||||
},
|
||||
};
|
||||
|
||||
export const providerD: DataProvidersAnd = {
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'context-field.name-d',
|
||||
kqlQuery: '',
|
||||
name: 'd',
|
||||
queryMatch: {
|
||||
field: 'field.name',
|
||||
value: 'd',
|
||||
operator: IS_OPERATOR,
|
||||
},
|
||||
};
|
||||
|
||||
export const providerE: DataProvidersAnd = {
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'context-field.name-e',
|
||||
kqlQuery: '',
|
||||
name: 'e',
|
||||
queryMatch: {
|
||||
field: 'field.name',
|
||||
value: 'e',
|
||||
operator: IS_OPERATOR,
|
||||
},
|
||||
};
|
||||
|
||||
export const providerF: DataProvidersAnd = {
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'context-field.name-f',
|
||||
kqlQuery: '',
|
||||
name: 'f',
|
||||
queryMatch: {
|
||||
field: 'field.name',
|
||||
value: 'f',
|
||||
operator: IS_OPERATOR,
|
||||
},
|
||||
};
|
||||
|
||||
export const twoGroups: DataProvider[] = [
|
||||
{ ...providerA, and: [providerB, providerC] },
|
||||
{ ...providerD, and: [providerE, providerF] },
|
||||
];
|
|
@ -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 '../../../../common/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 '../../../../common/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';
|
|
@ -129,7 +129,7 @@ export const Providers = React.memo<Props>(
|
|||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{dataProviderGroups.map((group, groupIndex) => (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="none" key={`droppable-${groupIndex}`}>
|
||||
<OrFlexItem grow={false}>
|
||||
|
@ -259,7 +259,7 @@ export const Providers = React.memo<Props>(
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -143,7 +143,6 @@ exports[`Header rendering renders correctly against snapshot 1`] = `
|
|||
onDataProviderRemoved={[MockFunction]}
|
||||
onToggleDataProviderEnabled={[MockFunction]}
|
||||
onToggleDataProviderExcluded={[MockFunction]}
|
||||
show={true}
|
||||
/>
|
||||
<Connect(StatefulSearchOrFilterComponent)
|
||||
browserFields={Object {}}
|
||||
|
|
|
@ -44,7 +44,7 @@ describe('Header', () => {
|
|||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it renders the data providers', () => {
|
||||
test('it renders the data providers when show is true', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<TimelineHeader
|
||||
|
@ -66,6 +66,28 @@ describe('Header', () => {
|
|||
expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toEqual(true);
|
||||
});
|
||||
|
||||
test('it does NOT render the data providers when show is false', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<TimelineHeader
|
||||
browserFields={{}}
|
||||
dataProviders={mockDataProviders}
|
||||
filterManager={new FilterManager(mockUiSettingsForFilterManager)}
|
||||
id="foo"
|
||||
indexPattern={indexPattern}
|
||||
onDataProviderEdited={jest.fn()}
|
||||
onDataProviderRemoved={jest.fn()}
|
||||
onToggleDataProviderEnabled={jest.fn()}
|
||||
onToggleDataProviderExcluded={jest.fn()}
|
||||
show={false}
|
||||
showCallOutUnauthorizedMsg={false}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="dataProviders"]').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
test('it renders the unauthorized call out providers', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
|
|
|
@ -68,7 +68,6 @@ const TimelineHeaderComponent: React.FC<Props> = ({
|
|||
onDataProviderRemoved={onDataProviderRemoved}
|
||||
onToggleDataProviderEnabled={onToggleDataProviderEnabled}
|
||||
onToggleDataProviderExcluded={onToggleDataProviderExcluded}
|
||||
show={show}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
Loading…
Reference in a new issue