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 9b992be84e29e..d046ad3bb2990 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 @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect, useCallback, useRef, useContext } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { EuiTitle, EuiFlexGroup, @@ -17,10 +17,8 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import memoizeOne from 'memoize-one'; -import { BehaviorSubject } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; -import { INDEX_PATTERN_TYPE, MatchedItem } from '@kbn/data-views-plugin/public'; +import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; import { DataView, @@ -32,7 +30,6 @@ import { UseField, } from '../shared_imports'; -import { ensureMinimumTime, getMatchedIndices } from '../lib'; import { FlyoutPanels } from './flyout_panels'; import { removeSpaces } from '../lib'; @@ -41,7 +38,6 @@ import { DataViewEditorContext, RollupIndicesCapsResponse, IndexPatternConfig, - MatchedIndicesSet, FormInternal, } from '../types'; @@ -57,7 +53,6 @@ import { RollupBetaWarning, } from '.'; import { editDataViewModal } from './confirm_modals/edit_data_view_changed_modal'; -import { DataViewEditorServiceContext } from './data_view_flyout_content_container'; import { DataViewEditorService } from '../data_view_editor_service'; export interface Props { @@ -70,19 +65,12 @@ export interface Props { */ onCancel: () => void; defaultTypeIsRollup?: boolean; - requireTimestampField?: boolean; editData?: DataView; showManagementLink?: boolean; allowAdHoc: boolean; + dataViewEditorService: DataViewEditorService; } -export const matchedIndiciesDefault = { - allIndices: [], - exactMatchedIndices: [], - partialMatchedIndices: [], - visibleIndices: [], -}; - const editorTitle = i18n.translate('indexPatternEditor.title', { defaultMessage: 'Create data view', }); @@ -95,17 +83,15 @@ const IndexPatternEditorFlyoutContentComponent = ({ onSave, onCancel, defaultTypeIsRollup, - requireTimestampField = false, editData, allowAdHoc, showManagementLink, + dataViewEditorService, }: Props) => { const { services: { application, dataViews, uiSettings, overlays }, } = useKibana(); - const { dataViewEditorService } = useContext(DataViewEditorServiceContext); - const canSave = dataViews.getCanSaveSync(); const { form } = useForm({ @@ -132,15 +118,12 @@ const IndexPatternEditorFlyoutContentComponent = ({ return; } - const rollupIndicesCapabilities = dataViewEditorService.rollupIndicesCapabilities$.getValue(); - const indexPatternStub: DataViewSpec = { title: removeSpaces(formData.title), timeFieldName: formData.timestampField?.value, id: formData.id, name: formData.name, }; - const rollupIndex = rollupIndex$.current.getValue(); if (type === INDEX_PATTERN_TYPE.ROLLUP && rollupIndex) { indexPatternStub.type = INDEX_PATTERN_TYPE.ROLLUP; @@ -175,110 +158,29 @@ const IndexPatternEditorFlyoutContentComponent = ({ allowHidden = schema.allowHidden.defaultValue, type = schema.type.defaultValue, }, - ] = useFormData({ form }); - - const currentLoadingMatchedIndicesRef = useRef(0); + ] = useFormData({ + form, + }); const isLoadingSources = useObservable(dataViewEditorService.isLoadingSources$, true); + const existingDataViewNames = useObservable(dataViewEditorService.dataViewNames$); + const rollupIndex = useObservable(dataViewEditorService.rollupIndex$); + const rollupIndicesCapabilities = useObservable(dataViewEditorService.rollupIndicesCaps$, {}); - const loadingMatchedIndices$ = useRef(new BehaviorSubject(false)); - - const isLoadingDataViewNames$ = useRef(new BehaviorSubject(true)); - const existingDataViewNames$ = useRef(new BehaviorSubject([])); - const isLoadingDataViewNames = useObservable(isLoadingDataViewNames$.current, true); - - const rollupIndicesCapabilities = useObservable( - dataViewEditorService.rollupIndicesCapabilities$, - {} - ); - - const rollupIndex$ = useRef(new BehaviorSubject(undefined)); - - // initial loading of indicies and data view names useEffect(() => { - let isCancelled = false; - const matchedIndicesSub = dataViewEditorService.matchedIndices$.subscribe((matchedIndices) => { - const timeFieldQuery = editData ? editData.title : title; - dataViewEditorService.loadTimestampFields( - removeSpaces(timeFieldQuery), - type, - requireTimestampField, - rollupIndex$.current.getValue() - ); - }); - - dataViewEditorService.loadIndices(title, allowHidden).then((matchedIndices) => { - if (isCancelled) return; - dataViewEditorService.matchedIndices$.next(matchedIndices); - }); + dataViewEditorService.setIndexPattern(title); + }, [dataViewEditorService, title]); - dataViewEditorService.loadDataViewNames(title).then((names) => { - if (isCancelled) return; - const filteredNames = editData ? names.filter((name) => name !== editData?.name) : names; - existingDataViewNames$.current.next(filteredNames); - isLoadingDataViewNames$.current.next(false); - }); + useEffect(() => { + dataViewEditorService.setAllowHidden(allowHidden); + }, [dataViewEditorService, allowHidden]); - return () => { - isCancelled = true; - matchedIndicesSub.unsubscribe(); - }; - }, [editData, type, title, allowHidden, requireTimestampField, dataViewEditorService]); + useEffect(() => { + dataViewEditorService.setType(type); + }, [dataViewEditorService, type]); const getRollupIndices = (rollupCaps: RollupIndicesCapsResponse) => Object.keys(rollupCaps); - // used in title field validation - const reloadMatchedIndices = useCallback( - async (newTitle: string) => { - let newRollupIndexName: string | undefined; - - const fetchIndices = async (query: string = '') => { - const currentLoadingMatchedIndicesIdx = ++currentLoadingMatchedIndicesRef.current; - - loadingMatchedIndices$.current.next(true); - - const allSrcs = await dataViewEditorService.getIndicesCached({ - pattern: '*', - showAllIndices: allowHidden, - }); - - const { matchedIndicesResult, exactMatched } = !isLoadingSources - ? await loadMatchedIndices(query, allowHidden, allSrcs, dataViewEditorService) - : { - matchedIndicesResult: matchedIndiciesDefault, - exactMatched: [], - }; - - if (currentLoadingMatchedIndicesIdx === currentLoadingMatchedIndicesRef.current) { - // we are still interested in this result - if (type === INDEX_PATTERN_TYPE.ROLLUP) { - const isRollupIndex = await dataViewEditorService.getIsRollupIndex(); - const rollupIndices = exactMatched.filter((index) => isRollupIndex(index.name)); - newRollupIndexName = rollupIndices.length === 1 ? rollupIndices[0].name : undefined; - rollupIndex$.current.next(newRollupIndexName); - } else { - rollupIndex$.current.next(undefined); - } - - dataViewEditorService.matchedIndices$.next(matchedIndicesResult); - loadingMatchedIndices$.current.next(false); - } - - return { matchedIndicesResult, newRollupIndexName }; - }; - - return fetchIndices(newTitle); - }, - [ - allowHidden, - type, - dataViewEditorService, - rollupIndex$, - isLoadingSources, - loadingMatchedIndices$, - ] - ); - const onTypeChange = useCallback( (newType) => { form.setFieldValue('title', ''); @@ -291,7 +193,7 @@ const IndexPatternEditorFlyoutContentComponent = ({ [form] ); - if (isLoadingSources || isLoadingDataViewNames) { + if (isLoadingSources || !existingDataViewNames) { return ; } @@ -343,14 +245,14 @@ const IndexPatternEditorFlyoutContentComponent = ({ form={form} className="indexPatternEditor__form" error={form.getErrors()} - isInvalid={form.isSubmitted && !form.isValid} + isInvalid={form.isSubmitted && !form.isValid && form.getErrors().length} > {indexPatternTypeSelect} - + @@ -358,9 +260,11 @@ const IndexPatternEditorFlyoutContentComponent = ({ @@ -370,7 +274,6 @@ const IndexPatternEditorFlyoutContentComponent = ({ @@ -415,59 +318,3 @@ const IndexPatternEditorFlyoutContentComponent = ({ }; export const IndexPatternEditorFlyoutContent = React.memo(IndexPatternEditorFlyoutContentComponent); - -// loadMatchedIndices is called both as an side effect inside of a parent component and the inside forms validation functions -// that are challenging to synchronize without a larger refactor -// Use memoizeOne as a caching layer to avoid excessive network requests on each key type -// TODO: refactor to remove `memoize` when https://github.com/elastic/kibana/pull/109238 is done -const loadMatchedIndices = memoizeOne( - async ( - query: string, - allowHidden: boolean, - allSources: MatchedItem[], - dataViewEditorService: DataViewEditorService - ): Promise<{ - matchedIndicesResult: MatchedIndicesSet; - exactMatched: MatchedItem[]; - partialMatched: MatchedItem[]; - }> => { - const indexRequests = []; - - if (query?.endsWith('*')) { - const exactMatchedQuery = dataViewEditorService.getIndicesCached({ - pattern: query, - showAllIndices: allowHidden, - }); - indexRequests.push(exactMatchedQuery); - // provide default value when not making a request for the partialMatchQuery - indexRequests.push(Promise.resolve([])); - } else { - const exactMatchQuery = dataViewEditorService.getIndicesCached({ - pattern: query, - showAllIndices: allowHidden, - }); - const partialMatchQuery = dataViewEditorService.getIndicesCached({ - pattern: `${query}*`, - showAllIndices: allowHidden, - }); - - indexRequests.push(exactMatchQuery); - indexRequests.push(partialMatchQuery); - } - - const [exactMatched, partialMatched] = (await ensureMinimumTime( - indexRequests - )) as MatchedItem[][]; - - const matchedIndicesResult = getMatchedIndices( - allSources, - partialMatched, - exactMatched, - allowHidden - ); - - return { matchedIndicesResult, exactMatched, partialMatched }; - }, - // compare only query and allowHidden - (newArgs, oldArgs) => newArgs[0] === oldArgs[0] && newArgs[1] === oldArgs[1] -); diff --git a/src/plugins/data_view_editor/public/components/data_view_flyout_content_container.tsx b/src/plugins/data_view_editor/public/components/data_view_flyout_content_container.tsx index 16e58d52624c3..52b311a98f30f 100644 --- a/src/plugins/data_view_editor/public/components/data_view_flyout_content_container.tsx +++ b/src/plugins/data_view_editor/public/components/data_view_flyout_content_container.tsx @@ -6,19 +6,15 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; +import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; import { DataViewSpec, useKibana } from '../shared_imports'; import { IndexPatternEditorFlyoutContent } from './data_view_editor_flyout_content'; import { DataViewEditorContext, DataViewEditorProps } from '../types'; import { DataViewEditorService } from '../data_view_editor_service'; -// @ts-ignore -export const DataViewEditorServiceContext = React.createContext<{ - dataViewEditorService: DataViewEditorService; -}>(); - const DataViewFlyoutContentContainer = ({ onSave, onCancel = () => {}, @@ -32,6 +28,24 @@ const DataViewFlyoutContentContainer = ({ services: { dataViews, notifications, http }, } = useKibana(); + const [dataViewEditorService] = useState( + () => + new DataViewEditorService({ + services: { http, dataViews }, + initialValues: { + name: editData?.name, + type: editData?.type as INDEX_PATTERN_TYPE, + indexPattern: editData?.getIndexPattern(), + }, + requireTimestampField, + }) + ); + + useEffect(() => { + const service = dataViewEditorService; + return service.destroy; + }, [dataViewEditorService]); + const onSaveClick = async (dataViewSpec: DataViewSpec, persist: boolean = true) => { try { let saveResponse; @@ -69,19 +83,15 @@ const DataViewFlyoutContentContainer = ({ }; return ( - - - + ); }; diff --git a/src/plugins/data_view_editor/public/components/form_fields/name_field.tsx b/src/plugins/data_view_editor/public/components/form_fields/name_field.tsx index 425178d452c5a..750b54f689361 100644 --- a/src/plugins/data_view_editor/public/components/form_fields/name_field.tsx +++ b/src/plugins/data_view_editor/public/components/form_fields/name_field.tsx @@ -9,8 +9,6 @@ import React, { ChangeEvent, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { BehaviorSubject } from 'rxjs'; -import useObservable from 'react-use/lib/useObservable'; import { UseField, ValidationConfig, @@ -21,7 +19,7 @@ import { IndexPatternConfig } from '../../types'; import { schema } from '../form_schema'; interface NameFieldProps { - existingDataViewNames$: BehaviorSubject; + namesNotAllowed: string[]; } interface GetNameConfigArgs { @@ -53,8 +51,7 @@ const getNameConfig = ({ namesNotAllowed }: GetNameConfigArgs): FieldConfig { - const namesNotAllowed = useObservable(existingDataViewNames$, []); +export const NameField = ({ namesNotAllowed }: NameFieldProps) => { const config = useMemo( () => getNameConfig({ diff --git a/src/plugins/data_view_editor/public/components/form_fields/timestamp_field.tsx b/src/plugins/data_view_editor/public/components/form_fields/timestamp_field.tsx index 846a9db09ee80..310cb9a3e5835 100644 --- a/src/plugins/data_view_editor/public/components/form_fields/timestamp_field.tsx +++ b/src/plugins/data_view_editor/public/components/form_fields/timestamp_field.tsx @@ -9,9 +9,9 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import { EuiFormRow, EuiComboBox, EuiFormHelpText, EuiComboBoxOptionOption } from '@elastic/eui'; -import { matchedIndiciesDefault } from '../data_view_editor_flyout_content'; +import { matchedIndiciesDefault } from '../../data_view_editor_service'; import { UseField, @@ -24,10 +24,9 @@ import { TimestampOption, MatchedIndicesSet } from '../../types'; import { schema } from '../form_schema'; interface Props { - options$: Subject; - isLoadingOptions$: BehaviorSubject; - isLoadingMatchedIndices$: BehaviorSubject; - matchedIndices$: Subject; + options$: Observable; + isLoadingOptions$: Observable; + matchedIndices$: Observable; } const requireTimestampOptionValidator = (options: TimestampOption[]): ValidationConfig => ({ @@ -71,15 +70,9 @@ const timestampFieldHelp = i18n.translate('indexPatternEditor.editor.form.timeFi defaultMessage: 'Select a timestamp field for use with the global time filter.', }); -export const TimestampField = ({ - options$, - isLoadingOptions$, - isLoadingMatchedIndices$, - matchedIndices$, -}: Props) => { +export const TimestampField = ({ options$, isLoadingOptions$, matchedIndices$ }: Props) => { const options = useObservable(options$, []); const isLoadingOptions = useObservable(isLoadingOptions$, false); - const isLoadingMatchedIndices = useObservable(isLoadingMatchedIndices$, false); const hasMatchedIndices = !!useObservable(matchedIndices$, matchedIndiciesDefault) .exactMatchedIndices.length; @@ -92,9 +85,7 @@ export const TimestampField = ({ const selectTimestampHelp = options.length ? timestampFieldHelp : ''; const timestampNoFieldsHelp = - options.length === 0 && !isLoadingMatchedIndices && !isLoadingOptions && hasMatchedIndices - ? noTimestampOptionText - : ''; + options.length === 0 && !isLoadingOptions && hasMatchedIndices ? noTimestampOptionText : ''; return ( > config={timestampConfig} path="timestampField"> @@ -106,7 +97,7 @@ export const TimestampField = ({ } const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); - const isDisabled = !optionsAsComboBoxOptions.length; + const isDisabled = !optionsAsComboBoxOptions.length || isLoadingOptions; // if the value isn't in the list then don't use it. const valueInList = !!optionsAsComboBoxOptions.find( (option) => option.value === value.value 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 6f443871dd66e..b2ea9c78e9fca 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,7 +9,7 @@ import React, { ChangeEvent, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { MatchedItem } from '@kbn/data-views-plugin/public'; import { @@ -18,21 +18,19 @@ import { ValidationConfig, FieldConfig, } from '../../shared_imports'; -import { canAppendWildcard, removeSpaces } from '../../lib'; +import { canAppendWildcard } from '../../lib'; import { schema } from '../form_schema'; import { RollupIndicesCapsResponse, IndexPatternConfig, MatchedIndicesSet } from '../../types'; -import { matchedIndiciesDefault } from '../data_view_editor_flyout_content'; - -interface RefreshMatchedIndicesResult { - matchedIndicesResult: MatchedIndicesSet; - newRollupIndexName?: string; -} +import { matchedIndiciesDefault } from '../../data_view_editor_service'; interface TitleFieldProps { isRollup: boolean; - matchedIndices$: Subject; + matchedIndices$: Observable; rollupIndicesCapabilities: RollupIndicesCapsResponse; - refreshMatchedIndices: (title: string) => Promise; + indexPatternValidationProvider: () => Promise<{ + matchedIndices: MatchedIndicesSet; + rollupIndex: string | null | undefined; + }>; } const rollupIndexPatternNoMatchError = { @@ -55,22 +53,23 @@ const mustMatchError = { interface MatchesValidatorArgs { rollupIndicesCapabilities: Record; - refreshMatchedIndices: (title: string) => Promise; isRollup: boolean; } const createMatchesIndicesValidator = ({ rollupIndicesCapabilities, - refreshMatchedIndices, isRollup, }: MatchesValidatorArgs): ValidationConfig<{}, string, string> => ({ - validator: async ({ value }) => { - const { matchedIndicesResult, newRollupIndexName } = await refreshMatchedIndices( - removeSpaces(value) - ); + validator: async ({ customData: { provider } }) => { + const { matchedIndices, rollupIndex } = (await provider()) as { + matchedIndices: MatchedIndicesSet; + rollupIndex?: string; + }; + + // verifies that the title matches at least one index, alias, or data stream const rollupIndices = Object.keys(rollupIndicesCapabilities); - if (matchedIndicesResult.exactMatchedIndices.length === 0) { + if (matchedIndices.exactMatchedIndices.length === 0) { return mustMatchError; } @@ -79,7 +78,7 @@ const createMatchesIndicesValidator = ({ } // A rollup index pattern needs to match one and only one rollup index. - const rollupIndexMatches = matchedIndicesResult.exactMatchedIndices.filter((matchedIndex) => + const rollupIndexMatches = matchedIndices.exactMatchedIndices.filter((matchedIndex) => rollupIndices.includes(matchedIndex.name) ); @@ -90,7 +89,7 @@ const createMatchesIndicesValidator = ({ } // Error info is potentially provided via the rollup indices caps request - const error = newRollupIndexName && rollupIndicesCapabilities[newRollupIndexName].error; + const error = rollupIndex && rollupIndicesCapabilities[rollupIndex].error; if (error) { return { @@ -109,13 +108,11 @@ interface GetTitleConfigArgs { isRollup: boolean; matchedIndices: MatchedItem[]; rollupIndicesCapabilities: RollupIndicesCapsResponse; - refreshMatchedIndices: (title: string) => Promise; } const getTitleConfig = ({ isRollup, rollupIndicesCapabilities, - refreshMatchedIndices, }: GetTitleConfigArgs): FieldConfig => { const titleFieldConfig = schema.title; @@ -124,7 +121,6 @@ const getTitleConfig = ({ // note this is responsible for triggering the state update for the selected source list. createMatchesIndicesValidator({ rollupIndicesCapabilities, - refreshMatchedIndices, isRollup, }), ]; @@ -139,7 +135,7 @@ export const TitleField = ({ isRollup, matchedIndices$, rollupIndicesCapabilities, - refreshMatchedIndices, + indexPatternValidationProvider, }: TitleFieldProps) => { const [appendedWildcard, setAppendedWildcard] = useState(false); const matchedIndices = useObservable(matchedIndices$, matchedIndiciesDefault).exactMatchedIndices; @@ -150,15 +146,15 @@ export const TitleField = ({ isRollup, matchedIndices, rollupIndicesCapabilities, - refreshMatchedIndices, }), - [isRollup, matchedIndices, rollupIndicesCapabilities, refreshMatchedIndices] + [isRollup, matchedIndices, rollupIndicesCapabilities] ); return ( path="title" config={fieldConfig} + validationDataProvider={indexPatternValidationProvider} componentProps={{ euiFieldProps: { 'aria-label': i18n.translate('indexPatternEditor.form.titleAriaLabel', { 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 59e195a1f1280..69993f17ecb35 100644 --- a/src/plugins/data_view_editor/public/components/form_schema.ts +++ b/src/plugins/data_view_editor/public/components/form_schema.ts @@ -36,7 +36,7 @@ export const schema = { { validator: fieldValidators.emptyField( i18n.translate('indexPatternEditor.validations.titleIsRequiredErrorMessage', { - defaultMessage: 'An Index pattern is required.', + defaultMessage: 'An index pattern is required.', }) ), }, 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 28163384ca0f8..07b1fd91b85b6 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 @@ -9,11 +9,11 @@ import React from 'react'; import { EuiSpacer } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; -import { Subject } from 'rxjs'; +import { Observable } from 'rxjs'; import { INDEX_PATTERN_TYPE } from '@kbn/data-views-plugin/public'; import { StatusMessage } from './status_message'; import { IndicesList } from './indices_list'; -import { matchedIndiciesDefault } from '../data_view_editor_flyout_content'; +import { matchedIndiciesDefault } from '../../data_view_editor_service'; import { MatchedIndicesSet } from '../../types'; @@ -21,7 +21,7 @@ interface Props { type: INDEX_PATTERN_TYPE; allowHidden: boolean; title: string; - matchedIndices$: Subject; + matchedIndices$: Observable; } export const PreviewPanel = ({ type, allowHidden, title = '', matchedIndices$ }: Props) => { diff --git a/src/plugins/data_view_editor/public/data_view_editor_service.ts b/src/plugins/data_view_editor/public/data_view_editor_service.ts index 57963830149db..3589f7d491904 100644 --- a/src/plugins/data_view_editor/public/data_view_editor_service.ts +++ b/src/plugins/data_view_editor/public/data_view_editor_service.ts @@ -7,7 +7,15 @@ */ import { HttpSetup } from '@kbn/core/public'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { + BehaviorSubject, + Subject, + first, + firstValueFrom, + from, + Observable, + Subscription, +} from 'rxjs'; import { DataViewsServicePublic, @@ -17,30 +25,117 @@ import { } from '@kbn/data-views-plugin/public'; import { RollupIndicesCapsResponse, MatchedIndicesSet, TimestampOption } from './types'; -import { getMatchedIndices, ensureMinimumTime, extractTimeFields } from './lib'; +import { getMatchedIndices, ensureMinimumTime, extractTimeFields, removeSpaces } from './lib'; import { GetFieldsOptions } from './shared_imports'; +export const matchedIndiciesDefault = { + allIndices: [], + exactMatchedIndices: [], + partialMatchedIndices: [], + visibleIndices: [], +}; + +export interface DataViewEditorServiceConstructorArgs { + services: { + http: HttpSetup; + dataViews: DataViewsServicePublic; + }; + requireTimestampField?: boolean; + initialValues: { + name?: string; + type?: INDEX_PATTERN_TYPE; + indexPattern?: string; + }; +} + export class DataViewEditorService { - constructor(private http: HttpSetup, private dataViews: DataViewsServicePublic) { + constructor({ + services: { http, dataViews }, + initialValues: { + type: initialType = INDEX_PATTERN_TYPE.DEFAULT, + indexPattern: initialIndexPattern = '', + name: initialName = '', + }, + requireTimestampField = false, + }: DataViewEditorServiceConstructorArgs) { + this.http = http; + this.dataViews = dataViews; + this.requireTimestampField = requireTimestampField; + this.type = initialType; + this.indexPattern = removeSpaces(initialIndexPattern); + + // fire off a couple of requests that we know we'll need this.rollupCapsResponse = this.getRollupIndexCaps(); + this.dataViewNames$ = from(this.loadDataViewNames(initialName)); + + // public observables + this.matchedIndices$ = this.matchedIndicesInternal$.asObservable(); + this.rollupIndicesCaps$ = this.rollupIndicesCapsInternal$.asObservable(); + this.isLoadingSources$ = this.isLoadingSourcesInternal$.asObservable(); + this.loadingTimestampFields$ = this.loadingTimestampFieldsInternal$.asObservable(); + this.timestampFieldOptions$ = this.timestampFieldOptionsInternal$.asObservable(); + this.rollupIndex$ = this.rollupIndexInternal$.asObservable(); + + // when list of matched indices is updated always update timestamp fields + this.loadTimestampFieldsSub = this.matchedIndices$.subscribe(() => this.loadTimestampFields()); + + // alternate value with undefined so validation knows when its getting a fresh value + this.matchedIndicesForProviderSub = this.matchedIndices$.subscribe((matchedIndices) => { + this.matchedIndicesForProvider$.next(matchedIndices); + this.matchedIndicesForProvider$.next(undefined); + }); + + // alternate value with undefined so validation knows when its getting a fresh value + this.rollupIndexForProviderSub = this.rollupIndex$.subscribe((rollupIndex) => { + this.rollupIndexForProvider$.next(rollupIndex); + this.rollupIndexForProvider$.next(undefined); + }); } - rollupIndicesCapabilities$ = new BehaviorSubject({}); - isLoadingSources$ = new BehaviorSubject(false); + private http: HttpSetup; + private dataViews: DataViewsServicePublic; + // config + private requireTimestampField: boolean; + private type = INDEX_PATTERN_TYPE.DEFAULT; + + // state + private indexPattern = ''; + private allowHidden = false; + + // used for data view name validation - no dupes! + dataViewNames$: Observable; + + private loadTimestampFieldsSub: Subscription; + private matchedIndicesForProviderSub: Subscription; + private rollupIndexForProviderSub: Subscription; + + // used for validating rollup data views - must match one and only one data view + private rollupIndicesCapsInternal$ = new BehaviorSubject({}); + rollupIndicesCaps$: Observable; + private isLoadingSourcesInternal$ = new BehaviorSubject(false); + isLoadingSources$: Observable; - loadingTimestampFields$ = new BehaviorSubject(false); - timestampFieldOptions$ = new Subject(); + private loadingTimestampFieldsInternal$ = new BehaviorSubject(false); + loadingTimestampFields$: Observable; + private timestampFieldOptionsInternal$ = new Subject(); + timestampFieldOptions$: Observable; - matchedIndices$ = new BehaviorSubject({ - allIndices: [], - exactMatchedIndices: [], - partialMatchedIndices: [], - visibleIndices: [], - }); + // current matched rollup index + private rollupIndexInternal$ = new BehaviorSubject(undefined); + rollupIndex$: Observable; + // alernates between value and undefined so validation can treat new value as thought its a promise + private rollupIndexForProvider$ = new Subject(); + + private matchedIndicesInternal$ = new BehaviorSubject(matchedIndiciesDefault); + matchedIndices$: Observable; + + // alernates between value and undefined so validation can treat new value as thought its a promise + private matchedIndicesForProvider$ = new Subject(); private rollupCapsResponse: Promise; private currentLoadingTimestampFields = 0; + private currentLoadingMatchedIndices = 0; private getRollupIndexCaps = async () => { let response: RollupIndicesCapsResponse = {}; @@ -49,33 +144,35 @@ export class DataViewEditorService { } catch (e) { // Silently swallow failure responses such as expired trials } - this.rollupIndicesCapabilities$.next(response); + this.rollupIndicesCapsInternal$.next(response); return response; }; - private getRollupIndices = (rollupCaps: RollupIndicesCapsResponse) => Object.keys(rollupCaps); - - getIsRollupIndex = async () => { + private getIsRollupIndex = async () => { const response = await this.rollupCapsResponse; - return (indexName: string) => this.getRollupIndices(response).includes(indexName); + const indices = Object.keys(response); + return (indexName: string) => indices.includes(indexName); }; - loadMatchedIndices = async ( + private loadMatchedIndices = async ( query: string, allowHidden: boolean, - allSources: MatchedItem[] - ): Promise<{ - matchedIndicesResult: MatchedIndicesSet; - exactMatched: MatchedItem[]; - partialMatched: MatchedItem[]; - }> => { + allSources: MatchedItem[], + type: INDEX_PATTERN_TYPE + ): Promise => { + const currentLoadingMatchedIndicesIdx = ++this.currentLoadingMatchedIndices; + const isRollupIndex = await this.getIsRollupIndex(); const indexRequests = []; + let newRollupIndexName: string | undefined | null; + + this.loadingTimestampFieldsInternal$.next(true); if (query?.endsWith('*')) { const exactMatchedQuery = this.getIndicesCached({ pattern: query, showAllIndices: allowHidden, }); + indexRequests.push(exactMatchedQuery); // provide default value when not making a request for the partialMatchQuery indexRequests.push(Promise.resolve([])); @@ -104,53 +201,73 @@ export class DataViewEditorService { allowHidden ); - this.matchedIndices$.next(matchedIndicesResult); - return { matchedIndicesResult, exactMatched, partialMatched }; + // verify we're looking at the current result + if (currentLoadingMatchedIndicesIdx === this.currentLoadingMatchedIndices) { + if (type === INDEX_PATTERN_TYPE.ROLLUP) { + const rollupIndices = exactMatched.filter((index) => isRollupIndex(index.name)); + newRollupIndexName = rollupIndices.length === 1 ? rollupIndices[0].name : null; + this.rollupIndexInternal$.next(newRollupIndexName); + } else { + this.rollupIndexInternal$.next(null); + } + + this.matchedIndicesInternal$.next(matchedIndicesResult); + } }; - loadIndices = async (title: string, allowHidden: boolean) => { + setIndexPattern = (indexPattern: string) => { + this.indexPattern = removeSpaces(indexPattern); + this.loadIndices(); + }; + + setAllowHidden = (allowHidden: boolean) => { + this.allowHidden = allowHidden; + this.loadIndices(); + }; + + setType = (type: INDEX_PATTERN_TYPE) => { + this.type = type; + this.loadIndices(); + }; + + private loadIndices = async () => { const allSrcs = await this.getIndicesCached({ pattern: '*', - showAllIndices: allowHidden, + showAllIndices: this.allowHidden, }); + await this.loadMatchedIndices(this.indexPattern, this.allowHidden, allSrcs, this.type); - const matchedSet = await this.loadMatchedIndices(title, allowHidden, allSrcs); - - this.isLoadingSources$.next(false); - const matchedIndices = getMatchedIndices( - allSrcs, - matchedSet.partialMatched, - matchedSet.exactMatched, - allowHidden - ); - - this.matchedIndices$.next(matchedIndices); - return matchedIndices; + this.isLoadingSourcesInternal$.next(false); }; - loadDataViewNames = async (dataViewName?: string) => { - const dataViewListItems = await this.dataViews.getIdsWithTitle(dataViewName ? true : false); + private loadDataViewNames = async (initialName?: string) => { + const dataViewListItems = await this.dataViews.getIdsWithTitle(true); const dataViewNames = dataViewListItems.map((item) => item.name || item.title); - return dataViewName ? dataViewNames.filter((v) => v !== dataViewName) : dataViewNames; + return initialName ? dataViewNames.filter((v) => v !== initialName) : dataViewNames; }; private getIndicesMemory: Record> = {}; - getIndicesCached = async (props: { pattern: string; showAllIndices?: boolean | undefined }) => { + + private getIndicesCached = async (props: { + pattern: string; + showAllIndices?: boolean | undefined; + }) => { const key = JSON.stringify(props); - const getIndicesPromise = this.getIsRollupIndex().then((isRollupIndex) => - this.dataViews.getIndices({ ...props, isRollupIndex }) - ); - this.getIndicesMemory[key] = this.getIndicesMemory[key] || getIndicesPromise; + this.getIndicesMemory[key] = + this.getIndicesMemory[key] || + this.getIsRollupIndex().then((isRollupIndex) => + this.dataViews.getIndices({ ...props, isRollupIndex }) + ); - getIndicesPromise.catch(() => { + this.getIndicesMemory[key].catch(() => { delete this.getIndicesMemory[key]; }); - return await getIndicesPromise; + return await this.getIndicesMemory[key]; }; - private timeStampOptionsMemory: Record> = {}; + private timestampOptionsMemory: Record> = {}; private getTimestampOptionsForWildcard = async ( getFieldsOptions: GetFieldsOptions, requireTimestampField: boolean @@ -169,47 +286,72 @@ export class DataViewEditorService { getFieldsOptions, requireTimestampField ); - this.timeStampOptionsMemory[key] = - this.timeStampOptionsMemory[key] || getTimestampOptionsPromise; + this.timestampOptionsMemory[key] = + this.timestampOptionsMemory[key] || getTimestampOptionsPromise; getTimestampOptionsPromise.catch(() => { - delete this.timeStampOptionsMemory[key]; + delete this.timestampOptionsMemory[key]; }); return await getTimestampOptionsPromise; }; - loadTimestampFields = async ( - index: string, - type: INDEX_PATTERN_TYPE, - requireTimestampField: boolean, - rollupIndex?: string - ) => { - if (this.matchedIndices$.getValue().exactMatchedIndices.length === 0) { - this.timestampFieldOptions$.next([]); + private loadTimestampFields = async () => { + if (this.matchedIndicesInternal$.getValue().exactMatchedIndices.length === 0) { + this.timestampFieldOptionsInternal$.next([]); + this.loadingTimestampFieldsInternal$.next(false); return; } const currentLoadingTimestampFieldsIdx = ++this.currentLoadingTimestampFields; - this.loadingTimestampFields$.next(true); + const getFieldsOptions: GetFieldsOptions = { - pattern: index, + pattern: this.indexPattern, }; - if (type === INDEX_PATTERN_TYPE.ROLLUP) { + if (this.type === INDEX_PATTERN_TYPE.ROLLUP) { getFieldsOptions.type = INDEX_PATTERN_TYPE.ROLLUP; - getFieldsOptions.rollupIndex = rollupIndex; + getFieldsOptions.rollupIndex = this.rollupIndexInternal$.getValue() || ''; } let timestampOptions: TimestampOption[] = []; try { timestampOptions = await this.getTimestampOptionsForWildcardCached( getFieldsOptions, - requireTimestampField + this.requireTimestampField ); } finally { if (currentLoadingTimestampFieldsIdx === this.currentLoadingTimestampFields) { - this.timestampFieldOptions$.next(timestampOptions); - this.loadingTimestampFields$.next(false); + this.timestampFieldOptionsInternal$.next(timestampOptions); + this.loadingTimestampFieldsInternal$.next(false); } } }; + + // provides info necessary for validation of index pattern in required async format + indexPatternValidationProvider = async () => { + const rollupIndexPromise = firstValueFrom( + this.rollupIndex$.pipe(first((data) => data !== undefined)) + ); + + const matchedIndicesPromise = firstValueFrom( + this.matchedIndicesForProvider$.pipe(first((data) => data !== undefined)) + ); + + // necessary to get new observable value if the field hasn't changed + this.loadIndices(); + + // Wait until we have fetched the indices. + // The result will then be sent to the field validator(s) (when calling await provider();); + const [rollupIndex, matchedIndices] = await Promise.all([ + rollupIndexPromise, + matchedIndicesPromise, + ]); + + return { rollupIndex, matchedIndices: matchedIndices || matchedIndiciesDefault }; + }; + + destroy = () => { + this.loadTimestampFieldsSub.unsubscribe(); + this.matchedIndicesForProviderSub.unsubscribe(); + this.rollupIndexForProviderSub.unsubscribe(); + }; } diff --git a/src/plugins/data_view_editor/public/shared_imports.ts b/src/plugins/data_view_editor/public/shared_imports.ts index 9f805feedeca1..57fb7fc6f7031 100644 --- a/src/plugins/data_view_editor/public/shared_imports.ts +++ b/src/plugins/data_view_editor/public/shared_imports.ts @@ -35,6 +35,7 @@ export { Form, UseField, getFieldValidityAndErrorMessage, + useBehaviorSubject, } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; export { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; diff --git a/test/functional/apps/management/_index_pattern_create_delete.ts b/test/functional/apps/management/_index_pattern_create_delete.ts index e9ceba4439a03..8447610d60aa8 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.ts +++ b/test/functional/apps/management/_index_pattern_create_delete.ts @@ -46,6 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('can resolve errors and submit', async function () { await PageObjects.settings.setIndexPatternField('log*'); + await new Promise((e) => setTimeout(e, 500)); await (await PageObjects.settings.getSaveDataViewButtonActive()).click(); await PageObjects.settings.removeIndexPattern(); });