diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 440ff3ce2b12c..5cd1458028626 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -23,7 +23,7 @@ pageLoadAssetSize: dataViewEditor: 12000 dataViewFieldEditor: 27000 dataViewManagement: 5000 - dataViews: 44532 + dataViews: 46532 dataVisualizer: 27530 devTools: 38637 discover: 99999 diff --git a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx index c9baa374ed1de..08a13c7f43d48 100644 --- a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx +++ b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx @@ -10,7 +10,12 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import memoizeOne from 'memoize-one'; -import { DataViewField } from '@kbn/data-views-plugin/public'; +import { + DataViewField, + DataViewsPublicPluginStart, + INDEX_PATTERN_TYPE, + MatchedItem, +} from '@kbn/data-views-plugin/public'; import { DataView, @@ -23,16 +28,14 @@ import { UseField, } from '../shared_imports'; -import { ensureMinimumTime, getIndices, extractTimeFields, getMatchedIndices } from '../lib'; +import { ensureMinimumTime, extractTimeFields, getMatchedIndices } from '../lib'; import { FlyoutPanels } from './flyout_panels'; import { removeSpaces } from '../lib'; import { - MatchedItem, DataViewEditorContext, RollupIndicesCapsResponse, - INDEX_PATTERN_TYPE, IndexPatternConfig, MatchedIndicesSet, FormInternal, @@ -176,18 +179,19 @@ const IndexPatternEditorFlyoutContentComponent = ({ // load all data sources and set initial matchedIndices const loadSources = useCallback(() => { - getIndices({ - http, - isRollupIndex: () => false, - pattern: '*', - showAllIndices: allowHidden, - }).then((dataSources) => { - setAllSources(dataSources); - const matchedSet = getMatchedIndices(dataSources, [], [], allowHidden); - setMatchedIndices(matchedSet); - setIsLoadingSources(false); - }); - }, [http, allowHidden]); + dataViews + .getIndices({ + isRollupIndex: () => false, + pattern: '*', + showAllIndices: allowHidden, + }) + .then((dataSources) => { + setAllSources(dataSources); + const matchedSet = getMatchedIndices(dataSources, [], [], allowHidden); + setMatchedIndices(matchedSet); + setIsLoadingSources(false); + }); + }, [allowHidden, dataViews]); // loading list of index patterns useEffect(() => { @@ -271,7 +275,7 @@ const IndexPatternEditorFlyoutContentComponent = ({ const { matchedIndicesResult, exactMatched } = !isLoadingSources ? await loadMatchedIndices(query, allowHidden, allSources, { isRollupIndex, - http, + dataViews, }) : { matchedIndicesResult: { @@ -302,7 +306,7 @@ const IndexPatternEditorFlyoutContentComponent = ({ return fetchIndices(newTitle); }, - [http, allowHidden, allSources, type, rollupIndicesCapabilities, isLoadingSources] + [dataViews, allowHidden, allSources, type, rollupIndicesCapabilities, isLoadingSources] ); // If editData exists, loadSources so that MatchedIndices can be loaded for the Timestampfields @@ -453,10 +457,10 @@ const loadMatchedIndices = memoizeOne( allSources: MatchedItem[], { isRollupIndex, - http, + dataViews, }: { isRollupIndex: (index: string) => boolean; - http: DataViewEditorContext['http']; + dataViews: DataViewsPublicPluginStart; } ): Promise<{ matchedIndicesResult: MatchedIndicesSet; @@ -466,8 +470,7 @@ const loadMatchedIndices = memoizeOne( const indexRequests = []; if (query?.endsWith('*')) { - const exactMatchedQuery = getIndices({ - http, + const exactMatchedQuery = dataViews.getIndices({ isRollupIndex, pattern: query, showAllIndices: allowHidden, @@ -476,14 +479,12 @@ const loadMatchedIndices = memoizeOne( // provide default value when not making a request for the partialMatchQuery indexRequests.push(Promise.resolve([])); } else { - const exactMatchQuery = getIndices({ - http, + const exactMatchQuery = dataViews.getIndices({ isRollupIndex, pattern: query, showAllIndices: allowHidden, }); - const partialMatchQuery = getIndices({ - http, + const partialMatchQuery = dataViews.getIndices({ isRollupIndex, pattern: `${query}*`, showAllIndices: allowHidden, diff --git a/src/plugins/data_view_editor/public/components/form_fields/title_field.tsx b/src/plugins/data_view_editor/public/components/form_fields/title_field.tsx index 822de9506500a..9a4c209a56ebf 100644 --- a/src/plugins/data_view_editor/public/components/form_fields/title_field.tsx +++ b/src/plugins/data_view_editor/public/components/form_fields/title_field.tsx @@ -9,6 +9,7 @@ import React, { ChangeEvent, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { MatchedItem } from '@kbn/data-views-plugin/public'; import { UseField, getFieldValidityAndErrorMessage, @@ -17,12 +18,7 @@ import { } from '../../shared_imports'; import { canAppendWildcard, removeSpaces } from '../../lib'; import { schema } from '../form_schema'; -import { - MatchedItem, - RollupIndicesCapsResponse, - IndexPatternConfig, - MatchedIndicesSet, -} from '../../types'; +import { RollupIndicesCapsResponse, IndexPatternConfig, MatchedIndicesSet } from '../../types'; interface RefreshMatchedIndicesResult { matchedIndicesResult: MatchedIndicesSet; diff --git a/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx b/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx index 11b46c7ee31fa..b11d8ac2e03ea 100644 --- a/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx +++ b/src/plugins/data_view_editor/public/components/form_fields/type_field.tsx @@ -20,9 +20,10 @@ import { EuiBadge, } from '@elastic/eui'; +import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; import { UseField } from '../../shared_imports'; -import { INDEX_PATTERN_TYPE, IndexPatternConfig } from '../../types'; +import { IndexPatternConfig } from '../../types'; interface TypeFieldProps { onChange: (type: INDEX_PATTERN_TYPE) => void; diff --git a/src/plugins/data_view_editor/public/components/form_schema.ts b/src/plugins/data_view_editor/public/components/form_schema.ts index 98c3a3b5322eb..59e195a1f1280 100644 --- a/src/plugins/data_view_editor/public/components/form_schema.ts +++ b/src/plugins/data_view_editor/public/components/form_schema.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ +import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; import { i18n } from '@kbn/i18n'; import { fieldValidators, ValidationFunc } from '../shared_imports'; -import { INDEX_PATTERN_TYPE } from '../types'; export const singleAstriskValidator = ( ...args: Parameters diff --git a/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.test.tsx b/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.test.tsx index cad9f323f95ed..074865006a385 100644 --- a/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.test.tsx +++ b/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { IndicesList } from '.'; import { shallow } from 'enzyme'; -import { MatchedItem } from '../../../types'; +import { MatchedItem } from '@kbn/data-views-plugin/public'; const indices = [ { name: 'kibana', tags: [] }, diff --git a/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx b/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx index db3d1dc491453..51405efc58790 100644 --- a/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx +++ b/src/plugins/data_view_editor/public/components/preview_panel/indices_list/indices_list.tsx @@ -27,7 +27,7 @@ import { import { Pager } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { MatchedItem, Tag } from '../../../types'; +import { MatchedItem, Tag } from '@kbn/data-views-plugin/public'; interface IndicesListProps { indices: MatchedItem[]; diff --git a/src/plugins/data_view_editor/public/components/preview_panel/preview_panel.tsx b/src/plugins/data_view_editor/public/components/preview_panel/preview_panel.tsx index 28413debdb2a9..26f6b2b1c2a70 100644 --- a/src/plugins/data_view_editor/public/components/preview_panel/preview_panel.tsx +++ b/src/plugins/data_view_editor/public/components/preview_panel/preview_panel.tsx @@ -8,10 +8,11 @@ import React from 'react'; import { EuiSpacer } from '@elastic/eui'; +import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; import { StatusMessage } from './status_message'; import { IndicesList } from './indices_list'; -import { INDEX_PATTERN_TYPE, MatchedIndicesSet } from '../../types'; +import { MatchedIndicesSet } from '../../types'; interface Props { type: INDEX_PATTERN_TYPE; diff --git a/src/plugins/data_view_editor/public/components/preview_panel/status_message/status_message.test.tsx b/src/plugins/data_view_editor/public/components/preview_panel/status_message/status_message.test.tsx index 858a00f025e9c..23f0e1b8a6b01 100644 --- a/src/plugins/data_view_editor/public/components/preview_panel/status_message/status_message.test.tsx +++ b/src/plugins/data_view_editor/public/components/preview_panel/status_message/status_message.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { StatusMessage } from '.'; import { shallow } from 'enzyme'; -import { MatchedItem } from '../../../types'; +import { MatchedItem } from '@kbn/data-views-plugin/public'; const tagsPartial = { tags: [], diff --git a/src/plugins/data_view_editor/public/lib/get_matched_indices.test.ts b/src/plugins/data_view_editor/public/lib/get_matched_indices.test.ts index b1c80791a1343..a5f12f7221ab1 100644 --- a/src/plugins/data_view_editor/public/lib/get_matched_indices.test.ts +++ b/src/plugins/data_view_editor/public/lib/get_matched_indices.test.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ +import { MatchedItem } from '@kbn/data-views-plugin/public'; +import { Tag } from '@kbn/data-views-plugin/public/types'; import { getMatchedIndices } from './get_matched_indices'; -import { Tag, MatchedItem } from '../types'; jest.mock('../constants', () => ({ MAX_NUMBER_OF_MATCHING_INDICES: 6, diff --git a/src/plugins/data_view_editor/public/lib/get_matched_indices.ts b/src/plugins/data_view_editor/public/lib/get_matched_indices.ts index 0b659aa5fbc76..e35ba8f1e8fae 100644 --- a/src/plugins/data_view_editor/public/lib/get_matched_indices.ts +++ b/src/plugins/data_view_editor/public/lib/get_matched_indices.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { MatchedItem } from '@kbn/data-views-plugin/public'; import { MAX_NUMBER_OF_MATCHING_INDICES } from '../constants'; function isSystemIndex(index: string): boolean { @@ -50,7 +51,7 @@ function filterSystemIndices(indices: MatchedItem[], isIncludingSystemIndices: b We call this `exact` matches because ES is telling us exactly what it matches */ -import { MatchedItem, MatchedIndicesSet } from '../types'; +import { MatchedIndicesSet } from '../types'; export function getMatchedIndices( unfilteredAllIndices: MatchedItem[], diff --git a/src/plugins/data_view_editor/public/lib/index.ts b/src/plugins/data_view_editor/public/lib/index.ts index 981c9df03527b..97a1f08d35045 100644 --- a/src/plugins/data_view_editor/public/lib/index.ts +++ b/src/plugins/data_view_editor/public/lib/index.ts @@ -10,8 +10,6 @@ export { canAppendWildcard } from './can_append_wildcard'; export { ensureMinimumTime } from './ensure_minimum_time'; -export { getIndices } from './get_indices'; - export { getMatchedIndices } from './get_matched_indices'; export { containsIllegalCharacters } from './contains_illegal_characters'; diff --git a/src/plugins/data_view_editor/public/types.ts b/src/plugins/data_view_editor/public/types.ts index 5b177f65b0602..4500e522119d5 100644 --- a/src/plugins/data_view_editor/public/types.ts +++ b/src/plugins/data_view_editor/public/types.ts @@ -17,7 +17,12 @@ import { import { EuiComboBoxOptionOption } from '@elastic/eui'; -import type { DataView, DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import type { + DataView, + DataViewsPublicPluginStart, + INDEX_PATTERN_TYPE, + MatchedItem, +} from '@kbn/data-views-plugin/public'; import { DataPublicPluginStart, IndexPatternAggRestrictions } from './shared_imports'; export interface DataViewEditorContext { @@ -80,51 +85,6 @@ export interface StartPlugins { export type CloseEditor = () => void; -export interface MatchedItem { - name: string; - tags: Tag[]; - item: { - name: string; - backing_indices?: string[]; - timestamp_field?: string; - indices?: string[]; - aliases?: string[]; - attributes?: ResolveIndexResponseItemIndexAttrs[]; - data_stream?: string; - }; -} - -// for showing index matches -export interface ResolveIndexResponse { - indices?: ResolveIndexResponseItemIndex[]; - aliases?: ResolveIndexResponseItemAlias[]; - data_streams?: ResolveIndexResponseItemDataStream[]; -} - -export interface ResolveIndexResponseItem { - name: string; -} - -export interface ResolveIndexResponseItemDataStream extends ResolveIndexResponseItem { - backing_indices: string[]; - timestamp_field: string; -} - -export interface ResolveIndexResponseItemAlias extends ResolveIndexResponseItem { - indices: string[]; -} - -export interface ResolveIndexResponseItemIndex extends ResolveIndexResponseItem { - aliases?: string[]; - attributes?: ResolveIndexResponseItemIndexAttrs[]; - data_stream?: string; -} - -export interface Tag { - name: string; - key: string; - color: string; -} // end for index matches export interface IndexPatternTableItem { @@ -135,13 +95,6 @@ export interface IndexPatternTableItem { sort: string; } -export enum ResolveIndexResponseItemIndexAttrs { - OPEN = 'open', - CLOSED = 'closed', - HIDDEN = 'hidden', - FROZEN = 'frozen', -} - export interface RollupIndiciesCapability { aggs: Record; error: string; @@ -149,11 +102,6 @@ export interface RollupIndiciesCapability { export type RollupIndicesCapsResponse = Record; -export enum INDEX_PATTERN_TYPE { - ROLLUP = 'rollup', - DEFAULT = 'default', -} - export interface IndexPatternConfig { title: string; timestampField?: EuiComboBoxOptionOption; diff --git a/src/plugins/data_views/public/data_views_service_public.ts b/src/plugins/data_views/public/data_views_service_public.ts index 4693e7000b2a3..30625b1b59da5 100644 --- a/src/plugins/data_views/public/data_views_service_public.ts +++ b/src/plugins/data_views/public/data_views_service_public.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { DataViewsService } from '.'; +import { DataViewsService, MatchedItem } from '.'; import { DataViewsServiceDeps } from '../common/data_views/data_views'; import { HasDataService } from '../common'; @@ -24,6 +24,11 @@ export interface DataViewsServicePublicDeps extends DataViewsServiceDeps { * Has data service */ hasData: HasDataService; + getIndices: (props: { + pattern: string; + showAllIndices?: boolean; + isRollupIndex: (indexName: string) => boolean; + }) => Promise; } /** @@ -32,6 +37,12 @@ export interface DataViewsServicePublicDeps extends DataViewsServiceDeps { */ export class DataViewsServicePublic extends DataViewsService { public getCanSaveSync: () => boolean; + + public getIndices: (props: { + pattern: string; + showAllIndices?: boolean; + isRollupIndex: (indexName: string) => boolean; + }) => Promise; public hasData: HasDataService; /** @@ -43,5 +54,6 @@ export class DataViewsServicePublic extends DataViewsService { super(deps); this.getCanSaveSync = deps.getCanSaveSync; this.hasData = deps.hasData; + this.getIndices = deps.getIndices; } } diff --git a/src/plugins/data_views/public/index.ts b/src/plugins/data_views/public/index.ts index f886d60696b8a..8ee149622f39f 100644 --- a/src/plugins/data_views/public/index.ts +++ b/src/plugins/data_views/public/index.ts @@ -32,7 +32,14 @@ export { getFieldSubtypeNested, } from '../common'; -export type { DataViewsPublicSetupDependencies, DataViewsPublicStartDependencies } from './types'; +export type { + DataViewsPublicSetupDependencies, + DataViewsPublicStartDependencies, + MatchedItem, + Tag, +} from './types'; + +export { INDEX_PATTERN_TYPE } from './types'; export type { DataViewsServicePublic, diff --git a/src/plugins/data_views/public/plugin.ts b/src/plugins/data_views/public/plugin.ts index df379ab18964e..415a6c97bc73a 100644 --- a/src/plugins/data_views/public/plugin.ts +++ b/src/plugins/data_views/public/plugin.ts @@ -21,7 +21,7 @@ import { SavedObjectsClientPublicToCommon } from './saved_objects_client_wrapper import { UiSettingsPublicToCommon } from './ui_settings_wrapper'; import { DataViewsServicePublic } from './data_views_service_public'; -import { HasData } from './services'; +import { getIndices, HasData } from './services'; import { debounceByKey } from './debounce_by_key'; @@ -76,6 +76,7 @@ export class DataViewsPublicPlugin getCanSaveSync: () => application.capabilities.indexPatterns.save === true, getCanSaveAdvancedSettings: () => Promise.resolve(application.capabilities.advancedSettings.save === true), + getIndices: (props) => getIndices({ ...props, http: core.http }), }); } diff --git a/src/plugins/data_view_editor/public/lib/__snapshots__/get_indices.test.ts.snap b/src/plugins/data_views/public/services/__snapshots__/get_indices.test.ts.snap similarity index 100% rename from src/plugins/data_view_editor/public/lib/__snapshots__/get_indices.test.ts.snap rename to src/plugins/data_views/public/services/__snapshots__/get_indices.test.ts.snap diff --git a/src/plugins/data_view_editor/public/lib/get_indices.test.ts b/src/plugins/data_views/public/services/get_indices.test.ts similarity index 100% rename from src/plugins/data_view_editor/public/lib/get_indices.test.ts rename to src/plugins/data_views/public/services/get_indices.test.ts diff --git a/src/plugins/data_view_editor/public/lib/get_indices.ts b/src/plugins/data_views/public/services/get_indices.ts similarity index 90% rename from src/plugins/data_view_editor/public/lib/get_indices.ts rename to src/plugins/data_views/public/services/get_indices.ts index 2c04d6e389964..fba5004367526 100644 --- a/src/plugins/data_view_editor/public/lib/get_indices.ts +++ b/src/plugins/data_views/public/services/get_indices.ts @@ -12,20 +12,20 @@ import { i18n } from '@kbn/i18n'; import { Tag, INDEX_PATTERN_TYPE } from '../types'; import { MatchedItem, ResolveIndexResponse, ResolveIndexResponseItemIndexAttrs } from '../types'; -const aliasLabel = i18n.translate('indexPatternEditor.aliasLabel', { defaultMessage: 'Alias' }); -const dataStreamLabel = i18n.translate('indexPatternEditor.dataStreamLabel', { +const aliasLabel = i18n.translate('dataViews.aliasLabel', { defaultMessage: 'Alias' }); +const dataStreamLabel = i18n.translate('dataViews.dataStreamLabel', { defaultMessage: 'Data stream', }); -const indexLabel = i18n.translate('indexPatternEditor.indexLabel', { +const indexLabel = i18n.translate('dataViews.indexLabel', { defaultMessage: 'Index', }); -const frozenLabel = i18n.translate('indexPatternEditor.frozenLabel', { +const frozenLabel = i18n.translate('dataViews.frozenLabel', { defaultMessage: 'Frozen', }); -const rollupLabel = i18n.translate('indexPatternEditor.rollupLabel', { +const rollupLabel = i18n.translate('dataViews.rollupLabel', { defaultMessage: 'Rollup', }); diff --git a/src/plugins/data_views/public/services/index.ts b/src/plugins/data_views/public/services/index.ts index 36d35d69bcb54..af3787bd5dbd1 100644 --- a/src/plugins/data_views/public/services/index.ts +++ b/src/plugins/data_views/public/services/index.ts @@ -6,4 +6,17 @@ * Side Public License, v 1. */ +import { HttpStart } from '@kbn/core/public'; +import { MatchedItem } from '../types'; + export * from './has_data'; + +export async function getIndices(props: { + http: HttpStart; + pattern: string; + showAllIndices?: boolean; + isRollupIndex: (indexName: string) => boolean; +}): Promise { + const { getIndices: getIndicesLazy } = await import('./get_indices'); + return getIndicesLazy(props); +} diff --git a/src/plugins/data_views/public/types.ts b/src/plugins/data_views/public/types.ts index fc888d2c42c87..637d4191c671e 100644 --- a/src/plugins/data_views/public/types.ts +++ b/src/plugins/data_views/public/types.ts @@ -11,6 +11,11 @@ import { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/ import { DataViewsServicePublicMethods } from './data_views'; import { HasDataService } from '../common'; +export enum INDEX_PATTERN_TYPE { + ROLLUP = 'rollup', + DEFAULT = 'default', +} + export enum IndicesResponseItemIndexAttrs { OPEN = 'open', CLOSED = 'closed', @@ -98,6 +103,11 @@ export interface DataViewsPublicPluginSetup {} export interface DataViewsServicePublic extends DataViewsServicePublicMethods { getCanSaveSync: () => boolean; hasData: HasDataService; + getIndices: (props: { + pattern: string; + showAllIndices?: boolean; + isRollupIndex: (indexName: string) => boolean; + }) => Promise; } export type DataViewsContract = DataViewsServicePublic; @@ -106,3 +116,55 @@ export type DataViewsContract = DataViewsServicePublic; * Data views plugin public Start contract */ export type DataViewsPublicPluginStart = DataViewsServicePublic; + +export interface MatchedItem { + name: string; + tags: Tag[]; + item: { + name: string; + backing_indices?: string[]; + timestamp_field?: string; + indices?: string[]; + aliases?: string[]; + attributes?: ResolveIndexResponseItemIndexAttrs[]; + data_stream?: string; + }; +} + +// for showing index matches +export interface ResolveIndexResponse { + indices?: ResolveIndexResponseItemIndex[]; + aliases?: ResolveIndexResponseItemAlias[]; + data_streams?: ResolveIndexResponseItemDataStream[]; +} + +export interface ResolveIndexResponseItem { + name: string; +} + +export interface ResolveIndexResponseItemDataStream extends ResolveIndexResponseItem { + backing_indices: string[]; + timestamp_field: string; +} + +export interface ResolveIndexResponseItemAlias extends ResolveIndexResponseItem { + indices: string[]; +} + +export interface ResolveIndexResponseItemIndex extends ResolveIndexResponseItem { + aliases?: string[]; + attributes?: ResolveIndexResponseItemIndexAttrs[]; + data_stream?: string; +} + +export interface Tag { + name: string; + key: string; + color: string; +} +export enum ResolveIndexResponseItemIndexAttrs { + OPEN = 'open', + CLOSED = 'closed', + HIDDEN = 'hidden', + FROZEN = 'frozen', +} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx index 22d33215b1eaf..666b75a689499 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx @@ -194,4 +194,14 @@ describe('discover sidebar', function () { const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new'); expect(createDataViewButton.length).toBe(0); }); + + it('should render the Visualize in Lens button in text based languages mode', () => { + const compInViewerMode = mountWithIntl( + + + + ); + const visualizeField = findTestSubject(compInViewerMode, 'textBased-visualize'); + expect(visualizeField.length).toBe(1); + }); }); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index d7e227df94b8c..355512da5c52c 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -22,6 +22,7 @@ import { useResizeObserver, EuiButton, } from '@elastic/eui'; +import { isOfAggregateQueryType } from '@kbn/es-query'; import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { isEqual } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -39,6 +40,7 @@ import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; import { VIEW_MODE } from '../../../../components/view_mode_toggle'; import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour'; import type { DataTableRecord } from '../../../../types'; +import { triggerVisualizeActionsTextBasedLanguages } from './lib/visualize_trigger_utils'; /** * Default number of available fields displayed and added on scroll @@ -309,6 +311,12 @@ export function DiscoverSidebarComponent({ const filterChanged = useMemo(() => isEqual(fieldFilter, getDefaultFieldFilter()), [fieldFilter]); + const visualizeAggregateQuery = useCallback(() => { + const aggregateQuery = + state.query && isOfAggregateQueryType(state.query) ? state.query : undefined; + triggerVisualizeActionsTextBasedLanguages(columns, selectedDataView, aggregateQuery); + }, [columns, selectedDataView, state.query]); + if (!selectedDataView) { return null; } @@ -532,6 +540,20 @@ export function DiscoverSidebarComponent({ )} + {isPlainRecord && ( + + + {i18n.translate('discover.textBasedLanguages.visualize.label', { + defaultMessage: 'Visualize in Lens', + })} + + + )} ); diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts b/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts index 005accf3021f3..85537df2ef364 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/visualize_trigger_utils.ts @@ -12,6 +12,7 @@ import { visualizeFieldTrigger, visualizeGeoFieldTrigger, } from '@kbn/ui-actions-plugin/public'; +import type { AggregateQuery } from '@kbn/es-query'; import type { DataViewField, DataView } from '@kbn/data-views-plugin/public'; import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public'; import { getUiActions } from '../../../../../kibana_services'; @@ -59,6 +60,22 @@ export function triggerVisualizeActions( getUiActions().getTrigger(trigger).exec(triggerOptions); } +export function triggerVisualizeActionsTextBasedLanguages( + contextualFields: string[], + dataView?: DataView, + query?: AggregateQuery +) { + if (!dataView) return; + const triggerOptions = { + dataViewSpec: dataView.toSpec(false), + fieldName: '', + contextualFields, + originatingApp: PLUGIN_ID, + query, + }; + getUiActions().getTrigger(VISUALIZE_FIELD_TRIGGER).exec(triggerOptions); +} + export interface VisualizeInformation { field: DataViewField; href?: string; diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 241a20a0a1559..82f3b1aadadbf 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -66,7 +66,7 @@ export const DiscoverTopNav = ({ [dataView] ); const services = useDiscoverServices(); - const { dataViewEditor, navigation, dataViewFieldEditor, data, uiSettings } = services; + const { dataViewEditor, navigation, dataViewFieldEditor, data, uiSettings, dataViews } = services; const canEditDataView = Boolean(dataViewEditor?.userPermissions.editDataView()); @@ -141,6 +141,19 @@ export const DiscoverTopNav = ({ [canEditDataView, dataViewEditor, onChangeDataView] ); + const onCreateDefaultAdHocDataView = useCallback( + async (pattern: string) => { + const newDataView = await dataViews.create({ + title: pattern, + }); + if (newDataView.fields.getByName('@timestamp')?.type === 'date') { + newDataView.timeFieldName = '@timestamp'; + } + onChangeDataView(newDataView.id!); + }, + [dataViews, onChangeDataView] + ); + const topNavMenu = useMemo( () => getTopNavLinks({ @@ -201,6 +214,7 @@ export const DiscoverTopNav = ({ currentDataViewId: dataView?.id, onAddField: addField, onDataViewCreated: createNewDataView, + onCreateDefaultAdHocDataView, onChangeDataView, textBasedLanguages: supportedTextBasedLanguages as DataViewPickerProps['textBasedLanguages'], adHocDataViews: adHocDataViewList, diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index a76a8aa760ed7..fb2d9869e21c7 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { AggregateQuery } from '@kbn/es-query'; import type { DataViewSpec } from '@kbn/data-views-plugin/public'; import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; @@ -19,6 +20,7 @@ export interface VisualizeFieldContext { dataViewSpec: DataViewSpec; contextualFields?: string[]; originatingApp?: string; + query?: AggregateQuery; } export const ACTION_VISUALIZE_FIELD = 'ACTION_VISUALIZE_FIELD'; diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx index 2f641cd2d4e28..72a2d8fea290f 100644 --- a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { css } from '@emotion/react'; import { EuiPopover, @@ -24,6 +24,7 @@ import { EuiFlexItem, EuiButtonEmpty, EuiToolTip, + EuiSpacer, } from '@elastic/eui'; import type { DataViewListItem } from '@kbn/data-views-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; @@ -71,9 +72,13 @@ export function ChangeDataView({ onTextLangQuerySubmit, textBasedLanguage, isDisabled, + onCreateDefaultAdHocDataView, }: DataViewPickerPropsExtended) { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const [noDataViewMatches, setNoDataViewMatches] = useState(false); + const [dataViewSearchString, setDataViewSearchString] = useState(''); + const [indexMatches, setIndexMatches] = useState(0); const [dataViewsList, setDataViewsList] = useState([]); const [triggerLabel, setTriggerLabel] = useState(''); const [isTextBasedLangSelected, setIsTextBasedLangSelected] = useState( @@ -111,6 +116,24 @@ export function ChangeDataView({ fetchDataViews(); }, [data, currentDataViewId, adHocDataViews]); + const pendingIndexMatch = useRef(); + useEffect(() => { + async function checkIndices() { + if (dataViewSearchString !== '' && noDataViewMatches) { + const matches = await kibana.services.dataViews.getIndices({ + pattern: dataViewSearchString, + isRollupIndex: () => false, + showAllIndices: false, + }); + setIndexMatches(matches.length); + } + } + if (pendingIndexMatch.current) { + clearTimeout(pendingIndexMatch.current); + } + pendingIndexMatch.current = setTimeout(checkIndices, 250); + }, [dataViewSearchString, kibana.services.dataViews, noDataViewMatches]); + useEffect(() => { if (trigger.label) { if (textBasedLanguage) { @@ -282,10 +305,57 @@ export function ChangeDataView({ } }} currentDataViewId={currentDataViewId} - selectableProps={selectableProps} + selectableProps={{ + ...(selectableProps || {}), + // @ts-expect-error Some EUI weirdness + searchProps: { + ...(selectableProps?.searchProps || {}), + onChange: (value, matches) => { + selectableProps?.searchProps?.onChange?.(value, matches); + setNoDataViewMatches(matches.length === 0 && dataViewsList.length > 0); + setDataViewSearchString(value); + }, + }, + }} searchListInputId={searchListInputId} isTextBasedLangSelected={isTextBasedLangSelected} /> + {onCreateDefaultAdHocDataView && noDataViewMatches && indexMatches > 0 && ( + + + { + setPopoverIsOpen(false); + onCreateDefaultAdHocDataView(dataViewSearchString); + }} + > + {i18n.translate( + 'unifiedSearch.query.queryBar.indexPattern.createForMatchingIndices', + { + defaultMessage: `Explore {indicesLength, plural, + one {# matching index} + other {# matching indices}}`, + values: { + indicesLength: indexMatches, + }, + } + )} + + + + + )} ); diff --git a/src/plugins/unified_search/public/dataview_picker/index.tsx b/src/plugins/unified_search/public/dataview_picker/index.tsx index 1cc0a8219af96..9fb794d58e642 100644 --- a/src/plugins/unified_search/public/dataview_picker/index.tsx +++ b/src/plugins/unified_search/public/dataview_picker/index.tsx @@ -63,6 +63,8 @@ export interface DataViewPickerProps { * Also works as a flag to show the create dataview button. */ onDataViewCreated?: () => void; + + onCreateDefaultAdHocDataView?: (pattern: string) => void; /** * List of the supported text based languages (SQL, ESQL) etc. * Defined per application, if not provided, no text based languages @@ -104,6 +106,7 @@ export const DataViewPicker = ({ onSaveTextLanguageQuery, onTextLangQuerySubmit, textBasedLanguage, + onCreateDefaultAdHocDataView, isDisabled, }: DataViewPickerPropsExtended) => { return ( @@ -113,6 +116,7 @@ export const DataViewPicker = ({ onChangeDataView={onChangeDataView} onAddField={onAddField} onDataViewCreated={onDataViewCreated} + onCreateDefaultAdHocDataView={onCreateDefaultAdHocDataView} trigger={trigger} adHocDataViews={adHocDataViews} selectableProps={selectableProps} diff --git a/src/plugins/unified_search/public/types.ts b/src/plugins/unified_search/public/types.ts index d079cd72edf81..85f32a9d4b7e3 100755 --- a/src/plugins/unified_search/public/types.ts +++ b/src/plugins/unified_search/public/types.ts @@ -86,5 +86,6 @@ export interface IUnifiedSearchPluginServices extends Partial { storage: IStorageWrapper; docLinks: DocLinksStart; data: DataPublicPluginStart; + dataViews: DataViewsPublicPluginStart; usageCollection?: UsageCollectionStart; } diff --git a/src/plugins/unified_search/tsconfig.json b/src/plugins/unified_search/tsconfig.json index 61b1f83058821..2f09d27c84b08 100644 --- a/src/plugins/unified_search/tsconfig.json +++ b/src/plugins/unified_search/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../../core/tsconfig.json" }, { "path": "../data/tsconfig.json" }, { "path": "../data_views/tsconfig.json" }, + { "path": "../data_view_editor/tsconfig.json" }, { "path": "../embeddable/tsconfig.json" }, { "path": "../usage_collection/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts index f869ac64380c6..6c94971397d3e 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.test.ts @@ -229,6 +229,31 @@ describe('getLayers', () => { ], series: [createSeries({ metrics: staticValueMetric })], }); + + const panelWithSingleAnnotationWithoutQueryStringAndTimefield = createPanel({ + annotations: [ + { + fields: 'geo.src,host', + template: 'Security Error from {{geo.src}} on {{host}}', + id: 'ann1', + color: 'rgba(211,49,21,0.7)', + icon: 'fa-asterisk', + ignore_global_filters: 1, + ignore_panel_filters: 1, + time_field: '', + query_string: { + query: '', + language: 'lucene', + }, + hidden: true, + index_pattern: { + id: 'test', + }, + }, + ], + series: [createSeries({ metrics: staticValueMetric })], + }); + const panelWithMultiAnnotations = createPanel({ annotations: [ { @@ -284,6 +309,27 @@ describe('getLayers', () => { ], series: [createSeries({ metrics: staticValueMetric })], }); + const panelWithSingleAnnotationDefaultDataView = createPanel({ + annotations: [ + { + fields: 'geo.src,host', + template: 'Security Error from {{geo.src}} on {{host}}', + query_string: { + query: 'tags:error AND tags:security', + language: 'lucene', + }, + id: 'ann1', + color: 'rgba(211,49,21,0.7)', + time_field: 'timestamp', + icon: 'fa-asterisk', + ignore_global_filters: 1, + ignore_panel_filters: 1, + hidden: true, + index_pattern: '', + }, + ], + series: [createSeries({ metrics: staticValueMetric })], + }); test.each<[string, [Record, Panel], Array>]>([ [ @@ -411,6 +457,51 @@ describe('getLayers', () => { }, ], ], + [ + 'annotation layer should gets correct default params', + [dataSourceLayersWithStatic, panelWithSingleAnnotationWithoutQueryStringAndTimefield], + [ + { + layerType: 'referenceLine', + accessors: ['column-id-1'], + layerId: 'test-layer-1', + yConfig: [ + { + forAccessor: 'column-id-1', + axisMode: 'right', + color: '#68BC00', + fill: 'below', + }, + ], + }, + { + layerId: 'test-id', + layerType: 'annotations', + ignoreGlobalFilters: true, + annotations: [ + { + color: '#D33115', + extraFields: ['geo.src'], + filter: { + language: 'lucene', + query: '*', + type: 'kibana_query', + }, + icon: 'asterisk', + id: 'ann1', + isHidden: true, + key: { + type: 'point_in_time', + }, + label: 'Event', + timeField: 'test_field', + type: 'query', + }, + ], + indexPatternId: 'test', + }, + ], + ], [ 'multiple annotations with different data views create separate layers', [dataSourceLayersWithStatic, panelWithMultiAnnotations], @@ -451,6 +542,14 @@ describe('getLayers', () => { timeField: 'timestamp', type: 'query', }, + ], + indexPatternId: 'test', + }, + { + layerId: 'test-id', + layerType: 'annotations', + ignoreGlobalFilters: false, + annotations: [ { color: '#0000FF', filter: { @@ -497,6 +596,51 @@ describe('getLayers', () => { }, ], ], + [ + 'annotation layer gets correct dataView when none is defined', + [dataSourceLayersWithStatic, panelWithSingleAnnotationDefaultDataView], + [ + { + layerType: 'referenceLine', + accessors: ['column-id-1'], + layerId: 'test-layer-1', + yConfig: [ + { + forAccessor: 'column-id-1', + axisMode: 'right', + color: '#68BC00', + fill: 'below', + }, + ], + }, + { + layerId: 'test-id', + layerType: 'annotations', + ignoreGlobalFilters: true, + annotations: [ + { + color: '#D33115', + extraFields: ['geo.src'], + filter: { + language: 'lucene', + query: 'tags:error AND tags:security', + type: 'kibana_query', + }, + icon: 'asterisk', + id: 'ann1', + isHidden: true, + key: { + type: 'point_in_time', + }, + label: 'Event', + timeField: 'timestamp', + type: 'query', + }, + ], + indexPatternId: 'default', + }, + ], + ], ])('should return %s', async (_, input, expected) => { const layers = await getLayers(...input, indexPatternsService as DataViewsPublicPluginStart); expect(layers).toEqual(expected.map(expect.objectContaining)); @@ -507,13 +651,20 @@ const mockedIndices = [ { id: 'test', title: 'test', + timeFieldName: 'test_field', getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }), }, ] as unknown as DataView[]; const indexPatternsService = { - getDefault: jest.fn(() => Promise.resolve({ id: 'default', title: 'index' })), - get: jest.fn(() => Promise.resolve(mockedIndices[0])), + getDefault: jest.fn(() => + Promise.resolve({ + id: 'default', + title: 'index', + getFieldByName: (name: string) => ({ aggregatable: name !== 'host' }), + }) + ), + get: jest.fn((id) => Promise.resolve({ ...mockedIndices[0], id })), find: jest.fn((search: string, size: number) => { if (size !== 1) { // shouldn't request more than one data view since there is a significant performance penalty diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts index 6815e278e0f30..ec0e24e2db873 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/configurations/xy/layers.ts @@ -20,6 +20,7 @@ import Color from 'color'; import { euiLightVars } from '@kbn/ui-theme'; import { groupBy } from 'lodash'; import { DataViewsPublicPluginStart, DataView } from '@kbn/data-plugin/public/data_views'; +import { getDefaultQueryLanguage } from '../../../../application/components/lib/get_default_query_language'; import { fetchIndexPattern } from '../../../../../common/index_patterns_utils'; import { ICON_TYPES_MAP } from '../../../../application/visualizations/constants'; import { SUPPORTED_METRICS } from '../../metrics'; @@ -62,7 +63,7 @@ function getColor( } function nonNullable(value: T): value is NonNullable { - return value !== null && value !== undefined; + return value != null; } export const getLayers = async ( @@ -131,16 +132,22 @@ export const getLayers = async ( return nonAnnotationsLayers; } - const annotationsByIndexPattern = groupBy( - model.annotations, - (a) => typeof a.index_pattern === 'object' && 'id' in a.index_pattern && a.index_pattern.id - ); + const annotationsByIndexPatternAndIgnoreFlag = groupBy(model.annotations, (a) => { + const id = typeof a.index_pattern === 'object' && 'id' in a.index_pattern && a.index_pattern.id; + return `${id}-${Boolean(a.ignore_global_filters)}`; + }); try { const annotationsLayers: Array = await Promise.all( - Object.entries(annotationsByIndexPattern).map(async ([indexPatternId, annotations]) => { + Object.values(annotationsByIndexPatternAndIgnoreFlag).map(async (annotations) => { + const [firstAnnotation] = annotations; + const indexPatternId = + typeof firstAnnotation.index_pattern === 'string' + ? firstAnnotation.index_pattern + : firstAnnotation.index_pattern?.id; const convertedAnnotations: EventAnnotationConfig[] = []; - const { indexPattern } = (await fetchIndexPattern({ id: indexPatternId }, dataViews)) || {}; + const { indexPattern } = + (await fetchIndexPattern(indexPatternId && { id: indexPatternId }, dataViews)) || {}; if (indexPattern) { annotations.forEach((a: Annotation) => { @@ -152,9 +159,9 @@ export const getLayers = async ( return { layerId: v4(), layerType: 'annotations', - ignoreGlobalFilters: true, + ignoreGlobalFilters: Boolean(firstAnnotation.ignore_global_filters), annotations: convertedAnnotations, - indexPatternId, + indexPatternId: indexPattern.id!, }; } }) @@ -170,38 +177,36 @@ const convertAnnotation = ( annotation: Annotation, dataView: DataView ): EventAnnotationConfig | undefined => { - if (annotation.query_string) { - const extraFields = annotation.fields - ? annotation.fields - ?.replace(/\s/g, '') - ?.split(',') - .map((field) => { - const dataViewField = dataView.getFieldByName(field); - return dataViewField && dataViewField.aggregatable ? field : undefined; - }) - .filter(nonNullable) - : undefined; - return { - type: 'query', - id: annotation.id, - label: 'Event', - key: { - type: 'point_in_time', - }, - color: new Color(transparentize(annotation.color || euiLightVars.euiColorAccent, 1)).hex(), - timeField: annotation.time_field, - icon: - annotation.icon && - ICON_TYPES_MAP[annotation.icon] && - typeof ICON_TYPES_MAP[annotation.icon] === 'string' - ? ICON_TYPES_MAP[annotation.icon] - : 'triangle', - filter: { - type: 'kibana_query', - ...annotation.query_string, - }, - extraFields, - isHidden: annotation.hidden, - }; - } + const extraFields = annotation.fields + ?.replace(/\s/g, '') + .split(',') + .map((field) => { + const dataViewField = dataView.getFieldByName(field); + return dataViewField && dataViewField.aggregatable ? field : undefined; + }) + .filter(nonNullable); + + return { + type: 'query', + id: annotation.id, + label: 'Event', + key: { + type: 'point_in_time', + }, + color: new Color(transparentize(annotation.color || euiLightVars.euiColorAccent, 1)).hex(), + timeField: annotation.time_field || dataView.timeFieldName, + icon: + annotation.icon && + ICON_TYPES_MAP[annotation.icon] && + typeof ICON_TYPES_MAP[annotation.icon] === 'string' + ? ICON_TYPES_MAP[annotation.icon] + : 'triangle', + filter: { + type: 'kibana_query', + query: annotation.query_string?.query || '*', + language: annotation.query_string?.language || getDefaultQueryLanguage(), + }, + extraFields, + isHidden: annotation.hidden, + }; }; diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json index 6648816b07843..ce8057bc03f04 100755 --- a/x-pack/plugins/aiops/kibana.json +++ b/x-pack/plugins/aiops/kibana.json @@ -15,6 +15,6 @@ "licensing" ], "optionalPlugins": [], - "requiredBundles": ["fieldFormats"], + "requiredBundles": ["fieldFormats", "kibanaReact"], "extraPublicDirs": ["common"] } diff --git a/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx index 605f130594bc8..6b3e57200b90b 100644 --- a/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx @@ -7,19 +7,33 @@ // TODO Consolidate with duplicate component `DatePickerWrapper` in // `x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx` - +// `x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx` import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Subscription } from 'rxjs'; import { debounce } from 'lodash'; -import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSuperDatePicker, + OnRefreshProps, + OnTimeChangeProps, +} from '@elastic/eui'; import type { TimeRange } from '@kbn/es-query'; -import { TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public'; +import { TimefilterContract, TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs/operators'; +import { toMountPoint, wrapWithTheme } from '@kbn/kibana-react-plugin/public'; import { useUrlState } from '../../hooks/use_url_state'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { aiopsRefresh$ } from '../../application/services/timefilter_refresh_service'; +const DEFAULT_REFRESH_INTERVAL_MS = 5000; + interface TimePickerQuickRange { from: string; to: string; @@ -49,19 +63,64 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistoryContract) { }; } -function updateLastRefresh(timeRange: OnRefreshProps) { +function updateLastRefresh(timeRange?: OnRefreshProps) { aiopsRefresh$.next({ lastRefresh: Date.now(), timeRange }); } +export const useRefreshIntervalUpdates = (timefilter: TimefilterContract) => { + return useObservable( + timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)), + timefilter.getRefreshInterval() + ); +}; + +export const useTimeRangeUpdates = (timefilter: TimefilterContract, absolute = false) => { + const getTimeCallback = absolute + ? timefilter.getAbsoluteTime.bind(timefilter) + : timefilter.getTime.bind(timefilter); + + return useObservable(timefilter.getTimeUpdate$().pipe(map(getTimeCallback)), getTimeCallback()); +}; + export const DatePickerWrapper: FC = () => { - const { uiSettings, data } = useAiopsAppContext(); - const { timefilter, history } = data.query.timefilter; + const services = useAiopsAppContext(); + const { toasts } = services.notifications; + const config = services.uiSettings; + + const { timefilter, history } = services.data.query.timefilter; + const theme$ = services.theme.theme$; const [globalState, setGlobalState] = useUrlState('_g'); const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(history); - const refreshInterval: RefreshInterval = - globalState?.refreshInterval ?? timefilter.getRefreshInterval(); + const timeFilterRefreshInterval = useRefreshIntervalUpdates(timefilter); + const time = useTimeRangeUpdates(timefilter); + + useEffect( + function syncTimRangeFromUrlState() { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [globalState?.time?.from, globalState?.time?.to, globalState?.time?.ts] + ); + + useEffect( + function syncRefreshIntervalFromUrlState() { + if (globalState?.refreshInterval !== undefined) { + timefilter.setRefreshInterval({ + pause: !!globalState?.refreshInterval?.pause, + value: globalState?.refreshInterval?.value, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [globalState?.refreshInterval] + ); // eslint-disable-next-line react-hooks/exhaustive-deps const setRefreshInterval = useCallback( @@ -71,7 +130,6 @@ export const DatePickerWrapper: FC = () => { [setGlobalState] ); - const [time, setTime] = useState(timefilter.getTime()); const [recentlyUsedRanges, setRecentlyUsedRanges] = useState(getRecentlyUsedRanges()); const [isAutoRefreshSelectorEnabled, setIsAutoRefreshSelectorEnabled] = useState( timefilter.isAutoRefreshSelectorEnabled() @@ -80,8 +138,69 @@ export const DatePickerWrapper: FC = () => { timefilter.isTimeRangeSelectorEnabled() ); - const dateFormat = uiSettings.get('dateFormat'); - const timePickerQuickRanges = uiSettings.get( + const refreshInterval = useMemo( + (): RefreshInterval => globalState?.refreshInterval ?? timeFilterRefreshInterval, + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(globalState?.refreshInterval), timeFilterRefreshInterval] + ); + + useEffect( + function warnAboutShortRefreshInterval() { + const isResolvedFromUrlState = !!globalState?.refreshInterval; + const isTooShort = refreshInterval.value < DEFAULT_REFRESH_INTERVAL_MS; + + // Only warn about short interval with enabled auto-refresh. + if (!isTooShort || refreshInterval.pause) return; + + toasts.addWarning( + { + title: isResolvedFromUrlState + ? i18n.translate('xpack.aiops.datePicker.shortRefreshIntervalURLWarningMessage', { + defaultMessage: + 'The refresh interval in the URL is shorter than the minimum supported by Machine Learning.', + }) + : i18n.translate( + 'xpack.aiops.datePicker.shortRefreshIntervalTimeFilterWarningMessage', + { + defaultMessage: + 'The refresh interval in Advanced Settings is shorter than the minimum supported by Machine Learning.', + } + ), + text: toMountPoint( + wrapWithTheme( + + + , + theme$ + ) + ), + }, + { toastLifeTimeMs: 30000 } + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(refreshInterval), + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(globalState?.refreshInterval), + setRefreshInterval, + ] + ); + + const dateFormat = config.get('dateFormat'); + const timePickerQuickRanges = config.get( UI_SETTINGS.TIMEPICKER_QUICK_RANGES ); @@ -97,22 +216,7 @@ export const DatePickerWrapper: FC = () => { useEffect(() => { const subscriptions = new Subscription(); - const refreshIntervalUpdate$ = timefilter.getRefreshIntervalUpdate$(); - if (refreshIntervalUpdate$ !== undefined) { - subscriptions.add( - refreshIntervalUpdate$.subscribe((r) => { - setRefreshInterval(timefilter.getRefreshInterval()); - }) - ); - } - const timeUpdate$ = timefilter.getTimeUpdate$(); - if (timeUpdate$ !== undefined) { - subscriptions.add( - timeUpdate$.subscribe((v) => { - setTime(timefilter.getTime()); - }) - ); - } + const enabledUpdated$ = timefilter.getEnabledUpdated$(); if (enabledUpdated$ !== undefined) { subscriptions.add( @@ -126,15 +230,21 @@ export const DatePickerWrapper: FC = () => { return function cleanup() { subscriptions.unsubscribe(); }; - }, [setRefreshInterval, timefilter]); - - function updateFilter({ start, end }: Duration) { - const newTime = { from: start, to: end }; - // Update timefilter for controllers listening for changes - timefilter.setTime(newTime); - setTime(newTime); - setRecentlyUsedRanges(getRecentlyUsedRanges()); - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateTimeFilter = useCallback( + ({ start, end }: OnTimeChangeProps) => { + setRecentlyUsedRanges(getRecentlyUsedRanges()); + setGlobalState('time', { + from: start, + to: end, + ...(start === 'now' || end === 'now' ? { ts: Date.now() } : {}), + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setGlobalState] + ); function updateInterval({ isPaused: pause, @@ -146,26 +256,41 @@ export const DatePickerWrapper: FC = () => { setRefreshInterval({ pause, value }); } - /** - * Enforce pause when it's set to false with 0 refresh interval. - */ - const isPaused = refreshInterval.pause || (!refreshInterval.pause && !refreshInterval.value); - return isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled ? ( -
- -
+ + + + + + {isTimeRangeSelectorEnabled ? null : ( + + updateLastRefresh()} + data-test-subj="aiOpsRefreshPageButton" + > + + + + )} + ) : null; }; diff --git a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts index 3c44ae7bdb0a3..ddbfca3eb8b11 100644 --- a/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts +++ b/x-pack/plugins/aiops/public/hooks/use_aiops_app_context.ts @@ -15,6 +15,7 @@ import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { CoreStart, CoreSetup, HttpStart, IUiSettingsClient } from '@kbn/core/public'; +import type { ThemeServiceStart } from '@kbn/core/public'; export interface AiopsAppDependencies { application: CoreStart['application']; @@ -24,6 +25,7 @@ export interface AiopsAppDependencies { http: HttpStart; notifications: CoreSetup['notifications']; storage: IStorageWrapper; + theme: ThemeServiceStart; uiSettings: IUiSettingsClient; unifiedSearch: UnifiedSearchPublicPluginStart; share: SharePluginStart; diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx index fd3ee2f3e4552..bef88a78f6845 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx @@ -118,7 +118,6 @@ const policy = { namespace: 'default', policy_id: 'policy-elastic-agent-on-cloud', enabled: true, - output_id: '', inputs: [ { type: 'apm', @@ -341,7 +340,6 @@ const newPolicy = { namespace: 'default', policy_id: 'policy-elastic-agent-on-cloud', enabled: true, - output_id: '', package: { name: 'apm', title: 'Elastic APM', diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx index c1bfa7ac6cf6f..b4243124766ab 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx @@ -81,7 +81,6 @@ const policy = { namespace: 'default', enabled: true, policy_id: 'policy-elastic-agent-on-cloud', - output_id: '', package: { name: 'apm', version: '8.3.0', diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx index 8a8a44282fb78..bad43923934a7 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/index.tsx @@ -133,7 +133,6 @@ export function APMPolicyForm({ vars = {}, updateAPMPolicy }: Props) { } ), settings: tailSamplingSettings, - isBeta: false, isPlatinumLicence: true, }, ] diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx index a834c41098798..d32ace1933427 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/settings_form/index.tsx @@ -92,7 +92,6 @@ export interface SettingsSection { title: string; subtitle?: string; settings: SettingsRow[]; - isBeta?: boolean; isPlatinumLicence?: boolean; } diff --git a/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts index 023ba2755ea9a..d2eea5db4ef17 100644 --- a/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/routes/fleet/get_apm_package_policy_definition.ts @@ -40,7 +40,6 @@ export async function getApmPackagePolicyDefinition({ namespace: 'default', enabled: true, policy_id: POLICY_ELASTIC_AGENT_ON_CLOUD, - output_id: '', inputs: [ { type: 'apm', diff --git a/x-pack/plugins/apm/server/routes/fleet/source_maps.test.ts b/x-pack/plugins/apm/server/routes/fleet/source_maps.test.ts index d4f9b79e516ce..0f617b076ab83 100644 --- a/x-pack/plugins/apm/server/routes/fleet/source_maps.test.ts +++ b/x-pack/plugins/apm/server/routes/fleet/source_maps.test.ts @@ -19,7 +19,6 @@ const packagePolicy = { namespace: 'default', policy_id: '7a87c160-c961-11eb-81e2-f7327d61c92a', enabled: true, - output_id: '', inputs: [ { policy_template: 'apmserver', diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx index 7130841d0a6ac..12d6b257ec292 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/date_picker_wrapper/date_picker_wrapper.tsx @@ -5,17 +5,35 @@ * 2.0. */ +// TODO Consolidate with duplicate component `DatePickerWrapper` in +// `x-pack/plugins/aiops/public/components/date_picker_wrapper/date_picker_wrapper.tsx` + import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Subscription } from 'rxjs'; import { debounce } from 'lodash'; -import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiSuperDatePicker, + OnRefreshProps, + OnTimeChangeProps, +} from '@elastic/eui'; import type { TimeRange } from '@kbn/es-query'; import { TimeHistoryContract, UI_SETTINGS } from '@kbn/data-plugin/public'; - -import { useUrlState } from '../../util/url_state'; +import { i18n } from '@kbn/i18n'; +import { wrapWithTheme } from '@kbn/kibana-react-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + useRefreshIntervalUpdates, + useTimeRangeUpdates, +} from '../../../index_data_visualizer/hooks/use_time_filter'; import { useDataVisualizerKibana } from '../../../kibana_context'; import { dataVisualizerRefresh$ } from '../../../index_data_visualizer/services/timefilter_refresh_service'; +import { useUrlState } from '../../util/url_state'; + +const DEFAULT_REFRESH_INTERVAL_MS = 5000; interface TimePickerQuickRange { from: string; @@ -46,20 +64,52 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistoryContract) { }; } -function updateLastRefresh(timeRange: OnRefreshProps) { +function updateLastRefresh(timeRange?: OnRefreshProps) { dataVisualizerRefresh$.next({ lastRefresh: Date.now(), timeRange }); } +// FIXME: Consolidate this component with ML and AIOps's component export const DatePickerWrapper: FC = () => { - const { services } = useDataVisualizerKibana(); + const { + services, + notifications: { toasts }, + } = useDataVisualizerKibana(); const config = services.uiSettings; + const theme$ = services.theme.theme$; + const { timefilter, history } = services.data.query.timefilter; const [globalState, setGlobalState] = useUrlState('_g'); const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(history); - const refreshInterval: RefreshInterval = - globalState?.refreshInterval ?? timefilter.getRefreshInterval(); + const timeFilterRefreshInterval = useRefreshIntervalUpdates(); + const time = useTimeRangeUpdates(); + + useEffect( + function syncTimRangeFromUrlState() { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [globalState?.time?.from, globalState?.time?.to, globalState?.time?.ts] + ); + + useEffect( + function syncRefreshIntervalFromUrlState() { + if (globalState?.refreshInterval !== undefined) { + timefilter.setRefreshInterval({ + pause: !!globalState?.refreshInterval?.pause, + value: globalState?.refreshInterval?.value, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [globalState?.refreshInterval] + ); // eslint-disable-next-line react-hooks/exhaustive-deps const setRefreshInterval = useCallback( @@ -69,7 +119,6 @@ export const DatePickerWrapper: FC = () => { [setGlobalState] ); - const [time, setTime] = useState(timefilter.getTime()); const [recentlyUsedRanges, setRecentlyUsedRanges] = useState(getRecentlyUsedRanges()); const [isAutoRefreshSelectorEnabled, setIsAutoRefreshSelectorEnabled] = useState( timefilter.isAutoRefreshSelectorEnabled() @@ -78,6 +127,57 @@ export const DatePickerWrapper: FC = () => { timefilter.isTimeRangeSelectorEnabled() ); + const refreshInterval = useMemo( + (): RefreshInterval => globalState?.refreshInterval ?? timeFilterRefreshInterval, + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(globalState?.refreshInterval), timeFilterRefreshInterval] + ); + + useEffect( + function warnAboutShortRefreshInterval() { + const isTooShort = refreshInterval.value < DEFAULT_REFRESH_INTERVAL_MS; + + // Only warn about short interval with enabled auto-refresh. + if (!isTooShort || refreshInterval.pause) return; + + toasts.warning({ + title: i18n.translate( + 'xpack.dataVisualizer.index.datePicker.shortRefreshIntervalURLWarningMessage', + { + defaultMessage: + 'The refresh interval in the URL is shorter than the minimum supported by Machine Learning.', + } + ), + body: wrapWithTheme( + + + , + theme$ + ), + toastLifeTimeMs: 30000, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(refreshInterval), + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(globalState?.refreshInterval), + setRefreshInterval, + ] + ); + const dateFormat = config.get('dateFormat'); const timePickerQuickRanges = config.get( UI_SETTINGS.TIMEPICKER_QUICK_RANGES @@ -95,22 +195,7 @@ export const DatePickerWrapper: FC = () => { useEffect(() => { const subscriptions = new Subscription(); - const refreshIntervalUpdate$ = timefilter.getRefreshIntervalUpdate$(); - if (refreshIntervalUpdate$ !== undefined) { - subscriptions.add( - refreshIntervalUpdate$.subscribe((r) => { - setRefreshInterval(timefilter.getRefreshInterval()); - }) - ); - } - const timeUpdate$ = timefilter.getTimeUpdate$(); - if (timeUpdate$ !== undefined) { - subscriptions.add( - timeUpdate$.subscribe((v) => { - setTime(timefilter.getTime()); - }) - ); - } + const enabledUpdated$ = timefilter.getEnabledUpdated$(); if (enabledUpdated$ !== undefined) { subscriptions.add( @@ -124,15 +209,21 @@ export const DatePickerWrapper: FC = () => { return function cleanup() { subscriptions.unsubscribe(); }; - }, [setRefreshInterval, timefilter]); - - function updateFilter({ start, end }: Duration) { - const newTime = { from: start, to: end }; - // Update timefilter for controllers listening for changes - timefilter.setTime(newTime); - setTime(newTime); - setRecentlyUsedRanges(getRecentlyUsedRanges()); - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateTimeFilter = useCallback( + ({ start, end }: OnTimeChangeProps) => { + setRecentlyUsedRanges(getRecentlyUsedRanges()); + setGlobalState('time', { + from: start, + to: end, + ...(start === 'now' || end === 'now' ? { ts: Date.now() } : {}), + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setGlobalState] + ); function updateInterval({ isPaused: pause, @@ -144,26 +235,44 @@ export const DatePickerWrapper: FC = () => { setRefreshInterval({ pause, value }); } - /** - * Enforce pause when it's set to false with 0 refresh interval. - */ - const isPaused = refreshInterval.pause || (!refreshInterval.pause && !refreshInterval.value); - return isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled ? ( -
- -
+ + + + + + {isTimeRangeSelectorEnabled ? null : ( + + updateLastRefresh()} + data-test-subj="dataVisualizerRefreshPageButton" + > + + + + )} + ) : null; }; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_time_filter.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_time_filter.ts index 132d03c81c0e6..727c8bab88dc3 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_time_filter.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/hooks/use_time_filter.ts @@ -6,6 +6,8 @@ */ import { useEffect } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs/operators'; import { useDataVisualizerKibana } from '../../kibana_context'; interface UseTimefilterOptions { @@ -36,3 +38,22 @@ export const useTimefilter = ({ return timefilter; }; + +export const useRefreshIntervalUpdates = () => { + const timefilter = useTimefilter(); + + return useObservable( + timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)), + timefilter.getRefreshInterval() + ); +}; + +export const useTimeRangeUpdates = (absolute = false) => { + const timefilter = useTimefilter(); + + const getTimeCallback = absolute + ? timefilter.getAbsoluteTime.bind(timefilter) + : timefilter.getTime.bind(timefilter); + + return useObservable(timefilter.getTimeUpdate$().pipe(map(getTimeCallback)), getTimeCallback()); +}; diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts new file mode 100644 index 0000000000000..29becfa3e99ae --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.test.ts @@ -0,0 +1,52 @@ +/* + * 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 { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { BUILT_IN_MODEL_TAG } from '@kbn/ml-plugin/common/constants/data_frame_analytics'; + +import { getMlModelTypesForModelConfig, BUILT_IN_MODEL_TAG as LOCAL_BUILT_IN_MODEL_TAG } from '.'; + +describe('getMlModelTypesForModelConfig lib function', () => { + const mockModel: MlTrainedModelConfig = { + inference_config: { + ner: {}, + }, + input: { + field_names: [], + }, + model_id: 'test_id', + model_type: 'pytorch', + tags: ['test_tag'], + }; + const builtInMockModel: MlTrainedModelConfig = { + inference_config: { + text_classification: {}, + }, + input: { + field_names: [], + }, + model_id: 'test_id', + model_type: 'lang_ident', + tags: [BUILT_IN_MODEL_TAG], + }; + + it('should return the model type and inference config type', () => { + const expected = ['pytorch', 'ner']; + const response = getMlModelTypesForModelConfig(mockModel); + expect(response.sort()).toEqual(expected.sort()); + }); + + it('should include the built in type', () => { + const expected = ['lang_ident', 'text_classification', BUILT_IN_MODEL_TAG]; + const response = getMlModelTypesForModelConfig(builtInMockModel); + expect(response.sort()).toEqual(expected.sort()); + }); + + it('local BUILT_IN_MODEL_TAG matches ml plugin', () => { + expect(LOCAL_BUILT_IN_MODEL_TAG).toEqual(BUILT_IN_MODEL_TAG); + }); +}); diff --git a/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts new file mode 100644 index 0000000000000..3bc43fa14d7b2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/ml_inference_pipeline/index.ts @@ -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 { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import { MlInferencePipeline } from '../types/pipelines'; + +// Getting an error importing this from @kbn/ml-plugin/common/constants/data_frame_analytics' +// So defining it locally for now with a test to make sure it matches. +export const BUILT_IN_MODEL_TAG = 'prepackaged'; + +export interface MlInferencePipelineParams { + description?: string; + destinationField: string; + model: MlTrainedModelConfig; + pipelineName: string; + sourceField: string; +} + +/** + * Generates the pipeline body for a machine learning inference pipeline + * @param pipelineConfiguration machine learning inference pipeline configuration parameters + * @returns pipeline body + */ +export const generateMlInferencePipelineBody = ({ + description, + destinationField, + model, + pipelineName, + sourceField, +}: MlInferencePipelineParams): MlInferencePipeline => { + // if model returned no input field, insert a placeholder + const modelInputField = + model.input?.field_names?.length > 0 ? model.input.field_names[0] : 'MODEL_INPUT_FIELD'; + return { + description: description ?? '', + processors: [ + { + remove: { + field: `ml.inference.${destinationField}`, + ignore_missing: true, + }, + }, + { + inference: { + field_map: { + [sourceField]: modelInputField, + }, + model_id: model.model_id, + target_field: `ml.inference.${destinationField}`, + }, + }, + { + append: { + field: '_source._ingest.processors', + value: [ + { + model_version: model.version, + pipeline: pipelineName, + processed_timestamp: '{{{ _ingest.timestamp }}}', + types: getMlModelTypesForModelConfig(model), + }, + ], + }, + }, + ], + version: 1, + }; +}; + +/** + * Parses model types list from the given configuration of a trained machine learning model + * @param trainedModel configuration for a trained machine learning model + * @returns list of model types + */ +export const getMlModelTypesForModelConfig = (trainedModel: MlTrainedModelConfig): string[] => { + if (!trainedModel) return []; + + const isBuiltIn = trainedModel.tags?.includes(BUILT_IN_MODEL_TAG); + + return [ + trainedModel.model_type, + ...Object.keys(trainedModel.inference_config || {}), + ...(isBuiltIn ? [BUILT_IN_MODEL_TAG] : []), + ].filter((type): type is string => type !== undefined); +}; + +export const formatPipelineName = (rawName: string) => + rawName + .trim() + .replace(/\s+/g, '_') // Convert whitespaces to underscores + .toLowerCase(); diff --git a/x-pack/plugins/enterprise_search/common/types/pipelines.ts b/x-pack/plugins/enterprise_search/common/types/pipelines.ts index 269f7149cc7b3..8652eb57f9b48 100644 --- a/x-pack/plugins/enterprise_search/common/types/pipelines.ts +++ b/x-pack/plugins/enterprise_search/common/types/pipelines.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types'; + export interface InferencePipeline { modelState: TrainedModelState; modelStateReason?: string; @@ -19,3 +21,7 @@ export enum TrainedModelState { Started = 'started', Failed = 'failed', } + +export interface MlInferencePipeline extends IngestPipeline { + version?: number; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx index 8b2cfb1d4b5d8..916a295df0050 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/add_ml_inference_pipeline_modal.tsx @@ -15,6 +15,8 @@ import { EuiCallOut, EuiFlexGroup, EuiFlexItem, + EuiStepsHorizontal, + EuiStepsHorizontalProps, EuiModal, EuiModalBody, EuiModalFooter, @@ -26,11 +28,18 @@ import { import { i18n } from '@kbn/i18n'; +import { + BACK_BUTTON_LABEL, + CANCEL_BUTTON_LABEL, + CONTINUE_BUTTON_LABEL, +} from '../../../../../shared/constants'; import { IndexNameLogic } from '../../index_name_logic'; import { ConfigurePipeline } from './configure_pipeline'; -import { MLInferenceLogic, AddInferencePipelineModal } from './ml_inference_logic'; +import { AddInferencePipelineSteps, MLInferenceLogic } from './ml_inference_logic'; import { NoModelsPanel } from './no_models'; +import { ReviewPipeline } from './review_pipeline'; +import { TestPipeline } from './test_pipeline'; interface AddMLInferencePipelineModalProps { onClose: () => void; @@ -64,15 +73,13 @@ export const AddMLInferencePipelineModal: React.FC { - const { pipelineName, modelID, sourceField } = configuration; - return pipelineName.trim().length === 0 || modelID.length === 0 || sourceField.length === 0; -}; - const AddProcessorContent: React.FC = ({ onClose }) => { - const { addInferencePipelineModal, createErrors, supportedMLModels, isLoading } = - useValues(MLInferenceLogic); - const { createPipeline } = useActions(MLInferenceLogic); + const { + createErrors, + supportedMLModels, + isLoading, + addInferencePipelineModal: { step }, + } = useValues(MLInferenceLogic); if (isLoading) { return ( @@ -103,37 +110,126 @@ const AddProcessorContent: React.FC = ({ onClo )} - + + {step === AddInferencePipelineSteps.Configuration && } + {step === AddInferencePipelineSteps.Test && } + {step === AddInferencePipelineSteps.Review && } - - - - - - {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.footer.cancel', - { - defaultMessage: 'Cancel', - } - )} + + + ); +}; + +const ModalSteps: React.FC = () => { + const { + addInferencePipelineModal: { step }, + isPipelineDataValid, + } = useValues(MLInferenceLogic); + const { setAddInferencePipelineStep } = useActions(MLInferenceLogic); + const navSteps: EuiStepsHorizontalProps['steps'] = [ + { + onClick: () => setAddInferencePipelineStep(AddInferencePipelineSteps.Configuration), + status: isPipelineDataValid ? 'complete' : 'disabled', + title: i18n.translate( + 'xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.steps.configure.title', + { + defaultMessage: 'Configure', + } + ), + }, + { + onClick: () => setAddInferencePipelineStep(AddInferencePipelineSteps.Test), + status: isPipelineDataValid ? 'incomplete' : 'disabled', + title: i18n.translate( + 'xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.steps.test.title', + { + defaultMessage: 'Test', + } + ), + }, + { + onClick: () => setAddInferencePipelineStep(AddInferencePipelineSteps.Review), + status: isPipelineDataValid ? 'incomplete' : 'disabled', + title: i18n.translate( + 'xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.steps.review.title', + { + defaultMessage: 'Review', + } + ), + }, + ]; + switch (step) { + case AddInferencePipelineSteps.Configuration: + navSteps[0].status = isPipelineDataValid ? 'complete' : 'current'; + break; + case AddInferencePipelineSteps.Test: + navSteps[1].status = 'current'; + break; + case AddInferencePipelineSteps.Review: + navSteps[2].status = 'current'; + break; + } + return ; +}; + +const ModalFooter: React.FC = ({ onClose }) => { + const { addInferencePipelineModal: modal, isPipelineDataValid } = useValues(MLInferenceLogic); + const { createPipeline, setAddInferencePipelineStep } = useActions(MLInferenceLogic); + + let nextStep: AddInferencePipelineSteps | undefined; + let previousStep: AddInferencePipelineSteps | undefined; + switch (modal.step) { + case AddInferencePipelineSteps.Test: + nextStep = AddInferencePipelineSteps.Review; + previousStep = AddInferencePipelineSteps.Configuration; + break; + case AddInferencePipelineSteps.Review: + previousStep = AddInferencePipelineSteps.Test; + break; + case AddInferencePipelineSteps.Configuration: + nextStep = AddInferencePipelineSteps.Test; + break; + } + return ( + + + + {previousStep !== undefined ? ( + setAddInferencePipelineStep(previousStep as AddInferencePipelineSteps)} + > + {BACK_BUTTON_LABEL} - - + ) : null} + + + + {CANCEL_BUTTON_LABEL} + + + {nextStep !== undefined ? ( setAddInferencePipelineStep(nextStep as AddInferencePipelineSteps)} + disabled={!isPipelineDataValid} > + {CONTINUE_BUTTON_LABEL} + + ) : ( + {i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.footer.create', + 'xpack.enterpriseSearch.content.indices.transforms.addInferencePipelineModal.footer.create', { defaultMessage: 'Create', } )} - - - - + )} + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx index 199d62f914f03..b1d8fd4d074a8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/configure_pipeline.tsx @@ -36,6 +36,7 @@ export const ConfigurePipeline: React.FC = () => { const { destinationField, modelID, pipelineName, sourceField } = configuration; const models = supportedMLModels ?? []; + const nameError = formErrors.pipelineName !== undefined && pipelineName.length > 0; return ( <> @@ -73,7 +74,7 @@ export const ConfigurePipeline: React.FC = () => { } )} helpText={ - formErrors.pipelineName === undefined && + !nameError && i18n.translate( 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.name.helpText', { @@ -82,8 +83,8 @@ export const ConfigurePipeline: React.FC = () => { } ) } - error={formErrors.pipelineName} - isInvalid={formErrors.pipelineName !== undefined} + error={nameError && formErrors.pipelineName} + isInvalid={nameError} > void; createApiError: (error: HttpError) => HttpError; createApiSuccess: typeof CreateMlInferencePipelineApiLogic.actions.apiSuccess; createPipeline: () => void; @@ -49,10 +59,10 @@ interface MLInferenceProcessorsActions { makeMappingRequest: typeof MappingsApiLogic.actions.makeRequest; mappingsApiError(error: HttpError): HttpError; mlModelsApiError(error: HttpError): HttpError; - setCreateErrors(errors: string[]): { errors: string[] }; - setFormErrors: (inputErrors: AddInferencePipelineFormErrors) => { - inputErrors: AddInferencePipelineFormErrors; + setAddInferencePipelineStep: (step: AddInferencePipelineSteps) => { + step: AddInferencePipelineSteps; }; + setCreateErrors(errors: string[]): { errors: string[] }; setIndexName: (indexName: string) => { indexName: string }; setInferencePipelineConfiguration: (configuration: InferencePipelineConfiguration) => { configuration: InferencePipelineConfiguration; @@ -62,6 +72,7 @@ interface MLInferenceProcessorsActions { export interface AddInferencePipelineModal { configuration: InferencePipelineConfiguration; indexName: string; + step: AddInferencePipelineSteps; } interface MLInferenceProcessorsValues { @@ -69,8 +80,10 @@ interface MLInferenceProcessorsValues { createErrors: string[]; formErrors: AddInferencePipelineFormErrors; isLoading: boolean; + isPipelineDataValid: boolean; mappingData: typeof MappingsApiLogic.values.data; mappingStatus: Status; + mlInferencePipeline?: MlInferencePipeline; mlModelsData: typeof MLModelsApiLogic.values.data; mlModelsStatus: typeof MLModelsApiLogic.values.apiStatus; sourceFields: string[] | undefined; @@ -83,6 +96,7 @@ export const MLInferenceLogic = kea< actions: { clearFormErrors: true, createPipeline: true, + setAddInferencePipelineStep: (step: AddInferencePipelineSteps) => ({ step }), setCreateErrors: (errors: string[]) => ({ errors }), setFormErrors: (inputErrors: AddInferencePipelineFormErrors) => ({ inputErrors }), setIndexName: (indexName: string) => ({ indexName }), @@ -124,12 +138,6 @@ export const MLInferenceLogic = kea< const { addInferencePipelineModal: { configuration, indexName }, } = values; - const validationErrors = validateInferencePipelineConfiguration(configuration); - if (validationErrors !== undefined) { - actions.setFormErrors(validationErrors); - return; - } - actions.clearFormErrors(); actions.makeCreatePipelineRequest({ indexName, @@ -155,8 +163,10 @@ export const MLInferenceLogic = kea< ...EMPTY_PIPELINE_CONFIGURATION, }, indexName: '', + step: AddInferencePipelineSteps.Configuration, }, { + setAddInferencePipelineStep: (modal, { step }) => ({ ...modal, step }), setIndexName: (modal, { indexName }) => ({ ...modal, indexName }), setInferencePipelineConfiguration: (modal, { configuration }) => ({ ...modal, @@ -171,21 +181,47 @@ export const MLInferenceLogic = kea< setCreateErrors: (_, { errors }) => errors, }, ], - formErrors: [ - {}, - { - clearFormErrors: () => ({}), - setFormErrors: (_, { inputErrors }) => inputErrors, - }, - ], }, selectors: ({ selectors }) => ({ + formErrors: [ + () => [selectors.addInferencePipelineModal], + (modal: AddInferencePipelineModal) => + validateInferencePipelineConfiguration(modal.configuration), + ], isLoading: [ () => [selectors.mlModelsStatus, selectors.mappingStatus], (mlModelsStatus, mappingStatus) => !API_REQUEST_COMPLETE_STATUSES.includes(mlModelsStatus) || !API_REQUEST_COMPLETE_STATUSES.includes(mappingStatus), ], + isPipelineDataValid: [ + () => [selectors.formErrors], + (errors: AddInferencePipelineFormErrors) => Object.keys(errors).length === 0, + ], + mlInferencePipeline: [ + () => [ + selectors.isPipelineDataValid, + selectors.addInferencePipelineModal, + selectors.mlModelsData, + ], + ( + isPipelineDataValid: boolean, + { configuration }: AddInferencePipelineModal, + models: MLInferenceProcessorsValues['mlModelsData'] + ) => { + if (!isPipelineDataValid) return undefined; + const model = models?.find((mlModel) => mlModel.model_id === configuration.modelID); + if (!model) return undefined; + + return generateMlInferencePipelineBody({ + destinationField: + configuration.destinationField || formatPipelineName(configuration.pipelineName), + model, + pipelineName: configuration.pipelineName, + sourceField: configuration.sourceField, + }); + }, + ], sourceFields: [ () => [selectors.mappingStatus, selectors.mappingData], (status: Status, mapping: IndicesGetMappingIndexMappingRecord) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/review_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/review_pipeline.tsx new file mode 100644 index 0000000000000..580a5acaac2cc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/review_pipeline.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 { useValues } from 'kea'; + +import { EuiCodeBlock, EuiSpacer, EuiText } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { MLInferenceLogic } from './ml_inference_logic'; + +export const ReviewPipeline: React.FC = () => { + const { mlInferencePipeline } = useValues(MLInferenceLogic); + return ( + <> + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.review.title', + { + defaultMessage: 'Pipeline configuration', + } + )} +

+
+ + {JSON.stringify(mlInferencePipeline ?? {}, null, 2)} + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.review.description', + { + defaultMessage: + "This pipeline will be created and injected as a processor into your default pipeline for this index. You'll be able to use this new pipeline independently as well.", + } + )} +

+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx new file mode 100644 index 0000000000000..523973ad2d3d1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/test_pipeline.tsx @@ -0,0 +1,12 @@ +/* + * 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'; + +export const TestPipeline: React.FC = () => { + return
Test Pipeline
; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts index 9ada53d224c8e..29ad5e9193fdb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/types.ts @@ -14,5 +14,7 @@ export interface InferencePipelineConfiguration { export interface AddInferencePipelineFormErrors { destinationField?: string; + modelID?: string; pipelineName?: string; + sourceField?: string; } diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts index b788a522d395f..8e168586a4819 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/pipelines/ml_inference/utils.ts @@ -41,17 +41,34 @@ export const isValidPipelineName = (input: string): boolean => { return input.length > 0 && VALID_PIPELINE_NAME_REGEX.test(input); }; +const INVALID_PIPELINE_NAME_ERROR = i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.invalidPipelineName', + { + defaultMessage: 'Name must only contain letters, numbers, underscores, and hyphens.', + } +); +const FIELD_REQUIRED_ERROR = i18n.translate( + 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.emptyValueError', + { + defaultMessage: 'Field is required.', + } +); + export const validateInferencePipelineConfiguration = ( config: InferencePipelineConfiguration -): AddInferencePipelineFormErrors | undefined => { +): AddInferencePipelineFormErrors => { const errors: AddInferencePipelineFormErrors = {}; - if (!isValidPipelineName(config.pipelineName)) { - errors.pipelineName = i18n.translate( - 'xpack.enterpriseSearch.content.indices.pipelines.addInferencePipelineModal.steps.configure.invalidPipelineName', - { - defaultMessage: 'Name must only contain letters, numbers, underscores, and hyphens.', - } - ); - return errors; + if (config.pipelineName.trim().length === 0) { + errors.pipelineName = FIELD_REQUIRED_ERROR; + } else if (!isValidPipelineName(config.pipelineName)) { + errors.pipelineName = INVALID_PIPELINE_NAME_ERROR; } + if (config.modelID.trim().length === 0) { + errors.modelID = FIELD_REQUIRED_ERROR; + } + if (config.sourceField.trim().length === 0) { + errors.sourceField = FIELD_REQUIRED_ERROR; + } + + return errors; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/settings/settings.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/settings/settings.tsx index eaab5d56b1b46..94c4c1fdbaad9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/settings/settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/settings/settings.tsx @@ -31,9 +31,6 @@ export const Settings: React.FC = () => { return ( { }); }); -describe('getMlModelTypesForModelConfig lib function', () => { - const mockModel: MlTrainedModelConfig = { - inference_config: { - ner: {}, - }, - input: { - field_names: [], - }, - model_id: 'test_id', - model_type: 'pytorch', - tags: ['test_tag'], - }; - const builtInMockModel: MlTrainedModelConfig = { - inference_config: { - text_classification: {}, - }, - input: { - field_names: [], - }, - model_id: 'test_id', - model_type: 'lang_ident', - tags: [BUILT_IN_MODEL_TAG], - }; - - it('should return the model type and inference config type', () => { - const expected = ['pytorch', 'ner']; - const response = getMlModelTypesForModelConfig(mockModel); - expect(response.sort()).toEqual(expected.sort()); - }); - - it('should include the built in type', () => { - const expected = ['lang_ident', 'text_classification', BUILT_IN_MODEL_TAG]; - const response = getMlModelTypesForModelConfig(builtInMockModel); - expect(response.sort()).toEqual(expected.sort()); - }); -}); - describe('getMlModelConfigsForModelIds lib function', () => { const mockClient = { ml: { diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.ts b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.ts index 95839a9b6ac20..72867ad717065 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/fetch_ml_inference_pipeline_processors.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { MlTrainedModelConfig } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ElasticsearchClient } from '@kbn/core/server'; -import { BUILT_IN_MODEL_TAG } from '@kbn/ml-plugin/common/constants/data_frame_analytics'; +import { getMlModelTypesForModelConfig } from '../../../common/ml_inference_pipeline'; import { InferencePipeline, TrainedModelState } from '../../../common/types/pipelines'; import { getInferencePipelineNameFromIndexName } from '../../utils/ml_inference_pipeline_utils'; @@ -70,18 +69,6 @@ export const fetchPipelineProcessorInferenceData = async ( ); }; -export const getMlModelTypesForModelConfig = (trainedModel: MlTrainedModelConfig): string[] => { - if (!trainedModel) return []; - - const isBuiltIn = trainedModel.tags?.includes(BUILT_IN_MODEL_TAG); - - return [ - trainedModel.model_type, - ...Object.keys(trainedModel.inference_config || {}), - ...(isBuiltIn ? [BUILT_IN_MODEL_TAG] : []), - ].filter((type): type is string => type !== undefined); -}; - export const getMlModelConfigsForModelIds = async ( client: ElasticsearchClient, trainedModelNames: string[] diff --git a/x-pack/plugins/enterprise_search/server/lib/pipelines/create_pipeline_definitions.ts b/x-pack/plugins/enterprise_search/server/lib/pipelines/create_pipeline_definitions.ts index bcb33f9f82b36..f32590fb516c5 100644 --- a/x-pack/plugins/enterprise_search/server/lib/pipelines/create_pipeline_definitions.ts +++ b/x-pack/plugins/enterprise_search/server/lib/pipelines/create_pipeline_definitions.ts @@ -5,20 +5,16 @@ * 2.0. */ -import { IngestPipeline } from '@elastic/elasticsearch/lib/api/types'; import { ElasticsearchClient } from '@kbn/core/server'; +import { generateMlInferencePipelineBody } from '../../../common/ml_inference_pipeline'; +import { MlInferencePipeline } from '../../../common/types/pipelines'; import { getInferencePipelineNameFromIndexName } from '../../utils/ml_inference_pipeline_utils'; -import { getMlModelTypesForModelConfig } from '../indices/fetch_ml_inference_pipeline_processors'; export interface CreatedPipelines { created: string[]; } -export interface MlInferencePipeline extends IngestPipeline { - version?: number; -} - /** * Used to create index-specific Ingest Pipelines to be used in conjunction with Enterprise Search * ingestion mechanisms. Three pipelines are created: @@ -237,43 +233,10 @@ export const formatMlPipelineBody = async ( // this will raise a 404 if model doesn't exist const models = await esClient.ml.getTrainedModels({ model_id: modelId }); const model = models.trained_model_configs[0]; - // if model returned no input field, insert a placeholder - const modelInputField = - model.input?.field_names?.length > 0 ? model.input.field_names[0] : 'MODEL_INPUT_FIELD'; - const modelTypes = getMlModelTypesForModelConfig(model); - const modelVersion = model.version; - return { - description: '', - processors: [ - { - remove: { - field: `ml.inference.${destinationField}`, - ignore_missing: true, - }, - }, - { - inference: { - field_map: { - [sourceField]: modelInputField, - }, - model_id: modelId, - target_field: `ml.inference.${destinationField}`, - }, - }, - { - append: { - field: '_source._ingest.processors', - value: [ - { - model_version: modelVersion, - pipeline: pipelineName, - processed_timestamp: '{{{ _ingest.timestamp }}}', - types: modelTypes, - }, - ], - }, - }, - ], - version: 1, - }; + return generateMlInferencePipelineBody({ + destinationField, + model, + pipelineName, + sourceField, + }); }; diff --git a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts index dfcd8d4884972..da4bce12d71ef 100644 --- a/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts +++ b/x-pack/plugins/enterprise_search/server/utils/create_ml_inference_pipeline.ts @@ -8,6 +8,7 @@ import { IngestGetPipelineResponse, IngestPipeline } from '@elastic/elasticsearch/lib/api/types'; import { ElasticsearchClient } from '@kbn/core/server'; +import { formatPipelineName } from '../../common/ml_inference_pipeline'; import { ErrorCode } from '../../common/types/error_codes'; import { formatMlPipelineBody } from '../lib/pipelines/create_pipeline_definitions'; @@ -15,7 +16,6 @@ import { formatMlPipelineBody } from '../lib/pipelines/create_pipeline_definitio import { getInferencePipelineNameFromIndexName, getPrefixedInferencePipelineProcessorName, - formatPipelineName, } from './ml_inference_pipeline_utils'; /** diff --git a/x-pack/plugins/enterprise_search/server/utils/ml_inference_pipeline_utils.ts b/x-pack/plugins/enterprise_search/server/utils/ml_inference_pipeline_utils.ts index 6547034f25403..e92db3d263a63 100644 --- a/x-pack/plugins/enterprise_search/server/utils/ml_inference_pipeline_utils.ts +++ b/x-pack/plugins/enterprise_search/server/utils/ml_inference_pipeline_utils.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { formatPipelineName } from '../../common/ml_inference_pipeline'; + export const getInferencePipelineNameFromIndexName = (indexName: string) => `${indexName}@ml-inference`; @@ -12,9 +14,3 @@ export const getPrefixedInferencePipelineProcessorName = (pipelineName: string) pipelineName.startsWith('ml-inference-') ? formatPipelineName(pipelineName) : `ml-inference-${formatPipelineName(pipelineName)}`; - -export const formatPipelineName = (rawName: string) => - rawName - .trim() - .replace(/\s+/g, '_') // Convert whitespaces to underscores - .toLowerCase(); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts index df1c87105bcf2..bb2be99b868bc 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts @@ -48,6 +48,11 @@ describe('buildDefaultSettings', () => { name: 'field5Wildcard', type: 'wildcard', }, + { + name: 'field6NotDefault', + type: 'keyword', + default_field: false, + }, ], }); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts index 5accf7b120f9e..b15eb01117825 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts @@ -11,19 +11,20 @@ import type { Field, Fields } from '../../fields/field'; const QUERY_DEFAULT_FIELD_TYPES = ['keyword', 'text', 'match_only_text', 'wildcard']; const QUERY_DEFAULT_FIELD_LIMIT = 1024; -const flattenFieldsToNameAndType = ( +const flattenAndExtractFields = ( fields: Fields, path: string = '' -): Array> => { - let newFields: Array> = []; +): Array> => { + let newFields: Array> = []; fields.forEach((field) => { const fieldName = path ? `${path}.${field.name}` : field.name; newFields.push({ name: fieldName, type: field.type, + default_field: field.default_field, }); if (field.fields && field.fields.length) { - newFields = newFields.concat(flattenFieldsToNameAndType(field.fields, fieldName)); + newFields = newFields.concat(flattenAndExtractFields(field.fields, fieldName)); } }); return newFields; @@ -45,8 +46,9 @@ export function buildDefaultSettings({ const logger = appContextService.getLogger(); // Find all field names to set `index.query.default_field` to, which will be // the first 1024 keyword or text fields - const defaultFields = flattenFieldsToNameAndType(fields).filter( - (field) => field.type && QUERY_DEFAULT_FIELD_TYPES.includes(field.type) + const defaultFields = flattenAndExtractFields(fields).filter( + (field) => + field.type && QUERY_DEFAULT_FIELD_TYPES.includes(field.type) && field.default_field !== false ); if (defaultFields.length > QUERY_DEFAULT_FIELD_LIMIT) { logger.warn( diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index e970a2fea1459..b8af566463896 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -37,6 +37,7 @@ export interface Field { include_in_root?: boolean; null_value?: string; dimension?: boolean; + default_field?: boolean; // Meta fields metric_type?: string; diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 8875d2d0f9839..e72253f6e2a5f 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -15,8 +15,8 @@ import { downloadMultipleAs } from '@kbn/share-plugin/public'; import { tableHasFormulas } from '@kbn/data-plugin/common'; import { exporters, getEsQueryConfig } from '@kbn/data-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; -import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { DataViewPickerProps } from '@kbn/unified-search-plugin/public'; import { ENABLE_SQL } from '../../common'; import { LensAppServices, @@ -768,13 +768,15 @@ export const LensTopNavMenu = ({ closeDataViewEditor.current = dataViewEditor.openEditor({ onSave: async (dataView) => { if (dataView.id) { - dispatch( - switchAndCleanDatasource({ - newDatasourceId: 'indexpattern', - visualizationId: visualization?.activeId, - currentIndexPatternId: dataView?.id, - }) - ); + if (isOnTextBasedMode) { + dispatch( + switchAndCleanDatasource({ + newDatasourceId: 'indexpattern', + visualizationId: visualization?.activeId, + currentIndexPatternId: dataView?.id, + }) + ); + } dispatchChangeIndexPattern(dataView); setCurrentIndexPattern(dataView); } @@ -783,7 +785,43 @@ export const LensTopNavMenu = ({ }); } : undefined, - [canEditDataView, dataViewEditor, dispatch, dispatchChangeIndexPattern, visualization?.activeId] + [ + canEditDataView, + dataViewEditor, + dispatch, + dispatchChangeIndexPattern, + isOnTextBasedMode, + visualization?.activeId, + ] + ); + + const onCreateDefaultAdHocDataView = useCallback( + async (pattern: string) => { + const dataView = await dataViewsService.create({ + title: pattern, + }); + if (dataView.fields.getByName('@timestamp')?.type === 'date') { + dataView.timeFieldName = '@timestamp'; + } + if (isOnTextBasedMode) { + dispatch( + switchAndCleanDatasource({ + newDatasourceId: 'indexpattern', + visualizationId: visualization?.activeId, + currentIndexPatternId: dataView?.id, + }) + ); + } + dispatchChangeIndexPattern(dataView); + setCurrentIndexPattern(dataView); + }, + [ + dataViewsService, + dispatch, + dispatchChangeIndexPattern, + isOnTextBasedMode, + visualization?.activeId, + ] ); // setting that enables/disables SQL @@ -793,7 +831,7 @@ export const LensTopNavMenu = ({ supportedTextBasedLanguages.push('SQL'); } - const dataViewPickerProps = { + const dataViewPickerProps: DataViewPickerProps = { trigger: { label: currentIndexPattern?.getName?.() || '', 'data-test-subj': 'lns-dataView-switch-link', @@ -802,6 +840,7 @@ export const LensTopNavMenu = ({ currentDataViewId: currentIndexPattern?.id, onAddField: addField, onDataViewCreated: createNewDataView, + onCreateDefaultAdHocDataView, adHocDataViews: indexPatterns.filter((pattern) => !pattern.isPersisted()), onChangeDataView: (newIndexPatternId: string) => { const currentDataView = dataViewsList.find( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx index a118183c49061..6e3978a414ec7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/draggable_dimension_button.tsx @@ -6,6 +6,7 @@ */ import React, { useMemo, useCallback, useContext, ReactElement } from 'react'; +import { isDraggedField } from '../../../../utils'; import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop'; import { Datasource, @@ -14,23 +15,20 @@ import { DropType, DatasourceLayers, IndexPatternMap, + DragDropOperation, + Visualization, } from '../../../../types'; import { getCustomDropTarget, getAdditionalClassesOnDroppable, getAdditionalClassesOnEnter, - getDropProps, } from './drop_targets_utils'; export function DraggableDimensionButton({ - layerId, - label, - accessorIndex, - groupIndex, - layerIndex, - columnId, + order, group, onDrop, + activeVisualization, onDragStart, onDragEnd, children, @@ -39,100 +37,82 @@ export function DraggableDimensionButton({ datasourceLayers, registerNewButtonRef, indexPatterns, + target, }: { - layerId: string; - groupIndex: number; - layerIndex: number; + target: DragDropOperation & { + id: string; + humanData: { + label: string; + groupLabel: string; + position: number; + layerNumber: number; + }; + }; + order: [2, number, number, number]; onDrop: (source: DragDropIdentifier, dropTarget: DragDropIdentifier, dropType?: DropType) => void; onDragStart: () => void; onDragEnd: () => void; + activeVisualization: Visualization; group: VisualizationDimensionGroupConfig; - label: string; children: ReactElement; layerDatasource?: Datasource; datasourceLayers: DatasourceLayers; state: unknown; - accessorIndex: number; - columnId: string; registerNewButtonRef: (id: string, instance: HTMLDivElement | null) => void; indexPatterns: IndexPatternMap; }) { const { dragging } = useContext(DragContext); - const sharedDatasource = - !isOperation(dragging) || - datasourceLayers?.[dragging.layerId]?.datasourceId === datasourceLayers?.[layerId]?.datasourceId - ? layerDatasource - : undefined; + let getDropProps; - const dropProps = getDropProps( - { - state, - source: dragging, - target: { - layerId, - columnId, - groupId: group.groupId, - filterOperations: group.filterOperations, - prioritizedOperation: group.prioritizedOperation, - }, - indexPatterns, - }, - sharedDatasource - ); + if (dragging) { + if (!layerDatasource) { + getDropProps = activeVisualization.getDropProps; + } else if ( + isDraggedField(dragging) || + (isOperation(dragging) && + layerDatasource && + datasourceLayers?.[dragging.layerId]?.datasourceId === + datasourceLayers?.[target.layerId]?.datasourceId) + ) { + getDropProps = layerDatasource.getDropProps; + } + } + + const { dropTypes, nextLabel } = getDropProps?.({ + state, + source: dragging, + target, + indexPatterns, + }) || { dropTypes: [], nextLabel: '' }; - const dropTypes = dropProps?.dropTypes; - const nextLabel = dropProps?.nextLabel; const canDuplicate = !!( - dropTypes && - (dropTypes.includes('replace_duplicate_incompatible') || - dropTypes.includes('replace_duplicate_compatible')) + dropTypes.includes('replace_duplicate_incompatible') || + dropTypes.includes('replace_duplicate_compatible') ); const canSwap = !!( - dropTypes && - (dropTypes.includes('swap_incompatible') || dropTypes.includes('swap_compatible')) + dropTypes.includes('swap_incompatible') || dropTypes.includes('swap_compatible') ); const canCombine = Boolean( - dropTypes && - (dropTypes.includes('combine_compatible') || - dropTypes.includes('field_combine') || - dropTypes.includes('combine_incompatible')) + dropTypes.includes('combine_compatible') || + dropTypes.includes('field_combine') || + dropTypes.includes('combine_incompatible') ); const value = useMemo( () => ({ - columnId, - groupId: group.groupId, - layerId, - id: columnId, - filterOperations: group.filterOperations, + ...target, humanData: { + ...target.humanData, canSwap, canDuplicate, canCombine, - label, - groupLabel: group.groupLabel, - position: accessorIndex + 1, nextLabel: nextLabel || '', - layerNumber: layerIndex + 1, }, }), - [ - columnId, - group.groupId, - accessorIndex, - layerId, - label, - group.groupLabel, - nextLabel, - group.filterOperations, - canDuplicate, - canSwap, - canCombine, - layerIndex, - ] + [target, nextLabel, canDuplicate, canSwap, canCombine] ); const reorderableGroup = useMemo( @@ -144,8 +124,8 @@ export function DraggableDimensionButton({ ); const registerNewButtonRefMemoized = useCallback( - (el) => registerNewButtonRef(columnId, el), - [registerNewButtonRef, columnId] + (el) => registerNewButtonRef(target.columnId, el), + [registerNewButtonRef, target.columnId] ); const handleOnDrop = useCallback( @@ -162,7 +142,7 @@ export function DraggableDimensionButton({ getCustomDropTarget={getCustomDropTarget} getAdditionalClassesOnEnter={getAdditionalClassesOnEnter} getAdditionalClassesOnDroppable={getAdditionalClassesOnDroppable} - order={[2, layerIndex, groupIndex, accessorIndex]} + order={order} draggable dragType={isOperation(dragging) ? 'move' : 'copy'} dropTypes={dropTypes} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.test.tsx deleted file mode 100644 index 17907ac19c4bc..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getDropProps } from './drop_targets_utils'; -import { createMockDatasource } from '../../../../mocks'; - -describe('getDropProps', () => { - it('should run datasource getDropProps if exists', () => { - const mockDatasource = createMockDatasource('testDatasource'); - getDropProps( - { - state: 'datasourceState', - target: { - columnId: 'col1', - groupId: 'x', - layerId: 'first', - filterOperations: () => true, - }, - source: { - columnId: 'col1', - groupId: 'x', - layerId: 'first', - id: 'annotationColumn2', - humanData: { label: 'Event' }, - }, - indexPatterns: {}, - }, - mockDatasource - ); - expect(mockDatasource.getDropProps).toHaveBeenCalled(); - }); - describe('no datasource', () => { - it('returns reorder for the same group existing columns', () => { - expect( - getDropProps({ - state: 'datasourceState', - target: { - columnId: 'annotationColumn', - groupId: 'xAnnotations', - layerId: 'second', - filterOperations: () => true, - }, - source: { - columnId: 'annotationColumn2', - groupId: 'xAnnotations', - layerId: 'second', - id: 'annotationColumn2', - humanData: { label: 'Event' }, - }, - indexPatterns: {}, - }) - ).toEqual({ dropTypes: ['reorder'] }); - }); - it('returns duplicate for the same group existing column and not existing column', () => { - expect( - getDropProps({ - state: 'datasourceState', - target: { - columnId: 'annotationColumn', - groupId: 'xAnnotations', - layerId: 'second', - isNewColumn: true, - filterOperations: () => true, - }, - source: { - columnId: 'annotationColumn2', - groupId: 'xAnnotations', - layerId: 'second', - id: 'annotationColumn2', - humanData: { label: 'Event' }, - }, - indexPatterns: {}, - }) - ).toEqual({ dropTypes: ['duplicate_compatible'] }); - }); - it('returns replace_duplicate and replace for replacing to different layer', () => { - expect( - getDropProps({ - state: 'datasourceState', - target: { - columnId: 'annotationColumn', - groupId: 'xAnnotations', - layerId: 'first', - filterOperations: () => true, - }, - source: { - columnId: 'annotationColumn2', - groupId: 'xAnnotations', - layerId: 'second', - id: 'annotationColumn2', - humanData: { label: 'Event' }, - }, - indexPatterns: {}, - }) - ).toEqual({ - dropTypes: ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'], - }); - }); - it('returns duplicate and move for replacing to different layer for empty column', () => { - expect( - getDropProps({ - state: 'datasourceState', - target: { - columnId: 'annotationColumn', - groupId: 'xAnnotations', - layerId: 'first', - isNewColumn: true, - filterOperations: () => true, - }, - source: { - columnId: 'annotationColumn2', - groupId: 'xAnnotations', - layerId: 'second', - id: 'annotationColumn2', - humanData: { label: 'Event' }, - }, - indexPatterns: {}, - }) - ).toEqual({ - dropTypes: ['move_compatible', 'duplicate_compatible'], - }); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx index 5f3fd2d4a73b5..3094a07cf3290 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils.tsx @@ -9,12 +9,10 @@ import React from 'react'; import classNames from 'classnames'; import { EuiIcon, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { DragDropIdentifier, DraggingIdentifier } from '../../../../drag_drop'; +import { DragDropIdentifier } from '../../../../drag_drop'; import { - Datasource, DropType, FramePublicAPI, - GetDropPropsArgs, isOperation, Visualization, DragDropOperation, @@ -140,53 +138,6 @@ export const getAdditionalClassesOnDroppable = (dropType?: string) => { } }; -const isOperationFromCompatibleGroup = (op1?: DraggingIdentifier, op2?: DragDropOperation) => { - return ( - isOperation(op1) && - isOperation(op2) && - op1.columnId !== op2.columnId && - op1.groupId === op2.groupId && - op1.layerId !== op2.layerId - ); -}; - -export const isOperationFromTheSameGroup = (op1?: DraggingIdentifier, op2?: DragDropOperation) => { - return ( - isOperation(op1) && - isOperation(op2) && - op1.columnId !== op2.columnId && - op1.groupId === op2.groupId && - op1.layerId === op2.layerId - ); -}; - -export function getDropPropsForSameGroup( - isNewColumn?: boolean -): { dropTypes: DropType[]; nextLabel?: string } | undefined { - return !isNewColumn ? { dropTypes: ['reorder'] } : { dropTypes: ['duplicate_compatible'] }; -} - -export const getDropProps = ( - dropProps: GetDropPropsArgs, - sharedDatasource?: Datasource -): { dropTypes: DropType[]; nextLabel?: string } | undefined => { - if (sharedDatasource) { - return sharedDatasource?.getDropProps(dropProps); - } else { - if (isOperationFromTheSameGroup(dropProps.source, dropProps.target)) { - return getDropPropsForSameGroup(dropProps.target.isNewColumn); - } - if (isOperationFromCompatibleGroup(dropProps.source, dropProps.target)) { - return { - dropTypes: dropProps.target.isNewColumn - ? ['move_compatible', 'duplicate_compatible'] - : ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'], - }; - } - } - return; -}; - export interface OnVisDropProps { prevState: T; target: DragDropOperation; @@ -215,7 +166,6 @@ export function onDropForVisualization( frame, }); - // remove source if ( isOperation(source) && (dropType === 'move_compatible' || diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx index b6d7e58b0f7e7..8b1fe4082b31c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/empty_dimension_button.tsx @@ -9,6 +9,7 @@ import React, { useMemo, useState, useEffect, useContext } from 'react'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { isDraggedField } from '../../../../utils'; import { generateId } from '../../../../id_generator'; import { DragDrop, DragDropIdentifier, DragContext } from '../../../../drag_drop'; @@ -19,16 +20,10 @@ import { DatasourceLayers, isOperation, IndexPatternMap, + DragDropOperation, + Visualization, } from '../../../../types'; -import { - getCustomDropTarget, - getAdditionalClassesOnDroppable, - getDropProps, -} from './drop_targets_utils'; - -const label = i18n.translate('xpack.lens.indexPattern.emptyDimensionButton', { - defaultMessage: 'Empty dimension', -}); +import { getCustomDropTarget, getAdditionalClassesOnDroppable } from './drop_targets_utils'; interface EmptyButtonProps { columnId: string; @@ -106,91 +101,81 @@ export function EmptyDimensionButton({ group, layerDatasource, state, - layerId, - groupIndex, - layerIndex, onClick, onDrop, datasourceLayers, indexPatterns, + activeVisualization, + order, + target, }: { - layerId: string; - groupIndex: number; - layerIndex: number; - onDrop: (source: DragDropIdentifier, dropTarget: DragDropIdentifier, dropType?: DropType) => void; - onClick: (id: string) => void; + order: [2, number, number, number]; group: VisualizationDimensionGroupConfig; layerDatasource?: Datasource; datasourceLayers: DatasourceLayers; state: unknown; + onDrop: (source: DragDropIdentifier, dropTarget: DragDropIdentifier, dropType?: DropType) => void; + onClick: (id: string) => void; indexPatterns: IndexPatternMap; + activeVisualization: Visualization; + target: Omit & { + humanData: { + groupLabel: string; + position: number; + layerNumber: number; + label: string; + }; + }; }) { const { dragging } = useContext(DragContext); - const sharedDatasource = - !isOperation(dragging) || - datasourceLayers?.[dragging.layerId]?.datasourceId === datasourceLayers?.[layerId]?.datasourceId - ? layerDatasource - : undefined; - const itemIndex = group.accessors.length; + let getDropProps; + + if (dragging) { + if (!layerDatasource) { + getDropProps = activeVisualization.getDropProps; + } else if ( + isDraggedField(dragging) || + (isOperation(dragging) && + layerDatasource && + datasourceLayers?.[dragging.layerId]?.datasourceId === + datasourceLayers?.[target.layerId]?.datasourceId) + ) { + getDropProps = layerDatasource.getDropProps; + } + } const [newColumnId, setNewColumnId] = useState(generateId()); useEffect(() => { setNewColumnId(generateId()); - }, [itemIndex]); - - const dropProps = getDropProps( - { - state, - source: dragging, - target: { - layerId, - columnId: newColumnId, - groupId: group.groupId, - filterOperations: group.filterOperations, - prioritizedOperation: group.prioritizedOperation, - isNewColumn: true, - }, - indexPatterns, - }, - sharedDatasource - ); + }, [group.accessors.length]); - const dropTypes = dropProps?.dropTypes; - const nextLabel = dropProps?.nextLabel; + const { dropTypes, nextLabel } = getDropProps?.({ + state, + source: dragging, + target: { + ...target, + columnId: newColumnId, + }, + indexPatterns, + }) || { dropTypes: [], nextLabel: '' }; const canDuplicate = !!( - dropTypes && - (dropTypes.includes('duplicate_compatible') || dropTypes.includes('duplicate_incompatible')) + dropTypes.includes('duplicate_compatible') || dropTypes.includes('duplicate_incompatible') ); const value = useMemo( () => ({ + ...target, columnId: newColumnId, - groupId: group.groupId, - layerId, - filterOperations: group.filterOperations, id: newColumnId, humanData: { - label, - groupLabel: group.groupLabel, - position: itemIndex + 1, + ...target.humanData, nextLabel: nextLabel || '', canDuplicate, - layerNumber: layerIndex + 1, }, }), - [ - newColumnId, - group.groupId, - layerId, - group.groupLabel, - group.filterOperations, - itemIndex, - nextLabel, - canDuplicate, - layerIndex, - ] + [newColumnId, target, nextLabel, canDuplicate] ); const handleOnDrop = React.useCallback( @@ -209,7 +194,7 @@ export function EmptyDimensionButton({ layerDatasource.getUsedDataView(layerDatasourceState, layer)), - defaultDataView: layerDatasource.getCurrentIndexPatternId(layerDatasourceState), + defaultDataView: layerDatasource.getUsedDataView(layerDatasourceState), } as ActionExecutionContext); } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx index 26d4c1f04f41a..97d0cf73b80dd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/clone_layer_action.tsx @@ -6,8 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { Visualization } from '../../../..'; -import { LayerAction } from './types'; +import type { LayerAction } from '../../../../types'; +import type { Visualization } from '../../../..'; interface CloneLayerAction { execute: () => void; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx index b9ca695882ef2..bc1c41caa650b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/layer_actions.tsx @@ -5,9 +5,8 @@ * 2.0. */ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import type { CoreStart } from '@kbn/core/public'; import { EuiButtonIcon, EuiContextMenuPanel, @@ -18,13 +17,28 @@ import { EuiText, EuiOutsideClickDetector, } from '@elastic/eui'; -import type { LayerType, Visualization } from '../../../..'; -import type { LayerAction } from './types'; - +import type { CoreStart } from '@kbn/core/public'; +import type { LayerType } from '../../../..'; +import type { LayerAction, Visualization } from '../../../../types'; import { getCloneLayerAction } from './clone_layer_action'; import { getRemoveLayerAction } from './remove_layer_action'; export interface LayerActionsProps { + layerIndex: number; + actions: LayerAction[]; +} + +/** @internal **/ +export const getSharedActions = ({ + core, + layerIndex, + layerType, + activeVisualization, + isOnlyLayer, + isTextBasedLanguage, + onCloneLayer, + onRemoveLayer, +}: { onRemoveLayer: () => void; onCloneLayer: () => void; layerIndex: number; @@ -33,14 +47,25 @@ export interface LayerActionsProps { layerType?: LayerType; isTextBasedLanguage?: boolean; core: Pick; -} +}) => [ + getCloneLayerAction({ + execute: onCloneLayer, + layerIndex, + activeVisualization, + isTextBasedLanguage, + }), + getRemoveLayerAction({ + execute: onRemoveLayer, + layerIndex, + activeVisualization, + layerType, + isOnlyLayer, + core, + }), +]; /** @internal **/ -const InContextMenuActions = ( - props: LayerActionsProps & { - actions: LayerAction[]; - } -) => { +const InContextMenuActions = (props: LayerActionsProps) => { const dataTestSubject = `lnsLayerSplitButton--${props.layerIndex}`; const [isPopoverOpen, setPopover] = useState(false); const splitButtonPopoverId = useGeneratedHtmlId({ @@ -105,47 +130,24 @@ const InContextMenuActions = ( }; export const LayerActions = (props: LayerActionsProps) => { - const compatibleActions = useMemo( - () => - [ - getCloneLayerAction({ - execute: props.onCloneLayer, - layerIndex: props.layerIndex, - activeVisualization: props.activeVisualization, - isTextBasedLanguage: props.isTextBasedLanguage, - }), - getRemoveLayerAction({ - execute: props.onRemoveLayer, - layerIndex: props.layerIndex, - activeVisualization: props.activeVisualization, - layerType: props.layerType, - isOnlyLayer: props.isOnlyLayer, - core: props.core, - }), - ].filter((i) => i.isCompatible), - [props] - ); - - if (!compatibleActions.length) { + if (!props.actions.length) { return null; } - if (compatibleActions.length > 1) { - return ; - } else { - const [{ displayName, execute, icon, color, 'data-test-subj': dataTestSubj }] = - compatibleActions; - - return ( - - ); + if (props.actions.length > 1) { + return ; } + const [{ displayName, execute, icon, color, 'data-test-subj': dataTestSubj }] = props.actions; + + return ( + + ); }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx index 32a18d1535697..58a4248b51857 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/remove_layer_action.tsx @@ -22,10 +22,9 @@ import { import { i18n } from '@kbn/i18n'; import { toMountPoint } from '@kbn/kibana-react-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; -import { LayerAction } from './types'; -import { Visualization } from '../../../../types'; +import type { LayerAction, Visualization } from '../../../../types'; import { LOCAL_STORAGE_LENS_KEY } from '../../../../settings_storage'; -import { LayerType, layerTypes } from '../../../..'; +import { type LayerType, layerTypes } from '../../../..'; interface RemoveLayerAction { execute: () => void; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/types.ts deleted file mode 100644 index 4614874777cc2..0000000000000 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_actions/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import type { IconType, EuiButtonIconColor } from '@elastic/eui'; - -/** @internal **/ -export interface LayerAction { - displayName: string; - execute: () => void | Promise; - icon: IconType; - color?: EuiButtonIconColor; - isCompatible: boolean; - 'data-test-subj'?: string; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index cc748df7c3ecd..3d1068ebd521f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -43,6 +43,7 @@ import { selectDatasourceStates, } from '../../../state_management'; import { onDropForVisualization } from './buttons/drop_targets_utils'; +import { getSharedActions } from './layer_actions/layer_actions'; const initialActiveDimensionState = { isNew: false, @@ -310,6 +311,39 @@ export function LayerPanel( const [datasource] = Object.values(framePublicAPI.datasourceLayers); const isTextBasedLanguage = Boolean(datasource?.isTextBasedLanguage()); + const compatibleActions = useMemo( + () => + [ + ...(activeVisualization.getSupportedActionsForLayer?.( + layerId, + visualizationState, + updateVisualization + ) || []), + ...getSharedActions({ + activeVisualization, + core, + layerIndex, + layerType: activeVisualization.getLayerType(layerId, visualizationState), + isOnlyLayer, + isTextBasedLanguage, + onCloneLayer, + onRemoveLayer, + }), + ].filter((i) => i.isCompatible), + [ + activeVisualization, + core, + isOnlyLayer, + isTextBasedLanguage, + layerId, + layerIndex, + onCloneLayer, + onRemoveLayer, + updateVisualization, + visualizationState, + ] + ); + return ( <>
@@ -332,16 +366,7 @@ export function LayerPanel( /> - + {(layerDatasource || activeVisualization.renderLayerPanel) && } @@ -458,18 +483,34 @@ export function LayerPanel( const { columnId } = accessorConfig; return ( setHideTooltip(true)} onDragEnd={() => setHideTooltip(false)} onDrop={onDrop} @@ -567,10 +608,27 @@ export function LayerPanel( {group.supportsMoreColumns ? ( s.datasourceId === 'textBasedLanguages'); + } + } if (suggestions.length) { return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts index c311c2d4e9f22..a1f16006fd803 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/get_drop_props.ts @@ -18,16 +18,13 @@ import { getOperationDisplay, hasOperationSupportForMultipleFields, } from '../../operations'; -import { hasField, isDraggedField } from '../../pure_utils'; +import { isDraggedDataViewField, isOperationFromTheSameGroup } from '../../../utils'; +import { hasField } from '../../pure_utils'; import { DragContextState } from '../../../drag_drop/providers'; -import { OperationMetadata } from '../../../types'; +import { OperationMetadata, DraggedField } from '../../../types'; import { getOperationTypesForField } from '../../operations'; import { GenericIndexPatternColumn } from '../../indexpattern'; -import { IndexPatternPrivateState, DraggedField, DataViewDragDropOperation } from '../../types'; -import { - getDropPropsForSameGroup, - isOperationFromTheSameGroup, -} from '../../../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils'; +import { IndexPatternPrivateState, DataViewDragDropOperation } from '../../types'; interface GetDropPropsArgs { state: IndexPatternPrivateState; @@ -72,7 +69,9 @@ export function getField(column: GenericIndexPatternColumn | undefined, dataView return field; } -export function getDropProps(props: GetDropPropsArgs) { +export function getDropProps( + props: GetDropPropsArgs +): { dropTypes: DropType[]; nextLabel?: string } | undefined { const { state, source, target, indexPatterns } = props; if (!source) { return; @@ -83,7 +82,7 @@ export function getDropProps(props: GetDropPropsArgs) { dataView: indexPatterns[state.layers[target.layerId].indexPatternId], }; - if (isDraggedField(source)) { + if (isDraggedDataViewField(source)) { return getDropPropsForField({ ...props, source, target: targetProps }); } @@ -98,7 +97,9 @@ export function getDropProps(props: GetDropPropsArgs) { } if (target.columnId !== source.columnId && targetProps.dataView === sourceProps.dataView) { if (isOperationFromTheSameGroup(source, target)) { - return getDropPropsForSameGroup(!targetProps.column); + return !targetProps.column + ? { dropTypes: ['duplicate_compatible'] } + : { dropTypes: ['reorder'] }; } if (targetProps.filterOperations?.(sourceProps?.column)) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/mocks.ts index 45987bb26cbb0..93b63071d6642 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/mocks.ts @@ -241,6 +241,7 @@ export const mockedDndOperations = { columnId: 'col1', id: 'col1', humanData: { label: 'Column 1' }, + indexPatternId: 'first', }, metric: { layerId: 'first', @@ -249,6 +250,7 @@ export const mockedDndOperations = { filterOperations: (op: OperationMetadata) => !op.isBucketed, id: 'col1', humanData: { label: 'Column 1' }, + indexPatternId: 'first', }, numericalOnly: { layerId: 'first', @@ -257,6 +259,7 @@ export const mockedDndOperations = { filterOperations: (op: OperationMetadata) => op.dataType === 'number', id: 'col1', humanData: { label: 'Column 1' }, + indexPatternId: 'first', }, bucket: { columnId: 'col2', @@ -265,6 +268,7 @@ export const mockedDndOperations = { id: 'col2', humanData: { label: 'Column 2' }, filterOperations: (op: OperationMetadata) => op.isBucketed, + indexPatternId: 'first', }, staticValue: { columnId: 'col1', @@ -273,6 +277,7 @@ export const mockedDndOperations = { id: 'col1', humanData: { label: 'Column 2' }, filterOperations: (op: OperationMetadata) => !!op.isStaticValue, + indexPatternId: 'first', }, bucket2: { columnId: 'col3', @@ -282,6 +287,7 @@ export const mockedDndOperations = { humanData: { label: '', }, + indexPatternId: 'first', }, metricC: { columnId: 'col4', @@ -292,5 +298,6 @@ export const mockedDndOperations = { label: '', }, filterOperations: (op: OperationMetadata) => !op.isBucketed, + indexPatternId: 'first', }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.test.ts index 6156be3570031..3b468181db6df 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.test.ts @@ -1562,12 +1562,14 @@ describe('IndexPatternDimensionEditorPanel: onDrop', () => { groupId: 'x', layerId: 'first', filterOperations: (op: OperationMetadata) => op.isBucketed, + indexPatternId: 'indexPattern1', }, target: { filterOperations: (op: OperationMetadata) => op.isBucketed, columnId: 'newCol', groupId: 'x', layerId: 'second', + indexPatternId: 'indexPattern1', }, dimensionGroups: defaultDimensionGroups, dropType: 'move_compatible', @@ -2161,6 +2163,7 @@ describe('IndexPatternDimensionEditorPanel: onDrop', () => { groupId: 'y', layerId: 'second', filterOperations: (op) => !op.isBucketed, + indexPatternId: 'test', }, }; @@ -2224,6 +2227,7 @@ describe('IndexPatternDimensionEditorPanel: onDrop', () => { groupId: 'y', layerId: 'second', filterOperations: (op) => !op.isBucketed, + indexPatternId: 'test', }, }) ).toEqual(true); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts index 8ea027a3da98f..232c96610f04c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/on_drop_handler.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import { isDraggedDataViewField } from '../../../utils'; import { DatasourceDimensionDropHandlerProps, DragDropOperation, @@ -12,6 +13,7 @@ import { isOperation, StateSetter, VisualizationDimensionGroupConfig, + DraggedField, } from '../../../types'; import { insertOrReplaceColumn, @@ -25,9 +27,8 @@ import { deleteColumnInLayers, } from '../../operations'; import { mergeLayer, mergeLayers } from '../../state_helpers'; -import { isDraggedField } from '../../pure_utils'; import { getNewOperation, getField } from './get_drop_props'; -import { IndexPatternPrivateState, DraggedField, DataViewDragDropOperation } from '../../types'; +import { IndexPatternPrivateState, DataViewDragDropOperation } from '../../types'; interface DropHandlerProps { state: IndexPatternPrivateState; @@ -48,7 +49,7 @@ interface DropHandlerProps { export function onDrop(props: DatasourceDimensionDropHandlerProps) { const { target, source, dropType, state, indexPatterns } = props; - if (isDraggedField(source) && isFieldDropType(dropType)) { + if (isDraggedDataViewField(source) && isFieldDropType(dropType)) { return onFieldDrop( { ...props, @@ -142,7 +143,7 @@ function onFieldDrop(props: DropHandlerProps, shouldAddField?: boo ); if ( - !isDraggedField(source) || + !isDraggedDataViewField(source) || !newOperation || (shouldAddField && !hasOperationSupportForMultipleFields(indexPattern, targetColumn, undefined, source.field)) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index 2404d53b8f02a..3c6ddbe5e849d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -9,9 +9,11 @@ import React, { ReactElement } from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { EuiLoadingSpinner, EuiPopover } from '@elastic/eui'; +import type { DiscoverStart } from '@kbn/discover-plugin/public'; import { InnerFieldItem, FieldItemProps } from './field_item'; import { coreMock } from '@kbn/core/public/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import { IndexPattern } from '../types'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; @@ -47,6 +49,11 @@ const mockedServices = { fieldFormats: fieldFormatsServiceMock.createStartContract(), charts: chartPluginMock.createSetupContract(), uiSettings: coreMock.createStart().uiSettings, + discover: { + locator: { + getRedirectUrl: jest.fn(() => 'discover_url'), + }, + } as unknown as DiscoverStart, }; const InnerFieldItemWrapper: React.FC = (props) => { @@ -414,4 +421,43 @@ describe('IndexPattern Field Item', () => { expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); expect(wrapper.find(FieldStats).text()).toBe('Analysis is not available for this field.'); }); + + it('should display Explore in discover button', async () => { + const wrapper = await mountWithIntl(); + + await clickField(wrapper, 'bytes'); + + await wrapper.update(); + + const exploreInDiscoverBtn = findTestSubject( + wrapper, + 'lnsFieldListPanel-exploreInDiscover-bytes' + ); + expect(exploreInDiscoverBtn.length).toBe(1); + }); + + it('should not display Explore in discover button for a geo_point field', async () => { + const wrapper = await mountWithIntl( + + ); + + await clickField(wrapper, 'geo_point'); + + await wrapper.update(); + + const exploreInDiscoverBtn = findTestSubject( + wrapper, + 'lnsFieldListPanel-exploreInDiscover-geo_point' + ); + expect(exploreInDiscoverBtn.length).toBe(0); + }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 29f666a4ff6eb..a91286881197e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -19,6 +19,7 @@ import { EuiText, EuiTitle, EuiToolTip, + EuiButton, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; @@ -30,17 +31,17 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { AddFieldFilterHandler, FieldStats } from '@kbn/unified-field-list-plugin/public'; -import { generateFilters } from '@kbn/data-plugin/public'; +import { generateFilters, getEsQueryConfig } from '@kbn/data-plugin/public'; import { DragDrop, DragDropIdentifier } from '../drag_drop'; -import { DatasourceDataPanelProps, DataType } from '../types'; +import { DatasourceDataPanelProps, DataType, DraggedField } from '../types'; import { DOCUMENT_FIELD_NAME } from '../../common'; import type { IndexPattern, IndexPatternField } from '../types'; -import type { DraggedField } from './types'; import { LensFieldIcon } from '../shared_components/field_picker/lens_field_icon'; import { VisualizeGeoFieldButton } from './visualize_geo_field_button'; import type { LensAppServices } from '../app_plugin/types'; import { debouncedComponent } from '../debounced_component'; import { getFieldType } from './pure_utils'; +import { combineQueryAndFilters } from '../app_plugin/show_underlying_data'; export interface FieldItemProps { core: DatasourceDataPanelProps['core']; @@ -347,6 +348,41 @@ function FieldItemPopoverContents(props: FieldItemProps) { /> ); + const exploreInDiscover = useMemo(() => { + const meta = { + id: indexPattern.id, + columns: [field.name], + filters: { + enabled: { + lucene: [], + kuery: [], + }, + disabled: { + lucene: [], + kuery: [], + }, + }, + }; + const { filters: newFilters, query: newQuery } = combineQueryAndFilters( + query, + filters, + meta, + [indexPattern], + getEsQueryConfig(services.uiSettings) + ); + + if (!services.discover) { + return; + } + return services.discover.locator!.getRedirectUrl({ + dataViewSpec: indexPattern?.spec, + timeRange: services.data.query.timefilter.timefilter.getTime(), + filters: newFilters, + query: newQuery, + columns: meta.columns, + }); + }, [field.name, filters, indexPattern, query, services]); + if (hideDetails) { return panelHeader; } @@ -404,6 +440,21 @@ function FieldItemPopoverContents(props: FieldItemProps) { return params.element; }} /> + {exploreInDiscover && field.type !== 'geo_point' && field.type !== 'geo_shape' && ( + + + {i18n.translate('xpack.lens.indexPattern.fieldExploreInDiscover', { + defaultMessage: 'Explore values in Discover', + })} + + + )} ); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts index 4bf33013b3280..6b1b052c90b14 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -8,6 +8,7 @@ import type { CoreSetup } from '@kbn/core/public'; import { createStartServicesGetter, Storage } from '@kbn/kibana-utils-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; +import type { DiscoverStart } from '@kbn/discover-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { ExpressionsSetup } from '@kbn/expressions-plugin/public'; import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; @@ -30,6 +31,7 @@ export interface IndexPatternDatasourceSetupPlugins { export interface IndexPatternDatasourceStartPlugins { data: DataPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; + discover?: DiscoverStart; fieldFormats: FieldFormatsStart; dataViewFieldEditor: IndexPatternFieldEditorStart; dataViews: DataViewsPublicPluginStart; @@ -62,7 +64,7 @@ export class IndexPatternDatasource { const [ coreStart, - { dataViewFieldEditor, uiActions, data, fieldFormats, dataViews, unifiedSearch }, + { dataViewFieldEditor, uiActions, data, fieldFormats, dataViews, unifiedSearch, discover }, ] = await core.getStartServices(); return getIndexPatternDatasource({ @@ -71,6 +73,7 @@ export class IndexPatternDatasource { storage: new Storage(localStorage), data, unifiedSearch, + discover, dataViews, charts, dataViewFieldEditor, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 20d0df1358be7..fb31c3c1a9a71 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -11,6 +11,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import type { CoreStart, SavedObjectReference } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { TimeRange } from '@kbn/es-query'; +import type { DiscoverStart } from '@kbn/discover-plugin/public'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { flatten, isEqual } from 'lodash'; @@ -68,7 +69,8 @@ import { isColumnInvalid, cloneLayer, } from './utils'; -import { normalizeOperationDataType, isDraggedField } from './pure_utils'; +import { isDraggedDataViewField } from '../utils'; +import { normalizeOperationDataType } from './pure_utils'; import { LayerPanel } from './layerpanel'; import { DateHistogramIndexPatternColumn, @@ -130,6 +132,7 @@ export function getIndexPatternDatasource({ storage, data, unifiedSearch, + discover, dataViews, fieldFormats, charts, @@ -140,6 +143,7 @@ export function getIndexPatternDatasource({ storage: IStorageWrapper; data: DataPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart; + discover?: DiscoverStart; dataViews: DataViewsPublicPluginStart; fieldFormats: FieldFormatsStart; charts: ChartsPluginSetup; @@ -176,10 +180,6 @@ export function getIndexPatternDatasource({ return extractReferences(state); }, - getCurrentIndexPatternId(state: IndexPatternPrivateState) { - return state.currentIndexPatternId; - }, - insertLayer(state: IndexPatternPrivateState, newLayerId: string) { return { ...state, @@ -282,6 +282,7 @@ export function getIndexPatternDatasource({ fieldFormats, charts, unifiedSearch, + discover, }} > { + getUsedDataView: (state: IndexPatternPrivateState, layerId?: string) => { + if (!layerId) { + return state.currentIndexPatternId; + } return state.layers[layerId].indexPatternId; }, getUsedDataViews: (state) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index 299896e5ffe65..2b227674c26e9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -399,8 +399,8 @@ describe('filters', () => { ); instance - .find('[data-test-subj="lns-customBucketContainer-remove"]') - .at(2) + .find('[data-test-subj="lns-customBucketContainer-remove-1"]') + .at(0) .simulate('click'); expect(updateLayerSpy).toHaveBeenCalledWith({ ...layer, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx index b972129109e1e..434dbb092bdb0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.tsx @@ -235,13 +235,14 @@ export const FilterList = ({ droppableId="FILTERS_DROPPABLE_AREA" items={localFilters} > - {localFilters?.map((filter: FilterValue, idx: number) => { + {localFilters?.map((filter, idx, arrayRef) => { const isInvalid = !isQueryValid(filter.input, indexPattern); + const id = filter.id; return ( { + const addNewRange = useCallback(() => { const newRangeId = generateId(); setLocalRanges([ @@ -228,13 +228,13 @@ export const AdvancedRangeEditor = ({ { id: newRangeId, from: localRanges[localRanges.length - 1].to, - to: Infinity, + to: Number.POSITIVE_INFINITY, label: '', }, ]); setActiveRangeId(newRangeId); - }; + }, [localRanges]); const changeActiveRange = (rangeId: string) => { let newActiveRangeId = rangeId; @@ -264,11 +264,10 @@ export const AdvancedRangeEditor = ({ <> {}} droppableId="RANGES_DROPPABLE_AREA" items={localRanges} > - {localRanges.map((range: LocalRangeType, idx: number) => ( + {localRanges.map((range, idx, arrayRef) => ( { - const newRanges = localRanges.filter((_, i) => i !== idx); + const newRanges = arrayRef.filter((_, i) => i !== idx); setLocalRanges(newRanges); }} removeTitle={i18n.translate('xpack.lens.indexPattern.ranges.deleteRange', { defaultMessage: 'Delete range', })} - isNotRemovable={localRanges.length === 1} + isNotRemovable={arrayRef.length === 1} + isNotDraggable={arrayRef.length < 2} > changeActiveRange('')} setRange={(newRange: LocalRangeType) => { - const newRanges = [...localRanges]; + const newRanges = [...arrayRef]; if (newRange.id === newRanges[idx].id) { newRanges[idx] = newRange; } else { @@ -320,9 +320,7 @@ export const AdvancedRangeEditor = ({ ))} { - addNewRange(); - }} + onClick={addNewRange} label={i18n.translate('xpack.lens.indexPattern.ranges.addRange', { defaultMessage: 'Add range', })} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx index 907a9f685215a..874559ae66c19 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx @@ -797,8 +797,8 @@ describe('ranges', () => { // This series of act closures are made to make it work properly the update flush act(() => { instance - .find('[data-test-subj="lns-customBucketContainer-remove"]') - .last() + .find('[data-test-subj="lns-customBucketContainer-remove-1"]') + .at(0) .simulate('click'); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx index f140fdb9807ac..d22fc5392f2cc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/field_inputs.tsx @@ -5,24 +5,16 @@ * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; -import { - EuiButtonIcon, - EuiDraggable, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - htmlIdGenerator, - EuiPanel, - useEuiTheme, -} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ExistingFieldsMap, IndexPattern } from '../../../../types'; import { DragDropBuckets, + FieldsBucketContainer, NewBucketButton, - TooltipWrapper, useDebouncedValue, + DraggableBucketContainer, } from '../../../../shared_components'; import { FieldSelect } from '../../../dimension_panel/field_select'; import type { TermsIndexPatternColumn } from './types'; @@ -61,9 +53,6 @@ export function FieldInputs({ operationSupportMatrix, invalidFields, }: FieldInputsProps) { - const { euiTheme } = useEuiTheme(); - const [isDragging, setIsDragging] = useState(false); - const onChangeWrapped = useCallback( (values: WrappedValue[]) => onChange(values.filter(removeNewEmptyField).map(({ value }) => value)), @@ -97,154 +86,90 @@ export function FieldInputs({ ); const disableActions = - (localValues.length === 2 && localValues.some(({ isNew }) => isNew)) || - localValues.length === 1; + localValues.length === 1 || localValues.filter(({ isNew }) => !isNew).length < 2; const localValuesFilled = localValues.filter(({ isNew }) => !isNew); return ( <> -
{ + handleInputChange(updatedValues); }} + droppableId="TOP_TERMS_DROPPABLE_AREA" + items={localValues} + bgColor="subdued" > - { - handleInputChange(updatedValues); - setIsDragging(false); - }} - className="lnsIndexPatternDimensionEditor__droppable" - onDragStart={() => { - setIsDragging(true); - }} - droppableId="TOP_TERMS_DROPPABLE_AREA" - items={localValues} - > - {localValues.map(({ id, value, isNew }, index) => { - // need to filter the available fields for multiple terms - // * a scripted field should be removed - // * a field of unsupported type should be removed - // * a field that has been used - // * a scripted field was used in a singular term, should be marked as invalid for multi-terms - const filteredOperationByField = Object.keys(operationSupportMatrix.operationByField) - .filter((key) => { - if (key === value) { - return true; - } - const field = indexPattern.getFieldByName(key); - if (index === 0) { - return !rawValuesLookup.has(key) && field && supportedTypes.has(field.type); - } else { - return ( - !rawValuesLookup.has(key) && - field && - !field.scripted && - supportedTypes.has(field.type) - ); - } - }) - .reduce((memo, key) => { - memo[key] = operationSupportMatrix.operationByField[key]; - return memo; - }, {}); + {localValues.map(({ id, value, isNew }, index, arrayRef) => { + // need to filter the available fields for multiple terms + // * a scripted field should be removed + // * a field of unsupported type should be removed + // * a field that has been used + // * a scripted field was used in a singular term, should be marked as invalid for multi-terms + const filteredOperationByField = Object.keys(operationSupportMatrix.operationByField) + .filter((key) => { + if (key === value) { + return true; + } + const field = indexPattern.getFieldByName(key); + if (index === 0) { + return !rawValuesLookup.has(key) && field && supportedTypes.has(field.type); + } else { + return ( + !rawValuesLookup.has(key) && + field && + !field.scripted && + supportedTypes.has(field.type) + ); + } + }) + .reduce((memo, key) => { + memo[key] = operationSupportMatrix.operationByField[key]; + return memo; + }, {}); - const shouldShowError = Boolean( - value && - ((indexPattern.getFieldByName(value)?.scripted && localValuesFilled.length > 1) || - invalidFields?.includes(value)) - ); - return ( - - {(provided) => ( - - - - - - - { - onFieldSelectChange(choice, index); - }} - isInvalid={shouldShowError} - data-test-subj={ - localValues.length !== 1 - ? `indexPattern-dimension-field-${index}` - : undefined - } - /> - - - - { - handleInputChange(localValues.filter((_, i) => i !== index)); - }} - data-test-subj={`indexPattern-terms-removeField-${index}`} - isDisabled={disableActions && !isNew} - /> - - - - - )} - - ); - })} - -
+ const shouldShowError = Boolean( + value && + ((indexPattern.getFieldByName(value)?.scripted && localValuesFilled.length > 1) || + invalidFields?.includes(value)) + ); + const itemId = (value ?? 'newField') + id; + + return ( + { + handleInputChange(arrayRef.filter((_, i) => i !== index)); + }} + removeTitle={i18n.translate('xpack.lens.indexPattern.terms.deleteButtonLabel', { + defaultMessage: 'Delete', + })} + isNotRemovable={disableActions && !isNew} + isNotDraggable={arrayRef.length < 2} + data-test-subj={`indexPattern-terms`} + Container={FieldsBucketContainer} + isInsidePanel={true} + > + { + onFieldSelectChange(choice, index); + }} + isInvalid={shouldShowError} + data-test-subj={ + localValues.length !== 1 ? `indexPattern-dimension-field-${index}` : undefined + } + /> + + ); + })} + { handleInputChange([...localValues, { id: generateId(), value: undefined, isNew: true }]); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 2df8300eb6481..1a77cd253424f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -190,6 +190,7 @@ describe('state_helpers', () => { layerId: 'layer', dataView: indexPattern, filterOperations: () => true, + indexPatternId: '1', }, target: { columnId: 'copy', @@ -197,6 +198,7 @@ describe('state_helpers', () => { dataView: indexPattern, layerId: 'layer', filterOperations: () => true, + indexPatternId: '1', }, shouldDeleteSource: false, }).layer diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts index e1fd78e0b1713..39b4bcdf49229 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/pure_utils.ts @@ -6,7 +6,7 @@ */ import type { DataType, IndexPattern, IndexPatternField } from '../types'; -import type { DraggedField, IndexPatternLayer } from './types'; +import type { IndexPatternLayer } from './types'; import type { BaseIndexPatternColumn, FieldBasedIndexPatternColumn, @@ -53,11 +53,3 @@ export function sortByField(columns: C[]) { return column1.operationType.localeCompare(column2.operationType); }); } - -export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { - return ( - typeof fieldCandidate === 'object' && - fieldCandidate !== null && - ['id', 'field', 'indexPatternId'].every((prop) => prop in fieldCandidate) - ); -} diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 3d169b643c2ce..65d001a726b8c 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -41,7 +41,6 @@ export function createMockDatasource(id: string): DatasourceMock { initialize: jest.fn((_state?) => {}), renderDataPanel: jest.fn(), renderLayerPanel: jest.fn(), - getCurrentIndexPatternId: jest.fn(), toExpression: jest.fn((_frame, _state, _indexPatterns) => null), insertLayer: jest.fn((_state, _newLayerId) => ({})), removeLayer: jest.fn((_state, _layerId) => {}), diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.test.tsx b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.test.tsx index aba0cbfc40a6e..2336495c9a316 100644 --- a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.test.tsx +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.test.tsx @@ -63,14 +63,13 @@ describe('buckets shared components', () => { it('should render invalid component', () => { const instance = mount(); const iconProps = instance.find(EuiIcon).first().props(); - expect(iconProps.color).toEqual('danger'); + expect(iconProps.color).toEqual('#BD271E'); expect(iconProps.type).toEqual('alert'); - expect(iconProps.title).toEqual('invalid'); }); it('should call onRemoveClick when remove icon is clicked', () => { const instance = mount(); const removeIcon = instance - .find('[data-test-subj="lns-customBucketContainer-remove"]') + .find('[data-test-subj="lns-customBucketContainer-remove-0"]') .first(); removeIcon.simulate('click'); expect(defaultProps.onRemoveClick).toHaveBeenCalled(); diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.tsx b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.tsx index 720d8c85ca486..7d0893cdd54ee 100644 --- a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.tsx +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/buckets.tsx @@ -5,162 +5,110 @@ * 2.0. */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; +import React, { useCallback, useState } from 'react'; +import type { Assign } from '@kbn/utility-types'; import { - EuiFlexGroup, - EuiFlexItem, EuiPanel, - EuiButtonIcon, - EuiIcon, - EuiDragDropContext, - euiDragDropReorder, EuiDraggable, EuiDroppable, - EuiButtonEmpty, + EuiPanelProps, + EuiDragDropContext, + DragDropContextProps, + euiDragDropReorder, + useEuiTheme, } from '@elastic/eui'; - -export const NewBucketButton = ({ - label, - onClick, - ['data-test-subj']: dataTestSubj, - isDisabled, - className, -}: { - label: string; - onClick: () => void; - 'data-test-subj'?: string; - isDisabled?: boolean; - className?: string; -}) => ( - - {label} - -); - -interface BucketContainerProps { - isInvalid?: boolean; - invalidMessage: string; - onRemoveClick: () => void; - removeTitle: string; - isNotRemovable?: boolean; - children: React.ReactNode; - dataTestSubj?: string; -} - -const BucketContainer = ({ - isInvalid, - invalidMessage, - onRemoveClick, - removeTitle, - children, - dataTestSubj, - isNotRemovable, -}: BucketContainerProps) => { - return ( - - - {/* Empty for spacing */} - - - - {children} - - - - - - ); -}; +import { DefaultBucketContainer } from './default_bucket_container'; +import type { BucketContainerProps } from './types'; export const DraggableBucketContainer = ({ id, - idx, children, + isInsidePanel, + Container = DefaultBucketContainer, ...bucketContainerProps -}: { - id: string; - idx: number; - children: React.ReactNode; -} & BucketContainerProps) => { +}: Assign< + Omit, + { + id: string; + children: React.ReactNode; + isInsidePanel?: boolean; + Container?: React.FunctionComponent; + } +>) => { + const { euiTheme } = useEuiTheme(); + return ( - {(provided) => {children}} + {(provided, state) => ( + + {children} + + )} ); }; -interface DraggableLocation { - droppableId: string; - index: number; -} - -export const DragDropBuckets = ({ +export function DragDropBuckets({ items, onDragStart, onDragEnd, droppableId, children, - className, + bgColor, }: { - items: any; // eslint-disable-line @typescript-eslint/no-explicit-any - onDragStart: () => void; - onDragEnd: (items: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any + items: T[]; droppableId: string; children: React.ReactElement[]; - className?: string; -}) => { - const handleDragEnd = ({ - source, - destination, - }: { - source?: DraggableLocation; - destination?: DraggableLocation; - }) => { - if (source && destination) { - const newItems = euiDragDropReorder(items, source.index, destination.index); - onDragEnd(newItems); - } - }; + onDragStart?: () => void; + onDragEnd?: (items: T[]) => void; + bgColor?: EuiPanelProps['color']; +}) { + const [isDragging, setIsDragging] = useState(false); + + const handleDragEnd: DragDropContextProps['onDragEnd'] = useCallback( + ({ source, destination }) => { + setIsDragging(false); + if (source && destination) { + onDragEnd?.(euiDragDropReorder(items, source.index, destination.index)); + } + }, + [items, onDragEnd] + ); + + const handleDragStart: DragDropContextProps['onDragStart'] = useCallback(() => { + setIsDragging(true); + onDragStart?.(); + }, [onDragStart]); + return ( - - - {children} - + + + + {children} + + ); -}; +} diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/default_bucket_container.tsx b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/default_bucket_container.tsx new file mode 100644 index 0000000000000..bfae243dc1ac5 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/default_bucket_container.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 { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + useEuiTheme, +} from '@elastic/eui'; +import type { BucketContainerProps } from './types'; +import { TooltipWrapper } from '../tooltip_wrapper'; + +export const DefaultBucketContainer = ({ + idx, + isInvalid, + invalidMessage, + onRemoveClick, + removeTitle, + children, + draggableProvided, + isNotRemovable, + isNotDraggable, + 'data-test-subj': dataTestSubj = 'lns-customBucketContainer', +}: BucketContainerProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + + {children} + + + + + + + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/fields_bucket_container.tsx b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/fields_bucket_container.tsx new file mode 100644 index 0000000000000..eedcba3fee5ec --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/fields_bucket_container.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 from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + useEuiTheme, +} from '@elastic/eui'; +import { TooltipWrapper } from '..'; +import type { BucketContainerProps } from './types'; + +export const FieldsBucketContainer = ({ + idx, + onRemoveClick, + removeTitle, + children, + draggableProvided, + isNotRemovable, + isNotDraggable, + isDragging, + 'data-test-subj': dataTestSubj = 'lns-fieldsBucketContainer', +}: BucketContainerProps) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + + + {children} + + + + + + + + + ); +}; diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/index.tsx b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/index.tsx new file mode 100644 index 0000000000000..127471dc2cca5 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/index.tsx @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export { NewBucketButton } from './new_bucket_button'; +export { FieldsBucketContainer } from './fields_bucket_container'; + +export * from './buckets'; diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/new_bucket_button.tsx b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/new_bucket_button.tsx new file mode 100644 index 0000000000000..38b74ca7c83fb --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/new_bucket_button.tsx @@ -0,0 +1,37 @@ +/* + * 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 { EuiButtonEmpty } from '@elastic/eui'; + +interface NewBucketButtonProps { + label: string; + onClick: () => void; + isDisabled?: boolean; + className?: string; + 'data-test-subj'?: string; +} + +export const NewBucketButton = ({ + label, + onClick, + isDisabled, + className, + 'data-test-subj': dataTestSubj = 'lns-newBucket-add', +}: NewBucketButtonProps) => ( + + {label} + +); diff --git a/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/types.ts b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/types.ts new file mode 100644 index 0000000000000..fe08048e875c2 --- /dev/null +++ b/x-pack/plugins/lens/public/shared_components/drag_drop_bucket/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type { DraggableProvided } from 'react-beautiful-dnd'; + +export interface BucketContainerProps { + children: React.ReactNode; + removeTitle: string; + idx: number; + onRemoveClick: () => void; + isDragging?: boolean; + draggableProvided?: DraggableProvided; + isInvalid?: boolean; + invalidMessage?: string; + isNotRemovable?: boolean; + isNotDraggable?: boolean; + 'data-test-subj'?: string; +} diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index 3f30eb64ff2c9..a2fcc9c54882d 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -17,7 +17,8 @@ export { NewBucketButton, DraggableBucketContainer, DragDropBuckets, -} from './drag_drop_bucket/buckets'; + FieldsBucketContainer, +} from './drag_drop_bucket'; export { RangeInputField } from './range_input_field'; export { BucketAxisBoundsControl, diff --git a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts index dc2425917bad8..5b942eff4a683 100644 --- a/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts +++ b/x-pack/plugins/lens/public/state_management/init_middleware/load_initial.ts @@ -115,6 +115,11 @@ export function loadInitial( defaultIndexPatternId: lensServices.uiSettings.get('defaultIndex'), }; + let activeDatasourceId: string | undefined; + if (initialContext && 'query' in initialContext) { + activeDatasourceId = 'textBasedLanguages'; + } + if ( !initialInput || (attributeService.inputIsRefType(initialInput) && @@ -141,6 +146,7 @@ export function loadInitial( ...emptyState, dataViews: getInitialDataViewsObject(indexPatterns, indexPatternRefs), searchSessionId: data.search.session.getSessionId() || data.search.session.start(), + ...(activeDatasourceId && { activeDatasourceId }), datasourceStates: Object.entries(datasourceStates).reduce( (state, [datasourceId, datasourceState]) => ({ ...state, diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts index fc536b30ddac6..f83786238f628 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.test.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.test.ts @@ -279,7 +279,7 @@ describe('lensSlice', () => { removeLayer: (layerIds: unknown, layerId: string) => (layerIds as string[]).filter((id: string) => id !== layerId), insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId], - getCurrentIndexPatternId: jest.fn(() => 'indexPattern1'), + getUsedDataView: jest.fn(() => 'indexPattern1'), }; }; const datasourceStates = { diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 725c60bfc22cb..38aee718a536f 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -377,7 +377,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { ); state.stagedPreview = undefined; // reuse the activeDatasource current dataView id for the moment - const currentDataViewsId = activeDataSource.getCurrentIndexPatternId( + const currentDataViewsId = activeDataSource.getUsedDataView( state.datasourceStates[state.activeDatasourceId!].state ); state.visualization.state = @@ -928,7 +928,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { const activeVisualization = visualizationMap[state.visualization.activeId]; const activeDatasource = datasourceMap[state.activeDatasourceId]; // reuse the active datasource dataView id for the new layer - const currentDataViewsId = activeDatasource.getCurrentIndexPatternId( + const currentDataViewsId = activeDatasource.getUsedDataView( state.datasourceStates[state.activeDatasourceId!].state ); const visualizationState = activeVisualization.appendLayer!( diff --git a/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.test.ts b/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.test.ts index c2238bfd9fef1..92a3ef01fdb7d 100644 --- a/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.test.ts +++ b/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.test.ts @@ -12,6 +12,7 @@ import { TextBasedLanguagesPersistedState, TextBasedLanguagesPrivateState } from import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { getTextBasedLanguagesDatasource } from './text_based_languages'; +import { generateId } from '../id_generator'; import { DatasourcePublicAPI, Datasource } from '../types'; jest.mock('../id_generator'); @@ -272,6 +273,97 @@ describe('IndexPattern Data Source', () => { }); }); + describe('#getDatasourceSuggestionsForVisualizeField', () => { + (generateId as jest.Mock).mockReturnValue(`newid`); + it('should create the correct layers', () => { + const state = { + layers: {}, + initialContext: { + contextualFields: ['bytes', 'dest'], + query: { sql: 'SELECT * FROM "foo"' }, + dataViewSpec: { + title: 'foo', + id: '1', + name: 'Foo', + }, + }, + } as unknown as TextBasedLanguagesPrivateState; + const suggestions = textBasedLanguagesDatasource.getDatasourceSuggestionsForVisualizeField( + state, + '1', + '', + indexPatterns + ); + expect(suggestions[0].state).toEqual({ + ...state, + layers: { + newid: { + allColumns: [ + { + columnId: 'newid', + fieldName: 'bytes', + meta: { + type: 'number', + }, + }, + { + columnId: 'newid', + fieldName: 'dest', + meta: { + type: 'string', + }, + }, + ], + columns: [ + { + columnId: 'newid', + fieldName: 'bytes', + meta: { + type: 'number', + }, + }, + { + columnId: 'newid', + fieldName: 'dest', + meta: { + type: 'string', + }, + }, + ], + index: 'foo', + query: { + sql: 'SELECT * FROM "foo"', + }, + }, + }, + }); + + expect(suggestions[0].table).toEqual({ + changeType: 'initial', + columns: [ + { + columnId: 'newid', + operation: { + dataType: 'number', + isBucketed: false, + label: 'bytes', + }, + }, + { + columnId: 'newid', + operation: { + dataType: 'string', + isBucketed: true, + label: 'dest', + }, + }, + ], + isMultiRow: false, + layerId: 'newid', + }); + }); + }); + describe('#getErrorMessages', () => { it('should use the results of getErrorMessages directly when single layer', () => { const state = { diff --git a/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.tsx b/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.tsx index 5a03500c76fbf..e9ab24b8392c6 100644 --- a/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.tsx +++ b/x-pack/plugins/lens/public/text_based_languages_datasource/text_based_languages.tsx @@ -14,7 +14,7 @@ import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { AggregateQuery } from '@kbn/es-query'; import type { SavedObjectReference } from '@kbn/core/public'; import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; -import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import type { ExpressionsStart, DatatableColumnType } from '@kbn/expressions-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { @@ -36,7 +36,7 @@ import type { TextBasedLanguageField, } from './types'; import { FieldSelect } from './field_select'; -import { Datasource } from '../types'; +import type { Datasource, IndexPatternMap } from '../types'; import { LayerPanel } from './layerpanel'; function getLayerReferenceName(layerId: string) { @@ -82,6 +82,77 @@ export function getTextBasedLanguagesDatasource({ }; }); }; + const getSuggestionsForVisualizeField = ( + state: TextBasedLanguagesPrivateState, + indexPatternId: string, + fieldName: string, + indexPatterns: IndexPatternMap + ) => { + const context = state.initialContext; + if (context && 'dataViewSpec' in context && context.dataViewSpec.title) { + const newLayerId = generateId(); + const indexPattern = indexPatterns[indexPatternId]; + + const contextualFields = context.contextualFields; + const newColumns = contextualFields?.map((c) => { + let field = indexPattern?.getFieldByName(c); + if (!field) { + field = indexPattern?.fields.find((f) => f.name.includes(c)); + } + const newId = generateId(); + const type = field?.type ?? 'number'; + return { + columnId: newId, + fieldName: c, + meta: { + type: type as DatatableColumnType, + }, + }; + }); + + const index = context.dataViewSpec.title; + const query = context.query; + const updatedState = { + ...state, + layers: { + ...state.layers, + [newLayerId]: { + index, + query, + columns: newColumns ?? [], + allColumns: newColumns ?? [], + }, + }, + }; + + return [ + { + state: { + ...updatedState, + }, + table: { + changeType: 'initial' as TableChangeType, + isMultiRow: false, + layerId: newLayerId, + columns: + newColumns?.map((f) => { + return { + columnId: f.columnId, + operation: { + dataType: f?.meta?.type as DataType, + label: f.fieldName, + isBucketed: Boolean(f?.meta?.type !== 'number'), + }, + }; + }) ?? [], + }, + keptLayerIds: [newLayerId], + }, + ]; + } + + return []; + }; const TextBasedLanguagesDatasource: Datasource< TextBasedLanguagesPrivateState, TextBasedLanguagesPersistedState @@ -137,6 +208,7 @@ export function getTextBasedLanguagesDatasource({ ...initState, fieldList: [], indexPatternRefs: refs, + initialContext: context, }; }, onRefreshIndexPattern() {}, @@ -224,10 +296,6 @@ export function getTextBasedLanguagesDatasource({ getLayers(state: TextBasedLanguagesPrivateState) { return state && state.layers ? Object.keys(state?.layers) : []; }, - getCurrentIndexPatternId(state: TextBasedLanguagesPrivateState) { - const layers = Object.values(state.layers); - return layers?.[0]?.index; - }, isTimeBased: (state, indexPatterns) => { if (!state) return false; const { layers } = state; @@ -238,7 +306,11 @@ export function getTextBasedLanguagesDatasource({ }) ); }, - getUsedDataView: (state: TextBasedLanguagesPrivateState, layerId: string) => { + getUsedDataView: (state: TextBasedLanguagesPrivateState, layerId?: string) => { + if (!layerId) { + const layers = Object.values(state.layers); + return layers?.[0]?.index; + } return state.layers[layerId].index; }, @@ -291,7 +363,11 @@ export function getTextBasedLanguagesDatasource({ const columnExists = props.state.fieldList.some((f) => f.name === selectedField?.fieldName); render( - {}}> + {}} + data-test-subj="lns-dimensionTrigger-textBased" + > {customLabel ?? i18n.translate('xpack.lens.textBasedLanguages.missingField', { defaultMessage: 'Missing field', @@ -564,7 +640,7 @@ export function getTextBasedLanguagesDatasource({ }); return []; }, - getDatasourceSuggestionsForVisualizeField: getSuggestionsForState, + getDatasourceSuggestionsForVisualizeField: getSuggestionsForVisualizeField, getDatasourceSuggestionsFromCurrentState: getSuggestionsForState, getDatasourceSuggestionsForVisualizeCharts: getSuggestionsForState, isEqual: () => true, diff --git a/x-pack/plugins/lens/public/text_based_languages_datasource/types.ts b/x-pack/plugins/lens/public/text_based_languages_datasource/types.ts index 2ea1692cf37c0..11b9612624efd 100644 --- a/x-pack/plugins/lens/public/text_based_languages_datasource/types.ts +++ b/x-pack/plugins/lens/public/text_based_languages_datasource/types.ts @@ -6,6 +6,8 @@ */ import type { DatatableColumn } from '@kbn/expressions-plugin/public'; import type { AggregateQuery } from '@kbn/es-query'; +import type { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; +import type { VisualizeEditorContext } from '../types'; export interface TextBasedLanguagesLayerColumn { columnId: string; @@ -34,6 +36,7 @@ export interface TextBasedLanguagesPersistedState { export type TextBasedLanguagesPrivateState = TextBasedLanguagesPersistedState & { indexPatternRefs: IndexPatternRef[]; fieldList: DatatableColumn[]; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; }; export interface IndexPatternRef { diff --git a/x-pack/plugins/lens/public/text_based_languages_datasource/utils.test.ts b/x-pack/plugins/lens/public/text_based_languages_datasource/utils.test.ts index 1cbd830a92fc2..bc511bb2b86ce 100644 --- a/x-pack/plugins/lens/public/text_based_languages_datasource/utils.test.ts +++ b/x-pack/plugins/lens/public/text_based_languages_datasource/utils.test.ts @@ -84,6 +84,18 @@ describe('Text based languages utils', () => { index: '', }, }, + indexPatternRefs: [], + fieldList: [], + initialContext: { + contextualFields: ['bytes', 'dest'], + query: { sql: 'SELECT * FROM "foo"' }, + fieldName: '', + dataViewSpec: { + title: 'foo', + id: '1', + name: 'Foo', + }, + }, }; const dataViewsMock = dataViewPluginMocks.createStartContract(); const dataMock = dataPluginMock.createStartContract(); @@ -113,6 +125,16 @@ describe('Text based languages utils', () => { ); expect(updatedState).toStrictEqual({ + initialContext: { + contextualFields: ['bytes', 'dest'], + query: { sql: 'SELECT * FROM "foo"' }, + fieldName: '', + dataViewSpec: { + title: 'foo', + id: '1', + name: 'Foo', + }, + }, fieldList: [ { name: 'timestamp', diff --git a/x-pack/plugins/lens/public/text_based_languages_datasource/utils.ts b/x-pack/plugins/lens/public/text_based_languages_datasource/utils.ts index 6c17d5206efb0..c4e41103f0fd1 100644 --- a/x-pack/plugins/lens/public/text_based_languages_datasource/utils.ts +++ b/x-pack/plugins/lens/public/text_based_languages_datasource/utils.ts @@ -15,7 +15,7 @@ import { fetchDataFromAggregateQuery } from './fetch_data_from_aggregate_query'; import type { IndexPatternRef, - TextBasedLanguagesPersistedState, + TextBasedLanguagesPrivateState, TextBasedLanguagesLayerColumn, } from './types'; @@ -36,7 +36,7 @@ export async function loadIndexPatternRefs( } export async function getStateFromAggregateQuery( - state: TextBasedLanguagesPersistedState, + state: TextBasedLanguagesPrivateState, query: AggregateQuery, dataViews: DataViewsPublicPluginStart, data: DataPublicPluginStart, @@ -45,13 +45,14 @@ export async function getStateFromAggregateQuery( const indexPatternRefs: IndexPatternRef[] = await loadIndexPatternRefs(dataViews); const errors: Error[] = []; const layerIds = Object.keys(state.layers); + const context = state.initialContext; const newLayerId = layerIds.length > 0 ? layerIds[0] : generateId(); // fetch the pattern from the query const indexPattern = getIndexPatternFromTextBasedQuery(query); // get the id of the dataview const index = indexPatternRefs.find((r) => r.title === indexPattern)?.id ?? ''; let columnsFromQuery: DatatableColumn[] = []; - let columns: TextBasedLanguagesLayerColumn[] = []; + let allColumns: TextBasedLanguagesLayerColumn[] = []; let timeFieldName; try { const table = await fetchDataFromAggregateQuery(query, dataViews, data, expressions); @@ -59,7 +60,8 @@ export async function getStateFromAggregateQuery( timeFieldName = dataView.timeFieldName; columnsFromQuery = table?.columns ?? []; const existingColumns = state.layers[newLayerId].allColumns; - columns = [ + + allColumns = [ ...existingColumns, ...columnsFromQuery.map((c) => ({ columnId: c.id, fieldName: c.id, meta: c.meta })), ]; @@ -73,7 +75,7 @@ export async function getStateFromAggregateQuery( index, query, columns: state.layers[newLayerId].columns ?? [], - allColumns: columns, + allColumns, timeField: timeFieldName, errors, }, @@ -84,6 +86,7 @@ export async function getStateFromAggregateQuery( ...tempState, fieldList: columnsFromQuery ?? [], indexPatternRefs, + initialContext: context, }; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 32fc21cc4a61d..2ccc393a09326 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -30,6 +30,7 @@ import type { IndexPatternAggRestrictions } from '@kbn/data-plugin/public'; import type { FieldSpec, DataViewSpec } from '@kbn/data-views-plugin/common'; import type { FieldFormatParams } from '@kbn/field-formats-plugin/common'; import { SearchResponseWarning } from '@kbn/data-plugin/public/search/types'; +import type { EuiButtonIconColor } from '@elastic/eui'; import type { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; import type { @@ -228,14 +229,7 @@ export type VisualizeEditorContext = { export interface GetDropPropsArgs { state: T; source?: DraggingIdentifier; - target: { - layerId: string; - groupId: string; - columnId: string; - filterOperations: (meta: OperationMetadata) => boolean; - prioritizedOperation?: string; - isNewColumn?: boolean; - }; + target: DragDropOperation; indexPatterns: IndexPatternMap; } @@ -258,7 +252,6 @@ export interface Datasource { // Given the current state, which parts should be saved? getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] }; - getCurrentIndexPatternId: (state: T) => string; getUnifiedSearchErrors?: (state: T) => Error[]; insertLayer: (state: T, newLayerId: string) => T; @@ -441,7 +434,7 @@ export interface Datasource { /** * Get the used DataView value from state */ - getUsedDataView: (state: T, layerId: string) => string; + getUsedDataView: (state: T, layerId?: string) => string; /** * Get all the used DataViews from state */ @@ -514,6 +507,17 @@ export interface DatasourceDataPanelProps { usedIndexPatterns?: string[]; } +/** @internal **/ +export interface LayerAction { + displayName: string; + description?: string; + execute: () => void | Promise; + icon: IconType; + color?: EuiButtonIconColor; + isCompatible: boolean; + 'data-test-subj'?: string; +} + interface SharedDimensionProps { /** Visualizations can restrict operations based on their own rules. * For example, limiting to only bucketed or only numeric operations. @@ -582,8 +586,16 @@ export interface DragDropOperation { groupId: string; columnId: string; filterOperations: (operation: OperationMetadata) => boolean; + indexPatternId?: string; + isNewColumn?: boolean; + prioritizedOperation?: string; } +export type DraggedField = DragDropIdentifier & { + field: IndexPatternField; + indexPatternId: string; +}; + export function isOperation(operationCandidate: unknown): operationCandidate is DragDropOperation { return ( typeof operationCandidate === 'object' && @@ -892,6 +904,7 @@ export interface Visualization { */ initialize: (addNewLayer: () => string, state?: T, mainPalette?: PaletteOutput) => T; + getUsedDataView?: (state: T, layerId: string) => string | undefined; /** * Retrieve the used DataViews in the visualization */ @@ -961,6 +974,16 @@ export interface Visualization { staticValue?: unknown; }>; }>; + /** + * returns a list of custom actions supported by the visualization layer. + * Default actions like delete/clear are not included in this list and are managed by the editor frame + * */ + getSupportedActionsForLayer?: ( + layerId: string, + state: T, + setState: StateSetter + ) => LayerAction[]; + /** returns the type string of the given layer */ getLayerType: (layerId: string, state?: T) => LayerType | undefined; /* returns the type of removal operation to perform for the specific layer in the current state */ getRemoveOperation?: (state: T, layerId: string) => 'remove' | 'clear'; @@ -1022,6 +1045,10 @@ export interface Visualization { group?: VisualizationDimensionGroupConfig; }) => T; + getDropProps?: ( + dropProps: GetDropPropsArgs + ) => { dropTypes: DropType[]; nextLabel?: string } | undefined; + /** * Additional editor that gets rendered inside the dimension popover. * This can be used to configure dimension-specific options diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index 8f25379c0e21e..c40403cfd0928 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -15,15 +15,19 @@ import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public' import type { DatatableUtilitiesService } from '@kbn/data-plugin/common'; import { BrushTriggerEvent, ClickTriggerEvent } from '@kbn/charts-plugin/public'; import type { Document } from './persistence/saved_object_store'; -import type { +import { Datasource, DatasourceMap, Visualization, IndexPatternMap, IndexPatternRef, + DraggedField, + DragDropOperation, + isOperation, } from './types'; import type { DatasourceStates, VisualizationState } from './state_management'; import { IndexPatternServiceAPI } from './data_views_service/service'; +import { DraggingIdentifier } from './drag_drop'; export function getVisualizeGeoFieldMessage(fieldType: string) { return i18n.translate('xpack.lens.visualizeGeoFieldMessage', { @@ -126,7 +130,7 @@ export function getIndexPatternsIds({ const references: SavedObjectReference[] = []; Object.entries(activeDatasources).forEach(([id, datasource]) => { const { savedObjectReferences } = datasource.getPersistableState(datasourceStates[id].state); - const indexPatternId = datasource.getCurrentIndexPatternId(datasourceStates[id].state); + const indexPatternId = datasource.getUsedDataView(datasourceStates[id].state); currentIndexPatternId = indexPatternId; references.push(...savedObjectReferences); }); @@ -242,3 +246,42 @@ export function renewIDs( */ export const DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS = 'lensDontCloseDimensionContainerOnClick'; + +export function isDraggedField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + ['id', 'field'].every((prop) => prop in fieldCandidate) + ); +} + +export function isDraggedDataViewField(fieldCandidate: unknown): fieldCandidate is DraggedField { + return ( + typeof fieldCandidate === 'object' && + fieldCandidate !== null && + ['id', 'field', 'indexPatternId'].every((prop) => prop in fieldCandidate) + ); +} + +export const isOperationFromCompatibleGroup = ( + op1?: DraggingIdentifier, + op2?: DragDropOperation +) => { + return ( + isOperation(op1) && + isOperation(op2) && + op1.columnId !== op2.columnId && + op1.groupId === op2.groupId && + op1.layerId !== op2.layerId + ); +}; + +export const isOperationFromTheSameGroup = (op1?: DraggingIdentifier, op2?: DragDropOperation) => { + return ( + isOperation(op1) && + isOperation(op2) && + op1.columnId !== op2.columnId && + op1.groupId === op2.groupId && + op1.layerId === op2.layerId + ); +}; diff --git a/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts new file mode 100644 index 0000000000000..d7d3c9ca8a56a --- /dev/null +++ b/x-pack/plugins/lens/public/visualizations/xy/annotations/actions.ts @@ -0,0 +1,54 @@ +/* + * 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 type { LayerAction, StateSetter } from '../../../types'; +import type { XYState, XYAnnotationLayerConfig } from '../types'; + +export const createAnnotationActions = ({ + state, + layer, + layerIndex, + setState, +}: { + state: XYState; + layer: XYAnnotationLayerConfig; + layerIndex: number; + setState: StateSetter; +}): LayerAction[] => { + const label = !layer.ignoreGlobalFilters + ? i18n.translate('xpack.lens.xyChart.annotations.ignoreGlobalFiltersLabel', { + defaultMessage: 'Ignore global filters', + }) + : i18n.translate('xpack.lens.xyChart.annotations.keepGlobalFiltersLabel', { + defaultMessage: 'Keep global filters', + }); + return [ + { + displayName: label, + description: !layer.ignoreGlobalFilters + ? i18n.translate('xpack.lens.xyChart.annotations.ignoreGlobalFiltersDescription', { + defaultMessage: + 'All the dimensions configured in this layer ignore filters defined at kibana level.', + }) + : i18n.translate('xpack.lens.xyChart.annotations.keepGlobalFiltersDescription', { + defaultMessage: + 'All the dimensions configured in this layer respect filters defined at kibana level.', + }), + execute: () => { + const newLayers = [...state.layers]; + newLayers[layerIndex] = { ...layer, ignoreGlobalFilters: !layer.ignoreGlobalFilters }; + return setState({ ...state, layers: newLayers }); + }, + icon: !layer.ignoreGlobalFilters ? 'eyeClosed' : 'eye', + isCompatible: true, + 'data-test-subj': !layer.ignoreGlobalFilters + ? 'lnsXY_annotationLayer_ignoreFilters' + : 'lnsXY_annotationLayer_keepFilters', + }, + ]; +}; diff --git a/x-pack/plugins/lens/public/visualizations/xy/annotations/helpers.tsx b/x-pack/plugins/lens/public/visualizations/xy/annotations/helpers.tsx index baaed78ec0237..990d0dd8f94d0 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/annotations/helpers.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/annotations/helpers.tsx @@ -10,10 +10,12 @@ import moment from 'moment'; import { defaultAnnotationColor, defaultAnnotationRangeColor, + isQueryAnnotationConfig, isRangeAnnotationConfig, } from '@kbn/event-annotation-plugin/public'; import { EventAnnotationConfig } from '@kbn/event-annotation-plugin/common'; import { IconChartBarAnnotations } from '@kbn/chart-icons'; +import { isDraggedDataViewField } from '../../../utils'; import { layerTypes } from '../../../../common'; import type { FramePublicAPI, Visualization } from '../../../types'; import { isHorizontalChart } from '../state_helpers'; @@ -125,7 +127,7 @@ export const getAnnotationsSupportedLayer = ( }; }; -const getDefaultAnnotationConfig = (id: string, timestamp: string): EventAnnotationConfig => ({ +const getDefaultManualAnnotation = (id: string, timestamp: string): EventAnnotationConfig => ({ label: defaultAnnotationLabel, type: 'manual', key: { @@ -136,13 +138,32 @@ const getDefaultAnnotationConfig = (id: string, timestamp: string): EventAnnotat id, }); +const getDefaultQueryAnnotation = ( + id: string, + fieldName: string, + timeField: string +): EventAnnotationConfig => ({ + filter: { + type: 'kibana_query', + query: `${fieldName}: *`, + language: 'kuery', + }, + timeField, + type: 'query', + key: { + type: 'point_in_time', + }, + id, + label: `${fieldName}: *`, +}); + const createCopiedAnnotation = ( newId: string, timestamp: string, source?: EventAnnotationConfig ): EventAnnotationConfig => { if (!source) { - return getDefaultAnnotationConfig(newId, timestamp); + return getDefaultManualAnnotation(newId, timestamp); } return { ...source, @@ -158,17 +179,78 @@ export const onAnnotationDrop: Visualization['onDrop'] = ({ dropType, }) => { const targetLayer = prevState.layers.find((l) => l.layerId === target.layerId); - const sourceLayer = prevState.layers.find((l) => l.layerId === source.layerId); - if ( - !targetLayer || - !isAnnotationsLayer(targetLayer) || - !sourceLayer || - !isAnnotationsLayer(sourceLayer) - ) { + if (!targetLayer || !isAnnotationsLayer(targetLayer)) { return prevState; } const targetAnnotation = targetLayer.annotations.find(({ id }) => id === target.columnId); + const targetDataView = frame.dataViews.indexPatterns[targetLayer.indexPatternId]; + + if (isDraggedDataViewField(source)) { + const timeField = targetDataView.timeFieldName; + switch (dropType) { + case 'field_add': + if (targetAnnotation || !timeField) { + return prevState; + } + return { + ...prevState, + layers: prevState.layers.map( + (l): XYLayerConfig => + l.layerId === target.layerId + ? { + ...targetLayer, + annotations: [ + ...targetLayer.annotations, + getDefaultQueryAnnotation(target.columnId, source.field.name, timeField), + ], + } + : l + ), + }; + case 'field_replace': + if (!targetAnnotation || !timeField) { + return prevState; + } + + return { + ...prevState, + layers: prevState.layers.map( + (l): XYLayerConfig => + l.layerId === target.layerId + ? { + ...targetLayer, + annotations: [ + ...targetLayer.annotations.map((a) => + a === targetAnnotation + ? { + ...targetAnnotation, + ...getDefaultQueryAnnotation( + target.columnId, + source.field.name, + timeField + ), + } + : a + ), + ], + } + : l + ), + }; + } + + return prevState; + } + + const sourceLayer = prevState.layers.find((l) => l.layerId === source.layerId); + if (!sourceLayer || !isAnnotationsLayer(sourceLayer)) { + return prevState; + } const sourceAnnotation = sourceLayer.annotations.find(({ id }) => id === source.columnId); + const sourceDataView = frame.dataViews.indexPatterns[sourceLayer.indexPatternId]; + if (sourceDataView !== targetDataView && isQueryAnnotationConfig(sourceAnnotation)) { + return prevState; + } switch (dropType) { case 'reorder': if (!targetAnnotation || !sourceAnnotation || source.layerId !== target.layerId) { diff --git a/x-pack/plugins/lens/public/visualizations/xy/to_expression.ts b/x-pack/plugins/lens/public/visualizations/xy/to_expression.ts index 345e8ffcb5b19..bf75018f111ee 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/to_expression.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/to_expression.ts @@ -190,7 +190,10 @@ export const buildExpression = ( annotations: layer.annotations.map((c) => ({ ...c, label: uniqueLabels[c.id], - ignoreGlobalFilters: layer.ignoreGlobalFilters, + ...(c.type === 'query' + ? // Move the ignore flag at the event level + { ignoreGlobalFilters: layer.ignoreGlobalFilters } + : {}), })), }; }); diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts index 556a89c9a8553..e76633b349924 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.test.ts @@ -338,7 +338,11 @@ describe('xy_visualization', () => { let frame: ReturnType; beforeEach(() => { - frame = createMockFramePublicAPI(); + frame = createMockFramePublicAPI({ + dataViews: createMockDataViewsState({ + indexPatterns: { indexPattern1: createMockedIndexPattern() }, + }), + }); mockDatasource = createMockDatasource('testDatasource'); mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ @@ -494,309 +498,650 @@ describe('xy_visualization', () => { ], }); }); - it('should copy previous column if passed and assign a new id', () => { - expect( - xyVisualization.onDrop!({ - frame, - prevState: { - ...exampleState(), - layers: [ - { - layerId: 'annotation', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2], - ignoreGlobalFilters: true, + + describe('getDropProps', () => { + it('dragging operation: returns reorder for the same group existing columns', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'second', + filterOperations: () => true, + indexPatternId: '1', + }, + source: { + columnId: 'annotationColumn2', + groupId: 'xAnnotations', + layerId: 'second', + id: 'annotationColumn2', + humanData: { label: 'Event' }, + indexPatternId: '1', + }, + indexPatterns: {}, + }) + ).toEqual({ dropTypes: ['reorder'] }); + }); + it('dragging operation: returns duplicate for the same group existing column and not existing column', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'second', + isNewColumn: true, + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + source: { + columnId: 'annotationColumn2', + groupId: 'xAnnotations', + layerId: 'second', + id: 'annotationColumn2', + humanData: { label: 'Event' }, + indexPatternId: 'indexPattern1', + }, + indexPatterns: {}, + }) + ).toEqual({ dropTypes: ['duplicate_compatible'] }); + }); + it('dragging operation: returns replace_duplicate and replace for replacing to different layer', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'first', + filterOperations: () => true, + indexPatternId: '1', + }, + source: { + columnId: 'annotationColumn2', + groupId: 'xAnnotations', + layerId: 'second', + id: 'annotationColumn2', + humanData: { label: 'Event' }, + indexPatternId: '1', + }, + indexPatterns: {}, + }) + ).toEqual({ + dropTypes: ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'], + }); + }); + it('dragging operation: returns duplicate and move for replacing to different layer for empty column', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'first', + isNewColumn: true, + indexPatternId: 'indexPattern1', + filterOperations: () => true, + }, + source: { + columnId: 'annotationColumn2', + groupId: 'xAnnotations', + layerId: 'second', + id: 'annotationColumn2', + humanData: { label: 'Event' }, + indexPatternId: 'indexPattern1', + }, + indexPatterns: {}, + }) + ).toEqual({ + dropTypes: ['move_compatible', 'duplicate_compatible'], + }); + }); + it('dragging operation: does not allow to drop for different operations on different data views', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'first', + isNewColumn: true, + indexPatternId: 'indexPattern1', + filterOperations: () => true, + }, + source: { + columnId: 'annotationColumn2', + groupId: 'xAnnotations', + layerId: 'second', + id: 'annotationColumn2', + humanData: { label: 'Event' }, + indexPatternId: 'indexPattern2', + }, + indexPatterns: {}, + }) + ).toEqual(undefined); + }); + it('dragging field: should add a new dimension when dragged to a new dimension', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'first', + isNewColumn: true, + indexPatternId: 'indexPattern1', + filterOperations: () => true, + }, + source: { + field: { + name: 'agent.keyword', + displayName: 'agent.keyword', }, - ], - }, - dropType: 'duplicate_compatible', - source: { - layerId: 'annotation', - groupId: 'xAnnotation', - columnId: 'an2', - id: 'an2', - humanData: { label: 'an2' }, - }, - target: { - layerId: 'annotation', - groupId: 'xAnnotation', - columnId: 'newColId', - filterOperations: Boolean, - }, - }).layers[0] - ).toEqual({ - layerId: 'annotation', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2, { ...exampleAnnotation2, id: 'newColId' }], - ignoreGlobalFilters: true, + indexPatternId: 'indexPattern1', + id: 'agent.keyword', + humanData: { + label: 'agent.keyword', + position: 2, + }, + }, + indexPatterns: {}, + }) + ).toEqual({ dropTypes: ['field_add'] }); }); - }); - it('should reorder a dimension to a annotation layer', () => { - expect( - xyVisualization.onDrop!({ - frame, - prevState: { - ...exampleState(), - layers: [ - { - layerId: 'annotation', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation, exampleAnnotation2], - ignoreGlobalFilters: true, + it('dragging field: should replace an existing dimension when dragged to a dimension', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'first', + indexPatternId: 'indexPattern1', + filterOperations: () => true, + }, + source: { + field: { + name: 'agent.keyword', + displayName: 'agent.keyword', }, - ], - }, - source: { - layerId: 'annotation', - groupId: 'xAnnotation', - columnId: 'an2', - id: 'an2', - humanData: { label: 'label' }, - filterOperations: () => true, - }, - target: { - layerId: 'annotation', - groupId: 'xAnnotation', - columnId: 'an1', - filterOperations: () => true, - }, - dropType: 'reorder', - }).layers[0] - ).toEqual({ - layerId: 'annotation', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2, exampleAnnotation], - ignoreGlobalFilters: true, + indexPatternId: 'indexPattern1', + id: 'agent.keyword', + humanData: { + label: 'agent.keyword', + position: 2, + }, + }, + indexPatterns: {}, + }) + ).toEqual({ dropTypes: ['field_replace'] }); + }); + it('dragging field: should not allow to drop when data view conflict', () => { + expect( + xyVisualization.getDropProps?.({ + state: 'datasourceState', + target: { + columnId: 'annotationColumn', + groupId: 'xAnnotations', + layerId: 'first', + indexPatternId: 'indexPattern1', + filterOperations: () => true, + }, + source: { + field: { + name: 'agent.keyword', + displayName: 'agent.keyword', + }, + indexPatternId: 'indexPattern2', + id: 'agent.keyword', + humanData: { + label: 'agent.keyword', + position: 2, + }, + }, + indexPatterns: {}, + }) + ).toEqual(undefined); }); }); - it('should duplicate the annotations and replace the target in another annotation layer', () => { - expect( - xyVisualization.onDrop!({ - frame, - prevState: { - ...exampleState(), - layers: [ - { - layerId: 'first', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], - ignoreGlobalFilters: true, + describe('onDrop', () => { + it('dragging field: should add a new dimension when dragged to a new dimension', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + }, + ], + }, + dropType: 'field_add', + source: { + field: { + name: 'agent.keyword', }, - { - layerId: 'second', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2], - ignoreGlobalFilters: true, + indexPatternId: 'indexPattern1', + id: 'agent.keyword', + humanData: { + label: 'agent.keyword', + position: 2, }, - ], - }, - source: { - layerId: 'first', - groupId: 'xAnnotation', - columnId: 'an1', - id: 'an1', - humanData: { label: 'label' }, - filterOperations: () => true, - }, - target: { - layerId: 'second', - groupId: 'xAnnotation', - columnId: 'an2', - filterOperations: () => true, - }, - dropType: 'replace_duplicate_compatible', - }).layers - ).toEqual([ - { - layerId: 'first', + }, + target: { + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'newColId', + filterOperations: Boolean, + indexPatternId: 'indexPattern1', + }, + }).layers[0] + ).toEqual({ + layerId: 'annotation', layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], + annotations: [ + exampleAnnotation2, + { + filter: { + language: 'kuery', + query: 'agent.keyword: *', + type: 'kibana_query', + }, + id: 'newColId', + key: { + type: 'point_in_time', + }, + label: 'agent.keyword: *', + timeField: 'timestamp', + type: 'query', + }, + ], ignoreGlobalFilters: true, - }, - { - layerId: 'second', + }); + }); + it('dragging field: should replace an existing dimension when dragged to a dimension', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, + }, + ], + }, + dropType: 'field_replace', + source: { + field: { + name: 'agent.keyword', + }, + indexPatternId: 'indexPattern1', + id: 'agent.keyword', + humanData: { + label: 'agent.keyword', + position: 2, + }, + }, + target: { + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'an1', + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + }).layers[0] + ).toEqual({ + layerId: 'annotation', layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', - annotations: [{ ...exampleAnnotation, id: 'an2' }], - ignoreGlobalFilters: true, - }, - ]); - }); - it('should swap the annotations between layers', () => { - expect( - xyVisualization.onDrop!({ - frame, - prevState: { - ...exampleState(), - layers: [ - { - layerId: 'first', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], - ignoreGlobalFilters: true, + annotations: [ + { + filter: { + language: 'kuery', + query: 'agent.keyword: *', + type: 'kibana_query', }, - { - layerId: 'second', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2], - ignoreGlobalFilters: true, + icon: 'circle', + id: 'an1', + key: { + type: 'point_in_time', }, - ], - }, - source: { - layerId: 'first', - groupId: 'xAnnotation', - columnId: 'an1', - id: 'an1', - humanData: { label: 'label' }, - filterOperations: () => true, - }, - target: { - layerId: 'second', - groupId: 'xAnnotation', - columnId: 'an2', - filterOperations: () => true, - }, - dropType: 'swap_compatible', - }).layers - ).toEqual([ - { - layerId: 'first', + label: 'agent.keyword: *', + timeField: 'timestamp', + type: 'query', + }, + ], + ignoreGlobalFilters: true, + }); + }); + it('dragging operation: should copy previous column if passed and assign a new id', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + }, + ], + }, + dropType: 'duplicate_compatible', + source: { + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'an2', + id: 'an2', + humanData: { label: 'an2' }, + indexPatternId: 'indexPattern1', + }, + target: { + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'newColId', + filterOperations: Boolean, + indexPatternId: 'indexPattern1', + }, + }).layers[0] + ).toEqual({ + layerId: 'annotation', layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2], + annotations: [exampleAnnotation2, { ...exampleAnnotation2, id: 'newColId' }], ignoreGlobalFilters: true, - }, - { - layerId: 'second', + }); + }); + it('dragging operation: should reorder a dimension to a annotation layer', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation, exampleAnnotation2], + ignoreGlobalFilters: true, + }, + ], + }, + source: { + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'an2', + id: 'an2', + humanData: { label: 'label' }, + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + target: { + layerId: 'annotation', + groupId: 'xAnnotation', + columnId: 'an1', + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + dropType: 'reorder', + }).layers[0] + ).toEqual({ + layerId: 'annotation', layerType: layerTypes.ANNOTATIONS, indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], + annotations: [exampleAnnotation2, exampleAnnotation], ignoreGlobalFilters: true, - }, - ]); - }); - it('should replace the target in another annotation layer', () => { - expect( - xyVisualization.onDrop!({ - frame, - prevState: { - ...exampleState(), - layers: [ - { - layerId: 'first', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], - ignoreGlobalFilters: true, - }, - { - layerId: 'second', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation2], - ignoreGlobalFilters: true, - }, - ], + }); + }); + + it('dragging operation: should duplicate the annotations and replace the target in another annotation layer', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, + }, + { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + }, + ], + }, + source: { + layerId: 'first', + groupId: 'xAnnotation', + columnId: 'an1', + id: 'an1', + humanData: { label: 'label' }, + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + target: { + layerId: 'second', + groupId: 'xAnnotation', + columnId: 'an2', + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + dropType: 'replace_duplicate_compatible', + }).layers + ).toEqual([ + { + layerId: 'first', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, }, - source: { + { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [{ ...exampleAnnotation, id: 'an2' }], + ignoreGlobalFilters: true, + }, + ]); + }); + it('dragging operation: should swap the annotations between layers', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, + }, + { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + }, + ], + }, + source: { + layerId: 'first', + groupId: 'xAnnotation', + columnId: 'an1', + id: 'an1', + humanData: { label: 'label' }, + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + target: { + layerId: 'second', + groupId: 'xAnnotation', + columnId: 'an2', + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + dropType: 'swap_compatible', + }).layers + ).toEqual([ + { layerId: 'first', - groupId: 'xAnnotation', - columnId: 'an1', - id: 'an1', - humanData: { label: 'label' }, - filterOperations: () => true, + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, }, - target: { + { layerId: 'second', - groupId: 'xAnnotation', - columnId: 'an2', - filterOperations: () => true, + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, }, - dropType: 'replace_compatible', - }).layers - ).toEqual([ - { - layerId: 'first', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [], - ignoreGlobalFilters: true, - }, - { - layerId: 'second', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], - ignoreGlobalFilters: true, - }, - ]); - }); - it('should move compatible to another annotation layer', () => { - expect( - xyVisualization.onDrop!({ - frame, - prevState: { - ...exampleState(), - layers: [ - { - layerId: 'first', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], - ignoreGlobalFilters: true, - }, - { - layerId: 'second', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [], - ignoreGlobalFilters: true, - }, - ], + ]); + }); + it('dragging operation: should replace the target in another annotation layer', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, + }, + { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + }, + ], + }, + source: { + layerId: 'first', + groupId: 'xAnnotation', + columnId: 'an1', + id: 'an1', + humanData: { label: 'label' }, + filterOperations: () => true, + }, + target: { + layerId: 'second', + groupId: 'xAnnotation', + columnId: 'an2', + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + dropType: 'replace_compatible', + }).layers + ).toEqual([ + { + layerId: 'first', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [], + ignoreGlobalFilters: true, + }, + { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, }, - source: { + ]); + }); + it('dragging operation: should move compatible to another annotation layer', () => { + expect( + xyVisualization.onDrop!({ + frame, + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, + }, + { + layerId: 'second', + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [], + ignoreGlobalFilters: true, + }, + ], + }, + source: { + layerId: 'first', + groupId: 'xAnnotation', + columnId: 'an1', + id: 'an1', + humanData: { label: 'label' }, + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + target: { + layerId: 'second', + groupId: 'xAnnotation', + columnId: 'an2', + filterOperations: () => true, + indexPatternId: 'indexPattern1', + }, + dropType: 'move_compatible', + }).layers + ).toEqual([ + { layerId: 'first', - groupId: 'xAnnotation', - columnId: 'an1', - id: 'an1', - humanData: { label: 'label' }, - filterOperations: () => true, + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [], + ignoreGlobalFilters: true, }, - target: { + { layerId: 'second', - groupId: 'xAnnotation', - columnId: 'an2', - filterOperations: () => true, + layerType: layerTypes.ANNOTATIONS, + indexPatternId: 'indexPattern1', + annotations: [exampleAnnotation], + ignoreGlobalFilters: true, }, - dropType: 'move_compatible', - }).layers - ).toEqual([ - { - layerId: 'first', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [], - ignoreGlobalFilters: true, - }, - { - layerId: 'second', - layerType: layerTypes.ANNOTATIONS, - indexPatternId: 'indexPattern1', - annotations: [exampleAnnotation], - ignoreGlobalFilters: true, - }, - ]); + ]); + }); }); }); }); @@ -2512,4 +2857,81 @@ describe('xy_visualization', () => { }); }); }); + + describe('getSupportedActionsForLayer', () => { + it('should return no actions for a data layer', () => { + expect( + xyVisualization.getSupportedActionsForLayer?.('first', exampleState(), jest.fn()) + ).toHaveLength(0); + }); + + it('should return one action for an annotation layer', () => { + const baseState = exampleState(); + expect( + xyVisualization.getSupportedActionsForLayer?.( + 'annotation', + { + ...baseState, + layers: [ + ...baseState.layers, + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + indexPatternId: 'myIndexPattern', + }, + ], + }, + jest.fn() + ) + ).toEqual([ + expect.objectContaining({ + displayName: 'Keep global filters', + description: + 'All the dimensions configured in this layer respect filters defined at kibana level.', + icon: 'eye', + isCompatible: true, + 'data-test-subj': 'lnsXY_annotationLayer_keepFilters', + }), + ]); + }); + + it('should return an action that performs a state update on click', () => { + const baseState = exampleState(); + const setState = jest.fn(); + const [action] = xyVisualization.getSupportedActionsForLayer?.( + 'annotation', + { + ...baseState, + layers: [ + ...baseState.layers, + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation2], + ignoreGlobalFilters: true, + indexPatternId: 'myIndexPattern', + }, + ], + }, + setState + )!; + action.execute(); + + expect(setState).toHaveBeenCalledWith( + expect.objectContaining({ + layers: expect.arrayContaining([ + { + layerId: 'annotation', + layerType: layerTypes.ANNOTATIONS, + annotations: [exampleAnnotation2], + ignoreGlobalFilters: false, + indexPatternId: 'myIndexPattern', + }, + ]), + }) + ); + }); + }); }); diff --git a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx index 34ab6c88ffa19..c013f0cd1d079 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/visualization.tsx @@ -20,20 +20,25 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { generateId } from '../../id_generator'; -import { renewIDs } from '../../utils'; +import { + isDraggedDataViewField, + isOperationFromCompatibleGroup, + isOperationFromTheSameGroup, + renewIDs, +} from '../../utils'; import { getSuggestions } from './xy_suggestions'; import { XyToolbar } from './xy_config_panel'; import { DimensionEditor } from './xy_config_panel/dimension_editor'; import { LayerHeader, LayerHeaderContent } from './xy_config_panel/layer_header'; -import type { Visualization, AccessorConfig, FramePublicAPI } from '../../types'; +import { Visualization, AccessorConfig, FramePublicAPI } from '../../types'; import { - State, + type State, + type XYLayerConfig, + type XYDataLayerConfig, + type SeriesType, + type XYSuggestion, + type PersistedState, visualizationTypes, - XYLayerConfig, - XYDataLayerConfig, - SeriesType, - XYSuggestion, - PersistedState, } from './types'; import { layerTypes } from '../../../common'; import { @@ -79,12 +84,13 @@ import { validateLayersForDimension, } from './visualization_helpers'; import { groupAxesByType } from './axes_configuration'; -import { XYState } from './types'; +import type { XYState } from './types'; import { ReferenceLinePanel } from './xy_config_panel/reference_line_config_panel'; import { AnnotationsPanel } from './xy_config_panel/annotations_config_panel'; import { DimensionTrigger } from '../../shared_components/dimension_trigger'; import { defaultAnnotationLabel } from './annotations/helpers'; import { onDropForVisualization } from '../../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils'; +import { createAnnotationActions } from './annotations/actions'; const XY_ID = 'lnsXY'; export const getXyVisualization = ({ @@ -235,6 +241,16 @@ export const getXyVisualization = ({ ]; }, + getSupportedActionsForLayer(layerId, state, setState) { + const layerIndex = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[layerIndex]; + const actions = []; + if (isAnnotationsLayer(layer)) { + actions.push(...createAnnotationActions({ state, layerIndex, layer, setState })); + } + return actions; + }, + onIndexPatternChange(state, indexPatternId, layerId) { const layerIndex = state.layers.findIndex((l) => l.layerId === layerId); const layer = state.layers[layerIndex]; @@ -366,6 +382,39 @@ export const getXyVisualization = ({ return getFirstDataLayer(state.layers)?.palette; }, + getDropProps(dropProps) { + if (!dropProps.source) { + return; + } + const srcDataView = dropProps.source.indexPatternId; + const targetDataView = dropProps.target.indexPatternId; + if (!targetDataView || srcDataView !== targetDataView) { + return; + } + + if (isDraggedDataViewField(dropProps.source)) { + if (dropProps.source.field.type === 'document') { + return; + } + return dropProps.target.isNewColumn + ? { dropTypes: ['field_add'] } + : { dropTypes: ['field_replace'] }; + } + + if (isOperationFromTheSameGroup(dropProps.source, dropProps.target)) { + return dropProps.target.isNewColumn + ? { dropTypes: ['duplicate_compatible'] } + : { dropTypes: ['reorder'] }; + } + if (isOperationFromCompatibleGroup(dropProps.source, dropProps.target)) { + return { + dropTypes: dropProps.target.isNewColumn + ? ['move_compatible', 'duplicate_compatible'] + : ['replace_compatible', 'replace_duplicate_compatible', 'swap_compatible'], + }; + } + }, + onDrop(props) { const targetLayer: XYLayerConfig | undefined = props.prevState.layers.find( (l) => l.layerId === props.target.layerId @@ -724,6 +773,9 @@ export const getXyVisualization = ({ getUniqueLabels(state) { return getUniqueLabels(state.layers); }, + getUsedDataView(state, layerId) { + return getAnnotationsLayers(state.layers).find((l) => l.layerId === layerId)?.indexPatternId; + }, getUsedDataViews(state) { return ( state?.layers.filter(isAnnotationsLayer).map(({ indexPatternId }) => indexPatternId) ?? [] diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx index a6e1cd6b2ac34..480c0773f4520 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx @@ -352,7 +352,11 @@ export const AnnotationsPanel = ( defaultMessage: 'Color', })} /> - setAnnotations({ isHidden: ev.target.checked })} /> @@ -384,31 +388,25 @@ export const AnnotationsPanel = ( ); }; -const ConfigPanelHideSwitch = ({ +const ConfigPanelGenericSwitch = ({ + label, + ['data-test-subj']: dataTestSubj, value, onChange, }: { + label: string; + 'data-test-subj': string; value: boolean; onChange: (event: EuiSwitchEvent) => void; -}) => { - return ( - - - - ); -}; +}) => ( + + + +); diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/helpers.ts b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/helpers.ts index 2ca0f9530bbe1..89fbdfd38fcf1 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/helpers.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/helpers.ts @@ -97,6 +97,7 @@ export const sanitizeProperties = (annotation: EventAnnotationConfig) => { 'textField', 'filter', 'extraFields', + 'ignoreGlobalFilters', ]); return lineAnnotation; } diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/index.scss b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/index.scss index 2b59be0b044f9..93bf0e2c72662 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/index.scss +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/index.scss @@ -14,11 +14,6 @@ margin-top: $euiSizeXS; } -.lnsConfigPanelAnnotations__droppable { - padding: $euiSizeXS; - border-radius: $euiBorderRadiusSmall; -} - .lnsConfigPanelAnnotations__fieldPicker { cursor: pointer; -} \ No newline at end of file +} diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx index c44f76427e0a7..20a99e8458fc0 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx @@ -5,19 +5,9 @@ * 2.0. */ -import { - htmlIdGenerator, - EuiButtonIcon, - EuiDraggable, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiPanel, - useEuiTheme, - EuiText, -} from '@elastic/eui'; +import { htmlIdGenerator, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common'; import type { ExistingFieldsMap, IndexPattern } from '../../../../types'; import { @@ -28,8 +18,12 @@ import { useDebouncedValue, NewBucketButton, DragDropBuckets, + DraggableBucketContainer, + FieldsBucketContainer, } from '../../../../shared_components'; +export const MAX_TOOLTIP_FIELDS_SIZE = 2; + const generateId = htmlIdGenerator(); const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']); @@ -60,8 +54,6 @@ export function TooltipSection({ existingFields, invalidFields, }: FieldInputsProps) { - const { euiTheme } = useEuiTheme(); - const [isDragging, setIsDragging] = useState(false); const onChangeWrapped = useCallback( (values: WrappedValue[]) => { setConfig({ @@ -108,6 +100,7 @@ export function TooltipSection({ label={i18n.translate('xpack.lens.xyChart.annotation.tooltip.addField', { defaultMessage: 'Add field', })} + isDisabled={localValues.length > MAX_TOOLTIP_FIELDS_SIZE} /> ); @@ -132,7 +125,7 @@ export function TooltipSection({ ); } const currentExistingField = existingFields[indexPattern.title]; - const disableActions = localValues.length === 2 && localValues.some(({ isNew }) => isNew); + const options = indexPattern.fields .filter( ({ displayName, type }) => @@ -154,107 +147,62 @@ export function TooltipSection({ ) .sort((a, b) => a.label.localeCompare(b.label)); - const isDragDisabled = localValues.length < 2; - return ( <> -
{ + handleInputChange(updatedValues); }} + droppableId="ANNOTATION_TOOLTIP_DROPPABLE_AREA" + items={localValues} + bgColor="subdued" > - { - handleInputChange(updatedValues); - setIsDragging(false); - }} - onDragStart={() => { - setIsDragging(true); - }} - droppableId="ANNOTATION_TOOLTIP_DROPPABLE_AREA" - items={localValues} - className="lnsConfigPanelAnnotations__droppable" - > - {localValues.map(({ id, value, isNew }, index) => { - const fieldIsValid = value ? Boolean(indexPattern.getFieldByName(value)) : true; - return ( - - {(provided) => ( - - - {/* Empty for spacing */} - - - - - - - - { - handleInputChange(localValues.filter((_, i) => i !== index)); - }} - data-test-subj={`lnsXY-annotation-tooltip-removeField-${index}`} - isDisabled={disableActions && !isNew} - /> - - - - )} - - ); - })} - -
+ {localValues.map(({ id, value, isNew }, index, arrayRef) => { + const fieldIsValid = value ? Boolean(indexPattern.getFieldByName(value)) : true; + + return ( + { + handleInputChange(arrayRef.filter((_, i) => i !== index)); + }} + removeTitle={i18n.translate( + 'xpack.lens.xyChart.annotation.tooltip.deleteButtonLabel', + { + defaultMessage: 'Delete', + } + )} + isNotDraggable={arrayRef.length < 2} + Container={FieldsBucketContainer} + isInsidePanel={true} + data-test-subj={`lnsXY-annotation-tooltip-${index}`} + > + { + onFieldSelectChange(choice, index); + }} + fieldIsInvalid={!fieldIsValid} + className="lnsConfigPanelAnnotations__fieldPicker" + data-test-subj={`lnsXY-annotation-tooltip-field-picker--${index}`} + autoFocus={isNew && value == null} + /> + + ); + })} + {newBucketButton} ); diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx index 2a8ce6e04144c..9cfa17fe71941 100644 --- a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -57,6 +57,7 @@ export const ExplainLogRateSpikesPage: FC = () => { 'storage', 'uiSettings', 'unifiedSearch', + 'theme', ])} /> )} diff --git a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx index 899006b5918dd..3f70e0c58324d 100644 --- a/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx +++ b/x-pack/plugins/ml/public/application/aiops/log_categorization.tsx @@ -57,6 +57,7 @@ export const LogCategorizationPage: FC = () => { 'storage', 'uiSettings', 'unifiedSearch', + 'theme', ])} /> )} diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 8d5958c2f5574..1014b4e3a08b3 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -15,6 +15,7 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { MlStorageContextProvider } from './contexts/storage'; import { setDependencyCache, clearCache } from './util/dependency_cache'; import { setLicenseCache } from './license'; import type { MlSetupDependencies, MlStartDependencies } from '../plugin'; @@ -109,7 +110,9 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { mlServices: getMlGlobalServices(coreStart.http, deps.usageCollection), }} > - + + + diff --git a/x-pack/plugins/ml/public/application/components/ml_page/notifications_indicator.tsx b/x-pack/plugins/ml/public/application/components/ml_page/notifications_indicator.tsx index d0e3516af3db0..2d95f0c971696 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/notifications_indicator.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/notifications_indicator.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { @@ -15,60 +15,14 @@ import { EuiNotificationBadge, EuiToolTip, } from '@elastic/eui'; -import { combineLatest, of, timer } from 'rxjs'; -import { catchError, switchMap } from 'rxjs/operators'; -import moment from 'moment'; import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter'; -import { useAsObservable } from '../../hooks'; -import { NotificationsCountResponse } from '../../../../common/types/notifications'; -import { useMlKibana } from '../../contexts/kibana'; -import { useStorage } from '../../contexts/storage'; -import { ML_NOTIFICATIONS_LAST_CHECKED_AT } from '../../../../common/types/storage'; - -const NOTIFICATIONS_CHECK_INTERVAL = 60000; +import { useMlNotifications } from '../../contexts/ml/ml_notifications_context'; export const NotificationsIndicator: FC = () => { - const { - services: { - mlServices: { mlApiServices }, - }, - } = useMlKibana(); + const { notificationsCounts, latestRequestedAt } = useMlNotifications(); const dateFormatter = useFieldFormatter(FIELD_FORMAT_IDS.DATE); - const [lastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT); - const lastCheckedAt$ = useAsObservable(lastCheckedAt); - - /** Holds the value used for the actual request */ - const [lastCheckRequested, setLastCheckRequested] = useState(); - const [notificationsCounts, setNotificationsCounts] = useState(); - - useEffect(function startPollingNotifications() { - const subscription = combineLatest([lastCheckedAt$, timer(0, NOTIFICATIONS_CHECK_INTERVAL)]) - .pipe( - switchMap(([lastChecked]) => { - const lastCheckedAtQuery = lastChecked ?? moment().subtract(7, 'd').valueOf(); - setLastCheckRequested(lastCheckedAtQuery); - // Use the latest check time or 7 days ago by default. - return mlApiServices.notifications.countMessages$({ - lastCheckedAt: lastCheckedAtQuery, - }); - }), - catchError((error) => { - // Fail silently for now - return of({} as NotificationsCountResponse); - }) - ) - .subscribe((response) => { - setNotificationsCounts(response); - }); - - return () => { - subscription.unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const errorsAndWarningCount = (notificationsCounts?.error ?? 0) + (notificationsCounts?.warning ?? 0); const hasUnread = notificationsCounts && Object.values(notificationsCounts).some((v) => v > 0); @@ -91,7 +45,7 @@ export const NotificationsIndicator: FC = () => { defaultMessage="There {count, plural, one {is # notification} other {are # notifications}} with error or warning level since {lastCheckedAt}" values={{ count: errorsAndWarningCount, - lastCheckedAt: dateFormatter(lastCheckRequested), + lastCheckedAt: dateFormatter(latestRequestedAt), }} /> } @@ -115,7 +69,7 @@ export const NotificationsIndicator: FC = () => { } > diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx index 5f263b51364e0..536cc26888a98 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx @@ -123,7 +123,7 @@ describe('Navigation Menu: ', () => { refreshSubscription.unsubscribe(); }); - test('should not allow disabled pause with 0 refresh interval', () => { + test('should set interval to default of 5s when pause is disabled and refresh interval is 0', () => { // arrange (useUrlState as jest.Mock).mockReturnValue([{ refreshInterval: { pause: false, value: 0 } }]); @@ -137,9 +137,10 @@ describe('Navigation Menu: ', () => { render(); // assert - expect(displayWarningSpy).not.toHaveBeenCalled(); + // Show warning that the interval set is too short + expect(displayWarningSpy).toHaveBeenCalled(); const calledWith = MockedEuiSuperDatePicker.mock.calls[0][0]; - expect(calledWith.isPaused).toBe(true); + expect(calledWith.isPaused).toBe(false); expect(calledWith.refreshInterval).toBe(5000); }); diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx index 75cb787abadc4..75c503a139499 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx @@ -126,18 +126,11 @@ export const DatePickerWrapper: FC = () => { timefilter.isTimeRangeSelectorEnabled() ); - const refreshInterval = useMemo((): RefreshInterval => { - const resultInterval = globalState?.refreshInterval ?? timeFilterRefreshInterval; - - /** - * Enforce pause when it's set to false with 0 refresh interval. - */ - const pause = resultInterval.pause || (!resultInterval.pause && resultInterval.value <= 0); - const value = resultInterval.value; - - return { value, pause }; + const refreshInterval = useMemo( + (): RefreshInterval => globalState?.refreshInterval ?? timeFilterRefreshInterval, // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(globalState?.refreshInterval), timeFilterRefreshInterval]); + [JSON.stringify(globalState?.refreshInterval), timeFilterRefreshInterval] + ); useEffect( function warnAboutShortRefreshInterval() { @@ -251,6 +244,9 @@ export const DatePickerWrapper: FC = () => { isPaused: boolean; refreshInterval: number; }) { + if (pause === false && value <= 0) { + setRefreshInterval({ pause, value: 5000 }); + } setRefreshInterval({ pause, value }); } diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_notifications_context.test.tsx b/x-pack/plugins/ml/public/application/contexts/ml/ml_notifications_context.test.tsx new file mode 100644 index 0000000000000..4076966942cd8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_notifications_context.test.tsx @@ -0,0 +1,140 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { of } from 'rxjs'; +import { useMlNotifications, MlNotificationsContextProvider } from './ml_notifications_context'; +import { useStorage } from '../storage'; + +const mockCountMessages = jest.fn(() => { + return of({ info: 1, error: 0, warning: 0 }); +}); + +jest.mock('../kibana', () => ({ + useMlKibana: () => { + return { + services: { + mlServices: { + mlApiServices: { + notifications: { + countMessages$: mockCountMessages, + }, + }, + }, + }, + }; + }, +})); + +const mockSetStorageValue = jest.fn(); +jest.mock('../storage', () => ({ + useStorage: jest.fn(() => { + return [undefined, mockSetStorageValue]; + }), +})); + +describe('useMlNotifications', () => { + beforeEach(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(1663945337063); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test('returns the default values', () => { + const { result } = renderHook(useMlNotifications, { wrapper: MlNotificationsContextProvider }); + expect(result.current.notificationsCounts).toEqual({ info: 0, error: 0, warning: 0 }); + expect(result.current.latestRequestedAt).toEqual(null); + expect(result.current.lastCheckedAt).toEqual(undefined); + }); + + test('starts polling for notifications with a 1 minute interval during the last week by default ', () => { + const { result } = renderHook(useMlNotifications, { + wrapper: MlNotificationsContextProvider, + }); + + act(() => { + jest.advanceTimersByTime(0); + }); + + expect(mockCountMessages).toHaveBeenCalledTimes(1); + expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1663340537063 }); + expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 }); + expect(result.current.latestRequestedAt).toEqual(1663340537063); + expect(result.current.lastCheckedAt).toEqual(undefined); + + act(() => { + mockCountMessages.mockReturnValueOnce(of({ info: 1, error: 2, warning: 0 })); + jest.advanceTimersByTime(60000); + }); + + expect(mockCountMessages).toHaveBeenCalledTimes(2); + expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1663340537063 + 60000 }); + expect(result.current.notificationsCounts).toEqual({ info: 1, error: 2, warning: 0 }); + expect(result.current.latestRequestedAt).toEqual(1663340537063 + 60000); + expect(result.current.lastCheckedAt).toEqual(undefined); + }); + + test('starts polling for notifications with a 1 minute interval using the lastCheckedAt from storage', () => { + (useStorage as jest.MockedFunction).mockReturnValue([ + 1664551009292, + mockSetStorageValue, + ]); + const { result } = renderHook(useMlNotifications, { + wrapper: MlNotificationsContextProvider, + }); + + act(() => { + jest.advanceTimersByTime(0); + }); + + expect(mockCountMessages).toHaveBeenCalledTimes(1); + expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1664551009292 }); + expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 }); + expect(result.current.latestRequestedAt).toEqual(1664551009292); + expect(result.current.lastCheckedAt).toEqual(1664551009292); + }); + + test('switches to polling with the lastCheckedAt from storage when available', () => { + (useStorage as jest.MockedFunction).mockReturnValue([ + undefined, + mockSetStorageValue, + ]); + const { result, rerender } = renderHook(useMlNotifications, { + wrapper: MlNotificationsContextProvider, + }); + + act(() => { + jest.advanceTimersByTime(0); + }); + + expect(mockCountMessages).toHaveBeenCalledTimes(1); + expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1663340537063 }); + expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 }); + expect(result.current.latestRequestedAt).toEqual(1663340537063); + expect(result.current.lastCheckedAt).toEqual(undefined); + + act(() => { + (useStorage as jest.MockedFunction).mockReturnValue([ + 1664551009292, + mockSetStorageValue, + ]); + }); + + rerender(); + + expect(mockCountMessages).toHaveBeenCalledTimes(2); + expect(mockCountMessages).toHaveBeenCalledWith({ lastCheckedAt: 1664551009292 }); + expect(result.current.notificationsCounts).toEqual({ info: 1, error: 0, warning: 0 }); + expect(result.current.latestRequestedAt).toEqual(1664551009292); + expect(result.current.lastCheckedAt).toEqual(1664551009292); + }); +}); diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_notifications_context.tsx b/x-pack/plugins/ml/public/application/contexts/ml/ml_notifications_context.tsx new file mode 100644 index 0000000000000..fef04e9366671 --- /dev/null +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_notifications_context.tsx @@ -0,0 +1,91 @@ +/* + * 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, { FC, useContext, useEffect, useState } from 'react'; +import { combineLatest, of, timer } from 'rxjs'; +import { catchError, switchMap, map, tap } from 'rxjs/operators'; +import moment from 'moment'; +import { useMlKibana } from '../kibana'; +import { useStorage } from '../storage'; +import { ML_NOTIFICATIONS_LAST_CHECKED_AT } from '../../../../common/types/storage'; +import { useAsObservable } from '../../hooks'; +import type { NotificationsCountResponse } from '../../../../common/types/notifications'; + +const NOTIFICATIONS_CHECK_INTERVAL = 60000; + +export const MlNotificationsContext = React.createContext<{ + notificationsCounts: NotificationsCountResponse; + /** Timestamp of the latest notification checked by the user */ + lastCheckedAt: number | null; + /** Holds the value used for the actual request */ + latestRequestedAt: number | null; + setLastCheckedAt: (v: number) => void; +}>({ + notificationsCounts: { info: 0, error: 0, warning: 0 }, + lastCheckedAt: null, + latestRequestedAt: null, + setLastCheckedAt: () => {}, +}); + +export const MlNotificationsContextProvider: FC = ({ children }) => { + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const [lastCheckedAt, setLastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT); + const lastCheckedAt$ = useAsObservable(lastCheckedAt); + + /** Holds the value used for the actual request */ + const [latestRequestedAt, setLatestRequestedAt] = useState(null); + const [notificationsCounts, setNotificationsCounts] = useState({ + info: 0, + error: 0, + warning: 0, + }); + + useEffect(function startPollingNotifications() { + const subscription = combineLatest([lastCheckedAt$, timer(0, NOTIFICATIONS_CHECK_INTERVAL)]) + .pipe( + // Use the latest check time or 7 days ago by default. + map(([lastChecked]) => lastChecked ?? moment().subtract(7, 'd').valueOf()), + tap((lastCheckedAtQuery) => { + setLatestRequestedAt(lastCheckedAtQuery); + }), + switchMap((lastCheckedAtQuery) => + mlApiServices.notifications.countMessages$({ + lastCheckedAt: lastCheckedAtQuery, + }) + ), + catchError((error) => { + // Fail silently for now + return of({} as NotificationsCountResponse); + }) + ) + .subscribe((response) => { + setNotificationsCounts(response); + }); + + return () => { + subscription.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + {children} + + ); +}; + +export function useMlNotifications() { + return useContext(MlNotificationsContext); +} diff --git a/x-pack/plugins/ml/public/application/contexts/storage/storage_context.tsx b/x-pack/plugins/ml/public/application/contexts/storage/storage_context.tsx index c2b00a176c08c..65e2a64ee3181 100644 --- a/x-pack/plugins/ml/public/application/contexts/storage/storage_context.tsx +++ b/x-pack/plugins/ml/public/application/contexts/storage/storage_context.tsx @@ -33,10 +33,12 @@ export const MlStorageContextProvider: FC = ({ children }) => { services: { storage }, } = useMlKibana(); - const initialValue = ML_STORAGE_KEYS.reduce((acc, curr) => { - acc[curr as MlStorageKey] = storage.get(curr); - return acc; - }, {} as Exclude); + const initialValue = useMemo(() => { + return ML_STORAGE_KEYS.reduce((acc, curr) => { + acc[curr as MlStorageKey] = storage.get(curr); + return acc; + }, {} as Exclude); + }, [storage]); const [state, setState] = useState(initialValue); @@ -44,21 +46,20 @@ export const MlStorageContextProvider: FC = ({ children }) => { >(key: K, value: T) => { storage.set(key, value); - const update = { - ...state, + setState((prevState) => ({ + ...prevState, [key]: value, - }; - setState(update); + })); }, - [state, storage] + [storage] ); const removeStorageValue = useCallback( (key: MlStorageKey) => { storage.remove(key); - setState(omit(state, key)); + setState((prevState) => omit(prevState, key)); }, - [state, storage] + [storage] ); useEffect(function updateStorageOnExternalChange() { diff --git a/x-pack/plugins/ml/public/application/notifications/components/notifications_list.tsx b/x-pack/plugins/ml/public/application/notifications/components/notifications_list.tsx index 9ea6aa1b70f00..cd68f366b57a6 100644 --- a/x-pack/plugins/ml/public/application/notifications/components/notifications_list.tsx +++ b/x-pack/plugins/ml/public/application/notifications/components/notifications_list.tsx @@ -22,9 +22,8 @@ import { import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table'; import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; import useDebounce from 'react-use/lib/useDebounce'; +import { useMlNotifications } from '../../contexts/ml/ml_notifications_context'; import { ML_NOTIFICATIONS_MESSAGE_LEVEL } from '../../../../common/constants/notifications'; -import { ML_NOTIFICATIONS_LAST_CHECKED_AT } from '../../../../common/types/storage'; -import { useStorage } from '../../contexts/storage'; import { SavedObjectsWarning } from '../../components/saved_objects_warning'; import { useTimefilter, useTimeRangeUpdates } from '../../contexts/kibana/use_timefilter'; import { useToastNotificationService } from '../../services/toast_notification_service'; @@ -61,7 +60,8 @@ export const NotificationsList: FC = () => { } = useMlKibana(); const { displayErrorToast } = useToastNotificationService(); - const [lastCheckedAt, setLastCheckedAt] = useStorage(ML_NOTIFICATIONS_LAST_CHECKED_AT); + const { lastCheckedAt, setLastCheckedAt, notificationsCounts, latestRequestedAt } = + useMlNotifications(); const timeFilter = useTimefilter(); const timeRange = useTimeRangeUpdates(); @@ -280,10 +280,29 @@ export const NotificationsList: FC = () => { ]; }, []); + const newNotificationsCount = Object.values(notificationsCounts).reduce((a, b) => a + b); + return ( <> + {newNotificationsCount ? ( + <> + + } + iconType="bell" + /> + + + ) : null} + = ({ pageDeps }) => ( - - + + - - + + ); diff --git a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx index b9600088af3bf..bc41b8d803285 100644 --- a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx @@ -16,9 +16,11 @@ import { addDisplayNames } from '../pages/alerts/containers/alerts_table_t_grid/ import { columns as alertO11yColumns } from '../pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid'; import { getRowActions } from '../pages/alerts/containers/alerts_table_t_grid/get_row_actions'; import type { ObservabilityRuleTypeRegistry } from '../rules/create_observability_rule_type_registry'; +import type { ConfigSchema } from '../plugin'; const getO11yAlertsTableConfiguration = ( - observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry + observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry, + config: ConfigSchema ) => ({ id: observabilityFeatureId, casesFeatureId, @@ -33,7 +35,7 @@ const getO11yAlertsTableConfiguration = ( }, }, ], - useActionsColumn: getRowActions(observabilityRuleTypeRegistry), + useActionsColumn: getRowActions(observabilityRuleTypeRegistry, config), useBulkActions: useBulkAddToCaseActions, useInternalFlyout: () => { const { header, body, footer } = useToGetInternalFlyout(observabilityRuleTypeRegistry); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_footer.tsx b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_footer.tsx index 3a6bcb8d28f26..eecadba66b614 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_footer.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/alerts_flyout/alerts_flyout_footer.tsx @@ -18,7 +18,7 @@ export default function AlertsFlyoutFooter({ alert, isInApp }: FlyoutProps & { i const { http } = services; const prepend = http?.basePath.prepend; const getAlertDetailsButton = () => { - if (!config.unsafe.alertDetails.enabled || !alert) return <>; + if (!config?.unsafe?.alertDetails.enabled || !alert) return <>; return ( ({ describe('ObservabilityActions component', () => { const setup = async (pageId: string) => { const props: ObservabilityActionsProps = { + config, eventId: '6d4c6d74-d51a-495c-897d-88ced3b95e30', ecsData: { _id: '6d4c6d74-d51a-495c-897d-88ced3b95e30', diff --git a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx index eddead1fa90de..2ebb4629ba7bd 100644 --- a/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/components/observability_actions.tsx @@ -19,7 +19,6 @@ import React, { useMemo, useState, useCallback } from 'react'; import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public'; import { CommentType } from '@kbn/cases-plugin/common'; import type { ActionProps } from '@kbn/timelines-plugin/common'; -import { usePluginContext } from '../../../hooks/use_plugin_context'; import { useKibana } from '../../../utils/kibana_react'; import { useGetUserCasesPermissions } from '../../../hooks/use_get_user_cases_permissions'; import { parseAlert } from './parse_alert'; @@ -33,6 +32,7 @@ import { RULE_DETAILS_PAGE_ID } from '../../rule_details/types'; import type { TopAlert } from '../containers/alerts_page/types'; import { ObservabilityRuleTypeRegistry } from '../../..'; import { ALERT_DETAILS_PAGE_ID } from '../../alert_details/types'; +import { ConfigSchema } from '../../../plugin'; export type ObservabilityActionsProps = Pick< ActionProps, @@ -41,6 +41,7 @@ export type ObservabilityActionsProps = Pick< setFlyoutAlert: React.Dispatch>; observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry; id?: string; + config: ConfigSchema; }; export function ObservabilityActions({ @@ -49,12 +50,12 @@ export function ObservabilityActions({ ecsData, id: pageId, observabilityRuleTypeRegistry, + config, setFlyoutAlert, }: ObservabilityActionsProps) { const dataFieldEs = data.reduce((acc, d) => ({ ...acc, [d.field]: d.value }), {}); const [openActionsPopoverId, setActionsPopover] = useState(null); const { cases, http } = useKibana().services; - const { config } = usePluginContext(); const parseObservabilityAlert = useMemo( () => parseAlert(observabilityRuleTypeRegistry), @@ -108,7 +109,6 @@ export function ObservabilityActions({ selectCaseModal.open({ attachments: caseAttachments }); closeActionsPopover(); }, [caseAttachments, closeActionsPopover, selectCaseModal]); - const actionsMenuItems = useMemo(() => { return [ ...(userCasesPermissions.create && userCasesPermissions.read @@ -143,7 +143,7 @@ export function ObservabilityActions({ : []), ...[ - config.unsafe.alertDetails.enabled && linkToAlert ? ( + config?.unsafe?.alertDetails.enabled && linkToAlert ? ( ().services; - const { observabilityRuleTypeRegistry } = usePluginContext(); + const { observabilityRuleTypeRegistry, config } = usePluginContext(); const [flyoutAlert, setFlyoutAlert] = useState(undefined); const [tGridState, setTGridState] = useState | null>( @@ -210,13 +210,14 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { setEventsDeleted={setEventsDeleted} setFlyoutAlert={setFlyoutAlert} observabilityRuleTypeRegistry={observabilityRuleTypeRegistry} + config={config} /> ); }, }, ]; - }, [setEventsDeleted, observabilityRuleTypeRegistry]); + }, [setEventsDeleted, observabilityRuleTypeRegistry, config]); const onStateChange = useCallback( (state: TGridState) => { diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx index 26af884840570..0cf6c092dc70a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/get_row_actions.tsx @@ -9,6 +9,7 @@ import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strat import { ObservabilityRuleTypeRegistry } from '../../../../rules/create_observability_rule_type_registry'; import { ObservabilityActions } from '../../components/observability_actions'; import type { ObservabilityActionsProps } from '../../components/observability_actions'; +import type { ConfigSchema } from '../../../../plugin'; const buildData = (alerts: EcsFieldsResponse): ObservabilityActionsProps['data'] => { return Object.entries(alerts).reduce( @@ -17,7 +18,10 @@ const buildData = (alerts: EcsFieldsResponse): ObservabilityActionsProps['data'] ); }; const fakeSetEventsDeleted = () => []; -export const getRowActions = (observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry) => { +export const getRowActions = ( + observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry, + config: ConfigSchema +) => { return () => ({ renderCustomActionsRow: ( alert: EcsFieldsResponse, @@ -33,6 +37,7 @@ export const getRowActions = (observabilityRuleTypeRegistry: ObservabilityRuleTy observabilityRuleTypeRegistry={observabilityRuleTypeRegistry} setEventsDeleted={fakeSetEventsDeleted} setFlyoutAlert={setFlyoutAlert} + config={config} /> ); }, diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 6c84eb2588a0d..161fe2851ae3a 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -294,7 +294,10 @@ export class Plugin const { getO11yAlertsTableConfiguration } = await import( './config/register_alerts_table_configuration' ); - return getO11yAlertsTableConfiguration(this.observabilityRuleTypeRegistry); + return getO11yAlertsTableConfiguration( + this.observabilityRuleTypeRegistry, + this.initContext.config.get() + ); }; const { alertsTableConfigurationRegistry } = pluginsStart.triggersActionsUi; diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts index d8f65cf07d28f..e5b2f78c25472 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts @@ -110,24 +110,60 @@ describe('Endpoint Authz service', () => { describe('and endpoint rbac is enabled', () => { it.each<[EndpointAuthzKeyList[number], string]>([ + ['canWriteEndpointList', 'writeEndpointList'], + ['canReadEndpointList', 'readEndpointList'], + ['canWritePolicyManagement', 'writePolicyManagement'], + ['canReadPolicyManagement', 'readPolicyManagement'], + ['canWriteActionsLogManagement', 'writeActionsLogManagement'], + ['canReadActionsLogManagement', 'readActionsLogManagement'], ['canIsolateHost', 'writeHostIsolation'], ['canUnIsolateHost', 'writeHostIsolation'], ['canKillProcess', 'writeProcessOperations'], ['canSuspendProcess', 'writeProcessOperations'], ['canGetRunningProcesses', 'writeProcessOperations'], + ['canWriteFileOperations', 'writeFileOperations'], + ['canWriteTrustedApplications', 'writeTrustedApplications'], + ['canReadTrustedApplications', 'readTrustedApplications'], + ['canWriteHostIsolationExceptions', 'writeHostIsolationExceptions'], + ['canReadHostIsolationExceptions', 'readHostIsolationExceptions'], + ['canWriteBlocklist', 'writeBlocklist'], + ['canReadBlocklist', 'readBlocklist'], + ['canWriteEventFilters', 'writeEventFilters'], + ['canReadEventFilters', 'readEventFilters'], ])('%s should be true if `packagePrivilege.%s` is `true`', (auth) => { const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, true); expect(authz[auth]).toBe(true); }); - it.each<[EndpointAuthzKeyList[number], string]>([ - ['canIsolateHost', 'writeHostIsolation'], - ['canUnIsolateHost', 'writeHostIsolation'], - ['canKillProcess', 'writeProcessOperations'], - ['canSuspendProcess', 'writeProcessOperations'], - ['canGetRunningProcesses', 'writeProcessOperations'], - ])('%s should be false if `packagePrivilege.%s` is `false`', (auth, privilege) => { - fleetAuthz.packagePrivileges!.endpoint.actions[privilege].executePackageAction = false; + it.each<[EndpointAuthzKeyList[number], string[]]>([ + ['canWriteEndpointList', ['writeEndpointList']], + ['canReadEndpointList', ['writeEndpointList', 'readEndpointList']], + ['canWritePolicyManagement', ['writePolicyManagement']], + ['canReadPolicyManagement', ['writePolicyManagement', 'readPolicyManagement']], + ['canWriteActionsLogManagement', ['writeActionsLogManagement']], + ['canReadActionsLogManagement', ['writeActionsLogManagement', 'readActionsLogManagement']], + ['canIsolateHost', ['writeHostIsolation']], + ['canUnIsolateHost', ['writeHostIsolation']], + ['canKillProcess', ['writeProcessOperations']], + ['canSuspendProcess', ['writeProcessOperations']], + ['canGetRunningProcesses', ['writeProcessOperations']], + ['canWriteFileOperations', ['writeFileOperations']], + ['canWriteTrustedApplications', ['writeTrustedApplications']], + ['canReadTrustedApplications', ['writeTrustedApplications', 'readTrustedApplications']], + ['canWriteHostIsolationExceptions', ['writeHostIsolationExceptions']], + [ + 'canReadHostIsolationExceptions', + ['writeHostIsolationExceptions', 'readHostIsolationExceptions'], + ], + ['canWriteBlocklist', ['writeBlocklist']], + ['canReadBlocklist', ['writeBlocklist', 'readBlocklist']], + ['canWriteEventFilters', ['writeEventFilters']], + ['canReadEventFilters', ['writeEventFilters', 'readEventFilters']], + ])('%s should be false if `packagePrivilege.%s` is `false`', (auth, privileges) => { + // read permission checks for write || read so we need to set both to false + privileges.forEach((privilege) => { + fleetAuthz.packagePrivileges!.endpoint.actions[privilege].executePackageAction = false; + }); const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles, true); expect(authz[auth]).toBe(false); }); @@ -139,13 +175,28 @@ describe('Endpoint Authz service', () => { expect(getEndpointAuthzInitialState()).toEqual({ canAccessFleet: false, canAccessEndpointManagement: false, + canCreateArtifactsByPolicy: false, + canWriteEndpointList: false, + canReadEndpointList: false, + canWritePolicyManagement: false, + canReadPolicyManagement: false, + canWriteActionsLogManagement: false, + canReadActionsLogManagement: false, canIsolateHost: false, canUnIsolateHost: true, - canCreateArtifactsByPolicy: false, canKillProcess: false, canSuspendProcess: false, canGetRunningProcesses: false, canAccessResponseConsole: false, + canWriteFileOperations: false, + canWriteTrustedApplications: false, + canReadTrustedApplications: false, + canWriteHostIsolationExceptions: false, + canReadHostIsolationExceptions: false, + canWriteBlocklist: false, + canReadBlocklist: false, + canWriteEventFilters: false, + canReadEventFilters: false, }); }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts index 9e280f383cae3..dde2a7f92b1e0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts @@ -5,11 +5,23 @@ * 2.0. */ -import type { FleetAuthz } from '@kbn/fleet-plugin/common'; +import type { ENDPOINT_PRIVILEGES } from '@kbn/fleet-plugin/common'; +import { type FleetAuthz } from '@kbn/fleet-plugin/common'; import type { LicenseService } from '../../../license'; import type { EndpointAuthz } from '../../types/authz'; import type { MaybeImmutable } from '../../types'; +function hasPermission( + fleetAuthz: FleetAuthz, + isEndpointRbacEnabled: boolean, + hasEndpointManagementAccess: boolean, + privilege: typeof ENDPOINT_PRIVILEGES[number] +) { + return isEndpointRbacEnabled + ? fleetAuthz.packagePrivileges?.endpoint?.actions[privilege].executePackageAction ?? false + : hasEndpointManagementAccess; +} + /** * Used by both the server and the UI to generate the Authorization for access to Endpoint related * functionality @@ -27,19 +39,128 @@ export const calculateEndpointAuthz = ( const isPlatinumPlusLicense = licenseService.isPlatinumPlus(); const isEnterpriseLicense = licenseService.isEnterprise(); const hasEndpointManagementAccess = userRoles.includes('superuser'); - const canIsolateHost = isEndpointRbacEnabled - ? fleetAuthz.packagePrivileges?.endpoint?.actions?.writeHostIsolation?.executePackageAction || - false - : hasEndpointManagementAccess; - const canWriteProcessOperations = isEndpointRbacEnabled - ? fleetAuthz.packagePrivileges?.endpoint?.actions?.writeProcessOperations - ?.executePackageAction || false - : hasEndpointManagementAccess; + const canWriteEndpointList = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeEndpointList' + ); + const canReadEndpointList = + canWriteEndpointList || + hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'readEndpointList' + ); + const canWritePolicyManagement = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writePolicyManagement' + ); + const canReadPolicyManagement = + canWritePolicyManagement || + hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'readPolicyManagement' + ); + const canWriteActionsLogManagement = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeActionsLogManagement' + ); + const canReadActionsLogManagement = + canWriteActionsLogManagement || + hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'readActionsLogManagement' + ); + const canIsolateHost = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeHostIsolation' + ); + const canWriteProcessOperations = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeProcessOperations' + ); + const canWriteTrustedApplications = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeTrustedApplications' + ); + const canReadTrustedApplications = + canWriteTrustedApplications || + hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'readTrustedApplications' + ); + const canWriteHostIsolationExceptions = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeHostIsolationExceptions' + ); + const canReadHostIsolationExceptions = + canWriteHostIsolationExceptions || + hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'readHostIsolationExceptions' + ); + const canWriteBlocklist = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeBlocklist' + ); + const canReadBlocklist = + canWriteBlocklist || + hasPermission(fleetAuthz, isEndpointRbacEnabled, hasEndpointManagementAccess, 'readBlocklist'); + const canWriteEventFilters = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeEventFilters' + ); + const canReadEventFilters = + canWriteEventFilters || + hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'readEventFilters' + ); + const canWriteFileOperations = hasPermission( + fleetAuthz, + isEndpointRbacEnabled, + hasEndpointManagementAccess, + 'writeFileOperations' + ); return { canAccessFleet: fleetAuthz?.fleet.all ?? userRoles.includes('superuser'), canAccessEndpointManagement: hasEndpointManagementAccess, canCreateArtifactsByPolicy: hasEndpointManagementAccess && isPlatinumPlusLicense, + canWriteEndpointList, + canReadEndpointList, + canWritePolicyManagement, + canReadPolicyManagement, + canWriteActionsLogManagement, + canReadActionsLogManagement, // Response Actions canIsolateHost: canIsolateHost && isPlatinumPlusLicense, canUnIsolateHost: canIsolateHost, @@ -47,6 +168,16 @@ export const calculateEndpointAuthz = ( canSuspendProcess: canWriteProcessOperations && isEnterpriseLicense, canGetRunningProcesses: canWriteProcessOperations && isEnterpriseLicense, canAccessResponseConsole: hasEndpointManagementAccess && isEnterpriseLicense, + canWriteFileOperations: canWriteFileOperations && isEnterpriseLicense, + // artifacts + canWriteTrustedApplications, + canReadTrustedApplications, + canWriteHostIsolationExceptions: canWriteHostIsolationExceptions && isPlatinumPlusLicense, + canReadHostIsolationExceptions, + canWriteBlocklist, + canReadBlocklist, + canWriteEventFilters, + canReadEventFilters, }; }; @@ -55,11 +186,26 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canAccessFleet: false, canAccessEndpointManagement: false, canCreateArtifactsByPolicy: false, + canWriteEndpointList: false, + canReadEndpointList: false, + canWritePolicyManagement: false, + canReadPolicyManagement: false, + canWriteActionsLogManagement: false, + canReadActionsLogManagement: false, canIsolateHost: false, canUnIsolateHost: true, canKillProcess: false, canSuspendProcess: false, canGetRunningProcesses: false, canAccessResponseConsole: false, + canWriteFileOperations: false, + canWriteTrustedApplications: false, + canReadTrustedApplications: false, + canWriteHostIsolationExceptions: false, + canReadHostIsolationExceptions: false, + canWriteBlocklist: false, + canReadBlocklist: false, + canWriteEventFilters: false, + canReadEventFilters: false, }; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts index e237e4ba1fe9c..b8ca15d69d1a6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts @@ -16,6 +16,18 @@ export interface EndpointAuthz { canAccessEndpointManagement: boolean; /** if user has permissions to create Artifacts by Policy */ canCreateArtifactsByPolicy: boolean; + /** if user has write permissions to endpoint list */ + canWriteEndpointList: boolean; + /** if user has read permissions to endpoint list */ + canReadEndpointList: boolean; + /** if user has write permissions for policy management */ + canWritePolicyManagement: boolean; + /** if user has read permissions for policy management */ + canReadPolicyManagement: boolean; + /** if user has write permissions for actions log management */ + canWriteActionsLogManagement: boolean; + /** if user has read permissions for actions log management */ + canReadActionsLogManagement: boolean; /** If user has permissions to isolate hosts */ canIsolateHost: boolean; /** If user has permissions to un-isolate (release) hosts */ @@ -28,6 +40,24 @@ export interface EndpointAuthz { canGetRunningProcesses: boolean; /** If user has permissions to use the Response Actions Console */ canAccessResponseConsole: boolean; + /** If user has write permissions to use file operations */ + canWriteFileOperations: boolean; + /** if user has write permissions for trusted applications */ + canWriteTrustedApplications: boolean; + /** if user has read permissions for trusted applications */ + canReadTrustedApplications: boolean; + /** if user has write permissions for host isolation exceptions */ + canWriteHostIsolationExceptions: boolean; + /** if user has read permissions for host isolation exceptions */ + canReadHostIsolationExceptions: boolean; + /** if user has write permissions for blocklist entries */ + canWriteBlocklist: boolean; + /** if user has read permissions for blocklist entries */ + canReadBlocklist: boolean; + /** if user has write permissions for event filters */ + canWriteEventFilters: boolean; + /** if user has read permissions for event filters */ + canReadEventFilters: boolean; } export type EndpointAuthzKeyList = Array; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx index d34cf07aa9146..139d171088ec9 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx @@ -109,6 +109,7 @@ describe('ExceptionsViewer', () => { ], }} listType={ExceptionListTypeEnum.DETECTION} + isViewReadOnly={false} /> ); @@ -146,6 +147,7 @@ describe('ExceptionsViewer', () => { ], }} listType={ExceptionListTypeEnum.DETECTION} + isViewReadOnly={false} /> ); @@ -183,6 +185,7 @@ describe('ExceptionsViewer', () => { ], }} listType={ExceptionListTypeEnum.ENDPOINT} + isViewReadOnly={false} /> ); @@ -226,6 +229,7 @@ describe('ExceptionsViewer', () => { ], }} listType={ExceptionListTypeEnum.DETECTION} + isViewReadOnly={false} /> ); @@ -268,6 +272,7 @@ describe('ExceptionsViewer', () => { ], }} listType={ExceptionListTypeEnum.DETECTION} + isViewReadOnly={false} /> ); @@ -301,6 +306,7 @@ describe('ExceptionsViewer', () => { ], }} listType={ExceptionListTypeEnum.DETECTION} + isViewReadOnly={false} /> ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index d090f5a3bc590..ffe07bb2c5dfb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -75,12 +75,15 @@ export interface GetExceptionItemProps { interface ExceptionsViewerProps { rule: Rule | null; listType: ExceptionListTypeEnum; + /* Used for when displaying exceptions for a rule that has since been deleted, forcing read only view */ + isViewReadOnly: boolean; onRuleChange?: () => void; } const ExceptionsViewerComponent = ({ rule, listType, + isViewReadOnly, onRuleChange, }: ExceptionsViewerProps): JSX.Element => { const { services } = useKibana(); @@ -337,8 +340,8 @@ const ExceptionsViewerComponent = ({ // User privileges checks useEffect((): void => { - setReadOnly(!canUserCRUD || !hasIndexWrite); - }, [setReadOnly, canUserCRUD, hasIndexWrite]); + setReadOnly(isViewReadOnly || !canUserCRUD || !hasIndexWrite); + }, [setReadOnly, isViewReadOnly, canUserCRUD, hasIndexWrite]); useEffect(() => { if (exceptionListsToQuery.length > 0) { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index 0babfc8de387d..4cc5aed8aab69 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -86,8 +86,7 @@ export const RuleActionsField: React.FC = ({ field, messageVariables }) = updatedActions[index] = deepMerge(updatedActions[index], { id }); field.setValue(updatedActions); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [field.setValue, actions] + [field, actions] ); const setAlertActionsProperty = useCallback( @@ -98,20 +97,26 @@ export const RuleActionsField: React.FC = ({ field, messageVariables }) = const setActionParamsProperty = useCallback( // eslint-disable-next-line @typescript-eslint/no-explicit-any (key: string, value: any, index: number) => { - field.setValue((prevValue: RuleAction[]) => { - const updatedActions = [...prevValue]; - updatedActions[index] = { - ...updatedActions[index], - params: { - ...updatedActions[index].params, - [key]: value, - }, - }; - return updatedActions; - }); + // validation is not triggered correctly when actions params updated (more details in https://github.com/elastic/kibana/issues/142217) + // wrapping field.setValue in setTimeout fixes the issue above + // and triggers validation after params have been updated + setTimeout( + () => + field.setValue((prevValue: RuleAction[]) => { + const updatedActions = [...prevValue]; + updatedActions[index] = { + ...updatedActions[index], + params: { + ...updatedActions[index].params, + [key]: value, + }, + }; + return updatedActions; + }), + 0 + ); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [field.setValue] + [field] ); const actionForm = useMemo( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts index 8baf21db15c0d..3d817adb2605c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/translations.ts @@ -157,7 +157,7 @@ export const referenceErrorMessage = (referenceCount: number) => export const EXCEPTION_LIST_SEARCH_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.all.exceptions.searchPlaceholder', { - defaultMessage: 'Search by name or list_id', + defaultMessage: 'Search by name or list id', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 5149269c57d9c..0393fcf239f7d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -871,6 +871,7 @@ const RuleDetailsPageComponent: React.FC = ({ rule={rule} listType={ExceptionListTypeEnum.DETECTION} onRuleChange={refreshRule} + isViewReadOnly={!isExistingRule} data-test-subj="exceptionTab" /> @@ -881,6 +882,7 @@ const RuleDetailsPageComponent: React.FC = ({ rule={rule} listType={ExceptionListTypeEnum.ENDPOINT} onRuleChange={refreshRule} + isViewReadOnly={!isExistingRule} data-test-subj="endpointExceptionsTab" /> diff --git a/x-pack/plugins/threat_intelligence/public/common/utils/barchart.test.ts b/x-pack/plugins/threat_intelligence/public/common/utils/barchart.test.ts deleted file mode 100644 index 004071059a739..0000000000000 --- a/x-pack/plugins/threat_intelligence/public/common/utils/barchart.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { Aggregation } from '../../modules/indicators/services/fetch_aggregated_indicators'; -import { convertAggregationToChartSeries } from './barchart'; - -const aggregation1: Aggregation = { - events: { - buckets: [ - { - doc_count: 0, - key: 1641016800000, - key_as_string: '1 Jan 2022 06:00:00 GMT', - }, - { - doc_count: 10, - key: 1641038400000, - key_as_string: '1 Jan 2022 12:00:00 GMT', - }, - ], - }, - doc_count: 0, - key: '[Filebeat] AbuseCH Malware', -}; -const aggregation2: Aggregation = { - events: { - buckets: [ - { - doc_count: 20, - key: 1641016800000, - key_as_string: '1 Jan 2022 06:00:00 GMT', - }, - { - doc_count: 8, - key: 1641038400000, - key_as_string: '1 Jan 2022 12:00:00 GMT', - }, - ], - }, - doc_count: 0, - key: '[Filebeat] AbuseCH MalwareBazaar', -}; - -describe('barchart', () => { - describe('convertAggregationToChartSeries', () => { - it('should convert Aggregation[] to ChartSeries[]', () => { - expect(convertAggregationToChartSeries([aggregation1, aggregation2])).toEqual([ - { - x: '1 Jan 2022 06:00:00 GMT', - y: 0, - g: '[Filebeat] AbuseCH Malware', - }, - { - x: '1 Jan 2022 12:00:00 GMT', - y: 10, - g: '[Filebeat] AbuseCH Malware', - }, - { - x: '1 Jan 2022 06:00:00 GMT', - y: 20, - g: '[Filebeat] AbuseCH MalwareBazaar', - }, - { - x: '1 Jan 2022 12:00:00 GMT', - y: 8, - g: '[Filebeat] AbuseCH MalwareBazaar', - }, - ]); - }); - }); -}); diff --git a/x-pack/plugins/threat_intelligence/public/common/utils/barchart.ts b/x-pack/plugins/threat_intelligence/public/common/utils/barchart.ts deleted file mode 100644 index c994e7e9f3a3f..0000000000000 --- a/x-pack/plugins/threat_intelligence/public/common/utils/barchart.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { - Aggregation, - AggregationValue, - ChartSeries, -} from '../../modules/indicators/services/fetch_aggregated_indicators'; - -/** - * Converts data received from an Elastic search with date_histogram aggregation enabled to something usable in the "@elastic/chart" BarChart component - * @param aggregations An array of {@link Aggregation} objects to process - * @returns An array of {@link ChartSeries} directly usable in a BarChart component - */ -export const convertAggregationToChartSeries = (aggregations: Aggregation[]): ChartSeries[] => - aggregations.reduce( - (accumulated: ChartSeries[], current: Aggregation) => - accumulated.concat( - current.events.buckets.map((val: AggregationValue) => ({ - x: val.key_as_string, - y: val.doc_count, - g: current.key, - })) - ), - [] - ); diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/__snapshots__/indicators_barchart_wrapper.test.tsx.snap b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/__snapshots__/wrapper.test.tsx.snap similarity index 100% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/__snapshots__/indicators_barchart_wrapper.test.tsx.snap rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/__snapshots__/wrapper.test.tsx.snap diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/__snapshots__/indicators_barchart.test.tsx.snap b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/__snapshots__/barchart.test.tsx.snap similarity index 100% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/__snapshots__/indicators_barchart.test.tsx.snap rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/__snapshots__/barchart.test.tsx.snap diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.stories.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.stories.tsx similarity index 87% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.stories.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.stories.tsx index bb0a1b1205b52..0a8831cfc6ff8 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.stories.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.stories.tsx @@ -9,10 +9,10 @@ import moment from 'moment'; import React from 'react'; import { Story } from '@storybook/react'; import { TimeRangeBounds } from '@kbn/data-plugin/common'; -import { StoryProvidersComponent } from '../../../../common/mocks/story_providers'; -import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service'; -import { IndicatorsBarChart } from './indicators_barchart'; -import { ChartSeries } from '../../services/fetch_aggregated_indicators'; +import { StoryProvidersComponent } from '../../../../../common/mocks/story_providers'; +import { mockKibanaTimelinesService } from '../../../../../common/mocks/mock_kibana_timelines_service'; +import { IndicatorsBarChart } from '.'; +import { ChartSeries } from '../../../services'; const mockIndicators: ChartSeries[] = [ { diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.test.tsx similarity index 88% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.test.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.test.tsx index 19542bdf200a5..4a95c0ec890fe 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.test.tsx @@ -9,9 +9,9 @@ import moment from 'moment-timezone'; import React from 'react'; import { render } from '@testing-library/react'; import { TimeRangeBounds } from '@kbn/data-plugin/common'; -import { TestProvidersComponent } from '../../../../common/mocks/test_providers'; -import { IndicatorsBarChart } from './indicators_barchart'; -import { ChartSeries } from '../../services/fetch_aggregated_indicators'; +import { TestProvidersComponent } from '../../../../../common/mocks/test_providers'; +import { IndicatorsBarChart } from '.'; +import { ChartSeries } from '../../../services'; moment.suppressDeprecationWarnings = true; moment.tz.setDefault('UTC'); diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.tsx similarity index 89% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.tsx index d5535f53a862f..2f4fecbd930a2 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/indicators_barchart.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/barchart.tsx @@ -9,9 +9,9 @@ import React, { VFC } from 'react'; import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '@elastic/charts'; import { EuiThemeProvider } from '@elastic/eui'; import { TimeRangeBounds } from '@kbn/data-plugin/common'; -import { IndicatorBarchartLegendAction } from '../indicator_barchart_legend_action/indicator_barchart_legend_action'; -import { barChartTimeAxisLabelFormatter } from '../../../../common/utils/dates'; -import type { ChartSeries } from '../../services/fetch_aggregated_indicators'; +import { IndicatorBarchartLegendAction } from '../legend_action'; +import { barChartTimeAxisLabelFormatter } from '../../../../../common/utils/dates'; +import type { ChartSeries } from '../../../services'; const ID = 'tiIndicator'; const DEFAULT_CHART_HEIGHT = '200px'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/index.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/index.ts similarity index 86% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/index.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/index.ts index 673d2532ad17f..6c9a52cae6c92 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart/index.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/barchart/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './indicators_barchart'; +export * from './barchart'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/__snapshots__/indicators_field_selector.test.tsx.snap b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/__snapshots__/field_selector.test.tsx.snap similarity index 100% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/__snapshots__/indicators_field_selector.test.tsx.snap rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/__snapshots__/field_selector.test.tsx.snap diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.stories.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.stories.tsx similarity index 90% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.stories.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.stories.tsx index 4c7b9d67ea92f..e58dc9a7dcc81 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.stories.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.stories.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { Story } from '@storybook/react'; import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import { RawIndicatorFieldId } from '../../../../../common/types/indicator'; -import { IndicatorsFieldSelector } from './indicators_field_selector'; +import { RawIndicatorFieldId } from '../../../../../../common/types/indicator'; +import { IndicatorsFieldSelector } from '.'; const mockIndexPattern: DataView = { fields: [ diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.test.tsx similarity index 90% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.test.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.test.tsx index 0d62bbcef6219..bc41fc26c2411 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.test.tsx @@ -8,8 +8,8 @@ import React from 'react'; import { render } from '@testing-library/react'; import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; -import { TestProvidersComponent } from '../../../../common/mocks/test_providers'; -import { IndicatorsFieldSelector } from './indicators_field_selector'; +import { TestProvidersComponent } from '../../../../../common/mocks/test_providers'; +import { IndicatorsFieldSelector } from '.'; const mockIndexPattern: DataView = { fields: [ diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.tsx similarity index 94% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.tsx index df45a003cf168..2707ba250784f 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/indicators_field_selector.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/field_selector.tsx @@ -10,8 +10,8 @@ import { EuiComboBox } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; -import { SecuritySolutionDataViewBase } from '../../../../types'; -import { RawIndicatorFieldId } from '../../../../../common/types/indicator'; +import { SecuritySolutionDataViewBase } from '../../../../../types'; +import { RawIndicatorFieldId } from '../../../../../../common/types/indicator'; import { useStyles } from './styles'; export const DROPDOWN_TEST_ID = 'tiIndicatorFieldSelectorDropdown'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/index.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/index.ts similarity index 84% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/index.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/index.ts index 34c531bfc0b36..23409f2df2824 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/index.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './indicators_field_selector'; +export * from './field_selector'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/styles.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/styles.ts similarity index 100% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_field_selector/styles.ts rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/field_selector/styles.ts diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/index.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/index.ts similarity index 84% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/index.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/index.ts index 252dab4799123..77ebb9fb329cb 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/index.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './indicators_barchart_wrapper'; +export * from './wrapper'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/legend_action/index.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/legend_action/index.ts new file mode 100644 index 0000000000000..ca82d7dc3a463 --- /dev/null +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/legend_action/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './legend_action'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicator_barchart_legend_action/indicator_barchart_legend_action.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/legend_action/legend_action.tsx similarity index 89% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicator_barchart_legend_action/indicator_barchart_legend_action.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/legend_action/legend_action.tsx index fa573e981f6ca..b792d4e222589 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicator_barchart_legend_action/indicator_barchart_legend_action.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/legend_action/legend_action.tsx @@ -8,9 +8,9 @@ import React, { useState, VFC } from 'react'; import { EuiButtonIcon, EuiContextMenuPanel, EuiPopover, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FilterInContextMenu } from '../../../query_bar/components/filter_in'; -import { FilterOutContextMenu } from '../../../query_bar/components/filter_out'; -import { AddToTimelineContextMenu } from '../../../timeline/components/add_to_timeline'; +import { FilterInContextMenu } from '../../../../query_bar/components/filter_in'; +import { FilterOutContextMenu } from '../../../../query_bar/components/filter_out'; +import { AddToTimelineContextMenu } from '../../../../timeline/components/add_to_timeline'; export const POPOVER_BUTTON_TEST_ID = 'tiBarchartPopoverButton'; export const TIMELINE_BUTTON_TEST_ID = 'tiBarchartTimelineButton'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.stories.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.stories.tsx similarity index 96% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.stories.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.stories.tsx index ed7be8b06b4af..74fd94d6d562a 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.stories.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.stories.tsx @@ -17,12 +17,8 @@ import { IUiSettingsClient } from '@kbn/core/public'; import { StoryProvidersComponent } from '../../../../common/mocks/story_providers'; import { mockKibanaTimelinesService } from '../../../../common/mocks/mock_kibana_timelines_service'; import { DEFAULT_TIME_RANGE } from '../../../query_bar/hooks/use_filters/utils'; -import { IndicatorsBarChartWrapper } from './indicators_barchart_wrapper'; -import { - Aggregation, - AGGREGATION_NAME, - ChartSeries, -} from '../../services/fetch_aggregated_indicators'; +import { IndicatorsBarChartWrapper } from '.'; +import { Aggregation, AGGREGATION_NAME, ChartSeries } from '../../services'; export default { component: IndicatorsBarChartWrapper, diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.test.tsx similarity index 96% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.test.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.test.tsx index ef968be02c224..577afefe80834 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.test.tsx @@ -10,10 +10,7 @@ import { render } from '@testing-library/react'; import { TimeRange } from '@kbn/es-query'; import { DataView, DataViewField } from '@kbn/data-views-plugin/common'; import { TestProvidersComponent } from '../../../../common/mocks/test_providers'; -import { - CHART_UPDATE_PROGRESS_TEST_ID, - IndicatorsBarChartWrapper, -} from './indicators_barchart_wrapper'; +import { CHART_UPDATE_PROGRESS_TEST_ID, IndicatorsBarChartWrapper } from '.'; import { DEFAULT_TIME_RANGE } from '../../../query_bar/hooks/use_filters/utils'; import moment from 'moment'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.tsx similarity index 90% rename from x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.tsx rename to x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.tsx index aabfecde30243..57ec76d17bd41 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/components/indicators_barchart_wrapper/indicators_barchart_wrapper.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/components/barchart/wrapper.tsx @@ -19,9 +19,9 @@ import { TimeRange } from '@kbn/es-query'; import { TimeRangeBounds } from '@kbn/data-plugin/common'; import { SecuritySolutionDataViewBase } from '../../../../types'; import { RawIndicatorFieldId } from '../../../../../common/types/indicator'; -import { IndicatorsFieldSelector } from '../indicators_field_selector/indicators_field_selector'; -import { IndicatorsBarChart } from '../indicators_barchart/indicators_barchart'; -import { ChartSeries } from '../../services/fetch_aggregated_indicators'; +import { IndicatorsFieldSelector } from './field_selector'; +import { IndicatorsBarChart } from './barchart'; +import { ChartSeries } from '../../services'; const DEFAULT_FIELD = RawIndicatorFieldId.Feed; @@ -33,7 +33,7 @@ export interface IndicatorsBarChartWrapperProps { */ timeRange?: TimeRange; /** - * List of fields coming from the Security Solution sourcerer data view, passed down to the {@link IndicatorFieldSelector} to populate the dropdown. + * List of fields coming from the Security Solution sourcerer data view, passed down to the {@link IndicatorsFieldSelector} to populate the dropdown. */ indexPattern: SecuritySolutionDataViewBase; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/index.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/index.ts new file mode 100644 index 0000000000000..23461fc809957 --- /dev/null +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export * from './use_aggregated_indicators'; +export * from './use_indicators_filters_context'; +export * from './use_indicators_total_count'; +export * from './use_sourcerer_data_view'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx index 21bff01fab760..7791020d58e02 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.test.tsx @@ -12,7 +12,7 @@ import { mockedTimefilterService, TestProvidersComponent, } from '../../../common/mocks/test_providers'; -import { createFetchAggregatedIndicators } from '../services/fetch_aggregated_indicators'; +import { createFetchAggregatedIndicators } from '../services'; jest.mock('../services/fetch_aggregated_indicators'); @@ -28,7 +28,8 @@ const renderUseAggregatedIndicators = () => wrapper: TestProvidersComponent, }); -describe('useAggregatedIndicators()', () => { +// FLAKY: https://github.com/elastic/kibana/issues/142312 +describe.skip('useAggregatedIndicators()', () => { beforeEach(jest.clearAllMocks); type MockedCreateFetchAggregatedIndicators = jest.MockedFunction< diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.ts index 06b99202288b6..dd603d387c17f 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/hooks/use_aggregated_indicators.ts @@ -13,7 +13,7 @@ import { useInspector } from '../../../hooks/use_inspector'; import { RawIndicatorFieldId } from '../../../../common/types/indicator'; import { useKibana } from '../../../hooks/use_kibana'; import { DEFAULT_TIME_RANGE } from '../../query_bar/hooks/use_filters/utils'; -import { useSourcererDataView } from './use_sourcerer_data_view'; +import { useSourcererDataView } from '.'; import { ChartSeries, createFetchAggregatedIndicators, @@ -22,11 +22,17 @@ import { export interface UseAggregatedIndicatorsParam { /** - * From and To values passed to the {@link }useAggregatedIndicators} hook + * From and To values passed to the {@link useAggregatedIndicators} hook * to query indicators for the Indicators barchart. */ timeRange?: TimeRange; + /** + * Filters data passed to the {@link useAggregatedIndicators} hook to query indicators. + */ filters: Filter[]; + /** + * Query data passed to the {@link useAggregatedIndicators} hook to query indicators. + */ filterQuery: Query; } diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/indicators_page.tsx b/x-pack/plugins/threat_intelligence/public/modules/indicators/indicators_page.tsx index 5ad0413003388..e0b5158274c15 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/indicators_page.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/indicators_page.tsx @@ -7,7 +7,7 @@ import React, { FC, VFC } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { IndicatorsBarChartWrapper } from './components/indicators_barchart_wrapper/indicators_barchart_wrapper'; +import { IndicatorsBarChartWrapper } from './components/barchart'; import { IndicatorsTable } from './components/indicators_table/indicators_table'; import { useIndicators } from './hooks/use_indicators'; import { DefaultPageLayout } from '../../components/layout'; diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.test.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.test.ts index c5503f1b32a0c..007623943c531 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.test.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.test.ts @@ -8,12 +8,54 @@ import { mockedQueryService, mockedSearchService } from '../../../common/mocks/test_providers'; import { BehaviorSubject, throwError } from 'rxjs'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; -import { AGGREGATION_NAME, createFetchAggregatedIndicators } from './fetch_aggregated_indicators'; +import { + Aggregation, + AGGREGATION_NAME, + convertAggregationToChartSeries, + createFetchAggregatedIndicators, +} from '.'; const aggregationResponse = { rawResponse: { aggregations: { [AGGREGATION_NAME]: { buckets: [] } } }, }; +const aggregation1: Aggregation = { + events: { + buckets: [ + { + doc_count: 0, + key: 1641016800000, + key_as_string: '1 Jan 2022 06:00:00 GMT', + }, + { + doc_count: 10, + key: 1641038400000, + key_as_string: '1 Jan 2022 12:00:00 GMT', + }, + ], + }, + doc_count: 0, + key: '[Filebeat] AbuseCH Malware', +}; +const aggregation2: Aggregation = { + events: { + buckets: [ + { + doc_count: 20, + key: 1641016800000, + key_as_string: '1 Jan 2022 06:00:00 GMT', + }, + { + doc_count: 8, + key: 1641038400000, + key_as_string: '1 Jan 2022 12:00:00 GMT', + }, + ], + }, + doc_count: 0, + key: '[Filebeat] AbuseCH MalwareBazaar', +}; + describe('FetchAggregatedIndicatorsService', () => { beforeEach(jest.clearAllMocks); @@ -115,3 +157,30 @@ describe('FetchAggregatedIndicatorsService', () => { }); }); }); + +describe('convertAggregationToChartSeries', () => { + it('should convert Aggregation[] to ChartSeries[]', () => { + expect(convertAggregationToChartSeries([aggregation1, aggregation2])).toEqual([ + { + x: '1 Jan 2022 06:00:00 GMT', + y: 0, + g: '[Filebeat] AbuseCH Malware', + }, + { + x: '1 Jan 2022 12:00:00 GMT', + y: 10, + g: '[Filebeat] AbuseCH Malware', + }, + { + x: '1 Jan 2022 06:00:00 GMT', + y: 20, + g: '[Filebeat] AbuseCH MalwareBazaar', + }, + { + x: '1 Jan 2022 12:00:00 GMT', + y: 8, + g: '[Filebeat] AbuseCH MalwareBazaar', + }, + ]); + }); +}); diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.ts index 6cf0fea18b2c3..0edc0cca34e5c 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/services/fetch_aggregated_indicators.ts @@ -9,7 +9,6 @@ import { TimeRangeBounds } from '@kbn/data-plugin/common'; import type { ISearchStart, QueryStart } from '@kbn/data-plugin/public'; import type { Filter, Query, TimeRange } from '@kbn/es-query'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; -import { convertAggregationToChartSeries } from '../../../common/utils/barchart'; import { calculateBarchartColumnTimeInterval } from '../../../common/utils/dates'; import { RawIndicatorFieldId } from '../../../../common/types/indicator'; import { getIndicatorQueryParams } from '../utils/get_indicator_query_params'; @@ -55,6 +54,24 @@ export interface FetchAggregatedIndicatorsParams { field: string; } +/** + * Converts data received from an Elastic search with date_histogram aggregation enabled to something usable in the "@elastic/chart" BarChart component + * @param aggregations An array of {@link Aggregation} objects to process + * @returns An array of {@link ChartSeries} directly usable in a BarChart component + */ +export const convertAggregationToChartSeries = (aggregations: Aggregation[]): ChartSeries[] => + aggregations.reduce( + (accumulated: ChartSeries[], current: Aggregation) => + accumulated.concat( + current.events.buckets.map((val: AggregationValue) => ({ + x: val.key_as_string, + y: val.doc_count, + g: current.key, + })) + ), + [] + ); + export const createFetchAggregatedIndicators = ({ inspectorAdapter, diff --git a/x-pack/plugins/threat_intelligence/public/modules/indicators/services/index.ts b/x-pack/plugins/threat_intelligence/public/modules/indicators/services/index.ts new file mode 100644 index 0000000000000..3215ff6621597 --- /dev/null +++ b/x-pack/plugins/threat_intelligence/public/modules/indicators/services/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './fetch_aggregated_indicators'; +export * from './fetch_indicators'; diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap index 5f74967ff423c..966b3487ebfb2 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/__snapshots__/popover_form.test.tsx.snap @@ -2,12 +2,12 @@ exports[`Transform: Aggregation Minimal initialization 1`] = ` ', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + test('Minimal initialization', () => { const defaultData: PivotAggsConfig = { agg: PIVOT_SUPPORTED_AGGS.CARDINALITY, @@ -37,4 +40,67 @@ describe('Transform: Aggregation ', () => { expect(wrapper).toMatchSnapshot(); }); + + test('preserves the field for unsupported aggs', async () => { + const mockOnChange = jest.fn(); + const { getByTestId } = render( + + + + ); + + const aggNameInput = getByTestId('transformAggName'); + fireEvent.change(aggNameInput, { + target: { value: 'betterName' }, + }); + + const applyButton = getByTestId('transformApplyAggChanges'); + fireEvent.click(applyButton); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith({ + field: 'AvgTicketPrice', + keyed: true, + ranges: [ + { + to: 500, + }, + { + from: 500, + to: 700, + }, + { + from: 700, + }, + ], + // @ts-ignore + agg: 'range', + aggName: 'betterName', + dropDownName: 'AvgTicketPrice.ranges', + }); + }); }); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx index 56eeea8a242c0..0585d4553a99c 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/aggregation_list/popover_form.tsx @@ -100,8 +100,8 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha ...aggConfigDef, agg, aggName, - field: resultField, dropDownName: defaultData.dropDownName, + ...(isUnsupportedAgg ? {} : { field: resultField }), }; return updatedItem; @@ -153,7 +153,7 @@ export const PopoverForm: React.FC = ({ defaultData, otherAggNames, onCha } return ( - + = ({ defaultData, otherAggNames, onCha fontSize="s" language="json" paddingSize="s" - style={{ width: '100%', height: '200px' }} + css={{ width: '100%', height: '200px' }} > {JSON.stringify(getEsAggFromAggConfig(defaultData), null, 2)} diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 09d5ebabd0d0a..4ab7756b5fb43 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -3720,10 +3720,10 @@ "indexPatternEditor.status.partialMatchLabel.partialMatchDetail": "Votre modèle d'indexation ne correspond à aucun flux de données, index ni alias d'index, mais {strongIndices} {matchedIndicesLength, plural, one {est semblable} other {sont semblables} }.", "indexPatternEditor.status.partialMatchLabel.strongIndicesLabel": "{matchedIndicesLength, plural, one {source} other {# sources} }", "indexPatternEditor.status.successLabel.successDetail": "Votre modèle d'indexation correspond à {sourceCount} {sourceCount, plural, one {source} other {sources} }.", - "indexPatternEditor.aliasLabel": "Alias", + "dataViews.aliasLabel": "Alias", "indexPatternEditor.createIndex.noMatch": "Le nom doit correspondre à au moins un flux de données, index ou alias d'index.", "indexPatternEditor.createIndexPattern.stepTime.noTimeFieldOptionLabel": "--- Je ne souhaite pas utiliser le filtre temporel ---", - "indexPatternEditor.dataStreamLabel": "Flux de données", + "dataViews.dataStreamLabel": "Flux de données", "indexPatternEditor.dataView.unableSaveLabel": "Échec de l'enregistrement de la vue de données.", "indexPatternEditor.dataViewExists.ValidationErrorMessage": "Une vue de données de ce nom existe déjà.", "indexPatternEditor.editDataView.editConfirmationModal.confirmButton": "Confirmer", @@ -3754,8 +3754,8 @@ "indexPatternEditor.form.customIndexPatternIdLabel": "ID de vue de données personnalisé", "indexPatternEditor.form.nameAriaLabel": "Champ de nom facultatif", "indexPatternEditor.form.titleAriaLabel": "Champ de modèle d'indexation", - "indexPatternEditor.frozenLabel": "Frozen", - "indexPatternEditor.indexLabel": "Index", + "dataViews.frozenLabel": "Frozen", + "dataViews.indexLabel": "Index", "indexPatternEditor.loadingHeader": "Recherche d'index correspondants…", "indexPatternEditor.requireTimestampOption.ValidationErrorMessage": "Sélectionnez un champ d'horodatage.", "indexPatternEditor.rollupDataView.createIndex.noMatchError": "Erreur de vue de données de cumul : doit correspondre à un index de cumul", @@ -3763,7 +3763,7 @@ "indexPatternEditor.rollupDataView.warning.textParagraphOne": "Kibana propose un support bêta pour les vues de données basées sur les cumuls. Vous pourriez rencontrer des problèmes lors de l'utilisation de ces vues dans les recherches enregistrées, les visualisations et les tableaux de bord. Ils ne sont pas compatibles avec certaines fonctionnalités avancées, telles que Timelion et le Machine Learning.", "indexPatternEditor.rollupDataView.warning.textParagraphTwo": "Vous pouvez mettre une vue de données de cumul en correspondance avec un index de cumul et zéro index régulier ou plus. Une vue de données de cumul dispose d'indicateurs, de champs, d'intervalles et d'agrégations limités. Un index de cumul se limite aux index disposant d'une configuration de tâche ou de plusieurs tâches avec des configurations compatibles.", "indexPatternEditor.rollupIndexPattern.warning.title": "Fonctionnalité bêta", - "indexPatternEditor.rollupLabel": "Cumul", + "dataViews.rollupLabel": "Cumul", "indexPatternEditor.saved": "'{indexPatternName}' enregistré", "indexPatternEditor.status.noSystemIndicesLabel": "Aucun flux de données, index ni alias d'index ne correspond à votre modèle d'indexation.", "indexPatternEditor.status.noSystemIndicesWithPromptLabel": "Aucun flux de données, index ni alias d'index ne correspond à votre modèle d'indexation.", @@ -11543,7 +11543,6 @@ "xpack.enterpriseSearch.content.searchIndex.totalStats.domainCountCardLabel": "Nombre de domaines", "xpack.enterpriseSearch.content.searchIndex.totalStats.ingestionTypeCardLabel": "Type d’ingestion", "xpack.enterpriseSearch.content.searchIndices.actions.columnTitle": "Actions", - "xpack.enterpriseSearch.content.searchIndices.content.breadcrumb": "Contenu", "xpack.enterpriseSearch.content.searchIndices.create.buttonTitle": "Créer un nouvel index", "xpack.enterpriseSearch.content.searchIndices.docsCount.columnTitle": "Nombre de documents", "xpack.enterpriseSearch.content.searchIndices.health.columnTitle": "Intégrité des index", @@ -17839,10 +17838,7 @@ "xpack.lens.indexPattern.terms.addaFilter": "Ajouter un champ", "xpack.lens.indexPattern.terms.addRegex": "Utiliser une expression régulière", "xpack.lens.indexPattern.terms.advancedSettings": "Avancé", - "xpack.lens.indexPattern.terms.deleteButtonAriaLabel": "Supprimer", - "xpack.lens.indexPattern.terms.deleteButtonDisabled": "Cette fonction nécessite au minimum un champ défini.", "xpack.lens.indexPattern.terms.deleteButtonLabel": "Supprimer", - "xpack.lens.indexPattern.terms.dragToReorder": "Faire glisser pour réorganiser", "xpack.lens.indexPattern.terms.exclude": "Exclure les valeurs", "xpack.lens.indexPattern.terms.include": "Inclure les valeurs", "xpack.lens.indexPattern.terms.includeExcludePatternPlaceholder": "Entrer une expression régulière pour filtrer les valeurs", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 32c2dbbf5f01e..c01a643d5e953 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3715,10 +3715,10 @@ "indexPatternEditor.status.partialMatchLabel.partialMatchDetail": "インデックスパターンはどのデータストリーム、インデックス、インデックスエイリアスとも一致しませんが、{strongIndices} {matchedIndicesLength, plural, other {が} }類似しています。", "indexPatternEditor.status.partialMatchLabel.strongIndicesLabel": "{matchedIndicesLength, plural, other {# ソース} }", "indexPatternEditor.status.successLabel.successDetail": "インデックスパターンは、{sourceCount} {sourceCount, plural, other {ソース} }と一致します。", - "indexPatternEditor.aliasLabel": "エイリアス", + "dataViews.aliasLabel": "エイリアス", "indexPatternEditor.createIndex.noMatch": "名前は1つ以上のデータストリーム、インデックス、またはインデックスエイリアスと一致する必要があります。", "indexPatternEditor.createIndexPattern.stepTime.noTimeFieldOptionLabel": "--- 時間フィルターを使用しない ---", - "indexPatternEditor.dataStreamLabel": "データストリーム", + "dataViews.dataStreamLabel": "データストリーム", "indexPatternEditor.dataView.unableSaveLabel": "データビューの保存に失敗しました。", "indexPatternEditor.dataViewExists.ValidationErrorMessage": "この名前のデータビューはすでに存在します。", "indexPatternEditor.editDataView.editConfirmationModal.confirmButton": "確認", @@ -3749,8 +3749,8 @@ "indexPatternEditor.form.customIndexPatternIdLabel": "カスタムデータビューID", "indexPatternEditor.form.nameAriaLabel": "名前フィールド(任意)", "indexPatternEditor.form.titleAriaLabel": "インデックスパターンフィールド", - "indexPatternEditor.frozenLabel": "凍結", - "indexPatternEditor.indexLabel": "インデックス", + "dataViews.frozenLabel": "凍結", + "dataViews.indexLabel": "インデックス", "indexPatternEditor.loadingHeader": "一致するインデックスを検索中…", "indexPatternEditor.requireTimestampOption.ValidationErrorMessage": "タイムスタンプフィールドを選択します。", "indexPatternEditor.rollupDataView.createIndex.noMatchError": "ロールアップデータビューエラー:ロールアップインデックスの 1 つと一致している必要があります", @@ -3758,7 +3758,7 @@ "indexPatternEditor.rollupDataView.warning.textParagraphOne": "Kibanaではロールアップに基づいてデータビューのデータサポートを提供します。保存された検索、可視化、ダッシュボードでこれらを使用すると問題が発生する場合があります。Timelionや機械学習などの一部の高度な機能ではサポートされていません。", "indexPatternEditor.rollupDataView.warning.textParagraphTwo": "ロールアップデータビューは、1つのロールアップインデックスとゼロ以上の標準インデックスと一致させることができます。ロールアップデータビューでは、メトリック、フィールド、間隔、アグリゲーションが制限されています。ロールアップインデックスは、1つのジョブ構成があるインデックス、または複数のジョブと互換する構成があるインデックスに制限されています。", "indexPatternEditor.rollupIndexPattern.warning.title": "ベータ機能", - "indexPatternEditor.rollupLabel": "ロールアップ", + "dataViews.rollupLabel": "ロールアップ", "indexPatternEditor.saved": "'{indexPatternName}'が保存されました", "indexPatternEditor.status.noSystemIndicesLabel": "データストリーム、インデックス、またはインデックスエイリアスがインデックスパターンと一致しません。", "indexPatternEditor.status.noSystemIndicesWithPromptLabel": "データストリーム、インデックス、またはインデックスエイリアスがインデックスパターンと一致しません。", @@ -11529,7 +11529,6 @@ "xpack.enterpriseSearch.content.searchIndex.totalStats.domainCountCardLabel": "ドメインカウント", "xpack.enterpriseSearch.content.searchIndex.totalStats.ingestionTypeCardLabel": "インジェスチョンタイプ", "xpack.enterpriseSearch.content.searchIndices.actions.columnTitle": "アクション", - "xpack.enterpriseSearch.content.searchIndices.content.breadcrumb": "コンテンツ", "xpack.enterpriseSearch.content.searchIndices.create.buttonTitle": "新しいインデックスを作成", "xpack.enterpriseSearch.content.searchIndices.docsCount.columnTitle": "ドキュメント数", "xpack.enterpriseSearch.content.searchIndices.health.columnTitle": "インデックス正常性", @@ -17822,10 +17821,7 @@ "xpack.lens.indexPattern.terms.addaFilter": "フィールドの追加", "xpack.lens.indexPattern.terms.addRegex": "正規表現を使用", "xpack.lens.indexPattern.terms.advancedSettings": "高度な設定", - "xpack.lens.indexPattern.terms.deleteButtonAriaLabel": "削除", - "xpack.lens.indexPattern.terms.deleteButtonDisabled": "この関数には定義された1つのフィールドの最小値が必須です", "xpack.lens.indexPattern.terms.deleteButtonLabel": "削除", - "xpack.lens.indexPattern.terms.dragToReorder": "ドラッグして並べ替え", "xpack.lens.indexPattern.terms.exclude": "値を除外", "xpack.lens.indexPattern.terms.include": "値を含める", "xpack.lens.indexPattern.terms.includeExcludePatternPlaceholder": "値をフィルターするには正規表現を入力します", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 48fe03474e196..75eab045500e7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3720,10 +3720,10 @@ "indexPatternEditor.status.partialMatchLabel.partialMatchDetail": "您的索引模式不匹配任何数据流、索引或索引别名,但{strongIndices}{matchedIndicesLength, plural, other {} }类似。", "indexPatternEditor.status.partialMatchLabel.strongIndicesLabel": "{matchedIndicesLength, plural, one {个源} other {# 个源} }", "indexPatternEditor.status.successLabel.successDetail": "您的索引模式匹配 {sourceCount} 个{sourceCount, plural, other {源} }。", - "indexPatternEditor.aliasLabel": "别名", + "dataViews.aliasLabel": "别名", "indexPatternEditor.createIndex.noMatch": "名称必须匹配一个或多个数据流、索引或索引别名。", "indexPatternEditor.createIndexPattern.stepTime.noTimeFieldOptionLabel": "--- 我不想使用时间筛选 ---", - "indexPatternEditor.dataStreamLabel": "数据流", + "dataViews.dataStreamLabel": "数据流", "indexPatternEditor.dataView.unableSaveLabel": "无法保存数据视图。", "indexPatternEditor.dataViewExists.ValidationErrorMessage": "具有此名称的数据视图已存在。", "indexPatternEditor.editDataView.editConfirmationModal.confirmButton": "确认", @@ -3754,8 +3754,8 @@ "indexPatternEditor.form.customIndexPatternIdLabel": "定制数据视图 ID", "indexPatternEditor.form.nameAriaLabel": "名称字段(可选)", "indexPatternEditor.form.titleAriaLabel": "索引模式字段", - "indexPatternEditor.frozenLabel": "已冻结", - "indexPatternEditor.indexLabel": "索引", + "dataViews.frozenLabel": "已冻结", + "dataViews.indexLabel": "索引", "indexPatternEditor.loadingHeader": "正在寻找匹配的索引......", "indexPatternEditor.requireTimestampOption.ValidationErrorMessage": "选择时间戳字段。", "indexPatternEditor.rollupDataView.createIndex.noMatchError": "汇总/打包数据视图错误:必须匹配一个汇总/打包索引", @@ -3763,7 +3763,7 @@ "indexPatternEditor.rollupDataView.warning.textParagraphOne": "Kibana 基于汇总/打包为数据视图提供公测版支持。将这些视图用于已保存搜索、可视化以及仪表板可能会遇到问题。某些高级功能,如 Timelion 和 Machine Learning,不支持这些模式。", "indexPatternEditor.rollupDataView.warning.textParagraphTwo": "可以根据一个汇总/打包索引和零个或更多常规索引匹配汇总/打包数据视图。汇总/打包数据视图的指标、字段、时间间隔和聚合有限。汇总/打包索引仅限于具有一个作业配置或多个作业配置兼容的索引。", "indexPatternEditor.rollupIndexPattern.warning.title": "公测版功能", - "indexPatternEditor.rollupLabel": "汇总/打包", + "dataViews.rollupLabel": "汇总/打包", "indexPatternEditor.saved": "已保存“{indexPatternName}”", "indexPatternEditor.status.noSystemIndicesLabel": "没有数据流、索引或索引别名匹配您的索引模式。", "indexPatternEditor.status.noSystemIndicesWithPromptLabel": "没有数据流、索引或索引别名匹配您的索引模式。", @@ -11548,7 +11548,6 @@ "xpack.enterpriseSearch.content.searchIndex.totalStats.domainCountCardLabel": "域计数", "xpack.enterpriseSearch.content.searchIndex.totalStats.ingestionTypeCardLabel": "采集类型", "xpack.enterpriseSearch.content.searchIndices.actions.columnTitle": "操作", - "xpack.enterpriseSearch.content.searchIndices.content.breadcrumb": "内容", "xpack.enterpriseSearch.content.searchIndices.create.buttonTitle": "创建新索引", "xpack.enterpriseSearch.content.searchIndices.docsCount.columnTitle": "文档计数", "xpack.enterpriseSearch.content.searchIndices.health.columnTitle": "索引运行状况", @@ -17847,10 +17846,7 @@ "xpack.lens.indexPattern.terms.addaFilter": "添加字段", "xpack.lens.indexPattern.terms.addRegex": "使用正则表达式", "xpack.lens.indexPattern.terms.advancedSettings": "高级", - "xpack.lens.indexPattern.terms.deleteButtonAriaLabel": "删除", - "xpack.lens.indexPattern.terms.deleteButtonDisabled": "此函数需要至少定义一个字段", "xpack.lens.indexPattern.terms.deleteButtonLabel": "删除", - "xpack.lens.indexPattern.terms.dragToReorder": "拖动以重新排序", "xpack.lens.indexPattern.terms.exclude": "排除值", "xpack.lens.indexPattern.terms.include": "包括值", "xpack.lens.indexPattern.terms.includeExcludePatternPlaceholder": "输入正则表达式以筛选值", diff --git a/x-pack/test/api_integration/apis/ml/jobs/index.ts b/x-pack/test/api_integration/apis/ml/jobs/index.ts index e530b6bfb3622..8152f3e760b60 100644 --- a/x-pack/test/api_integration/apis/ml/jobs/index.ts +++ b/x-pack/test/api_integration/apis/ml/jobs/index.ts @@ -23,5 +23,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./stop_datafeeds')); loadTestFile(require.resolve('./stop_datafeeds_spaces')); loadTestFile(require.resolve('./get_groups')); + loadTestFile(require.resolve('./jobs')); }); } diff --git a/x-pack/test/api_integration/apis/ml/jobs/jobs.ts b/x-pack/test/api_integration/apis/ml/jobs/jobs.ts new file mode 100644 index 0000000000000..c843aab29dc48 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/jobs/jobs.ts @@ -0,0 +1,210 @@ +/* + * 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 expect from '@kbn/expect'; +import type { CombinedJobWithStats } from '@kbn/ml-plugin/common/types/anomaly_detection_jobs'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { MULTI_METRIC_JOB_CONFIG, SINGLE_METRIC_JOB_CONFIG, DATAFEED_CONFIG } from './common_jobs'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const idSpace1 = 'space1'; + + const testSetupJobConfigs = [SINGLE_METRIC_JOB_CONFIG, MULTI_METRIC_JOB_CONFIG]; + + const testSetupJobConfigsWithSpace = [ + { ...SINGLE_METRIC_JOB_CONFIG, job_id: `${SINGLE_METRIC_JOB_CONFIG.job_id}-${idSpace1}` }, + ]; + + const testCalendarsConfigs = [ + { + calendar_id: `test_get_cal_1`, + job_ids: ['multi-metric'], + description: `Test calendar 1`, + }, + { + calendar_id: `test_get_cal_2`, + job_ids: [MULTI_METRIC_JOB_CONFIG.job_id, 'multi-metric'], + description: `Test calendar 2`, + }, + { + calendar_id: `test_get_cal_3`, + job_ids: ['brand-new-group'], + description: `Test calendar 3`, + }, + ]; + + async function runGetJobsRequest( + user: USER, + requestBody: object, + expectedResponsecode: number, + space?: string + ): Promise { + const path = space === undefined ? '/api/ml/jobs/jobs' : `/s/${space}/api/ml/jobs/jobs`; + const { body, status } = await supertest + .post(path) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody); + ml.api.assertResponseStatusCode(expectedResponsecode, status, body); + + return body; + } + + const expectedJobProperties = [ + { + jobId: MULTI_METRIC_JOB_CONFIG.job_id, + datafeedId: `datafeed-${MULTI_METRIC_JOB_CONFIG.job_id}`, + calendarIds: ['test_get_cal_1', 'test_get_cal_2'], + groups: ['farequote', 'automated', 'multi-metric'], + modelBytes: 0, + datafeedTotalSearchTimeMs: 0, + }, + { + jobId: SINGLE_METRIC_JOB_CONFIG.job_id, + datafeedId: `datafeed-${SINGLE_METRIC_JOB_CONFIG.job_id}`, + calendarIds: undefined, + groups: ['farequote', 'automated', 'single-metric'], + modelBytes: 0, + datafeedTotalSearchTimeMs: 0, + }, + ]; + + const expectedJobPropertiesWithSpace = [ + { + jobId: `${SINGLE_METRIC_JOB_CONFIG.job_id}-${idSpace1}`, + datafeedId: `datafeed-${SINGLE_METRIC_JOB_CONFIG.job_id}-${idSpace1}`, + calendarIds: undefined, + groups: ['farequote', 'automated', 'single-metric'], + modelBytes: 0, + datafeedTotalSearchTimeMs: 0, + }, + ]; + + describe('get combined jobs with stats', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + for (const job of testSetupJobConfigs) { + await ml.api.createAnomalyDetectionJob(job); + await ml.api.createDatafeed({ + ...DATAFEED_CONFIG, + datafeed_id: `datafeed-${job.job_id}`, + job_id: job.job_id, + }); + } + + for (const job of testSetupJobConfigsWithSpace) { + await ml.api.createAnomalyDetectionJob(job, idSpace1); + await ml.api.createDatafeed( + { + ...DATAFEED_CONFIG, + datafeed_id: `datafeed-${job.job_id}`, + job_id: job.job_id, + }, + idSpace1 + ); + } + + for (const cal of testCalendarsConfigs) { + await ml.api.createCalendar(cal.calendar_id, cal); + } + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('returns expected list of combined jobs with stats in default space', async () => { + const jobs = await runGetJobsRequest(USER.ML_VIEWER, {}, 200); + + expect(jobs.length).to.eql( + testSetupJobConfigs.length, + `number of jobs in default space should be ${testSetupJobConfigs.length})` + ); + + jobs.forEach((job, i) => { + expect(job.job_id).to.eql( + expectedJobProperties[i].jobId, + `job id should be equal to ${JSON.stringify(expectedJobProperties[i].jobId)})` + ); + expect(job.datafeed_config.datafeed_id).to.eql( + expectedJobProperties[i].datafeedId, + `datafeed id should be equal to ${JSON.stringify(expectedJobProperties[i].datafeedId)})` + ); + expect(job.calendars).to.eql( + expectedJobProperties[i].calendarIds, + `calendars should be equal to ${JSON.stringify(expectedJobProperties[i].calendarIds)})` + ); + expect(job.groups).to.eql( + expectedJobProperties[i].groups, + `groups should be equal to ${JSON.stringify(expectedJobProperties[i].groups)})` + ); + expect(job.model_size_stats.model_bytes).to.eql( + expectedJobProperties[i].modelBytes, + `model_bytes should be equal to ${JSON.stringify(expectedJobProperties[i].modelBytes)})` + ); + expect(job.datafeed_config.timing_stats.total_search_time_ms).to.eql( + expectedJobProperties[i].datafeedTotalSearchTimeMs, + `datafeed total_search_time_ms should be equal to ${JSON.stringify( + expectedJobProperties[i].datafeedTotalSearchTimeMs + )})` + ); + }); + }); + + it('returns expected list of combined jobs with stats in specified space', async () => { + const jobs = await runGetJobsRequest(USER.ML_VIEWER, {}, 200, idSpace1); + + expect(jobs.length).to.eql( + testSetupJobConfigsWithSpace.length, + `number of jobs in default space should be ${testSetupJobConfigsWithSpace.length})` + ); + + jobs.forEach((job, i) => { + expect(job.job_id).to.eql( + expectedJobPropertiesWithSpace[i].jobId, + `job id should be equal to ${JSON.stringify(expectedJobPropertiesWithSpace[i].jobId)})` + ); + expect(job.datafeed_config.datafeed_id).to.eql( + expectedJobPropertiesWithSpace[i].datafeedId, + `datafeed id should be equal to ${JSON.stringify( + expectedJobPropertiesWithSpace[i].datafeedId + )})` + ); + expect(job.calendars).to.eql( + expectedJobPropertiesWithSpace[i].calendarIds, + `calendars should be equal to ${JSON.stringify( + expectedJobPropertiesWithSpace[i].calendarIds + )})` + ); + expect(job.groups).to.eql( + expectedJobPropertiesWithSpace[i].groups, + `groups should be equal to ${JSON.stringify(expectedJobPropertiesWithSpace[i].groups)})` + ); + expect(job.model_size_stats.model_bytes).to.eql( + expectedJobPropertiesWithSpace[i].modelBytes, + `model_bytes should be equal to ${JSON.stringify( + expectedJobPropertiesWithSpace[i].modelBytes + )})` + ); + expect(job.datafeed_config.timing_stats.total_search_time_ms).to.eql( + expectedJobPropertiesWithSpace[i].datafeedTotalSearchTimeMs, + `datafeed total_search_time_ms should be equal to ${JSON.stringify( + expectedJobPropertiesWithSpace[i].datafeedTotalSearchTimeMs + )})` + ); + }); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/index.ts b/x-pack/test/api_integration/apis/ml/saved_objects/index.ts index f1464095edffe..5139a121e07d1 100644 --- a/x-pack/test/api_integration/apis/ml/saved_objects/index.ts +++ b/x-pack/test/api_integration/apis/ml/saved_objects/index.ts @@ -18,5 +18,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./sync_jobs')); loadTestFile(require.resolve('./sync_trained_models')); loadTestFile(require.resolve('./update_jobs_spaces')); + loadTestFile(require.resolve('./remove_from_current_space')); }); } diff --git a/x-pack/test/api_integration/apis/ml/saved_objects/remove_from_current_space.ts b/x-pack/test/api_integration/apis/ml/saved_objects/remove_from_current_space.ts new file mode 100644 index 0000000000000..3e98c51ea047e --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/saved_objects/remove_from_current_space.ts @@ -0,0 +1,137 @@ +/* + * 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 expect from '@kbn/expect'; +import { MlSavedObjectType } from '@kbn/ml-plugin/common/types/saved_objects'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common_api'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const spacesService = getService('spaces'); + const supertest = getService('supertestWithoutAuth'); + + const adJobId = 'fq_single'; + const dfaJobId = 'ihp_od'; + const trainedModelId = 'trained_model'; + const idSpace1 = 'space1'; + const idSpace2 = 'space2'; + const defaultSpaceId = 'default'; + + async function runRequest( + requestBody: { + ids: string[]; + mlSavedObjectType: MlSavedObjectType; + }, + space: string, + expectedStatusCode: number, + user: USER + ) { + const { body, status } = await supertest + .post(`/s/${space}/api/ml/saved_objects/remove_item_from_current_space`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody); + ml.api.assertResponseStatusCode(expectedStatusCode, status, body); + + return body; + } + + describe('POST saved_objects/remove_item_from_current_space', () => { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ihp_outlier'); + + // create spaces + await spacesService.create({ id: idSpace1, name: 'space_one', disabledFeatures: [] }); + await spacesService.create({ id: idSpace2, name: 'space_two', disabledFeatures: [] }); + + // create anomaly detection job + await ml.api.createAnomalyDetectionJob(ml.commonConfig.getADFqSingleMetricJobConfig(adJobId)); + // create data frame analytics job + await ml.api.createDataFrameAnalyticsJob( + ml.commonConfig.getDFAIhpOutlierDetectionJobConfig(dfaJobId) + ); + // Create trained model + const trainedModelConfig = ml.api.createTestTrainedModelConfig(trainedModelId, 'regression'); + await ml.api.createTrainedModel(trainedModelId, trainedModelConfig.body); + + // reassign spaces for all items + await ml.api.updateJobSpaces(adJobId, 'anomaly-detector', [idSpace1, idSpace2], []); + await ml.api.updateJobSpaces(dfaJobId, 'data-frame-analytics', [idSpace1, idSpace2], []); + await ml.api.updateTrainedModelSpaces(trainedModelId, [idSpace1, idSpace2], []); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.cleanMLSavedObjects(); + await spacesService.delete(idSpace1); + await spacesService.delete(idSpace2); + }); + + it('should remove AD job from current space', async () => { + await ml.api.assertJobSpaces(adJobId, 'anomaly-detector', [ + defaultSpaceId, + idSpace1, + idSpace2, + ]); + const mlSavedObjectType = 'anomaly-detector'; + const body = await runRequest( + { + ids: [adJobId], + mlSavedObjectType, + }, + idSpace1, + 200, + USER.ML_POWERUSER + ); + + expect(body).to.eql({ [adJobId]: { success: true, type: mlSavedObjectType } }); + await ml.api.assertJobSpaces(adJobId, mlSavedObjectType, [defaultSpaceId, idSpace2]); + }); + + it('should remove DFA job from current space', async () => { + await ml.api.assertJobSpaces(dfaJobId, 'data-frame-analytics', [ + defaultSpaceId, + idSpace1, + idSpace2, + ]); + const mlSavedObjectType = 'data-frame-analytics'; + const body = await runRequest( + { + ids: [dfaJobId], + mlSavedObjectType, + }, + idSpace2, + 200, + USER.ML_POWERUSER + ); + + expect(body).to.eql({ [dfaJobId]: { success: true, type: mlSavedObjectType } }); + await ml.api.assertJobSpaces(dfaJobId, mlSavedObjectType, [defaultSpaceId, idSpace1]); + }); + + it('should remove trained model from current space', async () => { + await ml.api.assertTrainedModelSpaces(trainedModelId, [defaultSpaceId, idSpace1, idSpace2]); + const mlSavedObjectType = 'trained-model'; + const body = await runRequest( + { + ids: [trainedModelId], + mlSavedObjectType, + }, + idSpace2, + 200, + USER.ML_POWERUSER + ); + + expect(body).to.eql({ [trainedModelId]: { success: true, type: mlSavedObjectType } }); + await ml.api.assertTrainedModelSpaces(trainedModelId, [defaultSpaceId, idSpace1]); + }); + }); +}; diff --git a/x-pack/test/functional/apps/discover/visualize_field.ts b/x-pack/test/functional/apps/discover/visualize_field.ts index 0a7f075d50bd4..be1f223110503 100644 --- a/x-pack/test/functional/apps/discover/visualize_field.ts +++ b/x-pack/test/functional/apps/discover/visualize_field.ts @@ -26,12 +26,20 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'spaceSelector', 'header', ]); + const monacoEditor = getService('monacoEditor'); + + const defaultSettings = { + 'discover:enableSql': true, + }; async function setDiscoverTimeRange() { await PageObjects.timePicker.setDefaultAbsoluteRange(); } describe('discover field visualize button', () => { + before(async () => { + await kibanaServer.uiSettings.replace(defaultSettings); + }); beforeEach(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/logstash_functional'); await kibanaServer.importExport.load( @@ -95,5 +103,23 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(selectedPattern).to.eql('logst*'); }); }); + + it('should visualize correctly text based language queries', async () => { + await PageObjects.discover.selectTextBaseLang('SQL'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await monacoEditor.setCodeEditorValue( + 'SELECT extension, AVG("bytes") as average FROM "logstash-*" GROUP BY extension' + ); + await testSubjects.click('querySubmitButton'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await testSubjects.click('textBased-visualize'); + + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger-textBased'); + expect(dimensions).to.have.length(2); + expect(await dimensions[1].getVisibleText()).to.be('average'); + }); + }); }); }