* use elastic-charts for histogram * add class accessibility * specify onElementClick type annotation * set chartElement tooltip type to Follow * use moment methods for now annotation logic * move historam inside directive folder * remove unused timechart directive * remove dependency from tsvb brush handler * remove non-required class to fix tooltip overflow * change the cursor/crosshair * fix(ie11): add fixed width for header text * fix: annotation colors on dark theme * unpdate click and brush ui functional tests * move functional tests to percy
This commit is contained in:
parent
4c2eee1ecf
commit
6283651dc4
|
@ -54,7 +54,6 @@ import { migrateLegacyQuery } from 'ui/utils/migrate_legacy_query';
|
|||
import { subscribeWithScope } from 'ui/utils/subscribe_with_scope';
|
||||
import { getFilterGenerator } from 'ui/filter_manager';
|
||||
import { SavedObjectsClientProvider } from 'ui/saved_objects';
|
||||
import { VisualizeLoaderProvider } from 'ui/visualize/loader/visualize_loader';
|
||||
import { recentlyAccessed } from 'ui/persisted_log';
|
||||
import { getDocLink } from 'ui/documentation_links';
|
||||
import '../components/fetch_error';
|
||||
|
@ -195,8 +194,6 @@ function discoverController(
|
|||
localStorage,
|
||||
uiCapabilities
|
||||
) {
|
||||
const visualizeLoader = Private(VisualizeLoaderProvider);
|
||||
let visualizeHandler;
|
||||
const Vis = Private(VisProvider);
|
||||
const responseHandler = vislibSeriesResponseHandlerProvider().handler;
|
||||
const getUnhashableStates = Private(getUnhashableStatesProvider);
|
||||
|
@ -213,6 +210,13 @@ function discoverController(
|
|||
|
||||
timefilter.disableTimeRangeSelector();
|
||||
timefilter.disableAutoRefreshSelector();
|
||||
$scope.timefilterUpdateHandler = (ranges) => {
|
||||
timefilter.setTime({
|
||||
from: moment(ranges.from).toISOString(),
|
||||
to: moment(ranges.to).toISOString(),
|
||||
mode: 'absolute',
|
||||
});
|
||||
};
|
||||
|
||||
$scope.getDocLink = getDocLink;
|
||||
$scope.intervalOptions = intervalOptions;
|
||||
|
@ -793,15 +797,7 @@ function discoverController(
|
|||
.resolve(buildVislibDimensions($scope.vis, { timeRange: $scope.timeRange, searchSource: $scope.searchSource }))
|
||||
.then(resp => responseHandler(tabifiedData, resp))
|
||||
.then(resp => {
|
||||
visualizeHandler.render({
|
||||
as: 'visualization',
|
||||
value: {
|
||||
visType: $scope.vis.type.name,
|
||||
visData: resp,
|
||||
visConfig: $scope.vis.params,
|
||||
params: {},
|
||||
}
|
||||
});
|
||||
$scope.histogramData = resp;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1047,13 +1043,6 @@ function discoverController(
|
|||
$scope.searchSource.setField('aggs', function () {
|
||||
return $scope.vis.getAggConfig().toDsl();
|
||||
});
|
||||
|
||||
$timeout(async () => {
|
||||
const visEl = $element.find('#discoverHistogram')[0];
|
||||
visualizeHandler = await visualizeLoader.embedVisualizationWithSavedObject(visEl, visSavedObject, {
|
||||
autoFetch: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveIndexPatternLoading() {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.dscHistogram__header--partial {
|
||||
font-weight: $euiFontWeightRegular;
|
||||
min-width: $euiSize * 12;
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
@import 'no_results';
|
||||
@import 'histogram';
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui';
|
||||
import moment from 'moment-timezone';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import lightEuiTheme from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import darkEuiTheme from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
|
||||
import {
|
||||
AnnotationDomainTypes,
|
||||
Axis,
|
||||
Chart,
|
||||
HistogramBarSeries,
|
||||
GeometryValue,
|
||||
getAnnotationId,
|
||||
getAxisId,
|
||||
getSpecId,
|
||||
LineAnnotation,
|
||||
Position,
|
||||
ScaleType,
|
||||
Settings,
|
||||
RectAnnotation,
|
||||
TooltipValue,
|
||||
TooltipType,
|
||||
} from '@elastic/charts';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { getChartTheme } from 'ui/elastic_charts';
|
||||
import chrome from 'ui/chrome';
|
||||
// @ts-ignore: path dynamic for kibana
|
||||
import { timezoneProvider } from 'ui/vis/lib/timezone';
|
||||
|
||||
export interface DiscoverHistogramProps {
|
||||
chartData: any;
|
||||
timefilterUpdateHandler: (ranges: { from: number; to: number }) => void;
|
||||
}
|
||||
|
||||
export class DiscoverHistogram extends Component<DiscoverHistogramProps> {
|
||||
public static propTypes = {
|
||||
chartData: PropTypes.object,
|
||||
timefilterUpdateHandler: PropTypes.func,
|
||||
};
|
||||
|
||||
public onBrushEnd = (min: number, max: number) => {
|
||||
const range = {
|
||||
from: min,
|
||||
to: max,
|
||||
};
|
||||
|
||||
this.props.timefilterUpdateHandler(range);
|
||||
};
|
||||
|
||||
public onElementClick = (xInterval: number) => (elementData: GeometryValue[]) => {
|
||||
const startRange = elementData[0].x;
|
||||
|
||||
const range = {
|
||||
from: startRange,
|
||||
to: startRange + xInterval,
|
||||
};
|
||||
|
||||
this.props.timefilterUpdateHandler(range);
|
||||
};
|
||||
|
||||
public formatXValue = (val: string) => {
|
||||
const xAxisFormat = this.props.chartData.xAxisFormat.params.pattern;
|
||||
|
||||
return moment(val).format(xAxisFormat);
|
||||
};
|
||||
|
||||
public renderBarTooltip = (xInterval: number, domainStart: number, domainEnd: number) => (
|
||||
headerData: TooltipValue
|
||||
): JSX.Element | string => {
|
||||
const headerDataValue = headerData.value;
|
||||
const formattedValue = this.formatXValue(headerDataValue);
|
||||
|
||||
const partialDataText = i18n.translate('kbn.discover.histogram.partialData.bucketTooltipText', {
|
||||
defaultMessage:
|
||||
'The selected time range does not include this entire bucket, it may contain partial data.',
|
||||
});
|
||||
|
||||
if (headerDataValue < domainStart || headerDataValue + xInterval > domainEnd) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
className="dscHistogram__header--partial"
|
||||
responsive={false}
|
||||
gutterSize="xs"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type="iInCircle" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{partialDataText}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="xs" />
|
||||
<p>{formattedValue}</p>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return formattedValue;
|
||||
};
|
||||
|
||||
public render() {
|
||||
const uiSettings = chrome.getUiSettingsClient();
|
||||
const timeZone = timezoneProvider(uiSettings)();
|
||||
const { chartData } = this.props;
|
||||
|
||||
if (!chartData || !chartData.series[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = chartData.series[0].values;
|
||||
|
||||
/**
|
||||
* Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval].
|
||||
* see https://github.com/elastic/kibana/issues/27410
|
||||
* TODO: Once the Discover query has been update, we should change the below to use the new field
|
||||
*/
|
||||
const xInterval = chartData.ordered.interval;
|
||||
|
||||
const xValues = chartData.xAxisOrderedValues;
|
||||
const lastXValue = xValues[xValues.length - 1];
|
||||
|
||||
const domain = chartData.ordered;
|
||||
const domainStart = domain.min.valueOf();
|
||||
const domainEnd = domain.max.valueOf();
|
||||
|
||||
const domainMin = data[0].x > domainStart ? domainStart : data[0].x;
|
||||
const domainMax = domainEnd - xInterval > lastXValue ? domainEnd - xInterval : lastXValue;
|
||||
|
||||
const xDomain = {
|
||||
min: domainMin,
|
||||
max: domainMax,
|
||||
minInterval: xInterval,
|
||||
};
|
||||
|
||||
// Domain end of 'now' will be milliseconds behind current time, so we extend time by 1 minute and check if
|
||||
// the annotation is within this range; if so, the line annotation uses the domainEnd as its value
|
||||
const now = moment();
|
||||
const isAnnotationAtEdge =
|
||||
moment(domainEnd)
|
||||
.add(60000)
|
||||
.isAfter(now) && now.isAfter(domainEnd);
|
||||
const lineAnnotationValue = isAnnotationAtEdge ? domainEnd : now;
|
||||
|
||||
const lineAnnotationData = [
|
||||
{
|
||||
dataValue: lineAnnotationValue,
|
||||
},
|
||||
];
|
||||
const isDarkMode = uiSettings.get('theme:darkMode');
|
||||
|
||||
const lineAnnotationStyle = {
|
||||
line: {
|
||||
strokeWidth: 2,
|
||||
stroke: isDarkMode ? darkEuiTheme.euiColorDanger : lightEuiTheme.euiColorDanger,
|
||||
opacity: 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
const rectAnnotations = [];
|
||||
if (domainStart !== domainMin) {
|
||||
rectAnnotations.push({
|
||||
coordinates: {
|
||||
x1: domainStart,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (domainEnd !== domainMax) {
|
||||
rectAnnotations.push({
|
||||
coordinates: {
|
||||
x0: domainEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rectAnnotationStyle = {
|
||||
stroke: isDarkMode ? darkEuiTheme.euiColorLightShade : lightEuiTheme.euiColorDarkShade,
|
||||
strokeWidth: 0,
|
||||
opacity: isDarkMode ? 0.6 : 0.2,
|
||||
fill: isDarkMode ? darkEuiTheme.euiColorLightShade : lightEuiTheme.euiColorDarkShade,
|
||||
};
|
||||
|
||||
const tooltipProps = {
|
||||
headerFormatter: this.renderBarTooltip(xInterval, domainStart, domainEnd),
|
||||
type: TooltipType.VerticalCursor,
|
||||
};
|
||||
|
||||
return (
|
||||
<Chart size="100%">
|
||||
<Settings
|
||||
xDomain={xDomain}
|
||||
onBrushEnd={this.onBrushEnd}
|
||||
onElementClick={this.onElementClick(xInterval)}
|
||||
tooltip={tooltipProps}
|
||||
theme={getChartTheme()}
|
||||
/>
|
||||
<Axis
|
||||
id={getAxisId('discover-histogram-left-axis')}
|
||||
position={Position.Left}
|
||||
ticks={5}
|
||||
title={chartData.yAxisLabel}
|
||||
/>
|
||||
<Axis
|
||||
id={getAxisId('discover-histogram-bottom-axis')}
|
||||
position={Position.Bottom}
|
||||
title={chartData.xAxisLabel}
|
||||
tickFormat={this.formatXValue}
|
||||
ticks={10}
|
||||
/>
|
||||
<LineAnnotation
|
||||
annotationId={getAnnotationId('line-annotation')}
|
||||
domainType={AnnotationDomainTypes.XDomain}
|
||||
dataValues={lineAnnotationData}
|
||||
hideTooltips={true}
|
||||
style={lineAnnotationStyle}
|
||||
/>
|
||||
<RectAnnotation
|
||||
dataValues={rectAnnotations}
|
||||
annotationId={getAnnotationId('rect-annotation')}
|
||||
zIndex={2}
|
||||
style={rectAnnotationStyle}
|
||||
hideTooltips={true}
|
||||
/>
|
||||
<HistogramBarSeries
|
||||
id={getSpecId('discover-histogram')}
|
||||
xScaleType={ScaleType.Time}
|
||||
yScaleType={ScaleType.Linear}
|
||||
xAccessor="x"
|
||||
yAccessors={['y']}
|
||||
data={data}
|
||||
timeZone={timeZone}
|
||||
name={chartData.yAxisLabel}
|
||||
/>
|
||||
</Chart>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -20,14 +20,10 @@
|
|||
import 'ngreact';
|
||||
import { wrapInI18nContext } from 'ui/i18n';
|
||||
import { uiModules } from 'ui/modules';
|
||||
|
||||
import { DiscoverNoResults } from './no_results';
|
||||
|
||||
import { DiscoverUninitialized } from './uninitialized';
|
||||
|
||||
import { DiscoverUnsupportedIndexPattern } from './unsupported_index_pattern';
|
||||
|
||||
import './timechart';
|
||||
import { DiscoverHistogram } from './histogram';
|
||||
|
||||
const app = uiModules.get('apps/discover', ['react']);
|
||||
|
||||
|
@ -42,3 +38,5 @@ app.directive('discoverUninitialized', reactDirective =>
|
|||
app.directive('discoverUnsupportedIndexPattern', reactDirective =>
|
||||
reactDirective(wrapInI18nContext(DiscoverUnsupportedIndexPattern), ['unsupportedType'])
|
||||
);
|
||||
|
||||
app.directive('discoverHistogram', reactDirective => reactDirective(DiscoverHistogram));
|
||||
|
|
|
@ -162,11 +162,13 @@
|
|||
|
||||
</header>
|
||||
|
||||
<div id="discoverHistogram"
|
||||
ng-show="vis && rows.length !== 0"
|
||||
<discover-histogram
|
||||
style="display: flex; height: 200px"
|
||||
>
|
||||
</div>
|
||||
ng-show="vis && rows.length !== 0"
|
||||
chart-data="histogramData"
|
||||
timefilter-update-handler="timefilterUpdateHandler"
|
||||
watch-depth="reference"
|
||||
></discover-histogram>
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
|
|
@ -17,35 +17,10 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import VislibProvider from 'ui/vislib';
|
||||
import { uiModules } from 'ui/modules';
|
||||
uiModules
|
||||
.get('apps/discover')
|
||||
.directive('discoverTimechart', function (Private) {
|
||||
const vislib = Private(VislibProvider);
|
||||
import chrome from 'ui/chrome';
|
||||
import { Theme, LIGHT_THEME, DARK_THEME } from '@elastic/charts';
|
||||
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
data: '='
|
||||
},
|
||||
link: function ($scope, elem) {
|
||||
|
||||
const init = function () {
|
||||
// This elem should already have a height/width
|
||||
const myChart = new vislib.Chart(elem[0], {
|
||||
addLegend: false
|
||||
});
|
||||
|
||||
$scope.$watch('data', function (data) {
|
||||
if (data != null) {
|
||||
myChart.render(data);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start the directive
|
||||
init();
|
||||
}
|
||||
};
|
||||
});
|
||||
export function getChartTheme(): Theme {
|
||||
const isDarkMode = chrome.getUiSettingsClient().get('theme:darkMode');
|
||||
return isDarkMode ? DARK_THEME : LIGHT_THEME;
|
||||
}
|
|
@ -26,7 +26,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
const browser = getService('browser');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const filterBar = getService('filterBar');
|
||||
const PageObjects = getPageObjects(['common', 'discover', 'header', 'visualize', 'timePicker']);
|
||||
const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']);
|
||||
const defaultSettings = {
|
||||
defaultIndex: 'logstash-*',
|
||||
};
|
||||
|
@ -84,83 +84,30 @@ export default function ({ getService, getPageObjects }) {
|
|||
});
|
||||
});
|
||||
|
||||
it('should show the correct bar chart', async function () {
|
||||
const expectedBarChartData = [
|
||||
35,
|
||||
189,
|
||||
694,
|
||||
1347,
|
||||
1285,
|
||||
704,
|
||||
176,
|
||||
29,
|
||||
39,
|
||||
189,
|
||||
640,
|
||||
1276,
|
||||
1327,
|
||||
663,
|
||||
166,
|
||||
25,
|
||||
30,
|
||||
164,
|
||||
663,
|
||||
1320,
|
||||
1270,
|
||||
681,
|
||||
188,
|
||||
27,
|
||||
];
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
|
||||
it('should show correct time range string in chart', async function () {
|
||||
const actualTimeString = await PageObjects.discover.getChartTimespan();
|
||||
const expectedTimeString = `Sep 19, 2015 @ 06:31:44.000 - Sep 23, 2015 @ 18:31:44.000`;
|
||||
expect(actualTimeString).to.be(expectedTimeString);
|
||||
});
|
||||
|
||||
it('should show bars in the correct time zone', async function () {
|
||||
const maxTicks = [
|
||||
'2015-09-20 00:00',
|
||||
'2015-09-20 12:00',
|
||||
'2015-09-21 00:00',
|
||||
'2015-09-21 12:00',
|
||||
'2015-09-22 00:00',
|
||||
'2015-09-22 12:00',
|
||||
'2015-09-23 00:00',
|
||||
'2015-09-23 12:00'
|
||||
];
|
||||
|
||||
for (const tick of await PageObjects.discover.getBarChartXTicks()) {
|
||||
if (!maxTicks.includes(tick)) {
|
||||
throw new Error(`unexpected x-axis tick "${tick}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should modify the time range when a bar is clicked', async function () {
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await PageObjects.discover.clickHistogramBar(0);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await PageObjects.discover.clickHistogramBar();
|
||||
const time = await PageObjects.timePicker.getTimeConfig();
|
||||
expect(time.start).to.be('Sep 20, 2015 @ 00:00:00.000');
|
||||
expect(time.end).to.be('Sep 20, 2015 @ 03:00:00.000');
|
||||
const rowData = await PageObjects.discover.getDocTableIndex(1);
|
||||
expect(rowData).to.have.string('Sep 20, 2015 @ 02:57:03.761');
|
||||
expect(time.start).to.be('Sep 21, 2015 @ 09:00:00.000');
|
||||
expect(time.end).to.be('Sep 21, 2015 @ 12:00:00.000');
|
||||
const rowData = await PageObjects.discover.getDocTableField(1);
|
||||
expect(rowData).to.have.string('Sep 21, 2015 @ 11:59:22.316');
|
||||
});
|
||||
|
||||
it('should modify the time range when the histogram is brushed', async function () {
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await PageObjects.discover.brushHistogram(0, 1);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await PageObjects.discover.brushHistogram();
|
||||
|
||||
const newDurationHours = await PageObjects.timePicker.getTimeDurationInHours();
|
||||
expect(Math.round(newDurationHours)).to.be(3);
|
||||
expect(Math.round(newDurationHours)).to.be(108);
|
||||
const rowData = await PageObjects.discover.getDocTableField(1);
|
||||
expect(rowData).to.have.string('Sep 20, 2015 @ 02:56:02.323');
|
||||
expect(rowData).to.have.string('Sep 22, 2015 @ 23:50:13.253');
|
||||
});
|
||||
|
||||
it('should show correct initial chart interval of Auto', async function () {
|
||||
|
@ -172,167 +119,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
expect(actualInterval).to.be(expectedInterval);
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Hourly', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Hourly');
|
||||
|
||||
const expectedBarChartData = [
|
||||
4,
|
||||
7,
|
||||
16,
|
||||
23,
|
||||
38,
|
||||
87,
|
||||
132,
|
||||
159,
|
||||
248,
|
||||
320,
|
||||
349,
|
||||
376,
|
||||
380,
|
||||
324,
|
||||
293,
|
||||
233,
|
||||
188,
|
||||
125,
|
||||
69,
|
||||
40,
|
||||
28,
|
||||
17,
|
||||
2,
|
||||
3,
|
||||
8,
|
||||
10,
|
||||
12,
|
||||
28,
|
||||
36,
|
||||
84,
|
||||
111,
|
||||
157,
|
||||
229,
|
||||
292,
|
||||
324,
|
||||
373,
|
||||
378,
|
||||
345,
|
||||
306,
|
||||
223,
|
||||
167,
|
||||
124,
|
||||
72,
|
||||
35,
|
||||
22,
|
||||
11,
|
||||
7,
|
||||
1,
|
||||
6,
|
||||
5,
|
||||
12,
|
||||
25,
|
||||
27,
|
||||
76,
|
||||
111,
|
||||
175,
|
||||
228,
|
||||
294,
|
||||
358,
|
||||
372,
|
||||
366,
|
||||
344,
|
||||
276,
|
||||
213,
|
||||
201,
|
||||
113,
|
||||
72,
|
||||
39,
|
||||
36,
|
||||
12,
|
||||
7,
|
||||
3,
|
||||
];
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Daily', async function () {
|
||||
const chartInterval = 'Daily';
|
||||
const expectedBarChartData = [4757, 4614, 4633];
|
||||
await PageObjects.discover.setChartInterval(chartInterval);
|
||||
await retry.try(async () => {
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Weekly', async function () {
|
||||
const chartInterval = 'Weekly';
|
||||
const expectedBarChartData = [4757, 9247];
|
||||
|
||||
await PageObjects.discover.setChartInterval(chartInterval);
|
||||
await retry.try(async () => {
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
});
|
||||
|
||||
it('browser back button should show previous interval Daily', async function () {
|
||||
const expectedChartInterval = 'Daily';
|
||||
const expectedBarChartData = [4757, 4614, 4633];
|
||||
|
||||
await browser.goBack();
|
||||
await retry.try(async function tryingForTime() {
|
||||
const actualInterval = await PageObjects.discover.getChartInterval();
|
||||
expect(actualInterval).to.be(expectedChartInterval);
|
||||
});
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Monthly', async function () {
|
||||
const chartInterval = 'Monthly';
|
||||
const expectedBarChartData = [13129];
|
||||
|
||||
await PageObjects.discover.setChartInterval(chartInterval);
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Yearly', async function () {
|
||||
const chartInterval = 'Yearly';
|
||||
const expectedBarChartData = [13129];
|
||||
|
||||
await PageObjects.discover.setChartInterval(chartInterval);
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Auto', async function () {
|
||||
const chartInterval = 'Auto';
|
||||
const expectedBarChartData = [
|
||||
35,
|
||||
189,
|
||||
694,
|
||||
1347,
|
||||
1285,
|
||||
704,
|
||||
176,
|
||||
29,
|
||||
39,
|
||||
189,
|
||||
640,
|
||||
1276,
|
||||
1327,
|
||||
663,
|
||||
166,
|
||||
25,
|
||||
30,
|
||||
164,
|
||||
663,
|
||||
1320,
|
||||
1270,
|
||||
681,
|
||||
188,
|
||||
27,
|
||||
];
|
||||
|
||||
await PageObjects.discover.setChartInterval(chartInterval);
|
||||
await verifyChartData(expectedBarChartData);
|
||||
});
|
||||
|
||||
it('should show Auto chart interval', async function () {
|
||||
const expectedChartInterval = 'Auto';
|
||||
|
||||
|
@ -344,42 +130,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
const isVisible = await PageObjects.discover.hasNoResults();
|
||||
expect(isVisible).to.be(false);
|
||||
});
|
||||
|
||||
async function verifyChartData(expectedBarChartData) {
|
||||
await retry.try(async function tryingForTime() {
|
||||
const paths = await PageObjects.discover.getBarChartData();
|
||||
// the largest bars are over 100 pixels high so this is less than 1% tolerance
|
||||
const barHeightTolerance = 1;
|
||||
let stringResults = '';
|
||||
let hasFailure = false;
|
||||
for (let y = 0; y < expectedBarChartData.length; y++) {
|
||||
stringResults +=
|
||||
y +
|
||||
': expected = ' +
|
||||
expectedBarChartData[y] +
|
||||
', actual = ' +
|
||||
paths[y] +
|
||||
', Pass = ' +
|
||||
(Math.abs(expectedBarChartData[y] - paths[y]) <
|
||||
barHeightTolerance) +
|
||||
'\n';
|
||||
if (
|
||||
Math.abs(expectedBarChartData[y] - paths[y]) > barHeightTolerance
|
||||
) {
|
||||
hasFailure = true;
|
||||
}
|
||||
}
|
||||
if (hasFailure) {
|
||||
log.debug(stringResults);
|
||||
log.debug(paths);
|
||||
}
|
||||
for (let x = 0; x < expectedBarChartData.length; x++) {
|
||||
expect(
|
||||
Math.abs(expectedBarChartData[x] - paths[x]) < barHeightTolerance
|
||||
).to.be.ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('query #2, which has an empty time range', () => {
|
||||
|
@ -437,7 +187,8 @@ export default function ({ getService, getPageObjects }) {
|
|||
});
|
||||
|
||||
describe('time zone switch', () => {
|
||||
it('should show bars in the correct time zone after switching', async function () {
|
||||
// skipping this until we get an elastic-chart alternative to check the ticks value
|
||||
it.skip('should show ticks in the correct time zone after switching', async function () {
|
||||
await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' });
|
||||
await browser.refresh();
|
||||
await PageObjects.header.awaitKibanaChrome();
|
||||
|
@ -462,6 +213,16 @@ export default function ({ getService, getPageObjects }) {
|
|||
}
|
||||
});
|
||||
});
|
||||
it('should show bars in the correct time zone after switching', async function () {
|
||||
await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' });
|
||||
await browser.refresh();
|
||||
await PageObjects.header.awaitKibanaChrome();
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
|
||||
log.debug('check that the newest doc timestamp is now -7 hours from the UTC time in the first test');
|
||||
const rowData = await PageObjects.discover.getDocTableIndex(1);
|
||||
expect(rowData.startsWith('Sep 22, 2015 @ 16:50:13.253')).to.be.ok();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -129,13 +129,13 @@ export default function ({ getService, getPageObjects }) {
|
|||
const toTime = '2015-09-18 18:31:44.000';
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName);
|
||||
await retry.try(async function () {
|
||||
await PageObjects.discover.clickFieldListItemAdd(scriptedPainlessFieldName);
|
||||
});
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
const rowData = await PageObjects.discover.getDocTableIndex(1);
|
||||
expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\n18');
|
||||
|
@ -147,7 +147,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await log.debug('filter by the first value (14) in the expanded scripted field list');
|
||||
await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName, '14');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
expect(await PageObjects.discover.getHitCount()).to.be('31');
|
||||
});
|
||||
|
@ -161,7 +161,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await filterBar.removeAllFilters();
|
||||
await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await inspector.open();
|
||||
await inspector.setTablePageSize(50);
|
||||
await inspector.expectTableData(expectedChartValues);
|
||||
|
@ -191,13 +191,13 @@ export default function ({ getService, getPageObjects }) {
|
|||
const toTime = '2015-09-18 18:31:44.000';
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2);
|
||||
await retry.try(async function () {
|
||||
await PageObjects.discover.clickFieldListItemAdd(scriptedPainlessFieldName2);
|
||||
});
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
const rowData = await PageObjects.discover.getDocTableIndex(1);
|
||||
expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\ngood');
|
||||
|
@ -210,7 +210,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await log.debug('filter by "bad" in the expanded scripted field list');
|
||||
await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName2, 'bad');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
expect(await PageObjects.discover.getHitCount()).to.be('27');
|
||||
});
|
||||
|
@ -220,7 +220,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
it('should visualize scripted field in vertical bar chart', async function () {
|
||||
await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await inspector.open();
|
||||
await inspector.expectTableData([
|
||||
['good', '359'],
|
||||
|
@ -252,13 +251,13 @@ export default function ({ getService, getPageObjects }) {
|
|||
const toTime = '2015-09-18 18:31:44.000';
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2);
|
||||
await retry.try(async function () {
|
||||
await PageObjects.discover.clickFieldListItemAdd(scriptedPainlessFieldName2);
|
||||
});
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
const rowData = await PageObjects.discover.getDocTableIndex(1);
|
||||
expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\ntrue');
|
||||
|
@ -271,7 +270,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await log.debug('filter by "true" in the expanded scripted field list');
|
||||
await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName2, 'true');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
expect(await PageObjects.discover.getHitCount()).to.be('359');
|
||||
});
|
||||
|
@ -281,7 +280,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
it('should visualize scripted field in vertical bar chart', async function () {
|
||||
await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await inspector.open();
|
||||
await inspector.expectTableData([
|
||||
['true', '359'],
|
||||
|
@ -314,13 +312,13 @@ export default function ({ getService, getPageObjects }) {
|
|||
const toTime = '2015-09-18 07:00:00.000';
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2);
|
||||
await retry.try(async function () {
|
||||
await PageObjects.discover.clickFieldListItemAdd(scriptedPainlessFieldName2);
|
||||
});
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
const rowData = await PageObjects.discover.getDocTableIndex(1);
|
||||
expect(rowData).to.be('Sep 18, 2015 @ 06:52:55.953\n2015-09-18 07:00');
|
||||
|
@ -332,7 +330,7 @@ export default function ({ getService, getPageObjects }) {
|
|||
await log.debug('filter by "2015-09-17 23:00" in the expanded scripted field list');
|
||||
await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName2, '2015-09-17 23:00');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
|
||||
await retry.try(async function () {
|
||||
expect(await PageObjects.discover.getHitCount()).to.be('1');
|
||||
});
|
||||
|
@ -342,7 +340,6 @@ export default function ({ getService, getPageObjects }) {
|
|||
it('should visualize scripted field in vertical bar chart', async function () {
|
||||
await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.visualize.waitForVisualization();
|
||||
await inspector.open();
|
||||
await inspector.setTablePageSize(50);
|
||||
await inspector.expectTableData([
|
||||
|
|
|
@ -116,16 +116,20 @@ export function DiscoverPageProvider({ getService, getPageObjects }) {
|
|||
await testSubjects.click('discoverOpenButton');
|
||||
}
|
||||
|
||||
async clickHistogramBar(i) {
|
||||
const bars = await find.allByCssSelector(`.series.histogram rect`);
|
||||
await bars[i].click();
|
||||
async clickHistogramBar() {
|
||||
const el = await find.byCssSelector('.echChart canvas:last-of-type');
|
||||
|
||||
await browser.getActions()
|
||||
.move({ x: 200, y: 20, origin: el._webElement })
|
||||
.click()
|
||||
.perform();
|
||||
}
|
||||
|
||||
async brushHistogram(from, to) {
|
||||
const bars = await find.allByCssSelector('.series.histogram rect');
|
||||
async brushHistogram() {
|
||||
const el = await find.byCssSelector('.echChart canvas:last-of-type');
|
||||
await browser.dragAndDrop(
|
||||
{ location: bars[from], offset: { x: 0, y: -5 } },
|
||||
{ location: bars[to], offset: { x: 0, y: -5 } }
|
||||
{ location: el, offset: { x: 200, y: 20 } },
|
||||
{ location: el, offset: { x: 400, y: 30 } }
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -133,12 +137,6 @@ export function DiscoverPageProvider({ getService, getPageObjects }) {
|
|||
return await globalNav.getLastBreadcrumb();
|
||||
}
|
||||
|
||||
async getBarChartXTicks() {
|
||||
const xAxis = await find.byCssSelector('.x.axis.CategoryAxis-1');
|
||||
const $ = await xAxis.parseDomContent();
|
||||
return $('.tick > text').toArray().map(tick => $(tick).text().trim());
|
||||
}
|
||||
|
||||
async getBarChartData() {
|
||||
let yAxisLabel = 0;
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) {
|
|||
return {
|
||||
...functionalConfig.getAll(),
|
||||
|
||||
testFiles: [require.resolve('./tests/console_app')],
|
||||
testFiles: [require.resolve('./tests/console_app'), require.resolve('./tests/discover')],
|
||||
|
||||
services,
|
||||
|
||||
|
|
|
@ -27,15 +27,15 @@ import { FtrProviderContext } from '../../ftr_provider_context';
|
|||
// @ts-ignore internal js that is passed to the browser as is
|
||||
import { takePercySnapshot, takePercySnapshotWithAgent } from './take_percy_snapshot';
|
||||
|
||||
export const DEFAULT_OPTIONS = {
|
||||
widths: [1200],
|
||||
};
|
||||
|
||||
export async function VisualTestingProvider({ getService }: FtrProviderContext) {
|
||||
const browser = getService('browser');
|
||||
const log = getService('log');
|
||||
const lifecycle = getService('lifecycle');
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
widths: [1200],
|
||||
};
|
||||
|
||||
let currentTest: Test | undefined;
|
||||
lifecycle.on('beforeEachTest', (test: Test) => {
|
||||
currentTest = test;
|
||||
|
|
123
test/visual_regression/tests/discover/chart_visualization.js
Normal file
123
test/visual_regression/tests/discover/chart_visualization.js
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
|
||||
export default function ({ getService, getPageObjects }) {
|
||||
const log = getService('log');
|
||||
const retry = getService('retry');
|
||||
const esArchiver = getService('esArchiver');
|
||||
const browser = getService('browser');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']);
|
||||
const visualTesting = getService('visualTesting');
|
||||
const defaultSettings = {
|
||||
defaultIndex: 'logstash-*',
|
||||
};
|
||||
|
||||
describe('discover', function describeIndexTests() {
|
||||
const fromTime = '2015-09-19 06:31:44.000';
|
||||
const toTime = '2015-09-23 18:31:44.000';
|
||||
|
||||
before(async function () {
|
||||
log.debug('load kibana index with default index pattern');
|
||||
await esArchiver.load('discover');
|
||||
|
||||
// and load a set of makelogs data
|
||||
await esArchiver.loadIfNeeded('logstash_functional');
|
||||
await kibanaServer.uiSettings.replace(defaultSettings);
|
||||
log.debug('discover');
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
});
|
||||
|
||||
describe('query', function () {
|
||||
this.tags(['skipFirefox']);
|
||||
|
||||
it('should show bars in the correct time zone', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Hourly', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Hourly');
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Daily', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Daily');
|
||||
await retry.try(async () => {
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Weekly', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Weekly');
|
||||
await retry.try(async () => {
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('browser back button should show previous interval Daily', async function () {
|
||||
await browser.goBack();
|
||||
await retry.try(async function tryingForTime() {
|
||||
const actualInterval = await PageObjects.discover.getChartInterval();
|
||||
expect(actualInterval).to.be('Daily');
|
||||
});
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Monthly', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Monthly');
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Yearly', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Yearly');
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
|
||||
it('should show correct data for chart interval Auto', async function () {
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await PageObjects.discover.setChartInterval('Auto');
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('time zone switch', () => {
|
||||
it('should show bars in the correct time zone after switching', async function () {
|
||||
await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' });
|
||||
await browser.refresh();
|
||||
await PageObjects.header.awaitKibanaChrome();
|
||||
await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime);
|
||||
await PageObjects.header.awaitGlobalLoadingIndicatorHidden();
|
||||
await retry.try(async function () {
|
||||
await visualTesting.snapshot();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
}
|
42
test/visual_regression/tests/discover/index.js
Normal file
42
test/visual_regression/tests/discover/index.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { DEFAULT_OPTIONS } from '../../services/visual_testing/visual_testing';
|
||||
|
||||
// Width must be the same as visual_testing or canvas image widths will get skewed
|
||||
const [SCREEN_WIDTH] = DEFAULT_OPTIONS.widths || [];
|
||||
|
||||
export default function ({ getService, loadTestFile }) {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const browser = getService('browser');
|
||||
|
||||
describe('discover app', function () {
|
||||
this.tags('ciGroup6');
|
||||
|
||||
before(function () {
|
||||
return browser.setWindowSize(SCREEN_WIDTH, 1000);
|
||||
});
|
||||
|
||||
after(function unloadMakelogs() {
|
||||
return esArchiver.unload('logstash_functional');
|
||||
});
|
||||
|
||||
loadTestFile(require.resolve('./chart_visualization'));
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue