= {}
+) {
+ if (url) {
+ history = getHistoryFromUrl(url);
+ }
+
+ const core = {
+ ...mockCore(),
+ ...customCore,
+ };
+
+ return {
+ ...reactTestLibRender(
+
+ {ui}
+ ,
+ renderOptions
+ ),
+ history,
+ core,
+ };
+}
+
+const getHistoryFromUrl = (url: Url) => {
+ if (typeof url === 'string') {
+ return createMemoryHistory({
+ initialEntries: [url],
+ });
+ }
+
+ return createMemoryHistory({
+ initialEntries: [url.path + stringify(url.queryParams)],
+ });
+};
+
+export const mockFetcher = (data: any) => {
+ return jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({
+ data,
+ status: fetcherHook.FETCH_STATUS.SUCCESS,
+ refetch: jest.fn(),
+ });
+};
+
+export const mockUseHasData = () => {
+ const onRefreshTimeRange = jest.fn();
+ const spy = jest.spyOn(useHasDataHook, 'useHasData').mockReturnValue({
+ onRefreshTimeRange,
+ } as any);
+ return { spy, onRefreshTimeRange };
+};
+
+export const mockUseValuesList = (values?: string[]) => {
+ const onRefreshTimeRange = jest.fn();
+ const spy = jest.spyOn(useValuesListHook, 'useValuesList').mockReturnValue({
+ values: values ?? [],
+ } as any);
+ return { spy, onRefreshTimeRange };
+};
+
+export const mockUrlStorage = ({
+ data,
+ filters,
+ breakdown,
+}: {
+ data?: AllSeries;
+ filters?: UrlFilter[];
+ breakdown?: string;
+}) => {
+ const mockDataSeries = data || {
+ 'performance-distribution': {
+ reportType: 'pld',
+ breakdown: breakdown || 'user_agent.name',
+ time: { from: 'now-15m', to: 'now' },
+ ...(filters ? { filters } : {}),
+ },
+ };
+ const allSeriesIds = Object.keys(mockDataSeries);
+ const firstSeriesId = allSeriesIds?.[0];
+
+ const series = mockDataSeries[firstSeriesId];
+
+ const removeSeries = jest.fn();
+ const setSeries = jest.fn();
+
+ const spy = jest.spyOn(useUrlHook, 'useUrlStorage').mockReturnValue({
+ firstSeriesId,
+ allSeriesIds,
+ removeSeries,
+ setSeries,
+ series,
+ firstSeries: mockDataSeries[firstSeriesId],
+ allSeries: mockDataSeries,
+ } as any);
+
+ return { spy, removeSeries, setSeries };
+};
+
+export function mockUseSeriesFilter() {
+ const removeFilter = jest.fn();
+ const invertFilter = jest.fn();
+ const setFilter = jest.fn();
+ const spy = jest.spyOn(useSeriesFilterHook, 'useSeriesFilters').mockReturnValue({
+ removeFilter,
+ invertFilter,
+ setFilter,
+ });
+
+ return {
+ spy,
+ removeFilter,
+ invertFilter,
+ setFilter,
+ };
+}
+
+const hist = createMemoryHistory();
+export const mockHistory = {
+ ...hist,
+ createHref: jest.fn(({ pathname }) => `/observability${pathname}`),
+ push: jest.fn(),
+ location: {
+ ...hist.location,
+ pathname: '/current-path',
+ },
+};
+
+export const mockIndexPattern = getStubIndexPattern(
+ 'apm-*',
+ () => {},
+ '@timestamp',
+ JSON.parse(indexPatternData.attributes.fields),
+ mockCore() as any
+);
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx
new file mode 100644
index 0000000000000..d33d8515d3bee
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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 { mockUrlStorage, render } from '../../rtl_helpers';
+import { dataTypes, DataTypesCol } from './data_types_col';
+import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+
+describe('DataTypesCol', function () {
+ it('should render properly', function () {
+ const { getByText } = render();
+
+ dataTypes.forEach(({ label }) => {
+ getByText(label);
+ });
+ });
+
+ it('should set series on change', function () {
+ const { setSeries } = mockUrlStorage({});
+
+ render();
+
+ fireEvent.click(screen.getByText(/user experience\(rum\)/i));
+
+ expect(setSeries).toHaveBeenCalledTimes(1);
+ expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'rum' });
+ });
+
+ it('should set series on change on already selected', function () {
+ const { setSeries } = mockUrlStorage({
+ data: {
+ [NEW_SERIES_KEY]: {
+ dataType: 'synthetics',
+ reportType: 'upp',
+ breakdown: 'monitor.status',
+ time: { from: 'now-15m', to: 'now' },
+ },
+ },
+ });
+
+ render();
+
+ const button = screen.getByRole('button', {
+ name: /Synthetic Monitoring/i,
+ });
+
+ expect(button.classList).toContain('euiButton--fill');
+
+ fireEvent.click(button);
+
+ // undefined on click selected
+ expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: undefined });
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx
new file mode 100644
index 0000000000000..7ea44e66a721a
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 { AppDataType } from '../../types';
+import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
+import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
+
+export const dataTypes: Array<{ id: AppDataType; label: string }> = [
+ { id: 'synthetics', label: 'Synthetic Monitoring' },
+ { id: 'rum', label: 'User Experience(RUM)' },
+ { id: 'logs', label: 'Logs' },
+ { id: 'metrics', label: 'Metrics' },
+ { id: 'apm', label: 'APM' },
+];
+
+export function DataTypesCol() {
+ const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY);
+
+ const { loadIndexPattern } = useIndexPatternContext();
+
+ const onDataTypeChange = (dataType?: AppDataType) => {
+ if (dataType) {
+ loadIndexPattern(dataType);
+ }
+ setSeries(NEW_SERIES_KEY, { dataType } as any);
+ };
+
+ const selectedDataType = series.dataType;
+
+ return (
+
+ {dataTypes.map(({ id: dataTypeId, label }) => (
+
+ {
+ onDataTypeChange(dataTypeId === selectedDataType ? undefined : dataTypeId);
+ }}
+ >
+ {label}
+
+
+ ))}
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx
new file mode 100644
index 0000000000000..dba660fff9c36
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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 { render } from '../../../../../utils/test_helper';
+import { getDefaultConfigs } from '../../configurations/default_configs';
+import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers';
+import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+import { ReportBreakdowns } from './report_breakdowns';
+import { USER_AGENT_OS } from '../../configurations/data/elasticsearch_fieldnames';
+
+describe('Series Builder ReportBreakdowns', function () {
+ const dataViewSeries = getDefaultConfigs({
+ reportType: 'pld',
+ indexPattern: mockIndexPattern,
+ seriesId: NEW_SERIES_KEY,
+ });
+
+ it('should render properly', function () {
+ mockUrlStorage({});
+
+ render();
+
+ screen.getByText('Select an option: , is selected');
+ screen.getAllByText('Browser family');
+ });
+
+ it('should set new series breakdown on change', function () {
+ const { setSeries } = mockUrlStorage({});
+
+ render();
+
+ 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('newSeriesKey', {
+ breakdown: USER_AGENT_OS,
+ reportType: 'pld',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ });
+ it('should set undefined on new series on no select breakdown', function () {
+ const { setSeries } = mockUrlStorage({});
+
+ render();
+
+ 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('newSeriesKey', {
+ breakdown: undefined,
+ reportType: 'pld',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx
new file mode 100644
index 0000000000000..7667cea417a52
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx
@@ -0,0 +1,15 @@
+/*
+ * 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 { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+import { DataSeries } from '../../types';
+
+export function ReportBreakdowns({ dataViewSeries }: { dataViewSeries: DataSeries }) {
+ return ;
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx
new file mode 100644
index 0000000000000..2fda581154166
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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, mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers';
+import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+import { ReportDefinitionCol } from './report_definition_col';
+import { SERVICE_NAME } from '../../configurations/data/elasticsearch_fieldnames';
+
+describe('Series Builder ReportDefinitionCol', function () {
+ const dataViewSeries = getDefaultConfigs({
+ reportType: 'pld',
+ indexPattern: mockIndexPattern,
+ seriesId: NEW_SERIES_KEY,
+ });
+
+ const { setSeries } = mockUrlStorage({
+ data: {
+ 'performance-dist': {
+ dataType: 'rum',
+ reportType: 'pld',
+ time: { from: 'now-30d', to: 'now' },
+ reportDefinitions: { [SERVICE_NAME]: 'elastic-co' },
+ },
+ },
+ });
+
+ it('should render properly', async function () {
+ render();
+
+ 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', function () {
+ render();
+
+ screen.getByText('elastic-co');
+ });
+
+ it('should be able to remove selected definition', function () {
+ render();
+
+ const removeBtn = screen.getByText(/elastic-co/i);
+
+ fireEvent.click(removeBtn);
+
+ expect(setSeries).toHaveBeenCalledTimes(1);
+ expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
+ dataType: 'rum',
+ reportDefinitions: {},
+ reportType: 'pld',
+ time: { from: 'now-30d', to: 'now' },
+ });
+ });
+
+ it('should be able to unselected selected definition', async function () {
+ mockUseValuesList(['elastic-co']);
+ render();
+
+ const definitionBtn = screen.getByText(/web application/i);
+
+ fireEvent.click(definitionBtn);
+
+ screen.getByText('Apply');
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx
new file mode 100644
index 0000000000000..ce11c869de0ab
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx
@@ -0,0 +1,95 @@
+/*
+ * 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 { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
+import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
+import { CustomReportField } from '../custom_report_field';
+import FieldValueSuggestions from '../../../field_value_suggestions';
+import { DataSeries } from '../../types';
+
+export function ReportDefinitionCol({ dataViewSeries }: { dataViewSeries: DataSeries }) {
+ const { indexPattern } = useIndexPatternContext();
+
+ const { series, setSeries } = useUrlStorage(NEW_SERIES_KEY);
+
+ const { reportDefinitions: rtd = {} } = series;
+
+ const { reportDefinitions, labels, filters } = dataViewSeries;
+
+ const onChange = (field: string, value?: string) => {
+ if (!value) {
+ delete rtd[field];
+ setSeries(NEW_SERIES_KEY, {
+ ...series,
+ reportDefinitions: { ...rtd },
+ });
+ } else {
+ setSeries(NEW_SERIES_KEY, {
+ ...series,
+ reportDefinitions: { ...rtd, [field]: value },
+ });
+ }
+ };
+
+ const onRemove = (field: string) => {
+ delete rtd[field];
+ setSeries(NEW_SERIES_KEY, {
+ ...series,
+ reportDefinitions: rtd,
+ });
+ };
+
+ return (
+
+ {indexPattern &&
+ reportDefinitions.map(({ field, custom, options, defaultValue }) => (
+
+ {!custom ? (
+
+
+ onChange(field, val)}
+ filters={(filters ?? []).map(({ query }) => query)}
+ time={series.time}
+ width={200}
+ />
+
+ {rtd?.[field] && (
+
+ onRemove(field)}
+ iconOnClick={() => onRemove(field)}
+ iconOnClickAriaLabel={'Click to remove'}
+ onClickAriaLabel={'Click to remove'}
+ >
+ {rtd?.[field]}
+
+
+ )}
+
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx
new file mode 100644
index 0000000000000..674f5e6f49bde
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx
@@ -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 { render } from '../../../../../utils/test_helper';
+import { ReportFilters } from './report_filters';
+import { getDefaultConfigs } from '../../configurations/default_configs';
+import { mockIndexPattern, mockUrlStorage } from '../../rtl_helpers';
+import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+
+describe('Series Builder ReportFilters', function () {
+ const dataViewSeries = getDefaultConfigs({
+ reportType: 'pld',
+ indexPattern: mockIndexPattern,
+ seriesId: NEW_SERIES_KEY,
+ });
+ mockUrlStorage({});
+ it('should render properly', function () {
+ render();
+
+ screen.getByText('Add filter');
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx
new file mode 100644
index 0000000000000..903dda549aeee
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx
@@ -0,0 +1,22 @@
+/*
+ * 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 { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+import { DataSeries } from '../../types';
+
+export function ReportFilters({ dataViewSeries }: { dataViewSeries: DataSeries }) {
+ return (
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx
new file mode 100644
index 0000000000000..567e2654130e8
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 { mockUrlStorage, render } from '../../rtl_helpers';
+import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col';
+import { ReportTypes } from '../series_builder';
+
+describe('ReportTypesCol', function () {
+ it('should render properly', function () {
+ render();
+ screen.getByText('Performance distribution');
+ screen.getByText('KPI over time');
+ });
+
+ it('should display empty message', function () {
+ render();
+ screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT);
+ });
+
+ it('should set series on change', function () {
+ const { setSeries } = mockUrlStorage({});
+ render();
+
+ fireEvent.click(screen.getByText(/monitor duration/i));
+
+ expect(setSeries).toHaveBeenCalledWith('newSeriesKey', {
+ breakdown: 'user_agent.name',
+ reportDefinitions: {},
+ reportType: 'upd',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ expect(setSeries).toHaveBeenCalledTimes(1);
+ });
+
+ it('should set selected as filled', function () {
+ const { setSeries } = mockUrlStorage({
+ data: {
+ newSeriesKey: {
+ dataType: 'synthetics',
+ reportType: 'upp',
+ breakdown: 'monitor.status',
+ time: { from: 'now-15m', to: 'now' },
+ },
+ },
+ });
+
+ render();
+
+ const button = screen.getByRole('button', {
+ name: /pings histogram/i,
+ });
+
+ expect(button.classList).toContain('euiButton--fill');
+ fireEvent.click(button);
+
+ // undefined on click selected
+ expect(setSeries).toHaveBeenCalledWith('newSeriesKey', { dataType: 'synthetics' });
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx
new file mode 100644
index 0000000000000..5c94a5bca60f8
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx
@@ -0,0 +1,61 @@
+/*
+ * 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 { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
+import { ReportViewTypeId, SeriesUrl } from '../../types';
+import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
+
+interface Props {
+ reportTypes: Array<{ id: ReportViewTypeId; label: string }>;
+}
+
+export function ReportTypesCol({ reportTypes }: Props) {
+ const {
+ series: { reportType: selectedReportType, ...restSeries },
+ setSeries,
+ } = useUrlStorage(NEW_SERIES_KEY);
+
+ return reportTypes?.length > 0 ? (
+
+ {reportTypes.map(({ id: reportType, label }) => (
+
+ {
+ if (reportType === selectedReportType) {
+ setSeries(NEW_SERIES_KEY, {
+ dataType: restSeries.dataType,
+ } as SeriesUrl);
+ } else {
+ setSeries(NEW_SERIES_KEY, {
+ ...restSeries,
+ reportType,
+ reportDefinitions: {},
+ });
+ }
+ }}
+ >
+ {label}
+
+
+ ))}
+
+ ) : (
+ {SELECTED_DATA_TYPE_FOR_REPORT}
+ );
+}
+
+export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate(
+ 'xpack.observability.expView.reportType.noDataType',
+ { defaultMessage: 'Select a data type to start building a series.' }
+);
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx
new file mode 100644
index 0000000000000..6039fd4cba280
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/custom_report_field.tsx
@@ -0,0 +1,47 @@
+/*
+ * 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 { useUrlStorage } from '../hooks/use_url_strorage';
+import { ReportDefinition } from '../types';
+
+interface Props {
+ field: string;
+ seriesId: string;
+ defaultValue?: string;
+ options: ReportDefinition['options'];
+}
+
+export function CustomReportField({ field, seriesId, options: opts, defaultValue }: Props) {
+ const { series, setSeries } = useUrlStorage(seriesId);
+
+ const { reportDefinitions: rtd = {} } = series;
+
+ const onChange = (value: string) => {
+ setSeries(seriesId, { ...series, reportDefinitions: { ...rtd, [field]: value } });
+ };
+
+ const { reportDefinitions } = series;
+
+ const NO_SELECT = 'no_select';
+
+ const options = [{ label: 'Select metric', field: NO_SELECT }, ...(opts ?? [])];
+
+ return (
+
+ ({
+ value: fd,
+ inputDisplay: label,
+ }))}
+ valueOfSelected={reportDefinitions?.[field] || defaultValue || NO_SELECT}
+ onChange={(value) => onChange(value)}
+ />
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx
new file mode 100644
index 0000000000000..983c18af031d0
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx
@@ -0,0 +1,201 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { EuiButton, EuiBasicTable, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import styled from 'styled-components';
+import { AppDataType, ReportViewTypeId, ReportViewTypes, 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, useUrlStorage } from '../hooks/use_url_strorage';
+import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
+import { getDefaultConfigs } from '../configurations/default_configs';
+
+export const ReportTypes: Record> = {
+ synthetics: [
+ { id: 'upd', label: 'Monitor duration' },
+ { id: 'upp', label: 'Pings histogram' },
+ ],
+ rum: [
+ { id: 'pld', label: 'Performance distribution' },
+ { id: 'kpi', label: 'KPI over time' },
+ ],
+ apm: [
+ { id: 'svl', label: 'Latency' },
+ { id: 'tpt', label: 'Throughput' },
+ ],
+ logs: [
+ {
+ id: 'logs',
+ label: 'Logs Frequency',
+ },
+ ],
+ metrics: [
+ { id: 'cpu', label: 'CPU usage' },
+ { id: 'mem', label: 'Memory usage' },
+ { id: 'nwk', label: 'Network activity' },
+ ],
+};
+
+export function SeriesBuilder() {
+ const { series, setSeries, allSeriesIds, removeSeries } = useUrlStorage(NEW_SERIES_KEY);
+
+ const { dataType, reportType, reportDefinitions = {}, filters = [] } = series;
+
+ const [isFlyoutVisible, setIsFlyoutVisible] = useState(!!series.dataType);
+
+ const { indexPattern } = useIndexPatternContext();
+
+ const getDataViewSeries = () => {
+ return getDefaultConfigs({
+ indexPattern,
+ reportType: reportType!,
+ seriesId: NEW_SERIES_KEY,
+ });
+ };
+
+ const columns = [
+ {
+ name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', {
+ defaultMessage: 'Data Type',
+ }),
+ width: '20%',
+ render: (val: string) => ,
+ },
+ {
+ name: i18n.translate('xpack.observability.expView.seriesBuilder.report', {
+ defaultMessage: 'Report',
+ }),
+ width: '20%',
+ render: (val: string) => (
+
+ ),
+ },
+ {
+ name: i18n.translate('xpack.observability.expView.seriesBuilder.definition', {
+ defaultMessage: 'Definition',
+ }),
+ width: '30%',
+ render: (val: string) =>
+ reportType && indexPattern ? (
+
+ ) : null,
+ },
+ {
+ name: i18n.translate('xpack.observability.expView.seriesBuilder.filters', {
+ defaultMessage: 'Filters',
+ }),
+ width: '25%',
+ render: (val: string) =>
+ reportType && indexPattern ? : null,
+ },
+ {
+ name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', {
+ defaultMessage: 'Breakdowns',
+ }),
+ width: '25%',
+ field: 'id',
+ render: (val: string) =>
+ reportType && indexPattern ? (
+
+ ) : null,
+ },
+ ];
+
+ const addSeries = () => {
+ if (reportType) {
+ const newSeriesId = `${
+ reportDefinitions?.['service.name'] ||
+ reportDefinitions?.['monitor.id'] ||
+ ReportViewTypes[reportType]
+ }`;
+
+ const newSeriesN = {
+ reportType,
+ time: { from: 'now-30m', to: 'now' },
+ filters,
+ reportDefinitions,
+ } as SeriesUrl;
+
+ setSeries(newSeriesId, newSeriesN).then(() => {
+ removeSeries(NEW_SERIES_KEY);
+ setIsFlyoutVisible(false);
+ });
+ }
+ };
+
+ const items = [{ id: NEW_SERIES_KEY }];
+
+ let flyout;
+
+ if (isFlyoutVisible) {
+ flyout = (
+
+
+
+
+
+
+ {i18n.translate('xpack.observability.expView.seriesBuilder.add', {
+ defaultMessage: 'Add',
+ })}
+
+
+
+ {
+ removeSeries(NEW_SERIES_KEY);
+ setIsFlyoutVisible(false);
+ }}
+ >
+ {i18n.translate('xpack.observability.expView.seriesBuilder.cancel', {
+ defaultMessage: 'Cancel',
+ })}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {!isFlyoutVisible && (
+ <>
+ setIsFlyoutVisible((prevState) => !prevState)}
+ disabled={allSeriesIds.length > 0}
+ >
+ {i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', {
+ defaultMessage: 'Add series',
+ })}
+
+
+ >
+ )}
+ {flyout}
+
+ );
+}
+
+const BottomFlyout = styled.div`
+ height: 300px;
+`;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx
new file mode 100644
index 0000000000000..71e3317ad6db8
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx
@@ -0,0 +1,55 @@
+/*
+ * 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 { useUrlStorage } from '../hooks/use_url_strorage';
+import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges';
+
+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 { series, setSeries } = useUrlStorage(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: { from: 'now-5h', to: 'now' } });
+ }
+ }, [seriesId, series, setSeries]);
+
+ return (
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx
new file mode 100644
index 0000000000000..acc9ba9658a08
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/series_date_picker.test.tsx
@@ -0,0 +1,76 @@
+/*
+ * 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 { mockUrlStorage, mockUseHasData, render } from '../rtl_helpers';
+import { fireEvent, waitFor } from '@testing-library/react';
+import { SeriesDatePicker } from './index';
+
+describe('SeriesDatePicker', function () {
+ it('should render properly', function () {
+ mockUrlStorage({
+ data: {
+ 'uptime-pings-histogram': {
+ reportType: 'upp',
+ breakdown: 'monitor.status',
+ time: { from: 'now-30m', to: 'now' },
+ },
+ },
+ });
+ const { getByText } = render();
+
+ getByText('Last 30 minutes');
+ });
+
+ it('should set defaults', async function () {
+ const { setSeries: setSeries1 } = mockUrlStorage({
+ data: {
+ 'uptime-pings-histogram': {
+ reportType: 'upp',
+ breakdown: 'monitor.status',
+ },
+ },
+ } as any);
+ render();
+ expect(setSeries1).toHaveBeenCalledTimes(1);
+ expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', {
+ breakdown: 'monitor.status',
+ reportType: 'upp',
+ time: { from: 'now-5h', to: 'now' },
+ });
+ });
+
+ it('should set series data', async function () {
+ const { setSeries } = mockUrlStorage({
+ data: {
+ 'uptime-pings-histogram': {
+ reportType: 'upp',
+ breakdown: 'monitor.status',
+ time: { from: 'now-30m', to: 'now' },
+ },
+ },
+ });
+
+ const { onRefreshTimeRange } = mockUseHasData();
+ const { getByTestId } = render();
+
+ await waitFor(function () {
+ fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton'));
+ });
+
+ fireEvent.click(getByTestId('superDatePickerCommonlyUsed_Today'));
+
+ expect(onRefreshTimeRange).toHaveBeenCalledTimes(1);
+
+ expect(setSeries).toHaveBeenCalledWith('series-id', {
+ breakdown: 'monitor.status',
+ reportType: 'upp',
+ time: { from: 'now/d', to: 'now/d' },
+ });
+ expect(setSeries).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx
new file mode 100644
index 0000000000000..c6209381a4da1
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/actions_col.tsx
@@ -0,0 +1,31 @@
+/*
+ * 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 { DataSeries } from '../../types';
+import { SeriesChartTypes } from './chart_types';
+import { MetricSelection } from './metric_selection';
+
+interface Props {
+ series: DataSeries;
+}
+
+export function ActionsCol({ series }: Props) {
+ return (
+
+
+
+
+ {series.hasMetricType && (
+
+
+
+ )}
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx
new file mode 100644
index 0000000000000..654a93a08a7c8
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx
@@ -0,0 +1,49 @@
+/*
+ * 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 { Breakdowns } from './breakdowns';
+import { mockIndexPattern, mockUrlStorage, render } from '../../rtl_helpers';
+import { NEW_SERIES_KEY } from '../../hooks/use_url_strorage';
+import { getDefaultConfigs } from '../../configurations/default_configs';
+import { USER_AGENT_OS } from '../../configurations/data/elasticsearch_fieldnames';
+
+describe('Breakdowns', function () {
+ const dataViewSeries = getDefaultConfigs({
+ reportType: 'pld',
+ indexPattern: mockIndexPattern,
+ seriesId: NEW_SERIES_KEY,
+ });
+
+ it('should render properly', async function () {
+ mockUrlStorage({});
+
+ render();
+
+ screen.getAllByText('Browser family');
+ });
+
+ it('should call set series on change', function () {
+ const { setSeries } = mockUrlStorage({ breakdown: USER_AGENT_OS });
+
+ render();
+
+ screen.getAllByText('Operating system');
+
+ fireEvent.click(screen.getByTestId('seriesBreakdown'));
+
+ fireEvent.click(screen.getByText('Browser family'));
+
+ expect(setSeries).toHaveBeenCalledWith('series-id', {
+ breakdown: 'user_agent.name',
+ reportType: 'pld',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ expect(setSeries).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx
new file mode 100644
index 0000000000000..0d34d7245725a
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 { FieldLabels } from '../../configurations/constants';
+import { useUrlStorage } from '../../hooks/use_url_strorage';
+
+interface Props {
+ seriesId: string;
+ breakdowns: string[];
+}
+
+export function Breakdowns({ seriesId, breakdowns = [] }: Props) {
+ const { setSeries, series } = useUrlStorage(seriesId);
+
+ const selectedBreakdown = series.breakdown;
+ const NO_BREAKDOWN = 'no_breakdown';
+
+ const onOptionChange = (optionId: string) => {
+ if (optionId === NO_BREAKDOWN) {
+ setSeries(seriesId, {
+ ...series,
+ breakdown: undefined,
+ });
+ } else {
+ setSeries(seriesId, {
+ ...series,
+ breakdown: selectedBreakdown === optionId ? undefined : optionId,
+ });
+ }
+ };
+
+ const items = breakdowns.map((breakdown) => ({ id: breakdown, label: FieldLabels[breakdown] }));
+ items.push({
+ id: NO_BREAKDOWN,
+ label: i18n.translate('xpack.observability.exp.breakDownFilter.noBreakdown', {
+ defaultMessage: 'No breakdown',
+ }),
+ });
+
+ const options = items.map(({ id, label }) => ({
+ inputDisplay: id === NO_BREAKDOWN ? label : {label},
+ value: id,
+ dropdownDisplay: label,
+ }));
+
+ return (
+
+ onOptionChange(value)}
+ data-test-subj={'seriesBreakdown'}
+ />
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx
new file mode 100644
index 0000000000000..f291d0de4dac0
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+import { SeriesChartTypes, XYChartTypes } from './chart_types';
+import { mockUrlStorage, render } from '../../rtl_helpers';
+
+describe.skip('SeriesChartTypes', function () {
+ it('should render properly', async function () {
+ mockUrlStorage({});
+
+ render();
+
+ await waitFor(() => {
+ screen.getByText(/chart type/i);
+ });
+ });
+
+ it('should call set series on change', async function () {
+ const { setSeries } = mockUrlStorage({});
+
+ render();
+
+ await waitFor(() => {
+ screen.getByText(/chart type/i);
+ });
+
+ fireEvent.click(screen.getByText(/chart type/i));
+ fireEvent.click(screen.getByTestId('lnsXY_seriesType-bar_stacked'));
+
+ expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', {
+ breakdown: 'user_agent.name',
+ reportType: 'pld',
+ seriesType: 'bar_stacked',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ expect(setSeries).toHaveBeenCalledTimes(3);
+ });
+
+ describe('XYChartTypes', function () {
+ it('should render properly', async function () {
+ mockUrlStorage({});
+
+ render();
+
+ await waitFor(() => {
+ screen.getByText(/chart type/i);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx
new file mode 100644
index 0000000000000..017655053eef2
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx
@@ -0,0 +1,149 @@
+/*
+ * 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,
+ EuiButtonGroup,
+ EuiButtonIcon,
+ EuiLoadingSpinner,
+ EuiPopover,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import styled from 'styled-components';
+import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public';
+import { ObservabilityPublicPluginsStart } from '../../../../../plugin';
+import { useFetcher } from '../../../../..';
+import { useUrlStorage } from '../../hooks/use_url_strorage';
+import { SeriesType } from '../../../../../../../lens/public';
+
+export function SeriesChartTypes({
+ seriesId,
+ defaultChartType,
+}: {
+ seriesId: string;
+ defaultChartType: SeriesType;
+}) {
+ const { series, setSeries, allSeries } = useUrlStorage(seriesId);
+
+ const seriesType = series?.seriesType ?? defaultChartType;
+
+ const onChange = (value: SeriesType) => {
+ Object.keys(allSeries).forEach((seriesKey) => {
+ const seriesN = allSeries[seriesKey];
+
+ setSeries(seriesKey, { ...seriesN, seriesType: value });
+ });
+ };
+
+ return (
+
+ );
+}
+
+export interface XYChartTypesProps {
+ onChange: (value: SeriesType) => void;
+ value: SeriesType;
+ label?: string;
+ includeChartTypes?: string[];
+ excludeChartTypes?: string[];
+}
+
+export function XYChartTypes({
+ onChange,
+ value,
+ label,
+ includeChartTypes,
+ excludeChartTypes,
+}: XYChartTypesProps) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const {
+ services: { lens },
+ } = useKibana();
+
+ const { data = [], loading } = useFetcher(() => lens.getXyVisTypes(), [lens]);
+
+ let vizTypes = data ?? [];
+
+ if ((excludeChartTypes ?? []).length > 0) {
+ vizTypes = vizTypes.filter(({ id }) => !excludeChartTypes?.includes(id));
+ }
+
+ if ((includeChartTypes ?? []).length > 0) {
+ vizTypes = vizTypes.filter(({ id }) => includeChartTypes?.includes(id));
+ }
+
+ return loading ? (
+
+ ) : (
+ id === value)?.icon}
+ onClick={() => {
+ setIsOpen((prevState) => !prevState);
+ }}
+ >
+ {label}
+
+ ) : (
+ id === value)?.label}
+ iconType={vizTypes.find(({ id }) => id === value)?.icon!}
+ onClick={() => {
+ setIsOpen((prevState) => !prevState);
+ }}
+ />
+ )
+ }
+ closePopover={() => setIsOpen(false)}
+ >
+ ({
+ id: t.id,
+ label: t.label,
+ title: t.label,
+ iconType: t.icon || 'empty',
+ 'data-test-subj': `lnsXY_seriesType-${t.id}`,
+ }))}
+ idSelected={value}
+ onChange={(valueN: string) => {
+ onChange(valueN as SeriesType);
+ }}
+ />
+
+ );
+}
+
+const ButtonGroup = styled(EuiButtonGroup)`
+ &&& {
+ .euiButtonGroupButton-isSelected {
+ background-color: #a5a9b1 !important;
+ }
+ }
+`;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx
new file mode 100644
index 0000000000000..8c99de51978a7
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx
@@ -0,0 +1,20 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { SeriesDatePicker } from '../../series_date_picker';
+
+interface Props {
+ seriesId: string;
+}
+export function DatePickerCol({ seriesId }: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx
new file mode 100644
index 0000000000000..edd5546f13940
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx
@@ -0,0 +1,93 @@
+/*
+ * 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 { FilterExpanded } from './filter_expanded';
+import { mockUrlStorage, mockUseValuesList, render } from '../../rtl_helpers';
+import { USER_AGENT_NAME } from '../../configurations/data/elasticsearch_fieldnames';
+
+describe('FilterExpanded', function () {
+ it('should render properly', async function () {
+ mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
+
+ render(
+
+ );
+
+ screen.getByText('Browser Family');
+ });
+ it('should call go back on click', async function () {
+ mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
+ const goBack = jest.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Browser Family'));
+
+ expect(goBack).toHaveBeenCalledTimes(1);
+ expect(goBack).toHaveBeenCalledWith();
+ });
+
+ it('should call useValuesList on load', async function () {
+ mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
+
+ const { spy } = mockUseValuesList(['Chrome', 'Firefox']);
+
+ const goBack = jest.fn();
+
+ render(
+
+ );
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toBeCalledWith(
+ expect.objectContaining({
+ time: { from: 'now-15m', to: 'now' },
+ sourceField: USER_AGENT_NAME,
+ })
+ );
+ });
+ it('should filter display values', async function () {
+ mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
+
+ mockUseValuesList(['Chrome', 'Firefox']);
+
+ render(
+
+ );
+
+ expect(screen.queryByText('Firefox')).toBeTruthy();
+
+ fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'ch' } });
+
+ expect(screen.queryByText('Firefox')).toBeFalsy();
+ expect(screen.getByText('Chrome')).toBeTruthy();
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx
new file mode 100644
index 0000000000000..280912dd0902f
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx
@@ -0,0 +1,100 @@
+/*
+ * 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, Fragment } from 'react';
+import {
+ EuiFieldSearch,
+ EuiSpacer,
+ EuiButtonEmpty,
+ EuiLoadingSpinner,
+ EuiFilterGroup,
+} from '@elastic/eui';
+import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
+import { useUrlStorage } from '../../hooks/use_url_strorage';
+import { UrlFilter } from '../../types';
+import { FilterValueButton } from './filter_value_btn';
+import { useValuesList } from '../../../../../hooks/use_values_list';
+
+interface Props {
+ seriesId: string;
+ label: string;
+ field: string;
+ goBack: () => void;
+ nestedField?: string;
+}
+
+export function FilterExpanded({ seriesId, field, label, goBack, nestedField }: Props) {
+ const { indexPattern } = useIndexPatternContext();
+
+ const [value, setValue] = useState('');
+
+ const [isOpen, setIsOpen] = useState({ value: '', negate: false });
+
+ const { series } = useUrlStorage(seriesId);
+
+ const { values, loading } = useValuesList({
+ sourceField: field,
+ time: series.time,
+ indexPattern,
+ });
+
+ const filters = series?.filters ?? [];
+
+ const currFilter: UrlFilter | undefined = filters.find(({ field: fd }) => field === fd);
+
+ const displayValues = (values || []).filter((opt) =>
+ opt.toLowerCase().includes(value.toLowerCase())
+ );
+
+ return (
+ <>
+ goBack()}>
+ {label}
+
+ {
+ setValue(evt.target.value);
+ }}
+ />
+
+ {loading && (
+
+
+
+ )}
+ {displayValues.map((opt) => (
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx
new file mode 100644
index 0000000000000..7f76c9ea999ee
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx
@@ -0,0 +1,238 @@
+/*
+ * 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 { FilterValueButton } from './filter_value_btn';
+import { mockUrlStorage, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers';
+import {
+ USER_AGENT_NAME,
+ USER_AGENT_VERSION,
+} from '../../configurations/data/elasticsearch_fieldnames';
+
+describe('FilterValueButton', function () {
+ it('should render properly', async function () {
+ render(
+
+ );
+
+ screen.getByText('Chrome');
+ });
+
+ it('should render display negate state', async function () {
+ render(
+
+ );
+
+ screen.getByText('Not Chrome');
+ screen.getByTitle('Not Chrome');
+ const btn = screen.getByRole('button');
+ expect(btn.classList).toContain('euiButtonEmpty--danger');
+ });
+
+ it('should call set filter on click', async function () {
+ const { setFilter, removeFilter } = mockUseSeriesFilter();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Not Chrome'));
+
+ expect(removeFilter).toHaveBeenCalledTimes(0);
+ expect(setFilter).toHaveBeenCalledTimes(1);
+
+ expect(setFilter).toHaveBeenCalledWith({
+ field: 'user_agent.name',
+ negate: true,
+ value: 'Chrome',
+ });
+ });
+ it('should remove filter on click if already selected', async function () {
+ mockUrlStorage({});
+ const { removeFilter } = mockUseSeriesFilter();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Chrome'));
+
+ expect(removeFilter).toHaveBeenCalledWith({
+ field: 'user_agent.name',
+ negate: false,
+ value: 'Chrome',
+ });
+ });
+
+ it('should change filter on negated one', async function () {
+ const { removeFilter } = mockUseSeriesFilter();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Not Chrome'));
+
+ expect(removeFilter).toHaveBeenCalledWith({
+ field: 'user_agent.name',
+ negate: true,
+ value: 'Chrome',
+ });
+ });
+
+ it('should force open nested', async function () {
+ mockUseSeriesFilter();
+ const { spy } = mockUseValuesList();
+
+ render(
+
+ );
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toBeCalledWith(
+ expect.objectContaining({
+ filters: [
+ {
+ term: {
+ [USER_AGENT_NAME]: 'Chrome',
+ },
+ },
+ ],
+ sourceField: 'user_agent.version',
+ })
+ );
+ });
+ it('should set isNestedOpen on click', async function () {
+ mockUseSeriesFilter();
+ const { spy } = mockUseValuesList();
+
+ render(
+
+ );
+
+ expect(spy).toHaveBeenCalledTimes(2);
+ expect(spy).toBeCalledWith(
+ expect.objectContaining({
+ filters: [
+ {
+ term: {
+ [USER_AGENT_NAME]: 'Chrome',
+ },
+ },
+ ],
+ sourceField: USER_AGENT_VERSION,
+ })
+ );
+ });
+
+ it('should set call setIsNestedOpen on click selected', async function () {
+ mockUseSeriesFilter();
+ mockUseValuesList();
+
+ const setIsNestedOpen = jest.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Chrome'));
+
+ expect(setIsNestedOpen).toHaveBeenCalledTimes(1);
+ expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: false, value: '' });
+ });
+
+ it('should set call setIsNestedOpen on click not selected', async function () {
+ mockUseSeriesFilter();
+ mockUseValuesList();
+
+ const setIsNestedOpen = jest.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Not Chrome'));
+
+ expect(setIsNestedOpen).toHaveBeenCalledTimes(1);
+ expect(setIsNestedOpen).toHaveBeenCalledWith({ negate: true, value: 'Chrome' });
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx
new file mode 100644
index 0000000000000..42cdfd595e66b
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx
@@ -0,0 +1,117 @@
+/*
+ * 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, { useMemo } from 'react';
+import { EuiFilterButton, hexToRgb } from '@elastic/eui';
+import { useIndexPatternContext } from '../../hooks/use_default_index_pattern';
+import { useUrlStorage } from '../../hooks/use_url_strorage';
+import { useSeriesFilters } from '../../hooks/use_series_filters';
+import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common';
+import FieldValueSuggestions from '../../../field_value_suggestions';
+
+interface Props {
+ value: string;
+ field: string;
+ allSelectedValues?: string[];
+ negate: boolean;
+ nestedField?: string;
+ seriesId: string;
+ isNestedOpen: {
+ value: string;
+ negate: boolean;
+ };
+ setIsNestedOpen: (val: { value: string; negate: boolean }) => void;
+}
+
+export function FilterValueButton({
+ isNestedOpen,
+ setIsNestedOpen,
+ value,
+ field,
+ negate,
+ seriesId,
+ nestedField,
+ allSelectedValues,
+}: Props) {
+ const { series } = useUrlStorage(seriesId);
+
+ const { indexPattern } = useIndexPatternContext();
+
+ const { setFilter, removeFilter } = useSeriesFilters({ seriesId });
+
+ const hasActiveFilters = (allSelectedValues ?? []).includes(value);
+
+ const button = (
+ {
+ if (hasActiveFilters) {
+ removeFilter({ field, value, negate });
+ } else {
+ setFilter({ field, value, negate });
+ }
+ if (!hasActiveFilters) {
+ setIsNestedOpen({ value, negate });
+ } else {
+ setIsNestedOpen({ value: '', negate });
+ }
+ }}
+ >
+ {negate
+ ? i18n.translate('xpack.observability.expView.filterValueButton.negate', {
+ defaultMessage: 'Not {value}',
+ values: { value },
+ })
+ : value}
+
+ );
+
+ const onNestedChange = (val?: string) => {
+ setFilter({ field: nestedField!, value: val! });
+ setIsNestedOpen({ value: '', negate });
+ };
+
+ const forceOpenNested = isNestedOpen?.value === value && isNestedOpen.negate === negate;
+
+ const filters = useMemo(() => {
+ return [
+ {
+ term: {
+ [field]: value,
+ },
+ },
+ ];
+ }, [field, value]);
+
+ return nestedField && forceOpenNested ? (
+
+ ) : (
+ button
+ );
+}
+
+const FilterButton = euiStyled(EuiFilterButton)`
+ background-color: rgba(${(props) => {
+ const color = props.hasActiveFilters
+ ? props.color === 'danger'
+ ? hexToRgb(props.theme.eui.euiColorDanger)
+ : hexToRgb(props.theme.eui.euiColorPrimary)
+ : 'initial';
+ return `${color[0]}, ${color[1]}, ${color[2]}, 0.1`;
+ }});
+`;
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.test.tsx
new file mode 100644
index 0000000000000..ced04f0a59c8c
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.test.tsx
@@ -0,0 +1,112 @@
+/*
+ * 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 { mockUrlStorage, render } from '../../rtl_helpers';
+import { MetricSelection } from './metric_selection';
+
+describe('MetricSelection', function () {
+ it('should render properly', function () {
+ render();
+
+ screen.getByText('Average');
+ });
+
+ it('should display selected value', function () {
+ mockUrlStorage({
+ data: {
+ 'performance-distribution': {
+ reportType: 'kpi',
+ metric: 'median',
+ time: { from: 'now-15m', to: 'now' },
+ },
+ },
+ });
+
+ render();
+
+ screen.getByText('Median');
+ });
+
+ it('should be disabled on disabled state', function () {
+ render();
+
+ const btn = screen.getByRole('button');
+
+ expect(btn.classList).toContain('euiButton-isDisabled');
+ });
+
+ it('should call set series on change', function () {
+ const { setSeries } = mockUrlStorage({
+ data: {
+ 'performance-distribution': {
+ reportType: 'kpi',
+ metric: 'median',
+ time: { from: 'now-15m', to: 'now' },
+ },
+ },
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Median'));
+
+ screen.getByText('Chart metric group');
+
+ fireEvent.click(screen.getByText('95th Percentile'));
+
+ expect(setSeries).toHaveBeenNthCalledWith(1, 'performance-distribution', {
+ metric: '95th',
+ reportType: 'kpi',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ // FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times
+ // This should be one https://github.com/elastic/eui/issues/4629
+ expect(setSeries).toHaveBeenCalledTimes(3);
+ });
+
+ it('should call set series on change for all series', function () {
+ const { setSeries } = mockUrlStorage({
+ data: {
+ 'page-views': {
+ reportType: 'kpi',
+ metric: 'median',
+ time: { from: 'now-15m', to: 'now' },
+ },
+ 'performance-distribution': {
+ reportType: 'kpi',
+ metric: 'median',
+ time: { from: 'now-15m', to: 'now' },
+ },
+ },
+ });
+
+ render();
+
+ fireEvent.click(screen.getByText('Median'));
+
+ screen.getByText('Chart metric group');
+
+ fireEvent.click(screen.getByText('95th Percentile'));
+
+ expect(setSeries).toHaveBeenNthCalledWith(1, 'page-views', {
+ metric: '95th',
+ reportType: 'kpi',
+ time: { from: 'now-15m', to: 'now' },
+ });
+
+ expect(setSeries).toHaveBeenNthCalledWith(2, 'performance-distribution', {
+ metric: '95th',
+ reportType: 'kpi',
+ time: { from: 'now-15m', to: 'now' },
+ });
+ // FIXME This is a bug in EUI EuiButtonGroup calls on change multiple times
+ // This should be one https://github.com/elastic/eui/issues/4629
+ expect(setSeries).toHaveBeenCalledTimes(6);
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.tsx
new file mode 100644
index 0000000000000..e01e371b5eeeb
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/metric_selection.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import { EuiButton, EuiButtonGroup, EuiPopover } from '@elastic/eui';
+import { useUrlStorage } from '../../hooks/use_url_strorage';
+import { OperationType } from '../../../../../../../lens/public';
+
+const toggleButtons = [
+ {
+ id: `avg`,
+ label: i18n.translate('xpack.observability.expView.metricsSelect.average', {
+ defaultMessage: 'Average',
+ }),
+ },
+ {
+ id: `median`,
+ label: i18n.translate('xpack.observability.expView.metricsSelect.median', {
+ defaultMessage: 'Median',
+ }),
+ },
+ {
+ id: `95th`,
+ label: i18n.translate('xpack.observability.expView.metricsSelect.9thPercentile', {
+ defaultMessage: '95th Percentile',
+ }),
+ },
+ {
+ id: `99th`,
+ label: i18n.translate('xpack.observability.expView.metricsSelect.99thPercentile', {
+ defaultMessage: '99th Percentile',
+ }),
+ },
+];
+
+export function MetricSelection({
+ seriesId,
+ isDisabled,
+}: {
+ seriesId: string;
+ isDisabled: boolean;
+}) {
+ const { series, setSeries, allSeries } = useUrlStorage(seriesId);
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ const [toggleIdSelected, setToggleIdSelected] = useState(series?.metric ?? 'avg');
+
+ const onChange = (optionId: OperationType) => {
+ setToggleIdSelected(optionId);
+
+ Object.keys(allSeries).forEach((seriesKey) => {
+ const seriesN = allSeries[seriesKey];
+
+ setSeries(seriesKey, { ...seriesN, metric: optionId });
+ });
+ };
+ const button = (
+ setIsOpen((prevState) => !prevState)}
+ size="s"
+ color="text"
+ isDisabled={isDisabled}
+ >
+ {toggleButtons.find(({ id }) => id === toggleIdSelected)!.label}
+
+ );
+
+ return (
+ setIsOpen(false)}>
+ onChange(id as OperationType)}
+ />
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx
new file mode 100644
index 0000000000000..67aebed943326
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/remove_series.tsx
@@ -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 { i18n } from '@kbn/i18n';
+import React from 'react';
+import { EuiButtonIcon } from '@elastic/eui';
+import { DataSeries } from '../../types';
+import { useUrlStorage } from '../../hooks/use_url_strorage';
+
+interface Props {
+ series: DataSeries;
+}
+
+export function RemoveSeries({ series }: Props) {
+ const { removeSeries } = useUrlStorage();
+
+ const onClick = () => {
+ removeSeries(series.id);
+ };
+ return (
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx
new file mode 100644
index 0000000000000..24b65d2adb38e
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx
@@ -0,0 +1,139 @@
+/*
+ * 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 { DataSeries } from '../../types';
+import { FieldLabels } from '../../configurations/constants';
+import { SelectedFilters } from '../selected_filters';
+import { NEW_SERIES_KEY, useUrlStorage } from '../../hooks/use_url_strorage';
+
+interface Props {
+ seriesId: string;
+ defaultFilters: DataSeries['defaultFilters'];
+ series: DataSeries;
+ isNew?: boolean;
+}
+
+export interface Field {
+ label: string;
+ field: string;
+ nested?: string;
+}
+
+export function SeriesFilter({ series, isNew, seriesId, defaultFilters = [] }: Props) {
+ const [isPopoverVisible, setIsPopoverVisible] = useState(false);
+
+ const [selectedField, setSelectedField] = useState();
+
+ const options = defaultFilters.map((field) => {
+ if (typeof field === 'string') {
+ return { label: FieldLabels[field], field };
+ }
+ return { label: FieldLabels[field.field], field: field.field, nested: field.nested };
+ });
+ const disabled = seriesId === NEW_SERIES_KEY && !isNew;
+
+ const { setSeries, series: urlSeries } = useUrlStorage(seriesId);
+
+ const button = (
+ {
+ setIsPopoverVisible(true);
+ }}
+ isDisabled={disabled}
+ size="s"
+ >
+ {i18n.translate('xpack.observability.expView.seriesEditor.addFilter', {
+ defaultMessage: 'Add filter',
+ })}
+
+ );
+
+ const mainPanel = (
+ <>
+
+ {options.map((opt) => (
+
+ {
+ setSelectedField(opt);
+ }}
+ >
+ {opt.label}
+
+
+
+ ))}
+ >
+ );
+
+ const childPanel = selectedField ? (
+ {
+ setSelectedField(undefined);
+ }}
+ />
+ ) : null;
+
+ const closePopover = () => {
+ setIsPopoverVisible(false);
+ setSelectedField(undefined);
+ };
+
+ return (
+
+ {!disabled && }
+
+
+ {!selectedField ? mainPanel : childPanel}
+
+
+ {(urlSeries.filters ?? []).length > 0 && (
+
+ {
+ setSeries(seriesId, { ...urlSeries, filters: undefined });
+ }}
+ isDisabled={disabled}
+ size="s"
+ >
+ {i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', {
+ defaultMessage: 'Clear filters',
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx
new file mode 100644
index 0000000000000..5770a7e209f06
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx
@@ -0,0 +1,33 @@
+/*
+ * 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, waitFor } from '@testing-library/react';
+import { mockIndexPattern, mockUrlStorage, render } from '../rtl_helpers';
+import { SelectedFilters } from './selected_filters';
+import { getDefaultConfigs } from '../configurations/default_configs';
+import { NEW_SERIES_KEY } from '../hooks/use_url_strorage';
+import { USER_AGENT_NAME } from '../configurations/data/elasticsearch_fieldnames';
+
+describe('SelectedFilters', function () {
+ const dataViewSeries = getDefaultConfigs({
+ reportType: 'pld',
+ indexPattern: mockIndexPattern,
+ seriesId: NEW_SERIES_KEY,
+ });
+
+ it('should render properly', async function () {
+ mockUrlStorage({ filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] });
+
+ render();
+
+ await waitFor(() => {
+ screen.getByText('Chrome');
+ screen.getByTitle('Filter: Browser family: Chrome. Select for more filter actions.');
+ });
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx
new file mode 100644
index 0000000000000..be8b1feb4d723
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 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 { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
+import { FilterLabel } from '../components/filter_label';
+import { DataSeries, UrlFilter } from '../types';
+import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
+import { useSeriesFilters } from '../hooks/use_series_filters';
+import { getFiltersFromDefs } from '../hooks/use_lens_attributes';
+
+interface Props {
+ seriesId: string;
+ series: DataSeries;
+ isNew?: boolean;
+}
+export function SelectedFilters({ seriesId, isNew, series: dataSeries }: Props) {
+ const { series } = useUrlStorage(seriesId);
+
+ const { reportDefinitions = {} } = series;
+
+ const { labels } = dataSeries;
+
+ const filters: UrlFilter[] = series.filters ?? [];
+
+ let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions, dataSeries);
+
+ // we don't want to display report definition filters in new series view
+ if (seriesId === NEW_SERIES_KEY && isNew) {
+ definitionFilters = [];
+ }
+
+ const { removeFilter } = useSeriesFilters({ seriesId });
+
+ const { indexPattern } = useIndexPatternContext();
+
+ return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? (
+
+
+ {filters.map(({ field, values, notValues }) => (
+
+ {(values ?? []).map((val) => (
+
+ removeFilter({ field, value: val, negate: false })}
+ negate={false}
+ />
+
+ ))}
+ {(notValues ?? []).map((val) => (
+
+ removeFilter({ field, value: val, negate: true })}
+ />
+
+ ))}
+
+ ))}
+
+ {definitionFilters.map(({ field, values }) => (
+
+ {(values ?? []).map((val) => (
+
+ {
+ // FIXME handle this use case
+ }}
+ negate={false}
+ definitionFilter={true}
+ />
+
+ ))}
+
+ ))}
+
+
+ ) : null;
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx
new file mode 100644
index 0000000000000..2d423c9aee3fc
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx
@@ -0,0 +1,139 @@
+/*
+ * 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 { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
+import { SeriesFilter } from './columns/series_filter';
+import { ActionsCol } from './columns/actions_col';
+import { Breakdowns } from './columns/breakdowns';
+import { DataSeries } from '../types';
+import { SeriesBuilder } from '../series_builder/series_builder';
+import { NEW_SERIES_KEY, useUrlStorage } from '../hooks/use_url_strorage';
+import { getDefaultConfigs } from '../configurations/default_configs';
+import { DatePickerCol } from './columns/date_picker_col';
+import { RemoveSeries } from './columns/remove_series';
+import { useIndexPatternContext } from '../hooks/use_default_index_pattern';
+
+export function SeriesEditor() {
+ const { allSeries, firstSeriesId } = useUrlStorage();
+
+ const columns = [
+ {
+ name: i18n.translate('xpack.observability.expView.seriesEditor.name', {
+ defaultMessage: 'Name',
+ }),
+ field: 'id',
+ width: '15%',
+ render: (val: string) => (
+
+ {' '}
+ {val === NEW_SERIES_KEY ? 'new-series-preview' : val}
+
+ ),
+ },
+ ...(firstSeriesId !== NEW_SERIES_KEY
+ ? [
+ {
+ name: i18n.translate('xpack.observability.expView.seriesEditor.filters', {
+ defaultMessage: 'Filters',
+ }),
+ field: 'defaultFilters',
+ width: '25%',
+ render: (defaultFilters: string[], series: DataSeries) => (
+
+ ),
+ },
+ {
+ name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', {
+ defaultMessage: 'Breakdowns',
+ }),
+ field: 'breakdowns',
+ width: '15%',
+ render: (val: string[], item: DataSeries) => (
+
+ ),
+ },
+ {
+ name: '',
+ align: 'center' as const,
+ width: '15%',
+ field: 'id',
+ render: (val: string, item: DataSeries) => ,
+ },
+ ]
+ : []),
+ {
+ name: (
+
+ {i18n.translate('xpack.observability.expView.seriesEditor.time', {
+ defaultMessage: 'Time',
+ })}
+
+ ),
+ width: '20%',
+ field: 'id',
+ align: 'right' as const,
+ render: (val: string, item: DataSeries) => ,
+ },
+
+ ...(firstSeriesId !== NEW_SERIES_KEY
+ ? [
+ {
+ name: i18n.translate('xpack.observability.expView.seriesEditor.actions', {
+ defaultMessage: 'Actions',
+ }),
+ align: 'center' as const,
+ width: '5%',
+ field: 'id',
+ render: (val: string, item: DataSeries) => ,
+ },
+ ]
+ : []),
+ ];
+
+ const allSeriesKeys = Object.keys(allSeries);
+
+ const items: DataSeries[] = [];
+
+ const { indexPattern } = useIndexPatternContext();
+
+ allSeriesKeys.forEach((seriesKey) => {
+ const series = allSeries[seriesKey];
+ if (series.reportType && indexPattern) {
+ items.push(
+ getDefaultConfigs({
+ indexPattern,
+ reportType: series.reportType,
+ seriesId: seriesKey,
+ })
+ );
+ }
+ });
+
+ return (
+ <>
+
+ (firstSeriesId === NEW_SERIES_KEY ? {} : { height: 100 })}
+ noItemsMessage={i18n.translate('xpack.observability.expView.seriesEditor.notFound', {
+ defaultMessage: 'No series found, please add a series.',
+ })}
+ cellProps={{
+ style: {
+ verticalAlign: 'top',
+ },
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts
new file mode 100644
index 0000000000000..444e0ddaecb4a
--- /dev/null
+++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts
@@ -0,0 +1,89 @@
+/*
+ * 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 { PaletteOutput } from 'src/plugins/charts/public';
+import {
+ LastValueIndexPatternColumn,
+ DateHistogramIndexPatternColumn,
+ SeriesType,
+ OperationType,
+ IndexPatternColumn,
+} from '../../../../../lens/public';
+
+import { PersistableFilter } from '../../../../../lens/common';
+import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns';
+
+export const ReportViewTypes = {
+ pld: 'page-load-dist',
+ kpi: 'kpi-trends',
+ upd: 'uptime-duration',
+ upp: 'uptime-pings',
+ svl: 'service-latency',
+ tpt: 'service-throughput',
+ logs: 'logs-frequency',
+ cpu: 'cpu-usage',
+ mem: 'memory-usage',
+ nwk: 'network-activity',
+} as const;
+
+type ValueOf = T[keyof T];
+
+export type ReportViewTypeId = keyof typeof ReportViewTypes;
+
+export type ReportViewType = ValueOf;
+
+export interface ReportDefinition {
+ field: string;
+ required?: boolean;
+ custom?: boolean;
+ defaultValue?: string;
+ options?: Array<{ field: string; label: string; description?: string }>;
+}
+
+export interface DataSeries {
+ reportType: ReportViewType;
+ id: string;
+ xAxisColumn: Partial | Partial;
+ yAxisColumn: Partial;
+
+ breakdowns: string[];
+ defaultSeriesType: SeriesType;
+ defaultFilters: Array;
+ seriesTypes: SeriesType[];
+ filters?: PersistableFilter[];
+ reportDefinitions: ReportDefinition[];
+ labels: Record;
+ hasMetricType: boolean;
+ palette?: PaletteOutput;
+}
+
+export interface SeriesUrl {
+ time: {
+ to: string;
+ from: string;
+ };
+ breakdown?: string;
+ filters?: UrlFilter[];
+ seriesType?: SeriesType;
+ reportType: ReportViewTypeId;
+ metric?: OperationType;
+ dataType?: AppDataType;
+ reportDefinitions?: Record;
+}
+
+export interface UrlFilter {
+ field: string;
+ values?: string[];
+ notValues?: string[];
+}
+
+export interface ConfigProps {
+ seriesId: string;
+ indexPattern: IIndexPattern;
+}
+
+export type AppDataType = 'synthetics' | 'rum' | 'logs' | 'metrics' | 'apm';
diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx
index b2c682dc58937..a44aab2da85be 100644
--- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx
+++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx
@@ -15,14 +15,19 @@ import {
EuiSelectableOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
+import { PopoverAnchorPosition } from '@elastic/eui/src/components/popover/popover';
export interface FieldValueSelectionProps {
value?: string;
label: string;
- loading: boolean;
+ loading?: boolean;
onChange: (val?: string) => void;
values?: string[];
setQuery: Dispatch>;
+ anchorPosition?: PopoverAnchorPosition;
+ forceOpen?: boolean;
+ button?: JSX.Element;
+ width?: number;
}
const formatOptions = (values?: string[], value?: string): EuiSelectableOption[] => {
@@ -38,6 +43,10 @@ export function FieldValueSelection({
loading,
values,
setQuery,
+ button,
+ width,
+ forceOpen,
+ anchorPosition,
onChange: onSelectionChange,
}: FieldValueSelectionProps) {
const [options, setOptions] = useState(formatOptions(values, value));
@@ -63,8 +72,9 @@ export function FieldValueSelection({
setQuery((evt.target as HTMLInputElement).value);
};
- const button = (
+ const anchorButton = (
void;
+ filters: ESFilter[];
+ anchorPosition?: PopoverAnchorPosition;
+ time?: { from: string; to: string };
+ forceOpen?: boolean;
+ button?: JSX.Element;
+ width?: number;
}
export function FieldValueSuggestions({
@@ -25,12 +33,18 @@ export function FieldValueSuggestions({
label,
indexPattern,
value,
+ filters,
+ button,
+ time,
+ width,
+ forceOpen,
+ anchorPosition,
onChange: onSelectionChange,
}: FieldValueSuggestionsProps) {
const [query, setQuery] = useState('');
const [debouncedValue, setDebouncedValue] = useState('');
- const { values, loading } = useValuesList({ indexPattern, query, sourceField });
+ const { values, loading } = useValuesList({ indexPattern, query, sourceField, filters, time });
useDebounce(
() => {
@@ -48,6 +62,10 @@ export function FieldValueSuggestions({
setQuery={setDebouncedValue}
loading={loading}
value={value}
+ button={button}
+ forceOpen={forceOpen}
+ anchorPosition={anchorPosition}
+ width={width}
/>
);
}
diff --git a/x-pack/plugins/observability/public/context/has_data_context.test.tsx b/x-pack/plugins/observability/public/context/has_data_context.test.tsx
index 5e48860a9b049..01655c0d7b2d7 100644
--- a/x-pack/plugins/observability/public/context/has_data_context.test.tsx
+++ b/x-pack/plugins/observability/public/context/has_data_context.test.tsx
@@ -17,12 +17,19 @@ import { HasData, ObservabilityFetchDataPlugins } from '../typings/fetch_overvie
import { HasDataContextProvider } from './has_data_context';
import * as pluginContext from '../hooks/use_plugin_context';
import { PluginContextValue } from './plugin_context';
+import { Router } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
const relativeStart = '2020-10-08T06:00:00.000Z';
const relativeEnd = '2020-10-08T07:00:00.000Z';
function wrapper({ children }: { children: React.ReactElement }) {
- return {children};
+ const history = createMemoryHistory();
+ return (
+
+ {children}
+
+ );
}
function unregisterAll() {
diff --git a/x-pack/plugins/observability/public/context/has_data_context.tsx b/x-pack/plugins/observability/public/context/has_data_context.tsx
index 085b7fd7ba028..a2628d37828a4 100644
--- a/x-pack/plugins/observability/public/context/has_data_context.tsx
+++ b/x-pack/plugins/observability/public/context/has_data_context.tsx
@@ -7,6 +7,7 @@
import { uniqueId } from 'lodash';
import React, { createContext, useEffect, useState } from 'react';
+import { useRouteMatch } from 'react-router-dom';
import { Alert } from '../../../alerting/common';
import { getDataHandler } from '../data_handler';
import { FETCH_STATUS } from '../hooks/use_fetcher';
@@ -41,35 +42,38 @@ export function HasDataContextProvider({ children }: { children: React.ReactNode
const [hasData, setHasData] = useState({});
+ const isExploratoryView = useRouteMatch('/exploratory-view');
+
useEffect(
() => {
- apps.forEach(async (app) => {
- try {
- if (app !== 'alert') {
- const params =
- app === 'ux'
- ? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
- : undefined;
-
- const result = await getDataHandler(app)?.hasData(params);
+ if (!isExploratoryView)
+ apps.forEach(async (app) => {
+ try {
+ if (app !== 'alert') {
+ const params =
+ app === 'ux'
+ ? { absoluteTime: { start: absoluteStart, end: absoluteEnd } }
+ : undefined;
+
+ const result = await getDataHandler(app)?.hasData(params);
+ setHasData((prevState) => ({
+ ...prevState,
+ [app]: {
+ hasData: result,
+ status: FETCH_STATUS.SUCCESS,
+ },
+ }));
+ }
+ } catch (e) {
setHasData((prevState) => ({
...prevState,
[app]: {
- hasData: result,
- status: FETCH_STATUS.SUCCESS,
+ hasData: undefined,
+ status: FETCH_STATUS.FAILURE,
},
}));
}
- } catch (e) {
- setHasData((prevState) => ({
- ...prevState,
- [app]: {
- hasData: undefined,
- status: FETCH_STATUS.FAILURE,
- },
- }));
- }
- });
+ });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
diff --git a/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts b/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts
new file mode 100644
index 0000000000000..a354ac8a07f05
--- /dev/null
+++ b/x-pack/plugins/observability/public/hooks/use_breadcrumbs.ts
@@ -0,0 +1,71 @@
+/*
+ * 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 { ChromeBreadcrumb } from 'kibana/public';
+import { i18n } from '@kbn/i18n';
+import { MouseEvent, useEffect } from 'react';
+import { EuiBreadcrumb } from '@elastic/eui';
+import { stringify } from 'query-string';
+import { useKibana } from '../../../../../src/plugins/kibana_react/public';
+import { useQueryParams } from './use_query_params';
+
+const EMPTY_QUERY = '?';
+
+function handleBreadcrumbClick(
+ breadcrumbs: ChromeBreadcrumb[],
+ navigateToHref?: (url: string) => Promise
+) {
+ return breadcrumbs.map((bc) => ({
+ ...bc,
+ ...(bc.href
+ ? {
+ onClick: (event: MouseEvent) => {
+ if (navigateToHref && bc.href) {
+ event.preventDefault();
+ navigateToHref(bc.href);
+ }
+ },
+ }
+ : {}),
+ }));
+}
+
+export const makeBaseBreadcrumb = (href: string, params?: any): EuiBreadcrumb => {
+ if (params) {
+ const crumbParams = { ...params };
+
+ delete crumbParams.statusFilter;
+ const query = stringify(crumbParams, { skipEmptyString: true, skipNull: true });
+ href += query === EMPTY_QUERY ? '' : query;
+ }
+ return {
+ text: i18n.translate('xpack.observability.breadcrumbs.observability', {
+ defaultMessage: 'Observability',
+ }),
+ href,
+ };
+};
+
+export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => {
+ const params = useQueryParams();
+
+ const {
+ services: { chrome, application },
+ } = useKibana();
+
+ const setBreadcrumbs = chrome?.setBreadcrumbs;
+ const appPath = application?.getUrlForApp('observability-overview') ?? '';
+ const navigate = application?.navigateToUrl;
+
+ useEffect(() => {
+ if (setBreadcrumbs) {
+ setBreadcrumbs(
+ handleBreadcrumbClick([makeBaseBreadcrumb(appPath, params)].concat(extraCrumbs), navigate)
+ );
+ }
+ }, [appPath, extraCrumbs, navigate, params, setBreadcrumbs]);
+};
diff --git a/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx b/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx
new file mode 100644
index 0000000000000..82a0fc39b8519
--- /dev/null
+++ b/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx
@@ -0,0 +1,22 @@
+/*
+ * 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 { useUiSetting } from '../../../../../src/plugins/kibana_react/public';
+import { UI_SETTINGS } from '../../../../../src/plugins/data/common';
+import { TimePickerQuickRange } from '../components/shared/exploratory_view/series_date_picker';
+
+export function useQuickTimeRanges() {
+ const timePickerQuickRanges = useUiSetting(
+ UI_SETTINGS.TIMEPICKER_QUICK_RANGES
+ );
+
+ return timePickerQuickRanges.map(({ from, to, display }) => ({
+ start: from,
+ end: to,
+ label: display,
+ }));
+}
diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts
index 25a12ab4a9ebd..e17f515ed6cb9 100644
--- a/x-pack/plugins/observability/public/hooks/use_values_list.ts
+++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts
@@ -5,32 +5,58 @@
* 2.0.
*/
-import { IIndexPattern } from '../../../../../src/plugins/data/common';
+import { IndexPattern } from '../../../../../src/plugins/data/common';
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
+import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { useFetcher } from './use_fetcher';
import { ESFilter } from '../../../../../typings/elasticsearch';
-import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
-interface Props {
+export interface Props {
sourceField: string;
query?: string;
- indexPattern: IIndexPattern;
+ indexPattern: IndexPattern;
filters?: ESFilter[];
+ time?: { from: string; to: string };
}
-export const useValuesList = ({ sourceField, indexPattern, query, filters }: Props) => {
+export const useValuesList = ({
+ sourceField,
+ indexPattern,
+ query = '',
+ filters,
+ time,
+}: Props): { values: string[]; loading?: boolean } => {
const {
services: { data },
} = useKibana<{ data: DataPublicPluginStart }>();
- const { data: values, status } = useFetcher(() => {
+ const { from, to } = time ?? {};
+
+ const { data: values, loading } = useFetcher(() => {
+ if (!sourceField || !indexPattern) {
+ return [];
+ }
return data.autocomplete.getValueSuggestions({
indexPattern,
query: query || '',
- field: indexPattern.fields.find(({ name }) => name === sourceField)!,
- boolFilter: filters ?? [],
+ useTimeRange: !(from && to),
+ field: indexPattern.getFieldByName(sourceField)!,
+ boolFilter:
+ from && to
+ ? [
+ ...(filters || []),
+ {
+ range: {
+ '@timestamp': {
+ gte: from,
+ lte: to,
+ },
+ },
+ },
+ ]
+ : filters || [],
});
- }, [sourceField, query, data.autocomplete, indexPattern, filters]);
+ }, [query, sourceField, data.autocomplete, indexPattern, from, to, filters]);
- return { values, loading: status === 'loading' || status === 'pending' };
+ return { values: values as string[], loading };
};
diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts
index 35443ca090077..837404d273ee4 100644
--- a/x-pack/plugins/observability/public/index.ts
+++ b/x-pack/plugins/observability/public/index.ts
@@ -55,3 +55,4 @@ export * from './typings';
export { useChartTheme } from './hooks/use_chart_theme';
export { useTheme } from './hooks/use_theme';
export { getApmTraceUrl } from './utils/get_apm_trace_url';
+export { createExploratoryViewUrl } from './components/shared/exploratory_view/configurations/utils';
diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx
index 20817901dab82..49cc55832dcf2 100644
--- a/x-pack/plugins/observability/public/routes/index.tsx
+++ b/x-pack/plugins/observability/public/routes/index.tsx
@@ -14,6 +14,7 @@ import { OverviewPage } from '../pages/overview';
import { jsonRt } from './json_rt';
import { AlertsPage } from '../pages/alerts';
import { CasesPage } from '../pages/cases';
+import { ExploratoryViewPage } from '../components/shared/exploratory_view';
export type RouteParams = DecodeParams;
@@ -115,4 +116,24 @@ export const routes = {
},
],
},
+ '/exploratory-view': {
+ handler: () => {
+ return ;
+ },
+ params: {
+ query: t.partial({
+ rangeFrom: t.string,
+ rangeTo: t.string,
+ refreshPaused: jsonRt.pipe(t.boolean),
+ refreshInterval: jsonRt.pipe(t.number),
+ }),
+ },
+ breadcrumb: [
+ {
+ text: i18n.translate('xpack.observability.overview.exploratoryView', {
+ defaultMessage: 'Exploratory view',
+ }),
+ },
+ ],
+ },
};
diff --git a/x-pack/plugins/observability/public/utils/observability_index_patterns.ts b/x-pack/plugins/observability/public/utils/observability_index_patterns.ts
new file mode 100644
index 0000000000000..b23a246105544
--- /dev/null
+++ b/x-pack/plugins/observability/public/utils/observability_index_patterns.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { DataPublicPluginStart, IndexPattern } from '../../../../../src/plugins/data/public';
+
+export type DataType = 'synthetics' | 'apm' | 'logs' | 'metrics' | 'rum';
+
+const indexPatternList: Record = {
+ synthetics: 'synthetics_static_index_pattern_id',
+ apm: 'apm_static_index_pattern_id',
+ rum: 'apm_static_index_pattern_id',
+ logs: 'logs_static_index_pattern_id',
+ metrics: 'metrics_static_index_pattern_id',
+};
+
+const appToPatternMap: Record = {
+ synthetics: 'heartbeat-*',
+ apm: 'apm-*',
+ rum: 'apm-*',
+ logs: 'logs-*,filebeat-*',
+ metrics: 'metrics-*,metricbeat-*',
+};
+
+export class ObservabilityIndexPatterns {
+ data?: DataPublicPluginStart;
+
+ constructor(data: DataPublicPluginStart) {
+ this.data = data;
+ }
+
+ async createIndexPattern(app: DataType) {
+ if (!this.data) {
+ throw new Error('data is not defined');
+ }
+
+ const pattern = appToPatternMap[app];
+
+ const fields = await this.data.indexPatterns.getFieldsForWildcard({
+ pattern,
+ });
+
+ return await this.data.indexPatterns.createAndSave({
+ fields,
+ title: pattern,
+ id: indexPatternList[app],
+ timeFieldName: '@timestamp',
+ });
+ }
+
+ async getIndexPattern(app: DataType): Promise {
+ if (!this.data) {
+ throw new Error('data is not defined');
+ }
+ try {
+ return await this.data?.indexPatterns.get(indexPatternList[app]);
+ } catch (e) {
+ return await this.createIndexPattern(app || 'apm');
+ }
+ }
+}
diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json
index 083c35a26c20b..cc6e298795e4a 100644
--- a/x-pack/plugins/observability/tsconfig.json
+++ b/x-pack/plugins/observability/tsconfig.json
@@ -7,7 +7,14 @@
"declaration": true,
"declarationMap": true
},
- "include": ["common/**/*", "public/**/*", "server/**/*", "typings/**/*"],
+ "include": [
+ "common/**/*",
+ "public/**/*",
+ "public/**/*.json",
+ "server/**/*",
+ "typings/**/*",
+ "../../../typings/**/*"
+ ],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },
diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts
index e3457884594a9..7ea6b72547386 100644
--- a/x-pack/plugins/uptime/public/apps/plugin.ts
+++ b/x-pack/plugins/uptime/public/apps/plugin.ts
@@ -68,18 +68,21 @@ export class UptimePlugin
return UptimeDataHelper(coreStart);
};
- plugins.observability.dashboard.register({
- appName: 'uptime',
- hasData: async () => {
- const dataHelper = await getUptimeDataHelper();
- const status = await dataHelper.indexStatus();
- return status.docCount > 0;
- },
- fetchData: async (params: FetchDataParams) => {
- const dataHelper = await getUptimeDataHelper();
- return await dataHelper.overviewData(params);
- },
- });
+
+ if (plugins.observability) {
+ plugins.observability.dashboard.register({
+ appName: 'uptime',
+ hasData: async () => {
+ const dataHelper = await getUptimeDataHelper();
+ const status = await dataHelper.indexStatus();
+ return status.docCount > 0;
+ },
+ fetchData: async (params: FetchDataParams) => {
+ const dataHelper = await getUptimeDataHelper();
+ return await dataHelper.overviewData(params);
+ },
+ });
+ }
core.application.register({
id: PLUGIN.ID,