[Observability][Exploratory View] revert exploratory view multi-series (#107647)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dominique Clarke 2021-08-10 11:52:49 -04:00 committed by GitHub
parent 328c36dedc
commit 1649661ffd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
123 changed files with 2595 additions and 3871 deletions

View file

@ -22,6 +22,11 @@ interface UrlParam {
username?: string;
}
interface App {
pathname?: string;
hash?: string;
}
/**
* Converts a config and a pathname to a url
* @param {object} config A url config
@ -41,11 +46,11 @@ interface UrlParam {
* @return {string}
*/
function getUrl(config: UrlParam, app: UrlParam) {
function getUrl(config: UrlParam, app: App) {
return url.format(_.assign({}, config, app));
}
getUrl.noAuth = function getUrlNoAuth(config: UrlParam, app: UrlParam) {
getUrl.noAuth = function getUrlNoAuth(config: UrlParam, app: App) {
config = _.pickBy(config, function (val, param) {
return param !== 'auth';
});

View file

@ -202,13 +202,7 @@ export class CommonPageObject extends FtrService {
async navigateToApp(
appName: string,
{
basePath = '',
shouldLoginIfPrompted = true,
hash = '',
search = '',
insertTimestamp = true,
} = {}
{ basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {}
) {
let appUrl: string;
if (this.config.has(['apps', appName])) {
@ -217,13 +211,11 @@ export class CommonPageObject extends FtrService {
appUrl = getUrl.noAuth(this.config.get('servers.kibana'), {
pathname: `${basePath}${appConfig.pathname}`,
hash: hash || appConfig.hash,
search,
});
} else {
appUrl = getUrl.noAuth(this.config.get('servers.kibana'), {
pathname: `${basePath}/app/${appName}`,
hash,
search,
});
}

View file

@ -11,11 +11,11 @@ import { i18n } from '@kbn/i18n';
import {
createExploratoryViewUrl,
HeaderMenuPortal,
SeriesUrl,
} from '../../../../../../observability/public';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { AppMountParameters } from '../../../../../../../../src/core/public';
import { SERVICE_NAME } from '../../../../../common/elasticsearch_fieldnames';
const ANALYZE_DATA = i18n.translate('xpack.apm.analyzeDataButtonLabel', {
defaultMessage: 'Analyze data',
@ -38,22 +38,15 @@ export function UXActionMenu({
services: { http },
} = useKibana();
const { urlParams } = useUrlParams();
const { rangeTo, rangeFrom, serviceName } = urlParams;
const { rangeTo, rangeFrom } = urlParams;
const uxExploratoryViewLink = createExploratoryViewUrl(
{
reportType: 'kpi-over-time',
allSeries: [
{
dataType: 'ux',
name: `${serviceName}-page-views`,
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
[SERVICE_NAME]: serviceName ? [serviceName] : [],
},
selectedMetricField: 'Records',
},
],
'ux-series': ({
dataType: 'ux',
isNew: true,
time: { from: rangeFrom, to: rangeTo },
} as unknown) as SeriesUrl,
},
http?.basePath.get()
);
@ -67,7 +60,6 @@ export function UXActionMenu({
<EuiHeaderLinks gutterSize="xs">
<EuiToolTip position="top" content={<p>{ANALYZE_MESSAGE}</p>}>
<EuiHeaderLink
data-test-subj="uxAnalyzeBtn"
color="text"
href={uxExploratoryViewLink}
iconType="visBarVerticalStacked"

View file

@ -87,18 +87,15 @@ export function PageLoadDistribution() {
const exploratoryViewLink = createExploratoryViewUrl(
{
reportType: 'kpi-over-time',
allSeries: [
{
name: `${serviceName}-page-views`,
dataType: 'ux',
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
'service.name': serviceName as string[],
},
...(breakdown ? { breakdown: breakdown.fieldName } : {}),
[`${serviceName}-page-views`]: {
dataType: 'ux',
reportType: 'data-distribution',
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
'service.name': serviceName as string[],
},
],
...(breakdown ? { breakdown: breakdown.fieldName } : {}),
},
},
http?.basePath.get()
);

View file

@ -62,18 +62,15 @@ export function PageViewsTrend() {
const exploratoryViewLink = createExploratoryViewUrl(
{
reportType: 'kpi-over-time',
allSeries: [
{
name: `${serviceName}-page-views`,
dataType: 'ux',
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
'service.name': serviceName as string[],
},
...(breakdown ? { breakdown: breakdown.fieldName } : {}),
[`${serviceName}-page-views`]: {
dataType: 'ux',
reportType: 'kpi-over-time',
time: { from: rangeFrom!, to: rangeTo! },
reportDefinitions: {
'service.name': serviceName as string[],
},
],
...(breakdown ? { breakdown: breakdown.fieldName } : {}),
},
},
http?.basePath.get()
);

View file

@ -38,7 +38,7 @@ describe('AnalyzeDataButton', () => {
render(<Example agentName="rum-js" />);
expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual(
'http://localhost/app/observability/exploratory-view/configure#?reportType=kpi-over-time&sr=!((dt:ux,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),time:(from:now-15m,to:now)))'
'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:ux,isNew:!t,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))'
);
});
});
@ -48,7 +48,7 @@ describe('AnalyzeDataButton', () => {
render(<Example agentName="iOS/swift" />);
expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual(
'http://localhost/app/observability/exploratory-view/configure#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),time:(from:now-15m,to:now)))'
'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))'
);
});
});
@ -58,7 +58,7 @@ describe('AnalyzeDataButton', () => {
render(<Example environment={undefined} />);
expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual(
'http://localhost/app/observability/exploratory-view/configure#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.name:!(testServiceName)),time:(from:now-15m,to:now)))'
'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))'
);
});
});
@ -68,7 +68,7 @@ describe('AnalyzeDataButton', () => {
render(<Example environment={ENVIRONMENT_NOT_DEFINED.value} />);
expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual(
'http://localhost/app/observability/exploratory-view/configure#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(ENVIRONMENT_NOT_DEFINED),service.name:!(testServiceName)),time:(from:now-15m,to:now)))'
'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))'
);
});
});
@ -78,7 +78,7 @@ describe('AnalyzeDataButton', () => {
render(<Example environment={ENVIRONMENT_ALL.value} />);
expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual(
'http://localhost/app/observability/exploratory-view/configure#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(ALL_VALUES),service.name:!(testServiceName)),time:(from:now-15m,to:now)))'
'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.environment:!(ALL_VALUES),service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))'
);
});
});

View file

@ -9,7 +9,10 @@ import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { createExploratoryViewUrl } from '../../../../../../observability/public';
import {
createExploratoryViewUrl,
SeriesUrl,
} from '../../../../../../observability/public';
import { ALL_VALUES_SELECTED } from '../../../../../../observability/public';
import {
isIosAgentName,
@ -18,7 +21,6 @@ import {
import {
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_DURATION,
} from '../../../../../common/elasticsearch_fieldnames';
import {
ENVIRONMENT_ALL,
@ -27,11 +29,13 @@ import {
import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context';
import { useUrlParams } from '../../../../context/url_params_context/use_url_params';
function getEnvironmentDefinition(environment: string) {
function getEnvironmentDefinition(environment?: string) {
switch (environment) {
case ENVIRONMENT_ALL.value:
return { [SERVICE_ENVIRONMENT]: [ALL_VALUES_SELECTED] };
case ENVIRONMENT_NOT_DEFINED.value:
case undefined:
return {};
default:
return { [SERVICE_ENVIRONMENT]: [environment] };
}
@ -47,26 +51,21 @@ export function AnalyzeDataButton() {
if (
(isRumAgentName(agentName) || isIosAgentName(agentName)) &&
rangeFrom &&
canShowDashboard &&
rangeTo
canShowDashboard
) {
const href = createExploratoryViewUrl(
{
reportType: 'kpi-over-time',
allSeries: [
{
name: `${serviceName}-response-latency`,
selectedMetricField: TRANSACTION_DURATION,
dataType: isRumAgentName(agentName) ? 'ux' : 'mobile',
time: { from: rangeFrom, to: rangeTo },
reportDefinitions: {
[SERVICE_NAME]: [serviceName],
...(environment ? getEnvironmentDefinition(environment) : {}),
},
operationType: 'average',
'apm-series': {
dataType: isRumAgentName(agentName) ? 'ux' : 'mobile',
time: { from: rangeFrom, to: rangeTo },
reportType: 'kpi-over-time',
reportDefinitions: {
[SERVICE_NAME]: [serviceName],
...getEnvironmentDefinition(environment),
},
],
operationType: 'average',
isNew: true,
} as SeriesUrl,
},
basepath
);

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import moment from 'moment';
import { Setup, SetupTimeRange } from '../helpers/setup_request';
import {
SERVICE_NAME,
@ -21,10 +20,7 @@ export async function hasRumData({
setup: Setup & Partial<SetupTimeRange>;
}) {
try {
const {
start = moment().subtract(24, 'h').valueOf(),
end = moment().valueOf(),
} = setup;
const { start, end } = setup;
const params = {
apm: {

View file

@ -6,8 +6,16 @@
},
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "observability"],
"optionalPlugins": ["home", "discover", "lens", "licensing", "usageCollection"],
"configPath": [
"xpack",
"observability"
],
"optionalPlugins": [
"home",
"lens",
"licensing",
"usageCollection"
],
"requiredPlugins": [
"alerting",
"cases",

View file

@ -1,32 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHeaderLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../../utils/kibana_react';
export function MobileAddData() {
const kibana = useKibana();
return (
<EuiHeaderLink
aria-label={i18n.translate('xpack.observability.page_header.addMobileDataLink.label', {
defaultMessage: 'Navigate to a tutorial about adding mobile APM data',
})}
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/apm')}
color="primary"
iconType="indexOpen"
>
{ADD_DATA_LABEL}
</EuiHeaderLink>
);
}
const ADD_DATA_LABEL = i18n.translate('xpack.observability.mobile.addDataButtonLabel', {
defaultMessage: 'Add Mobile data',
});

View file

@ -1,32 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHeaderLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../../utils/kibana_react';
export function SyntheticsAddData() {
const kibana = useKibana();
return (
<EuiHeaderLink
aria-label={i18n.translate('xpack.observability.page_header.addUptimeDataLink.label', {
defaultMessage: 'Navigate to a tutorial about adding Uptime data',
})}
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/uptimeMonitors')}
color="primary"
iconType="indexOpen"
>
{ADD_DATA_LABEL}
</EuiHeaderLink>
);
}
const ADD_DATA_LABEL = i18n.translate('xpack.observability..synthetics.addDataButtonLabel', {
defaultMessage: 'Add synthetics data',
});

View file

@ -1,32 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiHeaderLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../../utils/kibana_react';
export function UXAddData() {
const kibana = useKibana();
return (
<EuiHeaderLink
aria-label={i18n.translate('xpack.observability.page_header.addUXDataLink.label', {
defaultMessage: 'Navigate to a tutorial about adding user experience APM data',
})}
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/apm')}
color="primary"
iconType="indexOpen"
>
{ADD_DATA_LABEL}
</EuiHeaderLink>
);
}
const ADD_DATA_LABEL = i18n.translate('xpack.observability.ux.addDataButtonLabel', {
defaultMessage: 'Add UX data',
});

View file

@ -1,59 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '../../rtl_helpers';
import { fireEvent, screen } from '@testing-library/dom';
import React from 'react';
import { sampleAttribute } from '../../configurations/test_data/sample_attribute';
import * as pluginHook from '../../../../../hooks/use_plugin_context';
import { TypedLensByValueInput } from '../../../../../../../lens/public';
import { ExpViewActionMenuContent } from './action_menu';
jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({
appMountParameters: {
setHeaderActionMenu: jest.fn(),
},
} as any);
describe('Action Menu', function () {
it('should be able to click open in lens', async function () {
const { findByText, core } = render(
<ExpViewActionMenuContent
lensAttributes={sampleAttribute as TypedLensByValueInput['attributes']}
timeRange={{ to: 'now', from: 'now-10m' }}
/>
);
expect(await screen.findByText('Open in Lens')).toBeInTheDocument();
fireEvent.click(await findByText('Open in Lens'));
expect(core.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1);
expect(core.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith(
{
id: '',
attributes: sampleAttribute,
timeRange: { to: 'now', from: 'now-10m' },
},
true
);
});
it('should be able to click save', async function () {
const { findByText } = render(
<ExpViewActionMenuContent
lensAttributes={sampleAttribute as TypedLensByValueInput['attributes']}
/>
);
expect(await screen.findByText('Save')).toBeInTheDocument();
fireEvent.click(await findByText('Save'));
expect(await screen.findByText('Lens Save Modal Component')).toBeInTheDocument();
});
});

View file

@ -1,92 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LensEmbeddableInput, TypedLensByValueInput } from '../../../../../../../lens/public';
import { ObservabilityAppServices } from '../../../../../application/types';
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
export function ExpViewActionMenuContent({
timeRange,
lensAttributes,
}: {
timeRange?: { from: string; to: string };
lensAttributes: TypedLensByValueInput['attributes'] | null;
}) {
const kServices = useKibana<ObservabilityAppServices>().services;
const { lens } = kServices;
const [isSaveOpen, setIsSaveOpen] = useState(false);
const LensSaveModalComponent = lens.SaveModalComponent;
return (
<>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
style={{ paddingRight: 20 }}
>
<EuiFlexItem grow={false}>
<EuiButton
iconType="lensApp"
fullWidth={false}
isDisabled={!lens.canUseEditor() || lensAttributes === null}
size="s"
onClick={() => {
if (lensAttributes) {
lens.navigateToPrefilledEditor(
{
id: '',
timeRange,
attributes: lensAttributes,
},
true
);
}
}}
>
{i18n.translate('xpack.observability.expView.heading.openInLens', {
defaultMessage: 'Open in Lens',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
iconType="save"
fullWidth={false}
isDisabled={!lens.canUseEditor() || lensAttributes === null}
onClick={() => {
if (lensAttributes) {
setIsSaveOpen(true);
}
}}
size="s"
>
{i18n.translate('xpack.observability.expView.heading.saveLensVisualization', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isSaveOpen && lensAttributes && (
<LensSaveModalComponent
initialInput={(lensAttributes as unknown) as LensEmbeddableInput}
onClose={() => setIsSaveOpen(false)}
// if we want to do anything after the viz is saved
// right now there is no action, so an empty function
onSave={() => {}}
/>
)}
</>
);
}

View file

@ -1,26 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ExpViewActionMenuContent } from './action_menu';
import HeaderMenuPortal from '../../../header_menu_portal';
import { usePluginContext } from '../../../../../hooks/use_plugin_context';
import { TypedLensByValueInput } from '../../../../../../../lens/public';
interface Props {
timeRange?: { from: string; to: string };
lensAttributes: TypedLensByValueInput['attributes'] | null;
}
export function ExpViewActionMenu(props: Props) {
const { appMountParameters } = usePluginContext();
return (
<HeaderMenuPortal setHeaderActionMenu={appMountParameters.setHeaderActionMenu}>
<ExpViewActionMenuContent {...props} />
</HeaderMenuPortal>
);
}

View file

@ -10,19 +10,19 @@ import { isEmpty } from 'lodash';
import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { LOADING_VIEW } from '../series_editor/series_editor';
import { ReportViewType, SeriesUrl } from '../types';
import { LOADING_VIEW } from '../series_builder/series_builder';
import { SeriesUrl } from '../types';
export function EmptyView({
loading,
height,
series,
reportType,
}: {
loading: boolean;
series?: SeriesUrl;
reportType: ReportViewType;
height: string;
series: SeriesUrl;
}) {
const { dataType, reportDefinitions } = series ?? {};
const { dataType, reportType, reportDefinitions } = series ?? {};
let emptyMessage = EMPTY_LABEL;
@ -45,7 +45,7 @@ export function EmptyView({
}
return (
<Wrapper>
<Wrapper height={height}>
{loading && (
<EuiProgress
size="xs"
@ -66,8 +66,9 @@ export function EmptyView({
);
}
const Wrapper = styled.div`
const Wrapper = styled.div<{ height: string }>`
text-align: center;
height: ${(props) => props.height};
position: relative;
`;

View file

@ -7,7 +7,7 @@
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../rtl_helpers';
import { mockAppIndexPattern, mockIndexPattern, render } from '../rtl_helpers';
import { FilterLabel } from './filter_label';
import * as useSeriesHook from '../hooks/use_series_filters';
import { buildFilterLabel } from '../../filter_value_label/filter_value_label';
@ -27,10 +27,9 @@ describe('FilterLabel', function () {
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={0}
seriesId={'kpi-over-time'}
removeFilter={jest.fn()}
indexPattern={mockIndexPattern}
series={mockUxSeries}
/>
);
@ -52,10 +51,9 @@ describe('FilterLabel', function () {
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={0}
seriesId={'kpi-over-time'}
removeFilter={removeFilter}
indexPattern={mockIndexPattern}
series={mockUxSeries}
/>
);
@ -76,10 +74,9 @@ describe('FilterLabel', function () {
value={'elastic-co'}
label={'Web Application'}
negate={false}
seriesId={0}
seriesId={'kpi-over-time'}
removeFilter={removeFilter}
indexPattern={mockIndexPattern}
series={mockUxSeries}
/>
);
@ -103,10 +100,9 @@ describe('FilterLabel', function () {
value={'elastic-co'}
label={'Web Application'}
negate={true}
seriesId={0}
seriesId={'kpi-over-time'}
removeFilter={jest.fn()}
indexPattern={mockIndexPattern}
series={mockUxSeries}
/>
);

View file

@ -9,24 +9,21 @@ import React from 'react';
import { IndexPattern } from '../../../../../../../../src/plugins/data/public';
import { useSeriesFilters } from '../hooks/use_series_filters';
import { FilterValueLabel } from '../../filter_value_label/filter_value_label';
import { SeriesUrl } from '../types';
interface Props {
field: string;
label: string;
value: string | string[];
seriesId: number;
series: SeriesUrl;
value: string;
seriesId: string;
negate: boolean;
definitionFilter?: boolean;
indexPattern: IndexPattern;
removeFilter: (field: string, value: string | string[], notVal: boolean) => void;
removeFilter: (field: string, value: string, notVal: boolean) => void;
}
export function FilterLabel({
label,
seriesId,
series,
field,
value,
negate,
@ -34,7 +31,7 @@ export function FilterLabel({
removeFilter,
definitionFilter,
}: Props) {
const { invertFilter } = useSeriesFilters({ seriesId, series });
const { invertFilter } = useSeriesFilters({ seriesId });
return indexPattern ? (
<FilterValueLabel

View file

@ -1,59 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiColorPicker, EuiFormRow, EuiIcon, EuiPopover, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useTheme } from '../../../../hooks/use_theme';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { ToolbarButton } from '../../../../../../../../src/plugins/kibana_react/public';
import { SeriesUrl } from '../types';
export function SeriesColorPicker({ seriesId, series }: { seriesId: number; series: SeriesUrl }) {
const theme = useTheme();
const { setSeries } = useSeriesStorage();
const [isOpen, setIsOpen] = useState(false);
const onChange = (colorN: string) => {
setSeries(seriesId, { ...series, color: colorN });
};
const color =
series.color ?? ((theme.eui as unknown) as Record<string, string>)[`euiColorVis${seriesId}`];
const button = (
<EuiToolTip content={EDIT_SERIES_COLOR_LABEL}>
<ToolbarButton size="s" onClick={() => setIsOpen((prevState) => !prevState)} hasArrow={false}>
<EuiIcon type="dot" size="l" color={color} />
</ToolbarButton>
</EuiToolTip>
);
return (
<EuiPopover button={button} isOpen={isOpen} closePopover={() => setIsOpen(false)}>
<EuiFormRow label={PICK_A_COLOR_LABEL}>
<EuiColorPicker onChange={onChange} color={color} />
</EuiFormRow>
</EuiPopover>
);
}
const PICK_A_COLOR_LABEL = i18n.translate(
'xpack.observability.overview.exploratoryView.pickColor',
{
defaultMessage: 'Pick a color',
}
);
const EDIT_SERIES_COLOR_LABEL = i18n.translate(
'xpack.observability.overview.exploratoryView.editSeriesColor',
{
defaultMessage: 'Edit color for series',
}
);

View file

@ -1,109 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { EuiSuperDatePicker, EuiText } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { useHasData } from '../../../../../hooks/use_has_data';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { useQuickTimeRanges } from '../../../../../hooks/use_quick_time_ranges';
import { parseTimeParts } from '../../series_viewer/columns/utils';
import { useUiSetting } from '../../../../../../../../../src/plugins/kibana_react/public';
import { SeriesUrl } from '../../types';
import { ReportTypes } from '../../configurations/constants';
export interface TimePickerTime {
from: string;
to: string;
}
export interface TimePickerQuickRange extends TimePickerTime {
display: string;
}
interface Props {
seriesId: number;
series: SeriesUrl;
readonly?: boolean;
}
const readableUnit: Record<string, string> = {
m: i18n.translate('xpack.observability.overview.exploratoryView.minutes', {
defaultMessage: 'Minutes',
}),
h: i18n.translate('xpack.observability.overview.exploratoryView.hour', {
defaultMessage: 'Hour',
}),
d: i18n.translate('xpack.observability.overview.exploratoryView.day', {
defaultMessage: 'Day',
}),
};
export function SeriesDatePicker({ series, seriesId, readonly = true }: Props) {
const { onRefreshTimeRange } = useHasData();
const commonlyUsedRanges = useQuickTimeRanges();
const { setSeries, reportType, allSeries, firstSeries } = useSeriesStorage();
function onTimeChange({ start, end }: { start: string; end: string }) {
onRefreshTimeRange();
if (reportType === ReportTypes.KPI) {
allSeries.forEach((currSeries, seriesIndex) => {
setSeries(seriesIndex, { ...currSeries, time: { from: start, to: end } });
});
} else {
setSeries(seriesId, { ...series, time: { from: start, to: end } });
}
}
const seriesTime = series.time ?? firstSeries!.time;
const dateFormat = useUiSetting<string>('dateFormat').replace('ss.SSS', 'ss');
if (readonly) {
const timeParts = parseTimeParts(seriesTime?.from, seriesTime?.to);
if (timeParts) {
const {
timeTense: timeTenseDefault,
timeUnits: timeUnitsDefault,
timeValue: timeValueDefault,
} = timeParts;
return (
<EuiText color="subdued" size="s">{`${timeTenseDefault} ${timeValueDefault} ${
readableUnit?.[timeUnitsDefault] ?? timeUnitsDefault
}`}</EuiText>
);
} else {
return (
<EuiText color="subdued" size="s">
{i18n.translate('xpack.observability.overview.exploratoryView.dateRangeReadonly', {
defaultMessage: '{start} to {end}',
values: {
start: moment(seriesTime.from).format(dateFormat),
end: moment(seriesTime.to).format(dateFormat),
},
})}
</EuiText>
);
}
}
return (
<EuiSuperDatePicker
start={series?.time?.from}
end={series?.time?.to}
onTimeChange={onTimeChange}
commonlyUsedRanges={commonlyUsedRanges}
onRefresh={onTimeChange}
showUpdateButton={false}
/>
);
}

View file

@ -94,19 +94,6 @@ export const DataViewLabels: Record<ReportViewType, string> = {
'device-data-distribution': DEVICE_DISTRIBUTION_LABEL,
};
export enum ReportTypes {
KPI = 'kpi-over-time',
DISTRIBUTION = 'data-distribution',
CORE_WEB_VITAL = 'core-web-vitals',
DEVICE_DISTRIBUTION = 'device-data-distribution',
}
export enum DataTypes {
SYNTHETICS = 'synthetics',
UX = 'ux',
MOBILE = 'mobile',
}
export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN';
export const FILTER_RECORDS = 'FILTER_RECORDS';
export const TERMS_COLUMN = 'TERMS_COLUMN';

View file

@ -8,12 +8,10 @@
export enum URL_KEYS {
DATA_TYPE = 'dt',
OPERATION_TYPE = 'op',
REPORT_TYPE = 'rt',
SERIES_TYPE = 'st',
BREAK_DOWN = 'bd',
FILTERS = 'ft',
REPORT_DEFINITIONS = 'rdf',
SELECTED_METRIC = 'mt',
HIDDEN = 'h',
NAME = 'n',
COLOR = 'c',
}

View file

@ -15,7 +15,6 @@ import { getCoreWebVitalsConfig } from './rum/core_web_vitals_config';
import { getMobileKPIConfig } from './mobile/kpi_over_time_config';
import { getMobileKPIDistributionConfig } from './mobile/distribution_config';
import { getMobileDeviceDistributionConfig } from './mobile/device_distribution_config';
import { DataTypes, ReportTypes } from './constants';
interface Props {
reportType: ReportViewType;
@ -25,24 +24,24 @@ interface Props {
export const getDefaultConfigs = ({ reportType, dataType, indexPattern }: Props) => {
switch (dataType) {
case DataTypes.UX:
if (reportType === ReportTypes.DISTRIBUTION) {
case 'ux':
if (reportType === 'data-distribution') {
return getRumDistributionConfig({ indexPattern });
}
if (reportType === ReportTypes.CORE_WEB_VITAL) {
if (reportType === 'core-web-vitals') {
return getCoreWebVitalsConfig({ indexPattern });
}
return getKPITrendsLensConfig({ indexPattern });
case DataTypes.SYNTHETICS:
if (reportType === ReportTypes.DISTRIBUTION) {
case 'synthetics':
if (reportType === 'data-distribution') {
return getSyntheticsDistributionConfig({ indexPattern });
}
return getSyntheticsKPIConfig({ indexPattern });
case DataTypes.MOBILE:
if (reportType === ReportTypes.DISTRIBUTION) {
case 'mobile':
if (reportType === 'data-distribution') {
return getMobileKPIDistributionConfig({ indexPattern });
}
if (reportType === ReportTypes.DEVICE_DISTRIBUTION) {
if (reportType === 'device-data-distribution') {
return getMobileDeviceDistributionConfig({ indexPattern });
}
return getMobileKPIConfig({ indexPattern });

View file

@ -16,7 +16,7 @@ import {
} from './constants/elasticsearch_fieldnames';
import { buildExistsFilter, buildPhrasesFilter } from './utils';
import { sampleAttributeKpi } from './test_data/sample_attribute_kpi';
import { RECORDS_FIELD, REPORT_METRIC_FIELD, ReportTypes } from './constants';
import { REPORT_METRIC_FIELD } from './constants';
describe('Lens Attribute', () => {
mockAppIndexPattern();
@ -38,9 +38,6 @@ describe('Lens Attribute', () => {
indexPattern: mockIndexPattern,
reportDefinitions: {},
time: { from: 'now-15m', to: 'now' },
color: 'green',
name: 'test-series',
selectedMetricField: TRANSACTION_DURATION,
};
beforeEach(() => {
@ -53,7 +50,7 @@ describe('Lens Attribute', () => {
it('should return expected json for kpi report type', function () {
const seriesConfigKpi = getDefaultConfigs({
reportType: ReportTypes.KPI,
reportType: 'kpi-over-time',
dataType: 'ux',
indexPattern: mockIndexPattern,
});
@ -66,9 +63,6 @@ describe('Lens Attribute', () => {
indexPattern: mockIndexPattern,
reportDefinitions: { 'service.name': ['elastic-co'] },
time: { from: 'now-15m', to: 'now' },
color: 'green',
name: 'test-series',
selectedMetricField: RECORDS_FIELD,
},
]);
@ -141,9 +135,6 @@ describe('Lens Attribute', () => {
indexPattern: mockIndexPattern,
reportDefinitions: { 'performance.metric': [LCP_FIELD] },
time: { from: 'now-15m', to: 'now' },
color: 'green',
name: 'test-series',
selectedMetricField: TRANSACTION_DURATION,
};
lnsAttr = new LensAttributes([layerConfig1]);
@ -286,7 +277,7 @@ describe('Lens Attribute', () => {
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
isBucketed: false,
label: 'test-series',
label: 'Pages loaded',
operationType: 'formula',
params: {
format: {
@ -392,7 +383,7 @@ describe('Lens Attribute', () => {
palette: undefined,
seriesType: 'line',
xAccessor: 'x-axis-column-layer0',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0' }],
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
},
],
legend: { isVisible: true, position: 'right' },
@ -412,9 +403,6 @@ describe('Lens Attribute', () => {
reportDefinitions: { 'performance.metric': [LCP_FIELD] },
breakdown: USER_AGENT_NAME,
time: { from: 'now-15m', to: 'now' },
color: 'green',
name: 'test-series',
selectedMetricField: TRANSACTION_DURATION,
};
lnsAttr = new LensAttributes([layerConfig1]);
@ -434,7 +422,7 @@ describe('Lens Attribute', () => {
seriesType: 'line',
splitAccessor: 'breakdown-column-layer0',
xAccessor: 'x-axis-column-layer0',
yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0' }],
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
},
]);
@ -495,7 +483,7 @@ describe('Lens Attribute', () => {
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
isBucketed: false,
label: 'test-series',
label: 'Pages loaded',
operationType: 'formula',
params: {
format: {
@ -601,9 +589,6 @@ describe('Lens Attribute', () => {
indexPattern: mockIndexPattern,
reportDefinitions: { 'performance.metric': [LCP_FIELD] },
time: { from: 'now-15m', to: 'now' },
color: 'green',
name: 'test-series',
selectedMetricField: TRANSACTION_DURATION,
};
const filters = lnsAttr.getLayerFilters(layerConfig1, 2);

View file

@ -7,7 +7,6 @@
import { i18n } from '@kbn/i18n';
import { capitalize } from 'lodash';
import {
CountIndexPatternColumn,
DateHistogramIndexPatternColumn,
@ -37,11 +36,10 @@ import {
REPORT_METRIC_FIELD,
RECORDS_FIELD,
RECORDS_PERCENTAGE_FIELD,
ReportTypes,
} from './constants';
import { ColumnFilter, SeriesConfig, UrlFilter, URLReportDefinition } from '../types';
import { PersistableFilter } from '../../../../../../lens/common';
import { parseAbsoluteDate } from '../components/date_range_picker';
import { parseAbsoluteDate } from '../series_date_picker/date_range_picker';
import { getDistributionInPercentageColumn } from './lens_columns/overall_column';
function getLayerReferenceName(layerId: string) {
@ -75,6 +73,14 @@ export const parseCustomFieldName = (seriesConfig: SeriesConfig, selectedMetricF
timeScale = currField?.timeScale;
columnLabel = currField?.label;
}
} else if (metricOptions?.[0].field || metricOptions?.[0].id) {
const firstMetricOption = metricOptions?.[0];
selectedMetricField = firstMetricOption.field || firstMetricOption.id;
columnType = firstMetricOption.columnType;
columnFilters = firstMetricOption.columnFilters;
timeScale = firstMetricOption.timeScale;
columnLabel = firstMetricOption.label;
}
return { fieldName: selectedMetricField!, columnType, columnFilters, timeScale, columnLabel };
@ -89,9 +95,7 @@ export interface LayerConfig {
reportDefinitions: URLReportDefinition;
time: { to: string; from: string };
indexPattern: IndexPattern;
selectedMetricField: string;
color: string;
name: string;
selectedMetricField?: string;
}
export class LensAttributes {
@ -467,15 +471,14 @@ export class LensAttributes {
getLayerFilters(layerConfig: LayerConfig, totalLayers: number) {
const {
filters,
time,
time: { from, to },
seriesConfig: { baseFilters: layerFilters, reportType },
} = layerConfig;
let baseFilters = '';
if (reportType !== ReportTypes.KPI && totalLayers > 1 && time) {
if (reportType !== 'kpi-over-time' && totalLayers > 1) {
// for kpi over time, we don't need to add time range filters
// since those are essentially plotted along the x-axis
baseFilters += `@timestamp >= ${time.from} and @timestamp <= ${time.to}`;
baseFilters += `@timestamp >= ${from} and @timestamp <= ${to}`;
}
layerFilters?.forEach((filter: PersistableFilter | ExistsFilter) => {
@ -531,11 +534,7 @@ export class LensAttributes {
}
getTimeShift(mainLayerConfig: LayerConfig, layerConfig: LayerConfig, index: number) {
if (
index === 0 ||
mainLayerConfig.seriesConfig.reportType !== ReportTypes.KPI ||
!layerConfig.time
) {
if (index === 0 || mainLayerConfig.seriesConfig.reportType !== 'kpi-over-time') {
return null;
}
@ -547,14 +546,11 @@ export class LensAttributes {
time: { from },
} = layerConfig;
const inDays = Math.abs(parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'days'));
const inDays = parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'days');
if (inDays > 1) {
return inDays + 'd';
}
const inHours = Math.abs(parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'hours'));
if (inHours === 0) {
return null;
}
const inHours = parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'hours');
return inHours + 'h';
}
@ -572,12 +568,6 @@ export class LensAttributes {
const { sourceField } = seriesConfig.xAxisColumn;
let label = timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label;
if (layerConfig.seriesConfig.reportType !== ReportTypes.CORE_WEB_VITAL && layerConfig.name) {
label = layerConfig.name;
}
layers[layerId] = {
columnOrder: [
`x-axis-column-${layerId}`,
@ -591,7 +581,7 @@ export class LensAttributes {
[`x-axis-column-${layerId}`]: this.getXAxis(layerConfig, layerId),
[`y-axis-column-${layerId}`]: {
...mainYAxis,
label,
label: timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label,
filter: { query: columnFilter, language: 'kuery' },
...(timeShift ? { timeShift } : {}),
},
@ -634,7 +624,7 @@ export class LensAttributes {
seriesType: layerConfig.seriesType || layerConfig.seriesConfig.defaultSeriesType,
palette: layerConfig.seriesConfig.palette,
yConfig: layerConfig.seriesConfig.yConfig || [
{ forAccessor: `y-axis-column-layer${index}`, color: layerConfig.color },
{ forAccessor: `y-axis-column-layer${index}` },
],
xAccessor: `x-axis-column-layer${index}`,
...(layerConfig.breakdown &&
@ -648,7 +638,7 @@ export class LensAttributes {
};
}
getJSON(refresh?: number): TypedLensByValueInput['attributes'] {
getJSON(): TypedLensByValueInput['attributes'] {
const uniqueIndexPatternsIds = Array.from(
new Set([...this.layerConfigs.map(({ indexPattern }) => indexPattern.id)])
);
@ -657,7 +647,7 @@ export class LensAttributes {
return {
title: 'Prefilled from exploratory view app',
description: String(refresh),
description: '',
visualizationType: 'lnsXY',
references: [
...uniqueIndexPatternsIds.map((patternId) => ({

View file

@ -6,7 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import { FieldLabels, REPORT_METRIC_FIELD, ReportTypes, USE_BREAK_DOWN_COLUMN } from '../constants';
import { FieldLabels, REPORT_METRIC_FIELD, USE_BREAK_DOWN_COLUMN } from '../constants';
import { buildPhraseFilter } from '../utils';
import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames';
import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels';
@ -14,7 +14,7 @@ import { MobileFields } from './mobile_fields';
export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig {
return {
reportType: ReportTypes.DEVICE_DISTRIBUTION,
reportType: 'device-data-distribution',
defaultSeriesType: 'bar',
seriesTypes: ['bar', 'bar_horizontal'],
xAxisColumn: {
@ -38,13 +38,13 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps)
...MobileFields,
[SERVICE_NAME]: MOBILE_APP,
},
definitionFields: [SERVICE_NAME],
metricOptions: [
{
field: 'labels.device_id',
id: 'labels.device_id',
field: 'labels.device_id',
label: NUMBER_OF_DEVICES,
},
],
definitionFields: [SERVICE_NAME],
};
}

View file

@ -6,7 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD, ReportTypes } from '../constants';
import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants';
import { buildPhrasesFilter } from '../utils';
import {
METRIC_SYSTEM_CPU_USAGE,
@ -21,7 +21,7 @@ import { MobileFields } from './mobile_fields';
export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig {
return {
reportType: ReportTypes.DISTRIBUTION,
reportType: 'data-distribution',
defaultSeriesType: 'bar',
seriesTypes: ['line', 'bar'],
xAxisColumn: {

View file

@ -6,13 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import {
FieldLabels,
OPERATION_COLUMN,
RECORDS_FIELD,
REPORT_METRIC_FIELD,
ReportTypes,
} from '../constants';
import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants';
import { buildPhrasesFilter } from '../utils';
import {
METRIC_SYSTEM_CPU_USAGE,
@ -32,7 +26,7 @@ import { MobileFields } from './mobile_fields';
export function getMobileKPIConfig({ indexPattern }: ConfigProps): SeriesConfig {
return {
reportType: ReportTypes.KPI,
reportType: 'kpi-over-time',
defaultSeriesType: 'line',
seriesTypes: ['line', 'bar', 'area'],
xAxisColumn: {

View file

@ -9,7 +9,7 @@ import { mockAppIndexPattern, mockIndexPattern } from '../../rtl_helpers';
import { getDefaultConfigs } from '../default_configs';
import { LayerConfig, LensAttributes } from '../lens_attributes';
import { sampleAttributeCoreWebVital } from '../test_data/sample_attribute_cwv';
import { LCP_FIELD, SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsearch_fieldnames';
import { SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsearch_fieldnames';
describe('Core web vital config test', function () {
mockAppIndexPattern();
@ -24,13 +24,10 @@ describe('Core web vital config test', function () {
const layerConfig: LayerConfig = {
seriesConfig,
color: 'green',
name: 'test-series',
breakdown: USER_AGENT_OS,
indexPattern: mockIndexPattern,
time: { from: 'now-15m', to: 'now' },
reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] },
selectedMetricField: LCP_FIELD,
time: { from: 'now-15m', to: 'now' },
breakdown: USER_AGENT_OS,
};
beforeEach(() => {

View file

@ -11,7 +11,6 @@ import {
FieldLabels,
FILTER_RECORDS,
REPORT_METRIC_FIELD,
ReportTypes,
USE_BREAK_DOWN_COLUMN,
} from '../constants';
import { buildPhraseFilter } from '../utils';
@ -39,7 +38,7 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon
return {
defaultSeriesType: 'bar_horizontal_percentage_stacked',
reportType: ReportTypes.CORE_WEB_VITAL,
reportType: 'core-web-vitals',
seriesTypes: ['bar_horizontal_percentage_stacked'],
xAxisColumn: {
sourceField: USE_BREAK_DOWN_COLUMN,
@ -154,6 +153,5 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon
{ color: statusPallete[1], forAccessor: 'y-axis-column-1' },
{ color: statusPallete[2], forAccessor: 'y-axis-column-2' },
],
query: { query: 'transaction.type: "page-load"', language: 'kuery' },
};
}

View file

@ -6,12 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import {
FieldLabels,
REPORT_METRIC_FIELD,
RECORDS_PERCENTAGE_FIELD,
ReportTypes,
} from '../constants';
import { FieldLabels, REPORT_METRIC_FIELD, RECORDS_PERCENTAGE_FIELD } from '../constants';
import { buildPhraseFilter } from '../utils';
import {
CLIENT_GEO_COUNTRY_NAME,
@ -46,7 +41,7 @@ import {
export function getRumDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig {
return {
reportType: ReportTypes.DISTRIBUTION,
reportType: 'data-distribution',
defaultSeriesType: 'line',
seriesTypes: [],
xAxisColumn: {

View file

@ -6,13 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import {
FieldLabels,
OPERATION_COLUMN,
RECORDS_FIELD,
REPORT_METRIC_FIELD,
ReportTypes,
} from '../constants';
import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants';
import { buildPhraseFilter } from '../utils';
import {
CLIENT_GEO_COUNTRY_NAME,
@ -49,7 +43,7 @@ export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): SeriesCon
return {
defaultSeriesType: 'bar_stacked',
seriesTypes: [],
reportType: ReportTypes.KPI,
reportType: 'kpi-over-time',
xAxisColumn: {
sourceField: '@timestamp',
},

View file

@ -6,12 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import {
FieldLabels,
REPORT_METRIC_FIELD,
RECORDS_PERCENTAGE_FIELD,
ReportTypes,
} from '../constants';
import { FieldLabels, REPORT_METRIC_FIELD, RECORDS_PERCENTAGE_FIELD } from '../constants';
import {
CLS_LABEL,
DCL_LABEL,
@ -35,7 +30,7 @@ export function getSyntheticsDistributionConfig({
indexPattern,
}: ConfigProps): SeriesConfig {
return {
reportType: ReportTypes.DISTRIBUTION,
reportType: 'data-distribution',
defaultSeriesType: series?.seriesType || 'line',
seriesTypes: [],
xAxisColumn: {

View file

@ -6,7 +6,7 @@
*/
import { ConfigProps, SeriesConfig } from '../../types';
import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD, ReportTypes } from '../constants';
import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD } from '../constants';
import {
CLS_LABEL,
DCL_LABEL,
@ -30,7 +30,7 @@ const SUMMARY_DOWN = 'summary.down';
export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesConfig {
return {
reportType: ReportTypes.KPI,
reportType: 'kpi-over-time',
defaultSeriesType: 'bar_stacked',
seriesTypes: [],
xAxisColumn: {

View file

@ -5,18 +5,12 @@
* 2.0.
*/
export const sampleAttribute = {
description: 'undefined',
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{
id: 'apm-*',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: 'apm-*',
name: 'indexpattern-datasource-layer-layer0',
type: 'index-pattern',
},
{ id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' },
{ id: 'apm-*', name: 'indexpattern-datasource-layer-layer0', type: 'index-pattern' },
],
state: {
datasourceStates: {
@ -34,23 +28,17 @@ export const sampleAttribute = {
],
columns: {
'x-axis-column-layer0': {
dataType: 'number',
isBucketed: true,
label: 'Page load time',
operationType: 'range',
params: {
maxBars: 'auto',
ranges: [
{
from: 0,
label: '',
to: 1000,
},
],
type: 'histogram',
},
scale: 'interval',
sourceField: 'transaction.duration.us',
label: 'Page load time',
dataType: 'number',
operationType: 'range',
isBucketed: true,
scale: 'interval',
params: {
type: 'histogram',
ranges: [{ from: 0, to: 1000, label: '' }],
maxBars: 'auto',
},
},
'y-axis-column-layer0': {
dataType: 'number',
@ -60,7 +48,7 @@ export const sampleAttribute = {
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
isBucketed: false,
label: 'test-series',
label: 'Pages loaded',
operationType: 'formula',
params: {
format: {
@ -93,16 +81,16 @@ export const sampleAttribute = {
'y-axis-column-layer0X1': {
customLabel: true,
dataType: 'number',
filter: {
language: 'kuery',
query:
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
isBucketed: false,
label: 'Part of count() / overall_sum(count())',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
filter: {
language: 'kuery',
query:
'transaction.type: page-load and processor.event: transaction and transaction.type : *',
},
},
'y-axis-column-layer0X2': {
customLabel: true,
@ -153,51 +141,26 @@ export const sampleAttribute = {
},
},
},
filters: [],
query: {
language: 'kuery',
query: 'transaction.duration.us < 60000000',
},
visualization: {
axisTitlesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
curveType: 'CURVE_MONOTONE_X',
legend: { isVisible: true, position: 'right' },
valueLabels: 'hide',
fittingFunction: 'Linear',
gridlinesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
curveType: 'CURVE_MONOTONE_X',
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column-layer0'],
layerId: 'layer0',
seriesType: 'line',
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
xAccessor: 'x-axis-column-layer0',
yConfig: [
{
color: 'green',
forAccessor: 'y-axis-column-layer0',
},
],
},
],
legend: {
isVisible: true,
position: 'right',
},
preferredSeriesType: 'line',
tickLabelsVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
valueLabels: 'hide',
},
query: { query: 'transaction.duration.us < 60000000', language: 'kuery' },
filters: [],
},
title: 'Prefilled from exploratory view app',
visualizationType: 'lnsXY',
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export const sampleAttributeCoreWebVital = {
description: 'undefined',
description: '',
references: [
{
id: 'apm-*',
@ -94,7 +94,7 @@ export const sampleAttributeCoreWebVital = {
filters: [],
query: {
language: 'kuery',
query: 'transaction.type: "page-load"',
query: '',
},
visualization: {
axisTitlesVisibilitySettings: {

View file

@ -5,18 +5,12 @@
* 2.0.
*/
export const sampleAttributeKpi = {
description: 'undefined',
title: 'Prefilled from exploratory view app',
description: '',
visualizationType: 'lnsXY',
references: [
{
id: 'apm-*',
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: 'apm-*',
name: 'indexpattern-datasource-layer-layer0',
type: 'index-pattern',
},
{ id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' },
{ id: 'apm-*', name: 'indexpattern-datasource-layer-layer0', type: 'index-pattern' },
],
state: {
datasourceStates: {
@ -26,27 +20,25 @@ export const sampleAttributeKpi = {
columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0'],
columns: {
'x-axis-column-layer0': {
sourceField: '@timestamp',
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: {
interval: 'auto',
},
params: { interval: 'auto' },
scale: 'interval',
sourceField: '@timestamp',
},
'y-axis-column-layer0': {
dataType: 'number',
filter: {
language: 'kuery',
query: 'transaction.type: page-load and processor.event: transaction',
},
isBucketed: false,
label: 'test-series',
label: 'Page views',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
filter: {
query: 'transaction.type: page-load and processor.event: transaction',
language: 'kuery',
},
},
},
incompleteColumns: {},
@ -54,51 +46,26 @@ export const sampleAttributeKpi = {
},
},
},
filters: [],
query: {
language: 'kuery',
query: '',
},
visualization: {
axisTitlesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
curveType: 'CURVE_MONOTONE_X',
legend: { isVisible: true, position: 'right' },
valueLabels: 'hide',
fittingFunction: 'Linear',
gridlinesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
curveType: 'CURVE_MONOTONE_X',
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true },
gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true },
preferredSeriesType: 'line',
layers: [
{
accessors: ['y-axis-column-layer0'],
layerId: 'layer0',
seriesType: 'line',
yConfig: [{ forAccessor: 'y-axis-column-layer0' }],
xAccessor: 'x-axis-column-layer0',
yConfig: [
{
color: 'green',
forAccessor: 'y-axis-column-layer0',
},
],
},
],
legend: {
isVisible: true,
position: 'right',
},
preferredSeriesType: 'line',
tickLabelsVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
valueLabels: 'hide',
},
query: { query: '', language: 'kuery' },
filters: [],
},
title: 'Prefilled from exploratory view app',
visualizationType: 'lnsXY',
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import rison, { RisonValue } from 'rison-node';
import type { ReportViewType, SeriesUrl, UrlFilter } from '../types';
import type { SeriesUrl, UrlFilter } from '../types';
import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns';
import { esFilters, ExistsFilter } from '../../../../../../../../src/plugins/data/public';
@ -16,43 +16,40 @@ export function convertToShortUrl(series: SeriesUrl) {
const {
operationType,
seriesType,
reportType,
breakdown,
filters,
reportDefinitions,
dataType,
selectedMetricField,
hidden,
name,
color,
...restSeries
} = series;
return {
[URL_KEYS.OPERATION_TYPE]: operationType,
[URL_KEYS.REPORT_TYPE]: reportType,
[URL_KEYS.SERIES_TYPE]: seriesType,
[URL_KEYS.BREAK_DOWN]: breakdown,
[URL_KEYS.FILTERS]: filters,
[URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions,
[URL_KEYS.DATA_TYPE]: dataType,
[URL_KEYS.SELECTED_METRIC]: selectedMetricField,
[URL_KEYS.HIDDEN]: hidden,
[URL_KEYS.NAME]: name,
[URL_KEYS.COLOR]: color,
...restSeries,
};
}
export function createExploratoryViewUrl(
{ reportType, allSeries }: { reportType: ReportViewType; allSeries: AllSeries },
baseHref = ''
) {
const allShortSeries: AllShortSeries = allSeries.map((series) => convertToShortUrl(series));
export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') {
const allSeriesIds = Object.keys(allSeries);
const allShortSeries: AllShortSeries = {};
allSeriesIds.forEach((seriesKey) => {
allShortSeries[seriesKey] = convertToShortUrl(allSeries[seriesKey]);
});
return (
baseHref +
`/app/observability/exploratory-view/configure#?reportType=${reportType}&sr=${rison.encode(
(allShortSeries as unknown) as RisonValue
)}`
`/app/observability/exploratory-view#?sr=${rison.encode(allShortSeries as RisonValue)}`
);
}

View file

@ -11,13 +11,6 @@ import { render, mockCore, mockAppIndexPattern } from './rtl_helpers';
import { ExploratoryView } from './exploratory_view';
import { getStubIndexPattern } from '../../../../../../../src/plugins/data/public/test_utils';
import * as obsvInd from './utils/observability_index_patterns';
import * as pluginHook from '../../../hooks/use_plugin_context';
jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({
appMountParameters: {
setHeaderActionMenu: jest.fn(),
},
} as any);
describe('ExploratoryView', () => {
mockAppIndexPattern();
@ -48,18 +41,29 @@ describe('ExploratoryView', () => {
it('renders exploratory view', async () => {
render(<ExploratoryView />);
expect(await screen.findByText(/Preview/i)).toBeInTheDocument();
expect(await screen.findByText(/Configure series/i)).toBeInTheDocument();
expect(await screen.findByText(/Hide chart/i)).toBeInTheDocument();
expect(await screen.findByText(/Refresh/i)).toBeInTheDocument();
expect(await screen.findByText(/open in lens/i)).toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: /Performance Distribution/i })
).toBeInTheDocument();
});
it('renders lens component when there is series', async () => {
render(<ExploratoryView />);
const initSeries = {
data: {
'ux-series': {
isNew: true,
dataType: 'ux' as const,
reportType: 'data-distribution' as const,
breakdown: 'user_agent .name',
reportDefinitions: { 'service.name': ['elastic-co'] },
time: { from: 'now-15m', to: 'now' },
},
},
};
render(<ExploratoryView />, { initSeries });
expect(await screen.findByText(/open in lens/i)).toBeInTheDocument();
expect((await screen.findAllByText('Performance distribution'))[0]).toBeInTheDocument();
expect(await screen.findByText(/Lens Embeddable Component/i)).toBeInTheDocument();

View file

@ -4,13 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useEffect, useRef, useState } from 'react';
import { EuiButtonEmpty, EuiPanel, EuiResizableContainer, EuiTitle } from '@elastic/eui';
import { EuiPanel, EuiTitle } from '@elastic/eui';
import styled from 'styled-components';
import { useRouteMatch } from 'react-router-dom';
import { PanelDirection } from '@elastic/eui/src/components/resizable_container/types';
import { isEmpty } from 'lodash';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { ExploratoryViewHeader } from './header/header';
@ -18,15 +16,40 @@ import { useSeriesStorage } from './hooks/use_series_storage';
import { useLensAttributes } from './hooks/use_lens_attributes';
import { TypedLensByValueInput } from '../../../../../lens/public';
import { useAppIndexPatternContext } from './hooks/use_app_index_pattern';
import { SeriesViews } from './views/series_views';
import { SeriesBuilder } from './series_builder/series_builder';
import { SeriesUrl } from './types';
import { LensEmbeddable } from './lens_embeddable';
import { EmptyView } from './components/empty_view';
export type PanelId = 'seriesPanel' | 'chartPanel';
export const combineTimeRanges = (
allSeries: Record<string, SeriesUrl>,
firstSeries?: SeriesUrl
) => {
let to: string = '';
let from: string = '';
if (firstSeries?.reportType === 'kpi-over-time') {
return firstSeries.time;
}
Object.values(allSeries ?? {}).forEach((series) => {
if (series.dataType && series.reportType && !isEmpty(series.reportDefinitions)) {
const seriesTo = new Date(series.time.to);
const seriesFrom = new Date(series.time.from);
if (!to || seriesTo > new Date(to)) {
to = series.time.to;
}
if (!from || seriesFrom < new Date(from)) {
from = series.time.from;
}
}
});
return { to, from };
};
export function ExploratoryView({
saveAttributes,
multiSeries,
}: {
multiSeries?: boolean;
saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void;
}) {
const {
@ -46,19 +69,20 @@ export function ExploratoryView({
const { loadIndexPattern, loading } = useAppIndexPatternContext();
const { firstSeries, allSeries, lastRefresh, reportType } = useSeriesStorage();
const { firstSeries, firstSeriesId, allSeries } = useSeriesStorage();
const lensAttributesT = useLensAttributes();
const setHeightOffset = () => {
if (seriesBuilderRef?.current && wrapperRef.current) {
const headerOffset = wrapperRef.current.getBoundingClientRect().top;
setHeight(`calc(100vh - ${headerOffset + 40}px)`);
const seriesOffset = seriesBuilderRef.current.getBoundingClientRect().height;
setHeight(`calc(100vh - ${seriesOffset + headerOffset + 40}px)`);
}
};
useEffect(() => {
allSeries.forEach((seriesT) => {
Object.values(allSeries).forEach((seriesT) => {
loadIndexPattern({
dataType: seriesT.dataType,
});
@ -72,104 +96,38 @@ export function ExploratoryView({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(lensAttributesT ?? {}), lastRefresh]);
}, [JSON.stringify(lensAttributesT ?? {})]);
useEffect(() => {
setHeightOffset();
});
const collapseFn = useRef<(id: PanelId, direction: PanelDirection) => void>();
const [hiddenPanel, setHiddenPanel] = useState('');
const isPreview = !!useRouteMatch('/exploratory-view/preview');
const onCollapse = (panelId: string) => {
setHiddenPanel((prevState) => (panelId === prevState ? '' : panelId));
};
const onChange = (panelId: PanelId) => {
onCollapse(panelId);
if (collapseFn.current) {
collapseFn.current(panelId, panelId === 'seriesPanel' ? 'right' : 'left');
}
};
return (
<Wrapper>
{lens ? (
<>
<ExploratoryViewHeader
lensAttributes={lensAttributes}
seriesId={0}
lastUpdated={lastUpdated}
/>
<ExploratoryViewHeader lensAttributes={lensAttributes} seriesId={firstSeriesId} />
<LensWrapper ref={wrapperRef} height={height}>
<EuiResizableContainer
style={{ height: '100%' }}
direction="vertical"
onToggleCollapsed={onCollapse}
>
{(EuiResizablePanel, EuiResizableButton, { togglePanel }) => {
collapseFn.current = (id, direction) => togglePanel?.(id, { direction });
return (
<>
<EuiResizablePanel
initialSize={isPreview ? 70 : 40}
minSize={isPreview ? '70%' : '30%'}
mode={isPreview ? 'main' : 'collapsible'}
id="chartPanel"
>
{lensAttributes ? (
<LensEmbeddable
setLastUpdated={setLastUpdated}
lensAttributes={lensAttributes}
/>
) : (
<EmptyView series={firstSeries} loading={loading} reportType={reportType} />
)}
</EuiResizablePanel>
<EuiResizableButton />
<EuiResizablePanel
initialSize={isPreview ? 30 : 60}
minSize="10%"
mode={isPreview ? 'collapsible' : 'main'}
id="seriesPanel"
>
{!isPreview &&
(hiddenPanel === 'chartPanel' ? (
<ShowChart onClick={() => onChange('chartPanel')} iconType="arrowDown">
{SHOW_CHART_LABEL}
</ShowChart>
) : (
<HideChart
onClick={() => onChange('chartPanel')}
iconType="arrowUp"
color="text"
>
{HIDE_CHART_LABEL}
</HideChart>
))}
<SeriesViews
seriesBuilderRef={seriesBuilderRef}
onSeriesPanelCollapse={onChange}
/>
</EuiResizablePanel>
</>
);
}}
</EuiResizableContainer>
{hiddenPanel === 'seriesPanel' && (
<ShowPreview onClick={() => onChange('seriesPanel')} iconType="arrowUp">
{PREVIEW_LABEL}
</ShowPreview>
{lensAttributes ? (
<LensEmbeddable setLastUpdated={setLastUpdated} lensAttributes={lensAttributes} />
) : (
<EmptyView series={firstSeries} loading={loading} height={height} />
)}
</LensWrapper>
<SeriesBuilder
seriesBuilderRef={seriesBuilderRef}
lastUpdated={lastUpdated}
multiSeries={multiSeries}
/>
</>
) : (
<EuiTitle>
<h2>{LENS_NOT_AVAILABLE}</h2>
<h2>
{i18n.translate('xpack.observability.overview.exploratoryView.lensDisabled', {
defaultMessage:
'Lens app is not available, please enable Lens to use exploratory view.',
})}
</h2>
</EuiTitle>
)}
</Wrapper>
@ -189,39 +147,4 @@ const Wrapper = styled(EuiPanel)`
margin: 0 auto;
width: 100%;
overflow-x: auto;
position: relative;
`;
const ShowPreview = styled(EuiButtonEmpty)`
position: absolute;
bottom: 34px;
`;
const HideChart = styled(EuiButtonEmpty)`
position: absolute;
top: -35px;
right: 50px;
`;
const ShowChart = styled(EuiButtonEmpty)`
position: absolute;
top: -10px;
right: 50px;
`;
const HIDE_CHART_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.hideChart', {
defaultMessage: 'Hide chart',
});
const SHOW_CHART_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.showChart', {
defaultMessage: 'Show chart',
});
const PREVIEW_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.preview', {
defaultMessage: 'Preview',
});
const LENS_NOT_AVAILABLE = i18n.translate(
'xpack.observability.overview.exploratoryView.lensDisabled',
{
defaultMessage: 'Lens app is not available, please enable Lens to use exploratory view.',
}
);

View file

@ -8,22 +8,51 @@
import React from 'react';
import { render } from '../rtl_helpers';
import { ExploratoryViewHeader } from './header';
import * as pluginHook from '../../../../hooks/use_plugin_context';
jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({
appMountParameters: {
setHeaderActionMenu: jest.fn(),
},
} as any);
import { fireEvent } from '@testing-library/dom';
describe('ExploratoryViewHeader', function () {
it('should render properly', function () {
const { getByText } = render(
<ExploratoryViewHeader
seriesId={0}
seriesId={'dummy-series'}
lensAttributes={{ title: 'Performance distribution' } as any}
/>
);
getByText('Refresh');
getByText('Open in Lens');
});
it('should be able to click open in lens', function () {
const initSeries = {
data: {
'uptime-pings-histogram': {
dataType: 'synthetics' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
};
const { getByText, core } = render(
<ExploratoryViewHeader
seriesId={'dummy-series'}
lensAttributes={{ title: 'Performance distribution' } as any}
/>,
{ initSeries }
);
fireEvent.click(getByText('Open in Lens'));
expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1);
expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith(
{
attributes: { title: 'Performance distribution' },
id: '',
timeRange: {
from: 'now-15m',
to: 'now',
},
},
true
);
});
});

View file

@ -5,37 +5,43 @@
* 2.0.
*/
import React from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { TypedLensByValueInput, LensEmbeddableInput } from '../../../../../../lens/public';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
import { DataViewLabels } from '../configurations/constants';
import { ObservabilityAppServices } from '../../../../application/types';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { LastUpdated } from './last_updated';
import { combineTimeRanges } from '../lens_embeddable';
import { ExpViewActionMenu } from '../components/action_menu';
import { combineTimeRanges } from '../exploratory_view';
interface Props {
seriesId?: number;
lastUpdated?: number;
seriesId: string;
lensAttributes: TypedLensByValueInput['attributes'] | null;
}
export function ExploratoryViewHeader({ seriesId, lensAttributes, lastUpdated }: Props) {
const { getSeries, allSeries, setLastRefresh, reportType } = useSeriesStorage();
export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) {
const kServices = useKibana<ObservabilityAppServices>().services;
const series = seriesId ? getSeries(seriesId) : undefined;
const { lens } = kServices;
const timeRange = combineTimeRanges(reportType, allSeries, series);
const { getSeries, allSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const [isSaveOpen, setIsSaveOpen] = useState(false);
const LensSaveModalComponent = lens.SaveModalComponent;
const timeRange = combineTimeRanges(allSeries, series);
return (
<>
<ExpViewActionMenu timeRange={timeRange} lensAttributes={lensAttributes} />
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem>
<EuiText>
<h2>
{DataViewLabels[reportType] ??
{DataViewLabels[series.reportType] ??
i18n.translate('xpack.observability.expView.heading.label', {
defaultMessage: 'Analyze data',
})}{' '}
@ -51,18 +57,53 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes, lastUpdated }:
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LastUpdated lastUpdated={lastUpdated} />
<EuiButton
iconType="lensApp"
fullWidth={false}
isDisabled={!lens.canUseEditor() || lensAttributes === null}
onClick={() => {
if (lensAttributes) {
lens.navigateToPrefilledEditor(
{
id: '',
timeRange,
attributes: lensAttributes,
},
true
);
}
}}
>
{i18n.translate('xpack.observability.expView.heading.openInLens', {
defaultMessage: 'Open in Lens',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton iconType="refresh" onClick={() => setLastRefresh(Date.now())}>
{REFRESH_LABEL}
<EuiButton
iconType="save"
fullWidth={false}
isDisabled={!lens.canUseEditor() || lensAttributes === null}
onClick={() => {
if (lensAttributes) {
setIsSaveOpen(true);
}
}}
>
{i18n.translate('xpack.observability.expView.heading.saveLensVisualization', {
defaultMessage: 'Save',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isSaveOpen && lensAttributes && (
<LensSaveModalComponent
initialInput={(lensAttributes as unknown) as LensEmbeddableInput}
onClose={() => setIsSaveOpen(false)}
onSave={() => {}}
/>
)}
</>
);
}
const REFRESH_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.refresh', {
defaultMessage: 'Refresh',
});

View file

@ -27,7 +27,7 @@ interface ProviderProps {
}
type HasAppDataState = Record<AppDataType, boolean | null>;
export type IndexPatternState = Record<AppDataType, IndexPattern>;
type IndexPatternState = Record<AppDataType, IndexPattern>;
type LoadingState = Record<AppDataType, boolean>;
export function IndexPatternContextProvider({ children }: ProviderProps) {

View file

@ -1,92 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useEffect, useState } from 'react';
import { useKibana } from '../../../../utils/kibana_react';
import { SeriesConfig, SeriesUrl } from '../types';
import { useAppIndexPatternContext } from './use_app_index_pattern';
import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter } from '../configurations/utils';
import { getFiltersFromDefs } from './use_lens_attributes';
import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants';
interface UseDiscoverLink {
seriesConfig: SeriesConfig;
series: SeriesUrl;
}
export const useDiscoverLink = ({ series, seriesConfig }: UseDiscoverLink) => {
const kServices = useKibana().services;
const {
application: { navigateToUrl },
} = kServices;
const { indexPatterns } = useAppIndexPatternContext();
const urlGenerator = kServices.discover?.urlGenerator;
const [discoverUrl, setDiscoverUrl] = useState<string>('');
useEffect(() => {
const indexPattern = indexPatterns?.[series.dataType];
const definitions = series.reportDefinitions ?? {};
const filters = [...(seriesConfig?.baseFilters ?? [])];
const definitionFilters = getFiltersFromDefs(definitions);
definitionFilters.forEach(({ field, values = [] }) => {
if (values.length > 1) {
filters.push(buildPhrasesFilter(field, values, indexPattern)[0]);
} else {
filters.push(buildPhraseFilter(field, values[0], indexPattern)[0]);
}
});
const selectedMetricField = series.selectedMetricField;
if (
selectedMetricField &&
selectedMetricField !== RECORDS_FIELD &&
selectedMetricField !== RECORDS_PERCENTAGE_FIELD
) {
filters.push(buildExistsFilter(selectedMetricField, indexPattern)[0]);
}
const getDiscoverUrl = async () => {
if (!urlGenerator?.createUrl) return;
const newUrl = await urlGenerator.createUrl({
filters,
indexPatternId: indexPattern?.id,
});
setDiscoverUrl(newUrl);
};
getDiscoverUrl();
}, [
indexPatterns,
series.dataType,
series.reportDefinitions,
series.selectedMetricField,
seriesConfig?.baseFilters,
urlGenerator,
]);
const onClick = useCallback(
(event: React.MouseEvent) => {
if (discoverUrl) {
event.preventDefault();
return navigateToUrl(discoverUrl);
}
},
[discoverUrl, navigateToUrl]
);
return {
href: discoverUrl,
onClick,
};
};

View file

@ -9,18 +9,12 @@ import { useMemo } from 'react';
import { isEmpty } from 'lodash';
import { TypedLensByValueInput } from '../../../../../../lens/public';
import { LayerConfig, LensAttributes } from '../configurations/lens_attributes';
import {
AllSeries,
allSeriesKey,
convertAllShortSeries,
useSeriesStorage,
} from './use_series_storage';
import { useSeriesStorage } from './use_series_storage';
import { getDefaultConfigs } from '../configurations/default_configs';
import { SeriesUrl, UrlFilter } from '../types';
import { useAppIndexPatternContext } from './use_app_index_pattern';
import { ALL_VALUES_SELECTED } from '../../field_value_suggestions/field_value_combobox';
import { useTheme } from '../../../../hooks/use_theme';
export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitions']) => {
return Object.entries(reportDefinitions ?? {})
@ -34,56 +28,41 @@ export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitio
};
export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => {
const { storage, autoApply, allSeries, lastRefresh, reportType } = useSeriesStorage();
const { allSeriesIds, allSeries } = useSeriesStorage();
const { indexPatterns } = useAppIndexPatternContext();
const theme = useTheme();
return useMemo(() => {
if (isEmpty(indexPatterns) || isEmpty(allSeries) || !reportType) {
if (isEmpty(indexPatterns) || isEmpty(allSeriesIds)) {
return null;
}
const allSeriesT: AllSeries = autoApply
? allSeries
: convertAllShortSeries(storage.get(allSeriesKey) ?? []);
const layerConfigs: LayerConfig[] = [];
allSeriesT.forEach((series, seriesIndex) => {
const indexPattern = indexPatterns?.[series?.dataType];
if (
indexPattern &&
!isEmpty(series.reportDefinitions) &&
!series.hidden &&
series.selectedMetricField
) {
allSeriesIds.forEach((seriesIdT) => {
const seriesT = allSeries[seriesIdT];
const indexPattern = indexPatterns?.[seriesT?.dataType];
if (indexPattern && seriesT.reportType && !isEmpty(seriesT.reportDefinitions)) {
const seriesConfig = getDefaultConfigs({
reportType,
reportType: seriesT.reportType,
dataType: seriesT.dataType,
indexPattern,
dataType: series.dataType,
});
const filters: UrlFilter[] = (series.filters ?? []).concat(
getFiltersFromDefs(series.reportDefinitions)
const filters: UrlFilter[] = (seriesT.filters ?? []).concat(
getFiltersFromDefs(seriesT.reportDefinitions)
);
const color = `euiColorVis${seriesIndex}`;
layerConfigs.push({
filters,
indexPattern,
seriesConfig,
time: series.time,
name: series.name,
breakdown: series.breakdown,
seriesType: series.seriesType,
operationType: series.operationType,
reportDefinitions: series.reportDefinitions ?? {},
selectedMetricField: series.selectedMetricField,
color: series.color ?? ((theme.eui as unknown) as Record<string, string>)[color],
time: seriesT.time,
breakdown: seriesT.breakdown,
seriesType: seriesT.seriesType,
operationType: seriesT.operationType,
reportDefinitions: seriesT.reportDefinitions ?? {},
selectedMetricField: seriesT.selectedMetricField,
});
}
});
@ -94,6 +73,6 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null
const lensAttributes = new LensAttributes(layerConfigs);
return lensAttributes.getJSON(lastRefresh);
}, [indexPatterns, allSeries, reportType, autoApply, storage, theme, lastRefresh]);
return lensAttributes.getJSON();
}, [indexPatterns, allSeriesIds, allSeries]);
};

View file

@ -6,16 +6,18 @@
*/
import { useSeriesStorage } from './use_series_storage';
import { SeriesUrl, UrlFilter } from '../types';
import { UrlFilter } from '../types';
export interface UpdateFilter {
field: string;
value: string | string[];
value: string;
negate?: boolean;
}
export const useSeriesFilters = ({ seriesId, series }: { seriesId: number; series: SeriesUrl }) => {
const { setSeries } = useSeriesStorage();
export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => {
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const filters = series.filters ?? [];
@ -24,14 +26,10 @@ export const useSeriesFilters = ({ seriesId, series }: { seriesId: number; serie
.map((filter) => {
if (filter.field === field) {
if (negate) {
const notValuesN = filter.notValues?.filter((val) =>
value instanceof Array ? !value.includes(val) : val !== value
);
const notValuesN = filter.notValues?.filter((val) => val !== value);
return { ...filter, notValues: notValuesN };
} else {
const valuesN = filter.values?.filter((val) =>
value instanceof Array ? !value.includes(val) : val !== value
);
const valuesN = filter.values?.filter((val) => val !== value);
return { ...filter, values: valuesN };
}
}
@ -45,9 +43,9 @@ export const useSeriesFilters = ({ seriesId, series }: { seriesId: number; serie
const addFilter = ({ field, value, negate }: UpdateFilter) => {
const currFilter: UrlFilter = { field };
if (negate) {
currFilter.notValues = value instanceof Array ? value : [value];
currFilter.notValues = [value];
} else {
currFilter.values = value instanceof Array ? value : [value];
currFilter.values = [value];
}
if (filters.length === 0) {
setSeries(seriesId, { ...series, filters: [currFilter] });
@ -67,26 +65,13 @@ export const useSeriesFilters = ({ seriesId, series }: { seriesId: number; serie
const currNotValues = currFilter.notValues ?? [];
const currValues = currFilter.values ?? [];
const notValues = currNotValues.filter((val) =>
value instanceof Array ? !value.includes(val) : val !== value
);
const values = currValues.filter((val) =>
value instanceof Array ? !value.includes(val) : val !== value
);
const notValues = currNotValues.filter((val) => val !== value);
const values = currValues.filter((val) => val !== value);
if (negate) {
if (value instanceof Array) {
notValues.push(...value);
} else {
notValues.push(value);
}
notValues.push(value);
} else {
if (value instanceof Array) {
values.push(...value);
} else {
values.push(value);
}
values.push(value);
}
currFilter.notValues = notValues.length > 0 ? notValues : undefined;

View file

@ -6,39 +6,37 @@
*/
import React, { useEffect } from 'react';
import { Route, Router } from 'react-router-dom';
import { render } from '@testing-library/react';
import { UrlStorageContextProvider, useSeriesStorage } from './use_series_storage';
import { getHistoryFromUrl } from '../rtl_helpers';
import { render } from '@testing-library/react';
const mockSingleSeries = [
{
name: 'performance-distribution',
const mockSingleSeries = {
'performance-distribution': {
reportType: 'data-distribution',
dataType: 'ux',
breakdown: 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
},
];
};
const mockMultipleSeries = [
{
name: 'performance-distribution',
const mockMultipleSeries = {
'performance-distribution': {
reportType: 'data-distribution',
dataType: 'ux',
breakdown: 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
},
{
name: 'kpi-over-time',
'kpi-over-time': {
reportType: 'kpi-over-time',
dataType: 'synthetics',
breakdown: 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
},
];
};
describe('userSeriesStorage', function () {
describe('userSeries', function () {
function setupTestComponent(seriesData: any) {
const setData = jest.fn();
function TestComponent() {
const data = useSeriesStorage();
@ -50,20 +48,11 @@ describe('userSeriesStorage', function () {
}
render(
<Router history={getHistoryFromUrl('/app/observability/exploratory-view/configure')}>
<Route path={'/app/observability/exploratory-view/:mode'}>
<UrlStorageContextProvider
storage={{
get: jest
.fn()
.mockImplementation((key: string) => (key === 'sr' ? seriesData : null)),
set: jest.fn(),
}}
>
<TestComponent />
</UrlStorageContextProvider>
</Route>
</Router>
<UrlStorageContextProvider
storage={{ get: jest.fn().mockReturnValue(seriesData), set: jest.fn() }}
>
<TestComponent />
</UrlStorageContextProvider>
);
return setData;
@ -74,20 +63,22 @@ describe('userSeriesStorage', function () {
expect(setData).toHaveBeenCalledTimes(2);
expect(setData).toHaveBeenLastCalledWith(
expect.objectContaining({
allSeries: [
{
name: 'performance-distribution',
dataType: 'ux',
allSeries: {
'performance-distribution': {
breakdown: 'user_agent.name',
dataType: 'ux',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
},
],
},
allSeriesIds: ['performance-distribution'],
firstSeries: {
name: 'performance-distribution',
dataType: 'ux',
breakdown: 'user_agent.name',
dataType: 'ux',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
},
firstSeriesId: 'performance-distribution',
})
);
});
@ -98,38 +89,42 @@ describe('userSeriesStorage', function () {
expect(setData).toHaveBeenCalledTimes(2);
expect(setData).toHaveBeenLastCalledWith(
expect.objectContaining({
allSeries: [
{
name: 'performance-distribution',
dataType: 'ux',
allSeries: {
'performance-distribution': {
breakdown: 'user_agent.name',
dataType: 'ux',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
},
{
name: 'kpi-over-time',
'kpi-over-time': {
reportType: 'kpi-over-time',
dataType: 'synthetics',
breakdown: 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
},
],
},
allSeriesIds: ['performance-distribution', 'kpi-over-time'],
firstSeries: {
name: 'performance-distribution',
dataType: 'ux',
breakdown: 'user_agent.name',
dataType: 'ux',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
},
firstSeriesId: 'performance-distribution',
})
);
});
it('should return expected result when there are no series', function () {
const setData = setupTestComponent([]);
const setData = setupTestComponent({});
expect(setData).toHaveBeenCalledTimes(1);
expect(setData).toHaveBeenCalledTimes(2);
expect(setData).toHaveBeenLastCalledWith(
expect.objectContaining({
allSeries: [],
allSeries: {},
allSeriesIds: [],
firstSeries: undefined,
firstSeriesId: undefined,
})
);
});

View file

@ -6,7 +6,6 @@
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useRouteMatch } from 'react-router-dom';
import {
IKbnUrlStateStorage,
ISessionStorageStateStorage,
@ -23,19 +22,13 @@ import { OperationType, SeriesType } from '../../../../../../lens/public';
import { URL_KEYS } from '../configurations/constants/url_constants';
export interface SeriesContextValue {
firstSeries?: SeriesUrl;
autoApply: boolean;
lastRefresh: number;
setLastRefresh: (val: number) => void;
setAutoApply: (val: boolean) => void;
applyChanges: () => void;
firstSeries: SeriesUrl;
firstSeriesId: string;
allSeriesIds: string[];
allSeries: AllSeries;
setSeries: (seriesIndex: number, newValue: SeriesUrl) => void;
getSeries: (seriesIndex: number) => SeriesUrl | undefined;
removeSeries: (seriesIndex: number) => void;
setReportType: (reportType: string) => void;
storage: IKbnUrlStateStorage | ISessionStorageStateStorage;
reportType: ReportViewType;
setSeries: (seriesIdN: string, newValue: SeriesUrl) => void;
getSeries: (seriesId: string) => SeriesUrl;
removeSeries: (seriesId: string) => void;
}
export const UrlStorageContext = createContext<SeriesContextValue>({} as SeriesContextValue);
@ -43,112 +36,72 @@ interface ProviderProps {
storage: IKbnUrlStateStorage | ISessionStorageStateStorage;
}
export function convertAllShortSeries(allShortSeries: AllShortSeries) {
return (allShortSeries ?? []).map((shortSeries) => convertFromShortUrl(shortSeries));
}
function convertAllShortSeries(allShortSeries: AllShortSeries) {
const allSeriesIds = Object.keys(allShortSeries);
const allSeriesN: AllSeries = {};
allSeriesIds.forEach((seriesKey) => {
allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]);
});
export const allSeriesKey = 'sr';
const autoApplyKey = 'autoApply';
const reportTypeKey = 'reportType';
return allSeriesN;
}
export function UrlStorageContextProvider({
children,
storage,
}: ProviderProps & { children: JSX.Element }) {
const allSeriesKey = 'sr';
const [allShortSeries, setAllShortSeries] = useState<AllShortSeries>(
() => storage.get(allSeriesKey) ?? {}
);
const [allSeries, setAllSeries] = useState<AllSeries>(() =>
convertAllShortSeries(storage.get(allSeriesKey) ?? [])
convertAllShortSeries(storage.get(allSeriesKey) ?? {})
);
const [autoApply, setAutoApply] = useState<boolean>(() => storage.get(autoApplyKey) ?? true);
const [lastRefresh, setLastRefresh] = useState<number>(() => Date.now());
const [reportType, setReportType] = useState<string>(
() => (storage as IKbnUrlStateStorage).get(reportTypeKey) ?? ''
);
const [firstSeriesId, setFirstSeriesId] = useState('');
const [firstSeries, setFirstSeries] = useState<SeriesUrl>();
const isPreview = !!useRouteMatch('/exploratory-view/preview');
useEffect(() => {
const allShortSeries = allSeries.map((series) => convertToShortUrl(series));
const firstSeriesT = allSeries?.[0];
setFirstSeries(firstSeriesT);
if (autoApply) {
(storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries);
}
}, [allSeries, autoApply, storage]);
useEffect(() => {
// needed for tab change
const allShortSeries = allSeries.map((series) => convertToShortUrl(series));
const allSeriesIds = Object.keys(allShortSeries);
const allSeriesN: AllSeries = convertAllShortSeries(allShortSeries ?? {});
setAllSeries(allSeriesN);
setFirstSeriesId(allSeriesIds?.[0]);
setFirstSeries(allSeriesN?.[allSeriesIds?.[0]]);
(storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries);
(storage as IKbnUrlStateStorage).set(reportTypeKey, reportType);
// this is only needed for tab change, so we will not add allSeries into dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPreview, storage]);
}, [allShortSeries, storage]);
const setSeries = useCallback((seriesIndex: number, newValue: SeriesUrl) => {
setAllSeries((prevAllSeries) => {
const newStateRest = prevAllSeries.map((series, index) => {
if (index === seriesIndex) {
return newValue;
}
return series;
});
if (prevAllSeries.length === seriesIndex) {
return [...newStateRest, newValue];
}
return [...newStateRest];
const setSeries = (seriesIdN: string, newValue: SeriesUrl) => {
setAllShortSeries((prevState) => {
prevState[seriesIdN] = convertToShortUrl(newValue);
return { ...prevState };
});
}, []);
};
useEffect(() => {
(storage as IKbnUrlStateStorage).set(reportTypeKey, reportType);
}, [reportType, storage]);
const removeSeries = (seriesIdN: string) => {
setAllShortSeries((prevState) => {
delete prevState[seriesIdN];
return { ...prevState };
});
};
const removeSeries = useCallback((seriesIndex: number) => {
setAllSeries((prevAllSeries) =>
prevAllSeries.filter((seriesT, index) => index !== seriesIndex)
);
}, []);
const allSeriesIds = Object.keys(allShortSeries);
const getSeries = useCallback(
(seriesIndex: number) => {
return allSeries[seriesIndex];
(seriesId?: string) => {
return seriesId ? allSeries?.[seriesId] ?? {} : ({} as SeriesUrl);
},
[allSeries]
);
const applyChanges = useCallback(() => {
const allShortSeries = allSeries.map((series) => convertToShortUrl(series));
(storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries);
setLastRefresh(Date.now());
}, [allSeries, storage]);
useEffect(() => {
(storage as IKbnUrlStateStorage).set(autoApplyKey, autoApply);
}, [autoApply, storage]);
const value = {
autoApply,
setAutoApply,
applyChanges,
storage,
getSeries,
setSeries,
removeSeries,
firstSeriesId,
allSeries,
lastRefresh,
setLastRefresh,
setReportType,
reportType: storage.get(reportTypeKey) as ReportViewType,
allSeriesIds,
firstSeries: firstSeries!,
};
return <UrlStorageContext.Provider value={value}>{children}</UrlStorageContext.Provider>;
@ -159,9 +112,10 @@ export function useSeriesStorage() {
}
function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl {
const { dt, op, st, bd, ft, time, rdf, mt, h, n, c, ...restSeries } = newValue;
const { dt, op, st, rt, bd, ft, time, rdf, mt, ...restSeries } = newValue;
return {
operationType: op,
reportType: rt!,
seriesType: st,
breakdown: bd,
filters: ft!,
@ -169,31 +123,26 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl {
reportDefinitions: rdf,
dataType: dt!,
selectedMetricField: mt,
hidden: h,
name: n,
color: c,
...restSeries,
};
}
interface ShortUrlSeries {
[URL_KEYS.OPERATION_TYPE]?: OperationType;
[URL_KEYS.REPORT_TYPE]?: ReportViewType;
[URL_KEYS.DATA_TYPE]?: AppDataType;
[URL_KEYS.SERIES_TYPE]?: SeriesType;
[URL_KEYS.BREAK_DOWN]?: string;
[URL_KEYS.FILTERS]?: UrlFilter[];
[URL_KEYS.REPORT_DEFINITIONS]?: URLReportDefinition;
[URL_KEYS.SELECTED_METRIC]?: string;
[URL_KEYS.HIDDEN]?: boolean;
[URL_KEYS.NAME]: string;
[URL_KEYS.COLOR]?: string;
time?: {
to: string;
from: string;
};
}
export type AllShortSeries = ShortUrlSeries[];
export type AllSeries = SeriesUrl[];
export type AllShortSeries = Record<string, ShortUrlSeries>;
export type AllSeries = Record<string, SeriesUrl>;
export const NEW_SERIES_KEY = 'new-series';
export const NEW_SERIES_KEY = 'new-series-key';

View file

@ -25,9 +25,11 @@ import { TypedLensByValueInput } from '../../../../../lens/public';
export function ExploratoryViewPage({
saveAttributes,
multiSeries = false,
useSessionStorage = false,
}: {
useSessionStorage?: boolean;
multiSeries?: boolean;
saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void;
}) {
useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' });
@ -59,7 +61,7 @@ export function ExploratoryViewPage({
<Wrapper>
<IndexPatternContextProvider>
<UrlStorageContextProvider storage={kbnUrlStateStorage}>
<ExploratoryView saveAttributes={saveAttributes} />
<ExploratoryView saveAttributes={saveAttributes} multiSeries={multiSeries} />
</UrlStorageContextProvider>
</IndexPatternContextProvider>
</Wrapper>

View file

@ -7,51 +7,16 @@
import { i18n } from '@kbn/i18n';
import React, { Dispatch, SetStateAction, useCallback } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash';
import { combineTimeRanges } from './exploratory_view';
import { TypedLensByValueInput } from '../../../../../lens/public';
import { useSeriesStorage } from './hooks/use_series_storage';
import { ObservabilityPublicPluginsStart } from '../../../plugin';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { ReportViewType, SeriesUrl } from './types';
import { ReportTypes } from './configurations/constants';
interface Props {
lensAttributes: TypedLensByValueInput['attributes'];
setLastUpdated: Dispatch<SetStateAction<number | undefined>>;
}
export const combineTimeRanges = (
reportType: ReportViewType,
allSeries: SeriesUrl[],
firstSeries?: SeriesUrl
) => {
let to: string = '';
let from: string = '';
if (reportType === ReportTypes.KPI) {
return firstSeries?.time;
}
allSeries.forEach((series) => {
if (
series.dataType &&
series.selectedMetricField &&
!isEmpty(series.reportDefinitions) &&
series.time
) {
const seriesTo = new Date(series.time.to);
const seriesFrom = new Date(series.time.from);
if (!to || seriesTo > new Date(to)) {
to = series.time.to;
}
if (!from || seriesFrom < new Date(from)) {
from = series.time.from;
}
}
});
return { to, from };
};
export function LensEmbeddable(props: Props) {
const { lensAttributes, setLastUpdated } = props;
@ -62,11 +27,9 @@ export function LensEmbeddable(props: Props) {
const LensComponent = lens?.EmbeddableComponent;
const { firstSeries, setSeries, allSeries, reportType } = useSeriesStorage();
const { firstSeriesId, firstSeries: series, setSeries, allSeries } = useSeriesStorage();
const firstSeriesId = 0;
const timeRange = firstSeries ? combineTimeRanges(reportType, allSeries, firstSeries) : null;
const timeRange = combineTimeRanges(allSeries, series);
const onLensLoad = useCallback(() => {
setLastUpdated(Date.now());
@ -74,9 +37,9 @@ export function LensEmbeddable(props: Props) {
const onBrushEnd = useCallback(
({ range }: { range: number[] }) => {
if (reportType !== 'data-distribution' && firstSeries) {
if (series?.reportType !== 'data-distribution') {
setSeries(firstSeriesId, {
...firstSeries,
...series,
time: {
from: new Date(range[0]).toISOString(),
to: new Date(range[1]).toISOString(),
@ -90,30 +53,16 @@ export function LensEmbeddable(props: Props) {
);
}
},
[reportType, setSeries, firstSeries, notifications?.toasts]
[notifications?.toasts, series, firstSeriesId, setSeries]
);
if (timeRange === null || !firstSeries) {
return null;
}
return (
<LensWrapper>
<LensComponent
id="exploratoryView"
timeRange={timeRange}
attributes={lensAttributes}
onLoad={onLensLoad}
onBrushEnd={onBrushEnd}
/>
</LensWrapper>
<LensComponent
id="exploratoryView"
timeRange={timeRange}
attributes={lensAttributes}
onLoad={onLensLoad}
onBrushEnd={onBrushEnd}
/>
);
}
const LensWrapper = styled.div`
height: 100%;
&&& > div {
height: 100%;
}
`;

View file

@ -10,7 +10,7 @@ import React, { ReactElement } from 'react';
import { stringify } from 'query-string';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render as reactTestLibRender, RenderOptions } from '@testing-library/react';
import { Route, Router } from 'react-router-dom';
import { Router } from 'react-router-dom';
import { createMemoryHistory, History } from 'history';
import { CoreStart } from 'kibana/public';
import { I18nProvider } from '@kbn/i18n/react';
@ -24,7 +24,7 @@ import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/
import { lensPluginMock } from '../../../../../lens/public/mocks';
import * as useAppIndexPatternHook from './hooks/use_app_index_pattern';
import { IndexPatternContextProvider } from './hooks/use_app_index_pattern';
import { AllSeries, SeriesContextValue, UrlStorageContext } from './hooks/use_series_storage';
import { AllSeries, UrlStorageContext } from './hooks/use_series_storage';
import * as fetcherHook from '../../../hooks/use_fetcher';
import * as useSeriesFilterHook from './hooks/use_series_filters';
@ -39,10 +39,9 @@ import {
IndexPattern,
IndexPatternsContract,
} from '../../../../../../../src/plugins/data/common/index_patterns/index_patterns';
import { AppDataType, SeriesUrl, UrlFilter } from './types';
import { AppDataType, UrlFilter } from './types';
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
import { ListItem } from '../../../hooks/use_values_list';
import { TRANSACTION_DURATION } from './configurations/constants/elasticsearch_fieldnames';
interface KibanaProps {
services?: KibanaServices;
@ -159,11 +158,9 @@ export function MockRouter<ExtraCore>({
}: MockRouterProps<ExtraCore>) {
return (
<Router history={history}>
<Route path={'/app/observability/exploratory-view/:mode'}>
<MockKibanaProvider core={core} kibanaProps={kibanaProps} history={history}>
{children}
</MockKibanaProvider>
</Route>
<MockKibanaProvider core={core} kibanaProps={kibanaProps} history={history}>
{children}
</MockKibanaProvider>
</Router>
);
}
@ -176,7 +173,7 @@ export function render<ExtraCore>(
core: customCore,
kibanaProps,
renderOptions,
url = '/app/observability/exploratory-view/configure#?autoApply=!t',
url,
initSeries = {},
}: RenderRouterOptions<ExtraCore> = {}
) {
@ -206,7 +203,7 @@ export function render<ExtraCore>(
};
}
export const getHistoryFromUrl = (url: Url) => {
const getHistoryFromUrl = (url: Url) => {
if (typeof url === 'string') {
return createMemoryHistory({
initialEntries: [url],
@ -255,15 +252,6 @@ export const mockUseValuesList = (values?: ListItem[]) => {
return { spy, onRefreshTimeRange };
};
export const mockUxSeries = {
name: 'performance-distribution',
dataType: 'ux',
breakdown: 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
reportDefinitions: { 'service.name': ['elastic-co'] },
selectedMetricField: TRANSACTION_DURATION,
} as SeriesUrl;
function mockSeriesStorageContext({
data,
filters,
@ -273,34 +261,34 @@ function mockSeriesStorageContext({
filters?: UrlFilter[];
breakdown?: string;
}) {
const testSeries = {
...mockUxSeries,
breakdown: breakdown || 'user_agent.name',
...(filters ? { filters } : {}),
const mockDataSeries = data || {
'performance-distribution': {
reportType: 'data-distribution',
dataType: 'ux',
breakdown: breakdown || 'user_agent.name',
time: { from: 'now-15m', to: 'now' },
...(filters ? { filters } : {}),
},
};
const allSeriesIds = Object.keys(mockDataSeries);
const firstSeriesId = allSeriesIds?.[0];
const mockDataSeries = data || [testSeries];
const series = mockDataSeries[firstSeriesId];
const removeSeries = jest.fn();
const setSeries = jest.fn();
const getSeries = jest.fn().mockReturnValue(testSeries);
const getSeries = jest.fn().mockReturnValue(series);
return {
firstSeriesId,
allSeriesIds,
removeSeries,
setSeries,
getSeries,
autoApply: true,
reportType: 'data-distribution',
lastRefresh: Date.now(),
setLastRefresh: jest.fn(),
setAutoApply: jest.fn(),
applyChanges: jest.fn(),
firstSeries: mockDataSeries[0],
firstSeries: mockDataSeries[firstSeriesId],
allSeries: mockDataSeries,
setReportType: jest.fn(),
storage: { get: jest.fn() } as any,
} as SeriesContextValue;
};
}
export function mockUseSeriesFilter() {

View file

@ -7,12 +7,12 @@
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { mockUxSeries, render } from '../../rtl_helpers';
import { render } from '../../rtl_helpers';
import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types';
describe.skip('SeriesChartTypesSelect', function () {
it('should render properly', async function () {
render(<SeriesChartTypesSelect seriesId={0} defaultChartType={'line'} series={mockUxSeries} />);
render(<SeriesChartTypesSelect seriesId={'series-id'} defaultChartType={'line'} />);
await waitFor(() => {
screen.getByText(/chart type/i);
@ -21,7 +21,7 @@ describe.skip('SeriesChartTypesSelect', function () {
it('should call set series on change', async function () {
const { setSeries } = render(
<SeriesChartTypesSelect seriesId={0} defaultChartType={'line'} series={mockUxSeries} />
<SeriesChartTypesSelect seriesId={'series-id'} defaultChartType={'line'} />
);
await waitFor(() => {

View file

@ -6,11 +6,11 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, EuiSuperSelect } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../../plugin';
import { SeriesUrl, useFetcher } from '../../../../..';
import { useFetcher } from '../../../../..';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesType } from '../../../../../../../lens/public';
@ -20,14 +20,16 @@ const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.
export function SeriesChartTypesSelect({
seriesId,
series,
seriesTypes,
defaultChartType,
}: {
seriesId: number;
series: SeriesUrl;
seriesId: string;
seriesTypes?: SeriesType[];
defaultChartType: SeriesType;
}) {
const { setSeries } = useSeriesStorage();
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const seriesType = series?.seriesType ?? defaultChartType;
@ -40,15 +42,17 @@ export function SeriesChartTypesSelect({
onChange={onChange}
value={seriesType}
excludeChartTypes={['bar_percentage_stacked']}
includeChartTypes={[
'bar',
'bar_horizontal',
'line',
'area',
'bar_stacked',
'area_stacked',
'bar_horizontal_percentage_stacked',
]}
includeChartTypes={
seriesTypes || [
'bar',
'bar_horizontal',
'line',
'area',
'bar_stacked',
'area_stacked',
'bar_horizontal_percentage_stacked',
]
}
label={CHART_TYPE_LABEL}
/>
);
@ -101,14 +105,14 @@ export function XYChartTypesSelect({
});
return (
<EuiFormRow label={CHART_TYPE_LABEL} style={{ minWidth: 280 }}>
<EuiSuperSelect
fullWidth
valueOfSelected={value}
isLoading={loading}
options={options}
onChange={onChange}
/>
</EuiFormRow>
<EuiSuperSelect
fullWidth
compressed
prepend={CHART_TYPE_LABEL}
valueOfSelected={value}
isLoading={loading}
options={options}
onChange={onChange}
/>
);
}

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockAppIndexPattern, render } from '../../rtl_helpers';
import { dataTypes, DataTypesCol } from './data_types_col';
describe('DataTypesCol', function () {
const seriesId = 'test-series-id';
mockAppIndexPattern();
it('should render properly', function () {
const { getByText } = render(<DataTypesCol seriesId={seriesId} />);
dataTypes.forEach(({ label }) => {
getByText(label);
});
});
it('should set series on change', function () {
const { setSeries } = render(<DataTypesCol seriesId={seriesId} />);
fireEvent.click(screen.getByText(/user experience \(rum\)/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'ux',
isNew: true,
time: {
from: 'now-15m',
to: 'now',
},
});
});
it('should set series on change on already selected', function () {
const initSeries = {
data: {
[seriesId]: {
dataType: 'synthetics' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
},
},
};
render(<DataTypesCol seriesId={seriesId} />, { initSeries });
const button = screen.getByRole('button', {
name: /Synthetic Monitoring/i,
});
expect(button.classList).toContain('euiButton--fill');
});
});

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { AppDataType } from '../../types';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { useSeriesStorage } from '../../hooks/use_series_storage';
export const dataTypes: Array<{ id: AppDataType; label: string }> = [
{ id: 'synthetics', label: 'Synthetic Monitoring' },
{ id: 'ux', label: 'User Experience (RUM)' },
{ id: 'mobile', label: 'Mobile Experience' },
// { id: 'infra_logs', label: 'Logs' },
// { id: 'infra_metrics', label: 'Metrics' },
// { id: 'apm', label: 'APM' },
];
export function DataTypesCol({ seriesId }: { seriesId: string }) {
const { getSeries, setSeries, removeSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { loading } = useAppIndexPatternContext();
const onDataTypeChange = (dataType?: AppDataType) => {
if (!dataType) {
removeSeries(seriesId);
} else {
setSeries(seriesId || `${dataType}-series`, {
dataType,
isNew: true,
time: series.time,
} as any);
}
};
const selectedDataType = series.dataType;
return (
<FlexGroup direction="column" gutterSize="xs">
{dataTypes.map(({ id: dataTypeId, label }) => (
<EuiFlexItem key={dataTypeId}>
<Button
size="s"
iconSide="right"
iconType="arrowRight"
color={selectedDataType === dataTypeId ? 'primary' : 'text'}
fill={selectedDataType === dataTypeId}
isDisabled={loading}
isLoading={loading && selectedDataType === dataTypeId}
onClick={() => {
onDataTypeChange(dataTypeId);
}}
>
{label}
</Button>
</EuiFlexItem>
))}
</FlexGroup>
);
}
const FlexGroup = styled(EuiFlexGroup)`
width: 100%;
`;
const Button = styled(EuiButton)`
will-change: transform;
`;

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import styled from 'styled-components';
import { SeriesDatePicker } from '../../series_date_picker';
import { DateRangePicker } from '../../series_date_picker/date_range_picker';
import { useSeriesStorage } from '../../hooks/use_series_storage';
interface Props {
seriesId: string;
}
export function DatePickerCol({ seriesId }: Props) {
const { firstSeriesId, getSeries } = useSeriesStorage();
const { reportType } = getSeries(firstSeriesId);
return (
<Wrapper>
{firstSeriesId === seriesId || reportType !== 'kpi-over-time' ? (
<SeriesDatePicker seriesId={seriesId} />
) : (
<DateRangePicker seriesId={seriesId} />
)}
</Wrapper>
);
}
const Wrapper = styled.div`
.euiSuperDatePicker__flexWrapper {
width: 100%;
> .euiFlexItem {
margin-right: 0px;
}
}
`;

View file

@ -7,66 +7,62 @@
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockUxSeries, render } from '../../rtl_helpers';
import { render } from '../../rtl_helpers';
import { OperationTypeSelect } from './operation_type_select';
describe('OperationTypeSelect', function () {
it('should render properly', function () {
render(<OperationTypeSelect seriesId={0} series={mockUxSeries} />);
render(<OperationTypeSelect seriesId={'series-id'} />);
screen.getByText('Select an option: , is selected');
});
it('should display selected value', function () {
const initSeries = {
data: [
{
name: 'performance-distribution',
data: {
'performance-distribution': {
dataType: 'ux' as const,
reportType: 'kpi-over-time' as const,
operationType: 'median' as const,
time: { from: 'now-15m', to: 'now' },
},
],
},
};
render(<OperationTypeSelect seriesId={0} series={initSeries.data[0]} />, {
initSeries,
});
render(<OperationTypeSelect seriesId={'series-id'} />, { initSeries });
screen.getByText('Median');
});
it('should call set series on change', function () {
const initSeries = {
data: [
{
name: 'performance-distribution',
data: {
'series-id': {
dataType: 'ux' as const,
reportType: 'kpi-over-time' as const,
operationType: 'median' as const,
time: { from: 'now-15m', to: 'now' },
},
],
},
};
const { setSeries } = render(<OperationTypeSelect seriesId={0} series={initSeries.data[0]} />, {
initSeries,
});
const { setSeries } = render(<OperationTypeSelect seriesId={'series-id'} />, { initSeries });
fireEvent.click(screen.getByTestId('operationTypeSelect'));
expect(setSeries).toHaveBeenCalledWith(0, {
expect(setSeries).toHaveBeenCalledWith('series-id', {
operationType: 'median',
dataType: 'ux',
reportType: 'kpi-over-time',
time: { from: 'now-15m', to: 'now' },
name: 'performance-distribution',
});
fireEvent.click(screen.getByText('95th Percentile'));
expect(setSeries).toHaveBeenCalledWith(0, {
expect(setSeries).toHaveBeenCalledWith('series-id', {
operationType: '95th',
dataType: 'ux',
reportType: 'kpi-over-time',
time: { from: 'now-15m', to: 'now' },
name: 'performance-distribution',
});
});
});

View file

@ -11,18 +11,17 @@ import { EuiSuperSelect } from '@elastic/eui';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { OperationType } from '../../../../../../../lens/public';
import { SeriesUrl } from '../../types';
export function OperationTypeSelect({
seriesId,
series,
defaultOperationType,
}: {
seriesId: number;
series: SeriesUrl;
seriesId: string;
defaultOperationType?: OperationType;
}) {
const { setSeries } = useSeriesStorage();
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const operationType = series?.operationType;
@ -84,7 +83,11 @@ export function OperationTypeSelect({
return (
<EuiSuperSelect
fullWidth
prepend={i18n.translate('xpack.observability.expView.operationType.label', {
defaultMessage: 'Calculation',
})}
data-test-subj="operationTypeSelect"
compressed
valueOfSelected={operationType || defaultOperationType}
options={options}
onChange={onChange}

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, render } from '../../rtl_helpers';
import { ReportBreakdowns } from './report_breakdowns';
import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames';
describe('Series Builder ReportBreakdowns', function () {
const seriesId = 'test-series-id';
const dataViewSeries = getDefaultConfigs({
reportType: 'data-distribution',
dataType: 'ux',
indexPattern: mockIndexPattern,
});
it('should render properly', function () {
render(<ReportBreakdowns seriesConfig={dataViewSeries} seriesId={seriesId} />);
screen.getByText('Select an option: , is selected');
screen.getAllByText('Browser family');
});
it('should set new series breakdown on change', function () {
const { setSeries } = render(
<ReportBreakdowns seriesConfig={dataViewSeries} seriesId={seriesId} />
);
const btn = screen.getByRole('button', {
name: /select an option: Browser family , is selected/i,
hidden: true,
});
fireEvent.click(btn);
fireEvent.click(screen.getByText(/operating system/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(seriesId, {
breakdown: USER_AGENT_OS,
dataType: 'ux',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
});
});
it('should set undefined on new series on no select breakdown', function () {
const { setSeries } = render(
<ReportBreakdowns seriesConfig={dataViewSeries} seriesId={seriesId} />
);
const btn = screen.getByRole('button', {
name: /select an option: Browser family , is selected/i,
hidden: true,
});
fireEvent.click(btn);
fireEvent.click(screen.getByText(/no breakdown/i));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(seriesId, {
breakdown: undefined,
dataType: 'ux',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { Breakdowns } from '../../series_editor/columns/breakdowns';
import { SeriesConfig } from '../../types';
export function ReportBreakdowns({
seriesId,
seriesConfig,
}: {
seriesConfig: SeriesConfig;
seriesId: string;
}) {
return (
<Breakdowns
seriesConfig={seriesConfig}
breakdowns={seriesConfig.breakdownFields ?? []}
seriesId={seriesId}
/>
);
}

View file

@ -12,14 +12,14 @@ import {
mockAppIndexPattern,
mockIndexPattern,
mockUseValuesList,
mockUxSeries,
render,
} from '../../rtl_helpers';
import { ReportDefinitionCol } from './report_definition_col';
import { SERVICE_NAME } from '../../configurations/constants/elasticsearch_fieldnames';
describe('Series Builder ReportDefinitionCol', function () {
mockAppIndexPattern();
const seriesId = 0;
const seriesId = 'test-series-id';
const seriesConfig = getDefaultConfigs({
reportType: 'data-distribution',
@ -27,24 +27,36 @@ describe('Series Builder ReportDefinitionCol', function () {
dataType: 'ux',
});
const initSeries = {
data: {
[seriesId]: {
dataType: 'ux' as const,
reportType: 'data-distribution' as const,
time: { from: 'now-30d', to: 'now' },
reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] },
},
},
};
mockUseValuesList([{ label: 'elastic-co', count: 10 }]);
it('renders', async () => {
render(
<ReportDefinitionCol seriesConfig={seriesConfig} seriesId={seriesId} series={mockUxSeries} />
);
it('should render properly', async function () {
render(<ReportDefinitionCol seriesConfig={seriesConfig} seriesId={seriesId} />, {
initSeries,
});
await waitFor(() => {
expect(screen.getByText('Web Application')).toBeInTheDocument();
expect(screen.getByText('Environment')).toBeInTheDocument();
expect(screen.getByText('Search Environment')).toBeInTheDocument();
screen.getByText('Web Application');
screen.getByText('Environment');
screen.getByText('Select an option: Page load time, is selected');
screen.getByText('Page load time');
});
});
it('should render selected report definitions', async function () {
render(
<ReportDefinitionCol seriesConfig={seriesConfig} seriesId={seriesId} series={mockUxSeries} />
);
render(<ReportDefinitionCol seriesConfig={seriesConfig} seriesId={seriesId} />, {
initSeries,
});
expect(await screen.findByText('elastic-co')).toBeInTheDocument();
@ -53,7 +65,8 @@ describe('Series Builder ReportDefinitionCol', function () {
it('should be able to remove selected definition', async function () {
const { setSeries } = render(
<ReportDefinitionCol seriesConfig={seriesConfig} seriesId={seriesId} series={mockUxSeries} />
<ReportDefinitionCol seriesConfig={seriesConfig} seriesId={seriesId} />,
{ initSeries }
);
expect(
@ -67,14 +80,11 @@ describe('Series Builder ReportDefinitionCol', function () {
fireEvent.click(removeBtn);
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'ux',
name: 'performance-distribution',
breakdown: 'user_agent.name',
reportDefinitions: {},
selectedMetricField: 'transaction.duration.us',
time: { from: 'now-15m', to: 'now' },
reportType: 'data-distribution',
time: { from: 'now-30d', to: 'now' },
});
});
});

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import styled from 'styled-components';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { ReportMetricOptions } from '../report_metric_options';
import { SeriesConfig } from '../../types';
import { SeriesChartTypesSelect } from './chart_types';
import { OperationTypeSelect } from './operation_type_select';
import { DatePickerCol } from './date_picker_col';
import { parseCustomFieldName } from '../../configurations/lens_attributes';
import { ReportDefinitionField } from './report_definition_field';
function getColumnType(seriesConfig: SeriesConfig, selectedMetricField?: string) {
const { columnType } = parseCustomFieldName(seriesConfig, selectedMetricField);
return columnType;
}
export function ReportDefinitionCol({
seriesConfig,
seriesId,
}: {
seriesConfig: SeriesConfig;
seriesId: string;
}) {
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { reportDefinitions: selectedReportDefinitions = {}, selectedMetricField } = series ?? {};
const {
definitionFields,
defaultSeriesType,
hasOperationType,
yAxisColumns,
metricOptions,
} = seriesConfig;
const onChange = (field: string, value?: string[]) => {
if (!value?.[0]) {
delete selectedReportDefinitions[field];
setSeries(seriesId, {
...series,
reportDefinitions: { ...selectedReportDefinitions },
});
} else {
setSeries(seriesId, {
...series,
reportDefinitions: { ...selectedReportDefinitions, [field]: value },
});
}
};
const columnType = getColumnType(seriesConfig, selectedMetricField);
return (
<FlexGroup direction="column" gutterSize="s">
<EuiFlexItem>
<DatePickerCol seriesId={seriesId} />
</EuiFlexItem>
<EuiHorizontalRule margin="xs" />
{definitionFields.map((field) => (
<EuiFlexItem key={field}>
<ReportDefinitionField
seriesId={seriesId}
seriesConfig={seriesConfig}
field={field}
onChange={onChange}
/>
</EuiFlexItem>
))}
{metricOptions && (
<EuiFlexItem>
<ReportMetricOptions options={metricOptions} seriesId={seriesId} />
</EuiFlexItem>
)}
{(hasOperationType || columnType === 'operation') && (
<EuiFlexItem>
<OperationTypeSelect
seriesId={seriesId}
defaultOperationType={yAxisColumns[0].operationType}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<SeriesChartTypesSelect
seriesId={seriesId}
defaultChartType={defaultSeriesType}
seriesTypes={seriesConfig.seriesTypes}
/>
</EuiFlexItem>
</FlexGroup>
);
}
const FlexGroup = styled(EuiFlexGroup)`
width: 100%;
`;

View file

@ -6,25 +6,30 @@
*/
import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEmpty } from 'lodash';
import { ExistsFilter } from '@kbn/es-query';
import FieldValueSuggestions from '../../../field_value_suggestions';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearch';
import { PersistableFilter } from '../../../../../../../lens/common';
import { buildPhrasesFilter } from '../../configurations/utils';
import { SeriesConfig, SeriesUrl } from '../../types';
import { SeriesConfig } from '../../types';
import { ALL_VALUES_SELECTED } from '../../../field_value_suggestions/field_value_combobox';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesId: string;
field: string;
seriesConfig: SeriesConfig;
onChange: (field: string, value?: string[]) => void;
}
export function ReportDefinitionField({ series, field, seriesConfig, onChange }: Props) {
export function ReportDefinitionField({ seriesId, field, seriesConfig, onChange }: Props) {
const { getSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { indexPattern } = useAppIndexPatternContext(series.dataType);
const { reportDefinitions: selectedReportDefinitions = {} } = series;
@ -59,26 +64,23 @@ export function ReportDefinitionField({ series, field, seriesConfig, onChange }:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(selectedReportDefinitions), JSON.stringify(baseFilters)]);
if (!indexPattern) {
return null;
}
return (
<FieldValueSuggestions
label={labels[field]}
sourceField={field}
indexPatternTitle={indexPattern.title}
selectedValue={selectedReportDefinitions?.[field]}
onChange={(val?: string[]) => onChange(field, val)}
filters={queryFilters}
time={series.time}
fullWidth={true}
asCombobox={true}
allowExclusions={false}
allowAllValuesSelection={true}
usePrependLabel={false}
compressed={false}
required={isEmpty(selectedReportDefinitions)}
/>
<EuiFlexGroup justifyContent="flexStart" gutterSize="s" alignItems="center" wrap>
<EuiFlexItem>
{indexPattern && (
<FieldValueSuggestions
label={labels[field]}
sourceField={field}
indexPatternTitle={indexPattern.title}
selectedValue={selectedReportDefinitions?.[field]}
onChange={(val?: string[]) => onChange(field, val)}
filters={queryFilters}
time={series.time}
fullWidth={true}
allowAllValuesSelection={true}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { ReportFilters } from './report_filters';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { mockIndexPattern, render } from '../../rtl_helpers';
describe('Series Builder ReportFilters', function () {
const seriesId = 'test-series-id';
const dataViewSeries = getDefaultConfigs({
reportType: 'data-distribution',
indexPattern: mockIndexPattern,
dataType: 'ux',
});
it('should render properly', function () {
render(<ReportFilters seriesConfig={dataViewSeries} seriesId={seriesId} />);
screen.getByText('Add filter');
});
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { SeriesFilter } from '../../series_editor/columns/series_filter';
import { SeriesConfig } from '../../types';
export function ReportFilters({
seriesConfig,
seriesId,
}: {
seriesConfig: SeriesConfig;
seriesId: string;
}) {
return (
<SeriesFilter
seriesConfig={seriesConfig}
filterFields={seriesConfig.filterFields}
baseFilters={seriesConfig.baseFilters}
seriesId={seriesId}
isNew={true}
labels={seriesConfig.labels}
/>
);
}

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockAppIndexPattern, render } from '../../rtl_helpers';
import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col';
import { ReportTypes } from '../series_builder';
import { DEFAULT_TIME } from '../../configurations/constants';
describe('ReportTypesCol', function () {
const seriesId = 'performance-distribution';
mockAppIndexPattern();
it('should render properly', function () {
render(<ReportTypesCol reportTypes={ReportTypes.ux} seriesId={seriesId} />);
screen.getByText('Performance distribution');
screen.getByText('KPI over time');
});
it('should display empty message', function () {
render(<ReportTypesCol reportTypes={[]} seriesId={seriesId} />);
screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT);
});
it('should set series on change', function () {
const { setSeries } = render(
<ReportTypesCol reportTypes={ReportTypes.synthetics} seriesId={seriesId} />
);
fireEvent.click(screen.getByText(/KPI over time/i));
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'ux',
selectedMetricField: undefined,
reportType: 'kpi-over-time',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);
});
it('should set selected as filled', function () {
const initSeries = {
data: {
[seriesId]: {
dataType: 'synthetics' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-15m', to: 'now' },
isNew: true,
},
},
};
const { setSeries } = render(
<ReportTypesCol reportTypes={ReportTypes.synthetics} seriesId={seriesId} />,
{ initSeries }
);
const button = screen.getByRole('button', {
name: /KPI over time/i,
});
expect(button.classList).toContain('euiButton--fill');
fireEvent.click(button);
// undefined on click selected
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'synthetics',
time: DEFAULT_TIME,
isNew: true,
});
});
});

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { map } from 'lodash';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { ReportViewType, SeriesUrl } from '../../types';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { DEFAULT_TIME } from '../../configurations/constants';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { ReportTypeItem } from '../series_builder';
interface Props {
seriesId: string;
reportTypes: ReportTypeItem[];
}
export function ReportTypesCol({ seriesId, reportTypes }: Props) {
const { setSeries, getSeries, firstSeries, firstSeriesId } = useSeriesStorage();
const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId);
const { loading, hasData } = useAppIndexPatternContext(restSeries.dataType);
if (!restSeries.dataType) {
return (
<FormattedMessage
id="xpack.observability.expView.seriesBuilder.selectDataType"
defaultMessage="No data type selected"
/>
);
}
if (!loading && !hasData) {
return (
<FormattedMessage
id="xpack.observability.reportTypeCol.nodata"
defaultMessage="No data available"
/>
);
}
const disabledReportTypes: ReportViewType[] = map(
reportTypes.filter(
({ reportType }) => firstSeriesId !== seriesId && reportType !== firstSeries.reportType
),
'reportType'
);
return reportTypes?.length > 0 ? (
<FlexGroup direction="column" gutterSize="xs">
{reportTypes.map(({ reportType, label }) => (
<EuiFlexItem key={reportType}>
<Button
fullWidth
size="s"
iconSide="right"
iconType="arrowRight"
color={selectedReportType === reportType ? 'primary' : 'text'}
fill={selectedReportType === reportType}
isDisabled={loading || disabledReportTypes.includes(reportType)}
onClick={() => {
if (reportType === selectedReportType) {
setSeries(seriesId, {
dataType: restSeries.dataType,
time: DEFAULT_TIME,
isNew: true,
} as SeriesUrl);
} else {
setSeries(seriesId, {
...restSeries,
reportType,
selectedMetricField: undefined,
breakdown: undefined,
time: restSeries?.time ?? DEFAULT_TIME,
});
}
}}
>
{label}
</Button>
</EuiFlexItem>
))}
</FlexGroup>
) : (
<EuiText color="subdued">{SELECTED_DATA_TYPE_FOR_REPORT}</EuiText>
);
}
export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate(
'xpack.observability.expView.reportType.noDataType',
{ defaultMessage: 'No data type selected.' }
);
const FlexGroup = styled(EuiFlexGroup)`
width: 100%;
`;
const Button = styled(EuiButton)`
will-change: transform;
`;

View file

@ -8,7 +8,6 @@
import React, { useEffect, useState } from 'react';
import { EuiIcon, EuiText } from '@elastic/eui';
import moment from 'moment';
import { FormattedMessage } from '@kbn/i18n/react';
interface Props {
lastUpdated?: number;
@ -19,34 +18,20 @@ export function LastUpdated({ lastUpdated }: Props) {
useEffect(() => {
const interVal = setInterval(() => {
setRefresh(Date.now());
}, 5000);
}, 1000);
return () => {
clearInterval(interVal);
};
}, []);
useEffect(() => {
setRefresh(Date.now());
}, [lastUpdated]);
if (!lastUpdated) {
return null;
}
const isWarning = moment().diff(moment(lastUpdated), 'minute') > 5;
const isDanger = moment().diff(moment(lastUpdated), 'minute') > 10;
return (
<EuiText color={isDanger ? 'danger' : isWarning ? 'warning' : 'subdued'} size="s">
<EuiIcon type="clock" />
<FormattedMessage
id="xpack.observability.expView.lastUpdated.label"
defaultMessage="Last Updated: {updatedDate}"
values={{
updatedDate: moment(lastUpdated).from(refresh),
}}
/>
<EuiText color="subdued" size="s">
<EuiIcon type="clock" /> Last Updated: {moment(lastUpdated).from(refresh)}
</EuiText>
);
}

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { SeriesConfig } from '../types';
interface Props {
seriesId: string;
defaultValue?: string;
options: SeriesConfig['metricOptions'];
}
export function ReportMetricOptions({ seriesId, options: opts }: Props) {
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const onChange = (value: string) => {
setSeries(seriesId, {
...series,
selectedMetricField: value,
});
};
const options = opts ?? [];
return (
<EuiSuperSelect
fullWidth
compressed
prepend={'Metric'}
options={options.map(({ label, field: fd, id }) => ({
value: fd || id,
inputDisplay: label,
}))}
valueOfSelected={series.selectedMetricField || options?.[0].field || options?.[0].id}
onChange={(value) => onChange(value)}
/>
);
}

View file

@ -0,0 +1,303 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { RefObject, useEffect, useState } from 'react';
import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import {
EuiBasicTable,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiSwitch,
} from '@elastic/eui';
import { rgba } from 'polished';
import { AppDataType, SeriesConfig, ReportViewType, SeriesUrl } from '../types';
import { DataTypesCol } from './columns/data_types_col';
import { ReportTypesCol } from './columns/report_types_col';
import { ReportDefinitionCol } from './columns/report_definition_col';
import { ReportFilters } from './columns/report_filters';
import { ReportBreakdowns } from './columns/report_breakdowns';
import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { getDefaultConfigs } from '../configurations/default_configs';
import { SeriesEditor } from '../series_editor/series_editor';
import { SeriesActions } from '../series_editor/columns/series_actions';
import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common';
import { LastUpdated } from './last_updated';
import {
CORE_WEB_VITALS_LABEL,
DEVICE_DISTRIBUTION_LABEL,
KPI_OVER_TIME_LABEL,
PERF_DIST_LABEL,
} from '../configurations/constants/labels';
export interface ReportTypeItem {
id: string;
reportType: ReportViewType;
label: string;
}
export const ReportTypes: Record<AppDataType, ReportTypeItem[]> = {
synthetics: [
{ id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL },
],
ux: [
{ id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL },
{ id: 'cwv', reportType: 'core-web-vitals', label: CORE_WEB_VITALS_LABEL },
],
mobile: [
{ id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL },
{ id: 'mdd', reportType: 'device-data-distribution', label: DEVICE_DISTRIBUTION_LABEL },
],
apm: [],
infra_logs: [],
infra_metrics: [],
};
interface BuilderItem {
id: string;
series: SeriesUrl;
seriesConfig?: SeriesConfig;
}
export function SeriesBuilder({
seriesBuilderRef,
lastUpdated,
multiSeries,
}: {
seriesBuilderRef: RefObject<HTMLDivElement>;
lastUpdated?: number;
multiSeries?: boolean;
}) {
const [editorItems, setEditorItems] = useState<BuilderItem[]>([]);
const { getSeries, allSeries, allSeriesIds, setSeries, removeSeries } = useSeriesStorage();
const { loading, indexPatterns } = useAppIndexPatternContext();
useEffect(() => {
const getDataViewSeries = (dataType: AppDataType, reportType: SeriesUrl['reportType']) => {
if (indexPatterns?.[dataType]) {
return getDefaultConfigs({
dataType,
indexPattern: indexPatterns[dataType],
reportType: reportType!,
});
}
};
const seriesToEdit: BuilderItem[] =
allSeriesIds
.filter((sId) => {
return allSeries?.[sId]?.isNew;
})
.map((sId) => {
const series = getSeries(sId);
const seriesConfig = getDataViewSeries(series.dataType, series.reportType);
return { id: sId, series, seriesConfig };
}) ?? [];
const initSeries: BuilderItem[] = [{ id: 'series-id', series: {} as SeriesUrl }];
setEditorItems(multiSeries || seriesToEdit.length > 0 ? seriesToEdit : initSeries);
}, [allSeries, allSeriesIds, getSeries, indexPatterns, loading, multiSeries]);
const columns = [
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', {
defaultMessage: 'Data Type',
}),
field: 'id',
width: '15%',
render: (seriesId: string) => <DataTypesCol seriesId={seriesId} />,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.report', {
defaultMessage: 'Report',
}),
width: '15%',
field: 'id',
render: (seriesId: string, { series: { dataType } }: BuilderItem) => (
<ReportTypesCol seriesId={seriesId} reportTypes={dataType ? ReportTypes[dataType] : []} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.definition', {
defaultMessage: 'Definition',
}),
width: '30%',
field: 'id',
render: (
seriesId: string,
{ series: { dataType, reportType }, seriesConfig }: BuilderItem
) => {
if (dataType && seriesConfig) {
return loading ? (
LOADING_VIEW
) : reportType ? (
<ReportDefinitionCol seriesId={seriesId} seriesConfig={seriesConfig} />
) : (
SELECT_REPORT_TYPE
);
}
return null;
},
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.filters', {
defaultMessage: 'Filters',
}),
width: '20%',
field: 'id',
render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) =>
reportType && seriesConfig ? (
<ReportFilters seriesId={seriesId} seriesConfig={seriesConfig} />
) : null,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', {
defaultMessage: 'Breakdowns',
}),
width: '20%',
field: 'id',
render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) =>
reportType && seriesConfig ? (
<ReportBreakdowns seriesId={seriesId} seriesConfig={seriesConfig} />
) : null,
},
...(multiSeries
? [
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '10%',
field: 'id',
render: (seriesId: string, item: BuilderItem) => (
<SeriesActions seriesId={seriesId} editorMode={true} />
),
},
]
: []),
];
const applySeries = () => {
editorItems.forEach(({ series, id: seriesId }) => {
const { reportType, reportDefinitions, isNew, ...restSeries } = series;
if (reportType && !isEmpty(reportDefinitions)) {
const reportDefId = Object.values(reportDefinitions ?? {})[0];
const newSeriesId = `${reportDefId}-${reportType}`;
const newSeriesN: SeriesUrl = {
...restSeries,
reportType,
reportDefinitions,
};
setSeries(newSeriesId, newSeriesN);
removeSeries(seriesId);
}
});
};
const addSeries = () => {
const prevSeries = allSeries?.[allSeriesIds?.[0]];
setSeries(
`${NEW_SERIES_KEY}-${editorItems.length + 1}`,
prevSeries
? ({ isNew: true, time: prevSeries.time } as SeriesUrl)
: ({ isNew: true } as SeriesUrl)
);
};
return (
<Wrapper ref={seriesBuilderRef}>
{multiSeries && (
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem>
<LastUpdated lastUpdated={lastUpdated} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('xpack.observability.expView.seriesBuilder.autoApply', {
defaultMessage: 'Auto apply',
})}
checked={true}
onChange={(e) => {}}
compressed
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton onClick={() => applySeries()} isDisabled={true} size="s">
{i18n.translate('xpack.observability.expView.seriesBuilder.apply', {
defaultMessage: 'Apply changes',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton color="secondary" onClick={() => addSeries()} size="s">
{i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', {
defaultMessage: 'Add Series',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
)}
<div>
{multiSeries && <SeriesEditor />}
{editorItems.length > 0 && (
<EuiBasicTable
items={editorItems}
columns={columns}
cellProps={{ style: { borderRight: '1px solid #d3dae6', verticalAlign: 'initial' } }}
tableLayout="auto"
/>
)}
<EuiSpacer />
</div>
</Wrapper>
);
}
const Wrapper = euiStyled.div`
max-height: 50vh;
overflow-y: scroll;
overflow-x: clip;
&::-webkit-scrollbar {
height: ${({ theme }) => theme.eui.euiScrollBar};
width: ${({ theme }) => theme.eui.euiScrollBar};
}
&::-webkit-scrollbar-thumb {
background-clip: content-box;
background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)};
border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent;
}
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar-track {
background-color: transparent;
}
`;
export const LOADING_VIEW = i18n.translate(
'xpack.observability.expView.seriesBuilder.loadingView',
{
defaultMessage: 'Loading view ...',
}
);
export const SELECT_REPORT_TYPE = i18n.translate(
'xpack.observability.expView.seriesBuilder.selectReportType',
{
defaultMessage: 'No report type selected',
}
);

View file

@ -6,48 +6,48 @@
*/
import React from 'react';
import { EuiDatePicker, EuiDatePickerRange } from '@elastic/eui';
import { Moment } from 'moment';
import DateMath from '@elastic/datemath';
import { i18n } from '@kbn/i18n';
import { EuiDatePicker, EuiDatePickerRange } from '@elastic/eui';
import DateMath from '@elastic/datemath';
import { Moment } from 'moment';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { useUiSetting } from '../../../../../../../../src/plugins/kibana_react/public';
import { SeriesUrl } from '../types';
import { ReportTypes } from '../configurations/constants';
export const parseAbsoluteDate = (date: string, options = {}) => {
return DateMath.parse(date, options)!;
};
export function DateRangePicker({ seriesId, series }: { seriesId: number; series: SeriesUrl }) {
const { firstSeries, setSeries, reportType } = useSeriesStorage();
export function DateRangePicker({ seriesId }: { seriesId: string }) {
const { firstSeriesId, getSeries, setSeries } = useSeriesStorage();
const dateFormat = useUiSetting<string>('dateFormat');
const seriesFrom = series.time?.from;
const seriesTo = series.time?.to;
const {
time: { from, to },
reportType,
} = getSeries(firstSeriesId);
const { from: mainFrom, to: mainTo } = firstSeries!.time;
const series = getSeries(seriesId);
const startDate = parseAbsoluteDate(seriesFrom ?? mainFrom)!;
const endDate = parseAbsoluteDate(seriesTo ?? mainTo, { roundUp: true })!;
const {
time: { from: seriesFrom, to: seriesTo },
} = series;
const getTotalDuration = () => {
const mainStartDate = parseAbsoluteDate(mainTo)!;
const mainEndDate = parseAbsoluteDate(mainTo, { roundUp: true })!;
return mainEndDate.diff(mainStartDate, 'millisecond');
};
const startDate = parseAbsoluteDate(seriesFrom ?? from)!;
const endDate = parseAbsoluteDate(seriesTo ?? to, { roundUp: true })!;
const onStartChange = (newStartDate: Moment) => {
if (reportType === ReportTypes.KPI) {
const totalDuration = getTotalDuration();
const newFrom = newStartDate.toISOString();
const newTo = newStartDate.add(totalDuration, 'millisecond').toISOString();
const onStartChange = (newDate: Moment) => {
if (reportType === 'kpi-over-time') {
const mainStartDate = parseAbsoluteDate(from)!;
const mainEndDate = parseAbsoluteDate(to, { roundUp: true })!;
const totalDuration = mainEndDate.diff(mainStartDate, 'millisecond');
const newFrom = newDate.toISOString();
const newTo = newDate.add(totalDuration, 'millisecond').toISOString();
setSeries(seriesId, {
...series,
time: { from: newFrom, to: newTo },
});
} else {
const newFrom = newStartDate.toISOString();
const newFrom = newDate.toISOString();
setSeries(seriesId, {
...series,
@ -55,19 +55,20 @@ export function DateRangePicker({ seriesId, series }: { seriesId: number; series
});
}
};
const onEndChange = (newEndDate: Moment) => {
if (reportType === ReportTypes.KPI) {
const totalDuration = getTotalDuration();
const newTo = newEndDate.toISOString();
const newFrom = newEndDate.subtract(totalDuration, 'millisecond').toISOString();
const onEndChange = (newDate: Moment) => {
if (reportType === 'kpi-over-time') {
const mainStartDate = parseAbsoluteDate(from)!;
const mainEndDate = parseAbsoluteDate(to, { roundUp: true })!;
const totalDuration = mainEndDate.diff(mainStartDate, 'millisecond');
const newTo = newDate.toISOString();
const newFrom = newDate.subtract(totalDuration, 'millisecond').toISOString();
setSeries(seriesId, {
...series,
time: { from: newFrom, to: newTo },
});
} else {
const newTo = newEndDate.toISOString();
const newTo = newDate.toISOString();
setSeries(seriesId, {
...series,
@ -89,7 +90,7 @@ export function DateRangePicker({ seriesId, series }: { seriesId: number; series
aria-label={i18n.translate('xpack.observability.expView.dateRanger.startDate', {
defaultMessage: 'Start date',
})}
dateFormat={dateFormat.replace('ss.SSS', 'ss')}
dateFormat={dateFormat}
showTimeSelect
/>
}
@ -103,7 +104,7 @@ export function DateRangePicker({ seriesId, series }: { seriesId: number; series
aria-label={i18n.translate('xpack.observability.expView.dateRanger.endDate', {
defaultMessage: 'End date',
})}
dateFormat={dateFormat.replace('ss.SSS', 'ss')}
dateFormat={dateFormat}
showTimeSelect
/>
}

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiSuperDatePicker } from '@elastic/eui';
import React, { useEffect } from 'react';
import { useHasData } from '../../../../hooks/use_has_data';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges';
import { DEFAULT_TIME } from '../configurations/constants';
export interface TimePickerTime {
from: string;
to: string;
}
export interface TimePickerQuickRange extends TimePickerTime {
display: string;
}
interface Props {
seriesId: string;
}
export function SeriesDatePicker({ seriesId }: Props) {
const { onRefreshTimeRange } = useHasData();
const commonlyUsedRanges = useQuickTimeRanges();
const { getSeries, setSeries } = useSeriesStorage();
const series = getSeries(seriesId);
function onTimeChange({ start, end }: { start: string; end: string }) {
onRefreshTimeRange();
setSeries(seriesId, { ...series, time: { from: start, to: end } });
}
useEffect(() => {
if (!series || !series.time) {
setSeries(seriesId, { ...series, time: DEFAULT_TIME });
}
}, [series, seriesId, setSeries]);
return (
<EuiSuperDatePicker
start={series?.time?.from}
end={series?.time?.to}
onTimeChange={onTimeChange}
commonlyUsedRanges={commonlyUsedRanges}
onRefresh={onTimeChange}
showUpdateButton={false}
/>
);
}

View file

@ -6,48 +6,67 @@
*/
import React from 'react';
import { mockUseHasData, render } from '../../rtl_helpers';
import { mockUseHasData, render } from '../rtl_helpers';
import { fireEvent, waitFor } from '@testing-library/react';
import { SeriesDatePicker } from './index';
import { DEFAULT_TIME } from '../configurations/constants';
describe('SeriesDatePicker', function () {
it('should render properly', function () {
const initSeries = {
data: [
{
name: 'uptime-pings-histogram',
data: {
'uptime-pings-histogram': {
dataType: 'synthetics' as const,
reportType: 'data-distribution' as const,
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
],
},
};
const { getByText } = render(<SeriesDatePicker seriesId={0} series={initSeries.data[0]} />, {
initSeries,
});
const { getByText } = render(<SeriesDatePicker seriesId={'series-id'} />, { initSeries });
getByText('Last 30 Minutes');
getByText('Last 30 minutes');
});
it('should set defaults', async function () {
const initSeries = {
data: {
'uptime-pings-histogram': {
reportType: 'kpi-over-time' as const,
dataType: 'synthetics' as const,
breakdown: 'monitor.status',
},
},
};
const { setSeries: setSeries1 } = render(
<SeriesDatePicker seriesId={'uptime-pings-histogram'} />,
{ initSeries: initSeries as any }
);
expect(setSeries1).toHaveBeenCalledTimes(1);
expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', {
breakdown: 'monitor.status',
dataType: 'synthetics' as const,
reportType: 'kpi-over-time' as const,
time: DEFAULT_TIME,
});
});
it('should set series data', async function () {
const initSeries = {
data: [
{
name: 'uptime-pings-histogram',
data: {
'uptime-pings-histogram': {
dataType: 'synthetics' as const,
reportType: 'kpi-over-time' as const,
breakdown: 'monitor.status',
time: { from: 'now-30m', to: 'now' },
},
],
},
};
const { onRefreshTimeRange } = mockUseHasData();
const { getByTestId, setSeries } = render(
<SeriesDatePicker seriesId={0} series={initSeries.data[0]} readonly={false} />,
{
initSeries,
}
);
const { getByTestId, setSeries } = render(<SeriesDatePicker seriesId={'series-id'} />, {
initSeries,
});
await waitFor(function () {
fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton'));
@ -57,10 +76,10 @@ describe('SeriesDatePicker', function () {
expect(onRefreshTimeRange).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(0, {
name: 'uptime-pings-histogram',
expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'monitor.status',
dataType: 'synthetics',
reportType: 'kpi-over-time',
time: { from: 'now/d', to: 'now/d' },
});
expect(setSeries).toHaveBeenCalledTimes(1);

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Breakdowns } from './columns/breakdowns';
import { SeriesConfig } from '../types';
import { ChartOptions } from './columns/chart_options';
interface Props {
seriesConfig: SeriesConfig;
seriesId: string;
breakdownFields: string[];
}
export function ChartEditOptions({ seriesConfig, seriesId, breakdownFields }: Props) {
return (
<EuiFlexGroup wrap>
<EuiFlexItem>
<Breakdowns seriesId={seriesId} breakdowns={breakdownFields} seriesConfig={seriesConfig} />
</EuiFlexItem>
<EuiFlexItem>
<ChartOptions seriesConfig={seriesConfig} seriesId={seriesId} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { Breakdowns } from './breakdowns';
import { mockIndexPattern, mockUxSeries, render } from '../../rtl_helpers';
import { mockIndexPattern, render } from '../../rtl_helpers';
import { getDefaultConfigs } from '../../configurations/default_configs';
import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames';
@ -20,7 +20,13 @@ describe('Breakdowns', function () {
});
it('should render properly', async function () {
render(<Breakdowns seriesId={0} seriesConfig={dataViewSeries} series={mockUxSeries} />);
render(
<Breakdowns
seriesId={'series-id'}
breakdowns={dataViewSeries.breakdownFields}
seriesConfig={dataViewSeries}
/>
);
screen.getAllByText('Browser family');
});
@ -30,9 +36,9 @@ describe('Breakdowns', function () {
const { setSeries } = render(
<Breakdowns
seriesId={0}
seriesId={'series-id'}
breakdowns={dataViewSeries.breakdownFields}
seriesConfig={dataViewSeries}
series={{ ...mockUxSeries, breakdown: USER_AGENT_OS }}
/>,
{ initSeries }
);
@ -43,14 +49,10 @@ describe('Breakdowns', function () {
fireEvent.click(screen.getByText('Browser family'));
expect(setSeries).toHaveBeenCalledWith(0, {
expect(setSeries).toHaveBeenCalledWith('series-id', {
breakdown: 'user_agent.name',
dataType: 'ux',
name: 'performance-distribution',
reportDefinitions: {
'service.name': ['elastic-co'],
},
selectedMetricField: 'transaction.duration.us',
reportType: 'data-distribution',
time: { from: 'now-15m', to: 'now' },
});
expect(setSeries).toHaveBeenCalledTimes(1);

View file

@ -8,20 +8,20 @@
import React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useRouteMatch } from 'react-router-dom';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants';
import { SeriesConfig, SeriesUrl } from '../../types';
import { SeriesConfig } from '../../types';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesId: string;
breakdowns: string[];
seriesConfig: SeriesConfig;
}
export function Breakdowns({ seriesConfig, seriesId, series }: Props) {
const { setSeries } = useSeriesStorage();
const isPreview = !!useRouteMatch('/exploratory-view/preview');
export function Breakdowns({ seriesConfig, seriesId, breakdowns = [] }: Props) {
const { setSeries, getSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const selectedBreakdown = series.breakdown;
const NO_BREAKDOWN = 'no_breakdown';
@ -40,13 +40,9 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) {
}
};
if (!seriesConfig) {
return null;
}
const hasUseBreakdownColumn = seriesConfig.xAxisColumn.sourceField === USE_BREAK_DOWN_COLUMN;
const items = seriesConfig.breakdownFields.map((breakdown) => ({
const items = breakdowns.map((breakdown) => ({
id: breakdown,
label: seriesConfig.labels[breakdown],
}));
@ -54,12 +50,14 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) {
if (!hasUseBreakdownColumn) {
items.push({
id: NO_BREAKDOWN,
label: NO_BREAK_DOWN_LABEL,
label: i18n.translate('xpack.observability.exp.breakDownFilter.noBreakdown', {
defaultMessage: 'No breakdown',
}),
});
}
const options = items.map(({ id, label }) => ({
inputDisplay: label,
inputDisplay: id === NO_BREAKDOWN ? label : <strong>{label}</strong>,
value: id,
dropdownDisplay: label,
}));
@ -71,7 +69,7 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) {
<div style={{ width: 200 }}>
<EuiSuperSelect
fullWidth
compressed={isPreview}
compressed
options={options}
valueOfSelected={valueOfSelected}
onChange={(value) => onOptionChange(value)}
@ -80,10 +78,3 @@ export function Breakdowns({ seriesConfig, seriesId, series }: Props) {
</div>
);
}
export const NO_BREAK_DOWN_LABEL = i18n.translate(
'xpack.observability.exp.breakDownFilter.noBreakdown',
{
defaultMessage: 'No breakdown',
}
);

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { SeriesConfig } from '../../types';
import { OperationTypeSelect } from '../../series_builder/columns/operation_type_select';
import { SeriesChartTypesSelect } from '../../series_builder/columns/chart_types';
interface Props {
seriesConfig: SeriesConfig;
seriesId: string;
}
export function ChartOptions({ seriesConfig, seriesId }: Props) {
return (
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="center">
<EuiFlexItem grow={false}>
<SeriesChartTypesSelect
seriesId={seriesId}
defaultChartType={seriesConfig.seriesTypes[0]}
/>
</EuiFlexItem>
{seriesConfig.hasOperationType && (
<EuiFlexItem grow={false}>
<OperationTypeSelect seriesId={seriesId} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}

View file

@ -1,39 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, screen } from '@testing-library/react';
import { mockAppIndexPattern, mockUxSeries, render } from '../../rtl_helpers';
import { DataTypesLabels, DataTypesSelect } from './data_type_select';
import { DataTypes } from '../../configurations/constants';
describe('DataTypeSelect', function () {
const seriesId = 0;
mockAppIndexPattern();
it('should render properly', function () {
render(<DataTypesSelect seriesId={seriesId} series={mockUxSeries} />);
});
it('should set series on change', async function () {
const { setSeries } = render(<DataTypesSelect seriesId={seriesId} series={mockUxSeries} />);
fireEvent.click(await screen.findByText(DataTypesLabels[DataTypes.UX]));
fireEvent.click(await screen.findByText(DataTypesLabels[DataTypes.SYNTHETICS]));
expect(setSeries).toHaveBeenCalledTimes(1);
expect(setSeries).toHaveBeenCalledWith(seriesId, {
dataType: 'synthetics',
name: 'synthetics-series-1',
time: {
from: 'now-15m',
to: 'now',
},
});
});
});

View file

@ -1,105 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { AppDataType, SeriesUrl } from '../../types';
import { DataTypes, ReportTypes } from '../../configurations/constants';
interface Props {
seriesId: number;
series: SeriesUrl;
}
export const DataTypesLabels = {
[DataTypes.UX]: i18n.translate('xpack.observability.overview.exploratoryView.uxLabel', {
defaultMessage: 'User experience (RUM)',
}),
[DataTypes.SYNTHETICS]: i18n.translate(
'xpack.observability.overview.exploratoryView.syntheticsLabel',
{
defaultMessage: 'Synthetics monitoring',
}
),
[DataTypes.MOBILE]: i18n.translate(
'xpack.observability.overview.exploratoryView.mobileExperienceLabel',
{
defaultMessage: 'Mobile experience',
}
),
};
export const dataTypes: Array<{ id: AppDataType; label: string }> = [
{
id: DataTypes.SYNTHETICS,
label: DataTypesLabels[DataTypes.SYNTHETICS],
},
{
id: DataTypes.UX,
label: DataTypesLabels[DataTypes.UX],
},
{
id: DataTypes.MOBILE,
label: DataTypesLabels[DataTypes.MOBILE],
},
];
const SELECT_DATA_TYPE = 'SELECT_DATA_TYPE';
export function DataTypesSelect({ seriesId, series }: Props) {
const { setSeries, reportType } = useSeriesStorage();
const onDataTypeChange = (dataType: AppDataType) => {
if (String(dataType) !== SELECT_DATA_TYPE) {
setSeries(seriesId, {
dataType,
time: series.time,
name: `${dataType}-series-${seriesId + 1}`,
});
}
};
const options = dataTypes
.filter(({ id }) => {
if (reportType === ReportTypes.DEVICE_DISTRIBUTION) {
return id === DataTypes.MOBILE;
}
if (reportType === ReportTypes.CORE_WEB_VITAL) {
return id === DataTypes.UX;
}
return true;
})
.map(({ id, label }) => ({
value: id,
inputDisplay: label,
}));
return (
<EuiSuperSelect
fullWidth
options={
series.dataType
? options
: [{ value: SELECT_DATA_TYPE, inputDisplay: SELECT_DATA_TYPE_LABEL }, ...options]
}
valueOfSelected={series.dataType ?? SELECT_DATA_TYPE}
onChange={(value) => onDataTypeChange(value as AppDataType)}
style={{ minWidth: 220 }}
/>
);
}
const SELECT_DATA_TYPE_LABEL = i18n.translate(
'xpack.observability.overview.exploratoryView.selectDataType',
{
defaultMessage: 'Select data type',
}
);

View file

@ -6,84 +6,24 @@
*/
import React from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { SeriesDatePicker } from '../../series_date_picker';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { DateRangePicker } from '../../components/date_range_picker';
import { SeriesDatePicker } from '../../components/series_date_picker';
import { AppDataType, SeriesUrl } from '../../types';
import { ReportTypes } from '../../configurations/constants';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { SyntheticsAddData } from '../../../add_data_buttons/synthetics_add_data';
import { MobileAddData } from '../../../add_data_buttons/mobile_add_data';
import { UXAddData } from '../../../add_data_buttons/ux_add_data';
import { DateRangePicker } from '../../series_date_picker/date_range_picker';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesId: string;
}
const AddDataComponents: Record<AppDataType, React.FC | null> = {
mobile: MobileAddData,
ux: UXAddData,
synthetics: SyntheticsAddData,
apm: null,
infra_logs: null,
infra_metrics: null,
};
export function DatePickerCol({ seriesId, series }: Props) {
const { reportType } = useSeriesStorage();
const { hasAppData } = useAppIndexPatternContext();
if (!series.dataType) {
return null;
}
const AddDataButton = AddDataComponents[series.dataType];
if (hasAppData[series.dataType] === false && AddDataButton !== null) {
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<strong>
{i18n.translate('xpack.observability.overview.exploratoryView.noDataAvailable', {
defaultMessage: 'No {dataType} data available.',
values: {
dataType: series.dataType,
},
})}
</strong>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddDataButton />
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!series.selectedMetricField) {
return null;
}
export function DatePickerCol({ seriesId }: Props) {
const { firstSeriesId, getSeries } = useSeriesStorage();
const { reportType } = getSeries(firstSeriesId);
return (
<Wrapper>
{seriesId === 0 || reportType !== ReportTypes.KPI ? (
<SeriesDatePicker seriesId={seriesId} series={series} readonly={false} />
<div style={{ maxWidth: 300 }}>
{firstSeriesId === seriesId || reportType !== 'kpi-over-time' ? (
<SeriesDatePicker seriesId={seriesId} />
) : (
<DateRangePicker seriesId={seriesId} series={series} />
<DateRangePicker seriesId={seriesId} />
)}
</Wrapper>
</div>
);
}
const Wrapper = styled.div`
width: 100%;
.euiSuperDatePicker__flexWrapper {
width: 100%;
> .euiFlexItem {
margin-right: 0;
}
}
`;

View file

@ -8,24 +8,20 @@
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { FilterExpanded } from './filter_expanded';
import { mockUxSeries, mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers';
import { mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers';
import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames';
describe('FilterExpanded', function () {
const filters = [{ field: USER_AGENT_NAME, values: ['Chrome'] }];
const mockSeries = { ...mockUxSeries, filters };
it('render', async () => {
const initSeries = { filters };
it('should render properly', async function () {
const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] };
mockAppIndexPattern();
render(
<FilterExpanded
seriesId={0}
series={mockSeries}
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
filters={[]}
/>,
{ initSeries }
@ -37,14 +33,15 @@ describe('FilterExpanded', function () {
});
it('should call go back on click', async function () {
const initSeries = { filters };
const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] };
const goBack = jest.fn();
render(
<FilterExpanded
seriesId={0}
series={mockSeries}
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
filters={[]}
/>,
{ initSeries }
@ -52,23 +49,28 @@ describe('FilterExpanded', function () {
await waitFor(() => {
fireEvent.click(screen.getByText('Browser Family'));
expect(goBack).toHaveBeenCalledTimes(1);
expect(goBack).toHaveBeenCalledWith();
});
});
it('calls useValuesList on load', async () => {
const initSeries = { filters };
it('should call useValuesList on load', async function () {
const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] };
const { spy } = mockUseValuesList([
{ label: 'Chrome', count: 10 },
{ label: 'Firefox', count: 5 },
]);
const goBack = jest.fn();
render(
<FilterExpanded
seriesId={0}
series={mockSeries}
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={goBack}
filters={[]}
/>,
{ initSeries }
@ -85,8 +87,8 @@ describe('FilterExpanded', function () {
});
});
it('filters display values', async () => {
const initSeries = { filters };
it('should filter display values', async function () {
const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] };
mockUseValuesList([
{ label: 'Chrome', count: 10 },
@ -95,20 +97,18 @@ describe('FilterExpanded', function () {
render(
<FilterExpanded
seriesId={0}
series={mockUxSeries}
seriesId={'series-id'}
label={'Browser Family'}
field={USER_AGENT_NAME}
goBack={jest.fn()}
filters={[]}
/>,
{ initSeries }
);
expect(screen.getByText('Firefox')).toBeTruthy();
await waitFor(() => {
fireEvent.click(screen.getByText('Browser Family'));
expect(screen.queryByText('Firefox')).toBeTruthy();
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'ch' } });
expect(screen.queryByText('Firefox')).toBeFalsy();

View file

@ -6,14 +6,7 @@
*/
import React, { useState, Fragment } from 'react';
import {
EuiFieldSearch,
EuiSpacer,
EuiFilterGroup,
EuiText,
EuiPopover,
EuiFilterButton,
} from '@elastic/eui';
import { EuiFieldSearch, EuiSpacer, EuiButtonEmpty, EuiFilterGroup, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import { rgba } from 'polished';
import { i18n } from '@kbn/i18n';
@ -21,7 +14,8 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types';
import { map } from 'lodash';
import { ExistsFilter } from '@kbn/es-query';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { SeriesConfig, SeriesUrl, UrlFilter } from '../../types';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesConfig, UrlFilter } from '../../types';
import { FilterValueButton } from './filter_value_btn';
import { useValuesList } from '../../../../../hooks/use_values_list';
import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
@ -29,33 +23,31 @@ import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearc
import { PersistableFilter } from '../../../../../../../lens/common';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesId: string;
label: string;
field: string;
isNegated?: boolean;
goBack: () => void;
nestedField?: string;
filters: SeriesConfig['baseFilters'];
}
export interface NestedFilterOpen {
value: string;
negate: boolean;
}
export function FilterExpanded({
seriesId,
series,
field,
label,
goBack,
nestedField,
isNegated,
filters: defaultFilters,
}: Props) {
const [value, setValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [isNestedOpen, setIsNestedOpen] = useState<NestedFilterOpen>({ value: '', negate: false });
const [isOpen, setIsOpen] = useState({ value: '', negate: false });
const { getSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const queryFilters: ESFilter[] = [];
@ -89,71 +81,62 @@ export function FilterExpanded({
);
return (
<EuiPopover
button={
<EuiFilterButton onClick={() => setIsOpen((prevState) => !prevState)} iconType="arrowDown">
{label}
</EuiFilterButton>
}
isOpen={isOpen}
closePopover={() => setIsOpen(false)}
>
<Wrapper>
<EuiFieldSearch
fullWidth
isLoading={loading}
value={value}
onChange={(evt) => {
setValue(evt.target.value);
}}
placeholder={i18n.translate('xpack.observability.filters.expanded.search', {
defaultMessage: 'Search for {label}',
values: { label },
})}
/>
<EuiSpacer size="s" />
<ListWrapper>
{displayValues.length === 0 && !loading && (
<EuiText>
{i18n.translate('xpack.observability.filters.expanded.noFilter', {
defaultMessage: 'No filters found.',
})}
</EuiText>
)}
{displayValues.map((opt) => (
<Fragment key={opt}>
<EuiFilterGroup fullWidth={true} color="primary">
{isNegated !== false && (
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.notValues}
negate={true}
nestedField={nestedField}
seriesId={seriesId}
series={series}
isNestedOpen={isNestedOpen}
setIsNestedOpen={setIsNestedOpen}
/>
)}
<Wrapper>
<EuiButtonEmpty iconType="arrowLeft" color="text" onClick={() => goBack()}>
{label}
</EuiButtonEmpty>
<EuiFieldSearch
fullWidth
isLoading={loading}
value={value}
onChange={(evt) => {
setValue(evt.target.value);
}}
placeholder={i18n.translate('xpack.observability.filters.expanded.search', {
defaultMessage: 'Search for {label}',
values: { label },
})}
/>
<EuiSpacer size="s" />
<ListWrapper>
{displayValues.length === 0 && !loading && (
<EuiText>
{i18n.translate('xpack.observability.filters.expanded.noFilter', {
defaultMessage: 'No filters found.',
})}
</EuiText>
)}
{displayValues.map((opt) => (
<Fragment key={opt}>
<EuiFilterGroup fullWidth={true} color="primary">
{isNegated !== false && (
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.values}
allSelectedValues={currFilter?.notValues}
negate={true}
nestedField={nestedField}
seriesId={seriesId}
series={series}
negate={false}
isNestedOpen={isNestedOpen}
setIsNestedOpen={setIsNestedOpen}
isNestedOpen={isOpen}
setIsNestedOpen={setIsOpen}
/>
</EuiFilterGroup>
<EuiSpacer size="s" />
</Fragment>
))}
</ListWrapper>
</Wrapper>
</EuiPopover>
)}
<FilterValueButton
field={field}
value={opt}
allSelectedValues={currFilter?.values}
nestedField={nestedField}
seriesId={seriesId}
negate={false}
isNestedOpen={isOpen}
setIsNestedOpen={setIsOpen}
/>
</EuiFilterGroup>
<EuiSpacer size="s" />
</Fragment>
))}
</ListWrapper>
</Wrapper>
);
}

View file

@ -8,7 +8,7 @@
import React from 'react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { FilterValueButton } from './filter_value_btn';
import { mockUxSeries, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers';
import { mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers';
import {
USER_AGENT_NAME,
USER_AGENT_VERSION,
@ -19,98 +19,84 @@ describe('FilterValueButton', function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
series={mockUxSeries}
/>
);
screen.getByText('Chrome');
});
it('should render display negate state', async function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
/>
);
await waitFor(() => {
expect(screen.getByText('Chrome')).toBeInTheDocument();
screen.getByText('Not Chrome');
screen.getByTitle('Not Chrome');
const btn = screen.getByRole('button');
expect(btn.classList).toContain('euiButtonEmpty--danger');
});
});
describe('when negate is true', () => {
it('displays negate stats', async () => {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
series={mockUxSeries}
/>
);
it('should call set filter on click', async function () {
const { setFilter, removeFilter } = mockUseSeriesFilter();
await waitFor(() => {
expect(screen.getByText('Not Chrome')).toBeInTheDocument();
expect(screen.getByTitle('Not Chrome')).toBeInTheDocument();
const btn = screen.getByRole('button');
expect(btn.classList).toContain('euiButtonEmpty--danger');
});
});
it('calls setFilter on click', async () => {
const { setFilter, removeFilter } = mockUseSeriesFilter();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Firefox']}
series={mockUxSeries}
/>
);
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Firefox']}
/>
);
await waitFor(() => {
fireEvent.click(screen.getByText('Not Chrome'));
await waitFor(() => {
expect(removeFilter).toHaveBeenCalledTimes(0);
expect(setFilter).toHaveBeenCalledTimes(1);
expect(setFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: true,
value: 'Chrome',
});
expect(removeFilter).toHaveBeenCalledTimes(0);
expect(setFilter).toHaveBeenCalledTimes(1);
expect(setFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: true,
value: 'Chrome',
});
});
});
describe('when selected', () => {
it('removes the filter on click', async () => {
const { removeFilter } = mockUseSeriesFilter();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
series={mockUxSeries}
/>
);
it('should remove filter on click if already selected', async function () {
const { removeFilter } = mockUseSeriesFilter();
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
/>
);
await waitFor(() => {
fireEvent.click(screen.getByText('Chrome'));
await waitFor(() => {
expect(removeFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: false,
value: 'Chrome',
});
expect(removeFilter).toHaveBeenCalledWith({
field: 'user_agent.name',
negate: false,
value: 'Chrome',
});
});
});
@ -121,13 +107,12 @@ describe('FilterValueButton', function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={jest.fn()}
negate={true}
allSelectedValues={['Chrome', 'Firefox']}
series={mockUxSeries}
/>
);
@ -149,14 +134,13 @@ describe('FilterValueButton', function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: 'Chrome', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
series={mockUxSeries}
/>
);
@ -183,14 +167,13 @@ describe('FilterValueButton', function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: 'Chrome', negate: false }}
setIsNestedOpen={jest.fn()}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
series={mockUxSeries}
/>
);
@ -220,14 +203,13 @@ describe('FilterValueButton', function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: false }}
setIsNestedOpen={setIsNestedOpen}
negate={false}
allSelectedValues={['Chrome', 'Firefox']}
nestedField={USER_AGENT_VERSION}
series={mockUxSeries}
/>
);
@ -247,14 +229,13 @@ describe('FilterValueButton', function () {
render(
<FilterValueButton
field={USER_AGENT_NAME}
seriesId={0}
seriesId={'series-id'}
value={'Chrome'}
isNestedOpen={{ value: '', negate: true }}
setIsNestedOpen={setIsNestedOpen}
negate={true}
allSelectedValues={['Firefox']}
nestedField={USER_AGENT_VERSION}
series={mockUxSeries}
/>
);

View file

@ -8,11 +8,10 @@ import { i18n } from '@kbn/i18n';
import React, { useMemo } from 'react';
import { EuiFilterButton, hexToRgb } from '@elastic/eui';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { useSeriesFilters } from '../../hooks/use_series_filters';
import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
import FieldValueSuggestions from '../../../field_value_suggestions';
import { SeriesUrl } from '../../types';
import { NestedFilterOpen } from './filter_expanded';
interface Props {
value: string;
@ -20,13 +19,12 @@ interface Props {
allSelectedValues?: string[];
negate: boolean;
nestedField?: string;
seriesId: number;
series: SeriesUrl;
seriesId: string;
isNestedOpen: {
value: string;
negate: boolean;
};
setIsNestedOpen: (val: NestedFilterOpen) => void;
setIsNestedOpen: (val: { value: string; negate: boolean }) => void;
}
export function FilterValueButton({
@ -36,13 +34,16 @@ export function FilterValueButton({
field,
negate,
seriesId,
series,
nestedField,
allSelectedValues,
}: Props) {
const { getSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { indexPatterns } = useAppIndexPatternContext(series.dataType);
const { setFilter, removeFilter } = useSeriesFilters({ seriesId, series });
const { setFilter, removeFilter } = useSeriesFilters({ seriesId });
const hasActiveFilters = (allSelectedValues ?? []).includes(value);

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiButtonIcon } from '@elastic/eui';
import { useSeriesStorage } from '../../hooks/use_series_storage';
interface Props {
seriesId: string;
}
export function RemoveSeries({ seriesId }: Props) {
const { removeSeries } = useSeriesStorage();
const onClick = () => {
removeSeries(seriesId);
};
return (
<EuiButtonIcon
aria-label={i18n.translate('xpack.observability.expView.seriesEditor.removeSeries', {
defaultMessage: 'Click to remove series',
})}
iconType="cross"
color="danger"
onClick={onClick}
size="s"
/>
);
}

View file

@ -1,59 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesConfig, SeriesUrl } from '../../types';
import { ReportDefinitionField } from './report_definition_field';
export function ReportDefinitionCol({
seriesId,
series,
seriesConfig,
}: {
seriesId: number;
series: SeriesUrl;
seriesConfig: SeriesConfig;
}) {
const { setSeries } = useSeriesStorage();
const { reportDefinitions: selectedReportDefinitions = {} } = series;
const { definitionFields } = seriesConfig;
const onChange = (field: string, value?: string[]) => {
if (!value?.[0]) {
delete selectedReportDefinitions[field];
setSeries(seriesId, {
...series,
reportDefinitions: { ...selectedReportDefinitions },
});
} else {
setSeries(seriesId, {
...series,
reportDefinitions: { ...selectedReportDefinitions, [field]: value },
});
}
};
return (
<EuiFlexGroup gutterSize="l">
{definitionFields.map((field) => (
<EuiFlexItem key={field} grow={1}>
<ReportDefinitionField
seriesId={seriesId}
series={series}
seriesConfig={seriesConfig}
field={field}
onChange={onChange}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -1,64 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSuperSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { ReportViewType } from '../../types';
import {
CORE_WEB_VITALS_LABEL,
DEVICE_DISTRIBUTION_LABEL,
KPI_OVER_TIME_LABEL,
PERF_DIST_LABEL,
} from '../../configurations/constants/labels';
const SELECT_REPORT_TYPE = 'SELECT_REPORT_TYPE';
export const reportTypesList: Array<{
reportType: ReportViewType | typeof SELECT_REPORT_TYPE;
label: string;
}> = [
{
reportType: SELECT_REPORT_TYPE,
label: i18n.translate('xpack.observability.expView.reportType.selectLabel', {
defaultMessage: 'Select report type',
}),
},
{ reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL },
{ reportType: 'data-distribution', label: PERF_DIST_LABEL },
{ reportType: 'core-web-vitals', label: CORE_WEB_VITALS_LABEL },
{ reportType: 'device-data-distribution', label: DEVICE_DISTRIBUTION_LABEL },
];
export function ReportTypesSelect() {
const { setReportType, reportType: selectedReportType, allSeries } = useSeriesStorage();
const onReportTypeChange = (reportType: ReportViewType) => {
setReportType(reportType);
};
const options = reportTypesList
.filter(({ reportType }) => (selectedReportType ? reportType !== SELECT_REPORT_TYPE : true))
.map(({ reportType, label }) => ({
value: reportType,
inputDisplay: reportType === SELECT_REPORT_TYPE ? label : <strong>{label}</strong>,
dropdownDisplay: label,
}));
return (
<EuiSuperSelect
fullWidth
options={options}
valueOfSelected={selectedReportType ?? SELECT_REPORT_TYPE}
onChange={(value) => onReportTypeChange(value as ReportViewType)}
style={{ minWidth: 200 }}
isInvalid={!selectedReportType && allSeries.length > 0}
disabled={allSeries.length > 0}
/>
);
}

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isEmpty } from 'lodash';
import { RemoveSeries } from './remove_series';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesUrl } from '../../types';
interface Props {
seriesId: string;
editorMode?: boolean;
}
export function SeriesActions({ seriesId, editorMode = false }: Props) {
const { getSeries, setSeries, allSeriesIds, removeSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const onEdit = () => {
setSeries(seriesId, { ...series, isNew: true });
};
const copySeries = () => {
let copySeriesId: string = `${seriesId}-copy`;
if (allSeriesIds.includes(copySeriesId)) {
copySeriesId = copySeriesId + allSeriesIds.length;
}
setSeries(copySeriesId, series);
};
const { reportType, reportDefinitions, isNew, ...restSeries } = series;
const isSaveAble = reportType && !isEmpty(reportDefinitions);
const saveSeries = () => {
if (isSaveAble) {
const reportDefId = Object.values(reportDefinitions ?? {})[0];
let newSeriesId = `${reportDefId}-${reportType}`;
if (allSeriesIds.includes(newSeriesId)) {
newSeriesId = `${newSeriesId}-${allSeriesIds.length}`;
}
const newSeriesN: SeriesUrl = {
...restSeries,
reportType,
reportDefinitions,
};
setSeries(newSeriesId, newSeriesN);
removeSeries(seriesId);
}
};
return (
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="center">
{!editorMode && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="documentEdit"
aria-label={i18n.translate('xpack.observability.seriesEditor.edit', {
defaultMessage: 'Edit series',
})}
size="s"
onClick={onEdit}
/>
</EuiFlexItem>
)}
{editorMode && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={'save'}
aria-label={i18n.translate('xpack.observability.seriesEditor.save', {
defaultMessage: 'Save series',
})}
size="s"
onClick={saveSeries}
color="success"
isDisabled={!isSaveAble}
/>
</EuiFlexItem>
)}
{editorMode && (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType={'copy'}
aria-label={i18n.translate('xpack.observability.seriesEditor.clone', {
defaultMessage: 'Copy series',
})}
size="s"
onClick={copySeries}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<RemoveSeries seriesId={seriesId} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React, { useState, Fragment } from 'react';
import {
EuiButton,
EuiPopover,
EuiSpacer,
EuiButtonEmpty,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { FilterExpanded } from './filter_expanded';
import { SeriesConfig } from '../../types';
import { FieldLabels } from '../../configurations/constants/constants';
import { SelectedFilters } from '../selected_filters';
import { useSeriesStorage } from '../../hooks/use_series_storage';
interface Props {
seriesId: string;
filterFields: SeriesConfig['filterFields'];
baseFilters: SeriesConfig['baseFilters'];
seriesConfig: SeriesConfig;
isNew?: boolean;
labels?: Record<string, string>;
}
export interface Field {
label: string;
field: string;
nested?: string;
isNegated?: boolean;
}
export function SeriesFilter({
seriesConfig,
isNew,
seriesId,
filterFields = [],
baseFilters,
labels,
}: Props) {
const [isPopoverVisible, setIsPopoverVisible] = useState(false);
const [selectedField, setSelectedField] = useState<Field | undefined>();
const options: Field[] = filterFields.map((field) => {
if (typeof field === 'string') {
return { label: labels?.[field] ?? FieldLabels[field], field };
}
return {
field: field.field,
nested: field.nested,
isNegated: field.isNegated,
label: labels?.[field.field] ?? FieldLabels[field.field],
};
});
const { setSeries, getSeries } = useSeriesStorage();
const urlSeries = getSeries(seriesId);
const button = (
<EuiButtonEmpty
flush="left"
iconType="plus"
onClick={() => {
setIsPopoverVisible((prevState) => !prevState);
}}
size="s"
>
{i18n.translate('xpack.observability.expView.seriesEditor.addFilter', {
defaultMessage: 'Add filter',
})}
</EuiButtonEmpty>
);
const mainPanel = (
<>
<EuiSpacer size="s" />
{options.map((opt) => (
<Fragment key={opt.label}>
<EuiButton
fullWidth={true}
iconType="arrowRight"
iconSide="right"
onClick={() => {
setSelectedField(opt);
}}
>
{opt.label}
</EuiButton>
<EuiSpacer size="s" />
</Fragment>
))}
</>
);
const childPanel = selectedField ? (
<FilterExpanded
seriesId={seriesId}
field={selectedField.field}
label={selectedField.label}
nestedField={selectedField.nested}
isNegated={selectedField.isNegated}
goBack={() => {
setSelectedField(undefined);
}}
filters={baseFilters}
/>
) : null;
const closePopover = () => {
setIsPopoverVisible(false);
setSelectedField(undefined);
};
return (
<EuiFlexGroup wrap direction="column" gutterSize="xs" alignItems="flexStart">
<SelectedFilters seriesId={seriesId} seriesConfig={seriesConfig} isNew={isNew} />
<EuiFlexItem grow={false}>
<EuiPopover
button={button}
isOpen={isPopoverVisible}
closePopover={closePopover}
anchorPosition={isNew ? 'leftCenter' : 'rightCenter'}
>
{!selectedField ? mainPanel : childPanel}
</EuiPopover>
</EuiFlexItem>
{(urlSeries.filters ?? []).length > 0 && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
flush="left"
color="text"
iconType="cross"
onClick={() => {
setSeries(seriesId, { ...urlSeries, filters: undefined });
}}
size="s"
>
{i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', {
defaultMessage: 'Clear filters',
})}
</EuiButtonEmpty>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}

View file

@ -1,77 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { SeriesConfig, SeriesUrl } from '../types';
import { ReportDefinitionCol } from './columns/report_definition_col';
import { OperationTypeSelect } from './columns/operation_type_select';
import { parseCustomFieldName } from '../configurations/lens_attributes';
import { SeriesFilter } from '../series_viewer/columns/series_filter';
function getColumnType(seriesConfig: SeriesConfig, selectedMetricField?: string) {
const { columnType } = parseCustomFieldName(seriesConfig, selectedMetricField);
return columnType;
}
interface Props {
seriesId: number;
series: SeriesUrl;
seriesConfig: SeriesConfig;
}
export function ExpandedSeriesRow({ seriesId, series, seriesConfig }: Props) {
if (!seriesConfig) {
return null;
}
const { selectedMetricField } = series ?? {};
const { hasOperationType, yAxisColumns } = seriesConfig;
const columnType = getColumnType(seriesConfig, selectedMetricField);
return (
<div style={{ width: '100%' }}>
<EuiFlexGroup>
<EuiFlexItem>
<ReportDefinitionCol seriesId={seriesId} series={series} seriesConfig={seriesConfig} />
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem style={{ minWidth: 600 }}>
<EuiFormRow label={FILTERS_LABEL} fullWidth>
<SeriesFilter seriesConfig={seriesConfig} seriesId={seriesId} series={series} />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{(hasOperationType || columnType === 'operation') && (
<EuiFlexItem grow={false} style={{ minWidth: 200 }}>
<EuiFormRow label={OPERATION_LABEL}>
<OperationTypeSelect
seriesId={seriesId}
series={series}
defaultOperationType={yAxisColumns[0].operationType}
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer />
</div>
);
}
const FILTERS_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.selectFilters', {
defaultMessage: 'Filters',
});
const OPERATION_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.operation', {
defaultMessage: 'Operation',
});

View file

@ -1,101 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiSuperSelect, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { SeriesConfig, SeriesUrl } from '../types';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants';
interface Props {
seriesId: number;
series: SeriesUrl;
defaultValue?: string;
metricOptions: SeriesConfig['metricOptions'];
}
const SELECT_REPORT_METRIC = 'SELECT_REPORT_METRIC';
export function ReportMetricOptions({ seriesId, series, metricOptions }: Props) {
const { setSeries } = useSeriesStorage();
const { indexPatterns } = useAppIndexPatternContext();
const onChange = (value: string) => {
setSeries(seriesId, {
...series,
selectedMetricField: value,
});
};
if (!series.dataType) {
return null;
}
const indexPattern = indexPatterns?.[series.dataType];
const options = (metricOptions ?? []).map(({ label, field, id }) => {
let disabled = false;
if (field !== RECORDS_FIELD && field !== RECORDS_PERCENTAGE_FIELD && field) {
disabled = !Boolean(indexPattern?.getFieldByName(field));
}
return {
disabled,
value: field || id,
dropdownDisplay: disabled ? (
<EuiToolTip
content={
<FormattedMessage
id="xpack.observability.expView.seriesEditor.selectReportMetric.noFieldData"
defaultMessage="No data available for field {field}."
values={{
field: <strong>{field}</strong>,
}}
/>
}
>
<span>{label}</span>
</EuiToolTip>
) : (
label
),
inputDisplay: label,
};
});
return (
<EuiSuperSelect
fullWidth
options={
series.selectedMetricField
? options
: [
{
value: SELECT_REPORT_METRIC,
inputDisplay: SELECT_REPORT_METRIC_LABEL,
disabled: false,
},
...options,
]
}
valueOfSelected={series.selectedMetricField || SELECT_REPORT_METRIC}
onChange={(value) => onChange(value)}
style={{ minWidth: 220 }}
/>
);
}
const SELECT_REPORT_METRIC_LABEL = i18n.translate(
'xpack.observability.expView.seriesEditor.selectReportMetric',
{
defaultMessage: 'Select report metric',
}
);

View file

@ -7,7 +7,7 @@
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../rtl_helpers';
import { mockAppIndexPattern, mockIndexPattern, render } from '../rtl_helpers';
import { SelectedFilters } from './selected_filters';
import { getDefaultConfigs } from '../configurations/default_configs';
import { USER_AGENT_NAME } from '../configurations/constants/elasticsearch_fieldnames';
@ -22,19 +22,11 @@ describe('SelectedFilters', function () {
});
it('should render properly', async function () {
const filters = [{ field: USER_AGENT_NAME, values: ['Chrome'] }];
const initSeries = { filters };
const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] };
render(
<SelectedFilters
seriesId={0}
seriesConfig={dataViewSeries}
series={{ ...mockUxSeries, filters }}
/>,
{
initSeries,
}
);
render(<SelectedFilters seriesId={'series-id'} seriesConfig={dataViewSeries} />, {
initSeries,
});
await waitFor(() => {
screen.getByText('Chrome');

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useSeriesStorage } from '../hooks/use_series_storage';
import { FilterLabel } from '../components/filter_label';
import { SeriesConfig, UrlFilter } from '../types';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { useSeriesFilters } from '../hooks/use_series_filters';
import { getFiltersFromDefs } from '../hooks/use_lens_attributes';
interface Props {
seriesId: string;
seriesConfig: SeriesConfig;
isNew?: boolean;
}
export function SelectedFilters({ seriesId, isNew, seriesConfig }: Props) {
const { getSeries } = useSeriesStorage();
const series = getSeries(seriesId);
const { reportDefinitions = {} } = series;
const { labels } = seriesConfig;
const filters: UrlFilter[] = series.filters ?? [];
let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions);
// we don't want to display report definition filters in new series view
if (isNew) {
definitionFilters = [];
}
const { removeFilter } = useSeriesFilters({ seriesId });
const { indexPattern } = useAppIndexPatternContext(series.dataType);
return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? (
<EuiFlexItem>
<EuiFlexGroup wrap gutterSize="xs">
{filters.map(({ field, values, notValues }) => (
<Fragment key={field}>
{(values ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false} style={{ maxWidth: 300 }}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
removeFilter={() => removeFilter({ field, value: val, negate: false })}
negate={false}
indexPattern={indexPattern}
/>
</EuiFlexItem>
))}
{(notValues ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false} style={{ maxWidth: 300 }}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
negate={true}
removeFilter={() => removeFilter({ field, value: val, negate: true })}
indexPattern={indexPattern}
/>
</EuiFlexItem>
))}
</Fragment>
))}
{definitionFilters.map(({ field, values }) => (
<Fragment key={field}>
{(values ?? []).map((val) => (
<EuiFlexItem key={field + val} grow={false}>
<FilterLabel
seriesId={seriesId}
field={field}
label={labels[field]}
value={val}
removeFilter={() => {
// FIXME handle this use case
}}
negate={false}
definitionFilter={true}
indexPattern={indexPattern}
/>
</EuiFlexItem>
))}
</Fragment>
))}
</EuiFlexGroup>
</EuiFlexItem>
) : null;
}

View file

@ -5,399 +5,134 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiBasicTable,
EuiButtonIcon,
EuiSpacer,
EuiFormRow,
EuiFlexItem,
EuiFlexGroup,
EuiButtonEmpty,
} from '@elastic/eui';
import { rgba } from 'polished';
import classNames from 'classnames';
import { isEmpty } from 'lodash';
import { euiStyled } from './../../../../../../../../src/plugins/kibana_react/common';
import { AppDataType, SeriesConfig, ReportViewType, SeriesUrl } from '../types';
import { SeriesContextValue, useSeriesStorage } from '../hooks/use_series_storage';
import { IndexPatternState, useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { SeriesFilter } from './columns/series_filter';
import { SeriesConfig } from '../types';
import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage';
import { getDefaultConfigs } from '../configurations/default_configs';
import { SeriesActions } from '../series_viewer/columns/series_actions';
import { SeriesInfo } from '../series_viewer/columns/series_info';
import { DataTypesSelect } from './columns/data_type_select';
import { DatePickerCol } from './columns/date_picker_col';
import { ExpandedSeriesRow } from './expanded_series_row';
import { SeriesName } from '../series_viewer/columns/series_name';
import { ReportTypesSelect } from './columns/report_type_select';
import { ViewActions } from '../views/view_actions';
import { ReportMetricOptions } from './report_metric_options';
import { Breakdowns } from '../series_viewer/columns/breakdowns';
import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern';
import { SeriesActions } from './columns/series_actions';
import { ChartEditOptions } from './chart_edit_options';
export interface ReportTypeItem {
id: string;
reportType: ReportViewType;
label: string;
}
export interface BuilderItem {
id: number;
series: SeriesUrl;
interface EditItem {
seriesConfig: SeriesConfig;
id: string;
}
type ExpandedRowMap = Record<string, JSX.Element>;
export const getSeriesToEdit = ({
indexPatterns,
allSeries,
reportType,
}: {
allSeries: SeriesContextValue['allSeries'];
indexPatterns: IndexPatternState;
reportType: ReportViewType;
}): BuilderItem[] => {
const getDataViewSeries = (dataType: AppDataType) => {
if (indexPatterns?.[dataType]) {
return getDefaultConfigs({
dataType,
reportType,
indexPattern: indexPatterns[dataType],
});
}
};
return allSeries.map((series, seriesIndex) => {
const seriesConfig = getDataViewSeries(series.dataType)!;
return { id: seriesIndex, series, seriesConfig };
});
};
export const SeriesEditor = React.memo(function () {
const [editorItems, setEditorItems] = useState<BuilderItem[]>([]);
const { getSeries, allSeries, reportType, removeSeries } = useSeriesStorage();
const { loading, indexPatterns } = useAppIndexPatternContext();
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, JSX.Element>>(
{}
);
useEffect(() => {
const newExpandRows: ExpandedRowMap = {};
setEditorItems((prevState) => {
const newEditorItems = getSeriesToEdit({
reportType,
allSeries,
indexPatterns,
});
newEditorItems.forEach(({ series, id, seriesConfig }) => {
const prevSeriesItem = prevState.find(({ id: prevId }) => prevId === id);
if (
prevSeriesItem &&
series.selectedMetricField &&
prevSeriesItem.series.selectedMetricField !== series.selectedMetricField
) {
newExpandRows[id] = (
<ExpandedSeriesRow seriesId={id} series={series} seriesConfig={seriesConfig} />
);
}
});
return [...newEditorItems];
});
setItemIdToExpandedRowMap((prevState) => {
return { ...prevState, ...newExpandRows };
});
}, [allSeries, getSeries, indexPatterns, loading, reportType]);
useEffect(() => {
setItemIdToExpandedRowMap((prevState) => {
const itemIdToExpandedRowMapValues = { ...prevState };
const newEditorItems = getSeriesToEdit({
reportType,
allSeries,
indexPatterns,
});
newEditorItems.forEach((item) => {
if (itemIdToExpandedRowMapValues[item.id]) {
itemIdToExpandedRowMapValues[item.id] = (
<ExpandedSeriesRow
seriesId={item.id}
series={item.series}
seriesConfig={item.seriesConfig}
/>
);
}
});
return itemIdToExpandedRowMapValues;
});
}, [allSeries, editorItems, indexPatterns, reportType]);
const toggleDetails = (item: BuilderItem) => {
const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap };
if (itemIdToExpandedRowMapValues[item.id]) {
delete itemIdToExpandedRowMapValues[item.id];
} else {
itemIdToExpandedRowMapValues[item.id] = (
<ExpandedSeriesRow
seriesId={item.id}
series={item.series}
seriesConfig={item.seriesConfig}
/>
);
}
setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues);
};
export function SeriesEditor() {
const { allSeries, allSeriesIds } = useSeriesStorage();
const columns = [
{
align: 'left' as const,
width: '40px',
isExpander: true,
field: 'id',
name: '',
render: (id: number, item: BuilderItem) =>
item.series.dataType && item.series.selectedMetricField ? (
<EuiButtonIcon
onClick={() => toggleDetails(item)}
isDisabled={!item.series.dataType || !item.series.selectedMetricField}
aria-label={itemIdToExpandedRowMap[item.id] ? COLLAPSE_LABEL : EXPAND_LABEL}
iconType={itemIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
/>
) : null,
},
{
name: '',
field: 'id',
width: '40px',
render: (seriesId: number, { seriesConfig, series }: BuilderItem) => (
<SeriesInfo seriesId={seriesId} series={series} seriesConfig={seriesConfig} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.name', {
defaultMessage: 'Name',
}),
field: 'id',
width: '20%',
render: (seriesId: number, { series }: BuilderItem) => (
<SeriesName seriesId={seriesId} series={series} />
width: '15%',
render: (seriesId: string) => (
<EuiText>
<EuiIcon type="dot" color="green" size="l" />{' '}
{seriesId === NEW_SERIES_KEY ? 'series-preview' : seriesId}
</EuiText>
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.dataType', {
defaultMessage: 'Data type',
name: i18n.translate('xpack.observability.expView.seriesEditor.filters', {
defaultMessage: 'Filters',
}),
field: 'id',
field: 'defaultFilters',
width: '15%',
render: (seriesId: number, { series }: BuilderItem) => (
<DataTypesSelect seriesId={seriesId} series={series} />
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.reportMetric', {
defaultMessage: 'Report metric',
}),
field: 'id',
width: '15%',
render: (seriesId: number, { seriesConfig, series }: BuilderItem) => (
<ReportMetricOptions
series={series}
seriesId={seriesId}
metricOptions={seriesConfig?.metricOptions}
render: (seriesId: string, { seriesConfig, id }: EditItem) => (
<SeriesFilter
filterFields={seriesConfig.filterFields}
seriesId={id}
seriesConfig={seriesConfig}
baseFilters={seriesConfig.baseFilters}
/>
),
},
{
name: i18n.translate('xpack.observability.expView.seriesEditor.time', {
defaultMessage: 'Time',
name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', {
defaultMessage: 'Breakdowns',
}),
field: 'id',
width: '27%',
render: (seriesId: number, { series }: BuilderItem) => (
<DatePickerCol seriesId={seriesId} series={series} />
width: '25%',
render: (seriesId: string, { seriesConfig, id }: EditItem) => (
<ChartEditOptions
seriesId={id}
breakdownFields={seriesConfig.breakdownFields}
seriesConfig={seriesConfig}
/>
),
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdownBy', {
defaultMessage: 'Breakdown by',
}),
width: '10%',
name: (
<div>
<FormattedMessage
id="xpack.observability.expView.seriesEditor.time"
defaultMessage="Time"
/>
</div>
),
width: '20%',
field: 'id',
render: (seriesId: number, { series, seriesConfig }: BuilderItem) => (
<Breakdowns seriesConfig={seriesConfig} seriesId={seriesId} series={series} />
),
align: 'right' as const,
render: (seriesId: string, item: EditItem) => <DatePickerCol seriesId={seriesId} />,
},
{
name: i18n.translate('xpack.observability.expView.seriesBuilder.actions', {
name: i18n.translate('xpack.observability.expView.seriesEditor.actions', {
defaultMessage: 'Actions',
}),
align: 'center' as const,
width: '8%',
width: '10%',
field: 'id',
render: (seriesId: number, { series, seriesConfig }: BuilderItem) => (
<SeriesActions seriesId={seriesId} series={series} seriesConfig={seriesConfig} />
),
render: (seriesId: string, item: EditItem) => <SeriesActions seriesId={seriesId} />,
},
];
const getRowProps = (item: BuilderItem) => {
const { dataType, reportDefinitions, selectedMetricField } = item.series;
const { indexPatterns } = useAppIndexPatternContext();
const items: EditItem[] = [];
return {
className: classNames({
isExpanded: itemIdToExpandedRowMap[item.id],
isIncomplete: !dataType || isEmpty(reportDefinitions) || !selectedMetricField,
}),
// commenting this for now, since adding on click on row, blocks adding space
// into text field for name column
// ...(dataType && selectedMetricField
// ? {
// onClick: (evt: MouseEvent) => {
// const targetElem = evt.target as HTMLElement;
//
// if (
// targetElem.classList.contains('euiTableCellContent') &&
// targetElem.tagName !== 'BUTTON'
// ) {
// toggleDetails(item);
// }
// evt.stopPropagation();
// evt.preventDefault();
// },
// }
// : {}),
};
};
const resetView = () => {
const totalSeries = allSeries.length;
for (let i = totalSeries; i >= 0; i--) {
removeSeries(i);
allSeriesIds.forEach((seriesKey) => {
const series = allSeries[seriesKey];
if (series?.reportType && indexPatterns[series.dataType] && !series.isNew) {
items.push({
id: seriesKey,
seriesConfig: getDefaultConfigs({
indexPattern: indexPatterns[series.dataType],
reportType: series.reportType,
dataType: series.dataType,
}),
});
}
setEditorItems([]);
setItemIdToExpandedRowMap({});
};
});
if (items.length === 0 && allSeriesIds.length > 0) {
return null;
}
return (
<Wrapper>
<div>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow label={REPORT_TYPE_LABEL} display="columnCompressed">
<ReportTypesSelect />
</EuiFormRow>
</EuiFlexItem>
{reportType && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => resetView()} color="text">
{RESET_LABEL}
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem>
<ViewActions />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
{editorItems.length > 0 && (
<EuiBasicTable
loading={loading}
items={editorItems}
columns={columns}
tableLayout="auto"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
itemId="id"
rowProps={getRowProps}
/>
)}
<EuiSpacer />
</div>
</Wrapper>
<>
<EuiSpacer />
<EuiBasicTable
items={items}
rowHeader="firstName"
columns={columns}
noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.seriesNotFound', {
defaultMessage: 'No series found. Please add a series.',
})}
cellProps={{
style: {
verticalAlign: 'top',
},
}}
tableLayout="auto"
/>
<EuiSpacer />
</>
);
});
const Wrapper = euiStyled.div`
max-height: 50vh;
&::-webkit-scrollbar {
height: ${({ theme }) => theme.eui.euiScrollBar};
width: ${({ theme }) => theme.eui.euiScrollBar};
}
&::-webkit-scrollbar-thumb {
background-clip: content-box;
background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)};
border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent;
}
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar-track {
background-color: transparent;
}
&&& {
.euiTableRow-isExpandedRow .euiTableRowCell {
border-top: none;
background-color: #FFFFFF;
border-bottom: 2px solid #d3dae6;
border-right: 2px solid rgb(211, 218, 230);
border-left: 2px solid rgb(211, 218, 230);
}
.isExpanded {
border-right: 2px solid rgb(211, 218, 230);
border-left: 2px solid rgb(211, 218, 230);
.euiTableRowCell {
border-bottom: none;
}
}
.isIncomplete .euiTableRowCell {
background-color: rgba(254, 197, 20, 0.1);
}
}
`;
export const LOADING_VIEW = i18n.translate(
'xpack.observability.expView.seriesBuilder.loadingView',
{
defaultMessage: 'Loading view ...',
}
);
export const SELECT_REPORT_TYPE = i18n.translate(
'xpack.observability.expView.seriesBuilder.selectReportType',
{
defaultMessage: 'No report type selected',
}
);
export const RESET_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.reset', {
defaultMessage: 'Reset',
});
export const REPORT_TYPE_LABEL = i18n.translate(
'xpack.observability.expView.seriesBuilder.reportType',
{
defaultMessage: 'Report type',
}
);
const COLLAPSE_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.collapse', {
defaultMessage: 'Collapse',
});
const EXPAND_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.expand', {
defaultMessage: 'Exapnd',
});
}

View file

@ -1,70 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { EuiPopover, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
useKibana,
ToolbarButton,
} from '../../../../../../../../../src/plugins/kibana_react/public';
import { ObservabilityPublicPluginsStart } from '../../../../../plugin';
import { SeriesUrl, useFetcher } from '../../../../..';
import { SeriesConfig } from '../../types';
import { SeriesChartTypesSelect } from '../../series_editor/columns/chart_types';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesConfig: SeriesConfig;
}
export function SeriesChartTypes({ seriesId, series, seriesConfig }: Props) {
const seriesType = series?.seriesType ?? seriesConfig.defaultSeriesType;
const {
services: { lens },
} = useKibana<ObservabilityPublicPluginsStart>();
const { data = [] } = useFetcher(() => lens.getXyVisTypes(), [lens]);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
return (
<EuiPopover
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
button={
<EuiToolTip content={EDIT_CHART_TYPE_LABEL}>
<ToolbarButton
size="s"
iconType={(data ?? []).find(({ id }) => id === seriesType)?.icon!}
aria-label={CHART_TYPE_LABEL}
onClick={() => setIsPopoverOpen((prevState) => !prevState)}
/>
</EuiToolTip>
}
>
<SeriesChartTypesSelect
seriesId={seriesId}
series={series}
defaultChartType={seriesConfig.defaultSeriesType}
/>
</EuiPopover>
);
}
const EDIT_CHART_TYPE_LABEL = i18n.translate(
'xpack.observability.expView.seriesEditor.editChartSeriesLabel',
{
defaultMessage: 'Edit chart type for series',
}
);
const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.label', {
defaultMessage: 'Chart type',
});

View file

@ -1,51 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import { useSeriesStorage } from '../../hooks/use_series_storage';
interface Props {
seriesId: number;
}
export function RemoveSeries({ seriesId }: Props) {
const { removeSeries, allSeries } = useSeriesStorage();
const onClick = () => {
removeSeries(seriesId);
};
const isDisabled = seriesId === 0 && allSeries.length > 1;
return (
<EuiToolTip
content={
isDisabled
? i18n.translate('xpack.observability.expView.seriesEditor.removeSeriesDisabled', {
defaultMessage:
'Main series cannot be removed. Please remove all series below before you can remove this.',
})
: i18n.translate('xpack.observability.expView.seriesEditor.removeSeries', {
defaultMessage: 'Remove series',
})
}
>
<EuiButtonIcon
aria-label={i18n.translate('xpack.observability.expView.seriesEditor.removeSeries', {
defaultMessage: 'Remove series',
})}
iconType="trash"
color="text"
onClick={onClick}
size="s"
isDisabled={isDisabled}
/>
</EuiToolTip>
);
}

View file

@ -1,104 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { RemoveSeries } from './remove_series';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesConfig, SeriesUrl } from '../../types';
import { useDiscoverLink } from '../../hooks/use_discover_link';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesConfig: SeriesConfig;
}
export function SeriesActions({ seriesId, series, seriesConfig }: Props) {
const { setSeries, allSeries } = useSeriesStorage();
const { href: discoverHref } = useDiscoverLink({ series, seriesConfig });
const copySeries = () => {
let copySeriesId: string = `${series.name}-copy`;
if (allSeries.find(({ name }) => name === copySeriesId)) {
copySeriesId = copySeriesId + allSeries.length;
}
setSeries(allSeries.length, { ...series, name: copySeriesId });
};
const toggleSeries = () => {
if (series.hidden) {
setSeries(seriesId, { ...series, hidden: undefined });
} else {
setSeries(seriesId, { ...series, hidden: true });
}
};
return (
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.observability.seriesEditor.sampleDocuments', {
defaultMessage: 'View sample documents in new tab',
})}
>
<EuiButtonIcon
iconType="discoverApp"
aria-label={i18n.translate('xpack.observability.seriesEditor.sampleDocuments', {
defaultMessage: 'View sample documents in new tab',
})}
size="s"
color="text"
target="_blank"
href={discoverHref}
isDisabled={!series.dataType || !series.selectedMetricField}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.observability.seriesEditor.hide', {
defaultMessage: 'Hide series',
})}
>
<EuiButtonIcon
iconType={series.hidden ? 'eyeClosed' : 'eye'}
aria-label={i18n.translate('xpack.observability.seriesEditor.hide', {
defaultMessage: 'Hide series',
})}
size="s"
color="text"
onClick={toggleSeries}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate('xpack.observability.seriesEditor.clone', {
defaultMessage: 'Copy series',
})}
>
<EuiButtonIcon
iconType={'copy'}
color="text"
aria-label={i18n.translate('xpack.observability.seriesEditor.clone', {
defaultMessage: 'Copy series',
})}
size="s"
onClick={copySeries}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RemoveSeries seriesId={seriesId} />
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,69 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFilterGroup, EuiSpacer } from '@elastic/eui';
import { useRouteMatch } from 'react-router-dom';
import { FilterExpanded } from './filter_expanded';
import { SeriesConfig, SeriesUrl } from '../../types';
import { FieldLabels } from '../../configurations/constants/constants';
import { SelectedFilters } from '../selected_filters';
interface Props {
seriesId: number;
seriesConfig: SeriesConfig;
series: SeriesUrl;
}
export interface Field {
label: string;
field: string;
nested?: string;
isNegated?: boolean;
}
export function SeriesFilter({ series, seriesConfig, seriesId }: Props) {
const isPreview = !!useRouteMatch('/exploratory-view/preview');
const options: Field[] = seriesConfig.filterFields.map((field) => {
if (typeof field === 'string') {
return { label: seriesConfig.labels?.[field] ?? FieldLabels[field], field };
}
return {
field: field.field,
nested: field.nested,
isNegated: field.isNegated,
label: seriesConfig.labels?.[field.field] ?? FieldLabels[field.field],
};
});
return (
<>
{!isPreview && (
<>
<EuiFilterGroup>
{options.map((opt) => (
<FilterExpanded
series={series}
key={opt.label}
seriesId={seriesId}
field={opt.field}
label={opt.label}
nestedField={opt.nested}
isNegated={opt.isNegated}
filters={seriesConfig.baseFilters}
/>
))}
</EuiFilterGroup>
<EuiSpacer size="s" />
</>
)}
<SelectedFilters seriesId={seriesId} series={series} seriesConfig={seriesConfig} />
</>
);
}

View file

@ -1,95 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { isEmpty } from 'lodash';
import { EuiBadge, EuiBadgeGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { useRouteMatch } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { SeriesChartTypes } from './chart_types';
import { SeriesConfig, SeriesUrl } from '../../types';
import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern';
import { SeriesColorPicker } from '../../components/series_color_picker';
import { dataTypes } from '../../series_editor/columns/data_type_select';
interface Props {
seriesId: number;
series: SeriesUrl;
seriesConfig?: SeriesConfig;
}
export function SeriesInfo({ seriesId, series, seriesConfig }: Props) {
const isConfigure = !!useRouteMatch('/exploratory-view/configure');
const { dataType, reportDefinitions, selectedMetricField } = series;
const { loading } = useAppIndexPatternContext();
const isIncomplete =
(!dataType || isEmpty(reportDefinitions) || !selectedMetricField) && !loading;
if (!seriesConfig) {
return null;
}
const { definitionFields, labels } = seriesConfig;
const incompleteDefinition = isEmpty(reportDefinitions)
? i18n.translate('xpack.observability.overview.exploratoryView.missingReportDefinition', {
defaultMessage: 'Missing {reportDefinition}',
values: { reportDefinition: labels?.[definitionFields[0]] },
})
: '';
let incompleteMessage = !selectedMetricField ? MISSING_REPORT_METRIC_LABEL : incompleteDefinition;
if (!dataType) {
incompleteMessage = MISSING_DATA_TYPE_LABEL;
}
if (!isIncomplete && seriesConfig && isConfigure) {
return (
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<SeriesChartTypes seriesId={seriesId} series={series} seriesConfig={seriesConfig} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SeriesColorPicker seriesId={seriesId} series={series} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
{isIncomplete && <EuiBadge color="warning">{incompleteMessage}</EuiBadge>}
</EuiFlexItem>
{!isConfigure && (
<EuiFlexItem>
<EuiBadgeGroup>
<EuiBadge>{dataTypes.find(({ id }) => id === dataType)!.label}</EuiBadge>
</EuiBadgeGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
}
const MISSING_REPORT_METRIC_LABEL = i18n.translate(
'xpack.observability.overview.exploratoryView.missingReportMetric',
{
defaultMessage: 'Missing report metric',
}
);
const MISSING_DATA_TYPE_LABEL = i18n.translate(
'xpack.observability.overview.exploratoryView.missingDataType',
{
defaultMessage: 'Missing data type',
}
);

View file

@ -1,38 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, ChangeEvent, useEffect } from 'react';
import { EuiFieldText } from '@elastic/eui';
import { useSeriesStorage } from '../../hooks/use_series_storage';
import { SeriesUrl } from '../../types';
interface Props {
seriesId: number;
series: SeriesUrl;
}
export function SeriesName({ series, seriesId }: Props) {
const { setSeries } = useSeriesStorage();
const [value, setValue] = useState(series.name);
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
const onSave = () => {
if (value !== series.name) {
setSeries(seriesId, { ...series, name: value });
}
};
useEffect(() => {
setValue(series.name);
}, [series.name]);
return <EuiFieldText value={value} onChange={onChange} fullWidth onBlur={onSave} />;
}

Some files were not shown because too many files have changed in this diff Show more