[data view field editor] Move preview response and value formatter to controller (#153799)

## Summary

This is a refactor that moves logic from hooks to a controller class for
the data view field editor preview. Functionality is unaffected.
This commit is contained in:
Matthew Kime 2023-04-21 16:15:25 -05:00 committed by GitHub
parent 9bb127f26c
commit fdc23f570e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 136 additions and 120 deletions

View file

@ -149,7 +149,7 @@ export const WithFieldEditorDependencies =
};
const mergedDependencies = merge({}, dependencies, overridingDependencies);
const previewController = new PreviewController({ dataView, search });
const previewController = new PreviewController({ dataView, search, fieldFormats });
return (
<FieldEditorProvider {...mergedDependencies}>

View file

@ -24,7 +24,7 @@ import {
} from '../../../shared_imports';
import type { RuntimeFieldPainlessError } from '../../../types';
import { painlessErrorToMonacoMarker } from '../../../lib';
import { useFieldPreviewContext, Context } from '../../preview';
import { useFieldPreviewContext } from '../../preview';
import { schema } from '../form_schema';
import type { FieldFormInternal } from '../field_editor';
import { useStateSelector } from '../../../state_utils';
@ -57,6 +57,7 @@ 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 ScriptFieldComponent = ({ existingConcreteFields, links, placeholder }: Props) => {
const {
@ -66,14 +67,15 @@ const ScriptFieldComponent = ({ existingConcreteFields, links, placeholder }: Pr
const editorValidationSubscription = useRef<Subscription>();
const fieldCurrentValue = useRef<string>('');
const { error, isLoadingPreview, isPreviewAvailable, controller } = useFieldPreviewContext();
const { isLoadingPreview, isPreviewAvailable, controller } = useFieldPreviewContext();
const error = useStateSelector(controller.state$, currentErrorSelector);
const currentDocument = useStateSelector(controller.state$, currentDocumentSelector);
const isFetchingDoc = useStateSelector(controller.state$, currentDocumentIsLoadingSelector);
const [validationData$, nextValidationData$] = useBehaviorSubject<
| {
isFetchingDoc: boolean;
isLoadingPreview: boolean;
error: Context['error'];
error: PreviewState['previewResponse']['error'];
}
| undefined
>(undefined);

View file

@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n';
import { EuiComboBoxOptionOption } from '@elastic/eui';
import { fieldValidators, FieldConfig, RuntimeType, ValidationFunc } from '../../shared_imports';
import type { Context } from '../preview';
import { RUNTIME_FIELD_OPTIONS } from './constants';
import type { PreviewState } from '../preview/types';
const { containsCharsField, emptyField, numberGreaterThanField } = fieldValidators;
const i18nTexts = {
@ -25,7 +25,7 @@ const i18nTexts = {
// Validate the painless **script**
const painlessScriptValidator: ValidationFunc = async ({ customData: { provider } }) => {
const previewError = (await provider()) as Context['error'];
const previewError = (await provider()) as PreviewState['previewResponse']['error'];
if (previewError && previewError.code === 'PAINLESS_SCRIPT_ERROR') {
return {

View file

@ -85,7 +85,7 @@ export const FieldEditorFlyoutContentContainer = ({
fieldFormats,
uiSettings,
}: Props) => {
const [controller] = useState(() => new PreviewController({ dataView, search }));
const [controller] = useState(() => new PreviewController({ dataView, search, fieldFormats }));
const [isSaving, setIsSaving] = useState(false);
const { fields } = dataView;

View file

@ -16,6 +16,7 @@ import { DocumentsNavPreview } from './documents_nav_preview';
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 './field_preview.scss';
@ -28,12 +29,12 @@ export const FieldPreview = () => {
value: { name, script, format },
},
isLoadingPreview,
fields,
error,
documents: { fetchDocError },
reset,
isPreviewAvailable,
controller,
} = useFieldPreviewContext();
const { fields, error } = useStateSelector(controller.state$, (state) => state.previewResponse);
// 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

View file

@ -20,7 +20,6 @@ import { renderToString } from 'react-dom/server';
import useDebounce from 'react-use/lib/useDebounce';
import { i18n } from '@kbn/i18n';
import { get } from 'lodash';
import { castEsToKbnFieldTypeName } from '@kbn/field-types';
import { BehaviorSubject } from 'rxjs';
import { RuntimePrimitiveTypes } from '../../shared_imports';
import { useStateSelector } from '../../state_utils';
@ -32,7 +31,6 @@ import type {
Context,
Params,
EsDocument,
ScriptErrorCodes,
FetchDocError,
FieldPreview,
PreviewState,
@ -101,17 +99,11 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
notifications,
api: { getFieldPreview },
},
fieldFormats,
fieldName$,
} = useFieldEditorContext();
const fieldPreview$ = useRef(new BehaviorSubject<FieldPreview[] | undefined>(undefined));
/** Response from the Painless _execute API */
const [previewResponse, setPreviewResponse] = useState<{
fields: Context['fields'];
error: Context['error'];
}>({ fields: [], error: null });
const [initialPreviewComplete, setInitialPreviewComplete] = useState(false);
/** Possible error while fetching sample documents */
@ -169,45 +161,6 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
);
}, [type, script, currentDocId]);
const setPreviewError = useCallback((error: Context['error']) => {
setPreviewResponse((prev) => ({
...prev,
error,
}));
}, []);
const clearPreviewError = useCallback((errorCode: ScriptErrorCodes) => {
setPreviewResponse((prev) => {
const error = prev.error === null || prev.error?.code === errorCode ? null : prev.error;
return {
...prev,
error,
};
});
}, []);
const valueFormatter = useCallback(
(value: unknown) => {
if (format?.id) {
const formatter = fieldFormats.getInstance(format.id, format.params);
if (formatter) {
return formatter.getConverterFor('html')(value) ?? JSON.stringify(value);
}
}
if (type) {
const fieldType = castEsToKbnFieldTypeName(type);
const defaultFormatterForType = fieldFormats.getDefaultInstance(fieldType);
if (defaultFormatterForType) {
return defaultFormatterForType.getConverterFor('html')(value) ?? JSON.stringify(value);
}
}
return defaultValueFormatter(value);
},
[format, type, fieldFormats]
);
const fetchSampleDocuments = useCallback(
async (limit: number = 50) => {
if (typeof limit !== 'number') {
@ -217,7 +170,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
lastExecutePainlessRequestParams.current.documentId = undefined;
setIsFetchingDocument(true);
setPreviewResponse({ fields: [], error: null });
controller.setPreviewResponse({ fields: [], error: null });
const [response, searchError] = await search
.search({
@ -335,14 +288,14 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
const updateSingleFieldPreview = useCallback(
(fieldName: string, values: unknown[]) => {
const [value] = values;
const formattedValue = valueFormatter(value);
const formattedValue = controller.valueFormatter({ value, type, format });
setPreviewResponse({
controller.setPreviewResponse({
fields: [{ key: fieldName, value, formattedValue }],
error: null,
});
},
[valueFormatter]
[controller, type, format]
);
const updateCompositeFieldPreview = useCallback(
@ -359,7 +312,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
updatedFieldsInScript.push(fieldName);
const [value] = values;
const formattedValue = valueFormatter(value);
const formattedValue = controller.valueFormatter({ value, type, format });
return {
key: parentName
@ -375,12 +328,12 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
.sort((a, b) => a.key.localeCompare(b.key));
fieldPreview$.current.next(fields);
setPreviewResponse({
controller.setPreviewResponse({
fields,
error: null,
});
},
[valueFormatter, parentName, name, fieldPreview$, fieldName$]
[parentName, name, fieldPreview$, fieldName$, controller, type, format]
);
const updatePreview = useCallback(async () => {
@ -437,7 +390,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
const { values, error } = response.data;
if (error) {
setPreviewResponse({
controller.setPreviewResponse({
fields: [
{
key: name ?? '',
@ -474,6 +427,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
updateSingleFieldPreview,
updateCompositeFieldPreview,
currentDocIndex,
controller,
]);
const reset = useCallback(() => {
@ -482,7 +436,7 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
previewCount.current = 0;
controller.setDocuments([]);
setPreviewResponse({ fields: [], error: null });
controller.setPreviewResponse({ fields: [], error: null });
setIsLoadingPreview(false);
setIsFetchingDocument(false);
}, [controller]);
@ -490,8 +444,6 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
const ctx = useMemo<Context>(
() => ({
controller,
fields: previewResponse.fields,
error: previewResponse.error,
fieldPreview$: fieldPreview$.current,
isPreviewAvailable,
isLoadingPreview,
@ -521,7 +473,6 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
[
controller,
currentIdx,
previewResponse,
fieldPreview$,
fetchDocError,
params,
@ -583,74 +534,70 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
* Whenever the name or the format changes we immediately update the preview
*/
useEffect(() => {
setPreviewResponse((prev) => {
const { fields } = prev;
const { previewResponse: prev } = controller.state$.getValue();
const { fields } = prev;
let updatedFields: Context['fields'] = fields.map((field) => {
let key = name ?? '';
let updatedFields: PreviewState['previewResponse']['fields'] = fields.map((field) => {
let key = name ?? '';
if (type === 'composite') {
// restore initial key segement (the parent name), which was not returned
const { 1: fieldName } = field.key.split('.');
key = `${name ?? ''}.${fieldName}`;
}
return {
...field,
key,
};
});
// If the user has entered a name but not yet any script we will display
// the field in the preview with just the name
if (updatedFields.length === 0 && name !== null) {
updatedFields = [
{ key: name, value: undefined, formattedValue: undefined, type: undefined },
];
if (type === 'composite') {
// restore initial key segement (the parent name), which was not returned
const { 1: fieldName } = field.key.split('.');
key = `${name ?? ''}.${fieldName}`;
}
return {
...prev,
fields: updatedFields,
...field,
key,
};
});
}, [name, type, parentName]);
// If the user has entered a name but not yet any script we will display
// the field in the preview with just the name
if (updatedFields.length === 0 && name !== null) {
updatedFields = [{ key: name, value: undefined, formattedValue: undefined, type: undefined }];
}
controller.setPreviewResponse({
...prev,
fields: updatedFields,
});
}, [name, type, parentName, controller]);
/**
* Whenever the format changes we immediately update the preview
*/
useEffect(() => {
setPreviewResponse((prev) => {
const { fields } = prev;
const { previewResponse: prev } = controller.state$.getValue();
const { fields } = prev;
return {
...prev,
fields: fields.map((field) => {
const nextValue =
script === null && Boolean(document)
? get(document?._source, name ?? '') ?? get(document?.fields, name ?? '') // When there is no script we try to read the value from _source/fields
: field?.value;
controller.setPreviewResponse({
...prev,
fields: fields.map((field) => {
const nextValue =
script === null && Boolean(document)
? get(document?._source, name ?? '') ?? get(document?.fields, name ?? '') // When there is no script we try to read the value from _source/fields
: field?.value;
const formattedValue = valueFormatter(nextValue);
const formattedValue = controller.valueFormatter({ value: nextValue, type, format });
return {
...field,
value: nextValue,
formattedValue,
};
}),
};
return {
...field,
value: nextValue,
formattedValue,
};
}),
});
}, [name, script, document, valueFormatter]);
}, [name, script, document, controller, type, format]);
useEffect(() => {
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;
setPreviewError(null);
controller.setPreviewError(null);
}
}, [script?.source, setPreviewError]);
}, [script?.source, controller]);
// Handle the validation state coming from the Painless DiagnosticAdapter
// (see @kbn-monaco/src/painless/diagnostics_adapter.ts)
@ -677,16 +624,16 @@ export const FieldPreviewProvider: FunctionComponent<{ controller: PreviewContro
}),
},
};
setPreviewError(error);
controller.setPreviewError(error);
// 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;
} else {
// Clear possible previous syntax error
clearPreviewError('PAINLESS_SYNTAX_ERROR');
controller.clearPreviewError('PAINLESS_SYNTAX_ERROR');
}
}, [scriptEditorValidation, script?.source, setPreviewError, clearPreviewError]);
}, [scriptEditorValidation, script?.source, controller]);
/**
* Whenever updatePreview() changes (meaning whenever a param changes)

View file

@ -9,13 +9,23 @@
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 { BehaviorObservable } from '../../state_utils';
import { EsDocument } from './types';
import { EsDocument, ScriptErrorCodes, Params } from './types';
import type { FieldFormatsStart } from '../../shared_imports';
export const defaultValueFormatter = (value: unknown) => {
const content = typeof value === 'object' ? JSON.stringify(value) : String(value) ?? '-';
return renderToString(<>{content}</>);
};
interface PreviewControllerDependencies {
dataView: DataView;
search: ISearchStart;
fieldFormats: FieldFormatsStart;
}
const previewStateDefault: PreviewState = {
@ -30,12 +40,14 @@ const previewStateDefault: PreviewState = {
documentSource: 'cluster',
/** 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 },
};
export class PreviewController {
constructor({ dataView, search }: PreviewControllerDependencies) {
constructor({ dataView, search, fieldFormats }: PreviewControllerDependencies) {
this.dataView = dataView;
this.search = search;
this.fieldFormats = fieldFormats;
this.internalState$ = new BehaviorSubject<PreviewState>({
...previewStateDefault,
@ -44,10 +56,13 @@ export class PreviewController {
this.state$ = this.internalState$ as BehaviorObservable<PreviewState>;
}
// dependencies
// @ts-ignore
private dataView: DataView;
// @ts-ignore
private search: ISearchStart;
private fieldFormats: FieldFormatsStart;
private internalState$: BehaviorSubject<PreviewState>;
state$: BehaviorObservable<PreviewState>;
@ -104,4 +119,52 @@ export class PreviewController {
setCustomId = (customId?: string) => {
this.updateState({ customId });
};
setPreviewError = (error: PreviewState['previewResponse']['error']) => {
this.updateState({
previewResponse: { ...this.internalState$.getValue().previewResponse, error },
});
};
setPreviewResponse = (previewResponse: PreviewState['previewResponse']) => {
this.updateState({ previewResponse });
};
clearPreviewError = (errorCode: ScriptErrorCodes) => {
const { previewResponse: prev } = this.internalState$.getValue();
const error = prev.error === null || prev.error?.code === errorCode ? null : prev.error;
this.updateState({
previewResponse: {
...prev,
error,
},
});
};
valueFormatter = ({
value,
format,
type,
}: {
value: unknown;
format: Params['format'];
type: Params['type'];
}) => {
if (format?.id) {
const formatter = this.fieldFormats.getInstance(format.id, format.params);
if (formatter) {
return formatter.getConverterFor('html')(value) ?? JSON.stringify(value);
}
}
if (type) {
const fieldType = castEsToKbnFieldTypeName(type);
const defaultFormatterForType = this.fieldFormats.getDefaultInstance(fieldType);
if (defaultFormatterForType) {
return defaultFormatterForType.getConverterFor('html')(value) ?? JSON.stringify(value);
}
}
return defaultValueFormatter(value);
};
}

View file

@ -55,6 +55,11 @@ export interface PreviewState {
isValid: boolean;
message: string | null;
};
/** Response from the Painless _execute API */
previewResponse: {
fields: FieldPreview[];
error: PreviewError | null;
};
}
export interface FetchDocError {
@ -108,9 +113,7 @@ export type ChangeSet = Record<string, Change>;
export interface Context {
controller: PreviewController;
fields: FieldPreview[];
fieldPreview$: BehaviorSubject<FieldPreview[] | undefined>;
error: PreviewError | null;
fieldTypeInfo?: FieldTypeInfo[];
initialPreviewComplete: boolean;
params: {