[data view field editor] Refactor business logic from hooks to controller (#156203)

## Summary

This PR moves a lot of business logic from `field_preview_context.tsx`
to the controller. No functionality is changed. This is a step to simply
the code and eventually reduce bugs.

My approach to this change was -
1. Move `useState` calls to controller state, adding necessary setters
2. Move `useCallback` calls to controller methods.
3. Move `useEffect` calls - this one is the most complex - they get
called when state values are set. Sometimes the code is inlined or a new
private method might be created.

The bits that remain are mostly related to form values referred to as
`params`. I'm saving this for a subsequent PR as this PR is large enough
as it is.

Closes https://github.com/elastic/kibana/issues/151243

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Matthew Kime 2023-05-31 12:28:52 -05:00 committed by GitHub
parent ca0c9bd2a0
commit b3561b9328
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 438 additions and 404 deletions

View file

@ -58,6 +58,8 @@ const mapReturnTypeToPainlessContext = (runtimeType: RuntimeType): PainlessConte
const currentDocumentSelector = (state: PreviewState) => state.documents[state.currentIdx];
const currentDocumentIsLoadingSelector = (state: PreviewState) => state.isLoadingDocuments;
const currentErrorSelector = (state: PreviewState) => state.previewResponse?.error;
const isLoadingPreviewSelector = (state: PreviewState) => state.isLoadingPreview;
const isPreviewAvailableSelector = (state: PreviewState) => state.isPreviewAvailable;
const ScriptFieldComponent = ({ existingConcreteFields, links, placeholder }: Props) => {
const {
@ -67,10 +69,12 @@ const ScriptFieldComponent = ({ existingConcreteFields, links, placeholder }: Pr
const editorValidationSubscription = useRef<Subscription>();
const fieldCurrentValue = useRef<string>('');
const { isLoadingPreview, isPreviewAvailable, controller } = useFieldPreviewContext();
const { controller } = useFieldPreviewContext();
const error = useStateSelector(controller.state$, currentErrorSelector);
const currentDocument = useStateSelector(controller.state$, currentDocumentSelector);
const isFetchingDoc = useStateSelector(controller.state$, currentDocumentIsLoadingSelector);
const isLoadingPreview = useStateSelector(controller.state$, isLoadingPreviewSelector);
const isPreviewAvailable = useStateSelector(controller.state$, isPreviewAvailableSelector);
const [validationData$, nextValidationData$] = useBehaviorSubject<
| {
isFetchingDoc: boolean;

View file

@ -20,12 +20,14 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { euiFlyoutClassname } from '../constants';
import type { Field } from '../types';
import { PreviewState } from './preview/types';
import { ModifiedFieldModal, SaveFieldTypeOrNameChangedModal } from './confirm_modals';
import { FieldEditor, FieldEditorFormState } from './field_editor/field_editor';
import { useFieldEditorContext } from './field_editor_context';
import { FlyoutPanels } from './flyout_panels';
import { FieldPreview, useFieldPreviewContext } from './preview';
import { useStateSelector } from '../state_utils';
const i18nTexts = {
cancelButtonLabel: i18n.translate('indexPatternFieldEditor.editor.flyoutCancelButtonLabel', {
@ -61,6 +63,8 @@ export interface Props {
onMounted?: (args: { canCloseValidator: () => boolean }) => void;
}
const isPanelVisibleSelector = (state: PreviewState) => state.isPanelVisible;
const FieldEditorFlyoutContentComponent = ({
fieldToEdit,
fieldToCreate,
@ -75,9 +79,8 @@ const FieldEditorFlyoutContentComponent = ({
const isMobile = useIsWithinMaxBreakpoint('s');
const {
panel: { isVisible: isPanelVisible },
} = useFieldPreviewContext();
const { controller } = useFieldPreviewContext();
const isPanelVisible = useStateSelector(controller.state$, isPanelVisibleSelector);
const [formState, setFormState] = useState<FieldEditorFormState>({
isSubmitted: false,

View file

@ -27,14 +27,13 @@ const docIdSelector = (state: PreviewState) => {
customId: state.customId,
};
};
const fetchDocErrorSelector = (state: PreviewState) => state.fetchDocError;
export const DocumentsNavPreview = () => {
const {
documents: { loadSingle, loadFromCluster, fetchDocError },
controller,
} = useFieldPreviewContext();
const { controller } = useFieldPreviewContext();
const { goToPreviousDocument: prev, goToNextDocument: next } = controller;
const { documentId, customId } = useStateSelector(controller.state$, docIdSelector);
const fetchDocError = useStateSelector(controller.state$, fetchDocErrorSelector);
const isInvalid = fetchDocError?.code === 'DOC_NOT_FOUND';
@ -45,9 +44,9 @@ export const DocumentsNavPreview = () => {
const onDocumentIdChange = useCallback(
(e: React.SyntheticEvent<HTMLInputElement>) => {
const nextId = (e.target as HTMLInputElement).value;
loadSingle(nextId);
controller.setCustomDocIdToLoad(nextId);
},
[loadSingle]
[controller]
);
return (
@ -75,7 +74,7 @@ export const DocumentsNavPreview = () => {
color="primary"
size="xs"
flush="left"
onClick={() => loadFromCluster()}
onClick={() => controller.fetchSampleDocuments()}
data-test-subj="loadDocsFromClusterButton"
>
{i18n.translate(

View file

@ -23,6 +23,8 @@ import { useFieldPreviewContext } from '../field_preview_context';
import { IsUpdatingIndicator } from '../is_updating_indicator';
import { ImagePreviewModal } from '../image_preview_modal';
import type { DocumentField } from './field_list';
import { PreviewState } from '../types';
import { useStateSelector } from '../../../state_utils';
interface Props {
field: DocumentField;
@ -32,13 +34,16 @@ interface Props {
isFromScript?: boolean;
}
const isLoadingPreviewSelector = (state: PreviewState) => state.isLoadingPreview;
export const PreviewListItem: React.FC<Props> = ({
field: { key, value, formattedValue, isPinned = false },
toggleIsPinned,
hasScriptError,
isFromScript = false,
}) => {
const { isLoadingPreview } = useFieldPreviewContext();
const { controller } = useFieldPreviewContext();
const isLoadingPreview = useStateSelector(controller.state$, isLoadingPreviewSelector);
const [isPreviewImageModalVisible, setIsPreviewImageModalVisible] = useState(false);

View file

@ -5,7 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSpacer, EuiResizeObserver, EuiFieldSearch, EuiCallOut } from '@elastic/eui';
@ -17,9 +17,15 @@ import { FieldPreviewError } from './field_preview_error';
import { PreviewListItem } from './field_list/field_list_item';
import { PreviewFieldList } from './field_list/field_list';
import { useStateSelector } from '../../state_utils';
import { PreviewState } from './types';
import './field_preview.scss';
const previewResponseSelector = (state: PreviewState) => state.previewResponse;
const fetchDocErrorSelector = (state: PreviewState) => state.fetchDocError;
const isLoadingPreviewSelector = (state: PreviewState) => state.isLoadingPreview;
const isPreviewAvailableSelector = (state: PreviewState) => state.isPreviewAvailable;
export const FieldPreview = () => {
const [fieldListHeight, setFieldListHeight] = useState(-1);
const [searchValue, setSearchValue] = useState('');
@ -28,13 +34,12 @@ export const FieldPreview = () => {
params: {
value: { name, script, format },
},
isLoadingPreview,
documents: { fetchDocError },
reset,
isPreviewAvailable,
controller,
} = useFieldPreviewContext();
const { fields, error } = useStateSelector(controller.state$, (state) => state.previewResponse);
const { fields, error } = useStateSelector(controller.state$, previewResponseSelector);
const fetchDocError = useStateSelector(controller.state$, fetchDocErrorSelector);
const isLoadingPreview = useStateSelector(controller.state$, isLoadingPreviewSelector);
const isPreviewAvailable = useStateSelector(controller.state$, isPreviewAvailableSelector);
// To show the preview we at least need a name to be defined, the script or the format
// and an first response from the _execute API
@ -71,12 +76,6 @@ export const FieldPreview = () => {
);
};
useEffect(() => {
// When unmounting the preview pannel we make sure to reset
// the state of the preview panel.
return reset;
}, [reset]);
return (
<div
className="indexPatternFieldEditor__previewPannel"

View file

@ -26,15 +26,7 @@ import { useStateSelector } from '../../state_utils';
import { parseEsError } from '../../lib/runtime_field_validation';
import { useFieldEditorContext } from '../field_editor_context';
import type {
PainlessExecuteContext,
Context,
Params,
EsDocument,
FetchDocError,
FieldPreview,
PreviewState,
} from './types';
import type { PainlessExecuteContext, Context, Params, FieldPreview, PreviewState } from './types';
import type { PreviewController } from './preview_controller';
const fieldPreviewContext = createContext<Context | undefined>(undefined);
@ -69,7 +61,6 @@ const documentsSelector = (state: PreviewState) => {
totalDocs: state.documents.length,
currentDocIndex: currentDocument?._index,
currentDocId: currentDocument?._id,
currentIdx: state.currentIdx,
};
};
@ -77,25 +68,9 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
controller,
children,
}) => {
const previewCount = useRef(0);
// We keep in cache the latest params sent to the _execute API so we don't make unecessary requests
// when changing parameters that don't affect the preview result (e.g. changing the "name" field).
const lastExecutePainlessRequestParams = useRef<{
type: Params['type'];
script: string | undefined;
documentId: string | undefined;
}>({
type: null,
script: undefined,
documentId: undefined,
});
const {
dataView,
fieldTypeToProcess,
services: {
search,
notifications,
api: { getFieldPreview },
},
@ -104,10 +79,6 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
const fieldPreview$ = useRef(new BehaviorSubject<FieldPreview[] | undefined>(undefined));
const [initialPreviewComplete, setInitialPreviewComplete] = useState(false);
/** Possible error while fetching sample documents */
const [fetchDocError, setFetchDocError] = useState<FetchDocError | null>(null);
/** The parameters required for the Painless _execute API */
const [params, setParams] = useState<Params>(defaultParams);
@ -117,28 +88,10 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
message: string | null;
}>({ isValidating: false, isValid: true, message: null });
/** Flag to show/hide the preview panel */
const [isPanelVisible, setIsPanelVisible] = useState(true);
/** Flag to indicate if we are loading document from cluster */
const [isFetchingDocument, setIsFetchingDocument] = useState(false);
/** Flag to indicate if we are calling the _execute API */
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
/** Flag to indicate if we are loading a single document by providing its ID */
const [customDocIdToLoad, setCustomDocIdToLoad] = useState<string | null>(null);
const { currentDocument, currentDocIndex, currentDocId, totalDocs, currentIdx } =
useStateSelector(controller.state$, documentsSelector);
let isPreviewAvailable = true;
// If no documents could be fetched from the cluster (and we are not trying to load
// a custom doc ID) then we disable preview as the script field validation expect the result
// of the preview to before resolving. If there are no documents we can't have a preview
// (the _execute API expects one) and thus the validation should not expect a value.
if (!isFetchingDocument && !customDocIdToLoad && totalDocs === 0) {
isPreviewAvailable = false;
}
const { currentDocument, currentDocIndex, currentDocId } = useStateSelector(
controller.state$,
documentsSelector
);
const { name, document, script, format, type, parentName } = params;
@ -146,196 +99,6 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
setParams((prev) => ({ ...prev, ...updated }));
}, []);
const allParamsDefined = useMemo(() => {
if (!currentDocIndex || !script?.source || !type) {
return false;
}
return true;
}, [currentDocIndex, script?.source, type]);
const hasSomeParamsChanged = useMemo(() => {
return (
lastExecutePainlessRequestParams.current.type !== type ||
lastExecutePainlessRequestParams.current.script !== script?.source ||
lastExecutePainlessRequestParams.current.documentId !== currentDocId
);
}, [type, script, currentDocId]);
const fetchSampleDocuments = useCallback(
async (limit: number = 50) => {
if (typeof limit !== 'number') {
// We guard ourself from passing an <input /> event accidentally
throw new Error('The "limit" option must be a number');
}
lastExecutePainlessRequestParams.current.documentId = undefined;
setIsFetchingDocument(true);
controller.setPreviewResponse({ fields: [], error: null });
const [response, searchError] = await search
.search({
params: {
index: dataView.getIndexPattern(),
body: {
fields: ['*'],
size: limit,
},
},
})
.toPromise()
.then((res) => [res, null])
.catch((err) => [null, err]);
setIsFetchingDocument(false);
setCustomDocIdToLoad(null);
const error: FetchDocError | null = Boolean(searchError)
? {
code: 'ERR_FETCHING_DOC',
error: {
message: searchError.toString(),
reason: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.errorLoadingSampleDocumentsDescription',
{
defaultMessage: 'Error loading sample documents.',
}
),
},
}
: null;
setFetchDocError(error);
if (error === null) {
controller.setDocuments(response ? response.rawResponse.hits.hits : []);
}
},
[dataView, search, controller]
);
const loadDocument = useCallback(
async (id: string) => {
if (!Boolean(id.trim())) {
return;
}
lastExecutePainlessRequestParams.current.documentId = undefined;
setIsFetchingDocument(true);
const [response, searchError] = await search
.search({
params: {
index: dataView.getIndexPattern(),
body: {
size: 1,
fields: ['*'],
query: {
ids: {
values: [id],
},
},
},
},
})
.toPromise()
.then((res) => [res, null])
.catch((err) => [null, err]);
setIsFetchingDocument(false);
const isDocumentFound = response?.rawResponse.hits.total > 0;
const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : [];
const error: FetchDocError | null = Boolean(searchError)
? {
code: 'ERR_FETCHING_DOC',
error: {
message: searchError.toString(),
reason: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.errorLoadingDocumentDescription',
{
defaultMessage: 'Error loading document.',
}
),
},
}
: isDocumentFound === false
? {
code: 'DOC_NOT_FOUND',
error: {
message: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription',
{
defaultMessage: 'Document ID not found',
}
),
},
}
: null;
setFetchDocError(error);
if (error === null) {
controller.setDocuments(loadedDocuments);
} else {
// Make sure we disable the "Updating..." indicator as we have an error
// and we won't fetch the preview
setIsLoadingPreview(false);
}
},
[dataView, search, controller]
);
const updateSingleFieldPreview = useCallback(
(fieldName: string, values: unknown[]) => {
const [value] = values;
const formattedValue = controller.valueFormatter({ value, type, format });
controller.setPreviewResponse({
fields: [{ key: fieldName, value, formattedValue }],
error: null,
});
},
[controller, type, format]
);
const updateCompositeFieldPreview = useCallback(
(compositeValues: Record<string, unknown[]>) => {
const updatedFieldsInScript: string[] = [];
// if we're displaying a composite subfield, filter results
const filterSubfield = parentName ? (field: FieldPreview) => field.key === name : () => true;
const fields = Object.entries(compositeValues)
.map<FieldPreview>(([key, values]) => {
// The Painless _execute API returns the composite field values under a map.
// Each of the key is prefixed with "composite_field." (e.g. "composite_field.field1: ['value']")
const { 1: fieldName } = key.split('composite_field.');
updatedFieldsInScript.push(fieldName);
const [value] = values;
const formattedValue = controller.valueFormatter({ value, type, format });
return {
key: parentName
? `${parentName ?? ''}.${fieldName}`
: `${fieldName$.getValue() ?? ''}.${fieldName}`,
value,
formattedValue,
type: valueTypeToSelectedType(value),
};
})
.filter(filterSubfield)
// ...and sort alphabetically
.sort((a, b) => a.key.localeCompare(b.key));
fieldPreview$.current.next(fields);
controller.setPreviewResponse({
fields,
error: null,
});
},
[parentName, name, fieldPreview$, fieldName$, controller, type, format]
);
const updatePreview = useCallback(async () => {
// don't prevent rendering if we're working with a composite subfield (has parentName)
if (!parentName && scriptEditorValidation.isValidating) {
@ -344,19 +107,21 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
if (
!parentName &&
(!allParamsDefined || !hasSomeParamsChanged || scriptEditorValidation.isValid === false)
(!controller.allParamsDefined(type, script?.source, currentDocIndex) ||
!controller.hasSomeParamsChanged(type, script?.source, currentDocId) ||
scriptEditorValidation.isValid === false)
) {
setIsLoadingPreview(false);
controller.setIsLoadingPreview(false);
return;
}
lastExecutePainlessRequestParams.current = {
controller.setLastExecutePainlessRequestParams({
type,
script: script?.source,
documentId: currentDocId,
};
});
const currentApiCall = ++previewCount.current;
const currentApiCall = controller.incrementPreviewCount(); // ++previewCount.current;
const previewScript = (parentName && dataView.getRuntimeField(parentName)?.script) || script!;
@ -367,7 +132,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
script: previewScript,
});
if (currentApiCall !== previewCount.current) {
if (currentApiCall !== controller.getPreviewCount()) {
// Discard this response as there is another one inflight
// or we have called reset() and no longer need the response.
return;
@ -382,7 +147,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
});
notifications.toasts.addError(serverError, { title });
setIsLoadingPreview(false);
controller.setIsLoadingPreview(false);
return;
}
@ -402,15 +167,23 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
});
} else {
if (!Array.isArray(values)) {
updateCompositeFieldPreview(values);
controller.updateCompositeFieldPreview(
values,
parentName,
name!,
fieldName$.getValue(),
type,
format,
(value: FieldPreview[] | undefined) => fieldPreview$.current.next(value)
);
} else {
updateSingleFieldPreview(name!, values);
controller.updateSingleFieldPreview(name!, values, type, format);
}
}
}
setInitialPreviewComplete(true);
setIsLoadingPreview(false);
controller.setInitialPreviewComplete(true);
controller.setIsLoadingPreview(false);
}, [
name,
type,
@ -421,108 +194,40 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
currentDocId,
getFieldPreview,
notifications.toasts,
allParamsDefined,
scriptEditorValidation,
hasSomeParamsChanged,
updateSingleFieldPreview,
updateCompositeFieldPreview,
currentDocIndex,
controller,
format,
fieldName$,
]);
const reset = useCallback(() => {
// By resetting the previewCount we will discard previous inflight
// API call response coming in after calling reset() was called
previewCount.current = 0;
controller.setDocuments([]);
controller.setPreviewResponse({ fields: [], error: null });
setIsLoadingPreview(false);
setIsFetchingDocument(false);
}, [controller]);
const ctx = useMemo<Context>(
() => ({
controller,
fieldPreview$: fieldPreview$.current,
isPreviewAvailable,
isLoadingPreview,
initialPreviewComplete,
params: {
value: params,
update: updateParams,
},
documents: {
loadSingle: setCustomDocIdToLoad,
loadFromCluster: fetchSampleDocuments,
fetchDocError,
},
navigation: {
isFirstDoc: currentIdx === 0,
isLastDoc: currentIdx >= totalDocs - 1,
},
panel: {
isVisible: isPanelVisible,
setIsVisible: setIsPanelVisible,
},
validation: {
// todo do this next
setScriptEditorValidation,
},
reset,
}),
[
controller,
currentIdx,
fieldPreview$,
fetchDocError,
params,
isPreviewAvailable,
isLoadingPreview,
updateParams,
fetchSampleDocuments,
totalDocs,
isPanelVisible,
reset,
initialPreviewComplete,
]
[controller, fieldPreview$, params, updateParams]
);
/**
* In order to immediately display the "Updating..." state indicator and not have to wait
* the 500ms of the debounce, we set the isLoadingPreview state in this effect whenever
* one of the _execute API param changes
*/
useEffect(() => {
if (allParamsDefined && hasSomeParamsChanged) {
setIsLoadingPreview(true);
}
}, [allParamsDefined, hasSomeParamsChanged, script?.source, type, currentDocId]);
/**
* In order to immediately display the "Updating..." state indicator and not have to wait
* the 500ms of the debounce, we set the isFetchingDocument state in this effect whenever
* "customDocIdToLoad" changes
*/
useEffect(() => {
controller.setCustomId(customDocIdToLoad || undefined);
if (customDocIdToLoad !== null && Boolean(customDocIdToLoad.trim())) {
setIsFetchingDocument(true);
}
}, [customDocIdToLoad, controller]);
/**
* Whenever we show the preview panel we will update the documents from the cluster
*/
useEffect(() => {
if (isPanelVisible) {
fetchSampleDocuments();
}
}, [isPanelVisible, fetchSampleDocuments, fieldTypeToProcess]);
/**
* Each time the current document changes we update the parameters
* that will be sent in the _execute HTTP request.
*/
// This game me problems
useEffect(() => {
updateParams({
document: currentDocument,
@ -594,7 +299,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
if (script?.source === undefined) {
// Whenever the source is not defined ("Set value" is toggled off or the
// script is empty) we clear the error and update the params cache.
lastExecutePainlessRequestParams.current.script = undefined;
controller.setLastExecutePainlessRequestParams({ script: undefined });
controller.setPreviewError(null);
}
}, [script?.source, controller]);
@ -608,7 +313,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
if (scriptEditorValidation.isValid === false) {
// Make sure to remove the "Updating..." spinner
setIsLoadingPreview(false);
controller.setIsLoadingPreview(false);
// Set preview response error so it is displayed in the flyout footer
const error =
@ -628,7 +333,8 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
// Make sure to update the lastExecutePainlessRequestParams cache so when the user updates
// the script and fixes the syntax the "updatePreview()" will run
lastExecutePainlessRequestParams.current.script = script?.source;
// lastExecutePainlessRequestParams.current.script = script?.source;
controller.setLastExecutePainlessRequestParams({ script: script?.source });
} else {
// Clear possible previous syntax error
controller.clearPreviewError('PAINLESS_SYNTAX_ERROR');
@ -641,21 +347,6 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
*/
useDebounce(updatePreview, 500, [updatePreview]);
/**
* Whenever the doc ID to load changes we load the document (after a 500ms debounce)
*/
useDebounce(
() => {
if (customDocIdToLoad === null) {
return;
}
loadDocument(customDocIdToLoad);
},
500,
[customDocIdToLoad]
);
return <fieldPreviewContext.Provider value={ctx}>{children}</fieldPreviewContext.Provider>;
};

View file

@ -8,13 +8,17 @@
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useStateSelector } from '../../state_utils';
import { PreviewState } from './types';
import { useFieldPreviewContext } from './field_preview_context';
const fetchDocErrorSelector = (state: PreviewState) => state.fetchDocError;
export const FieldPreviewError = () => {
const {
documents: { fetchDocError },
} = useFieldPreviewContext();
const { controller } = useFieldPreviewContext();
const fetchDocError = useStateSelector(controller.state$, fetchDocErrorSelector);
if (fetchDocError === null) {
return null;

View file

@ -6,16 +6,19 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { ISearchStart } from '@kbn/data-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { castEsToKbnFieldTypeName } from '@kbn/field-types';
import { renderToString } from 'react-dom/server';
import React from 'react';
import { PreviewState } from './types';
import debounce from 'lodash/debounce';
import { PreviewState, FetchDocError } from './types';
import { BehaviorObservable } from '../../state_utils';
import { EsDocument, ScriptErrorCodes, Params } from './types';
import { EsDocument, ScriptErrorCodes, Params, FieldPreview } from './types';
import type { FieldFormatsStart } from '../../shared_imports';
import { valueTypeToSelectedType } from './field_preview_context';
export const defaultValueFormatter = (value: unknown) => {
const content = typeof value === 'object' ? JSON.stringify(value) : String(value) ?? '-';
@ -41,6 +44,20 @@ const previewStateDefault: PreviewState = {
/** Keep track if the script painless syntax is being validated and if it is valid */
scriptEditorValidation: { isValidating: false, isValid: true, message: null },
previewResponse: { fields: [], error: null },
/** Flag to indicate if we are loading document from cluster */
isFetchingDocument: false,
/** Possible error while fetching sample documents */
fetchDocError: null,
/** Flag to indicate if we are loading a single document by providing its ID */
customDocIdToLoad: null, // not used externally
// We keep in cache the latest params sent to the _execute API so we don't make unecessary requests
// when changing parameters that don't affect the preview result (e.g. changing the "name" field).
isLoadingPreview: false,
initialPreviewComplete: false,
isPreviewAvailable: true,
/** Flag to show/hide the preview panel */
isPanelVisible: true,
};
export class PreviewController {
@ -54,22 +71,34 @@ export class PreviewController {
});
this.state$ = this.internalState$ as BehaviorObservable<PreviewState>;
this.fetchSampleDocuments();
}
// dependencies
// @ts-ignore
private dataView: DataView;
// @ts-ignore
private search: ISearchStart;
private fieldFormats: FieldFormatsStart;
private internalState$: BehaviorSubject<PreviewState>;
state$: BehaviorObservable<PreviewState>;
private previewCount = 0;
private updateState = (newState: Partial<PreviewState>) => {
this.internalState$.next({ ...this.state$.getValue(), ...newState });
};
private lastExecutePainlessRequestParams: {
type: Params['type'];
script: string | undefined;
documentId: string | undefined;
} = {
type: null,
script: undefined,
documentId: undefined,
};
togglePinnedField = (fieldName: string) => {
const currentState = this.state$.getValue();
const pinnedFields = {
@ -80,18 +109,15 @@ export class PreviewController {
this.updateState({ pinnedFields });
};
setDocuments = (documents: EsDocument[]) => {
private setDocuments = (documents: EsDocument[]) => {
this.updateState({
documents,
currentIdx: 0,
isLoadingDocuments: false,
isPreviewAvailable: this.getIsPreviewAvailable({ documents }),
});
};
setCurrentIdx = (currentIdx: number) => {
this.updateState({ currentIdx });
};
goToNextDocument = () => {
const currentState = this.state$.getValue();
if (currentState.currentIdx >= currentState.documents.length - 1) {
@ -116,10 +142,6 @@ export class PreviewController {
};
*/
setCustomId = (customId?: string) => {
this.updateState({ customId });
};
setPreviewError = (error: PreviewState['previewResponse']['error']) => {
this.updateState({
previewResponse: { ...this.internalState$.getValue().previewResponse, error },
@ -130,6 +152,46 @@ export class PreviewController {
this.updateState({ previewResponse });
};
setCustomDocIdToLoad = (customDocIdToLoad: string | null) => {
this.updateState({
customDocIdToLoad,
customId: customDocIdToLoad || undefined,
isPreviewAvailable: this.getIsPreviewAvailable({ customDocIdToLoad }),
});
// load document if id is present
this.setIsFetchingDocument(!!customDocIdToLoad);
if (customDocIdToLoad) {
this.debouncedLoadDocument(customDocIdToLoad);
}
};
// If no documents could be fetched from the cluster (and we are not trying to load
// a custom doc ID) then we disable preview as the script field validation expect the result
// of the preview to before resolving. If there are no documents we can't have a preview
// (the _execute API expects one) and thus the validation should not expect a value.
private getIsPreviewAvailable = (update: {
isFetchingDocument?: boolean;
customDocIdToLoad?: string | null;
documents?: EsDocument[];
}) => {
const {
isFetchingDocument: existingIsFetchingDocument,
customDocIdToLoad: existingCustomDocIdToLoad,
documents: existingDocuments,
} = this.internalState$.getValue();
const existing = { existingIsFetchingDocument, existingCustomDocIdToLoad, existingDocuments };
const merged = { ...existing, ...update };
if (!merged.isFetchingDocument && !merged.customDocIdToLoad && merged.documents?.length === 0) {
return false;
} else {
return true;
}
};
clearPreviewError = (errorCode: ScriptErrorCodes) => {
const { previewResponse: prev } = this.internalState$.getValue();
const error = prev.error === null || prev.error?.code === errorCode ? null : prev.error;
@ -141,6 +203,65 @@ export class PreviewController {
});
};
private setIsFetchingDocument = (isFetchingDocument: boolean) => {
this.updateState({
isFetchingDocument,
isPreviewAvailable: this.getIsPreviewAvailable({ isFetchingDocument }),
});
};
private setFetchDocError = (fetchDocError: FetchDocError | null) => {
this.updateState({ fetchDocError });
};
setIsLoadingPreview = (isLoadingPreview: boolean) => {
this.updateState({ isLoadingPreview });
};
setInitialPreviewComplete = (initialPreviewComplete: boolean) => {
this.updateState({ initialPreviewComplete });
};
getIsFirstDoc = () => this.internalState$.getValue().currentIdx === 0;
getIsLastDoc = () => {
const { currentIdx, documents } = this.internalState$.getValue();
return currentIdx >= documents.length - 1;
};
setLastExecutePainlessRequestParams = (
lastExecutePainlessRequestParams: Partial<typeof this.lastExecutePainlessRequestParams>
) => {
const state = this.internalState$.getValue();
const currentDocument = state.documents[state.currentIdx];
const updated = {
...this.lastExecutePainlessRequestParams,
...lastExecutePainlessRequestParams,
};
if (
this.allParamsDefined(
updated.type,
updated.script,
// todo get current doc index
currentDocument?._index
) &&
this.hasSomeParamsChanged(
lastExecutePainlessRequestParams.type!,
lastExecutePainlessRequestParams.script,
lastExecutePainlessRequestParams.documentId
)
) {
/**
* In order to immediately display the "Updating..." state indicator and not have to wait
* the 500ms of the debounce, we set the isLoadingPreview state in this effect whenever
* one of the _execute API param changes
*/
this.setIsLoadingPreview(true);
}
this.lastExecutePainlessRequestParams = updated;
};
valueFormatter = ({
value,
format,
@ -167,4 +288,219 @@ export class PreviewController {
return defaultValueFormatter(value);
};
fetchSampleDocuments = async (limit: number = 50) => {
if (typeof limit !== 'number') {
// We guard ourself from passing an <input /> event accidentally
throw new Error('The "limit" option must be a number');
}
this.setLastExecutePainlessRequestParams({ documentId: undefined });
this.setIsFetchingDocument(true);
this.setPreviewResponse({ fields: [], error: null });
const [response, searchError] = await this.search
.search({
params: {
index: this.dataView.getIndexPattern(),
body: {
fields: ['*'],
size: limit,
},
},
})
.toPromise()
.then((res) => [res, null])
.catch((err) => [null, err]);
this.setIsFetchingDocument(false);
this.setCustomDocIdToLoad(null);
const error: FetchDocError | null = Boolean(searchError)
? {
code: 'ERR_FETCHING_DOC',
error: {
message: searchError.toString(),
reason: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.errorLoadingSampleDocumentsDescription',
{
defaultMessage: 'Error loading sample documents.',
}
),
},
}
: null;
this.setFetchDocError(error);
if (error === null) {
this.setDocuments(response ? response.rawResponse.hits.hits : []);
}
};
loadDocument = async (id: string) => {
if (!Boolean(id.trim())) {
return;
}
this.setLastExecutePainlessRequestParams({ documentId: undefined });
this.setIsFetchingDocument(true);
const [response, searchError] = await this.search
.search({
params: {
index: this.dataView.getIndexPattern(),
body: {
size: 1,
fields: ['*'],
query: {
ids: {
values: [id],
},
},
},
},
})
.toPromise()
.then((res) => [res, null])
.catch((err) => [null, err]);
this.setIsFetchingDocument(false);
const isDocumentFound = response?.rawResponse.hits.total > 0;
const loadedDocuments: EsDocument[] = isDocumentFound ? response.rawResponse.hits.hits : [];
const error: FetchDocError | null = Boolean(searchError)
? {
code: 'ERR_FETCHING_DOC',
error: {
message: searchError.toString(),
reason: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.errorLoadingDocumentDescription',
{
defaultMessage: 'Error loading document.',
}
),
},
}
: isDocumentFound === false
? {
code: 'DOC_NOT_FOUND',
error: {
message: i18n.translate(
'indexPatternFieldEditor.fieldPreview.error.documentNotFoundDescription',
{
defaultMessage: 'Document ID not found',
}
),
},
}
: null;
this.setFetchDocError(error);
if (error === null) {
this.setDocuments(loadedDocuments);
} else {
// Make sure we disable the "Updating..." indicator as we have an error
// and we won't fetch the preview
this.setIsLoadingPreview(false);
}
};
debouncedLoadDocument = debounce(this.loadDocument, 500, { leading: true });
reset = () => {
this.previewCount = 0;
this.updateState({
documents: [],
previewResponse: { fields: [], error: null },
isLoadingPreview: false,
isFetchingDocument: false,
});
};
hasSomeParamsChanged = (
type: Params['type'],
script: string | undefined,
currentDocId: string | undefined
) => {
return (
this.lastExecutePainlessRequestParams.type !== type ||
this.lastExecutePainlessRequestParams.script !== script ||
this.lastExecutePainlessRequestParams.documentId !== currentDocId
);
};
getPreviewCount = () => this.previewCount;
incrementPreviewCount = () => ++this.previewCount;
allParamsDefined = (
type: Params['type'],
script: string | undefined,
currentDocIndex: string
) => {
if (!currentDocIndex || !script || !type) {
return false;
}
return true;
};
updateSingleFieldPreview = (
fieldName: string,
values: unknown[],
type: Params['type'],
format: Params['format']
) => {
const [value] = values;
const formattedValue = this.valueFormatter({ value, type, format });
this.setPreviewResponse({
fields: [{ key: fieldName, value, formattedValue }],
error: null,
});
};
updateCompositeFieldPreview = (
compositeValues: Record<string, unknown[]>,
parentName: string | null,
name: string,
fieldName$Value: string,
type: Params['type'],
format: Params['format'],
onNext: (fields: FieldPreview[]) => void
) => {
const updatedFieldsInScript: string[] = [];
// if we're displaying a composite subfield, filter results
const filterSubfield = parentName ? (field: FieldPreview) => field.key === name : () => true;
const fields = Object.entries(compositeValues)
.map<FieldPreview>(([key, values]) => {
// The Painless _execute API returns the composite field values under a map.
// Each of the key is prefixed with "composite_field." (e.g. "composite_field.field1: ['value']")
const { 1: fieldName } = key.split('composite_field.');
updatedFieldsInScript.push(fieldName);
const [value] = values;
const formattedValue = this.valueFormatter({ value, type, format });
return {
key: parentName
? `${parentName ?? ''}.${fieldName}`
: `${fieldName$Value ?? ''}.${fieldName}`,
value,
formattedValue,
type: valueTypeToSelectedType(value),
};
})
.filter(filterSubfield)
// ...and sort alphabetically
.sort((a, b) => a.key.localeCompare(b.key));
onNext(fields);
this.setPreviewResponse({
fields,
error: null,
});
};
}

View file

@ -60,6 +60,14 @@ export interface PreviewState {
fields: FieldPreview[];
error: PreviewError | null;
};
isFetchingDocument: boolean;
fetchDocError: FetchDocError | null;
customDocIdToLoad: string | null;
/** Flag to indicate if we are calling the _execute API */
isLoadingPreview: boolean;
initialPreviewComplete: boolean;
isPreviewAvailable: boolean;
isPanelVisible: boolean;
}
export interface FetchDocError {
@ -115,27 +123,10 @@ export interface Context {
controller: PreviewController;
fieldPreview$: BehaviorSubject<FieldPreview[] | undefined>;
fieldTypeInfo?: FieldTypeInfo[];
initialPreviewComplete: boolean;
params: {
value: Params;
update: (updated: Partial<Params>) => void;
};
isPreviewAvailable: boolean;
isLoadingPreview: boolean;
documents: {
loadSingle: (id: string) => void;
loadFromCluster: () => Promise<void>;
fetchDocError: FetchDocError | null;
};
panel: {
isVisible: boolean;
setIsVisible: (isVisible: boolean) => void;
};
navigation: {
isFirstDoc: boolean;
isLastDoc: boolean;
};
reset: () => void;
validation: {
setScriptEditorValidation: React.Dispatch<
React.SetStateAction<{ isValid: boolean; isValidating: boolean; message: string | null }>

View file

@ -163,6 +163,8 @@ export const getFieldEditorOpener =
// Runtime field
field = {
name: fieldNameToEdit!,
customLabel: dataViewField.customLabel,
popularity: dataViewField.count,
format: dataView.getFormatterForFieldNoDefault(fieldNameToEdit!)?.toJSON(),
...dataView.getRuntimeField(fieldNameToEdit!)!,
};

View file

@ -233,9 +233,9 @@ export const IndexPatternTable = ({
</>
)}
{dataView?.tags?.map(({ key: tagKey, name: tagName }) => (
<>
&emsp;<EuiBadge key={tagKey}>{tagName}</EuiBadge>
</>
<span key={tagKey}>
&emsp;<EuiBadge>{tagName}</EuiBadge>
</span>
))}
</div>
),