From f89b5aeff500c7850de630097a5e400d9d416270 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Fri, 31 May 2024 17:37:16 -0500 Subject: [PATCH] [data views] Runtime field creation, preview sidebar - move code from container (hooks) to controller (service) (#181770) ## Summary This PR moves some runtime field editor UI code from react hooks into a plain js controller / service. Tech debt reduction. --- .../field_editor_flyout_content.helpers.ts | 1 - .../field_editor_flyout_preview.helpers.ts | 1 - .../helpers/setup_environment.tsx | 14 +- .../field_editor_flyout_content.tsx | 4 +- .../field_editor_flyout_content_container.tsx | 179 ++------------- .../components/preview/preview_controller.tsx | 208 ++++++++++++++++-- .../public/components/preview/types.ts | 1 + 7 files changed, 223 insertions(+), 185 deletions(-) diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts index e54a854cbb962..8893b469bf9a0 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_content.helpers.ts @@ -20,7 +20,6 @@ export { waitForUpdates, waitForDocumentsAndPreviewUpdate } from './helpers'; const defaultProps: Props = { onSave: () => {}, onCancel: () => {}, - isSavingField: false, }; const getActions = (testBed: TestBed) => { diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts index feb21f4bc2708..7251199af5621 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/field_editor_flyout_preview.helpers.ts @@ -27,7 +27,6 @@ import { const defaultProps: Props = { onSave: () => {}, onCancel: () => {}, - isSavingField: false, }; /** diff --git a/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx b/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx index 32d1d360f7067..81c2ce01bbd60 100644 --- a/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/src/plugins/data_view_field_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -16,8 +16,10 @@ import { defer, BehaviorSubject } from 'rxjs'; import { notificationServiceMock, uiSettingsServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { fieldFormatsMock as fieldFormats } from '@kbn/field-formats-plugin/common/mocks'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; import { createStubDataView } from '@kbn/data-views-plugin/common/data_views/data_view.stub'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import { PreviewController } from '../../../public/components/preview/preview_controller'; import { FieldEditorProvider, Context } from '../../../public/components/field_editor_context'; import { FieldPreviewProvider } from '../../../public/components/preview'; @@ -150,9 +152,17 @@ export const WithFieldEditorDependencies = const mergedDependencies = merge({}, dependencies, overridingDependencies); const previewController = new PreviewController({ + deps: { + dataViews: dataViewPluginMocks.createStartContract(), + search, + fieldFormats, + usageCollection: { + reportUiCounter: jest.fn(), + } as UsageCollectionStart, + notifications: notificationServiceMock.createStartContract(), + }, dataView, - search, - fieldFormats, + onSave: jest.fn(), fieldTypeToProcess: 'runtime', }); diff --git a/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx b/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx index fa3fef00419dd..94ef43a4fecf1 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx +++ b/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content.tsx @@ -56,7 +56,6 @@ export interface Props { fieldToEdit?: Field; /** Optional preselected configuration for new field */ fieldToCreate?: Field; - isSavingField: boolean; /** Handler to call when the component mounts. * We will pass "up" data that the parent component might need */ @@ -64,13 +63,13 @@ export interface Props { } const isPanelVisibleSelector = (state: PreviewState) => state.isPanelVisible; +const isSavingSelector = (state: PreviewState) => state.isSaving; const FieldEditorFlyoutContentComponent = ({ fieldToEdit, fieldToCreate, onSave, onCancel, - isSavingField, onMounted, }: Props) => { const isMounted = useRef(false); @@ -81,6 +80,7 @@ const FieldEditorFlyoutContentComponent = ({ const { controller } = useFieldPreviewContext(); const isPanelVisible = useStateSelector(controller.state$, isPanelVisibleSelector); + const isSavingField = useStateSelector(controller.state$, isSavingSelector); const [formState, setFormState] = useState({ isSubmitted: false, diff --git a/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content_container.tsx b/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content_container.tsx index 28b707da333a4..b8a1911025292 100644 --- a/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content_container.tsx +++ b/src/plugins/data_view_field_editor/public/components/field_editor_flyout_content_container.tsx @@ -6,10 +6,8 @@ * Side Public License, v 1. */ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { DocLinksStart, NotificationsStart, CoreStart } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; -import { METRIC_TYPE } from '@kbn/analytics'; import { BehaviorSubject } from 'rxjs'; import { @@ -19,10 +17,8 @@ import { UsageCollectionStart, DataViewsPublicPluginStart, FieldFormatsStart, - RuntimeType, } from '../shared_imports'; import type { Field, PluginStart, InternalFieldType } from '../types'; -import { pluginName } from '../constants'; import { getLinks, ApiService } from '../lib'; import { FieldEditorFlyoutContent, @@ -86,40 +82,21 @@ export const FieldEditorFlyoutContentContainer = ({ uiSettings, }: Props) => { const [controller] = useState( - () => new PreviewController({ dataView, search, fieldFormats, fieldTypeToProcess }) - ); - const [isSaving, setIsSaving] = useState(false); - - const { fields } = dataView; - - const namesNotAllowed = useMemo(() => { - const fieldNames = dataView.fields.map((fld) => fld.name); - const runtimeCompositeNames = Object.entries(dataView.getAllRuntimeFields()) - .filter(([, _runtimeField]) => _runtimeField.type === 'composite') - .map(([_runtimeFieldName]) => _runtimeFieldName); - return { - fields: fieldNames, - runtimeComposites: runtimeCompositeNames, - }; - }, [dataView]); - - const existingConcreteFields = useMemo(() => { - const existing: Array<{ name: string; type: string }> = []; - - fields - .filter((fld) => { - const isFieldBeingEdited = fieldToEdit?.name === fld.name; - return !isFieldBeingEdited && fld.isMapped; + () => + new PreviewController({ + deps: { + dataViews, + search, + fieldFormats, + usageCollection, + notifications, + }, + dataView, + onSave, + fieldToEdit, + fieldTypeToProcess, }) - .forEach((fld) => { - existing.push({ - name: fld.name, - type: (fld.esTypes && fld.esTypes[0]) || '', - }); - }); - - return existing; - }, [fields, fieldToEdit]); + ); const services = useMemo( () => ({ @@ -130,125 +107,6 @@ export const FieldEditorFlyoutContentContainer = ({ [apiService, search, notifications] ); - const updateRuntimeField = useCallback( - (updatedField: Field): DataViewField[] => { - const nameHasChanged = Boolean(fieldToEdit) && fieldToEdit!.name !== updatedField.name; - const typeHasChanged = Boolean(fieldToEdit) && fieldToEdit!.type !== updatedField.type; - const hasChangeToOrFromComposite = - typeHasChanged && (fieldToEdit!.type === 'composite' || updatedField.type === 'composite'); - - const { script } = updatedField; - - if (fieldTypeToProcess === 'runtime') { - try { - usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_runtime'); - // eslint-disable-next-line no-empty - } catch {} - // rename an existing runtime field - if (nameHasChanged || hasChangeToOrFromComposite) { - dataView.removeRuntimeField(fieldToEdit!.name); - } - - dataView.addRuntimeField(updatedField.name, { - type: updatedField.type as RuntimeType, - script, - fields: updatedField.fields, - }); - } else { - try { - usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_concrete'); - // eslint-disable-next-line no-empty - } catch {} - } - - return dataView.addRuntimeField(updatedField.name, updatedField); - }, - [fieldToEdit, dataView, fieldTypeToProcess, usageCollection] - ); - - const updateConcreteField = useCallback( - (updatedField: Field): DataViewField[] => { - const editedField = dataView.getFieldByName(updatedField.name); - - if (!editedField) { - throw new Error( - `Unable to find field named '${ - updatedField.name - }' on index pattern '${dataView.getIndexPattern()}'` - ); - } - - // Update custom label, popularity and format - dataView.setFieldCustomLabel(updatedField.name, updatedField.customLabel); - dataView.setFieldCustomDescription(updatedField.name, updatedField.customDescription); - - editedField.count = updatedField.popularity || 0; - if (updatedField.format) { - dataView.setFieldFormat(updatedField.name, updatedField.format!); - } else { - dataView.deleteFieldFormat(updatedField.name); - } - - return [editedField]; - }, - [dataView] - ); - - const saveField = useCallback( - async (updatedField: Field) => { - try { - usageCollection.reportUiCounter( - pluginName, - METRIC_TYPE.COUNT, - fieldTypeToProcess === 'runtime' ? 'save_runtime' : 'save_concrete' - ); - // eslint-disable-next-line no-empty - } catch {} - - setIsSaving(true); - - try { - const editedFields: DataViewField[] = - fieldTypeToProcess === 'runtime' - ? updateRuntimeField(updatedField) - : updateConcreteField(updatedField as Field); - - const afterSave = () => { - const message = i18n.translate('indexPatternFieldEditor.deleteField.savedHeader', { - defaultMessage: "Saved '{fieldName}'", - values: { fieldName: updatedField.name }, - }); - notifications.toasts.addSuccess(message); - setIsSaving(false); - onSave(editedFields); - }; - - if (dataView.isPersisted()) { - await dataViews.updateSavedObject(dataView); - } - afterSave(); - - setIsSaving(false); - } catch (e) { - const title = i18n.translate('indexPatternFieldEditor.save.errorTitle', { - defaultMessage: 'Failed to save field changes', - }); - notifications.toasts.addError(e, { title }); - setIsSaving(false); - } - }, - [ - onSave, - dataView, - dataViews, - notifications, - fieldTypeToProcess, - updateConcreteField, - updateRuntimeField, - usageCollection, - ] - ); - return ( diff --git a/src/plugins/data_view_field_editor/public/components/preview/preview_controller.tsx b/src/plugins/data_view_field_editor/public/components/preview/preview_controller.tsx index 53d5e68cedb08..ae756a305a9bd 100644 --- a/src/plugins/data_view_field_editor/public/components/preview/preview_controller.tsx +++ b/src/plugins/data_view_field_editor/public/components/preview/preview_controller.tsx @@ -7,8 +7,15 @@ */ import { i18n } from '@kbn/i18n'; -import type { DataView } from '@kbn/data-views-plugin/public'; +import type { + DataView, + DataViewField, + DataViewsPublicPluginStart, +} from '@kbn/data-views-plugin/public'; +import { NotificationsStart } from '@kbn/core/public'; +import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { ISearchStart } from '@kbn/data-plugin/public'; +import { METRIC_TYPE } from '@kbn/analytics'; import { BehaviorSubject } from 'rxjs'; import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { renderToString } from 'react-dom/server'; @@ -17,8 +24,10 @@ import debounce from 'lodash/debounce'; import { PreviewState, FetchDocError } from './types'; import { BehaviorObservable } from '../../state_utils'; import { EsDocument, ScriptErrorCodes, Params, FieldPreview } from './types'; -import type { FieldFormatsStart } from '../../shared_imports'; +import type { FieldFormatsStart, RuntimeType } from '../../shared_imports'; import { valueTypeToSelectedType } from './field_preview_context'; +import { Field } from '../../types'; +import { pluginName } from '../../constants'; import { InternalFieldType } from '../../types'; export const defaultValueFormatter = (value: unknown) => { @@ -26,11 +35,20 @@ export const defaultValueFormatter = (value: unknown) => { return renderToString(<>{content}); }; -interface PreviewControllerDependencies { +interface PreviewControllerArgs { dataView: DataView; + onSave: (field: DataViewField[]) => void; + fieldToEdit?: Field; + fieldTypeToProcess: InternalFieldType; + deps: PreviewControllerDependencies; +} + +interface PreviewControllerDependencies { search: ISearchStart; fieldFormats: FieldFormatsStart; - fieldTypeToProcess: InternalFieldType; + usageCollection: UsageCollectionStart; + notifications: NotificationsStart; + dataViews: DataViewsPublicPluginStart; } const previewStateDefault: PreviewState = { @@ -60,18 +78,17 @@ const previewStateDefault: PreviewState = { isPreviewAvailable: true, /** Flag to show/hide the preview panel */ isPanelVisible: true, + isSaving: false, }; export class PreviewController { - constructor({ - dataView, - search, - fieldFormats, - fieldTypeToProcess, - }: PreviewControllerDependencies) { + constructor({ deps, dataView, onSave, fieldToEdit, fieldTypeToProcess }: PreviewControllerArgs) { + this.deps = deps; + this.dataView = dataView; - this.search = search; - this.fieldFormats = fieldFormats; + this.onSave = onSave; + + this.fieldToEdit = fieldToEdit; this.fieldTypeToProcess = fieldTypeToProcess; this.internalState$ = new BehaviorSubject({ @@ -85,8 +102,17 @@ export class PreviewController { // dependencies private dataView: DataView; - private search: ISearchStart; - private fieldFormats: FieldFormatsStart; + + private deps: { + search: ISearchStart; + fieldFormats: FieldFormatsStart; + usageCollection: UsageCollectionStart; + notifications: NotificationsStart; + dataViews: DataViewsPublicPluginStart; + }; + + private onSave: (field: DataViewField[]) => void; + private fieldToEdit?: Field; private fieldTypeToProcess: InternalFieldType; private internalState$: BehaviorSubject; @@ -94,6 +120,11 @@ export class PreviewController { private previewCount = 0; + private namesNotAllowed?: { + fields: string[]; + runtimeComposites: string[]; + }; + private updateState = (newState: Partial) => { this.internalState$.next({ ...this.state$.getValue(), ...newState }); }; @@ -108,6 +139,143 @@ export class PreviewController { documentId: undefined, }; + getNamesNotAllowed = () => { + if (!this.namesNotAllowed) { + const fieldNames = this.dataView.fields.map((fld) => fld.name); + const runtimeCompositeNames = Object.entries(this.dataView.getAllRuntimeFields()) + .filter(([, _runtimeField]) => _runtimeField.type === 'composite') + .map(([_runtimeFieldName]) => _runtimeFieldName); + this.namesNotAllowed = { + fields: fieldNames, + runtimeComposites: runtimeCompositeNames, + }; + } + + return this.namesNotAllowed; + }; + + getExistingConcreteFields = () => { + const existing: Array<{ name: string; type: string }> = []; + + this.dataView.fields + .filter((fld) => { + const isFieldBeingEdited = this.fieldToEdit?.name === fld.name; + return !isFieldBeingEdited && fld.isMapped; + }) + .forEach((fld) => { + existing.push({ + name: fld.name, + type: (fld.esTypes && fld.esTypes[0]) || '', + }); + }); + + return existing; + }; + + updateConcreteField = (updatedField: Field): DataViewField[] => { + const editedField = this.dataView.getFieldByName(updatedField.name); + + if (!editedField) { + throw new Error( + `Unable to find field named '${ + updatedField.name + }' on index pattern '${this.dataView.getIndexPattern()}'` + ); + } + + // Update custom label, popularity and format + this.dataView.setFieldCustomLabel(updatedField.name, updatedField.customLabel); + this.dataView.setFieldCustomDescription(updatedField.name, updatedField.customDescription); + + editedField.count = updatedField.popularity || 0; + if (updatedField.format) { + this.dataView.setFieldFormat(updatedField.name, updatedField.format!); + } else { + this.dataView.deleteFieldFormat(updatedField.name); + } + + return [editedField]; + }; + + updateRuntimeField = (updatedField: Field): DataViewField[] => { + const nameHasChanged = + Boolean(this.fieldToEdit) && this.fieldToEdit!.name !== updatedField.name; + const typeHasChanged = + Boolean(this.fieldToEdit) && this.fieldToEdit!.type !== updatedField.type; + const hasChangeToOrFromComposite = + typeHasChanged && + (this.fieldToEdit!.type === 'composite' || updatedField.type === 'composite'); + + const { script } = updatedField; + + if (this.fieldTypeToProcess === 'runtime') { + try { + this.deps.usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_runtime'); + // eslint-disable-next-line no-empty + } catch {} + // rename an existing runtime field + if (nameHasChanged || hasChangeToOrFromComposite) { + this.dataView.removeRuntimeField(this.fieldToEdit!.name); + } + + this.dataView.addRuntimeField(updatedField.name, { + type: updatedField.type as RuntimeType, + script, + fields: updatedField.fields, + }); + } else { + try { + this.deps.usageCollection.reportUiCounter(pluginName, METRIC_TYPE.COUNT, 'save_concrete'); + // eslint-disable-next-line no-empty + } catch {} + } + + return this.dataView.addRuntimeField(updatedField.name, updatedField); + }; + + saveField = async (updatedField: Field) => { + try { + this.deps.usageCollection.reportUiCounter( + pluginName, + METRIC_TYPE.COUNT, + this.fieldTypeToProcess === 'runtime' ? 'save_runtime' : 'save_concrete' + ); + // eslint-disable-next-line no-empty + } catch {} + + this.setIsSaving(true); + + try { + const editedFields: DataViewField[] = + this.fieldTypeToProcess === 'runtime' + ? this.updateRuntimeField(updatedField) + : this.updateConcreteField(updatedField as Field); + + const afterSave = () => { + const message = i18n.translate('indexPatternFieldEditor.deleteField.savedHeader', { + defaultMessage: "Saved '{fieldName}'", + values: { fieldName: updatedField.name }, + }); + this.deps.notifications.toasts.addSuccess(message); + this.setIsSaving(false); + this.onSave(editedFields); + }; + + if (this.dataView.isPersisted()) { + await this.deps.dataViews.updateSavedObject(this.dataView); + } + afterSave(); + + this.setIsSaving(false); + } catch (e) { + const title = i18n.translate('indexPatternFieldEditor.save.errorTitle', { + defaultMessage: 'Failed to save field changes', + }); + this.deps.notifications.toasts.addError(e, { title }); + this.setIsSaving(false); + } + }; + public getInternalFieldType = () => this.fieldTypeToProcess; togglePinnedField = (fieldName: string) => { @@ -214,6 +382,10 @@ export class PreviewController { }); }; + private setIsSaving = (isSaving: boolean) => { + this.updateState({ isSaving }); + }; + private setIsFetchingDocument = (isFetchingDocument: boolean) => { this.updateState({ isFetchingDocument, @@ -283,7 +455,7 @@ export class PreviewController { type: Params['type']; }) => { if (format?.id) { - const formatter = this.fieldFormats.getInstance(format.id, format.params); + const formatter = this.deps.fieldFormats.getInstance(format.id, format.params); if (formatter) { return formatter.getConverterFor('html')(value) ?? JSON.stringify(value); } @@ -291,7 +463,7 @@ export class PreviewController { if (type) { const fieldType = castEsToKbnFieldTypeName(type); - const defaultFormatterForType = this.fieldFormats.getDefaultInstance(fieldType); + const defaultFormatterForType = this.deps.fieldFormats.getDefaultInstance(fieldType); if (defaultFormatterForType) { return defaultFormatterForType.getConverterFor('html')(value) ?? JSON.stringify(value); } @@ -310,7 +482,7 @@ export class PreviewController { this.setIsFetchingDocument(true); this.setPreviewResponse({ fields: [], error: null }); - const [response, searchError] = await this.search + const [response, searchError] = await this.deps.search .search({ params: { index: this.dataView.getIndexPattern(), @@ -357,7 +529,7 @@ export class PreviewController { this.setLastExecutePainlessRequestParams({ documentId: undefined }); this.setIsFetchingDocument(true); - const [response, searchError] = await this.search + const [response, searchError] = await this.deps.search .search({ params: { index: this.dataView.getIndexPattern(), diff --git a/src/plugins/data_view_field_editor/public/components/preview/types.ts b/src/plugins/data_view_field_editor/public/components/preview/types.ts index 6c9db3fa69daf..143f25f0c90ac 100644 --- a/src/plugins/data_view_field_editor/public/components/preview/types.ts +++ b/src/plugins/data_view_field_editor/public/components/preview/types.ts @@ -68,6 +68,7 @@ export interface PreviewState { initialPreviewComplete: boolean; isPreviewAvailable: boolean; isPanelVisible: boolean; + isSaving: boolean; } export interface FetchDocError {